@@ -261,15 +261,15 @@ class EditProfileViewModel @Inject constructor(
261261 val defaults = profile.defaultResolvers.ifEmpty {
262262 if (profile.resolversHidden) profile.resolvers else emptyList()
263263 }
264- val defaultKeys = defaults.map { " ${it.host} : ${it.port} " }.toSet()
265- val currentKeys = profile.resolvers.map { " ${it.host} : ${it.port} " }.toSet()
264+ val defaultKeys = defaults.map { formatResolver(it) }.toSet()
265+ val currentKeys = profile.resolvers.map { formatResolver(it) }.toSet()
266266 val hasCustom = profile.resolversHidden && defaults.isNotEmpty() && currentKeys != defaultKeys
267267 if (hasCustom) {
268- profile.resolvers.joinToString(" ," ) { " ${it.host} : ${it.port} " }
268+ profile.resolvers.joinToString(" ," ) { formatResolver(it) }
269269 } else if (profile.resolversHidden) {
270270 " "
271271 } else {
272- profile.resolvers.joinToString(" ," ) { " ${it.host} : ${it.port} " }
272+ profile.resolvers.joinToString(" ," ) { formatResolver(it) }
273273 }
274274 },
275275 defaultResolversList = profile.defaultResolvers.ifEmpty {
@@ -279,8 +279,8 @@ class EditProfileViewModel @Inject constructor(
279279 val defaults = profile.defaultResolvers.ifEmpty {
280280 if (profile.resolversHidden) profile.resolvers else emptyList()
281281 }
282- val defaultKeys = defaults.map { " ${it.host} : ${it.port} " }.toSet()
283- val currentKeys = profile.resolvers.map { " ${it.host} : ${it.port} " }.toSet()
282+ val defaultKeys = defaults.map { formatResolver(it) }.toSet()
283+ val currentKeys = profile.resolvers.map { formatResolver(it) }.toSet()
284284 profile.resolversHidden && defaults.isNotEmpty() && currentKeys != defaultKeys
285285 },
286286 authoritativeMode = profile.authoritativeMode,
@@ -1245,15 +1245,44 @@ class EditProfileViewModel @Inject constructor(
12451245 .map { it.trim() }
12461246 .filter { it.isNotBlank() }
12471247 .map { resolver ->
1248- val parts = resolver.split( " : " )
1248+ val (host, port) = parseHostPort(resolver )
12491249 DnsResolver (
1250- host = parts[ 0 ].trim() ,
1251- port = parts.getOrNull( 1 )?.trim()?.toIntOrNull() ? : 53 ,
1250+ host = host ,
1251+ port = port ,
12521252 authoritative = authoritativeMode
12531253 )
12541254 }
12551255 }
12561256
1257+ /* * Format a DnsResolver as host:port, using bracket notation for IPv6. */
1258+ private fun formatResolver (r : DnsResolver ): String {
1259+ return if (r.host.contains(' :' )) " [${r.host} ]:${r.port} " else " ${r.host} :${r.port} "
1260+ }
1261+
1262+ /* * Parse host:port supporting IPv6 bracket notation like [fe80::]:53 */
1263+ private fun parseHostPort (input : String ): Pair <String , Int > {
1264+ val trimmed = input.trim()
1265+ if (trimmed.startsWith(" [" )) {
1266+ val closeBracket = trimmed.indexOf(' ]' )
1267+ if (closeBracket != - 1 ) {
1268+ val host = trimmed.substring(1 , closeBracket)
1269+ val port = if (closeBracket + 2 < trimmed.length) {
1270+ trimmed.substring(closeBracket + 2 ).toIntOrNull() ? : 53
1271+ } else 53
1272+ return host to port
1273+ }
1274+ }
1275+ val lastColon = trimmed.lastIndexOf(' :' )
1276+ // Only treat as host:port if there's exactly one colon (IPv4 or hostname)
1277+ if (lastColon != - 1 && trimmed.indexOf(' :' ) == lastColon) {
1278+ val host = trimmed.substring(0 , lastColon).trim()
1279+ val port = trimmed.substring(lastColon + 1 ).trim().toIntOrNull() ? : 53
1280+ return host to port
1281+ }
1282+ // Bare host (no port) — could be IPv6 without brackets
1283+ return trimmed to 53
1284+ }
1285+
12571286 /* *
12581287 * Validates domain format for DNSTT and Slipstream tunnel types.
12591288 * These require a proper DNS domain name (e.g., "t.example.com").
@@ -1393,41 +1422,15 @@ class EditProfileViewModel @Inject constructor(
13931422 return " Resolver cannot be empty"
13941423 }
13951424
1396- // Handle IPv6 with port: [2001:db8::1]:53
1397- if (trimmed.startsWith(" [" )) {
1398- val closeBracket = trimmed.indexOf(" ]" )
1399- if (closeBracket == - 1 ) {
1400- return " Invalid IPv6 format: missing closing bracket in '$trimmed '"
1401- }
1402-
1403- val ipv6 = trimmed.substring(1 , closeBracket)
1404- if (! isValidIPv6(ipv6)) {
1405- return " Invalid IPv6 address: '$ipv6 '"
1406- }
1407-
1408- // Check for port after ]
1409- if (closeBracket < trimmed.length - 1 ) {
1410- if (trimmed[closeBracket + 1 ] != ' :' ) {
1411- return " Invalid format: expected ':' after ']' in '$trimmed '"
1412- }
1413- val portStr = trimmed.substring(closeBracket + 2 )
1414- val portError = validatePort(portStr, trimmed)
1415- if (portError != null ) return portError
1416- }
1417-
1418- return null
1425+ // Block IPv6 — not supported by the tunnel stack
1426+ if (trimmed.startsWith(" [" ) || trimmed.count { it == ' :' } > 1 ) {
1427+ return " IPv6 resolvers are not supported"
14191428 }
14201429
1421- // Count colons to distinguish IPv4:port from IPv6
1430+ // Count colons to distinguish IPv4:port from host:port
14221431 val colonCount = trimmed.count { it == ' :' }
14231432
14241433 when {
1425- // IPv6 without port (multiple colons)
1426- colonCount > 1 -> {
1427- if (! isValidIPv6(trimmed)) {
1428- return " Invalid IPv6 address: '$trimmed '"
1429- }
1430- }
14311434 // IPv4:port or host:port (single colon)
14321435 colonCount == 1 -> {
14331436 val parts = trimmed.split(" :" )
0 commit comments