diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt index afff326..0c606f5 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt @@ -31,7 +31,12 @@ import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.theme.ConnectedGreen import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverImportResult +import com.masterdns.vpn.util.ResolverImportStats +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private data class ImportedProfileDraft( val profile: ProfileEntity, @@ -49,7 +54,7 @@ fun ProfilesScreen( var showEditor by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } var importedDraft by remember { mutableStateOf(null) } - var importedResolvers by remember { mutableStateOf(null) } + var importedResolvers by remember { mutableStateOf(null) } var profilePendingDelete by remember { mutableStateOf(null) } val context = androidx.compose.ui.platform.LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -76,15 +81,23 @@ fun ProfilesScreen( ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - val text = readTextFromUri(context, uri).trim() - if (text.isBlank()) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) } - return@rememberLauncherForActivityResult + scope.launch { + val fileName = readDisplayName(context, uri) ?: "client_resolvers.txt" + val text = withContext(Dispatchers.IO) { readTextFromUri(context, uri) } + val result = withContext(Dispatchers.Default) { + ResolverAnalyzer.analyzeAndNormalize(text, fileName) + } + if (result == null || result.normalizedText.isBlank()) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) + return@launch + } + importedResolvers = result + editingProfile = null + showEditor = true + snackbarHostState.showSnackbar( + context.getString(R.string.profiles_resolvers_imported_stats_msg, result.stats.summary()) + ) } - importedResolvers = text - editingProfile = null - showEditor = true - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_imported_msg)) } } Scaffold( @@ -303,7 +316,7 @@ fun ProfileCard( private fun ProfileEditorDialog( profile: ProfileEntity?, importedDraft: ImportedProfileDraft?, - importedResolvers: String?, + importedResolvers: ResolverImportResult?, onImportToml: () -> Unit, onImportResolvers: () -> Unit, onSave: (ProfileEntity) -> Unit, @@ -365,8 +378,8 @@ private fun ProfileEditorDialog( encryptionKey = importedProfile.encryptionKey resolvers = importedProfile.resolvers } - if (!importedResolvers.isNullOrBlank()) { - resolvers = importedResolvers + if (importedResolvers != null) { + resolvers = importedResolvers.normalizedText } validationMessage = null } @@ -573,6 +586,9 @@ private fun ProfileEditorDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) ) } + importedResolvers?.stats?.let { stats -> + ResolverImportStatsCard(stats) + } validationMessage?.let { message -> Text( text = message, @@ -603,13 +619,18 @@ private fun ProfileEditorDialog( } val baseProfile = profile ?: importedDraft?.profile ?: ProfileEntity(name = "", domains = "") val domainJson = gson.toJson(finalDomainList) + val updatedProfile = baseProfile.copy( + name = name.trim(), + domains = domainJson, + encryptionKey = encryptionKey.trim(), + resolvers = resolvers.trim() + ) onSave( - baseProfile.copy( - name = name.trim(), - domains = domainJson, - encryptionKey = encryptionKey.trim(), - resolvers = resolvers.trim() - ) + if (importedResolvers != null) { + ResolverAnalyzer.withImportStats(updatedProfile, importedResolvers.stats) + } else { + updatedProfile + } ) } ) { @@ -624,6 +645,46 @@ private fun ProfileEditorDialog( ) } +@Composable +private fun ResolverImportStatsCard(stats: ResolverImportStats) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + stringResource(R.string.profiles_resolvers_import_preview_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource( + R.string.profiles_resolvers_import_preview_body, + stats.uniqueResolvers, + stats.duplicateResolvers, + stats.invalidLines, + stats.cidrRanges + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + if (stats.truncated || stats.skippedCidrRanges > 0) { + Text( + stringResource( + R.string.profiles_resolvers_import_preview_limits, + stats.skippedCidrRanges + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + private val gson = Gson() private fun readTextFromUri(context: Context, uri: Uri): String { diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt index ecde924..d99ca5f 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt @@ -3,6 +3,7 @@ package com.masterdns.vpn.ui.settings import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -72,7 +73,10 @@ import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.components.mdv.controls.MdvTopAppBar import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private enum class FieldType { TEXT, BOOL, OPTION } @@ -281,8 +285,20 @@ fun SettingsScreen( val selected = profile if (uri != null && selected != null) { val text = readTextFromUri(context, uri) - viewModel.importResolvers(selected, text) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_imported_msg)) } + val fileName = readDisplayName(context, uri) ?: "client_resolvers.txt" + scope.launch { + val result = withContext(Dispatchers.Default) { + ResolverAnalyzer.analyzeAndNormalize(text, fileName) + } + if (result == null || result.normalizedText.isBlank()) { + snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_empty_msg)) + return@launch + } + viewModel.importResolvers(selected, result) + snackbarHostState.showSnackbar( + context.getString(R.string.settings_resolvers_imported_stats_msg, result.stats.summary()) + ) + } } } val pickMtuExportLauncher = rememberLauncherForActivityResult( @@ -603,6 +619,14 @@ private fun readTextFromUri(context: Context, uri: Uri): String { }.getOrDefault("") } +private fun readDisplayName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex < 0 || !cursor.moveToFirst()) return@use null + cursor.getString(nameIndex) + }?.trim()?.takeIf { it.isNotEmpty() } +} + private fun writeTextToUri(context: Context, uri: Uri, content: String) { runCatching { context.contentResolver.openOutputStream(uri, "w")?.bufferedWriter()?.use { diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt index 2f11b3b..a7b8a7a 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt @@ -8,6 +8,8 @@ import com.google.gson.reflect.TypeToken import com.masterdns.vpn.data.local.ProfileEntity import com.masterdns.vpn.data.repository.ProfileRepository import com.masterdns.vpn.util.ConfigGenerator +import com.masterdns.vpn.util.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverImportResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -73,9 +75,10 @@ class SettingsViewModel @Inject constructor( return result } - fun importResolvers(profile: ProfileEntity, resolversText: String) { + fun importResolvers(profile: ProfileEntity, result: ResolverImportResult) { viewModelScope.launch { - profileRepository.updateProfile(profile.copy(resolvers = resolversText.trim())) + val updated = profile.copy(resolvers = result.normalizedText.trim()) + profileRepository.updateProfile(ResolverAnalyzer.withImportStats(updated, result.stats)) } } diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt new file mode 100644 index 0000000..6a01ec3 --- /dev/null +++ b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt @@ -0,0 +1,266 @@ +package com.masterdns.vpn.util + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.masterdns.vpn.data.local.ProfileEntity +import java.math.BigInteger +import java.net.InetAddress +import java.util.Locale + +data class ResolverImportStats( + val fileName: String = "", + val rawLines: Int = 0, + val blankLines: Int = 0, + val commentLines: Int = 0, + val validResolvers: Int = 0, + val duplicateResolvers: Int = 0, + val invalidLines: Int = 0, + val cidrRanges: Int = 0, + val cidrExpandedResolvers: Int = 0, + val skippedCidrRanges: Int = 0, + val customPorts: Int = 0, + val defaultPorts: Int = 0, + val truncated: Boolean = false +) { + val uniqueResolvers: Int + get() = validResolvers + + fun summary(): String { + val capped = if (truncated) ", capped" else "" + return "$uniqueResolvers usable, $duplicateResolvers duplicate, $invalidLines invalid$capped" + } +} + +data class ResolverImportResult( + val normalizedText: String, + val stats: ResolverImportStats +) + +object ResolverAnalyzer { + const val MAX_IMPORT_BYTES = 2 * 1024 * 1024 + const val MAX_NORMALIZED_RESOLVERS = 65_536 + const val STATS_ADVANCED_KEY = "ANDROID_RESOLVER_IMPORT_STATS" + const val SOURCE_ADVANCED_KEY = "ANDROID_RESOLVER_SOURCE" + const val SOURCE_INLINE_IMPORT = "INLINE_IMPORT" + + private const val DEFAULT_PORT = 53 + private const val MAX_CIDR_HOSTS = 65_536 + private val gson = Gson() + + fun analyzeAndNormalize(text: String, fileName: String = ""): ResolverImportResult? { + if (text.isBlank()) return null + if (text.toByteArray(Charsets.UTF_8).size > MAX_IMPORT_BYTES) { + return ResolverImportResult( + normalizedText = "", + stats = ResolverImportStats(fileName = fileName, truncated = true) + ) + } + + val seen = linkedSetOf() + val normalized = ArrayList() + var rawLines = 0 + var blankLines = 0 + var commentLines = 0 + var duplicateResolvers = 0 + var invalidLines = 0 + var cidrRanges = 0 + var cidrExpandedResolvers = 0 + var skippedCidrRanges = 0 + var customPorts = 0 + var defaultPorts = 0 + var truncated = false + + fun addResolver(ip: String, entry: ResolverEntry) { + if (normalized.size >= MAX_NORMALIZED_RESOLVERS) { + truncated = true + return + } + val key = "${ip.lowercase(Locale.US)}:${entry.port}" + if (!seen.add(key)) { + duplicateResolvers++ + return + } + normalized += formatRuntimeResolver(ip, entry.port) + if (entry.hasExplicitPort) customPorts++ else defaultPorts++ + } + + text.lineSequence().forEach { raw -> + if (truncated) return@forEach + rawLines++ + val trimmedRaw = raw.trim() + val line = raw.substringBefore("#").trim() + when { + trimmedRaw.isEmpty() -> { + blankLines++ + return@forEach + } + line.isEmpty() -> { + commentLines++ + return@forEach + } + } + + val entry = parseEntry(line) + if (entry == null) { + invalidLines++ + return@forEach + } + + if (entry.host.contains("/")) { + cidrRanges++ + val hosts = expandCidr(entry.host) + when { + hosts == null -> invalidLines++ + hosts.isEmpty() -> skippedCidrRanges++ + else -> hosts.forEach { ip -> + cidrExpandedResolvers++ + addResolver(ip, entry) + } + } + return@forEach + } + + val ip = parseIp(entry.host) + if (ip == null) { + invalidLines++ + return@forEach + } + addResolver(ip, entry) + } + + if (normalized.isEmpty()) return null + + val stats = ResolverImportStats( + fileName = fileName, + rawLines = rawLines, + blankLines = blankLines, + commentLines = commentLines, + validResolvers = normalized.size, + duplicateResolvers = duplicateResolvers, + invalidLines = invalidLines, + cidrRanges = cidrRanges, + cidrExpandedResolvers = cidrExpandedResolvers, + skippedCidrRanges = skippedCidrRanges, + customPorts = customPorts, + defaultPorts = defaultPorts, + truncated = truncated + ) + return ResolverImportResult( + normalizedText = normalized.joinToString(separator = "\n", postfix = "\n"), + stats = stats + ) + } + + fun withImportStats(profile: ProfileEntity, stats: ResolverImportStats): ProfileEntity { + val advanced = parseAdvanced(profile.advancedJson).toMutableMap() + advanced[SOURCE_ADVANCED_KEY] = SOURCE_INLINE_IMPORT + advanced[STATS_ADVANCED_KEY] = gson.toJson(stats) + return profile.copy(advancedJson = gson.toJson(advanced)) + } + + fun statsFromProfile(profile: ProfileEntity): ResolverImportStats? { + val json = parseAdvanced(profile.advancedJson)[STATS_ADVANCED_KEY] ?: return null + return runCatching { gson.fromJson(json, ResolverImportStats::class.java) }.getOrNull() + } + + private data class ResolverEntry( + val host: String, + val port: Int, + val hasExplicitPort: Boolean + ) + + private fun parseEntry(line: String): ResolverEntry? { + val text = line.trim() + if (text.isEmpty()) return null + if (text.startsWith("[")) { + val end = text.indexOf(']') + if (end <= 0) return null + val host = text.substring(1, end).trim() + val remainder = text.substring(end + 1).trim() + if (remainder.isEmpty()) return ResolverEntry(host, DEFAULT_PORT, false) + if (!remainder.startsWith(":")) return null + val port = remainder.drop(1).trim().toIntOrNull() ?: return null + return if (port in 1..65535) ResolverEntry(host, port, true) else null + } + + val colonCount = text.count { it == ':' } + val canHavePort = colonCount == 1 || ("/" in text && text.lastIndexOf(':') > text.lastIndexOf('/')) + if (canHavePort) { + val idx = text.lastIndexOf(':') + val host = text.substring(0, idx).trim() + val port = text.substring(idx + 1).trim().toIntOrNull() + if (host.isNotEmpty() && port != null && port in 1..65535) { + return ResolverEntry(host, port, true) + } + } + return ResolverEntry(text, DEFAULT_PORT, false) + } + + private fun parseIp(host: String): String? { + val text = host.trim() + val numericCandidate = when { + "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) + ":" in text -> text.matches(Regex("[0-9A-Fa-f:.]+")) + else -> false + } + if (!numericCandidate) return null + return runCatching { InetAddress.getByName(text).hostAddress }.getOrNull() + } + + private fun expandCidr(value: String): List? { + val parts = value.split("/") + if (parts.size != 2) return null + val normalizedBase = parseIp(parts[0].trim()) ?: return null + val base = runCatching { InetAddress.getByName(normalizedBase) }.getOrNull() ?: return null + val bytes = base.address + val totalBits = bytes.size * 8 + val prefixBits = parts[1].trim().toIntOrNull() ?: return null + if (prefixBits !in 0..totalBits) return null + val hostBits = totalBits - prefixBits + if (hostBits > 16) return emptyList() + + val total = BigInteger.ONE.shiftLeft(hostBits) + val usableStart = if (bytes.size == 4 && prefixBits < 31) BigInteger.ONE else BigInteger.ZERO + val usableEndExclusive = if (bytes.size == 4 && prefixBits < 31) total.subtract(BigInteger.ONE) else total + val usableCount = usableEndExclusive.subtract(usableStart) + if (usableCount <= BigInteger.ZERO || usableCount > BigInteger.valueOf(MAX_CIDR_HOSTS.toLong())) { + return emptyList() + } + + val baseInt = BigInteger(1, bytes) + val mask = BigInteger.ONE.shiftLeft(totalBits).subtract(BigInteger.ONE).xor( + BigInteger.ONE.shiftLeft(hostBits).subtract(BigInteger.ONE) + ) + val network = baseInt.and(mask) + val result = ArrayList(usableCount.toInt()) + var offset = usableStart + while (offset < usableEndExclusive) { + result += addressFromBigInteger(network.add(offset), bytes.size).hostAddress + offset = offset.add(BigInteger.ONE) + } + return result + } + + private fun addressFromBigInteger(value: BigInteger, byteCount: Int): InetAddress { + val raw = value.toByteArray() + val out = ByteArray(byteCount) + val copyStart = maxOf(0, raw.size - byteCount) + val copyLength = raw.size - copyStart + System.arraycopy(raw, copyStart, out, byteCount - copyLength, copyLength) + return InetAddress.getByAddress(out) + } + + private fun formatRuntimeResolver(ip: String, port: Int): String { + if (port == DEFAULT_PORT) return ip + return if (":" in ip) "[$ip]:$port" else "$ip:$port" + } + + private fun parseAdvanced(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6b3f371..b1d3275 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ TOML exported TOML imported to form Resolvers imported into profile + Resolvers imported: %1$s + No usable resolvers found in that file MTU export destination selected ⚠ Port %1$d requires root access on Android. The app will automatically use port 5353 instead. Global settings saved and applied @@ -99,6 +101,10 @@ TOML imported into profile form Resolvers file is empty Resolvers imported into profile form + Resolvers imported: %1$s + Resolver import preview + %1$d usable, %2$d duplicate, %3$d invalid, %4$d CIDR ranges + Large CIDR ranges skipped: %1$d. Import may be capped for stability. Profile name is required. Add at least one domain before saving. Encryption key is required.