Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(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) {
Expand All @@ -312,6 +317,7 @@ private fun ProfileEditorDialog(
domainList.addAll(parsed)
encryptionKey = profile.encryptionKey
resolvers = profile.resolvers
validationMessage = null
showResolversEditor = false
}
}
Expand All @@ -331,6 +337,7 @@ private fun ProfileEditorDialog(
if (!importedResolvers.isNullOrBlank()) {
resolvers = importedResolvers
}
validationMessage = null
}

AlertDialog(
Expand All @@ -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()
)
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 = {
Expand All @@ -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))
}
Expand Down Expand Up @@ -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)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -162,15 +163,40 @@ object ConfigGenerator {
appendLine("# MasterDnsVPN Resolvers")
appendLine("# Auto-generated by MasterDnsVPN Android")
appendLine()
val seenResolvers = linkedSetOf<String>()
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<String> {
return try {
val type = object : TypeToken<List<String>>() {}.type
Expand Down
53 changes: 45 additions & 8 deletions android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ object VpnManager {
private val _scanStatus = MutableStateFlow(ScanStatus())
val scanStatus: StateFlow<ScanStatus> = _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<LogEntry>(MAX_LOG_LINES)
private var logBufferVersion = 0L

fun updateState(newState: VpnState) {
_state.value = newState
Expand Down Expand Up @@ -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<LogEntry>
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
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
<string name="profiles_toml_imported_msg">TOML imported into profile form</string>
<string name="profiles_resolvers_empty_msg">Resolvers file is empty</string>
<string name="profiles_resolvers_imported_msg">Resolvers imported into profile form</string>
<string name="profiles_name_required_msg">Profile name is required.</string>
<string name="profiles_domain_required_msg">Add at least one domain before saving.</string>
<string name="profiles_encryption_key_required_msg">Encryption key is required.</string>
<string name="profiles_resolvers_required_msg">Add at least one resolver before saving.</string>
<string name="profiles_imported_profile_default">Imported Profile</string>
<string name="logs_share">Share Logs</string>
<string name="logs_auto">Auto</string>
Expand Down
Loading