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 bb76830..47b9a51 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 @@ -295,10 +295,15 @@ private fun ProfileEditorDialog( var newDomainInput by remember { mutableStateOf("") } var isDomainInputFocused by remember { mutableStateOf(false) } var encryptionKey by remember { mutableStateOf(profile?.encryptionKey.orEmpty()) } - var resolvers by remember { mutableStateOf(profile?.resolvers ?: "8.8.8.8") } + var resolvers by remember { mutableStateOf(profile?.resolvers.orEmpty()) } + var validationMessage by remember { mutableStateOf(null) } var showKey by remember { mutableStateOf(false) } var showResolversEditor by remember { mutableStateOf(false) } val largeResolversText = resolvers.length > 6000 + val nameRequiredMsg = stringResource(R.string.profiles_name_required_msg) + val domainRequiredMsg = stringResource(R.string.profiles_domain_required_msg) + val keyRequiredMsg = stringResource(R.string.profiles_encryption_key_required_msg) + val resolverRequiredMsg = stringResource(R.string.profiles_resolvers_required_msg) LaunchedEffect(profile?.id) { if (profile != null) { @@ -312,6 +317,7 @@ private fun ProfileEditorDialog( domainList.addAll(parsed) encryptionKey = profile.encryptionKey resolvers = profile.resolvers + validationMessage = null showResolversEditor = false } } @@ -331,6 +337,7 @@ private fun ProfileEditorDialog( if (!importedResolvers.isNullOrBlank()) { resolvers = importedResolvers } + validationMessage = null } AlertDialog( @@ -351,8 +358,12 @@ private fun ProfileEditorDialog( ) { OutlinedTextField( value = name, - onValueChange = { name = it }, + onValueChange = { + name = it + validationMessage = null + }, label = { Text(stringResource(R.string.profiles_name)) }, + isError = validationMessage == nameRequiredMsg, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -426,8 +437,12 @@ private fun ProfileEditorDialog( ) { OutlinedTextField( value = newDomainInput, - onValueChange = { newDomainInput = it }, + onValueChange = { + newDomainInput = it + validationMessage = null + }, label = { Text(stringResource(R.string.profiles_domain_hint)) }, + isError = validationMessage == domainRequiredMsg, placeholder = { if (isDomainInputFocused) { Text("(e.g. v.example.com)") @@ -460,8 +475,12 @@ private fun ProfileEditorDialog( OutlinedTextField( value = encryptionKey, - onValueChange = { encryptionKey = it }, + onValueChange = { + encryptionKey = it + validationMessage = null + }, label = { Text(stringResource(R.string.profiles_encryption_key)) }, + isError = validationMessage == keyRequiredMsg, singleLine = true, modifier = Modifier.fillMaxWidth(), visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(), @@ -512,13 +531,24 @@ private fun ProfileEditorDialog( } else { OutlinedTextField( value = resolvers, - onValueChange = { resolvers = it }, + onValueChange = { + resolvers = it + validationMessage = null + }, label = { Text(stringResource(R.string.profiles_resolvers_label)) }, + isError = validationMessage == resolverRequiredMsg, modifier = Modifier.fillMaxWidth().height(120.dp), maxLines = 6, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) ) } + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } } }, confirmButton = { @@ -530,18 +560,27 @@ private fun ProfileEditorDialog( } else { domainList.toList() } + validationMessage = when { + name.isBlank() -> nameRequiredMsg + finalDomainList.isEmpty() -> domainRequiredMsg + encryptionKey.isBlank() -> keyRequiredMsg + resolvers.isBlank() -> resolverRequiredMsg + else -> null + } + if (validationMessage != null) { + return@FilledTonalButton + } val baseProfile = profile ?: importedDraft?.profile ?: ProfileEntity(name = "", domains = "") val domainJson = gson.toJson(finalDomainList) onSave( baseProfile.copy( - name = name.trim().ifEmpty { "Profile" }, + name = name.trim(), domains = domainJson, - encryptionKey = encryptionKey, + encryptionKey = encryptionKey.trim(), resolvers = resolvers.trim() ) ) - }, - enabled = name.isNotBlank() && (domainList.isNotEmpty() || newDomainInput.isNotBlank()) + } ) { Text(stringResource(R.string.action_save)) } @@ -614,7 +653,7 @@ private fun parseProfileTomlForImport(fileName: String, tomlContent: String): Im uploadCompression = values["UPLOAD_COMPRESSION_TYPE"]?.toIntOrNull() ?: 0, downloadCompression = values["DOWNLOAD_COMPRESSION_TYPE"]?.toIntOrNull() ?: 0, logLevel = values["LOG_LEVEL"]?.trim().takeUnless { it.isNullOrBlank() } ?: "INFO", - resolvers = "8.8.8.8", + resolvers = "", advancedJson = gson.toJson(advanced) ) diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt index bfa1ed2..12b36d1 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt @@ -3,6 +3,7 @@ package com.masterdns.vpn.util import com.masterdns.vpn.data.local.ProfileEntity import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import java.util.Locale /** * Generates TOML config files from a ProfileEntity. @@ -162,15 +163,40 @@ object ConfigGenerator { appendLine("# MasterDnsVPN Resolvers") appendLine("# Auto-generated by MasterDnsVPN Android") appendLine() + val seenResolvers = linkedSetOf() profile.resolvers.lines().forEach { line -> val trimmed = line.trim() - if (trimmed.isNotEmpty()) { + val key = resolverDedupeKey(trimmed) + if (trimmed.isNotEmpty() && key != null && seenResolvers.add(key)) { appendLine(trimmed) } } } } + private fun resolverDedupeKey(line: String): String? { + val value = line.substringBefore("#").trim().lowercase(Locale.US) + if (value.isEmpty()) return null + + val bracketEnd = value.indexOf(']') + if (value.startsWith("[") && bracketEnd > 0) { + val host = value.substring(1, bracketEnd) + val port = value.substring(bracketEnd + 1) + .takeIf { it.startsWith(":") } + ?.drop(1) + ?.takeIf { it.isNotBlank() } + ?: "53" + return "$host:$port" + } + + val colonCount = value.count { it == ':' } + return when { + colonCount == 0 -> "$value:53" + colonCount == 1 -> value + else -> "$value:53" + } + } + private fun parseDomains(json: String): List { return try { val type = object : TypeToken>() {}.type diff --git a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt index 2b7e116..9457e4c 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt @@ -74,10 +74,14 @@ object VpnManager { private val _scanStatus = MutableStateFlow(ScanStatus()) val scanStatus: StateFlow = _scanStatus.asStateFlow() + private const val MAX_LOG_LINES = 500 + private const val LOG_EMIT_DELAY_MS = 150L private val monitorScope = CoroutineScope(Dispatchers.Default) private var trafficMonitorJob: Job? = null - - private const val MAX_LOG_LINES = 500 + private var logEmitJob: Job? = null + private val logBufferLock = Any() + private val logBuffer = ArrayDeque(MAX_LOG_LINES) + private var logBufferVersion = 0L fun updateState(newState: VpnState) { _state.value = newState @@ -117,23 +121,56 @@ object VpnManager { errors = _logCounters.value.errors + if (isError) 1 else 0, warnings = _logCounters.value.warnings + if (isWarn) 1 else 0 ) - val current = _logEntries.value.toMutableList() - current.add(LogEntry(normalizedLine, source)) - if (current.size > MAX_LOG_LINES) { - current.removeAt(0) + synchronized(logBufferLock) { + if (logBuffer.size == MAX_LOG_LINES) { + logBuffer.removeFirst() + } + logBuffer.addLast(LogEntry(normalizedLine, source)) + logBufferVersion++ } - _logEntries.value = current - _logs.value = current.map { it.line } + scheduleLogEmission() parseScanLine(normalizedLine) } fun clearLogs() { + synchronized(logBufferLock) { + logBuffer.clear() + logBufferVersion++ + } + logEmitJob?.cancel() + logEmitJob = null _logEntries.value = emptyList() _logs.value = emptyList() _logCounters.value = LogCounters() _scanStatus.value = ScanStatus() } + private fun scheduleLogEmission() { + if (logEmitJob?.isActive == true) return + logEmitJob = monitorScope.launch { + while (isActive) { + delay(LOG_EMIT_DELAY_MS) + val emittedVersion = emitLogs() + val currentVersion = synchronized(logBufferLock) { + logBufferVersion + } + if (currentVersion == emittedVersion) break + } + } + } + + private fun emitLogs(): Long { + val snapshot: List + val version: Long + synchronized(logBufferLock) { + snapshot = logBuffer.toList() + version = logBufferVersion + } + _logEntries.value = snapshot + _logs.value = snapshot.map { it.line } + return version + } + fun startTrafficMonitor(context: Context) { val appContext = context.applicationContext val uid = appContext.applicationInfo.uid diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 64dc1e5..813fa73 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -95,6 +95,10 @@ TOML imported into profile form Resolvers file is empty Resolvers imported into profile form + Profile name is required. + Add at least one domain before saving. + Encryption key is required. + Add at least one resolver before saving. Imported Profile Share Logs Auto