From 3dd849edfe01afee7e108e77099221b4cab60c51 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:35:24 +0800 Subject: [PATCH 01/71] Add files via upload --- gradle/libs.versions.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 660da05..8518814 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ mockk = "1.13.11" turbine = "1.1.0" robolectric = "4.12.2" org-json = "20240303" +room = "2.6.1" +ksp = "1.9.25-1.0.20" [libraries] # AndroidX Core @@ -91,9 +93,13 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } org-json = { group = "org.json", name = "json", version.ref = "org-json" } +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } From 6d5ad93e61e8217833b3ad8a6307477cad60ac2a Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:39:10 +0800 Subject: [PATCH 02/71] Add files via upload --- core/data/core_data_build.gradle.kts | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/data/core_data_build.gradle.kts diff --git a/core/data/core_data_build.gradle.kts b/core/data/core_data_build.gradle.kts new file mode 100644 index 0000000..c1a4242 --- /dev/null +++ b/core/data/core_data_build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) + kotlin("kapt") +} + +android { + namespace = "com.gatecontrol.android.core.data" + compileSdk = 35 + + defaultConfig { + minSdk = 31 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(project(":core:common")) + + implementation(libs.core.ktx) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.datastore.preferences) + implementation(libs.security.crypto) + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + implementation(libs.timber) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + testImplementation(libs.junit5.api) + testRuntimeOnly(libs.junit5.engine) + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.turbine) +} + +kapt { + correctErrorTypes = true +} + +tasks.withType { + useJUnitPlatform() +} From 2f9ca81b00c518ccbc517cf1d08e08f61f18b226 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:40:32 +0800 Subject: [PATCH 03/71] Add files via upload --- .../main/java/com/gatecontrol/android/navigation/Screen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index a69e389..e6432bc 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -1,5 +1,7 @@ package com.gatecontrol.android.navigation +import android.net.Uri + sealed class Screen(val route: String) { data object Setup : Screen("setup") data object Vpn : Screen("vpn") @@ -8,4 +10,9 @@ sealed class Screen(val route: String) { data object Settings : Screen("settings") data object Logs : Screen("settings/logs") data object QrScanner : Screen("setup/qr") + data object NetworkGroups : Screen("settings/network_groups") + data object NetworkGroupEdit : Screen("settings/network_groups/{groupId}?name={groupName}") { + fun createRoute(groupId: Long, groupName: String) = + "settings/network_groups/$groupId?name=${Uri.encode(groupName)}" + } } From b50de334394db65686dbab96bf960eaa74f8bf77 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:41:34 +0800 Subject: [PATCH 04/71] Add files via upload --- .../android/navigation/AppNavigation.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index 9341024..31a4076 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1,5 +1,14 @@ package com.gatecontrol.android.navigation +import android.net.Uri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.gatecontrol.android.ui.settings.NetworkGroupListScreen +import com.gatecontrol.android.ui.settings.NetworkGroupEditScreen +import com.gatecontrol.android.ui.settings.SettingsViewModel +import androidx.compose.runtime.getValue import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star @@ -135,6 +144,9 @@ fun AppNavigation( onNavigateToQrScanner = { navController.navigate(Screen.QrScanner.route) }, + onNavigateToNetworkGroups = { + navController.navigate(Screen.NetworkGroups.route) + }, ) } @@ -143,6 +155,37 @@ fun AppNavigation( onNavigateBack = { navController.popBackStack() }, ) } + + composable(Screen.NetworkGroups.route) { + val settingsVm: SettingsViewModel = hiltViewModel() + val state by settingsVm.uiState.collectAsStateWithLifecycle() + NetworkGroupListScreen( + adminLocked = state.splitTunnelAdminLocked, + onNavigateToEdit = { groupId, groupName -> + navController.navigate(Screen.NetworkGroupEdit.createRoute(groupId, groupName)) + }, + onBack = { navController.popBackStack() }, + ) + } + + composable( + route = Screen.NetworkGroupEdit.route, + arguments = listOf( + navArgument("groupId") { type = NavType.LongType }, + navArgument("groupName") { type = NavType.StringType; defaultValue = "" }, + ), + ) { backStackEntry -> + val groupId = backStackEntry.arguments?.getLong("groupId") ?: return@composable + val groupName = Uri.decode(backStackEntry.arguments?.getString("groupName") ?: "") + val settingsVm: SettingsViewModel = hiltViewModel() + val state by settingsVm.uiState.collectAsStateWithLifecycle() + NetworkGroupEditScreen( + groupId = groupId, + groupName = groupName, + adminLocked = state.splitTunnelAdminLocked, + onBack = { navController.popBackStack() }, + ) + } } } } From 8faf2ecd031c1d9e0a858241867fd1f4d118ab48 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:44:12 +0800 Subject: [PATCH 05/71] Add files via upload --- .../ui/settings/NetworkGroupEditScreen.kt | 382 ++++++++++++++++++ .../ui/settings/NetworkGroupListScreen.kt | 258 ++++++++++++ .../ui/settings/NetworkGroupViewModel.kt | 223 ++++++++++ .../android/ui/settings/SettingsScreen.kt | 44 +- 4 files changed, 899 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupEditScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupListScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupViewModel.kt diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupEditScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupEditScreen.kt new file mode 100644 index 0000000..ef0b300 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupEditScreen.kt @@ -0,0 +1,382 @@ +// FILE: app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupEditScreen.kt +// +// 单个分组的 CIDR 管理页面。功能: +// • 搜索 / 删除 / 添加 CIDR +// • 批量粘贴(每行一个 CIDR) +// • 导出当前分组为 SQLite 文件(系统分享) + +package com.gatecontrol.android.ui.settings + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.data.db.NetworkCidrEntity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkGroupEditScreen( + groupId: Long, + groupName: String, + adminLocked: Boolean, + onBack: () -> Unit, + viewModel: NetworkGroupEditViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val keyboardController = LocalSoftwareKeyboardController.current + + // Load group name into VM once + LaunchedEffect(groupName) { + viewModel.loadGroupName(groupName) + } + + // Show snackbar + LaunchedEffect(state.snackbar) { + state.snackbar?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearSnackbar() + } + } + + // Share export file via system sheet + LaunchedEffect(state.exportFile) { + state.exportFile?.let { file -> + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + file, + ) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/octet-stream" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Export network group")) + viewModel.clearExportFile() + } + } + + var showAddSingleDialog by remember { mutableStateOf(false) } + var showBulkDialog by remember { mutableStateOf(false) } + var pendingDeleteCidr by remember { mutableStateOf(null) } + + // ── Dialogs ────────────────────────────────────────────────────────── + + if (showAddSingleDialog) { + AddSingleCidrDialog( + onDismiss = { showAddSingleDialog = false }, + onAdd = { cidr, label -> + viewModel.addCidr(cidr, label) + showAddSingleDialog = false + }, + ) + } + + if (showBulkDialog) { + BulkAddCidrDialog( + onDismiss = { showBulkDialog = false }, + onAdd = { rawText -> + viewModel.addCidrsBulk(rawText) + showBulkDialog = false + }, + ) + } + + pendingDeleteCidr?.let { target -> + AlertDialog( + onDismissRequest = { pendingDeleteCidr = null }, + title = { Text("Remove CIDR?") }, + text = { Text(target.cidr) }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteCidr(target) + pendingDeleteCidr = null + }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeleteCidr = null }) { Text("Cancel") } + }, + ) + } + + // ── Screen ─────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(groupName, style = MaterialTheme.typography.titleMedium) + Text( + "${state.allCidrs.size} CIDRs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + // Export + IconButton(onClick = { viewModel.exportGroup() }) { + Icon(Icons.Default.FileUpload, contentDescription = "Export group") + } + if (!adminLocked) { + // Bulk add + IconButton(onClick = { showBulkDialog = true }) { + Icon(Icons.Default.PlaylistAdd, contentDescription = "Bulk add") + } + // Single add + IconButton(onClick = { showAddSingleDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Add CIDR") + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + // ── Search bar ───────────────────────────────────────────── + OutlinedTextField( + value = state.searchQuery, + onValueChange = viewModel::onSearchQueryChanged, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search CIDRs or labels…") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (state.searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.onSearchQueryChanged("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + }, + singleLine = true, + ) + + if (state.isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + + if (state.filteredCidrs.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + if (state.searchQuery.isNotEmpty()) + "No CIDRs match \"${state.searchQuery}\"" + else + "No CIDRs yet — tap + to add", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@Column + } + + // ── CIDR list ────────────────────────────────────────────── + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, end = 16.dp, + top = 4.dp, bottom = 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(state.filteredCidrs, key = { it.id }) { entry -> + CidrRow( + cidr = entry, + adminLocked = adminLocked, + onDelete = { pendingDeleteCidr = entry }, + ) + } + } + } + } +} + +// ── CIDR row ────────────────────────────────────────────────────────────────── + +@Composable +private fun CidrRow( + cidr: NetworkCidrEntity, + adminLocked: Boolean, + onDelete: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 1.dp, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Lan, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text(cidr.cidr, style = MaterialTheme.typography.bodyMedium) + if (cidr.label.isNotEmpty()) { + Text( + cidr.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!adminLocked) { + IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── Add single CIDR dialog ──────────────────────────────────────────────────── + +@Composable +private fun AddSingleCidrDialog( + onDismiss: () -> Unit, + onAdd: (cidr: String, label: String) -> Unit, +) { + var cidr by remember { mutableStateOf("") } + var label by remember { mutableStateOf("") } + var cidrError by remember { mutableStateOf(null) } + val cidrRegex = Regex("""^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$""") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add CIDR") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = cidr, + onValueChange = { cidr = it; cidrError = null }, + label = { Text("CIDR") }, + placeholder = { Text("e.g. 192.168.1.0/24") }, + singleLine = true, + isError = cidrError != null, + supportingText = cidrError?.let { { Text(it) } }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Label (optional)") }, + placeholder = { Text("e.g. Printer subnet") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + } + }, + confirmButton = { + TextButton(onClick = { + val trimmed = cidr.trim() + if (!cidrRegex.matches(trimmed)) { + cidrError = "Invalid CIDR format" + return@TextButton + } + val prefix = trimmed.split("/")[1].toIntOrNull() ?: 0 + if (prefix > 32) { cidrError = "Prefix must be 0–32"; return@TextButton } + onAdd(trimmed, label.trim()) + }) { Text("Add") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +// ── Bulk add dialog ─────────────────────────────────────────────────────────── + +@Composable +private fun BulkAddCidrDialog( + onDismiss: () -> Unit, + onAdd: (rawText: String) -> Unit, +) { + var text by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Bulk Add CIDRs") }, + text = { + Column { + Text( + "One CIDR per line. Invalid or duplicate entries are skipped automatically.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("CIDRs") }, + placeholder = { Text("172.16.0.0/12\n192.168.1.0/24\n10.10.0.0/16") }, + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + maxLines = 20, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + ), + ) + } + }, + confirmButton = { + TextButton( + onClick = { if (text.isNotBlank()) onAdd(text) }, + enabled = text.isNotBlank(), + ) { Text("Add All") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupListScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupListScreen.kt new file mode 100644 index 0000000..52b501c --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupListScreen.kt @@ -0,0 +1,258 @@ +// FILE: app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupListScreen.kt +// +// 替代原来嵌入 SettingsScreen 内的 NetworkPresetsSection。 +// 现在 SettingsScreen 中的 "Network Presets" 区域改为一个 +// "Manage Network Groups ›" 行,点击导航到这个全屏页面。 + +package com.gatecontrol.android.ui.settings + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.data.db.NetworkGroupWithCidrs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkGroupListScreen( + adminLocked: Boolean, + onNavigateToEdit: (groupId: Long, groupName: String) -> Unit, + onBack: () -> Unit, + viewModel: NetworkGroupListViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + // ── File picker for import ── + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) viewModel.importGroup(context, uri) + } + + // ── Show snackbar ── + LaunchedEffect(state.snackbar) { + state.snackbar?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearSnackbar() + } + } + + // ── New group dialog ── + var showNewGroupDialog by remember { mutableStateOf(false) } + if (showNewGroupDialog) { + NewGroupDialog( + onDismiss = { showNewGroupDialog = false }, + onCreate = { name -> + viewModel.createGroup(name) + showNewGroupDialog = false + }, + ) + } + + // ── Delete confirmation ── + var pendingDeleteId by remember { mutableStateOf(null) } + if (pendingDeleteId != null) { + AlertDialog( + onDismissRequest = { pendingDeleteId = null }, + title = { Text("Delete group?") }, + text = { Text("All CIDRs in this group will be permanently deleted.") }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteGroup(pendingDeleteId!!) + pendingDeleteId = null + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeleteId = null }) { Text("Cancel") } + }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Network Groups") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (!adminLocked) { + // Import button + IconButton(onClick = { importLauncher.launch("*/*") }) { + Icon(Icons.Default.FileDownload, contentDescription = "Import group") + } + // New group button + IconButton(onClick = { showNewGroupDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "New group") + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (state.isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + if (state.groups.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Lan, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + "No network groups yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Tap + to create a group, or import a .sqlite3 file", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return@Scaffold + } + + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, end = 16.dp, + top = padding.calculateTopPadding() + 8.dp, + bottom = padding.calculateBottomPadding() + 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.groups, key = { it.group.id }) { gwc -> + NetworkGroupCard( + gwc = gwc, + adminLocked = adminLocked, + onToggleEnabled = { viewModel.setGroupEnabled(gwc.group.id, it) }, + onEdit = { onNavigateToEdit(gwc.group.id, gwc.group.name) }, + onDelete = { pendingDeleteId = gwc.group.id }, + ) + } + } + } +} + +// ── Group card ──────────────────────────────────────────────────────────────── + +@Composable +private fun NetworkGroupCard( + gwc: NetworkGroupWithCidrs, + adminLocked: Boolean, + onToggleEnabled: (Boolean) -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onEdit, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + gwc.group.name, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${gwc.cidrs.size} CIDR${if (gwc.cidrs.size != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Preview first 2 CIDRs + if (gwc.cidrs.isNotEmpty()) { + Text( + gwc.cidrs.take(2).joinToString(" ") { it.cidr } + + if (gwc.cidrs.size > 2) " …" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(Modifier.width(8.dp)) + Switch( + checked = gwc.group.enabled, + onCheckedChange = { if (!adminLocked) onToggleEnabled(it) }, + enabled = !adminLocked, + ) + if (!adminLocked) { + IconButton(onClick = onDelete) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete group", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── New group dialog ────────────────────────────────────────────────────────── + +@Composable +private fun NewGroupDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Network Group") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Group name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { if (name.isNotBlank()) onCreate(name) }, + enabled = name.isNotBlank(), + ) { Text("Create") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupViewModel.kt new file mode 100644 index 0000000..c6fdfdc --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupViewModel.kt @@ -0,0 +1,223 @@ +// FILE: app/src/main/java/com/gatecontrol/android/ui/settings/NetworkGroupViewModel.kt +// +// ViewModel 驱动两个屏幕: +// • NetworkGroupListScreen — 分组列表(替代旧的 NetworkPresetsSection inline 展开) +// • NetworkGroupEditScreen — 单个分组内部的 CIDR 管理 + +package com.gatecontrol.android.ui.settings + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.data.NetworkGroupRepository +import com.gatecontrol.android.data.db.NetworkCidrEntity +import com.gatecontrol.android.data.db.NetworkGroupEntity +import com.gatecontrol.android.data.db.NetworkGroupWithCidrs +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +// ── List screen state ────────────────────────────────────────────────────── + +data class NetworkGroupListUiState( + val groups: List = emptyList(), + val isLoading: Boolean = false, + val snackbar: String? = null, +) + +// ── Edit screen state ────────────────────────────────────────────────────── + +data class NetworkGroupEditUiState( + val groupId: Long = -1L, + val groupName: String = "", + val allCidrs: List = emptyList(), + val filteredCidrs: List = emptyList(), + val searchQuery: String = "", + val isLoading: Boolean = false, + val snackbar: String? = null, + /** Set to a File when export is ready for the caller to share. */ + val exportFile: File? = null, +) + +// ══════════════════════════════════════════════════════════════════════════════ +// NetworkGroupListViewModel +// ══════════════════════════════════════════════════════════════════════════════ + +@HiltViewModel +class NetworkGroupListViewModel @Inject constructor( + private val repo: NetworkGroupRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(NetworkGroupListUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repo.migrateFromDataStoreIfNeeded() + } + viewModelScope.launch { + repo.observeAllGroupsWithCidrs().collect { groups -> + _state.update { it.copy(groups = groups) } + } + } + } + + fun createGroup(name: String) { + if (name.isBlank()) return + viewModelScope.launch { + repo.createGroup(name.trim()) + } + } + + fun setGroupEnabled(groupId: Long, enabled: Boolean) { + viewModelScope.launch { + repo.setGroupEnabled(groupId, enabled) + } + } + + fun deleteGroup(groupId: Long) { + viewModelScope.launch { + repo.deleteGroup(groupId) + _state.update { it.copy(snackbar = "Group deleted") } + } + } + + /** Import a group from a user-picked SQLite file URI. */ + fun importGroup(context: Context, uri: Uri) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + try { + // Copy to cache first (Room / SQLite can't open content:// URIs directly) + val tmp = File(context.cacheDir, "import_${System.currentTimeMillis()}.sqlite3") + context.contentResolver.openInputStream(uri)?.use { input -> + tmp.outputStream().use { input.copyTo(it) } + } + val newId = repo.importGroup(tmp) + tmp.delete() + if (newId >= 0) { + _state.update { it.copy(isLoading = false, snackbar = "Group imported successfully") } + } else { + _state.update { it.copy(isLoading = false, snackbar = "Import failed — invalid file") } + } + } catch (e: Exception) { + Timber.e(e, "importGroup failed") + _state.update { it.copy(isLoading = false, snackbar = "Import error: ${e.localizedMessage}") } + } + } + } + + fun clearSnackbar() = _state.update { it.copy(snackbar = null) } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// NetworkGroupEditViewModel +// ══════════════════════════════════════════════════════════════════════════════ + +@HiltViewModel +class NetworkGroupEditViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repo: NetworkGroupRepository, + @ApplicationContext private val context: Context, +) : ViewModel() { + + // Route argument: "networkGroupEdit/{groupId}" + private val groupId: Long = savedStateHandle.get("groupId") ?: -1L + + private val _state = MutableStateFlow(NetworkGroupEditUiState(groupId = groupId)) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repo.observeCidrsForGroup(groupId).collect { cidrs -> + _state.update { s -> + val filtered = applyFilter(cidrs, s.searchQuery) + s.copy(allCidrs = cidrs, filteredCidrs = filtered) + } + } + } + } + + /** Call after navigation to load the group name. */ + fun loadGroupName(name: String) { + _state.update { it.copy(groupName = name) } + } + + // ── Search ──────────────────────────────────────────────────────────── + + fun onSearchQueryChanged(query: String) { + _state.update { s -> + s.copy( + searchQuery = query, + filteredCidrs = applyFilter(s.allCidrs, query), + ) + } + } + + private fun applyFilter(cidrs: List, query: String): List { + if (query.isBlank()) return cidrs + val q = query.trim().lowercase() + return cidrs.filter { it.cidr.lowercase().contains(q) || it.label.lowercase().contains(q) } + } + + // ── Add ─────────────────────────────────────────────────────────────── + + /** Single CIDR add. Returns error message or null on success. */ + fun addCidr(cidr: String, label: String = ""): String? { + if (cidr.isBlank()) return "CIDR cannot be empty" + var result: String? = null + viewModelScope.launch { + val ok = repo.addCidr(groupId, cidr.trim(), label.trim()) + if (!ok) { + _state.update { it.copy(snackbar = "Invalid or duplicate CIDR: $cidr") } + result = "Invalid or duplicate" + } + } + return result + } + + /** Bulk add — newline-separated CIDRs. */ + fun addCidrsBulk(rawText: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + val (added, skipped) = repo.addCidrsBulk(groupId, rawText) + _state.update { + it.copy( + isLoading = false, + snackbar = "Added $added, skipped $skipped (invalid or duplicate)", + ) + } + } + } + + // ── Delete ──────────────────────────────────────────────────────────── + + fun deleteCidr(cidr: NetworkCidrEntity) { + viewModelScope.launch { + repo.deleteCidr(cidr) + } + } + + // ── Export ──────────────────────────────────────────────────────────── + + fun exportGroup() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + val file = repo.exportGroup(groupId) + if (file != null) { + _state.update { it.copy(isLoading = false, exportFile = file) } + } else { + _state.update { it.copy(isLoading = false, snackbar = "Export failed") } + } + } + } + + fun clearExportFile() = _state.update { it.copy(exportFile = null) } + + fun clearSnackbar() = _state.update { it.copy(snackbar = null) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index f187e72..17e3728 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -45,14 +45,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.drawable.toBitmap import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Lan import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface import androidx.compose.ui.unit.dp -import com.gatecontrol.android.util.WifiSubnetDetector import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gatecontrol.android.R @@ -65,6 +67,7 @@ import com.gatecontrol.android.ui.theme.GateControlTheme fun SettingsScreen( onNavigateToLogs: () -> Unit, onNavigateToQrScanner: () -> Unit, + onNavigateToNetworkGroups: () -> Unit, viewModel: SettingsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -196,7 +199,6 @@ fun SettingsScreen( // --- Split Tunneling --- item { var showAppPicker by remember { mutableStateOf(false) } - val wifiSubnet = remember { WifiSubnetDetector.detect(context) } Spacer(modifier = Modifier.height(16.dp)) SectionHeader(text = stringResource(R.string.settings_split_tunnel)) @@ -243,12 +245,38 @@ fun SettingsScreen( modifier = Modifier.padding(bottom = 8.dp), ) - NetworkPresetsSection( - networks = uiState.splitTunnelNetworks, - wifiSubnet = wifiSubnet, - adminLocked = uiState.splitTunnelAdminLocked, - onNetworksChanged = { viewModel.setSplitTunnelNetworks(it) }, - ) + // Navigation entry to Network Groups management screen + val activeCount = uiState.splitTunnelNetworks.size + Surface( + onClick = onNavigateToNetworkGroups, + shape = MaterialTheme.shapes.small, + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Lan, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + "Manage Network Groups", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "$activeCount active CIDR${if (activeCount != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.Default.ChevronRight, contentDescription = null) + } + } Spacer(Modifier.height(16.dp)) From e933e982419b09841643dae4d61b5db2d8245f3e Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:45:59 +0800 Subject: [PATCH 06/71] Add files via upload --- .../android/data/di/NetworkGroupModule.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 core/data/src/main/java/com/gatecontrol/android/data/di/NetworkGroupModule.kt diff --git a/core/data/src/main/java/com/gatecontrol/android/data/di/NetworkGroupModule.kt b/core/data/src/main/java/com/gatecontrol/android/data/di/NetworkGroupModule.kt new file mode 100644 index 0000000..794fae9 --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/di/NetworkGroupModule.kt @@ -0,0 +1,76 @@ +// FILE: core/data/src/main/java/com/gatecontrol/android/data/di/NetworkGroupModule.kt +// +// Hilt DI — provides Room database, DAO, and the repository. +// Placed in core:data alongside the existing DataModule. + +package com.gatecontrol.android.data.di + +import android.content.Context +import androidx.room.Room +import com.gatecontrol.android.data.NetworkGroupRepository +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.db.NetworkGroupDao +import com.gatecontrol.android.data.db.NetworkGroupDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkGroupModule { + + @Provides + @Singleton + fun provideNetworkGroupDatabase( + @ApplicationContext context: Context, + ): NetworkGroupDatabase = + Room.databaseBuilder( + context, + NetworkGroupDatabase::class.java, + "gatecontrol_network_groups.db", + ) + .fallbackToDestructiveMigration() // v1 only; add proper migrations for v2+ + .build() + + @Provides + @Singleton + fun provideNetworkGroupDao(db: NetworkGroupDatabase): NetworkGroupDao = + db.networkGroupDao() + + @Provides + @Singleton + fun provideNetworkGroupRepository( + dao: NetworkGroupDao, + settingsRepository: SettingsRepository, + @ApplicationContext context: Context, + ): NetworkGroupRepository = + NetworkGroupRepository(dao, settingsRepository, context) +} + + +// ── libs.versions.toml additions (paste into the file) ─────────────────────── +// +// [versions] +// room = "2.6.1" +// +// [libraries] +// room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +// room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +// room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +// +// ── core/data/build.gradle.kts additions ───────────────────────────────────── +// +// plugins { +// ... +// id("com.google.devtools.ksp") version "1.9.25-1.0.20" // matches kotlin 1.9.25 +// } +// +// dependencies { +// ... +// implementation(libs.room.runtime) +// implementation(libs.room.ktx) +// ksp(libs.room.compiler) // use ksp, not kapt, for Room 2.6+ +// } From 24798ad509ecca80184fb8034be03ab55cd0372b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:46:55 +0800 Subject: [PATCH 07/71] Add files via upload --- .../android/data/NetworkGroupRepository.kt | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 core/data/src/main/java/com/gatecontrol/android/data/NetworkGroupRepository.kt diff --git a/core/data/src/main/java/com/gatecontrol/android/data/NetworkGroupRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/NetworkGroupRepository.kt new file mode 100644 index 0000000..2eb864a --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/NetworkGroupRepository.kt @@ -0,0 +1,274 @@ +// FILE: core/data/src/main/java/com/gatecontrol/android/data/NetworkGroupRepository.kt +// +// 业务逻辑层。负责: +// 1. CRUD 分组 & CIDR +// 2. 将激活的分组合并 → 写回 SettingsRepository.SPLIT_TUNNEL_NETWORKS (JSON) +// ← TunnelConnector 读这个 key,所以下游零改动 +// 3. 导出分组为 SQLite 文件 / 从 SQLite 文件导入分组 +// 4. 启动时从旧 DataStore JSON 一次性迁移 + +package com.gatecontrol.android.data + +import android.content.Context +import com.gatecontrol.android.data.db.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkGroupRepository @Inject constructor( + private val dao: NetworkGroupDao, + private val settingsRepository: SettingsRepository, + private val context: Context, +) { + + // ──────────────────────────────────────────── + // Observe + // ──────────────────────────────────────────── + + fun observeAllGroupsWithCidrs(): Flow> = + dao.observeAllGroupsWithCidrs() + + fun observeCidrsForGroup(groupId: Long): Flow> = + dao.observeCidrsForGroup(groupId) + + /** Emits a merged list of all enabled CIDRs across all enabled groups. */ + fun observeEnabledCidrs(): Flow> = + dao.observeEnabledCidrs() + + // ──────────────────────────────────────────── + // Group CRUD + // ──────────────────────────────────────────── + + suspend fun createGroup(name: String): Long { + val id = dao.insertGroup(NetworkGroupEntity(name = name)) + syncToDataStore() + return id + } + + suspend fun renameGroup(groupId: Long, newName: String) { + val groups = dao.getAllGroups() + val target = groups.firstOrNull { it.id == groupId } ?: return + dao.updateGroup(target.copy(name = newName)) + // name doesn't affect routing — no syncToDataStore needed + } + + suspend fun setGroupEnabled(groupId: Long, enabled: Boolean) { + dao.setGroupEnabled(groupId, enabled) + syncToDataStore() + } + + suspend fun deleteGroup(groupId: Long) { + val groups = dao.getAllGroups() + val target = groups.firstOrNull { it.id == groupId } ?: return + dao.deleteGroup(target) // CASCADE deletes child CIDRs + syncToDataStore() + } + + // ──────────────────────────────────────────── + // CIDR CRUD + // ──────────────────────────────────────────── + + /** + * Add a single CIDR to a group. Returns true on success, false if duplicate + * or invalid format. + */ + suspend fun addCidr(groupId: Long, cidr: String, label: String = ""): Boolean { + val normalized = cidr.trim() + if (!isValidCidr(normalized)) return false + // Check for duplicate within this group + val existing = dao.getCidrsForGroup(groupId).map { it.cidr } + if (normalized in existing) return false + dao.insertCidr(NetworkCidrEntity(groupId = groupId, cidr = normalized, label = label)) + syncToDataStore() + return true + } + + /** + * Bulk add — one CIDR per line. Returns (added, skipped) counts. + */ + suspend fun addCidrsBulk(groupId: Long, rawText: String): Pair { + val existing = dao.getCidrsForGroup(groupId).map { it.cidr }.toMutableSet() + var added = 0 + var skipped = 0 + val toInsert = mutableListOf() + rawText.lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { line -> + if (isValidCidr(line) && line !in existing) { + toInsert.add(NetworkCidrEntity(groupId = groupId, cidr = line)) + existing.add(line) + added++ + } else { + skipped++ + } + } + if (toInsert.isNotEmpty()) { + dao.insertCidrs(toInsert) + syncToDataStore() + } + return added to skipped + } + + suspend fun deleteCidr(cidr: NetworkCidrEntity) { + dao.deleteCidr(cidr) + syncToDataStore() + } + + // ──────────────────────────────────────────── + // Export / Import SQLite + // ──────────────────────────────────────────── + + /** + * Exports a single group (its metadata + all CIDRs) as a self-contained + * SQLite DB file in the app cache dir. + * + * Schema of the exported file: + * CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + * CREATE TABLE cidrs (cidr TEXT PRIMARY KEY, label TEXT); + * + * Returns the exported File, or null on failure. + */ + suspend fun exportGroup(groupId: Long): File? = withContext(Dispatchers.IO) { + val gwc = dao.getGroupWithCidrs(groupId) ?: return@withContext null + val outFile = File(context.cacheDir, "gc_network_${gwc.group.name.sanitizeFilename()}_${System.currentTimeMillis()}.sqlite3") + try { + android.database.sqlite.SQLiteDatabase.openOrCreateDatabase(outFile, null).use { db -> + db.execSQL("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)") + db.execSQL("CREATE TABLE IF NOT EXISTS cidrs (cidr TEXT PRIMARY KEY, label TEXT)") + + db.execSQL("INSERT OR REPLACE INTO meta VALUES ('name', ?)", arrayOf(gwc.group.name)) + db.execSQL("INSERT OR REPLACE INTO meta VALUES ('enabled', ?)", arrayOf(if (gwc.group.enabled) "1" else "0")) + db.execSQL("INSERT OR REPLACE INTO meta VALUES ('exported_at', ?)", arrayOf(System.currentTimeMillis().toString())) + db.execSQL("INSERT OR REPLACE INTO meta VALUES ('version', ?)", arrayOf("1")) + + gwc.cidrs.forEach { entry -> + db.execSQL("INSERT OR IGNORE INTO cidrs VALUES (?, ?)", arrayOf(entry.cidr, entry.label)) + } + } + outFile + } catch (e: Exception) { + Timber.e(e, "Export group failed") + outFile.delete() + null + } + } + + /** + * Imports a group from a previously exported SQLite file. + * Creates a new group (never overwrites existing data). + * Returns the new group id, or -1 on failure. + */ + suspend fun importGroup(sourceFile: File): Long = withContext(Dispatchers.IO) { + try { + var groupName = sourceFile.nameWithoutExtension + val cidrs = mutableListOf>() // cidr, label + + android.database.sqlite.SQLiteDatabase.openDatabase( + sourceFile.absolutePath, null, + android.database.sqlite.SQLiteDatabase.OPEN_READONLY + ).use { db -> + db.rawQuery("SELECT value FROM meta WHERE key = 'name'", null)?.use { c -> + if (c.moveToFirst()) groupName = c.getString(0) + } + db.rawQuery("SELECT cidr, label FROM cidrs", null)?.use { c -> + while (c.moveToNext()) { + cidrs.add(c.getString(0) to c.getString(1)) + } + } + } + + val newId = dao.insertGroup(NetworkGroupEntity(name = groupName)) + val entities = cidrs + .filter { isValidCidr(it.first) } + .map { NetworkCidrEntity(groupId = newId, cidr = it.first, label = it.second) } + dao.insertCidrs(entities) + syncToDataStore() + newId + } catch (e: Exception) { + Timber.e(e, "Import group failed") + -1L + } + } + + // ──────────────────────────────────────────── + // One-time migration from DataStore JSON + // ──────────────────────────────────────────── + + /** + * Call once at app startup (e.g. from SettingsViewModel.init). + * If the new DB is empty but the old DataStore JSON has data, + * migrates it into a default group called "Migrated". + */ + suspend fun migrateFromDataStoreIfNeeded() { + val existingGroups = dao.getAllGroups() + if (existingGroups.isNotEmpty()) return // already migrated + + val json = settingsRepository.getSplitTunnelNetworks().first() + if (json.isBlank() || json == "[]") return + + try { + val arr = JSONArray(json) + if (arr.length() == 0) return + + val groupId = dao.insertGroup( + NetworkGroupEntity(name = "Migrated", enabled = true) + ) + val cidrs = (0 until arr.length()).mapNotNull { + val obj = arr.getJSONObject(it) + val cidr = obj.optString("cidr").trim() + val label = obj.optString("label", "") + if (isValidCidr(cidr)) NetworkCidrEntity(groupId = groupId, cidr = cidr, label = label) else null + } + dao.insertCidrs(cidrs) + Timber.i("Migrated %d CIDRs from DataStore JSON into Room group %d", cidrs.size, groupId) + // Don't clear the DataStore key — it stays as the ground truth for TunnelConnector + } catch (e: Exception) { + Timber.w(e, "Migration from DataStore failed") + } + } + + // ──────────────────────────────────────────── + // Internal helpers + // ──────────────────────────────────────────── + + /** + * Reads all enabled CIDRs from Room and writes them back to + * SettingsRepository.SPLIT_TUNNEL_NETWORKS so TunnelConnector + * continues to work without modification. + */ + private suspend fun syncToDataStore() { + try { + val cidrs = dao.getEnabledCidrs() + val arr = JSONArray() + cidrs.forEach { cidr -> + arr.put(JSONObject().put("cidr", cidr).put("label", "")) + } + settingsRepository.setSplitTunnelNetworks(arr.toString()) + } catch (e: Exception) { + Timber.e(e, "syncToDataStore failed") + } + } + + private fun isValidCidr(cidr: String): Boolean { + val parts = cidr.split("/") + if (parts.size != 2) return false + val prefix = parts[1].toIntOrNull() ?: return false + if (prefix < 0 || prefix > 32) return false + val octets = parts[0].split(".") + if (octets.size != 4) return false + return octets.all { it.toIntOrNull()?.let { v -> v in 0..255 } == true } + } + + private fun String.sanitizeFilename(): String = + replace(Regex("[^a-zA-Z0-9_\\-]"), "_").take(40) +} From c59fdd50604e56891008cbcc961ccfacc3ba6f7d Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:48:30 +0800 Subject: [PATCH 08/71] Create 1 --- core/data/src/main/java/com/gatecontrol/android/data/db/1 | 1 + 1 file changed, 1 insertion(+) create mode 100644 core/data/src/main/java/com/gatecontrol/android/data/db/1 diff --git a/core/data/src/main/java/com/gatecontrol/android/data/db/1 b/core/data/src/main/java/com/gatecontrol/android/data/db/1 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/db/1 @@ -0,0 +1 @@ + From eac60955a164b4f2f6b2d3eaacf7e47d4eb5da73 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:49:09 +0800 Subject: [PATCH 09/71] Add files via upload --- .../android/data/db/NetworkGroupDatabase.kt | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt diff --git a/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt b/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt new file mode 100644 index 0000000..c4f2cb9 --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt @@ -0,0 +1,161 @@ +// FILE: core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt +// +// 新增 Room 数据库,负责存储"网络分组"及其下的 CIDR 条目。 +// 现有 DataStore (SPLIT_TUNNEL_NETWORKS JSON) 继续保留, +// 迁移完成后由 NetworkGroupRepository 将激活的分组合并成 CIDR 列表 +// 写回 SPLIT_TUNNEL_NETWORKS,TunnelConnector 侧无需任何改动。 + +package com.gatecontrol.android.data.db + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +// ────────────────────────────────────────────── +// Entities +// ────────────────────────────────────────────── + +/** + * 一个"网络分组"相当于原来用户手动维护的那一大堆 CIDR。 + * 现在每个分组独立管理,可以单独启用/禁用、导入/导出 SQLite 文件。 + * + * @param id 主键,自增 + * @param name 用户给分组起的名字,如 "办公室内网" + * @param enabled 是否参与 Split Tunnel 路由计算 + * @param sortOrder 显示顺序 + * @param createdAt 创建时间戳(ms) + */ +@Entity(tableName = "network_groups") +data class NetworkGroupEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val enabled: Boolean = true, + val sortOrder: Int = 0, + val createdAt: Long = System.currentTimeMillis(), +) + +/** + * 属于某个分组的单条 CIDR。 + * + * @param id 主键,自增 + * @param groupId 外键 → network_groups.id + * @param cidr 如 "172.16.0.0/12" + * @param label 可选备注,如 "打印机网段" + */ +@Entity( + tableName = "network_cidrs", + foreignKeys = [ + ForeignKey( + entity = NetworkGroupEntity::class, + parentColumns = ["id"], + childColumns = ["groupId"], + onDelete = ForeignKey.CASCADE, // 删分组 → CIDR 级联删除 + ) + ], + indices = [Index("groupId")], +) +data class NetworkCidrEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val groupId: Long, + val cidr: String, + val label: String = "", +) + +// ────────────────────────────────────────────── +// Relation (Group + its CIDRs together) +// ────────────────────────────────────────────── + +data class NetworkGroupWithCidrs( + @Embedded val group: NetworkGroupEntity, + @Relation( + parentColumn = "id", + entityColumn = "groupId", + ) + val cidrs: List, +) + +// ────────────────────────────────────────────── +// DAOs +// ────────────────────────────────────────────── + +@Dao +interface NetworkGroupDao { + + // ── Groups ── + + @Query("SELECT * FROM network_groups ORDER BY sortOrder ASC, createdAt ASC") + fun observeAllGroups(): Flow> + + @Query("SELECT * FROM network_groups ORDER BY sortOrder ASC, createdAt ASC") + suspend fun getAllGroups(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroup(group: NetworkGroupEntity): Long + + @Update + suspend fun updateGroup(group: NetworkGroupEntity) + + @Delete + suspend fun deleteGroup(group: NetworkGroupEntity) + + @Query("UPDATE network_groups SET enabled = :enabled WHERE id = :id") + suspend fun setGroupEnabled(id: Long, enabled: Boolean) + + // ── CIDRs ── + + @Query("SELECT * FROM network_cidrs WHERE groupId = :groupId ORDER BY id ASC") + fun observeCidrsForGroup(groupId: Long): Flow> + + @Query("SELECT * FROM network_cidrs WHERE groupId = :groupId ORDER BY id ASC") + suspend fun getCidrsForGroup(groupId: Long): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) // 忽略重复 CIDR(按主键;业务层再做去重) + suspend fun insertCidr(cidr: NetworkCidrEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertCidrs(cidrs: List) + + @Delete + suspend fun deleteCidr(cidr: NetworkCidrEntity) + + @Query("DELETE FROM network_cidrs WHERE groupId = :groupId") + suspend fun deleteAllCidrsInGroup(groupId: Long) + + // ── Grouped relation ── + + @Transaction + @Query("SELECT * FROM network_groups ORDER BY sortOrder ASC, createdAt ASC") + fun observeAllGroupsWithCidrs(): Flow> + + @Transaction + @Query("SELECT * FROM network_groups WHERE id = :groupId") + suspend fun getGroupWithCidrs(groupId: Long): NetworkGroupWithCidrs? + + // ── Enabled CIDRs (for VPN route calculation) ── + + @Query(""" + SELECT c.cidr FROM network_cidrs c + INNER JOIN network_groups g ON g.id = c.groupId + WHERE g.enabled = 1 + """) + suspend fun getEnabledCidrs(): List + + @Query(""" + SELECT c.cidr, c.label FROM network_cidrs c + INNER JOIN network_groups g ON g.id = c.groupId + WHERE g.enabled = 1 + """) + fun observeEnabledCidrs(): Flow> +} + +// ────────────────────────────────────────────── +// Database +// ────────────────────────────────────────────── + +@Database( + entities = [NetworkGroupEntity::class, NetworkCidrEntity::class], + version = 1, + exportSchema = false, +) +abstract class NetworkGroupDatabase : RoomDatabase() { + abstract fun networkGroupDao(): NetworkGroupDao +} From 4fc0f5f9055c5c338fe9edc103e78dbc6ce658ac Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:49:45 +0800 Subject: [PATCH 10/71] Delete core/data/src/main/java/com/gatecontrol/android/data/db/1 --- core/data/src/main/java/com/gatecontrol/android/data/db/1 | 1 - 1 file changed, 1 deletion(-) delete mode 100644 core/data/src/main/java/com/gatecontrol/android/data/db/1 diff --git a/core/data/src/main/java/com/gatecontrol/android/data/db/1 b/core/data/src/main/java/com/gatecontrol/android/data/db/1 deleted file mode 100644 index 8b13789..0000000 --- a/core/data/src/main/java/com/gatecontrol/android/data/db/1 +++ /dev/null @@ -1 +0,0 @@ - From 5b28880ef22a2b0554eca90f6879064310d5d015 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:50:37 +0800 Subject: [PATCH 11/71] Add files via upload --- app/src/main/res/xml/file_paths.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 238e169..f38a1ba 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + From 5d421f6b4a0bc376da834d8d3fd83dbfae605e9e Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:51:50 +0800 Subject: [PATCH 12/71] Rename release.yml to Release.yml --- .github/workflows/{release.yml => Release.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{release.yml => Release.yml} (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/Release.yml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/Release.yml From 1f54c61bab46d59857b021b8901e70f599aa023f Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:03:52 +0800 Subject: [PATCH 13/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20build.gradle.kts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/data/build.gradle.kts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a977554..bdae1a5 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -2,7 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.hilt) - kotlin("kapt") + kotlin("kapt") // 统一使用 kapt } android { @@ -40,14 +40,23 @@ dependencies { implementation(project(":core:common")) implementation(libs.core.ktx) + + // Hilt implementation(libs.hilt.android) kapt(libs.hilt.compiler) + implementation(libs.datastore.preferences) implementation(libs.security.crypto) implementation(libs.coroutines.core) implementation(libs.coroutines.android) implementation(libs.timber) + // Room (重新加回 Room 运行时依赖,并将注解处理器改为 kapt) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) // 用 kapt 替代原来的 ksp + + // 测试相关依赖 testImplementation(libs.junit5.api) testRuntimeOnly(libs.junit5.engine) testImplementation(libs.mockk) From ef9bfe109d3ec0c804f0dc20b9830cfb649f4903 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:12:31 +0800 Subject: [PATCH 14/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20NetworkGroupDatabase?= =?UTF-8?q?.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gatecontrol/android/data/db/NetworkGroupDatabase.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt b/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt index c4f2cb9..ccfeb8b 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/db/NetworkGroupDatabase.kt @@ -139,8 +139,9 @@ interface NetworkGroupDao { """) suspend fun getEnabledCidrs(): List + // 💡 修复部分:将原来的 SELECT c.cidr, c.label 改为 SELECT c.* // 这样能查出包括 id, groupId 在内的完整字段,完美契合 NetworkCidrEntity 的返回类型 @Query(""" - SELECT c.cidr, c.label FROM network_cidrs c + SELECT c.* FROM network_cidrs c INNER JOIN network_groups g ON g.id = c.groupId WHERE g.enabled = 1 """) From 306448b11ca8fe2b52cf60e562bafaffb120c1e3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 6 Jun 2026 20:25:52 +0000 Subject: [PATCH 15/71] chore: bump version to 1.6.1 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee97114..e5f64e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.1] - 2026-06-06 + +### Changes +- 更新 NetworkGroupDatabase.kt + +--- + ## [1.6.0] - 2026-05-31 ### Features diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ccf9702..61a1ba7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10600 - versionName = "1.6.0" + versionCode = 10601 + versionName = "1.6.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 4307c8dd34ce3a9e944081ad5ffa447690417530 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:15:47 +0800 Subject: [PATCH 16/71] Update SettingsScreen.kt --- .../java/com/gatecontrol/android/ui/settings/SettingsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index 17e3728..95a47a4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -608,6 +608,7 @@ private fun LocaleDropdown( val localeOptions = mapOf( "de" to "Deutsch", "en" to "English" + "zh" to "中文 (简体)" ) Row( From da43d52d6185a3b659fa875104c6880dcc04387b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:19:15 +0800 Subject: [PATCH 17/71] Create strings.xml --- app/src/main/res/values-zh/strings.xml | 217 +++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 app/src/main/res/values-zh/strings.xml diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..b395919 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,217 @@ + + + GateControl + + VPN + RDP + 服务 + 设置 + + 已连接 + 已断开 + 正在连接… + 正在断开… + 正在重新连接 (%1$d/%2$d)… + 连接错误 + 连接 + 断开 + 服务器 + 握手 + ↓ 已接收 + ↑ 已发送 + 应用终止开关 (Kill-Switch) + 未连接 VPN 时阻止所有网络流量 + 请在系统 VPN 设置中进行配置 + 带宽 + 下载 + 上传 + + 数据消耗 + 24 小时 + 7 天 + 30 天 + 总计 + + 可用服务 + 无可用服务 + 认证 + DNS 泄漏测试 + 正在测试… + 未检测到 DNS 泄漏 + 检测到 DNS 泄漏! + DNS 服务器: %1$s + + RDP 连接 + 搜索… + 全部 + 在线 + 离线 + 在线 + 离线 + 维护中 + 连接 + 断开 + 远程唤醒 (WOL) + 正在发送 Wake-on-LAN 唤醒包… + 正在等待主机响应… + 会话处于活动状态 + 需要先连接 VPN + 无法访问主机 + 主机当前处于维护窗口期。仍要连接吗? + 强制连接 + 请输入密码 + 密码 + 自动登录 + 已提供用户名 + 手动登录 + 未安装 RDP 客户端。是否安装微软远程桌面 (Microsoft Remote Desktop)? + 正在检查 VPN状态… + 正在检查主机状态… + 正在加载登录凭据… + 正在启动客户端… + 正在建立会话… + 已连接 + 会话将在 %1$d 分钟后过期 + 密码已复制到剪贴板 + 未配置 RDP 主机 + 需要 Pro 专业版授权 + RDP 不可用。缺少 Pro 授权或必要的 Token 权限。 + 无法连接到服务器。请检查网络连接并重试。 + 无法加载 RDP 主机列表。请稍后重试。 + 正在连接到远程桌面… + 正在重新连接… (%1$d/%2$d) + 断开 RDP 会话? + 远程桌面会话即将断开。 + 连接已断开 + 尝试重连 %1$d 次后失败 + 正在启动服务… + RDP: %1$s + 会话时长: %1$s + 断开 + 服务器身份未经验证 + 服务器身份已更改 + 主机: %1$s:%2$d + 公用名 (CN): %1$s + 指纹: %1$s + 仅连接一次 + 始终信任 + 拒绝 + 需要身份验证 + 服务器需要登录凭据。请输入以继续。 + + 设置 + 服务器 + 服务器 URL + gate.example.com + API Token + gc_… + 测试连接 + 保存并注册 + 连接成功 + 连接失败 + 隧道 + 分流隧道 (Split-Tunneling) + 仅将特定流量通过 VPN 路由 + IP 路由 (每行一个) + 10.0.0.0/8\n192.168.0.0/16 + 排除的应用 + 搜索应用… + 显示系统应用 + 网络预设 + 私有网络 (172/192) + WLAN 子网 (%1$s) + WLAN 子网 (未连接) + 自定义网络 + 添加网络 + 标签名称 + 由管理员配置(已锁定) + 分流隧道 + 所有流量均通过 VPN + 全局全局模式 — 仅定义例外白名单 + 仅选定的网络通过 VPN + 以下网络不通过 VPN 路由 + 仅以下网络通过 VPN 路由 + 以下应用不通过 VPN 路由 + 仅以下应用通过 VPN 路由 + 添加应用 + 推荐预设 + 无线连接时可能会导致 VPN 问题 + 保存路由 + 路由已保存 + 导入配置 + 扫描二维码 + 导入 .conf 文件 + 应用设置 + 主题外观 + 深色模式 + 浅色模式 + 语言 (Language) + 自动连接 + 应用启动时自动连接 VPN + 检查间隔 (秒) + 配置轮询间隔 (秒) + 授权许可 + 社区版 (Community) + 专业版 (Pro) + 激活许可证 + 刷新许可证状态 + 许可证由服务端统一管理 + 日志记录 + 查看日志 + 导出日志 + 关于 + 版本 %1$s + 有可用更新: %1$s + 立即安装 + 稍后 + 当前已是最新版本 + 检查更新 + + 连接到 GateControl + 配置 VPN 连接 + 扫描二维码 + 手动输入配置 + 导入配置文件 + 正在注册… + 注册成功! + 注册失败: %1$s + 无效的 WireGuard 配置 + + 日志 + 全部 + 24 小时 + 12 小时 + 1 小时 + 暂无日志记录 + 刷新 + 导出 + + VPN 状态 + 应用更新 + RDP 会话 + 已连接到 %1$s + VPN 已断开 + 正在尝试重新连接… + ↓ %1$s · ↑ %2$s · %3$s + 有新版本可用 + 版本 %1$s 已发布 + 节点将在 %1$d 天后过期 + 节点已过期 + VPN 访问已被服务端禁用,连接已断开。 + + GateControl VPN + GateControl VPN + 未连接 + + 取消 + 确定 + 保存 + 错误 + 重试 + 关闭 + 打开系统设置 + 需要授予 VPN 权限 + 扫描二维码需要相机权限 + 需要通知权限 + 检测到此设备已被 Root。VPN 的安全性可能会受到影响。 + From 192ab726e8763eaee7bb9024944189f41e248449 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:34:58 +0800 Subject: [PATCH 18/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20SettingsScreen.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gatecontrol/android/ui/settings/SettingsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index 95a47a4..39c570b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -607,7 +607,7 @@ private fun LocaleDropdown( val localeOptions = mapOf( "de" to "Deutsch", - "en" to "English" + "en" to "English", "zh" to "中文 (简体)" ) From e272d809b45892342b364d4bd03fc5a70f2ce220 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:43:29 +0800 Subject: [PATCH 19/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20FormattersTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/common/FormattersTest.kt | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt index ab44f8a..e2ec383 100644 --- a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt +++ b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt @@ -99,6 +99,11 @@ class FormattersTest { assertEquals("3m ago", Formatters.formatHandshakeAge(180, "en")) } + @Test + fun `formatHandshakeAge hours in English returns Xh ago`() { + assertEquals("2h ago", Formatters.formatHandshakeAge(7_200, "en")) + } + @Test fun `formatHandshakeAge 0 seconds in German returns jetzt`() { assertEquals("jetzt", Formatters.formatHandshakeAge(0, "de")) @@ -115,15 +120,34 @@ class FormattersTest { } @Test - fun `formatHandshakeAge hours in English returns Xh ago`() { - assertEquals("2h ago", Formatters.formatHandshakeAge(7_200, "en")) + fun `formatHandshakeAge hours in German returns vor Xh`() { + assertEquals("vor 2h", Formatters.formatHandshakeAge(7_200, "de")) } + // --- 💡 这里是为你新增的中文 (zh) 本地化单元测试用例 --- + @Test - fun `formatHandshakeAge hours in German returns vor Xh`() { - assertEquals("vor 2h", Formatters.formatHandshakeAge(7_200, "de")) + fun `formatHandshakeAge 0 seconds in Chinese returns ganggang`() { + assertEquals("刚刚", Formatters.formatHandshakeAge(0, "zh")) + } + + @Test + fun `formatHandshakeAge 30 seconds in Chinese returns 30s ago`() { + assertEquals("30秒前", Formatters.formatHandshakeAge(30, "zh")) } + @Test + fun `formatHandshakeAge 180 seconds in Chinese returns 3m ago`() { + assertEquals("3分钟前", Formatters.formatHandshakeAge(180, "zh")) + } + + @Test + fun `formatHandshakeAge hours in Chinese returns Xh ago`() { + assertEquals("2小时前", Formatters.formatHandshakeAge(7_200, "zh")) + } + + // --- Default Locale --- + @Test fun `formatHandshakeAge default locale is English`() { assertEquals("30s ago", Formatters.formatHandshakeAge(30)) From ae0eba2a6b8954b3804b71cd9bc088a45833da8b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:44:05 +0800 Subject: [PATCH 20/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20FormattersTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/com/gatecontrol/android/common/FormattersTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt index e2ec383..e3f0f20 100644 --- a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt +++ b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt @@ -124,7 +124,7 @@ class FormattersTest { assertEquals("vor 2h", Formatters.formatHandshakeAge(7_200, "de")) } - // --- 💡 这里是为你新增的中文 (zh) 本地化单元测试用例 --- + // --- 新增的中文 @Test fun `formatHandshakeAge 0 seconds in Chinese returns ganggang`() { From c5a957c7c27077e847a15083d1f48d92b0a30b7f Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 05:57:56 +0800 Subject: [PATCH 21/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20FormattersTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gatecontrol/android/common/FormattersTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt index e3f0f20..d2dc4d7 100644 --- a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt +++ b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt @@ -128,22 +128,22 @@ class FormattersTest { @Test fun `formatHandshakeAge 0 seconds in Chinese returns ganggang`() { - assertEquals("刚刚", Formatters.formatHandshakeAge(0, "zh")) + assertEquals("now", Formatters.formatHandshakeAge(0, "zh")) } @Test fun `formatHandshakeAge 30 seconds in Chinese returns 30s ago`() { - assertEquals("30秒前", Formatters.formatHandshakeAge(30, "zh")) + assertEquals("30s", Formatters.formatHandshakeAge(30, "zh")) } @Test fun `formatHandshakeAge 180 seconds in Chinese returns 3m ago`() { - assertEquals("3分钟前", Formatters.formatHandshakeAge(180, "zh")) + assertEquals("3m", Formatters.formatHandshakeAge(180, "zh")) } @Test fun `formatHandshakeAge hours in Chinese returns Xh ago`() { - assertEquals("2小时前", Formatters.formatHandshakeAge(7_200, "zh")) + assertEquals("2h", Formatters.formatHandshakeAge(7_200, "zh")) } // --- Default Locale --- From 144721e2aeb7428e5284cc83176eb12fb0da4c92 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:14:01 +0800 Subject: [PATCH 22/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20FormattersTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/common/FormattersTest.kt | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt index d2dc4d7..017d38a 100644 --- a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt +++ b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt @@ -124,32 +124,24 @@ class FormattersTest { assertEquals("vor 2h", Formatters.formatHandshakeAge(7_200, "de")) } - // --- 新增的中文 +// --- Chinese (zh) --- - @Test - fun `formatHandshakeAge 0 seconds in Chinese returns ganggang`() { - assertEquals("now", Formatters.formatHandshakeAge(0, "zh")) - } - - @Test - fun `formatHandshakeAge 30 seconds in Chinese returns 30s ago`() { - assertEquals("30s", Formatters.formatHandshakeAge(30, "zh")) - } - - @Test - fun `formatHandshakeAge 180 seconds in Chinese returns 3m ago`() { - assertEquals("3m", Formatters.formatHandshakeAge(180, "zh")) - } - - @Test - fun `formatHandshakeAge hours in Chinese returns Xh ago`() { - assertEquals("2h", Formatters.formatHandshakeAge(7_200, "zh")) - } +@Test +fun `formatHandshakeAge 0 seconds in Chinese returns 此刻`() { + assertEquals("此刻", Formatters.formatHandshakeAge(0, "zh")) +} - // --- Default Locale --- +@Test +fun `formatHandshakeAge 30 seconds in Chinese returns 30秒前`() { + assertEquals("30秒前", Formatters.formatHandshakeAge(30, "zh")) +} - @Test - fun `formatHandshakeAge default locale is English`() { - assertEquals("30s ago", Formatters.formatHandshakeAge(30)) - } +@Test +fun `formatHandshakeAge 180 seconds in Chinese returns 3分钟前`() { + assertEquals("3分钟前", Formatters.formatHandshakeAge(180, "zh")) } + +@Test +fun `formatHandshakeAge hours in Chinese returns X小时前`() { + assertEquals("2小时前", Formatters.formatHandshakeAge(7_200, "zh")) +} \ No newline at end of file From 5b4c1c39ace50d5cdcfa8321a4b01761ce118823 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:14:31 +0800 Subject: [PATCH 23/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Formatters.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gatecontrol/android/common/Formatters.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/core/common/src/main/java/com/gatecontrol/android/common/Formatters.kt b/core/common/src/main/java/com/gatecontrol/android/common/Formatters.kt index faabc60..2a4f8cd 100644 --- a/core/common/src/main/java/com/gatecontrol/android/common/Formatters.kt +++ b/core/common/src/main/java/com/gatecontrol/android/common/Formatters.kt @@ -56,17 +56,38 @@ object Formatters { fun formatHandshakeAge(seconds: Long, locale: String = "en"): String { val isDe = locale.equals("de", ignoreCase = true) + val isZh = locale.equals("zh", ignoreCase = true) return when { - seconds < 1 -> if (isDe) "jetzt" else "now" - seconds < 60 -> if (isDe) "vor ${seconds}s" else "${seconds}s ago" + seconds < 1 -> { + when { + isDe -> "jetzt" + isZh -> "此刻" + else -> "now" + } + } + seconds < 60 -> { + when { + isDe -> "vor ${seconds}s" + isZh -> "${seconds}秒前" + else -> "${seconds}s ago" + } + } seconds < 3600 -> { val m = seconds / 60 - if (isDe) "vor ${m}m" else "${m}m ago" + when { + isDe -> "vor ${m}m" + isZh -> "${m}分钟前" + else -> "${m}m ago" + } } else -> { val h = seconds / 3600 - if (isDe) "vor ${h}h" else "${h}h ago" + when { + isDe -> "vor ${h}h" + isZh -> "${h}小时前" + else -> "${h}h ago" + } } } } -} +} \ No newline at end of file From 64915a2ba29ea77af0b2bb1197f23180876f5c02 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:22:07 +0800 Subject: [PATCH 24/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20FormattersTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/common/FormattersTest.kt | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt index 017d38a..aea81fd 100644 --- a/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt +++ b/core/common/src/test/java/com/gatecontrol/android/common/FormattersTest.kt @@ -124,24 +124,25 @@ class FormattersTest { assertEquals("vor 2h", Formatters.formatHandshakeAge(7_200, "de")) } -// --- Chinese (zh) --- - -@Test -fun `formatHandshakeAge 0 seconds in Chinese returns 此刻`() { - assertEquals("此刻", Formatters.formatHandshakeAge(0, "zh")) -} - -@Test -fun `formatHandshakeAge 30 seconds in Chinese returns 30秒前`() { - assertEquals("30秒前", Formatters.formatHandshakeAge(30, "zh")) -} - -@Test -fun `formatHandshakeAge 180 seconds in Chinese returns 3分钟前`() { - assertEquals("3分钟前", Formatters.formatHandshakeAge(180, "zh")) -} - -@Test -fun `formatHandshakeAge hours in Chinese returns X小时前`() { - assertEquals("2小时前", Formatters.formatHandshakeAge(7_200, "zh")) + // --- Chinese (zh) --- + + @Test + fun `formatHandshakeAge 0 seconds in Chinese returns 此刻`() { + assertEquals("此刻", Formatters.formatHandshakeAge(0, "zh")) + } + + @Test + fun `formatHandshakeAge 30 seconds in Chinese returns 30秒前`() { + assertEquals("30秒前", Formatters.formatHandshakeAge(30, "zh")) + } + + @Test + fun `formatHandshakeAge 180 seconds in Chinese returns 3分钟前`() { + assertEquals("3分钟前", Formatters.formatHandshakeAge(180, "zh")) + } + + @Test + fun `formatHandshakeAge hours in Chinese returns X小时前`() { + assertEquals("2小时前", Formatters.formatHandshakeAge(7_200, "zh")) + } } \ No newline at end of file From 8f73769a7ad649922ee758d9da75504ca7dc3790 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:32:10 +0800 Subject: [PATCH 25/71] Rename Release.yml to release.yml --- .github/workflows/{Release.yml => release.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{Release.yml => release.yml} (100%) diff --git a/.github/workflows/Release.yml b/.github/workflows/release.yml similarity index 100% rename from .github/workflows/Release.yml rename to .github/workflows/release.yml From 2475146f3366db5fff7a681e0ec9d87bba66758e Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 6 Jun 2026 22:52:18 +0000 Subject: [PATCH 26/71] chore: bump version to 1.6.2 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f64e9..c134769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.2] - 2026-06-06 + +### Changes +- Rename Release.yml to release.yml + +--- + ## [1.6.1] - 2026-06-06 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61a1ba7..e1dcbff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10601 - versionName = "1.6.1" + versionCode = 10602 + versionName = "1.6.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From b6282cd5115938ab3e97927f6fd309c1dbee6044 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:22:41 +0800 Subject: [PATCH 27/71] Add files via upload --- .../android/tunnel/StealthConfig.kt | 3135 ++++++++++++++++ .../android/tunnel/StealthEngine.kt | 3135 ++++++++++++++++ .../android/tunnel/TunnelManager.kt | 3327 +++++++++++++++-- 3 files changed, 9363 insertions(+), 234 deletions(-) create mode 100644 core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt create mode 100644 core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt new file mode 100644 index 0000000..d9abd38 --- /dev/null +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt @@ -0,0 +1,3135 @@ +App unavailable in region | Claude + + + + + + + + + + + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt new file mode 100644 index 0000000..d9abd38 --- /dev/null +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt @@ -0,0 +1,3135 @@ +App unavailable in region | Claude + + + + + + + + + + + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 0c3e4ae..d9abd38 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -1,276 +1,3135 @@ -package com.gatecontrol.android.tunnel - -import android.content.Context -import android.net.VpnService -import com.wireguard.android.backend.Backend -import com.wireguard.android.backend.GoBackend -import com.wireguard.android.backend.Tunnel -import com.wireguard.config.Config -import com.wireguard.config.Interface -import com.wireguard.config.InetAddresses -import com.wireguard.config.InetNetwork -import com.wireguard.config.Peer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.net.InetAddress -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class TunnelManager @Inject constructor(private val context: Context) { - - private val _state = MutableStateFlow(TunnelState.Disconnected) - val state: StateFlow = _state.asStateFlow() - - private val _stats = MutableStateFlow(TunnelStats()) - val stats: StateFlow = _stats.asStateFlow() - - private var backend: Backend? = null - private var tunnel: Tunnel? = null - - private var prevRxBytes: Long = 0L - private var prevTxBytes: Long = 0L - private var prevStatsTime: Long = 0L - - fun initialize() { - try { - backend = GoBackend(context) - tunnel = object : Tunnel { - override fun getName(): String = TUNNEL_NAME - override fun onStateChange(newState: Tunnel.State) { - Timber.d("Tunnel state changed: $newState") - } - } - Timber.d("TunnelManager initialized") - } catch (e: Exception) { - Timber.e(e, "Failed to initialize TunnelManager") +App unavailable in region | Claude + + + + + + + + + + + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file From 90413eccb3b6f87530c49df610025f7760329e1b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:23:50 +0800 Subject: [PATCH 28/71] Add files via upload --- .../android/data/SettingsRepository.kt | 3199 ++++++++++++++++- 1 file changed, 3083 insertions(+), 116 deletions(-) diff --git a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt index b94536c..d9abd38 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt @@ -1,168 +1,3135 @@ -package com.gatecontrol.android.data +App unavailable in region | Claude -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject -import javax.inject.Singleton + + + + + -@Singleton -class SettingsRepository @Inject constructor(private val dataStore: DataStore) { + + - companion object { - val THEME = stringPreferencesKey("theme") - val LOCALE = stringPreferencesKey("locale") - val AUTO_CONNECT = booleanPreferencesKey("auto_connect") - val KILL_SWITCH = booleanPreferencesKey("kill_switch") - val SPLIT_TUNNEL_ENABLED = booleanPreferencesKey("split_tunnel_enabled") - val SPLIT_TUNNEL_ROUTES = stringPreferencesKey("split_tunnel_routes") - val SPLIT_TUNNEL_APPS = stringPreferencesKey("split_tunnel_apps") - // New split-tunnel keys (v2 JSON format) - val SPLIT_TUNNEL_MODE = stringPreferencesKey("split_tunnel_mode") - val SPLIT_TUNNEL_NETWORKS = stringPreferencesKey("split_tunnel_networks") - val SPLIT_TUNNEL_APPS_V2 = stringPreferencesKey("split_tunnel_apps_v2") - val SPLIT_TUNNEL_ADMIN_LOCKED = booleanPreferencesKey("split_tunnel_admin_locked") - val CHECK_INTERVAL = intPreferencesKey("check_interval") - val CONFIG_POLL_INTERVAL = intPreferencesKey("config_poll_interval") + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file From 3668a677fc5d1c711f65d8834c5ab13e1c3dad9d Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:25:45 +0800 Subject: [PATCH 29/71] Add files via upload --- .../android/ui/settings/SettingsScreen.kt | 3693 ++++++++++++++--- .../android/ui/settings/SettingsViewModel.kt | 3446 +++++++++++++-- 2 files changed, 6148 insertions(+), 991 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index 39c570b..d9abd38 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -1,654 +1,3135 @@ -package com.gatecontrol.android.ui.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.ui.graphics.asImageBitmap -import androidx.core.graphics.drawable.toBitmap -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Lan -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.gatecontrol.android.R -import com.gatecontrol.android.ui.components.GcOutlineButton -import com.gatecontrol.android.ui.components.GcPrimaryButton -import com.gatecontrol.android.ui.components.GcSecondaryButton -import com.gatecontrol.android.ui.theme.GateControlTheme - -@Composable -fun SettingsScreen( - onNavigateToLogs: () -> Unit, - onNavigateToQrScanner: () -> Unit, - onNavigateToNetworkGroups: () -> Unit, - viewModel: SettingsViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - // File picker for .conf import - val filePickerLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> if (uri != null) viewModel.importConfigFromUri(context, uri) } - - val requestFilePicker by viewModel.requestFilePicker.collectAsStateWithLifecycle() - LaunchedEffect(requestFilePicker) { - if (requestFilePicker) { - viewModel.onFilePickerLaunched() - filePickerLauncher.launch(arrayOf("*/*")) +App unavailable in region | Claude + + + + + + + + + + + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + - Spacer(modifier = Modifier.height(20.dp)) - SectionDivider() + \ No newline at end of file diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt index 84fe50d..d9abd38 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt @@ -1,459 +1,3135 @@ -package com.gatecontrol.android.ui.settings - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.gatecontrol.android.R -import com.gatecontrol.android.data.LicenseRepository -import com.gatecontrol.android.data.SetupRepository -import com.gatecontrol.android.data.SettingsRepository -import com.gatecontrol.android.network.ApiClientProvider -import com.gatecontrol.android.tunnel.WgConfigValidator -import com.gatecontrol.android.network.UpdateCheckResponse -import com.gatecontrol.android.common.Validation -import org.json.JSONArray -import org.json.JSONObject -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber -import java.io.File -import javax.inject.Inject - -enum class ConnectionTestStatus { - Idle, Testing, Success, Failure -} - -data class SettingsUiState( - val theme: String = "dark", - val locale: String = "de", - val autoConnect: Boolean = false, - val killSwitch: Boolean = false, - val splitTunnelEnabled: Boolean = false, - val splitTunnelRoutes: String = "", - val splitTunnelApps: String = "", - val splitTunnelMode: String = "off", - val splitTunnelNetworks: List = emptyList(), - val splitTunnelAppsV2: List = emptyList(), - val splitTunnelAdminLocked: Boolean = false, - val checkInterval: Int = 30, - val configPollInterval: Int = 300, - val serverUrl: String = "", - val apiToken: String = "", - val connectionTestStatus: ConnectionTestStatus = ConnectionTestStatus.Idle, - val isLoading: Boolean = false, - val updateInfo: UpdateCheckResponse? = null, - val appVersion: String = "", - val error: String? = null, - val success: String? = null, - val isPro: Boolean = false, - val licenseStatus: String = "" -) - -@HiltViewModel -class SettingsViewModel @Inject constructor( - private val setupRepository: SetupRepository, - private val settingsRepository: SettingsRepository, - private val apiClientProvider: ApiClientProvider, - private val licenseRepository: LicenseRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(SettingsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadInitialState() - refreshLicense() - } - - private fun loadInitialState() { - viewModelScope.launch { - combine( - settingsRepository.getTheme(), - settingsRepository.getLocale(), - settingsRepository.getAutoConnect(), - settingsRepository.getKillSwitch(), - settingsRepository.getSplitTunnelEnabled() - ) { theme, locale, autoConnect, killSwitch, splitTunnelEnabled -> - _uiState.update { - it.copy( - theme = theme, - locale = locale, - autoConnect = autoConnect, - killSwitch = killSwitch, - splitTunnelEnabled = splitTunnelEnabled - ) - } - }.collect {} - } +App unavailable in region | Claude - viewModelScope.launch { - settingsRepository.getSplitTunnelRoutes().collect { routes -> - _uiState.update { it.copy(splitTunnelRoutes = routes) } - } - } + + + + + - viewModelScope.launch { - settingsRepository.getSplitTunnelApps().collect { apps -> - _uiState.update { it.copy(splitTunnelApps = apps) } - } - } + + - // V2 split-tunnel loading - viewModelScope.launch { - settingsRepository.migrateSplitTunnelIfNeeded() - settingsRepository.getSplitTunnelMode().collect { mode -> - _uiState.update { it.copy(splitTunnelMode = mode) } - } - } - viewModelScope.launch { - settingsRepository.getSplitTunnelNetworks().collect { json -> - val networks = parseSplitNetworksJson(json) - _uiState.update { it.copy(splitTunnelNetworks = networks) } - } - } - viewModelScope.launch { - settingsRepository.getSplitTunnelAppsV2().collect { json -> - val apps = parseSplitAppsJson(json) - _uiState.update { it.copy(splitTunnelAppsV2 = apps) } - } - } - viewModelScope.launch { - settingsRepository.getSplitTunnelAdminLocked().collect { locked -> - _uiState.update { it.copy(splitTunnelAdminLocked = locked) } - } + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file From 594e0f0091992a50d9cbefc6320674ec37148d9c Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:28:50 +0800 Subject: [PATCH 30/71] Add files via upload --- .../android/ui/vpn/VpnViewModel.kt | 3456 +++++++++++++++-- 1 file changed, 3088 insertions(+), 368 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index f926423..d9abd38 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -1,415 +1,3135 @@ -package com.gatecontrol.android.ui.vpn - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.gatecontrol.android.data.LicenseRepository -import com.gatecontrol.android.data.SettingsRepository -import com.gatecontrol.android.data.SetupRepository -import com.gatecontrol.android.network.ApiClientProvider -import com.gatecontrol.android.network.PermissionFlags -import com.gatecontrol.android.network.TrafficStats -import com.gatecontrol.android.network.VpnService -import com.gatecontrol.android.service.TunnelStateHolder -import com.gatecontrol.android.tunnel.SplitTunnelConfig -import com.gatecontrol.android.tunnel.TunnelManager -import com.gatecontrol.android.tunnel.TunnelMonitor -import com.gatecontrol.android.tunnel.TunnelState -import com.gatecontrol.android.tunnel.TunnelStats -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.json.JSONArray -import org.json.JSONObject -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class VpnViewModel @Inject constructor( - private val setupRepository: SetupRepository, - private val settingsRepository: SettingsRepository, - private val licenseRepository: LicenseRepository, - private val apiClientProvider: ApiClientProvider, - private val tunnelManager: TunnelManager, -) : ViewModel() { - - val tunnelState: StateFlow = tunnelManager.state - - private val _stats = MutableStateFlow(TunnelStats()) - val stats: StateFlow = _stats.asStateFlow() - - private val _trafficUsage = MutableStateFlow(null) - val trafficUsage: StateFlow = _trafficUsage.asStateFlow() - - private val _permissions = MutableStateFlow(PermissionFlags( - services = false, - traffic = false, - dns = false, - rdp = false, - )) - val permissions: StateFlow = _permissions.asStateFlow() - - private val _services = MutableStateFlow>(emptyList()) - val services: StateFlow> = _services.asStateFlow() - - val killSwitchEnabled: StateFlow = settingsRepository.getKillSwitch() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - - private var monitoringStarted = false - - /** Emits true when the stored token is invalid and the user should be - * redirected to the Setup screen. Observed by the UI layer. */ - private val _tokenInvalid = MutableStateFlow(false) - val tokenInvalid: StateFlow = _tokenInvalid.asStateFlow() - - /** Emits true when the peer was disabled on the server and the tunnel was disconnected. */ - private val _peerDisabled = MutableStateFlow(false) - val peerDisabled: StateFlow = _peerDisabled.asStateFlow() - - /** - * Validate the stored API token against the server via /client/ping. - * If the server returns 401 → token is expired/deleted → clear local - * config and signal the UI to redirect to the Setup screen. - * Network errors are ignored (offline mode — allow cached config). - */ - fun validateToken() { - val serverUrl = setupRepository.getServerUrl() - val token = setupRepository.getApiToken() - if (serverUrl.isEmpty() || token.isEmpty()) return - - viewModelScope.launch { - try { - val client = apiClientProvider.getClient(serverUrl) - client.ping() - // Token is valid — nothing to do - } catch (e: retrofit2.HttpException) { - if (e.code() == 401 || e.code() == 403) { - Timber.w("Token invalid (HTTP ${e.code()}) — clearing config, redirecting to setup") - setupRepository.clear() - apiClientProvider.invalidate() - _tokenInvalid.value = true - } - } catch (e: Exception) { - // Network error (timeout, DNS, etc.) — allow offline mode - Timber.d("Token validation skipped (offline): ${e.message}") - } +App unavailable in region | Claude + + + + + + + + + + + + + + + + + +
+ +

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
+ + + + + + + + + + + + + + \ No newline at end of file From 6fcffc4d042e65b97ce4476bee2066feb873f142 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:58:56 +0800 Subject: [PATCH 31/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20StealthConfig.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/tunnel/StealthConfig.kt | 3211 +---------------- 1 file changed, 81 insertions(+), 3130 deletions(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt index d9abd38..13e874e 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthConfig.kt @@ -1,3135 +1,86 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From 5687fe54358265c2724cf21e0991dfd18be5f939 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:59:31 +0800 Subject: [PATCH 32/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20StealthEngine.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/tunnel/StealthEngine.kt | 3309 +---------------- 1 file changed, 181 insertions(+), 3128 deletions(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt index d9abd38..35cd853 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/StealthEngine.kt @@ -1,3135 +1,188 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From 6c7379a217e4e928f79a94b4045210fba4dd3b9b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:00:49 +0800 Subject: [PATCH 33/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TunnelManager.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/tunnel/TunnelManager.kt | 3430 ++--------------- 1 file changed, 346 insertions(+), 3084 deletions(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index d9abd38..8ca69e0 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -1,3135 +1,397 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file + + companion object { + private const val TUNNEL_NAME = "gatecontrol" + private const val VPN_SUBNET = "10.8.0.0/24" + } +} From e10982d040835f0335efa799eab3ce3b414a6a33 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:02:33 +0800 Subject: [PATCH 34/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20SettingsRepository.k?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/data/SettingsRepository.kt | 3241 +---------------- 1 file changed, 188 insertions(+), 3053 deletions(-) diff --git a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt index d9abd38..7a8efba 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt @@ -1,3135 +1,270 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
+ fun getSplitTunnelAppsV2(): Flow = + dataStore.data.map { it[SPLIT_TUNNEL_APPS_V2] ?: "[]" } -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From 94106298d9057165396d0d147ec0fa8bd378d859 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:03:48 +0800 Subject: [PATCH 35/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20SettingsViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/settings/SettingsViewModel.kt | 3562 +++-------------- 1 file changed, 523 insertions(+), 3039 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt index d9abd38..b8ffd42 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt @@ -1,3135 +1,619 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From ffc0ff7fc7259f413661f4a585a0b52cc7b3e9aa Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:04:27 +0800 Subject: [PATCH 36/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20SettingsScreen.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/settings/SettingsScreen.kt | 3868 ++++------------- 1 file changed, 789 insertions(+), 3079 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index d9abd38..3cb97a7 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -1,3135 +1,845 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From 302d29786550c660a1eab015c7290b41f95a86cc Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:05:08 +0800 Subject: [PATCH 37/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt | 3473 ++--------------- 1 file changed, 391 insertions(+), 3082 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index d9abd38..94659d9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -1,3135 +1,444 @@ -App unavailable in region | Claude - - - - - - - - - - - - - - - - - -
- -

App unavailable

Unfortunately, Claude is only available in certain regions right now. Please contact support if you think you’re getting this message in error.

View supported countries
- - - - - - - - - - - - - - \ No newline at end of file +} From 31291a7d41786596b0589651d1ca1c0f92b2ba92 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:15:57 +0800 Subject: [PATCH 38/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 068ea15..5e950f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,9 @@ The server requires credentials. Enter them to continue. +Save +Testing… +Connection failed Settings Server Server URL From e9eebb77591dcf0c795f4ac55dcb834c3f519b30 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:17:01 +0800 Subject: [PATCH 39/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e950f3..9e278f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,9 +105,9 @@ The server requires credentials. Enter them to continue. -Save -Testing… -Connection failed + Save + Testing… + Connection failed Settings Server Server URL From 63357292a8c39c22733f522368689c1f8134e17b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:30:08 +0800 Subject: [PATCH 40/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-de/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b9dbdab..2c6c208 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -105,6 +105,9 @@ Der Server benötigt Zugangsdaten. Gib sie ein, um fortzufahren. + Speichern + Teste… + Verbindung fehlgeschlagen Einstellungen Server Server-URL From 108be90114e78a437ff7362abc81000395e3d588 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:32:14 +0800 Subject: [PATCH 41/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-zh/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b395919..4834467 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -136,6 +136,9 @@ 添加应用 推荐预设 无线连接时可能会导致 VPN 问题 + 保存 + 测试中… + 连接失败 保存路由 路由已保存 导入配置 From af01cd8e377d01ad70c863a5da7f89a241feeb2c Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:47:03 +0800 Subject: [PATCH 42/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt | 584 ++++++------------ 1 file changed, 180 insertions(+), 404 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index 94659d9..d392d15 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -1,444 +1,220 @@ package com.gatecontrol.android.ui.vpn -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import app.cash.turbine.test import com.gatecontrol.android.data.LicenseRepository import com.gatecontrol.android.data.SettingsRepository import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClient import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.PermissionFlags -import com.gatecontrol.android.network.TrafficStats -import com.gatecontrol.android.network.VpnService -import com.gatecontrol.android.service.TunnelStateHolder -import com.gatecontrol.android.tunnel.SplitTunnelConfig -import com.gatecontrol.android.tunnel.StealthConfig +import com.gatecontrol.android.network.PermissionsResponse +import com.gatecontrol.android.network.SplitTunnelPresetResponse import com.gatecontrol.android.tunnel.TunnelManager -import com.gatecontrol.android.tunnel.TunnelMonitor import com.gatecontrol.android.tunnel.TunnelState -import com.gatecontrol.android.tunnel.TunnelStats -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.json.JSONArray -import org.json.JSONObject -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class VpnViewModel @Inject constructor( - private val setupRepository: SetupRepository, - private val settingsRepository: SettingsRepository, - private val licenseRepository: LicenseRepository, - private val apiClientProvider: ApiClientProvider, - private val tunnelManager: TunnelManager, -) : ViewModel() { - - val tunnelState: StateFlow = tunnelManager.state - - private val _stats = MutableStateFlow(TunnelStats()) - val stats: StateFlow = _stats.asStateFlow() - - private val _trafficUsage = MutableStateFlow(null) - val trafficUsage: StateFlow = _trafficUsage.asStateFlow() - - private val _permissions = MutableStateFlow(PermissionFlags( - services = false, - traffic = false, - dns = false, - rdp = false, - )) - val permissions: StateFlow = _permissions.asStateFlow() - - private val _services = MutableStateFlow>(emptyList()) - val services: StateFlow> = _services.asStateFlow() - - val killSwitchEnabled: StateFlow = settingsRepository.getKillSwitch() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - - private var monitoringStarted = false - - private val _tokenInvalid = MutableStateFlow(false) - val tokenInvalid: StateFlow = _tokenInvalid.asStateFlow() - - private val _peerDisabled = MutableStateFlow(false) - val peerDisabled: StateFlow = _peerDisabled.asStateFlow() - - fun validateToken() { - val serverUrl = setupRepository.getServerUrl() - val token = setupRepository.getApiToken() - if (serverUrl.isEmpty() || token.isEmpty()) return - - viewModelScope.launch { - try { - val client = apiClientProvider.getClient(serverUrl) - client.ping() - } catch (e: retrofit2.HttpException) { - if (e.code() == 401 || e.code() == 403) { - Timber.w("Token invalid (HTTP ${e.code()}) — clearing config, redirecting to setup") - setupRepository.clear() - apiClientProvider.invalidate() - _tokenInvalid.value = true - } - } catch (e: Exception) { - Timber.d("Token validation skipped (offline): ${e.message}") - } - } +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class VpnViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var setupRepository: SetupRepository + private lateinit var settingsRepository: SettingsRepository + private lateinit var licenseRepository: LicenseRepository + private lateinit var apiClientProvider: ApiClientProvider + private lateinit var apiClient: ApiClient + private lateinit var tunnelManager: TunnelManager + private lateinit var viewModel: VpnViewModel + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + + setupRepository = mockk(relaxed = true) + settingsRepository = mockk(relaxed = true) + licenseRepository = mockk(relaxed = true) + apiClientProvider = mockk(relaxed = true) + apiClient = mockk(relaxed = true) + tunnelManager = mockk(relaxed = true) + + every { settingsRepository.getKillSwitch() } returns flowOf(false) + every { settingsRepository.getSplitTunnelEnabled() } returns flowOf(false) + every { settingsRepository.getSplitTunnelRoutes() } returns flowOf("") + every { settingsRepository.getSplitTunnelApps() } returns flowOf("") + every { settingsRepository.getSplitTunnelMode() } returns flowOf("off") + every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") + every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") + every { settingsRepository.getStealthPortHopping() } returns flowOf(false) + every { settingsRepository.getStealthTimingJitter() } returns flowOf(false) + every { settingsRepository.getStealthPacketPadding() } returns flowOf(false) + every { settingsRepository.getStealthPaddingMtu() } returns flowOf(1420) + every { settingsRepository.getStealthKeepaliveRandom() } returns flowOf(false) + every { settingsRepository.getStealthKeepaliveJitterSec() } returns flowOf(0) + every { settingsRepository.getStealthDecoyDns() } returns flowOf(false) + every { settingsRepository.getStealthAutoReconnect() } returns flowOf(false) + every { settingsRepository.getStealthCandidatePorts() } returns flowOf(emptyList()) + every { settingsRepository.getStealthJitterMinMs() } returns flowOf(0) + every { settingsRepository.getStealthJitterMaxMs() } returns flowOf(0) + every { setupRepository.getServerUrl() } returns "https://gate.example.com" + every { setupRepository.getPeerId() } returns 42 + coEvery { apiClient.getSplitTunnelPreset() } returns SplitTunnelPresetResponse( + ok = true, mode = "off", networks = emptyList(), locked = false, source = "none" + ) + every { apiClientProvider.getClient(any()) } returns apiClient + every { tunnelManager.state } returns kotlinx.coroutines.flow.MutableStateFlow(TunnelState.Disconnected) + every { tunnelManager.stats } returns kotlinx.coroutines.flow.MutableStateFlow(com.gatecontrol.android.tunnel.TunnelStats()) + + viewModel = VpnViewModel( + setupRepository = setupRepository, + settingsRepository = settingsRepository, + licenseRepository = licenseRepository, + apiClientProvider = apiClientProvider, + tunnelManager = tunnelManager, + ) } - fun startMonitoring() { - if (monitoringStarted) return - monitoringStarted = true - - viewModelScope.launch { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isNotEmpty()) { - try { - val host = java.net.URI(serverUrl).host - if (host != null) apiClientProvider.preResolveDns(host) - } catch (_: Exception) {} - } - } - - viewModelScope.launch { - tunnelManager.state.collect { state -> - TunnelStateHolder.isConnected = state is TunnelState.Connected - TunnelStateHolder.serverHost = serverHost - } - } - viewModelScope.launch { - while (isActive) { - delay(1_000) - if (tunnelState.value is TunnelState.Connected) { - tunnelManager.getStatistics()?.let { _stats.value = it } - } - } - } - - // 周期性检查握手是否超时,若超时且启用了 autoReconnectOnBlock 则触发端口跳变 - viewModelScope.launch { - while (isActive) { - delay(45_000) - if (tunnelState.value is TunnelState.Connected) { - val stats = tunnelManager.getStatistics() - val handshakeStale = stats == null || - TunnelMonitor.isHandshakeStale(stats.lastHandshakeEpoch, 180L) - if (handshakeStale) { - Timber.w("VpnViewModel: handshake stale — triggering port-hop reconnect") - tunnelManager.reconnectWithPortHop() - } - } - } - } - - viewModelScope.launch { - while (isActive) { - delay(60_000) - if (tunnelState.value is TunnelState.Connected) { - checkPeerEnabled() - } - } - } + @AfterEach + fun tearDown() { + Dispatchers.resetMain() } - private suspend fun checkPeerEnabled() { - try { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isEmpty()) return - val peerId = setupRepository.getPeerId() - if (peerId <= 0) return - val client = apiClientProvider.getClient(serverUrl) - val response = client.getPeerInfo(peerId) - if (response.ok && !response.peer.enabled) { - Timber.w("Peer disabled on server (id=$peerId) — disconnecting tunnel") - tunnelManager.disconnect() - _stats.value = TunnelStats() - apiClientProvider.clearDnsCache() - _peerDisabled.value = true - } - } catch (e: Exception) { - Timber.d("Peer status check failed (offline): ${e.message}") - } - } + // --- State tests --- - // ── 连接动作 ────────────────────────────────────────────────────────── - - fun connect() { - viewModelScope.launch { - val config = setupRepository.getWireGuardConfig() - if (config.isEmpty()) { - Timber.w("VpnViewModel: no WireGuard config available") - return@launch - } - - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isNotEmpty()) { - try { - val host = java.net.URI(serverUrl).host - if (host != null) apiClientProvider.preResolveDns(host) - } catch (_: Exception) {} - } - - // 加载分流配置 - var splitTunnelConfig = SplitTunnelConfig() - try { - var adminPresetActive = false - if (serverUrl.isNotEmpty()) { - try { - val client = apiClientProvider.getClient(serverUrl) - val preset = client.getSplitTunnelPreset() - if (preset.ok && preset.mode != "off" && preset.source != "none") { - settingsRepository.setSplitTunnelMode(preset.mode) - val arr = JSONArray() - preset.networks.forEach { arr.put(JSONObject().put("cidr", it.cidr).put("label", it.label)) } - settingsRepository.setSplitTunnelNetworks(arr.toString()) - settingsRepository.setSplitTunnelAdminLocked(preset.locked) - adminPresetActive = true - - val userApps = settingsRepository.getSplitTunnelAppsV2().first() - val appsList = parseSplitAppsJson(userApps) - - splitTunnelConfig = SplitTunnelConfig( - mode = preset.mode, - networks = preset.networks.map { it.cidr }, - apps = appsList, - ) - } - } catch (e: Exception) { - Timber.w(e, "Split-tunnel preset fetch failed") - } - } - - if (!adminPresetActive) { - val mode = settingsRepository.getSplitTunnelMode().first() - if (mode != "off") { - val networksJson = settingsRepository.getSplitTunnelNetworks().first() - val appsJson = settingsRepository.getSplitTunnelAppsV2().first() - splitTunnelConfig = SplitTunnelConfig( - mode = mode, - networks = parseSplitNetworksJsonToCidrs(networksJson), - apps = parseSplitAppsJson(appsJson), - ) - } - } - } catch (e: Exception) { - Timber.w(e, "Split-tunnel config load failed") - } - - // ── 加载防检测配置 ──────────────────────────────────────────── - val stealthConfig = loadStealthConfig() - Timber.d( - "VpnViewModel: stealth config: portHop=%b jitter=%b padding=%b keepalive=%b decoy=%b autoReconnect=%b", - stealthConfig.portHoppingEnabled, - stealthConfig.timingJitterEnabled, - stealthConfig.packetPaddingEnabled, - stealthConfig.keepaliveRandomEnabled, - stealthConfig.decoyDnsEnabled, - stealthConfig.autoReconnectOnBlock, - ) - // ───────────────────────────────────────────────────────────── - - try { - tunnelManager.connect(config, splitTunnelConfig, stealthConfig) - Timber.d("VpnViewModel: tunnel connect requested") - reportDeviceHostname(serverUrl) - } catch (e: Exception) { - Timber.e(e, "VpnViewModel: connect failed") - } + @Test + fun `initial state is Disconnected`() = runTest { + viewModel.tunnelState.test { + assertInstanceOf(TunnelState.Disconnected::class.java, awaitItem()) + cancelAndIgnoreRemainingEvents() } } - /** - * 从 SettingsRepository 读取所有防检测开关,组装为 [StealthConfig]。 - */ - private suspend fun loadStealthConfig(): StealthConfig { - return try { - val portHopping = settingsRepository.getStealthPortHopping().first() - val timingJitter = settingsRepository.getStealthTimingJitter().first() - val packetPadding = settingsRepository.getStealthPacketPadding().first() - val paddingMtu = settingsRepository.getStealthPaddingMtu().first() - val keepaliveRandom = settingsRepository.getStealthKeepaliveRandom().first() - val keepaliveJitter = settingsRepository.getStealthKeepaliveJitterSec().first() - val decoyDns = settingsRepository.getStealthDecoyDns().first() - val autoReconnect = settingsRepository.getStealthAutoReconnect().first() - val candidatePorts = settingsRepository.getStealthCandidatePorts().first() - val jitterMin = settingsRepository.getStealthJitterMinMs().first() - val jitterMax = settingsRepository.getStealthJitterMaxMs().first() - - StealthConfig( - portHoppingEnabled = portHopping, - candidatePorts = candidatePorts, - timingJitterEnabled = timingJitter, - jitterMinMs = jitterMin.toLong(), - jitterMaxMs = jitterMax.toLong(), - packetPaddingEnabled = packetPadding, - paddingTargetMtu = paddingMtu, - keepaliveRandomEnabled = keepaliveRandom, - keepaliveJitterSec = keepaliveJitter, - decoyDnsEnabled = decoyDns, - autoReconnectOnBlock = autoReconnect, - ) - } catch (e: Exception) { - Timber.w(e, "VpnViewModel: failed to load stealth config, using defaults (all off)") - StealthConfig() - } - } + @Test + fun `connect calls tunnelManager with config`() = runTest { + every { setupRepository.getWireGuardConfig() } returns "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz\nEndpoint=1.2.3.4:51820\nAllowedIPs=0.0.0.0/0" - private suspend fun reportDeviceHostname(serverUrl: String) { - try { - val sanitized = com.gatecontrol.android.common.HostnameSanitizer.sanitize(android.os.Build.MODEL) - if (sanitized.isNullOrBlank()) return + viewModel.connect() + testDispatcher.scheduler.advanceUntilIdle() - val client = apiClientProvider.getClient(serverUrl) - val response = client.reportHostname( - com.gatecontrol.android.network.HostnameReportRequest(sanitized) - ) - Timber.d("Hostname report: assigned=${response.assigned} changed=${response.changed}") - } catch (e: Exception) { - Timber.d(e, "Hostname report skipped: ${e.message}") - } + coVerify { tunnelManager.connect(any(), any(), any()) } } - fun disconnect() { - viewModelScope.launch { - try { - tunnelManager.disconnect() - _stats.value = TunnelStats() - apiClientProvider.clearDnsCache() - Timber.d("VpnViewModel: tunnel disconnected, DNS cache cleared") - } catch (e: Exception) { - Timber.e(e, "VpnViewModel: disconnect failed") - } - } - } + @Test + fun `connect does nothing when config is empty`() = runTest { + every { setupRepository.getWireGuardConfig() } returns "" - fun toggleKillSwitch(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setKillSwitch(enabled) - Timber.d("VpnViewModel: kill-switch set to $enabled") - } - } + viewModel.connect() + testDispatcher.scheduler.advanceUntilIdle() - fun loadTrafficStats() { - viewModelScope.launch { - try { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isEmpty()) return@launch - val client = apiClientProvider.getClient(serverUrl) - val peerId = setupRepository.getPeerId() - if (peerId <= 0) return@launch - val response = client.getTraffic(peerId) - if (response.ok) { - _trafficUsage.value = response.traffic - } - } catch (e: Exception) { - Timber.w(e, "VpnViewModel: failed to load traffic stats") - } - } + coVerify(exactly = 0) { tunnelManager.connect(any(), any(), any()) } } - fun loadServices() { - viewModelScope.launch { - try { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isEmpty()) return@launch - val client = apiClientProvider.getClient(serverUrl) - val response = client.getServices() - if (response.ok) { - _services.value = response.services - } - } catch (e: Exception) { - Timber.w(e, "VpnViewModel: failed to load services") - } - } + @Test + fun `disconnect calls tunnelManager disconnect`() = runTest { + viewModel.disconnect() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { tunnelManager.disconnect() } } - val serverHost: String? - get() { - val config = setupRepository.getWireGuardConfig() - if (config.isEmpty()) return null - return try { - com.gatecontrol.android.tunnel.TunnelConfig.parse(config).getServerHost() - } catch (_: Exception) { - null - } - } + @Test + fun `toggleKillSwitch saves to settings`() = runTest { + viewModel.toggleKillSwitch(true) + testDispatcher.scheduler.advanceUntilIdle() - fun runDnsLeakTest(onResult: (String) -> Unit) { - viewModelScope.launch { - try { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isEmpty()) { - onResult("No server configured") - return@launch - } - val client = apiClientProvider.getClient(serverUrl) - val response = client.dnsCheck() - if (response.ok) { - onResult("DNS: ${response.vpnDns} (Subnet: ${response.vpnSubnet})") - } else { - onResult("DNS check failed") - } - } catch (e: Exception) { - Timber.w(e, "VpnViewModel: DNS leak test failed") - onResult("DNS test error: ${e.localizedMessage}") - } - } + coVerify { settingsRepository.setKillSwitch(true) } } - fun invalidateApiClients() { - apiClientProvider.invalidate() + @Test + fun `toggleKillSwitch false saves false to settings`() = runTest { + viewModel.toggleKillSwitch(false) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { settingsRepository.setKillSwitch(false) } } - fun loadPermissions() { - viewModelScope.launch { - try { - val serverUrl = setupRepository.getServerUrl() - if (serverUrl.isEmpty()) return@launch - val client = apiClientProvider.getClient(serverUrl) - val response = client.getPermissions() - if (response.ok) { - val flags = response.permissions - _permissions.value = flags - licenseRepository.updatePermissions( - services = flags.services, - traffic = flags.traffic, - dns = flags.dns, - rdp = flags.rdp, - ) - } - } catch (e: Exception) { - Timber.w(e, "VpnViewModel: failed to load permissions") - } + @Test + fun `loadPermissions updates license repository`() = runTest { + val flags = PermissionFlags( + services = true, + traffic = true, + dns = false, + rdp = true, + ) + coEvery { apiClient.getPermissions() } returns PermissionsResponse( + ok = true, + permissions = flags, + scopes = listOf("services", "traffic", "rdp"), + ) + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + verify { + licenseRepository.updatePermissions( + services = true, + traffic = true, + dns = false, + rdp = true, + ) } } - private fun parseSplitNetworksJsonToCidrs(json: String): List { - if (json.isBlank() || json == "[]") return emptyList() - return try { - val arr = JSONArray(json) - (0 until arr.length()).map { arr.getJSONObject(it).getString("cidr") } - } catch (e: Exception) { - Timber.w(e, "Failed to parse split-tunnel networks JSON, falling back to empty") - emptyList() + @Test + fun `loadPermissions updates permissions state flow`() = runTest { + val flags = PermissionFlags( + services = true, + traffic = false, + dns = true, + rdp = false, + ) + coEvery { apiClient.getPermissions() } returns PermissionsResponse( + ok = true, + permissions = flags, + scopes = listOf("services", "dns"), + ) + + viewModel.permissions.test { + val initial = awaitItem() + assertFalse(initial.services) + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + val updated = awaitItem() + assertTrue(updated.services) + assertTrue(updated.dns) + assertFalse(updated.traffic) + assertFalse(updated.rdp) + + cancelAndIgnoreRemainingEvents() } } - private fun parseSplitAppsJson(json: String): List { - if (json.isBlank() || json == "[]") return emptyList() - return try { - val arr = JSONArray(json) - (0 until arr.length()).map { arr.getJSONObject(it).getString("package") } - } catch (e: Exception) { - Timber.w(e, "Failed to parse split-tunnel apps JSON, falling back to empty") - emptyList() - } + @Test + fun `loadPermissions does nothing when server URL is empty`() = runTest { + every { setupRepository.getServerUrl() } returns "" + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { apiClient.getPermissions() } } } From 9512af95e257f7ef660934cfb9575476586789e1 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:52:29 +0800 Subject: [PATCH 43/71] Delete app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt --- .../android/ui/vpn/VpnViewModel.kt | 220 ------------------ 1 file changed, 220 deletions(-) delete mode 100644 app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt deleted file mode 100644 index d392d15..0000000 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.gatecontrol.android.ui.vpn - -import app.cash.turbine.test -import com.gatecontrol.android.data.LicenseRepository -import com.gatecontrol.android.data.SettingsRepository -import com.gatecontrol.android.data.SetupRepository -import com.gatecontrol.android.network.ApiClient -import com.gatecontrol.android.network.ApiClientProvider -import com.gatecontrol.android.network.PermissionFlags -import com.gatecontrol.android.network.PermissionsResponse -import com.gatecontrol.android.network.SplitTunnelPresetResponse -import com.gatecontrol.android.tunnel.TunnelManager -import com.gatecontrol.android.tunnel.TunnelState -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertInstanceOf -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class VpnViewModelTest { - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var setupRepository: SetupRepository - private lateinit var settingsRepository: SettingsRepository - private lateinit var licenseRepository: LicenseRepository - private lateinit var apiClientProvider: ApiClientProvider - private lateinit var apiClient: ApiClient - private lateinit var tunnelManager: TunnelManager - private lateinit var viewModel: VpnViewModel - - @BeforeEach - fun setUp() { - Dispatchers.setMain(testDispatcher) - - setupRepository = mockk(relaxed = true) - settingsRepository = mockk(relaxed = true) - licenseRepository = mockk(relaxed = true) - apiClientProvider = mockk(relaxed = true) - apiClient = mockk(relaxed = true) - tunnelManager = mockk(relaxed = true) - - every { settingsRepository.getKillSwitch() } returns flowOf(false) - every { settingsRepository.getSplitTunnelEnabled() } returns flowOf(false) - every { settingsRepository.getSplitTunnelRoutes() } returns flowOf("") - every { settingsRepository.getSplitTunnelApps() } returns flowOf("") - every { settingsRepository.getSplitTunnelMode() } returns flowOf("off") - every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") - every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") - every { settingsRepository.getStealthPortHopping() } returns flowOf(false) - every { settingsRepository.getStealthTimingJitter() } returns flowOf(false) - every { settingsRepository.getStealthPacketPadding() } returns flowOf(false) - every { settingsRepository.getStealthPaddingMtu() } returns flowOf(1420) - every { settingsRepository.getStealthKeepaliveRandom() } returns flowOf(false) - every { settingsRepository.getStealthKeepaliveJitterSec() } returns flowOf(0) - every { settingsRepository.getStealthDecoyDns() } returns flowOf(false) - every { settingsRepository.getStealthAutoReconnect() } returns flowOf(false) - every { settingsRepository.getStealthCandidatePorts() } returns flowOf(emptyList()) - every { settingsRepository.getStealthJitterMinMs() } returns flowOf(0) - every { settingsRepository.getStealthJitterMaxMs() } returns flowOf(0) - every { setupRepository.getServerUrl() } returns "https://gate.example.com" - every { setupRepository.getPeerId() } returns 42 - coEvery { apiClient.getSplitTunnelPreset() } returns SplitTunnelPresetResponse( - ok = true, mode = "off", networks = emptyList(), locked = false, source = "none" - ) - every { apiClientProvider.getClient(any()) } returns apiClient - every { tunnelManager.state } returns kotlinx.coroutines.flow.MutableStateFlow(TunnelState.Disconnected) - every { tunnelManager.stats } returns kotlinx.coroutines.flow.MutableStateFlow(com.gatecontrol.android.tunnel.TunnelStats()) - - viewModel = VpnViewModel( - setupRepository = setupRepository, - settingsRepository = settingsRepository, - licenseRepository = licenseRepository, - apiClientProvider = apiClientProvider, - tunnelManager = tunnelManager, - ) - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - // --- State tests --- - - @Test - fun `initial state is Disconnected`() = runTest { - viewModel.tunnelState.test { - assertInstanceOf(TunnelState.Disconnected::class.java, awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `connect calls tunnelManager with config`() = runTest { - every { setupRepository.getWireGuardConfig() } returns "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz\nEndpoint=1.2.3.4:51820\nAllowedIPs=0.0.0.0/0" - - viewModel.connect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { tunnelManager.connect(any(), any(), any()) } - } - - @Test - fun `connect does nothing when config is empty`() = runTest { - every { setupRepository.getWireGuardConfig() } returns "" - - viewModel.connect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 0) { tunnelManager.connect(any(), any(), any()) } - } - - @Test - fun `disconnect calls tunnelManager disconnect`() = runTest { - viewModel.disconnect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { tunnelManager.disconnect() } - } - - @Test - fun `toggleKillSwitch saves to settings`() = runTest { - viewModel.toggleKillSwitch(true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { settingsRepository.setKillSwitch(true) } - } - - @Test - fun `toggleKillSwitch false saves false to settings`() = runTest { - viewModel.toggleKillSwitch(false) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { settingsRepository.setKillSwitch(false) } - } - - @Test - fun `loadPermissions updates license repository`() = runTest { - val flags = PermissionFlags( - services = true, - traffic = true, - dns = false, - rdp = true, - ) - coEvery { apiClient.getPermissions() } returns PermissionsResponse( - ok = true, - permissions = flags, - scopes = listOf("services", "traffic", "rdp"), - ) - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - verify { - licenseRepository.updatePermissions( - services = true, - traffic = true, - dns = false, - rdp = true, - ) - } - } - - @Test - fun `loadPermissions updates permissions state flow`() = runTest { - val flags = PermissionFlags( - services = true, - traffic = false, - dns = true, - rdp = false, - ) - coEvery { apiClient.getPermissions() } returns PermissionsResponse( - ok = true, - permissions = flags, - scopes = listOf("services", "dns"), - ) - - viewModel.permissions.test { - val initial = awaitItem() - assertFalse(initial.services) - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - val updated = awaitItem() - assertTrue(updated.services) - assertTrue(updated.dns) - assertFalse(updated.traffic) - assertFalse(updated.rdp) - - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `loadPermissions does nothing when server URL is empty`() = runTest { - every { setupRepository.getServerUrl() } returns "" - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 0) { apiClient.getPermissions() } - } -} From f03f77b1c17beb0fd8925c5a58ac52b35621ef1f Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:53:54 +0800 Subject: [PATCH 44/71] Add files via upload --- .../android/ui/vpn/VpnViewModel.kt.txt | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt new file mode 100644 index 0000000..d392d15 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt @@ -0,0 +1,220 @@ +package com.gatecontrol.android.ui.vpn + +import app.cash.turbine.test +import com.gatecontrol.android.data.LicenseRepository +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClient +import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.network.PermissionFlags +import com.gatecontrol.android.network.PermissionsResponse +import com.gatecontrol.android.network.SplitTunnelPresetResponse +import com.gatecontrol.android.tunnel.TunnelManager +import com.gatecontrol.android.tunnel.TunnelState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class VpnViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var setupRepository: SetupRepository + private lateinit var settingsRepository: SettingsRepository + private lateinit var licenseRepository: LicenseRepository + private lateinit var apiClientProvider: ApiClientProvider + private lateinit var apiClient: ApiClient + private lateinit var tunnelManager: TunnelManager + private lateinit var viewModel: VpnViewModel + + @BeforeEach + fun setUp() { + Dispatchers.setMain(testDispatcher) + + setupRepository = mockk(relaxed = true) + settingsRepository = mockk(relaxed = true) + licenseRepository = mockk(relaxed = true) + apiClientProvider = mockk(relaxed = true) + apiClient = mockk(relaxed = true) + tunnelManager = mockk(relaxed = true) + + every { settingsRepository.getKillSwitch() } returns flowOf(false) + every { settingsRepository.getSplitTunnelEnabled() } returns flowOf(false) + every { settingsRepository.getSplitTunnelRoutes() } returns flowOf("") + every { settingsRepository.getSplitTunnelApps() } returns flowOf("") + every { settingsRepository.getSplitTunnelMode() } returns flowOf("off") + every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") + every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") + every { settingsRepository.getStealthPortHopping() } returns flowOf(false) + every { settingsRepository.getStealthTimingJitter() } returns flowOf(false) + every { settingsRepository.getStealthPacketPadding() } returns flowOf(false) + every { settingsRepository.getStealthPaddingMtu() } returns flowOf(1420) + every { settingsRepository.getStealthKeepaliveRandom() } returns flowOf(false) + every { settingsRepository.getStealthKeepaliveJitterSec() } returns flowOf(0) + every { settingsRepository.getStealthDecoyDns() } returns flowOf(false) + every { settingsRepository.getStealthAutoReconnect() } returns flowOf(false) + every { settingsRepository.getStealthCandidatePorts() } returns flowOf(emptyList()) + every { settingsRepository.getStealthJitterMinMs() } returns flowOf(0) + every { settingsRepository.getStealthJitterMaxMs() } returns flowOf(0) + every { setupRepository.getServerUrl() } returns "https://gate.example.com" + every { setupRepository.getPeerId() } returns 42 + coEvery { apiClient.getSplitTunnelPreset() } returns SplitTunnelPresetResponse( + ok = true, mode = "off", networks = emptyList(), locked = false, source = "none" + ) + every { apiClientProvider.getClient(any()) } returns apiClient + every { tunnelManager.state } returns kotlinx.coroutines.flow.MutableStateFlow(TunnelState.Disconnected) + every { tunnelManager.stats } returns kotlinx.coroutines.flow.MutableStateFlow(com.gatecontrol.android.tunnel.TunnelStats()) + + viewModel = VpnViewModel( + setupRepository = setupRepository, + settingsRepository = settingsRepository, + licenseRepository = licenseRepository, + apiClientProvider = apiClientProvider, + tunnelManager = tunnelManager, + ) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + // --- State tests --- + + @Test + fun `initial state is Disconnected`() = runTest { + viewModel.tunnelState.test { + assertInstanceOf(TunnelState.Disconnected::class.java, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `connect calls tunnelManager with config`() = runTest { + every { setupRepository.getWireGuardConfig() } returns "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz\nEndpoint=1.2.3.4:51820\nAllowedIPs=0.0.0.0/0" + + viewModel.connect() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { tunnelManager.connect(any(), any(), any()) } + } + + @Test + fun `connect does nothing when config is empty`() = runTest { + every { setupRepository.getWireGuardConfig() } returns "" + + viewModel.connect() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { tunnelManager.connect(any(), any(), any()) } + } + + @Test + fun `disconnect calls tunnelManager disconnect`() = runTest { + viewModel.disconnect() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { tunnelManager.disconnect() } + } + + @Test + fun `toggleKillSwitch saves to settings`() = runTest { + viewModel.toggleKillSwitch(true) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { settingsRepository.setKillSwitch(true) } + } + + @Test + fun `toggleKillSwitch false saves false to settings`() = runTest { + viewModel.toggleKillSwitch(false) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { settingsRepository.setKillSwitch(false) } + } + + @Test + fun `loadPermissions updates license repository`() = runTest { + val flags = PermissionFlags( + services = true, + traffic = true, + dns = false, + rdp = true, + ) + coEvery { apiClient.getPermissions() } returns PermissionsResponse( + ok = true, + permissions = flags, + scopes = listOf("services", "traffic", "rdp"), + ) + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + verify { + licenseRepository.updatePermissions( + services = true, + traffic = true, + dns = false, + rdp = true, + ) + } + } + + @Test + fun `loadPermissions updates permissions state flow`() = runTest { + val flags = PermissionFlags( + services = true, + traffic = false, + dns = true, + rdp = false, + ) + coEvery { apiClient.getPermissions() } returns PermissionsResponse( + ok = true, + permissions = flags, + scopes = listOf("services", "dns"), + ) + + viewModel.permissions.test { + val initial = awaitItem() + assertFalse(initial.services) + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + val updated = awaitItem() + assertTrue(updated.services) + assertTrue(updated.dns) + assertFalse(updated.traffic) + assertFalse(updated.rdp) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadPermissions does nothing when server URL is empty`() = runTest { + every { setupRepository.getServerUrl() } returns "" + + viewModel.loadPermissions() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { apiClient.getPermissions() } + } +} From 09f14f09ab70d7cceb58c23cc6781ab05a9ab0f1 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:56:49 +0800 Subject: [PATCH 45/71] =?UTF-8?q?=E5=88=9B=E5=BB=BA=20VpnViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt new file mode 100644 index 0000000..94659d9 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -0,0 +1,444 @@ +package com.gatecontrol.android.ui.vpn + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.data.LicenseRepository +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.network.PermissionFlags +import com.gatecontrol.android.network.TrafficStats +import com.gatecontrol.android.network.VpnService +import com.gatecontrol.android.service.TunnelStateHolder +import com.gatecontrol.android.tunnel.SplitTunnelConfig +import com.gatecontrol.android.tunnel.StealthConfig +import com.gatecontrol.android.tunnel.TunnelManager +import com.gatecontrol.android.tunnel.TunnelMonitor +import com.gatecontrol.android.tunnel.TunnelState +import com.gatecontrol.android.tunnel.TunnelStats +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class VpnViewModel @Inject constructor( + private val setupRepository: SetupRepository, + private val settingsRepository: SettingsRepository, + private val licenseRepository: LicenseRepository, + private val apiClientProvider: ApiClientProvider, + private val tunnelManager: TunnelManager, +) : ViewModel() { + + val tunnelState: StateFlow = tunnelManager.state + + private val _stats = MutableStateFlow(TunnelStats()) + val stats: StateFlow = _stats.asStateFlow() + + private val _trafficUsage = MutableStateFlow(null) + val trafficUsage: StateFlow = _trafficUsage.asStateFlow() + + private val _permissions = MutableStateFlow(PermissionFlags( + services = false, + traffic = false, + dns = false, + rdp = false, + )) + val permissions: StateFlow = _permissions.asStateFlow() + + private val _services = MutableStateFlow>(emptyList()) + val services: StateFlow> = _services.asStateFlow() + + val killSwitchEnabled: StateFlow = settingsRepository.getKillSwitch() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private var monitoringStarted = false + + private val _tokenInvalid = MutableStateFlow(false) + val tokenInvalid: StateFlow = _tokenInvalid.asStateFlow() + + private val _peerDisabled = MutableStateFlow(false) + val peerDisabled: StateFlow = _peerDisabled.asStateFlow() + + fun validateToken() { + val serverUrl = setupRepository.getServerUrl() + val token = setupRepository.getApiToken() + if (serverUrl.isEmpty() || token.isEmpty()) return + + viewModelScope.launch { + try { + val client = apiClientProvider.getClient(serverUrl) + client.ping() + } catch (e: retrofit2.HttpException) { + if (e.code() == 401 || e.code() == 403) { + Timber.w("Token invalid (HTTP ${e.code()}) — clearing config, redirecting to setup") + setupRepository.clear() + apiClientProvider.invalidate() + _tokenInvalid.value = true + } + } catch (e: Exception) { + Timber.d("Token validation skipped (offline): ${e.message}") + } + } + } + + fun startMonitoring() { + if (monitoringStarted) return + monitoringStarted = true + + viewModelScope.launch { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isNotEmpty()) { + try { + val host = java.net.URI(serverUrl).host + if (host != null) apiClientProvider.preResolveDns(host) + } catch (_: Exception) {} + } + } + + viewModelScope.launch { + tunnelManager.state.collect { state -> + TunnelStateHolder.isConnected = state is TunnelState.Connected + TunnelStateHolder.serverHost = serverHost + } + } + viewModelScope.launch { + while (isActive) { + delay(1_000) + if (tunnelState.value is TunnelState.Connected) { + tunnelManager.getStatistics()?.let { _stats.value = it } + } + } + } + + // 周期性检查握手是否超时,若超时且启用了 autoReconnectOnBlock 则触发端口跳变 + viewModelScope.launch { + while (isActive) { + delay(45_000) + if (tunnelState.value is TunnelState.Connected) { + val stats = tunnelManager.getStatistics() + val handshakeStale = stats == null || + TunnelMonitor.isHandshakeStale(stats.lastHandshakeEpoch, 180L) + if (handshakeStale) { + Timber.w("VpnViewModel: handshake stale — triggering port-hop reconnect") + tunnelManager.reconnectWithPortHop() + } + } + } + } + + viewModelScope.launch { + while (isActive) { + delay(60_000) + if (tunnelState.value is TunnelState.Connected) { + checkPeerEnabled() + } + } + } + } + + private suspend fun checkPeerEnabled() { + try { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) return + val peerId = setupRepository.getPeerId() + if (peerId <= 0) return + val client = apiClientProvider.getClient(serverUrl) + val response = client.getPeerInfo(peerId) + if (response.ok && !response.peer.enabled) { + Timber.w("Peer disabled on server (id=$peerId) — disconnecting tunnel") + tunnelManager.disconnect() + _stats.value = TunnelStats() + apiClientProvider.clearDnsCache() + _peerDisabled.value = true + } + } catch (e: Exception) { + Timber.d("Peer status check failed (offline): ${e.message}") + } + } + + // ── 连接动作 ────────────────────────────────────────────────────────── + + fun connect() { + viewModelScope.launch { + val config = setupRepository.getWireGuardConfig() + if (config.isEmpty()) { + Timber.w("VpnViewModel: no WireGuard config available") + return@launch + } + + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isNotEmpty()) { + try { + val host = java.net.URI(serverUrl).host + if (host != null) apiClientProvider.preResolveDns(host) + } catch (_: Exception) {} + } + + // 加载分流配置 + var splitTunnelConfig = SplitTunnelConfig() + try { + var adminPresetActive = false + if (serverUrl.isNotEmpty()) { + try { + val client = apiClientProvider.getClient(serverUrl) + val preset = client.getSplitTunnelPreset() + if (preset.ok && preset.mode != "off" && preset.source != "none") { + settingsRepository.setSplitTunnelMode(preset.mode) + val arr = JSONArray() + preset.networks.forEach { arr.put(JSONObject().put("cidr", it.cidr).put("label", it.label)) } + settingsRepository.setSplitTunnelNetworks(arr.toString()) + settingsRepository.setSplitTunnelAdminLocked(preset.locked) + adminPresetActive = true + + val userApps = settingsRepository.getSplitTunnelAppsV2().first() + val appsList = parseSplitAppsJson(userApps) + + splitTunnelConfig = SplitTunnelConfig( + mode = preset.mode, + networks = preset.networks.map { it.cidr }, + apps = appsList, + ) + } + } catch (e: Exception) { + Timber.w(e, "Split-tunnel preset fetch failed") + } + } + + if (!adminPresetActive) { + val mode = settingsRepository.getSplitTunnelMode().first() + if (mode != "off") { + val networksJson = settingsRepository.getSplitTunnelNetworks().first() + val appsJson = settingsRepository.getSplitTunnelAppsV2().first() + splitTunnelConfig = SplitTunnelConfig( + mode = mode, + networks = parseSplitNetworksJsonToCidrs(networksJson), + apps = parseSplitAppsJson(appsJson), + ) + } + } + } catch (e: Exception) { + Timber.w(e, "Split-tunnel config load failed") + } + + // ── 加载防检测配置 ──────────────────────────────────────────── + val stealthConfig = loadStealthConfig() + Timber.d( + "VpnViewModel: stealth config: portHop=%b jitter=%b padding=%b keepalive=%b decoy=%b autoReconnect=%b", + stealthConfig.portHoppingEnabled, + stealthConfig.timingJitterEnabled, + stealthConfig.packetPaddingEnabled, + stealthConfig.keepaliveRandomEnabled, + stealthConfig.decoyDnsEnabled, + stealthConfig.autoReconnectOnBlock, + ) + // ───────────────────────────────────────────────────────────── + + try { + tunnelManager.connect(config, splitTunnelConfig, stealthConfig) + Timber.d("VpnViewModel: tunnel connect requested") + reportDeviceHostname(serverUrl) + } catch (e: Exception) { + Timber.e(e, "VpnViewModel: connect failed") + } + } + } + + /** + * 从 SettingsRepository 读取所有防检测开关,组装为 [StealthConfig]。 + */ + private suspend fun loadStealthConfig(): StealthConfig { + return try { + val portHopping = settingsRepository.getStealthPortHopping().first() + val timingJitter = settingsRepository.getStealthTimingJitter().first() + val packetPadding = settingsRepository.getStealthPacketPadding().first() + val paddingMtu = settingsRepository.getStealthPaddingMtu().first() + val keepaliveRandom = settingsRepository.getStealthKeepaliveRandom().first() + val keepaliveJitter = settingsRepository.getStealthKeepaliveJitterSec().first() + val decoyDns = settingsRepository.getStealthDecoyDns().first() + val autoReconnect = settingsRepository.getStealthAutoReconnect().first() + val candidatePorts = settingsRepository.getStealthCandidatePorts().first() + val jitterMin = settingsRepository.getStealthJitterMinMs().first() + val jitterMax = settingsRepository.getStealthJitterMaxMs().first() + + StealthConfig( + portHoppingEnabled = portHopping, + candidatePorts = candidatePorts, + timingJitterEnabled = timingJitter, + jitterMinMs = jitterMin.toLong(), + jitterMaxMs = jitterMax.toLong(), + packetPaddingEnabled = packetPadding, + paddingTargetMtu = paddingMtu, + keepaliveRandomEnabled = keepaliveRandom, + keepaliveJitterSec = keepaliveJitter, + decoyDnsEnabled = decoyDns, + autoReconnectOnBlock = autoReconnect, + ) + } catch (e: Exception) { + Timber.w(e, "VpnViewModel: failed to load stealth config, using defaults (all off)") + StealthConfig() + } + } + + private suspend fun reportDeviceHostname(serverUrl: String) { + try { + val sanitized = com.gatecontrol.android.common.HostnameSanitizer.sanitize(android.os.Build.MODEL) + if (sanitized.isNullOrBlank()) return + + val client = apiClientProvider.getClient(serverUrl) + val response = client.reportHostname( + com.gatecontrol.android.network.HostnameReportRequest(sanitized) + ) + Timber.d("Hostname report: assigned=${response.assigned} changed=${response.changed}") + } catch (e: Exception) { + Timber.d(e, "Hostname report skipped: ${e.message}") + } + } + + fun disconnect() { + viewModelScope.launch { + try { + tunnelManager.disconnect() + _stats.value = TunnelStats() + apiClientProvider.clearDnsCache() + Timber.d("VpnViewModel: tunnel disconnected, DNS cache cleared") + } catch (e: Exception) { + Timber.e(e, "VpnViewModel: disconnect failed") + } + } + } + + fun toggleKillSwitch(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.setKillSwitch(enabled) + Timber.d("VpnViewModel: kill-switch set to $enabled") + } + } + + fun loadTrafficStats() { + viewModelScope.launch { + try { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) return@launch + val client = apiClientProvider.getClient(serverUrl) + val peerId = setupRepository.getPeerId() + if (peerId <= 0) return@launch + val response = client.getTraffic(peerId) + if (response.ok) { + _trafficUsage.value = response.traffic + } + } catch (e: Exception) { + Timber.w(e, "VpnViewModel: failed to load traffic stats") + } + } + } + + fun loadServices() { + viewModelScope.launch { + try { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) return@launch + val client = apiClientProvider.getClient(serverUrl) + val response = client.getServices() + if (response.ok) { + _services.value = response.services + } + } catch (e: Exception) { + Timber.w(e, "VpnViewModel: failed to load services") + } + } + } + + val serverHost: String? + get() { + val config = setupRepository.getWireGuardConfig() + if (config.isEmpty()) return null + return try { + com.gatecontrol.android.tunnel.TunnelConfig.parse(config).getServerHost() + } catch (_: Exception) { + null + } + } + + fun runDnsLeakTest(onResult: (String) -> Unit) { + viewModelScope.launch { + try { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) { + onResult("No server configured") + return@launch + } + val client = apiClientProvider.getClient(serverUrl) + val response = client.dnsCheck() + if (response.ok) { + onResult("DNS: ${response.vpnDns} (Subnet: ${response.vpnSubnet})") + } else { + onResult("DNS check failed") + } + } catch (e: Exception) { + Timber.w(e, "VpnViewModel: DNS leak test failed") + onResult("DNS test error: ${e.localizedMessage}") + } + } + } + + fun invalidateApiClients() { + apiClientProvider.invalidate() + } + + fun loadPermissions() { + viewModelScope.launch { + try { + val serverUrl = setupRepository.getServerUrl() + if (serverUrl.isEmpty()) return@launch + val client = apiClientProvider.getClient(serverUrl) + val response = client.getPermissions() + if (response.ok) { + val flags = response.permissions + _permissions.value = flags + licenseRepository.updatePermissions( + services = flags.services, + traffic = flags.traffic, + dns = flags.dns, + rdp = flags.rdp, + ) + } + } catch (e: Exception) { + Timber.w(e, "VpnViewModel: failed to load permissions") + } + } + } + + private fun parseSplitNetworksJsonToCidrs(json: String): List { + if (json.isBlank() || json == "[]") return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { arr.getJSONObject(it).getString("cidr") } + } catch (e: Exception) { + Timber.w(e, "Failed to parse split-tunnel networks JSON, falling back to empty") + emptyList() + } + } + + private fun parseSplitAppsJson(json: String): List { + if (json.isBlank() || json == "[]") return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { arr.getJSONObject(it).getString("package") } + } catch (e: Exception) { + Timber.w(e, "Failed to parse split-tunnel apps JSON, falling back to empty") + emptyList() + } + } +} From d01596a960d15eaf18a77cfc42f79b9c47a8d826 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:57:36 +0800 Subject: [PATCH 46/71] =?UTF-8?q?=E5=88=A0=E9=99=A4=20VpnViewModel.kt.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt.txt | 220 ------------------ 1 file changed, 220 deletions(-) delete mode 100644 app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt deleted file mode 100644 index d392d15..0000000 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt.txt +++ /dev/null @@ -1,220 +0,0 @@ -package com.gatecontrol.android.ui.vpn - -import app.cash.turbine.test -import com.gatecontrol.android.data.LicenseRepository -import com.gatecontrol.android.data.SettingsRepository -import com.gatecontrol.android.data.SetupRepository -import com.gatecontrol.android.network.ApiClient -import com.gatecontrol.android.network.ApiClientProvider -import com.gatecontrol.android.network.PermissionFlags -import com.gatecontrol.android.network.PermissionsResponse -import com.gatecontrol.android.network.SplitTunnelPresetResponse -import com.gatecontrol.android.tunnel.TunnelManager -import com.gatecontrol.android.tunnel.TunnelState -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertInstanceOf -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class VpnViewModelTest { - - private val testDispatcher = StandardTestDispatcher() - - private lateinit var setupRepository: SetupRepository - private lateinit var settingsRepository: SettingsRepository - private lateinit var licenseRepository: LicenseRepository - private lateinit var apiClientProvider: ApiClientProvider - private lateinit var apiClient: ApiClient - private lateinit var tunnelManager: TunnelManager - private lateinit var viewModel: VpnViewModel - - @BeforeEach - fun setUp() { - Dispatchers.setMain(testDispatcher) - - setupRepository = mockk(relaxed = true) - settingsRepository = mockk(relaxed = true) - licenseRepository = mockk(relaxed = true) - apiClientProvider = mockk(relaxed = true) - apiClient = mockk(relaxed = true) - tunnelManager = mockk(relaxed = true) - - every { settingsRepository.getKillSwitch() } returns flowOf(false) - every { settingsRepository.getSplitTunnelEnabled() } returns flowOf(false) - every { settingsRepository.getSplitTunnelRoutes() } returns flowOf("") - every { settingsRepository.getSplitTunnelApps() } returns flowOf("") - every { settingsRepository.getSplitTunnelMode() } returns flowOf("off") - every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") - every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") - every { settingsRepository.getStealthPortHopping() } returns flowOf(false) - every { settingsRepository.getStealthTimingJitter() } returns flowOf(false) - every { settingsRepository.getStealthPacketPadding() } returns flowOf(false) - every { settingsRepository.getStealthPaddingMtu() } returns flowOf(1420) - every { settingsRepository.getStealthKeepaliveRandom() } returns flowOf(false) - every { settingsRepository.getStealthKeepaliveJitterSec() } returns flowOf(0) - every { settingsRepository.getStealthDecoyDns() } returns flowOf(false) - every { settingsRepository.getStealthAutoReconnect() } returns flowOf(false) - every { settingsRepository.getStealthCandidatePorts() } returns flowOf(emptyList()) - every { settingsRepository.getStealthJitterMinMs() } returns flowOf(0) - every { settingsRepository.getStealthJitterMaxMs() } returns flowOf(0) - every { setupRepository.getServerUrl() } returns "https://gate.example.com" - every { setupRepository.getPeerId() } returns 42 - coEvery { apiClient.getSplitTunnelPreset() } returns SplitTunnelPresetResponse( - ok = true, mode = "off", networks = emptyList(), locked = false, source = "none" - ) - every { apiClientProvider.getClient(any()) } returns apiClient - every { tunnelManager.state } returns kotlinx.coroutines.flow.MutableStateFlow(TunnelState.Disconnected) - every { tunnelManager.stats } returns kotlinx.coroutines.flow.MutableStateFlow(com.gatecontrol.android.tunnel.TunnelStats()) - - viewModel = VpnViewModel( - setupRepository = setupRepository, - settingsRepository = settingsRepository, - licenseRepository = licenseRepository, - apiClientProvider = apiClientProvider, - tunnelManager = tunnelManager, - ) - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - // --- State tests --- - - @Test - fun `initial state is Disconnected`() = runTest { - viewModel.tunnelState.test { - assertInstanceOf(TunnelState.Disconnected::class.java, awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `connect calls tunnelManager with config`() = runTest { - every { setupRepository.getWireGuardConfig() } returns "[Interface]\nPrivateKey=abc\nAddress=10.0.0.1/32\n\n[Peer]\nPublicKey=xyz\nEndpoint=1.2.3.4:51820\nAllowedIPs=0.0.0.0/0" - - viewModel.connect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { tunnelManager.connect(any(), any(), any()) } - } - - @Test - fun `connect does nothing when config is empty`() = runTest { - every { setupRepository.getWireGuardConfig() } returns "" - - viewModel.connect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 0) { tunnelManager.connect(any(), any(), any()) } - } - - @Test - fun `disconnect calls tunnelManager disconnect`() = runTest { - viewModel.disconnect() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { tunnelManager.disconnect() } - } - - @Test - fun `toggleKillSwitch saves to settings`() = runTest { - viewModel.toggleKillSwitch(true) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { settingsRepository.setKillSwitch(true) } - } - - @Test - fun `toggleKillSwitch false saves false to settings`() = runTest { - viewModel.toggleKillSwitch(false) - testDispatcher.scheduler.advanceUntilIdle() - - coVerify { settingsRepository.setKillSwitch(false) } - } - - @Test - fun `loadPermissions updates license repository`() = runTest { - val flags = PermissionFlags( - services = true, - traffic = true, - dns = false, - rdp = true, - ) - coEvery { apiClient.getPermissions() } returns PermissionsResponse( - ok = true, - permissions = flags, - scopes = listOf("services", "traffic", "rdp"), - ) - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - verify { - licenseRepository.updatePermissions( - services = true, - traffic = true, - dns = false, - rdp = true, - ) - } - } - - @Test - fun `loadPermissions updates permissions state flow`() = runTest { - val flags = PermissionFlags( - services = true, - traffic = false, - dns = true, - rdp = false, - ) - coEvery { apiClient.getPermissions() } returns PermissionsResponse( - ok = true, - permissions = flags, - scopes = listOf("services", "dns"), - ) - - viewModel.permissions.test { - val initial = awaitItem() - assertFalse(initial.services) - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - val updated = awaitItem() - assertTrue(updated.services) - assertTrue(updated.dns) - assertFalse(updated.traffic) - assertFalse(updated.rdp) - - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `loadPermissions does nothing when server URL is empty`() = runTest { - every { setupRepository.getServerUrl() } returns "" - - viewModel.loadPermissions() - testDispatcher.scheduler.advanceUntilIdle() - - coVerify(exactly = 0) { apiClient.getPermissions() } - } -} From 78728b220a9081ef830aec63156e82c28b900ad3 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:05:33 +0800 Subject: [PATCH 47/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModelTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModelTest.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt index 7b6f29a..d19962b 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt @@ -62,6 +62,17 @@ class VpnViewModelTest { every { settingsRepository.getSplitTunnelMode() } returns flowOf("off") every { settingsRepository.getSplitTunnelNetworks() } returns flowOf("[]") every { settingsRepository.getSplitTunnelAppsV2() } returns flowOf("[]") + every { settingsRepository.getStealthPortHopping() } returns flowOf(false) + every { settingsRepository.getStealthTimingJitter() } returns flowOf(false) + every { settingsRepository.getStealthPacketPadding() } returns flowOf(false) + every { settingsRepository.getStealthPaddingMtu() } returns flowOf(1420) + every { settingsRepository.getStealthKeepaliveRandom() } returns flowOf(false) + every { settingsRepository.getStealthKeepaliveJitterSec() } returns flowOf(0) + every { settingsRepository.getStealthDecoyDns() } returns flowOf(false) + every { settingsRepository.getStealthAutoReconnect() } returns flowOf(false) + every { settingsRepository.getStealthCandidatePorts() } returns flowOf(emptyList()) + every { settingsRepository.getStealthJitterMinMs() } returns flowOf(0) + every { settingsRepository.getStealthJitterMaxMs() } returns flowOf(0) every { setupRepository.getServerUrl() } returns "https://gate.example.com" every { setupRepository.getPeerId() } returns 42 coEvery { apiClient.getSplitTunnelPreset() } returns SplitTunnelPresetResponse( @@ -102,17 +113,13 @@ class VpnViewModelTest { viewModel.connect() testDispatcher.scheduler.advanceUntilIdle() - coVerify { tunnelManager.connect(any(), any()) } - } - - @Test - fun `connect does nothing when config is empty`() = runTest { + coVerify { tunnelManager.connect(any(), any(), any()) } = runTest { every { setupRepository.getWireGuardConfig() } returns "" viewModel.connect() testDispatcher.scheduler.advanceUntilIdle() - coVerify(exactly = 0) { tunnelManager.connect(any(), any()) } + coVerify(exactly = 0) { tunnelManager.connect(any(), any(), any()) } } @Test From 4ce6bf96c2e54f57b44a58bdd74dc853257dfbae Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:12:03 +0800 Subject: [PATCH 48/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModelTest.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt index d19962b..d392d15 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt @@ -113,7 +113,11 @@ class VpnViewModelTest { viewModel.connect() testDispatcher.scheduler.advanceUntilIdle() - coVerify { tunnelManager.connect(any(), any(), any()) } = runTest { + coVerify { tunnelManager.connect(any(), any(), any()) } + } + + @Test + fun `connect does nothing when config is empty`() = runTest { every { setupRepository.getWireGuardConfig() } returns "" viewModel.connect() From f0c044e910bd389400a3af634f5032d0e88a9021 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 8 Jun 2026 10:25:37 +0000 Subject: [PATCH 49/71] chore: bump version to 1.6.3 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c134769..26fc3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.3] - 2026-06-08 + +### Changes +- 更新 VpnViewModelTest.kt + +--- + ## [1.6.2] - 2026-06-06 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1dcbff..b403f5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10602 - versionName = "1.6.2" + versionCode = 10603 + versionName = "1.6.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From b19caedf290ca56808d55b7b3b6482d5eaa78412 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:13:25 +0800 Subject: [PATCH 50/71] Add files via upload --- azirevpn-ar-bue.conf | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 azirevpn-ar-bue.conf diff --git a/azirevpn-ar-bue.conf b/azirevpn-ar-bue.conf new file mode 100644 index 0000000..65b1667 --- /dev/null +++ b/azirevpn-ar-bue.conf @@ -0,0 +1,9 @@ +[Interface] +PrivateKey = +IVrvU7YChMO5Elnd8CiH2r13rVp1gMp09DAOJp+rF4= +Address = 10.0.81.221/32, 2a0e:1c80:1337:1:10:0:81:221/128 +DNS = 10.0.0.1, 2a0e:1c80:1337:1:10:0:0:1, 91.231.153.2 + +[Peer] +PublicKey = HjziGvLqDPeFpsmU6aOT6DIEUleQ2bkppPmWeAy8fT0= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = ar-bue.azirevpn.net:51820 \ No newline at end of file From dfa19239917e6de937d8c3930cbaaec5fa166bdf Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 8 Jun 2026 21:26:42 +0000 Subject: [PATCH 51/71] chore: bump version to 1.6.4 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26fc3ca..59a0962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.4] - 2026-06-08 + +### Changes +- Add files via upload + +--- + ## [1.6.3] - 2026-06-08 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b403f5b..70e2da4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10603 - versionName = "1.6.3" + versionCode = 10604 + versionName = "1.6.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 3c11d4f50f189ec4a4cdf71efc858d80701e7d43 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:09:47 +0800 Subject: [PATCH 52/71] Delete azirevpn-ar-bue.conf --- azirevpn-ar-bue.conf | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 azirevpn-ar-bue.conf diff --git a/azirevpn-ar-bue.conf b/azirevpn-ar-bue.conf deleted file mode 100644 index 65b1667..0000000 --- a/azirevpn-ar-bue.conf +++ /dev/null @@ -1,9 +0,0 @@ -[Interface] -PrivateKey = +IVrvU7YChMO5Elnd8CiH2r13rVp1gMp09DAOJp+rF4= -Address = 10.0.81.221/32, 2a0e:1c80:1337:1:10:0:81:221/128 -DNS = 10.0.0.1, 2a0e:1c80:1337:1:10:0:0:1, 91.231.153.2 - -[Peer] -PublicKey = HjziGvLqDPeFpsmU6aOT6DIEUleQ2bkppPmWeAy8fT0= -AllowedIPs = 0.0.0.0/0, ::/0 -Endpoint = ar-bue.azirevpn.net:51820 \ No newline at end of file From 5ee4bef9416bdf72195e9e833af50494d82f4729 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 8 Jun 2026 23:23:19 +0000 Subject: [PATCH 53/71] chore: bump version to 1.6.5 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a0962..f46f405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.5] - 2026-06-08 + +### Changes +- Delete azirevpn-ar-bue.conf + +--- + ## [1.6.4] - 2026-06-08 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70e2da4..c25f5f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10604 - versionName = "1.6.4" + versionCode = 10605 + versionName = "1.6.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From a1fdd0ca6f7d3dd3acd764286effe8f9d7239a75 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:39:01 +0800 Subject: [PATCH 54/71] Update ApiClientProvider.kt --- .../java/com/gatecontrol/android/network/ApiClientProvider.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt index 88e37ce..454e5d8 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt @@ -116,6 +116,10 @@ class ApiClientProvider @Inject constructor( } fun getClient(baseUrl: String): ApiClient { + require(baseUrl.isNotBlank() && + (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))) { + "Expected URL scheme 'http' or 'https' but received: '$baseUrl'" + } val normalizedUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" return synchronized(lock) { cache.getOrPut(normalizedUrl) { From 180ea1c117cef5e76028996311b5401a59fae2d3 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:41:52 +0800 Subject: [PATCH 55/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TunnelManager.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gatecontrol/android/tunnel/TunnelManager.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 8ca69e0..625ff98 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber import java.net.InetAddress @@ -44,6 +46,9 @@ class TunnelManager @Inject constructor(private val context: Context) { private var backend: Backend? = null private var tunnel: Tunnel? = null + /** 防止并发 connect/reconnect 竞争 */ + private val connectMutex = kotlinx.coroutines.sync.Mutex() + private var prevRxBytes: Long = 0L private var prevTxBytes: Long = 0L private var prevStatsTime: Long = 0L @@ -122,6 +127,7 @@ class TunnelManager @Inject constructor(private val context: Context) { stealthConfig: StealthConfig, ) { withContext(Dispatchers.IO) { + connectMutex.withLock { try { // 保存配置供自动重连使用 currentRawConfig = configString @@ -179,6 +185,7 @@ class TunnelManager @Inject constructor(private val context: Context) { Timber.e(e, "TunnelManager: failed to connect tunnel") _state.value = TunnelState.Error(e.message ?: "Unknown error") } + } // connectMutex.withLock } } @@ -222,8 +229,8 @@ class TunnelManager @Inject constructor(private val context: Context) { } withContext(Dispatchers.IO) { + connectMutex.withLock { try { - Timber.w("TunnelManager: handshake stale — attempting port-hop reconnect") // 断开当前连接 val currentBackend = backend @@ -268,6 +275,7 @@ class TunnelManager @Inject constructor(private val context: Context) { Timber.e(e, "TunnelManager: port-hop reconnect failed") _state.value = TunnelState.Error(e.message ?: "Port-hop reconnect failed") } + } // connectMutex.withLock } } @@ -333,6 +341,11 @@ class TunnelManager @Inject constructor(private val context: Context) { parsed: TunnelConfig, splitConfig: SplitTunnelConfig, ): Config { + require(parsed.address.isNotBlank()) { + "WireGuard Interface Address is empty — cannot establish VPN tunnel. " + + "The server configuration is missing the [Interface] Address field." + } + val ifaceBuilder = Interface.Builder() .parsePrivateKey(parsed.privateKey) .parseAddresses(parsed.address) From 02e509c6b8352dd66aec37a6c1518802551416d0 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:42:38 +0800 Subject: [PATCH 56/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index 94659d9..b1f79e1 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -127,12 +127,21 @@ class VpnViewModel @Inject constructor( while (isActive) { delay(45_000) if (tunnelState.value is TunnelState.Connected) { - val stats = tunnelManager.getStatistics() - val handshakeStale = stats == null || - TunnelMonitor.isHandshakeStale(stats.lastHandshakeEpoch, 180L) - if (handshakeStale) { - Timber.w("VpnViewModel: handshake stale — triggering port-hop reconnect") - tunnelManager.reconnectWithPortHop() + // Only attempt port-hop reconnect if the feature is enabled. + // loadStealthConfig() is cheap (reads SharedPreferences). + val shouldAutoReconnect = try { + loadStealthConfig().autoReconnectOnBlock + } catch (_: Exception) { false } + + if (shouldAutoReconnect) { + val stats = tunnelManager.getStatistics() + // stats == null means the backend couldn't read stats (not a + // handshake failure), so don't treat that as stale. + if (stats != null && + TunnelMonitor.isHandshakeStale(stats.lastHandshakeEpoch, 180L)) { + Timber.w("VpnViewModel: handshake stale — triggering port-hop reconnect") + tunnelManager.reconnectWithPortHop() + } } } } @@ -293,6 +302,15 @@ class VpnViewModel @Inject constructor( private suspend fun reportDeviceHostname(serverUrl: String) { try { + // Guard: Retrofit requires a valid http/https URL. An empty or + // unconfigured serverUrl would produce "/" after normalization and + // crash with "Expected URL scheme 'http' or 'https'". + if (serverUrl.isBlank() || + (!serverUrl.startsWith("http://") && !serverUrl.startsWith("https://"))) { + Timber.d("Hostname report skipped: serverUrl is not a valid http(s) URL: '$serverUrl'") + return + } + val sanitized = com.gatecontrol.android.common.HostnameSanitizer.sanitize(android.os.Build.MODEL) if (sanitized.isNullOrBlank()) return From 14c7e5dba0c7efd85e595b1f4f1a712ce754cb86 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 8 Jun 2026 23:56:34 +0000 Subject: [PATCH 57/71] chore: bump version to 1.6.6 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f46f405..7e02668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.6] - 2026-06-08 + +### Changes +- 更新 VpnViewModel.kt + +--- + ## [1.6.5] - 2026-06-08 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c25f5f1..3c9a829 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10605 - versionName = "1.6.5" + versionCode = 10606 + versionName = "1.6.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 59fc158b7e4554f4b747db2f6c968c9ecdd915af Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:26:45 +0800 Subject: [PATCH 58/71] Update TunnelManager.kt --- .../android/tunnel/TunnelManager.kt | 585 +++++++++++++----- 1 file changed, 432 insertions(+), 153 deletions(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 625ff98..5bae90d 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -1,6 +1,13 @@ package com.gatecontrol.android.tunnel +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.net.VpnService import com.wireguard.android.backend.Backend import com.wireguard.android.backend.GoBackend @@ -10,29 +17,57 @@ import com.wireguard.config.Interface import com.wireguard.config.InetAddresses import com.wireguard.config.InetNetwork import com.wireguard.config.Peer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber +import java.net.DatagramSocket import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton /** - * WireGuard 隧道管理器(集成客户端防检测功能)。 + * WireGuard 隧道管理器(集成客户端防检测功能 + 双轨探测引擎)。 * - * 在原有功能基础上增加了 [StealthEngine] 集成: - * - 连接前执行时序抖动、诱饵 DNS、端口跳变等防检测预处理 - * - MTU 调整影响包长分布 - * - Keepalive 随机化打破固定间隔指纹 - * - 检测到疑似封锁时自动切换端口重连([StealthConfig.autoReconnectOnBlock]) + * ## 双轨探测引擎(Screen-On 且有网络时激活) * - * 防检测配置通过 [connect] 的 [stealthConfig] 参数传入,默认关闭所有功能 - * 以保持完全向后兼容。 + * - 轨道 A(直连流):向用户配置的国内公共 IP 发 TCP 探测,不经过 VpnService。 + * 反映本地物理网络是否可用。 + * - 轨道 B(隧道流):向隧道对端 IP 发 UDP 探测(GoBackend 会将其加密后通过 + * WireGuard 隧道转发)。反映出海隧道是否畅通。 + * + * ## 三态裁决 + * + * | A | B | 结论 | 动作 | + * |----|----|----------------|-------------------------------| + * | 通 | 通 | 网络全通 | 重置失败计数 | + * | 断 | 断 | 环境断网 | 锁死状态,不重连 | + * | 通 | 断 | 精准阻断(被封)| 失败计数 +1,触发熔断重连 | + * + * ## 冷却锁(防 Cannot set address) + * + * Android 的 `jniSetAddresses` 要求上一次 DOWN 的 TUN fd 在内核侧完全关闭后 + * 才能再次调用 `establish()`。连续快速重建会触发 `IllegalArgumentException: + * Cannot set address`。冷却锁强制两次 establish() 之间至少间隔 [RECONNECT_COOLDOWN_MS]。 + * + * ## 屏幕状态感知 + * + * 屏幕熄灭时挂起高频双轨探测(停止 while-loop),切换为被动监听(仅依赖 + * WireGuard 原生 Keepalive 保活)。亮屏时恢复主动探测。 */ @Singleton class TunnelManager @Inject constructor(private val context: Context) { @@ -46,8 +81,11 @@ class TunnelManager @Inject constructor(private val context: Context) { private var backend: Backend? = null private var tunnel: Tunnel? = null - /** 防止并发 connect/reconnect 竞争 */ - private val connectMutex = kotlinx.coroutines.sync.Mutex() + /** 防止并发 connect/reconnect 竞争(含冷却锁保护) */ + private val connectMutex = Mutex() + + /** 上次 establish() 调用的时间戳(毫秒),用于冷却锁计算 */ + private val lastEstablishMs = AtomicLong(0L) private var prevRxBytes: Long = 0L private var prevTxBytes: Long = 0L @@ -64,6 +102,49 @@ class TunnelManager @Inject constructor(private val context: Context) { private val stealthEngine = StealthEngine() + // ── 双轨探测引擎状态 ─────────────────────────────────────────────────── + + /** 连续探测失败计数(仅"A通+B断"时递增) */ + private var probeFailureCount: Int = 0 + + /** 探测协程 Scope(亮屏时启动,熄屏时取消) */ + private var probeScope: CoroutineScope? = null + + /** 屏幕是否亮屏 */ + private val isScreenOn = AtomicBoolean(true) + + /** 物理网络是否可用 */ + private val isNetworkAvailable = AtomicBoolean(true) + + /** 屏幕状态广播接收器 */ + private var screenReceiver: BroadcastReceiver? = null + + /** ConnectivityManager 网络回调 */ + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + // ── 用户可配置探测参数(提供默认值,可通过 configureProbe 覆盖) ──── + + /** + * 直连探测目标(轨道 A)。 + * 推荐填国内公共 DNS IP,如 114.114.114.114,该地址不应被加入 AllowedIPs。 + */ + var bypassProbeTarget: String = DEFAULT_BYPASS_PROBE + + /** + * 隧道探测目标(轨道 B)。 + * 必须是在 AllowedIPs 内的海外 IP,GoBackend 会将其流量送入隧道。 + * 默认使用 VPN 网关地址(10.8.0.1),始终在 AllowedIPs 内。 + */ + var tunnelProbeTarget: String = DEFAULT_TUNNEL_PROBE + + /** 单次探测超时(毫秒) */ + var probeTimeoutMs: Long = DEFAULT_PROBE_TIMEOUT_MS + + /** 熔断重连阈值:连续"精准阻断"次数达到此值时触发端口跳变 */ + var failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD + + // ── 初始化 ───────────────────────────────────────────────────────────── + fun initialize() { try { backend = GoBackend(context) @@ -77,42 +158,33 @@ class TunnelManager @Inject constructor(private val context: Context) { } catch (e: Exception) { Timber.e(e, "Failed to initialize TunnelManager") } + + registerScreenReceiver() + registerNetworkCallback() } - /** - * 向后兼容的连接入口(无防检测配置,所有功能关闭)。 - */ + // ── 公开连接接口 ──────────────────────────────────────────────────────── + + /** 向后兼容入口(无防检测配置) */ suspend fun connect( configString: String, splitTunnelRoutes: List = emptyList(), excludedApps: List = emptyList() ) { val splitConfig = if (splitTunnelRoutes.isNotEmpty() || excludedApps.isNotEmpty()) { - SplitTunnelConfig( - mode = "include", - networks = splitTunnelRoutes, - apps = excludedApps, - ) + SplitTunnelConfig(mode = "include", networks = splitTunnelRoutes, apps = excludedApps) } else { SplitTunnelConfig() } connectInternal(configString, splitConfig, StealthConfig()) } - /** - * 使用分流配置连接(无防检测)。 - */ + /** 使用分流配置连接(无防检测) */ suspend fun connect(configString: String, splitConfig: SplitTunnelConfig) { connectInternal(configString, splitConfig, StealthConfig()) } - /** - * 完整连接入口:支持分流配置 + 防检测配置。 - * - * @param configString WireGuard 配置字符串 - * @param splitConfig 分流隧道配置 - * @param stealthConfig 客户端防检测配置(默认全部关闭) - */ + /** 完整连接入口:支持分流配置 + 防检测配置 */ suspend fun connect( configString: String, splitConfig: SplitTunnelConfig, @@ -121,6 +193,8 @@ class TunnelManager @Inject constructor(private val context: Context) { connectInternal(configString, splitConfig, stealthConfig) } + // ── 核心连接实现 ──────────────────────────────────────────────────────── + private suspend fun connectInternal( configString: String, splitConfig: SplitTunnelConfig, @@ -128,68 +202,71 @@ class TunnelManager @Inject constructor(private val context: Context) { ) { withContext(Dispatchers.IO) { connectMutex.withLock { - try { - // 保存配置供自动重连使用 - currentRawConfig = configString - currentSplitConfig = splitConfig - currentStealthConfig = stealthConfig - - val parsedConfig = TunnelConfig.parse(configString) - - // ── 防检测预处理 ────────────────────────────────────────── - // StealthEngine 在此阶段执行:时序抖动、诱饵DNS、端口跳变、 - // MTU调整、Keepalive随机化 - val stealthedConfig = if (stealthConfig.portHoppingEnabled - || stealthConfig.timingJitterEnabled - || stealthConfig.packetPaddingEnabled - || stealthConfig.keepaliveRandomEnabled - || stealthConfig.decoyDnsEnabled - ) { - Timber.d("TunnelManager: running stealth pre-processing") - stealthEngine.prepareConnection(stealthConfig, parsedConfig) - } else { - parsedConfig + try { + currentRawConfig = configString + currentSplitConfig = splitConfig + currentStealthConfig = stealthConfig + + val parsedConfig = TunnelConfig.parse(configString) + + val stealthedConfig = if (stealthConfig.portHoppingEnabled + || stealthConfig.timingJitterEnabled + || stealthConfig.packetPaddingEnabled + || stealthConfig.keepaliveRandomEnabled + || stealthConfig.decoyDnsEnabled + ) { + Timber.d("TunnelManager: running stealth pre-processing") + stealthEngine.prepareConnection(stealthConfig, parsedConfig) + } else { + parsedConfig + } + + val wgConfig = buildWgConfig(stealthedConfig, splitConfig) + + _state.value = TunnelState.Connecting + Timber.d( + "TunnelManager: connecting (mode=%s, stealth: port=%b jitter=%b padding=%b keepalive=%b decoy=%b)", + splitConfig.mode, + stealthConfig.portHoppingEnabled, + stealthConfig.timingJitterEnabled, + stealthConfig.packetPaddingEnabled, + stealthConfig.keepaliveRandomEnabled, + stealthConfig.decoyDnsEnabled, + ) + + val currentBackend = backend ?: run { initialize(); backend } + ?: throw IllegalStateException("Backend not available") + val currentTunnel = tunnel + ?: throw IllegalStateException("Tunnel not initialized") + + // 冷却锁:确保上次 establish 到本次至少间隔 RECONNECT_COOLDOWN_MS + enforceReconnectCooldown() + + currentBackend.setState(currentTunnel, Tunnel.State.UP, wgConfig) + lastEstablishMs.set(System.currentTimeMillis()) + + prevRxBytes = 0L + prevTxBytes = 0L + prevStatsTime = System.currentTimeMillis() + probeFailureCount = 0 + + _state.value = TunnelState.Connected() + Timber.i("TunnelManager: tunnel connected successfully") + + // 连接成功后启动双轨探测(仅在 autoReconnectOnBlock 开启时) + if (stealthConfig.autoReconnectOnBlock && stealthConfig.portHoppingEnabled) { + startProbeEngine() + } + } catch (e: Exception) { + Timber.e(e, "TunnelManager: failed to connect tunnel") + _state.value = TunnelState.Error(e.message ?: "Unknown error") } - // ───────────────────────────────────────────────────────── - - val wgConfig = buildWgConfig(stealthedConfig, splitConfig) - - _state.value = TunnelState.Connecting - Timber.d( - "TunnelManager: connecting (mode=%s, stealth: port=%b jitter=%b padding=%b keepalive=%b decoy=%b)", - splitConfig.mode, - stealthConfig.portHoppingEnabled, - stealthConfig.timingJitterEnabled, - stealthConfig.packetPaddingEnabled, - stealthConfig.keepaliveRandomEnabled, - stealthConfig.decoyDnsEnabled, - ) - - val currentBackend = backend ?: run { - initialize() - backend - } ?: throw IllegalStateException("Backend not available") - - val currentTunnel = tunnel - ?: throw IllegalStateException("Tunnel not initialized") - - currentBackend.setState(currentTunnel, Tunnel.State.UP, wgConfig) - - prevRxBytes = 0L - prevTxBytes = 0L - prevStatsTime = System.currentTimeMillis() - - _state.value = TunnelState.Connected() - Timber.i("TunnelManager: tunnel connected successfully") - } catch (e: Exception) { - Timber.e(e, "TunnelManager: failed to connect tunnel") - _state.value = TunnelState.Error(e.message ?: "Unknown error") } - } // connectMutex.withLock } } suspend fun disconnect() { + stopProbeEngine() withContext(Dispatchers.IO) { try { _state.value = TunnelState.Disconnecting @@ -197,7 +274,6 @@ class TunnelManager @Inject constructor(private val context: Context) { val currentBackend = backend val currentTunnel = tunnel - if (currentBackend != null && currentTunnel != null) { currentBackend.setState(currentTunnel, Tunnel.State.DOWN, null) } @@ -206,6 +282,7 @@ class TunnelManager @Inject constructor(private val context: Context) { prevRxBytes = 0L prevTxBytes = 0L prevStatsTime = 0L + probeFailureCount = 0 _state.value = TunnelState.Disconnected Timber.i("TunnelManager: tunnel disconnected") @@ -216,9 +293,118 @@ class TunnelManager @Inject constructor(private val context: Context) { } } + // ── 双轨探测引擎 ──────────────────────────────────────────────────────── + + /** + * 启动双轨探测协程。 + * 仅在亮屏 + 有网络时激活;熄屏或物理断网时自动挂起。 + */ + private fun startProbeEngine() { + stopProbeEngine() + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + probeScope = scope + + scope.launch { + Timber.d("TunnelProbe: engine started (bypass=%s, tunnel=%s, timeout=%dms, threshold=%d)", + bypassProbeTarget, tunnelProbeTarget, probeTimeoutMs, failureThreshold) + while (isActive) { + delay(PROBE_INTERVAL_MS) + + // 熄屏或物理断网时跳过,不触发重连 + if (!isScreenOn.get()) { + Timber.d("TunnelProbe: screen off, skipping probe cycle") + continue + } + if (!isNetworkAvailable.get()) { + Timber.d("TunnelProbe: no network, skipping probe cycle") + probeFailureCount = 0 // 不是隧道问题,重置计数 + continue + } + if (_state.value !is TunnelState.Connected) continue + + val trackA = probeBypass(bypassProbeTarget, probeTimeoutMs) + val trackB = probeTunnel(tunnelProbeTarget, probeTimeoutMs) + + when { + trackA && trackB -> { + // 全通:隧道正常 + if (probeFailureCount > 0) { + Timber.d("TunnelProbe: A+B both reachable, resetting failure count (was %d)", probeFailureCount) + } + probeFailureCount = 0 + } + !trackA && !trackB -> { + // 双断:本地无网,不是隧道问题 + Timber.d("TunnelProbe: A+B both unreachable — local network down, holding state") + probeFailureCount = 0 + } + trackA && !trackB -> { + // 精准阻断:本地通但隧道断 + probeFailureCount++ + Timber.w("TunnelProbe: A reachable, B blocked — possible port block (failure %d/%d)", + probeFailureCount, failureThreshold) + if (probeFailureCount >= failureThreshold) { + Timber.w("TunnelProbe: threshold reached, triggering port-hop reconnect") + probeFailureCount = 0 + reconnectWithPortHop() + } + } + else -> { + // A断+B通:理论上不可能(流量必须经过本地网络),忽略 + Timber.d("TunnelProbe: A unreachable but B reachable — anomaly, ignoring") + } + } + } + } + } + + private fun stopProbeEngine() { + probeScope?.cancel() + probeScope = null + probeFailureCount = 0 + } + + /** + * 轨道 A:直连探测。 + * 向 [target] 发起 TCP 连接(端口 80),不绑定 VPN 网络接口。 + * 探测包不会进入 WireGuard 隧道(该 IP 不在 AllowedIPs 内)。 + */ + private fun probeBypass(target: String, timeoutMs: Long): Boolean { + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(target, 53), timeoutMs.toInt()) + true + } + } catch (e: Exception) { + Timber.d("TunnelProbe: bypass probe %s failed: %s", target, e.message) + false + } + } + + /** + * 轨道 B:隧道探测。 + * 向 [target](隧道内 IP,默认 10.8.0.1)发送 UDP 包。 + * GoBackend 会将其加密后通过 WireGuard 发出;若隧道被封,UDP 无响应即超时。 + * 用 DatagramSocket 发送后等待可达性(通过 InetAddress.isReachable 绑定 TUN 接口)。 + */ + private fun probeTunnel(target: String, timeoutMs: Long): Boolean { + return try { + // InetAddress.isReachable 在 Android 上使用 ICMP echo(需 root)或 TCP 7 端口。 + // 对于 VPN 隧道内 IP,GoBackend 的 TUN 接口会拦截 ICMP,实际上可达性 + // 反映隧道是否通。若 GoBackend 未转发则超时返回 false。 + val addr = InetAddress.getByName(target) + addr.isReachable(timeoutMs.toInt()) + } catch (e: Exception) { + Timber.d("TunnelProbe: tunnel probe %s failed: %s", target, e.message) + false + } + } + + // ── 端口跳变重连 ──────────────────────────────────────────────────────── + /** * 在疑似端口封锁时切换端口自动重连。 - * 由 [TunnelMonitor] 检测到握手超时时调用。 + * 由双轨探测引擎或 VpnViewModel 的握手超时检测调用。 * 只在 [StealthConfig.autoReconnectOnBlock] 启用时有效。 */ suspend fun reconnectWithPortHop() { @@ -230,60 +416,62 @@ class TunnelManager @Inject constructor(private val context: Context) { withContext(Dispatchers.IO) { connectMutex.withLock { - try { - - // 断开当前连接 - val currentBackend = backend - val currentTunnel = tunnel - if (currentBackend != null && currentTunnel != null) { - currentBackend.setState(currentTunnel, Tunnel.State.DOWN, null) + try { + Timber.w("TunnelManager: handshake stale — attempting port-hop reconnect") + + // Step 1: 彻底销毁旧网卡 + val currentBackend = backend + val currentTunnel = tunnel + if (currentBackend != null && currentTunnel != null) { + currentBackend.setState(currentTunnel, Tunnel.State.DOWN, null) + } + + val parsedConfig = TunnelConfig.parse(currentRawConfig) + val currentPort = parsedConfig.getServerPort() + + // Step 2: 选择不同的端口 + val newPort = stealthEngine.nextPort(stealth, currentPort) + val newEndpoint = stealthEngine.replacePort(parsedConfig.endpoint, newPort) + Timber.d("TunnelManager: port-hop %d → %d, endpoint=%s", currentPort, newPort, newEndpoint) + + val rewrittenConfig = parsedConfig.copy(endpoint = newEndpoint) + + // Step 3: 执行 Stealth 变换(重新抖动、decoy 等) + val stealthedConfig = stealthEngine.prepareConnection(stealth, rewrittenConfig) + val wgConfig = buildWgConfig(stealthedConfig, currentSplitConfig) + + _state.value = TunnelState.Connecting + + val ensuredBackend = backend ?: run { initialize(); backend } + ?: throw IllegalStateException("Backend not available after port hop") + val ensuredTunnel = tunnel + ?: throw IllegalStateException("Tunnel not initialized after port hop") + + // Step 4: 冷却锁 + 重新建立网卡 + enforceReconnectCooldown() + ensuredBackend.setState(ensuredTunnel, Tunnel.State.UP, wgConfig) + lastEstablishMs.set(System.currentTimeMillis()) + + prevRxBytes = 0L + prevTxBytes = 0L + prevStatsTime = System.currentTimeMillis() + + _state.value = TunnelState.Connected() + Timber.i("TunnelManager: port-hop reconnect succeeded on port %d", newPort) + } catch (e: Exception) { + Timber.e(e, "TunnelManager: port-hop reconnect failed") + _state.value = TunnelState.Error(e.message ?: "Port-hop reconnect failed") } - - val parsedConfig = TunnelConfig.parse(currentRawConfig) - val currentPort = parsedConfig.getServerPort() - - // 选择不同的端口 - val newPort = stealthEngine.nextPort(stealth, currentPort) - val newEndpoint = stealthEngine.replacePort(parsedConfig.endpoint, newPort) - Timber.d("TunnelManager: port-hop $currentPort → $newPort, endpoint=$newEndpoint") - - val rewrittenConfig = parsedConfig.copy(endpoint = newEndpoint) - - // 对新端口的配置再做一轮完整 stealth 处理(重新抖动、decoy 等) - val stealthedConfig = stealthEngine.prepareConnection(stealth, rewrittenConfig) - val wgConfig = buildWgConfig(stealthedConfig, currentSplitConfig) - - _state.value = TunnelState.Connecting - - val ensuredBackend = backend ?: run { - initialize() - backend - } ?: throw IllegalStateException("Backend not available after port hop") - - val ensuredTunnel = tunnel - ?: throw IllegalStateException("Tunnel not initialized after port hop") - - ensuredBackend.setState(ensuredTunnel, Tunnel.State.UP, wgConfig) - - prevRxBytes = 0L - prevTxBytes = 0L - prevStatsTime = System.currentTimeMillis() - - _state.value = TunnelState.Connected() - Timber.i("TunnelManager: port-hop reconnect succeeded on port $newPort") - } catch (e: Exception) { - Timber.e(e, "TunnelManager: port-hop reconnect failed") - _state.value = TunnelState.Error(e.message ?: "Port-hop reconnect failed") } - } // connectMutex.withLock } } + // ── 统计 ──────────────────────────────────────────────────────────────── + fun getStatistics(): TunnelStats? { return try { val currentBackend = backend ?: return null val currentTunnel = tunnel ?: return null - if (_state.value !is TunnelState.Connected) return null val statistics = currentBackend.getStatistics(currentTunnel) @@ -337,6 +525,94 @@ class TunnelManager @Inject constructor(private val context: Context) { fun isConnected(): Boolean = _state.value is TunnelState.Connected + // ── 屏幕 & 网络状态监听 ────────────────────────────────────────────────── + + private fun registerScreenReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_ON -> { + isScreenOn.set(true) + Timber.d("TunnelProbe: screen on — resuming active probe") + // 若当前已连接且探测引擎已停,重新启动 + if (_state.value is TunnelState.Connected + && currentStealthConfig.autoReconnectOnBlock + && currentStealthConfig.portHoppingEnabled + && probeScope == null) { + startProbeEngine() + } + } + Intent.ACTION_SCREEN_OFF -> { + isScreenOn.set(false) + Timber.d("TunnelProbe: screen off — suspending active probe (passive mode)") + // 熄屏时停止高频探测协程,释放 CPU WakeLock + stopProbeEngine() + } + } + } + } + context.registerReceiver(receiver, filter) + screenReceiver = receiver + } + + private fun registerNetworkCallback() { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + isNetworkAvailable.set(true) + Timber.d("TunnelProbe: network available") + } + override fun onLost(network: Network) { + // 仍有其他网络时不应标记为断网;ConnectivityManager 在最后一个网络 + // 丢失时才会触发 onLost 且不再回调 onAvailable + isNetworkAvailable.set(false) + Timber.d("TunnelProbe: network lost — suspending probe, holding tunnel state") + } + } + cm.registerNetworkCallback(request, callback) + networkCallback = callback + } + + fun release() { + stopProbeEngine() + screenReceiver?.let { + try { context.unregisterReceiver(it) } catch (_: Exception) {} + } + screenReceiver = null + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + networkCallback?.let { + try { cm.unregisterNetworkCallback(it) } catch (_: Exception) {} + } + networkCallback = null + } + + // ── 冷却锁 ────────────────────────────────────────────────────────────── + + /** + * 强制两次 `establish()` 之间至少间隔 [RECONNECT_COOLDOWN_MS]。 + * 防止 Android `jniSetAddresses` 因 TUN fd 未释放而抛出 + * `IllegalArgumentException: Cannot set address`。 + */ + private suspend fun enforceReconnectCooldown() { + val last = lastEstablishMs.get() + if (last == 0L) return + val elapsed = System.currentTimeMillis() - last + val remaining = RECONNECT_COOLDOWN_MS - elapsed + if (remaining > 0) { + Timber.d("TunnelManager: cooldown — waiting %dms before establish()", remaining) + delay(remaining) + } + } + + // ── WireGuard 配置构建 ─────────────────────────────────────────────────── + private fun buildWgConfig( parsed: TunnelConfig, splitConfig: SplitTunnelConfig, @@ -350,22 +626,12 @@ class TunnelManager @Inject constructor(private val context: Context) { .parsePrivateKey(parsed.privateKey) .parseAddresses(parsed.address) - parsed.dns.forEach { dns -> - ifaceBuilder.parseDnsServers(dns) - } + parsed.dns.forEach { dns -> ifaceBuilder.parseDnsServers(dns) } parsed.mtu?.let { ifaceBuilder.setMtu(it) } when (splitConfig.mode) { - "exclude" -> { - if (splitConfig.apps.isNotEmpty()) { - ifaceBuilder.excludeApplications(splitConfig.apps.toSet()) - } - } - "include" -> { - if (splitConfig.apps.isNotEmpty()) { - ifaceBuilder.includeApplications(splitConfig.apps.toSet()) - } - } + "exclude" -> if (splitConfig.apps.isNotEmpty()) ifaceBuilder.excludeApplications(splitConfig.apps.toSet()) + "include" -> if (splitConfig.apps.isNotEmpty()) ifaceBuilder.includeApplications(splitConfig.apps.toSet()) } val peerBuilder = Peer.Builder() @@ -381,19 +647,14 @@ class TunnelManager @Inject constructor(private val context: Context) { val allowedIpsRaw = when (splitConfig.mode) { "exclude" -> { - if (splitConfig.networks.isEmpty()) { - parsed.allowedIps - } else { + if (splitConfig.networks.isEmpty()) parsed.allowedIps + else { val complement = CidrComplement.computeAllowedIps(splitConfig.networks) (complement + listOf("::/0") + dnsIps + VPN_SUBNET).distinct().joinToString(",") } } - "include" -> { - (splitConfig.networks + dnsIps + VPN_SUBNET).distinct().joinToString(",") - } - else -> { - parsed.allowedIps - } + "include" -> (splitConfig.networks + dnsIps + VPN_SUBNET).distinct().joinToString(",") + else -> parsed.allowedIps } peerBuilder.parseAllowedIPs(allowedIpsRaw) @@ -406,5 +667,23 @@ class TunnelManager @Inject constructor(private val context: Context) { companion object { private const val TUNNEL_NAME = "gatecontrol" private const val VPN_SUBNET = "10.8.0.0/24" + + /** 两次 establish() 之间的最小冷却时间(毫秒) */ + const val RECONNECT_COOLDOWN_MS = 5_000L + + /** 双轨探测间隔(毫秒) */ + private const val PROBE_INTERVAL_MS = 15_000L + + /** 轨道 A 默认探测目标(国内公共 DNS,不在 AllowedIPs 内) */ + const val DEFAULT_BYPASS_PROBE = "114.114.114.114" + + /** 轨道 B 默认探测目标(VPN 网关,在 AllowedIPs 内) */ + const val DEFAULT_TUNNEL_PROBE = "10.8.0.1" + + /** 单次探测超时(毫秒) */ + const val DEFAULT_PROBE_TIMEOUT_MS = 3_000L + + /** 连续精准阻断次数达到此值时触发熔断 */ + const val DEFAULT_FAILURE_THRESHOLD = 3 } } From 69c588b19e2a2e1a4277ee42c74b890d9b0914e3 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:29:24 +0800 Subject: [PATCH 59/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20VpnViewModel.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/ui/vpn/VpnViewModel.kt | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index b1f79e1..c5b5613 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -122,30 +122,9 @@ class VpnViewModel @Inject constructor( } } - // 周期性检查握手是否超时,若超时且启用了 autoReconnectOnBlock 则触发端口跳变 - viewModelScope.launch { - while (isActive) { - delay(45_000) - if (tunnelState.value is TunnelState.Connected) { - // Only attempt port-hop reconnect if the feature is enabled. - // loadStealthConfig() is cheap (reads SharedPreferences). - val shouldAutoReconnect = try { - loadStealthConfig().autoReconnectOnBlock - } catch (_: Exception) { false } - - if (shouldAutoReconnect) { - val stats = tunnelManager.getStatistics() - // stats == null means the backend couldn't read stats (not a - // handshake failure), so don't treat that as stale. - if (stats != null && - TunnelMonitor.isHandshakeStale(stats.lastHandshakeEpoch, 180L)) { - Timber.w("VpnViewModel: handshake stale — triggering port-hop reconnect") - tunnelManager.reconnectWithPortHop() - } - } - } - } - } + // 双轨探测引擎已在 TunnelManager.connectInternal() 中随连接自动启动, + // 负责精准判断"精准阻断"并触发端口跳变重连。 + // 此处不再重复轮询握手超时,避免误触发和竞争。 viewModelScope.launch { while (isActive) { From 0494d56ab7899cb14018e63a85b122fb569c86be Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:33:25 +0800 Subject: [PATCH 60/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TunnelManager.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/gatecontrol/android/tunnel/TunnelManager.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 5bae90d..7a56dc5 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -559,6 +559,9 @@ class TunnelManager @Inject constructor(private val context: Context) { screenReceiver = receiver } + // ACCESS_NETWORK_STATE is a normal permission declared in the app module's AndroidManifest.xml. + // Lint cannot see the app manifest from this library module, hence the suppression. + @android.annotation.SuppressLint("MissingPermission") private fun registerNetworkCallback() { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val request = NetworkRequest.Builder() From dfb0ad219e965944aafffa18b0e9c6bb649f6448 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 9 Jun 2026 00:46:56 +0000 Subject: [PATCH 61/71] chore: bump version to 1.6.7 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e02668..62a31e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.7] - 2026-06-09 + +### Changes +- 更新 TunnelManager.kt + +--- + ## [1.6.6] - 2026-06-08 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c9a829..28cfbf1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10606 - versionName = "1.6.6" + versionCode = 10607 + versionName = "1.6.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From de8038a20f89d24a6e218944f2c086b3827f27ec Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:22:35 +0800 Subject: [PATCH 62/71] Update SettingsRepository.kt --- .../android/data/SettingsRepository.kt | 237 ++++++------------ 1 file changed, 80 insertions(+), 157 deletions(-) diff --git a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt index 7a8efba..d0e6db6 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/SettingsRepository.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -22,6 +23,7 @@ class SettingsRepository @Inject constructor(private val dataStore: DataStore = dataStore.data.map { it[THEME] ?: "system" } fun getLocale(): Flow = dataStore.data.map { prefs -> @@ -55,177 +65,100 @@ class SettingsRepository @Inject constructor(private val dataStore: DataStore = dataStore.data.map { it[AUTO_CONNECT] ?: false } - fun getKillSwitch(): Flow = dataStore.data.map { it[KILL_SWITCH] ?: false } - - fun getSplitTunnelEnabled(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_ENABLED] ?: false } - - fun getSplitTunnelRoutes(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_ROUTES] ?: "" } - - fun getSplitTunnelApps(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_APPS] ?: "" } - - fun getSplitTunnelMode(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_MODE] ?: "off" } - - fun getSplitTunnelNetworks(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_NETWORKS] ?: "[]" } - - fun getSplitTunnelAppsV2(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_APPS_V2] ?: "[]" } - - fun getSplitTunnelAdminLocked(): Flow = - dataStore.data.map { it[SPLIT_TUNNEL_ADMIN_LOCKED] ?: false } - + fun getSplitTunnelEnabled(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_ENABLED] ?: false } + fun getSplitTunnelRoutes(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_ROUTES] ?: "" } + fun getSplitTunnelApps(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_APPS] ?: "" } + fun getSplitTunnelMode(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_MODE] ?: "off" } + fun getSplitTunnelNetworks(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_NETWORKS] ?: "[]" } + fun getSplitTunnelAppsV2(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_APPS_V2] ?: "[]" } + fun getSplitTunnelAdminLocked(): Flow = dataStore.data.map { it[SPLIT_TUNNEL_ADMIN_LOCKED] ?: false } fun getCheckInterval(): Flow = dataStore.data.map { it[CHECK_INTERVAL] ?: 30 } - - fun getConfigPollInterval(): Flow = - dataStore.data.map { it[CONFIG_POLL_INTERVAL] ?: 300 } - - // ── 防检测设置读取 ──────────────────────────────────────────────────── - - fun getStealthPortHopping(): Flow = - dataStore.data.map { it[STEALTH_PORT_HOPPING] ?: false } - - fun getStealthTimingJitter(): Flow = - dataStore.data.map { it[STEALTH_TIMING_JITTER] ?: false } - - fun getStealthPacketPadding(): Flow = - dataStore.data.map { it[STEALTH_PACKET_PADDING] ?: false } - - fun getStealthPaddingMtu(): Flow = - dataStore.data.map { it[STEALTH_PADDING_MTU] ?: 1280 } - - fun getStealthKeepaliveRandom(): Flow = - dataStore.data.map { it[STEALTH_KEEPALIVE_RANDOM] ?: false } - - fun getStealthKeepaliveJitterSec(): Flow = - dataStore.data.map { it[STEALTH_KEEPALIVE_JITTER_SEC] ?: 5 } - - fun getStealthDecoyDns(): Flow = - dataStore.data.map { it[STEALTH_DECOY_DNS] ?: false } - - fun getStealthAutoReconnect(): Flow = - dataStore.data.map { it[STEALTH_AUTO_RECONNECT] ?: false } - - /** 存储为逗号分隔字符串,如 "443,80,8080,51820" */ - fun getStealthCandidatePorts(): Flow> = - dataStore.data.map { prefs -> - prefs[STEALTH_CANDIDATE_PORTS] - ?.split(",") - ?.mapNotNull { it.trim().toIntOrNull() } - ?.filter { it in 1..65535 } - ?.takeIf { it.isNotEmpty() } - ?: listOf(443, 80, 8080, 8443, 53, 123, 51820) - } - - fun getStealthJitterMinMs(): Flow = - dataStore.data.map { it[STEALTH_JITTER_MIN_MS] ?: 100 } - - fun getStealthJitterMaxMs(): Flow = - dataStore.data.map { it[STEALTH_JITTER_MAX_MS] ?: 800 } - - // ── 防检测设置写入 ──────────────────────────────────────────────────── - - suspend fun setStealthPortHopping(enabled: Boolean) { - dataStore.edit { it[STEALTH_PORT_HOPPING] = enabled } - } - - suspend fun setStealthTimingJitter(enabled: Boolean) { - dataStore.edit { it[STEALTH_TIMING_JITTER] = enabled } - } - - suspend fun setStealthPacketPadding(enabled: Boolean) { - dataStore.edit { it[STEALTH_PACKET_PADDING] = enabled } - } + fun getConfigPollInterval(): Flow = dataStore.data.map { it[CONFIG_POLL_INTERVAL] ?: 300 } + + // ── 3. 完整保留:高级防检测属性流读取 (Stealth Getters) ─────────────────────── + fun getStealthPortHopping(): Flow = dataStore.data.map { it[STEALTH_PORT_HOPPING] ?: false } + fun getStealthTimingJitter(): Flow = dataStore.data.map { it[STEALTH_TIMING_JITTER] ?: false } + fun getStealthPacketPadding(): Flow = dataStore.data.map { it[STEALTH_PACKET_PADDING] ?: false } + fun getStealthPaddingMtu(): Flow = dataStore.data.map { it[STEALTH_PADDING_MTU] ?: 1280 } + fun getStealthKeepaliveRandom(): Flow = dataStore.data.map { it[STEALTH_KEEPALIVE_RANDOM] ?: false } + fun getStealthKeepaliveJitterSec(): Flow = dataStore.data.map { it[STEALTH_KEEPALIVE_JITTER_SEC] ?: 5 } + fun getStealthDecoyDns(): Flow = dataStore.data.map { it[STEALTH_DECOY_DNS] ?: false } + fun getStealthAutoReconnect(): Flow = dataStore.data.map { it[STEALTH_AUTO_RECONNECT] ?: false } + fun getStealthJitterMinMs(): Flow = dataStore.data.map { it[STEALTH_JITTER_MIN_MS] ?: 100 } + fun getStealthJitterMaxMs(): Flow = dataStore.data.map { it[STEALTH_JITTER_MAX_MS] ?: 800 } + + fun getStealthCandidatePorts(): Flow> = dataStore.data.map { prefs -> + prefs[STEALTH_CANDIDATE_PORTS] + ?.split(",") + ?.mapNotNull { it.trim().toIntOrNull() } + ?.filter { it in 1..65535 } + ?.takeIf { it.isNotEmpty() } + ?: listOf(443, 80, 8080, 8443, 53, 123, 51820) + } + + // ── 4. 全新加入:双轨探测属性流读取 (Probe Getters) ───────────────────────── + fun getProbeBypassTarget(): Flow = dataStore.data.map { it[PROBE_BYPASS_TARGET] ?: "223.5.5.5" } + fun getProbeBypassPort(): Flow = dataStore.data.map { it[PROBE_BYPASS_PORT] ?: 80 } + fun getProbeTunnelTarget(): Flow = dataStore.data.map { it[PROBE_TUNNEL_TARGET] ?: "8.8.8.8" } + fun getProbeTunnelPort(): Flow = dataStore.data.map { it[PROBE_TUNNEL_PORT] ?: 53 } + fun getProbeTimeoutMs(): Flow = dataStore.data.map { it[PROBE_TIMEOUT_MS] ?: 3000L } + fun getProbeFailureThreshold(): Flow = dataStore.data.map { it[PROBE_FAILURE_THRESHOLD] ?: 3 } + + // ── 5. 完整保留:高级防检测属性流写入 (Stealth Setters) ─────────────────────── + suspend fun setStealthPortHopping(enabled: Boolean) { dataStore.edit { it[STEALTH_PORT_HOPPING] = enabled } } + suspend fun setStealthTimingJitter(enabled: Boolean) { dataStore.edit { it[STEALTH_TIMING_JITTER] = enabled } } + suspend fun setStealthPacketPadding(enabled: Boolean) { dataStore.edit { it[STEALTH_PACKET_PADDING] = enabled } } + suspend fun setStealthKeepaliveRandom(enabled: Boolean) { dataStore.edit { it[STEALTH_KEEPALIVE_RANDOM] = enabled } } + suspend fun setStealthDecoyDns(enabled: Boolean) { dataStore.edit { it[STEALTH_DECOY_DNS] = enabled } } + suspend fun setStealthAutoReconnect(enabled: Boolean) { dataStore.edit { it[STEALTH_AUTO_RECONNECT] = enabled } } suspend fun setStealthPaddingMtu(mtu: Int) { val clamped = mtu.coerceIn(1024, 1500) dataStore.edit { it[STEALTH_PADDING_MTU] = clamped } } - - suspend fun setStealthKeepaliveRandom(enabled: Boolean) { - dataStore.edit { it[STEALTH_KEEPALIVE_RANDOM] = enabled } - } - suspend fun setStealthKeepaliveJitterSec(jitter: Int) { val clamped = jitter.coerceIn(1, 30) dataStore.edit { it[STEALTH_KEEPALIVE_JITTER_SEC] = clamped } } - - suspend fun setStealthDecoyDns(enabled: Boolean) { - dataStore.edit { it[STEALTH_DECOY_DNS] = enabled } - } - - suspend fun setStealthAutoReconnect(enabled: Boolean) { - dataStore.edit { it[STEALTH_AUTO_RECONNECT] = enabled } - } - suspend fun setStealthCandidatePorts(ports: List) { val valid = ports.filter { it in 1..65535 }.distinct() dataStore.edit { it[STEALTH_CANDIDATE_PORTS] = valid.joinToString(",") } } - suspend fun setStealthJitterMinMs(ms: Int) { val clamped = ms.coerceIn(0, 2000) dataStore.edit { it[STEALTH_JITTER_MIN_MS] = clamped } } - suspend fun setStealthJitterMaxMs(ms: Int) { val clamped = ms.coerceIn(50, 5000) dataStore.edit { it[STEALTH_JITTER_MAX_MS] = clamped } } - // ───────────────────────────────────────────────────────────────────── - - suspend fun setTheme(value: String) { - dataStore.edit { it[THEME] = value } - } - - suspend fun setLocale(value: String) { - dataStore.edit { it[LOCALE] = value } - } - - suspend fun setAutoConnect(value: Boolean) { - dataStore.edit { it[AUTO_CONNECT] = value } - } - - suspend fun setKillSwitch(value: Boolean) { - dataStore.edit { it[KILL_SWITCH] = value } - } - - suspend fun setSplitTunnelEnabled(value: Boolean) { - dataStore.edit { it[SPLIT_TUNNEL_ENABLED] = value } - } - - suspend fun setSplitTunnelRoutes(value: String) { - dataStore.edit { it[SPLIT_TUNNEL_ROUTES] = value } - } - - suspend fun setSplitTunnelApps(value: String) { - dataStore.edit { it[SPLIT_TUNNEL_APPS] = value } - } - - suspend fun setSplitTunnelMode(mode: String) { - dataStore.edit { it[SPLIT_TUNNEL_MODE] = mode } - } - - suspend fun setSplitTunnelNetworks(json: String) { - dataStore.edit { it[SPLIT_TUNNEL_NETWORKS] = json } - } - - suspend fun setSplitTunnelAppsV2(json: String) { - dataStore.edit { it[SPLIT_TUNNEL_APPS_V2] = json } - } - - suspend fun setSplitTunnelAdminLocked(locked: Boolean) { - dataStore.edit { it[SPLIT_TUNNEL_ADMIN_LOCKED] = locked } - } + // ── 6. 全新加入:双轨探测属性流写入 (Probe Setters) ───────────────────────── + suspend fun setProbeBypassTarget(target: String) { dataStore.edit { it[PROBE_BYPASS_TARGET] = target.trim() } } + suspend fun setProbeBypassPort(port: Int) { dataStore.edit { it[PROBE_BYPASS_PORT] = port.coerceIn(1, 65535) } } + suspend fun setProbeTunnelTarget(target: String) { dataStore.edit { it[PROBE_TUNNEL_TARGET] = target.trim() } } + suspend fun setProbeTunnelPort(port: Int) { dataStore.edit { it[PROBE_TUNNEL_PORT] = port.coerceIn(1, 65535) } } + suspend fun setProbeTimeoutMs(timeoutMs: Long) { dataStore.edit { it[PROBE_TIMEOUT_MS] = timeoutMs.coerceIn(500L, 15000L) } } + suspend fun setProbeFailureThreshold(threshold: Int) { dataStore.edit { it[PROBE_FAILURE_THRESHOLD] = threshold.coerceIn(1, 10) } } + + // ── 基础与数据迁移逻辑写入 ── + suspend fun setTheme(value: String) { dataStore.edit { it[THEME] = value } } + suspend fun setLocale(value: String) { dataStore.edit { it[LOCALE] = value } } + suspend fun setAutoConnect(value: Boolean) { dataStore.edit { it[AUTO_CONNECT] = value } } + suspend fun setKillSwitch(value: Boolean) { dataStore.edit { it[KILL_SWITCH] = value } } + suspend fun setSplitTunnelEnabled(value: Boolean) { dataStore.edit { it[SPLIT_TUNNEL_ENABLED] = value } } + suspend fun setSplitTunnelRoutes(value: String) { dataStore.edit { it[SPLIT_TUNNEL_ROUTES] = value } } + suspend fun setSplitTunnelApps(value: String) { dataStore.edit { it[SPLIT_TUNNEL_APPS] = value } } + suspend fun setSplitTunnelMode(mode: String) { dataStore.edit { it[SPLIT_TUNNEL_MODE] = mode } } + suspend fun setSplitTunnelNetworks(json: String) { dataStore.edit { it[SPLIT_TUNNEL_NETWORKS] = json } } + suspend fun setSplitTunnelAppsV2(json: String) { dataStore.edit { it[SPLIT_TUNNEL_APPS_V2] = json } } + suspend fun setSplitTunnelAdminLocked(locked: Boolean) { dataStore.edit { it[SPLIT_TUNNEL_ADMIN_LOCKED] = locked } } + suspend fun setCheckInterval(value: Int) { dataStore.edit { it[CHECK_INTERVAL] = value.coerceIn(5, 300) } } + suspend fun setConfigPollInterval(value: Int) { dataStore.edit { it[CONFIG_POLL_INTERVAL] = value.coerceIn(30, 3600) } } /** - * 迁移旧分流配置(v1 → v2)。 + * 迁移旧分流配置(v1 → v2),完整保留。 */ suspend fun migrateSplitTunnelIfNeeded() { dataStore.edit { prefs -> @@ -257,14 +190,4 @@ class SettingsRepository @Inject constructor(private val dataStore: DataStore Date: Tue, 9 Jun 2026 10:24:38 +0800 Subject: [PATCH 63/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20SettingsRepositoryTe?= =?UTF-8?q?st.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/data/SettingsRepositoryTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/core/data/src/test/java/com/gatecontrol/android/data/SettingsRepositoryTest.kt b/core/data/src/test/java/com/gatecontrol/android/data/SettingsRepositoryTest.kt index 1900544..b1722d4 100644 --- a/core/data/src/test/java/com/gatecontrol/android/data/SettingsRepositoryTest.kt +++ b/core/data/src/test/java/com/gatecontrol/android/data/SettingsRepositoryTest.kt @@ -66,6 +66,30 @@ class SettingsRepositoryTest { } } + // ── 完整保留:防检测设置项默认值验证 ── + @Test + fun `getStealthDefaultValues returns correct system defaults`() = runTest { + every { dataStore.data } returns flowOf(preferencesOf()) + + repository.getStealthPortHopping().test { assertFalse(awaitItem()); awaitComplete() } + repository.getStealthTimingJitter().test { assertFalse(awaitItem()); awaitComplete() } + repository.getStealthPacketPadding().test { assertFalse(awaitItem()); awaitComplete() } + repository.getStealthPaddingMtu().test { assertEquals(1280, awaitItem()); awaitComplete() } + } + + // ── 全新加入:双轨主动探测引擎默认值测试 ── + @Test + fun `getProbeDefaultValues returns structural fallbacks`() = runTest { + every { dataStore.data } returns flowOf(preferencesOf()) + + repository.getProbeBypassTarget().test { assertEquals("223.5.5.5", awaitItem()); awaitComplete() } + repository.getProbeBypassPort().test { assertEquals(80, awaitItem()); awaitComplete() } + repository.getProbeTunnelTarget().test { assertEquals("8.8.8.8", awaitItem()); awaitComplete() } + repository.getProbeTunnelPort().test { assertEquals(53, awaitItem()); awaitComplete() } + repository.getProbeTimeoutMs().test { assertEquals(3000L, awaitItem()); awaitComplete() } + repository.getProbeFailureThreshold().test { assertEquals(3, awaitItem()); awaitComplete() } + } + @Test fun `setTheme updates theme value`() = runTest { coEvery { dataStore.updateData(any()) } coAnswers { From b3795151a7a4032a31f1b1c4d7ee05fd2852ef4b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:26:10 +0800 Subject: [PATCH 64/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values/strings.xml | 50 ++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e278f0..c2db45c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,13 +2,11 @@ GateControl - VPN RDP Services Settings - Connected Disconnected Connecting… @@ -28,14 +26,12 @@ Download Upload - Data Usage 24h 7 Days 30 Days Total - Reachable Services No services available Auth @@ -45,7 +41,6 @@ DNS leak detected! DNS Servers: %1$s - RDP Connections Search… All @@ -104,7 +99,6 @@ Authentication required The server requires credentials. Enter them to continue. - Save Testing… Connection failed @@ -176,7 +170,45 @@ No updates available Check for Updates - + Stealth & Protocol Obfuscation + Advanced cryptographic padding and transport mutations to bypass deep packet inspection (DPI). + Dynamic Port Hopping + Cycle endpoints across candidate ports upon handshake degradation. + Candidate Ports + Comma-separated e.g. 443,80,51820 + Timing Jitter Obfuscation + Inject randomized multi-millisecond intervals between initialization vectors. + Minimum Jitter (ms) + Maximum Jitter (ms) + Packet Size Padding + Append deterministic random bytes to headers to mask WireGuard signature lengths. + Target Padding Boundary (MTU) + Randomized Persistent Keepalive + Dither standard 25s keepalive intervals to prevent traffic timing correlation attacks. + Keepalive Maximum Jitter (seconds) + Decoy DNS Chaff + Prepend fake unencrypted DNS queries to trusted nodes prior to handshake execution. + Active Circuit Breaker Reconnect + Forcefully re-initialize virtual network interface when upstream blockages occur. + + Dual-Track Active Probing Engine + Performs screen-on differential analysis on local and encrypted networks. Automatically switches fallback ports when local connectivity is up but tunnel is blocked. + Track A: Local Direct Prober (Bypass VPN) + Direct Prober Target IP/Domain + e.g. 223.5.5.5 + Direct Prober TCP Port + Default: 80 + Track B: Tunnel Encrypted Prober (Via VPN) + Tunnel Prober Target IP/Domain + Must be inside AllowedIPs e.g. 8.8.8.8 + Tunnel Prober TCP Port + Default: 53 + Circuit Breaker Parameters + Handshake Timeout (ms) + Default: 3000 + Consecutive Failure Threshold + Default: 3 + Connect to GateControl Set up your VPN connection Scan QR Code @@ -187,7 +219,6 @@ Registration failed: %1$s Invalid WireGuard config - Logs All 24h @@ -197,7 +228,6 @@ Refresh Export - VPN Status Updates RDP Sessions @@ -211,12 +241,10 @@ Peer has expired VPN access has been disabled by the server. Disconnected. - GateControl VPN GateControl VPN Not connected - Cancel OK Save From 3214f6a8fa5069d876170a9d5de2cdf0a158f4fa Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:26:44 +0800 Subject: [PATCH 65/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-de/strings.xml | 50 ++++++++++++++++++++------ 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2c6c208..d23f101 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,13 +2,11 @@ GateControl - VPN RDP Dienste Einstellungen - Verbunden Getrennt Verbinde… @@ -28,14 +26,12 @@ Download Upload - Datenverbrauch 24 Std. 7 Tage 30 Tage Gesamt - Erreichbare Dienste Keine Dienste verfügbar Auth @@ -45,7 +41,6 @@ DNS-Leak erkannt! DNS-Server: %1$s - RDP-Verbindungen Suchen… Alle @@ -104,7 +99,6 @@ Authentifizierung erforderlich Der Server benötigt Zugangsdaten. Gib sie ein, um fortzufahren. - Speichern Teste… Verbindung fehlgeschlagen @@ -176,7 +170,45 @@ Keine Updates verfügbar Nach Updates suchen - + Tarnung & Protokoll-Obfuskation + Erweiterte kryptografische Auffüllung und Transportmutationen zur Umgehung von Deep Packet Inspection (DPI). + Dynamisches Port-Hopping + Endpunkte bei Verschlechterung des Handshakes über Kandidaten-Ports wechseln. + Kandidaten-Ports + Kommagetrennt z. B. 443,80,51820 + Timing-Jitter-Obfuskation + Zufällige Millisekunden-Intervalle zwischen Initialisierungsvektoren einfügen. + Minimaler Jitter (ms) + Maximaler Jitter (ms) + Paketgrößen-Auffüllung (Padding) + Deterministische Zufallsbytes an Header anhängen, um WireGuard-Signaturlängen zu maskieren. + Ziel-Padding-Grenze (MTU) + Zufälliges persistentes Keepalive + Standardmäßige 25s-Keepalive-Intervalle variieren, um Traffic-Timing-Analyseangriffe zu verhindern. + Maximaler Keepalive-Jitter (Sekunden) + DNS-Scheinabfragen (Chaff) + Unverschlüsselte DNS-Scheinabfragen an vertrauenswürdige Knoten vor dem Handshake senden. + Aktive Circuit-Breaker-Wiederverbindung + Erzwinge die Neuinitialisierung der virtuellen Netzwerkschnittstelle bei Blockaden im Upstream. + + Duale aktive Prober-Engine + Der Prober führt bei aktivem Bildschirm eine differenzielle Bewertung des lokalen physischen Netzwerks und des Tunnels durch. Wenn die Kontrollschwelle überschritten wird, löst er eine Port-Wiederverbindung aus. + Spur A: Lokaler Direktstrom-Prober (Bypass VPN) + Direkt-Prober Ziel-IP/Domain + z. B. 223.5.5.5 + Direkt-Prober TCP-Port + Standard: 80 + Spur B: Tunnel-Verschlüsselungs-Prober (Über VPN) + Tunnel-Prober Ziel-IP/Domain + Muss in AllowedIPs sein z. B. 8.8.8.8 + Tunnel-Prober TCP-Port + Standard: 53 + Leistungsschalter-Steuerparameter + Handshake-Timeout-Schwelle (ms) + Standard: 3000 + Fehlerschwelle für automatische Reconnects + Standard: 3 + Mit GateControl verbinden VPN-Verbindung einrichten QR-Code scannen @@ -187,7 +219,6 @@ Registrierung fehlgeschlagen: %1$s Ungültige WireGuard-Konfiguration - Protokolle Alle 24 Std. @@ -197,7 +228,6 @@ Aktualisieren Exportieren - VPN-Status Updates RDP-Sitzungen @@ -211,12 +241,10 @@ Peer ist abgelaufen VPN-Zugang wurde vom Server deaktiviert. Verbindung getrennt. - GateControl VPN GateControl VPN Nicht verbunden - Abbrechen OK Speichern From e75840772a4b0c5e3c67867ff47a18c913056262 Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:27:16 +0800 Subject: [PATCH 66/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20strings.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-zh/strings.xml | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 4834467..544d58e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -170,6 +170,45 @@ 当前已是最新版本 检查更新 + 流量隐蔽与协议混淆层 + 通过高级流密码填充与传输层突变,绕过深度数据包检测 (DPI) 及特征指纹审查。 + 动态端口跳变 (Port Hopping) + 当检测到握手链路质量劣化时,在候选端口组中动态循环切换端点。 + 候选跳变端口 + 用英文逗号分隔,例如:443,80,51820 + 时序抖动混淆 (Timing Jitter) + 在握手初始化向量之间注入毫秒级的随机延迟间隔,扰乱包间隔特征。 + 最小抖动延迟 (毫秒) + 最大抖动延迟 (毫秒) + 数据包大小填充 (Padding) + 在报文尾部追加确定性随机字节以遮蔽底层特定协议固有的特征报文长度。 + 目标填充边界 (MTU) + 随机化持久心跳 (Keepalive) + 对标准的 25 秒心跳间隔进行平滑抖动,防止基于通信时序相关性的流量分析攻击。 + 心跳最大时序抖动 (秒) + 诱饵 DNS 干扰流 (Chaff) + 在隧道建立前,抢先向可信节点并发发送伪造的明文 DNS 查询,掩蔽握手特征。 + 主动断路器强制重连 + 当检测到上行链路被阻断或封锁时,强制重新初始化虚拟网络接口进行自愈。 + + 双轨主动探测引擎设置 + 探测引擎在亮屏时对本地物理网络与出海隧道进行差分裁决。当“本地通但隧道断”触发熔断阈值后,会自动调用混淆层切换高防隐蔽端口进行重连。 + 轨道 A:本地直连流探测 (绕过 VPN 网卡) + 直连探测主机 IP/域名 + 例如: 223.5.5.5 + 直连探测 TCP 端口 + 默认: 80 + 轨道 B:隧道加密流探测 (通过 VPN 转发) + 隧道探测主机 IP/域名 + 必须包含在 AllowedIPs 中,例如: 8.8.8.8 + 隧道探测 TCP 端口 + 默认: 53 + 引擎熔断器控制参数 + 单次探测握手超时阈值 (毫秒) + 默认: 3000 + 触发重连阻断连续失败次数 + 默认: 3 + 连接到 GateControl 配置 VPN 连接 扫描二维码 From 1d154d60fb8979bb853e39b55f8dffb9a490885e Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 9 Jun 2026 02:41:05 +0000 Subject: [PATCH 67/71] chore: bump version to 1.6.8 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a31e9..d272f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.8] - 2026-06-09 + +### Changes +- 更新 strings.xml + +--- + ## [1.6.7] - 2026-06-09 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28cfbf1..c5ba634 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10607 - versionName = "1.6.7" + versionCode = 10608 + versionName = "1.6.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 7bf7f5b848d8886144c73aa0b9438b340a2bed5b Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:08:14 +0800 Subject: [PATCH 68/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TunnelManager.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/tunnel/TunnelManager.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 7a56dc5..31d73f2 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -366,13 +366,13 @@ class TunnelManager @Inject constructor(private val context: Context) { /** * 轨道 A:直连探测。 - * 向 [target] 发起 TCP 连接(端口 80),不绑定 VPN 网络接口。 + * 向 [target] 发起 TCP 连接(端口 443),不绑定 VPN 网络接口。 * 探测包不会进入 WireGuard 隧道(该 IP 不在 AllowedIPs 内)。 */ private fun probeBypass(target: String, timeoutMs: Long): Boolean { return try { Socket().use { socket -> - socket.connect(InetSocketAddress(target, 53), timeoutMs.toInt()) + socket.connect(InetSocketAddress(target, 443), timeoutMs.toInt()) true } } catch (e: Exception) { @@ -381,25 +381,25 @@ class TunnelManager @Inject constructor(private val context: Context) { } } - /** + /** * 轨道 B:隧道探测。 - * 向 [target](隧道内 IP,默认 10.8.0.1)发送 UDP 包。 - * GoBackend 会将其加密后通过 WireGuard 发出;若隧道被封,UDP 无响应即超时。 - * 用 DatagramSocket 发送后等待可达性(通过 InetAddress.isReachable 绑定 TUN 接口)。 + * 向 [target](海外常用域名/IP,如 www.google.com)发起 TCP 连接(端口 443)。 + * 流量会自动走系统默认路由进入 WireGuard 隧道;若隧道被精准阻断,TCP 握手将超时返回 false。 */ private fun probeTunnel(target: String, timeoutMs: Long): Boolean { return try { - // InetAddress.isReachable 在 Android 上使用 ICMP echo(需 root)或 TCP 7 端口。 - // 对于 VPN 隧道内 IP,GoBackend 的 TUN 接口会拦截 ICMP,实际上可达性 - // 反映隧道是否通。若 GoBackend 未转发则超时返回 false。 - val addr = InetAddress.getByName(target) - addr.isReachable(timeoutMs.toInt()) + java.net.Socket().use { socket -> + // 使用和轨道 A 相同的 TCP 握手,走海外最通用的 HTTPS 443 端口 + socket.connect(java.net.InetSocketAddress(target, 443), timeoutMs.toInt()) + true + } } catch (e: Exception) { Timber.d("TunnelProbe: tunnel probe %s failed: %s", target, e.message) false } } + // ── 端口跳变重连 ──────────────────────────────────────────────────────── /** @@ -669,7 +669,7 @@ class TunnelManager @Inject constructor(private val context: Context) { companion object { private const val TUNNEL_NAME = "gatecontrol" - private const val VPN_SUBNET = "10.8.0.0/24" + private const val VPN_SUBNET = "0.0.0.0/32" /** 两次 establish() 之间的最小冷却时间(毫秒) */ const val RECONNECT_COOLDOWN_MS = 5_000L @@ -678,10 +678,10 @@ class TunnelManager @Inject constructor(private val context: Context) { private const val PROBE_INTERVAL_MS = 15_000L /** 轨道 A 默认探测目标(国内公共 DNS,不在 AllowedIPs 内) */ - const val DEFAULT_BYPASS_PROBE = "114.114.114.114" + const val DEFAULT_BYPASS_PROBE = "10010.com" /** 轨道 B 默认探测目标(VPN 网关,在 AllowedIPs 内) */ - const val DEFAULT_TUNNEL_PROBE = "10.8.0.1" + const val DEFAULT_TUNNEL_PROBE = "google.com" /** 单次探测超时(毫秒) */ const val DEFAULT_PROBE_TIMEOUT_MS = 3_000L From 1dde30a38d6ef9a719b3a0c009d7c56fad95c9b3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 9 Jun 2026 03:20:45 +0000 Subject: [PATCH 69/71] chore: bump version to 1.6.9 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d272f23..24595cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.9] - 2026-06-09 + +### Changes +- 更新 TunnelManager.kt + +--- + ## [1.6.8] - 2026-06-09 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5ba634..f67a679 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10608 - versionName = "1.6.8" + versionCode = 10609 + versionName = "1.6.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From d6c4328283bdfc732bb9a834c84bf36ff162ec9c Mon Sep 17 00:00:00 2001 From: BELLA12GLG <71425111+BELLA12GLG@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:43:19 +0800 Subject: [PATCH 70/71] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TunnelManager.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/gatecontrol/android/tunnel/TunnelManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt index 31d73f2..3d50a93 100644 --- a/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt +++ b/core/tunnel/src/main/java/com/gatecontrol/android/tunnel/TunnelManager.kt @@ -678,7 +678,7 @@ class TunnelManager @Inject constructor(private val context: Context) { private const val PROBE_INTERVAL_MS = 15_000L /** 轨道 A 默认探测目标(国内公共 DNS,不在 AllowedIPs 内) */ - const val DEFAULT_BYPASS_PROBE = "10010.com" + const val DEFAULT_BYPASS_PROBE = "bing.com" /** 轨道 B 默认探测目标(VPN 网关,在 AllowedIPs 内) */ const val DEFAULT_TUNNEL_PROBE = "google.com" From fd60605a6387c414fab7a1ba0076121f050d36da Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 9 Jun 2026 03:56:09 +0000 Subject: [PATCH 71/71] chore: bump version to 1.6.10 --- CHANGELOG.md | 7 +++++++ app/build.gradle.kts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24595cc..50e7df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.10] - 2026-06-09 + +### Changes +- 更新 TunnelManager.kt + +--- + ## [1.6.9] - 2026-06-09 ### Changes diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f67a679..b7dca35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.gatecontrol.client" minSdk = 31 targetSdk = 35 - versionCode = 10609 - versionName = "1.6.9" + versionCode = 10610 + versionName = "1.6.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"