diff --git a/app/src/main/java/shop/whitedns/client/MainActivity.kt b/app/src/main/java/shop/whitedns/client/MainActivity.kt index 18ba0ee..61f88e4 100644 --- a/app/src/main/java/shop/whitedns/client/MainActivity.kt +++ b/app/src/main/java/shop/whitedns/client/MainActivity.kt @@ -123,6 +123,7 @@ class MainActivity : ComponentActivity() { ConnectionStatus.CONNECTED -> viewModel.disconnect() } }, + onTestProfileClick = viewModel::testCurrentProfileReadiness, onScanFileSelected = viewModel::beginScanFromFile, onScanDefaultListSelected = viewModel::beginScanFromDefaultResolvers, onScanStartClick = viewModel::startPreparedScan, diff --git a/app/src/main/java/shop/whitedns/client/model/WhiteDnsAutoTune.kt b/app/src/main/java/shop/whitedns/client/model/WhiteDnsAutoTune.kt index cdab521..7d367c2 100644 --- a/app/src/main/java/shop/whitedns/client/model/WhiteDnsAutoTune.kt +++ b/app/src/main/java/shop/whitedns/client/model/WhiteDnsAutoTune.kt @@ -3,16 +3,57 @@ package shop.whitedns.client.model data class WhiteDnsAutoTunePreset( val id: String, val label: String, + val listenIp: String? = null, + val listenPort: String? = null, + val httpProxyEnabled: Boolean? = null, + val httpProxyPort: String? = null, + val socks5Authentication: Boolean? = null, + val socksUsername: String? = null, + val socksPassword: String? = null, + val localDnsEnabled: Boolean? = null, + val localDnsPort: String? = null, + val balancingStrategy: Int? = null, val minUploadMtu: String, val maxUploadMtu: String, val minDownloadMtu: String, val maxDownloadMtu: String, val resolverTimeoutSeconds: String, + val resolverRetries: String? = null, + val resolverParallelism: String? = null, + val logRetries: String? = null, + val logTimeoutSeconds: String? = null, + val logParallelism: String? = null, val dnsResponseFragmentStoreCapacity: String, val uploadDuplication: String, val downloadDuplication: String, val uploadCompression: Int, val downloadCompression: Int, + val baseEncodeData: Boolean? = null, + val rxTxWorkers: String? = null, + val tunnelProcessWorkers: String? = null, + val tunnelPacketTimeoutSeconds: String? = null, + val dispatcherIdlePollIntervalSeconds: String? = null, + val txChannelSize: String? = null, + val rxChannelSize: String? = null, + val resolverUdpConnectionPoolSize: String? = null, + val streamQueueInitialCapacity: String? = null, + val orphanQueueInitialCapacity: String? = null, + val maxActiveStreams: String? = null, + val localHandshakeTimeoutSeconds: String? = null, + val socksUdpAssociateReadTimeoutSeconds: String? = null, + val clientTerminalStreamRetentionSeconds: String? = null, + val clientCancelledSetupRetentionSeconds: String? = null, + val sessionInitRetryBaseSeconds: String? = null, + val sessionInitRetryStepSeconds: String? = null, + val sessionInitRetryLinearAfter: String? = null, + val sessionInitRetryMaxSeconds: String? = null, + val sessionInitBusyRetryIntervalSeconds: String? = null, + val startupMode: String? = null, + val pingWatchdogSeconds: String? = null, + val trafficWarmupEnabled: Boolean? = null, + val trafficWarmupProbeCount: String? = null, + val trafficKeepaliveIntervalSeconds: String? = null, + val logLevel: String? = null, ) object WhiteDnsAutoTunePresets { @@ -115,6 +156,61 @@ object WhiteDnsAutoTunePresets { uploadCompression = 2, downloadCompression = 2, ), + WhiteDnsAutoTunePreset( + id = "field-milad-telegram", + label = "Milad Telegram Proxy", + listenIp = "127.0.0.1", + listenPort = "10886", + httpProxyEnabled = true, + httpProxyPort = "10887", + socks5Authentication = false, + socksUsername = "master_dns_vpn", + socksPassword = "master_dns_vpn", + localDnsEnabled = false, + localDnsPort = "53", + balancingStrategy = 3, + minUploadMtu = "5", + maxUploadMtu = "30", + minDownloadMtu = "300", + maxDownloadMtu = "300", + resolverTimeoutSeconds = "3.0", + resolverRetries = "3", + resolverParallelism = "100", + logRetries = "5", + logTimeoutSeconds = "2.0", + logParallelism = "32", + dnsResponseFragmentStoreCapacity = "64", + uploadDuplication = "3", + downloadDuplication = "4", + uploadCompression = 2, + downloadCompression = 2, + baseEncodeData = false, + rxTxWorkers = "4", + tunnelProcessWorkers = "4", + tunnelPacketTimeoutSeconds = "10.0", + dispatcherIdlePollIntervalSeconds = "0.02", + txChannelSize = "2048", + rxChannelSize = "2048", + resolverUdpConnectionPoolSize = "64", + streamQueueInitialCapacity = "128", + orphanQueueInitialCapacity = "32", + maxActiveStreams = "2048", + localHandshakeTimeoutSeconds = "5.0", + socksUdpAssociateReadTimeoutSeconds = "30.0", + clientTerminalStreamRetentionSeconds = "45.0", + clientCancelledSetupRetentionSeconds = "120.0", + sessionInitRetryBaseSeconds = "1.0", + sessionInitRetryStepSeconds = "1.0", + sessionInitRetryLinearAfter = "5", + sessionInitRetryMaxSeconds = "60.0", + sessionInitBusyRetryIntervalSeconds = "60.0", + startupMode = "resolvers", + pingWatchdogSeconds = "30", + trafficWarmupEnabled = true, + trafficWarmupProbeCount = "4", + trafficKeepaliveIntervalSeconds = "5", + logLevel = "WARN", + ), ) } @@ -182,17 +278,62 @@ object WhiteDnsParallelTest { fun WhiteDnsSettings.applyAutoTunePreset(preset: WhiteDnsAutoTunePreset): WhiteDnsSettings { return copy( + listenIp = preset.listenIp ?: listenIp, + listenPort = preset.listenPort ?: listenPort, + httpProxyEnabled = preset.httpProxyEnabled ?: httpProxyEnabled, + httpProxyPort = preset.httpProxyPort ?: httpProxyPort, + socks5Authentication = preset.socks5Authentication ?: socks5Authentication, + socksUsername = preset.socksUsername ?: socksUsername, + socksPassword = preset.socksPassword ?: socksPassword, + localDnsEnabled = preset.localDnsEnabled ?: localDnsEnabled, + localDnsPort = preset.localDnsPort ?: localDnsPort, + balancingStrategy = preset.balancingStrategy ?: balancingStrategy, minUploadMtu = preset.minUploadMtu, maxUploadMtu = preset.maxUploadMtu, minDownloadMtu = preset.minDownloadMtu, maxDownloadMtu = preset.maxDownloadMtu, mtuTestTimeoutResolvers = preset.resolverTimeoutSeconds, - mtuTestTimeoutLogs = preset.resolverTimeoutSeconds, + mtuTestRetriesResolvers = preset.resolverRetries ?: mtuTestRetriesResolvers, + mtuTestParallelismResolvers = preset.resolverParallelism ?: mtuTestParallelismResolvers, + mtuTestRetriesLogs = preset.logRetries ?: mtuTestRetriesLogs, + mtuTestTimeoutLogs = preset.logTimeoutSeconds ?: preset.resolverTimeoutSeconds, + mtuTestParallelismLogs = preset.logParallelism ?: mtuTestParallelismLogs, dnsResponseFragmentStoreCapacity = preset.dnsResponseFragmentStoreCapacity, uploadDuplication = preset.uploadDuplication, downloadDuplication = preset.downloadDuplication, uploadCompression = preset.uploadCompression, downloadCompression = preset.downloadCompression, + baseEncodeData = preset.baseEncodeData ?: baseEncodeData, + rxTxWorkers = preset.rxTxWorkers ?: rxTxWorkers, + tunnelProcessWorkers = preset.tunnelProcessWorkers ?: tunnelProcessWorkers, + tunnelPacketTimeoutSeconds = preset.tunnelPacketTimeoutSeconds ?: tunnelPacketTimeoutSeconds, + dispatcherIdlePollIntervalSeconds = preset.dispatcherIdlePollIntervalSeconds ?: dispatcherIdlePollIntervalSeconds, + txChannelSize = preset.txChannelSize ?: txChannelSize, + rxChannelSize = preset.rxChannelSize ?: rxChannelSize, + resolverUdpConnectionPoolSize = preset.resolverUdpConnectionPoolSize ?: resolverUdpConnectionPoolSize, + streamQueueInitialCapacity = preset.streamQueueInitialCapacity ?: streamQueueInitialCapacity, + orphanQueueInitialCapacity = preset.orphanQueueInitialCapacity ?: orphanQueueInitialCapacity, + maxActiveStreams = preset.maxActiveStreams ?: maxActiveStreams, + localHandshakeTimeoutSeconds = preset.localHandshakeTimeoutSeconds ?: localHandshakeTimeoutSeconds, + socksUdpAssociateReadTimeoutSeconds = preset.socksUdpAssociateReadTimeoutSeconds + ?: socksUdpAssociateReadTimeoutSeconds, + clientTerminalStreamRetentionSeconds = preset.clientTerminalStreamRetentionSeconds + ?: clientTerminalStreamRetentionSeconds, + clientCancelledSetupRetentionSeconds = preset.clientCancelledSetupRetentionSeconds + ?: clientCancelledSetupRetentionSeconds, + sessionInitRetryBaseSeconds = preset.sessionInitRetryBaseSeconds ?: sessionInitRetryBaseSeconds, + sessionInitRetryStepSeconds = preset.sessionInitRetryStepSeconds ?: sessionInitRetryStepSeconds, + sessionInitRetryLinearAfter = preset.sessionInitRetryLinearAfter ?: sessionInitRetryLinearAfter, + sessionInitRetryMaxSeconds = preset.sessionInitRetryMaxSeconds ?: sessionInitRetryMaxSeconds, + sessionInitBusyRetryIntervalSeconds = preset.sessionInitBusyRetryIntervalSeconds + ?: sessionInitBusyRetryIntervalSeconds, + startupMode = preset.startupMode ?: startupMode, + pingWatchdogSeconds = preset.pingWatchdogSeconds ?: pingWatchdogSeconds, + trafficWarmupEnabled = preset.trafficWarmupEnabled ?: trafficWarmupEnabled, + trafficWarmupProbeCount = preset.trafficWarmupProbeCount ?: trafficWarmupProbeCount, + trafficKeepaliveIntervalSeconds = preset.trafficKeepaliveIntervalSeconds + ?: trafficKeepaliveIntervalSeconds, + logLevel = preset.logLevel ?: logLevel, ).syncSelectedConnectionProfileFields() } diff --git a/app/src/main/java/shop/whitedns/client/model/WhiteDnsModels.kt b/app/src/main/java/shop/whitedns/client/model/WhiteDnsModels.kt index edb7e7c..642ea83 100644 --- a/app/src/main/java/shop/whitedns/client/model/WhiteDnsModels.kt +++ b/app/src/main/java/shop/whitedns/client/model/WhiteDnsModels.kt @@ -224,6 +224,30 @@ data class ResolverTextValidation( get() = normalizedResolvers.isNotEmpty() && invalidEntries.isEmpty() } +object WhiteDnsValidationSeverity { + const val Fatal = "fatal" + const val Warning = "warning" +} + +data class WhiteDnsValidationIssue( + val severity: String, + val field: String, + val message: String, +) + +data class WhiteDnsSettingsValidation( + val issues: List, +) { + val fatalIssues: List + get() = issues.filter { it.severity == WhiteDnsValidationSeverity.Fatal } + + val warnings: List + get() = issues.filter { it.severity == WhiteDnsValidationSeverity.Warning } + + val canConnect: Boolean + get() = fatalIssues.isEmpty() +} + data class WhiteDnsSettings( val selectedConnectionProfileId: String = ConnectionProfile.DefaultId, val connectionProfiles: List = listOf(ConnectionProfile.defaultProfile()), @@ -366,6 +390,12 @@ data class ConnectionStats( val uploadSpeedBytesPerSecond: Long = 0, val peakSpeedBytesPerSecond: Long = 0, val connectedApps: Int = 0, + val estimatedPayloadDownloadBytes: Long = 0, + val estimatedPayloadUploadBytes: Long = 0, + val estimatedPayloadTotalBytes: Long = 0, + val estimatedPayloadDownloadSpeedBytesPerSecond: Long = 0, + val estimatedPayloadUploadSpeedBytesPerSecond: Long = 0, + val hasEstimatedPayloadTraffic: Boolean = false, ) data class ResolverRuntimeState( @@ -513,6 +543,7 @@ data class WhiteDnsUiState( val connectionStats: ConnectionStats = ConnectionStats(), val resolverRuntimeState: ResolverRuntimeState = ResolverRuntimeState(), val connectionProgress: ConnectionProgressState = ConnectionProgressState(), + val profileReadiness: ConnectionVerificationState = ConnectionVerificationState(), val connectionVerification: ConnectionVerificationState = ConnectionVerificationState(), val autoTuneTrialResults: List = emptyList(), val scanState: WhiteDnsScanState = WhiteDnsScanState(), @@ -534,12 +565,21 @@ object WhiteDnsOptions { const val SplitTunnelModeOff = "off" const val SplitTunnelModeInclude = "include" const val SplitTunnelModeExclude = "exclude" + const val ResolverTestParallelismCustom = "custom" val connectionModes = listOf( Choice("proxy", "Proxy Mode"), Choice("vpn", "Full VPN"), ) + val resolverTestParallelismPresets = listOf( + Choice("32", "32 - Light"), + Choice("64", "64 - Balanced"), + Choice("100", "100 - Current"), + Choice("128", "128 - Deep"), + Choice(ResolverTestParallelismCustom, "Custom"), + ) + val themeModes = listOf( Choice(WhiteDnsThemeMode.System, "Auto"), Choice(WhiteDnsThemeMode.Light, "Light"), @@ -603,6 +643,14 @@ object WhiteDnsOptions { fun splitTunnelModeLabel(mode: String): String { return splitTunnelModes.firstOrNull { it.value == mode }?.label ?: "All Apps" } + + fun resolverTestParallelismPreset(value: String): String { + val trimmed = value.trim() + return resolverTestParallelismPresets + .firstOrNull { choice -> choice.value == trimmed } + ?.value + ?: ResolverTestParallelismCustom + } } fun WhiteDnsSettings.normalizedConnectionProfiles(): List { @@ -918,6 +966,32 @@ fun WhiteDnsSettings.upsertConnectionProfile(profile: ConnectionProfile): WhiteD ).syncSelectedConnectionProfileFields() } +fun WhiteDnsSettings.duplicateConnectionProfile( + profileId: String, + nowMillis: Long = System.currentTimeMillis(), +): WhiteDnsSettings { + val profiles = normalizedConnectionProfiles() + val sourceProfile = profiles.firstOrNull { it.id == profileId } + ?: return copy(connectionProfiles = profiles).syncSelectedConnectionProfileFields() + val nextProfileId = uniqueConnectionProfileCopyId( + baseId = "profile-copy-$nowMillis", + existingIds = profiles.map { it.id }.toSet(), + ) + val nextProfileName = uniqueConnectionProfileCopyName( + baseName = sourceProfile.name, + existingNames = profiles.map { it.name }.toSet(), + ) + val copiedProfile = sourceProfile.copy( + id = nextProfileId, + name = nextProfileName, + serverMode = "custom", + ) + return copy( + connectionProfiles = profiles + copiedProfile, + selectedConnectionProfileId = nextProfileId, + ).syncSelectedConnectionProfileFields() +} + fun WhiteDnsSettings.upsertResolverProfile(profile: ResolverProfile): WhiteDnsSettings { if (profile.id == ResolverProfile.DefaultId) { return syncSelectedConnectionProfileFields() @@ -1129,6 +1203,30 @@ fun WhiteDnsSettings.deleteConnectionProfile(profileId: String): WhiteDnsSetting ).syncSelectedConnectionProfileFields() } +private fun uniqueConnectionProfileCopyId(baseId: String, existingIds: Set): String { + if (baseId !in existingIds) { + return baseId + } + var suffix = 2 + while ("$baseId-$suffix" in existingIds) { + suffix += 1 + } + return "$baseId-$suffix" +} + +private fun uniqueConnectionProfileCopyName(baseName: String, existingNames: Set): String { + val normalizedBaseName = baseName.trim().ifBlank { "Connection" } + val firstCopyName = "$normalizedBaseName Copy" + if (firstCopyName !in existingNames) { + return firstCopyName + } + var suffix = 2 + while ("$firstCopyName $suffix" in existingNames) { + suffix += 1 + } + return "$firstCopyName $suffix" +} + fun WhiteDnsSettings.duplicateConnectionProfileCount(): Int { return normalizedConnectionProfiles() .mapNotNull { it.duplicateServerKey() } @@ -1458,8 +1556,10 @@ private enum class ResolverTextEntryType { } private const val DefaultResolverPort = 53 +private const val MaxRecommendedResolvers = 256 private val ResolverIpv6Chars = Regex("^[0-9A-Fa-f:.]+$") +private val ServerDomainLabelRegex = Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") private fun normalizeSplitTunnelMode(raw: String): String { return when (raw) { @@ -1487,6 +1587,24 @@ private fun normalizeThemeMode(raw: String): String { } } +fun normalizeServerDomainText(raw: String): String { + return normalizeServerDomains(raw).joinToString(separator = "\n") +} + +fun normalizeServerDomains(raw: String): List { + return raw + .replace("[", " ") + .replace("]", " ") + .replace("\"", " ") + .replace("'", " ") + .split(Regex("[,;\\s]+")) + .asSequence() + .map { it.trim().trimEnd('.') } + .filter(String::isNotEmpty) + .distinct() + .toList() +} + private fun normalizePackageNames(raw: List): List { return raw .asSequence() @@ -1497,6 +1615,142 @@ private fun normalizePackageNames(raw: List): List { .toList() } +fun validateConnectionSettings(settings: WhiteDnsSettings): WhiteDnsSettingsValidation { + val normalizedSettings = settings.syncSelectedConnectionProfileFields() + val runtimeSettings = normalizedSettings.runtimeConnectionSettings() + val resolvedSettings = runtimeSettings.resolve() + val connectionProfile = normalizedSettings.selectedConnectionProfile() + val resolverValidation = validateResolverText(runtimeSettings.resolverText) + val issues = mutableListOf() + + fun fatal(field: String, message: String) { + issues += WhiteDnsValidationIssue(WhiteDnsValidationSeverity.Fatal, field, message) + } + + fun warning(field: String, message: String) { + issues += WhiteDnsValidationIssue(WhiteDnsValidationSeverity.Warning, field, message) + } + + val serverDomains = normalizeServerDomains(connectionProfile.customServerDomain) + if (serverDomains.isEmpty() || connectionProfile.customServerEncryptionKey.isBlank()) { + fatal("server", "Custom StormDNS domain and encryption key are required") + } else if (serverDomains.any { !isValidServerDomain(it) }) { + fatal("server", "One or more custom StormDNS domains are not valid") + } + + if (resolverValidation.normalizedResolvers.isEmpty()) { + fatal("resolvers", "Resolvers are required to connect") + } + if (resolverValidation.invalidEntries.isNotEmpty()) { + fatal("resolvers", "Resolver list contains invalid entries") + } + if (resolverValidation.normalizedResolvers.size > MaxRecommendedResolvers) { + warning("resolvers", "Large resolver lists can slow startup and increase battery use") + } + + validatePortText(runtimeSettings.listenPort, "listenPort", "SOCKS listen port") { field, message -> + fatal(field, message) + } + if (runtimeSettings.httpProxyEnabled) { + validatePortText(runtimeSettings.httpProxyPort, "httpProxyPort", "HTTP proxy port") { field, message -> + fatal(field, message) + } + if (resolvedSettings.httpProxyPort == resolvedSettings.listenPort) { + fatal("httpProxyPort", "HTTP proxy port must differ from the SOCKS listen port") + } + } + if (runtimeSettings.localDnsEnabled) { + validatePortText(runtimeSettings.localDnsPort, "localDnsPort", "Local DNS port") { field, message -> + fatal(field, message) + } + } + if (runtimeSettings.localDnsEnabled && resolvedSettings.localDnsPort == resolvedSettings.listenPort) { + fatal("localDnsPort", "Local DNS port must differ from the SOCKS listen port") + } + if (runtimeSettings.localDnsEnabled && runtimeSettings.httpProxyEnabled && + resolvedSettings.localDnsPort == resolvedSettings.httpProxyPort + ) { + fatal("localDnsPort", "Local DNS port must differ from the HTTP proxy port") + } + + if ( + resolvedSettings.connectionMode == "proxy" && + isLanReachableListenIp(resolvedSettings.listenIp) && + !hasCompleteSocksCredentials( + enabled = resolvedSettings.socks5Authentication, + username = resolvedSettings.socksUsername, + password = resolvedSettings.socksPassword, + ) + ) { + fatal("socks5Authentication", "LAN-reachable proxy requires a SOCKS5 username and password") + } + + if (resolvedSettings.rxTxWorkers > 32 || resolvedSettings.tunnelProcessWorkers > 32) { + warning("workers", "High worker counts can increase CPU and battery use") + } + if (resolvedSettings.mtuTestParallelismResolvers > 128) { + warning("resolverTestParallelism", "Very high resolver test parallelism can increase upload, CPU, and battery use") + } + if (resolvedSettings.txChannelSize > 8192 || resolvedSettings.rxChannelSize > 8192) { + warning("queues", "Large queues can increase memory use") + } + if (resolvedSettings.maxUploadMtu < resolvedSettings.minUploadMtu || + resolvedSettings.maxDownloadMtu < resolvedSettings.minDownloadMtu + ) { + fatal("mtu", "Maximum MTU values must be greater than or equal to minimum MTU values") + } + if (resolvedSettings.localHandshakeTimeoutSeconds < 1.0 || + resolvedSettings.socksUdpAssociateReadTimeoutSeconds < 1.0 || + resolvedSettings.tunnelPacketTimeoutSeconds < 1.0 + ) { + warning("timeouts", "Very short runtime timeouts can cause unstable connections") + } + + return WhiteDnsSettingsValidation(issues) +} + +private fun validatePortText( + raw: String, + field: String, + label: String, + fatal: (String, String) -> Unit, +) { + val port = raw.trim().toIntOrNull() + if (port == null || port !in 1..65535) { + fatal(field, "$label must be between 1 and 65535") + } +} + +private fun isValidServerDomain(raw: String): Boolean { + val domain = raw.trim().trimEnd('.') + if (domain.isBlank() || domain.length > 253 || domain.any(Char::isWhitespace)) { + return false + } + if (domain.startsWith("[") && domain.endsWith("]")) { + return domain.length > 2 + } + return domain.split('.').all { label -> + label.isNotBlank() && ServerDomainLabelRegex.matches(label) + } +} + +private fun isLanReachableListenIp(raw: String): Boolean { + val value = raw.trim() + return value.isNotBlank() && + value != "127.0.0.1" && + value != "localhost" && + value != "::1" && + value != "[::1]" +} + +private fun hasCompleteSocksCredentials( + enabled: Boolean, + username: String, + password: String, +): Boolean { + return enabled && username.isNotBlank() && password.isNotBlank() +} + fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings { fun boundedInt(raw: String, defaultValue: Int, minValue: Int, maxValue: Int): Int { return raw.trim().toIntOrNull()?.coerceIn(minValue, maxValue) ?: defaultValue @@ -1541,6 +1795,11 @@ fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings { .coerceAtLeast(resolvedMinUploadMtu) val resolvedMaxDownloadMtu = boundedInt(maxDownloadMtu, defaultValue = 3000, minValue = 1, maxValue = 65535) .coerceAtLeast(resolvedMinDownloadMtu) + val resolvedSocksUsername = socksUsername.take(255) + val resolvedSocksPassword = socksPassword.take(255) + val resolvedSocks5Authentication = socks5Authentication && + resolvedSocksUsername.isNotBlank() && + resolvedSocksPassword.isNotBlank() return ResolvedWhiteDnsSettings( connectionMode = when (connectionMode) { @@ -1553,9 +1812,9 @@ fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings { listenPort = boundedInt(listenPort, defaultValue = 10886, minValue = 1, maxValue = 65535), httpProxyEnabled = httpProxyEnabled, httpProxyPort = boundedInt(httpProxyPort, defaultValue = 10887, minValue = 1, maxValue = 65535), - socks5Authentication = socks5Authentication, - socksUsername = socksUsername.take(255), - socksPassword = socksPassword.take(255), + socks5Authentication = resolvedSocks5Authentication, + socksUsername = resolvedSocksUsername, + socksPassword = resolvedSocksPassword, balancingStrategy = listOf(1, 2, 3, 4).firstOrNull { it == balancingStrategy } ?: 3, uploadDuplication = boundedInt(uploadDuplication, defaultValue = 3, minValue = 1, maxValue = 30), downloadDuplication = boundedInt(downloadDuplication, defaultValue = 7, minValue = 1, maxValue = 30), diff --git a/app/src/main/java/shop/whitedns/client/runtime/StormDnsTrafficStats.kt b/app/src/main/java/shop/whitedns/client/runtime/StormDnsTrafficStats.kt index c87c680..fef4ed7 100644 --- a/app/src/main/java/shop/whitedns/client/runtime/StormDnsTrafficStats.kt +++ b/app/src/main/java/shop/whitedns/client/runtime/StormDnsTrafficStats.kt @@ -10,6 +10,20 @@ data class StormDnsTrafficStats( val uploadSpeedBytesPerSecond: Long, ) +fun StormDnsTrafficStats.estimateDeduplicatedTraffic( + uploadDuplication: Int, + downloadDuplication: Int, +): StormDnsTrafficStats { + val safeUploadDuplication = uploadDuplication.coerceAtLeast(1).toLong() + val safeDownloadDuplication = downloadDuplication.coerceAtLeast(1).toLong() + return copy( + downloadBytes = downloadBytes.divideRoundedUp(safeDownloadDuplication), + uploadBytes = uploadBytes.divideRoundedUp(safeUploadDuplication), + downloadSpeedBytesPerSecond = downloadSpeedBytesPerSecond.divideRoundedUp(safeDownloadDuplication), + uploadSpeedBytesPerSecond = uploadSpeedBytesPerSecond.divideRoundedUp(safeUploadDuplication), + ) +} + class StormDnsTrafficAccounting { private var lastRawStats: StormDnsTrafficStats? = null private var accumulatedDownloadBytes: Long = 0L @@ -113,6 +127,15 @@ private fun parseDataAmount( return (amount * multiplier).roundToLong().coerceAtLeast(0) } +private fun Long.divideRoundedUp(divisor: Long): Long { + val safeValue = coerceAtLeast(0) + return if (safeValue == 0L) { + 0L + } else { + 1L + ((safeValue - 1L) / divisor.coerceAtLeast(1L)) + } +} + private val AnsiEscapeRegex = Regex("\\u001B\\[[;\\d]*m") private val StormDnsTrafficStatsRegex = Regex( """([0-9]+(?:\.[0-9]+)?)\s*([KMGT]?B)/s\s*\(Total:\s*([0-9]+(?:\.[0-9]+)?)\s*([KMGT]?B)\)\s*\|\s*[^0-9]*([0-9]+(?:\.[0-9]+)?)\s*([KMGT]?B)/s\s*\(Total:\s*([0-9]+(?:\.[0-9]+)?)\s*([KMGT]?B)\)""", diff --git a/app/src/main/java/shop/whitedns/client/security/SecretRedactor.kt b/app/src/main/java/shop/whitedns/client/security/SecretRedactor.kt new file mode 100644 index 0000000..75a4922 --- /dev/null +++ b/app/src/main/java/shop/whitedns/client/security/SecretRedactor.kt @@ -0,0 +1,58 @@ +package shop.whitedns.client.security + +data class RedactionSecrets( + val serverDomains: List = emptyList(), + val encryptionKeys: List = emptyList(), + val socksUsernames: List = emptyList(), + val socksPasswords: List = emptyList(), + val profileLinks: List = emptyList(), + val runtimePaths: List = emptyList(), +) + +object SecretRedactor { + fun redact(source: String, secrets: RedactionSecrets = RedactionSecrets()): String { + if (source.isEmpty()) { + return source + } + return source + .replace(AnsiEscapeRegex, "") + .replace(ProfileLinkRegex, "[profile link]") + .redactNamedSecretFields() + .replaceValues(secrets.profileLinks, "[profile link]") + .replaceValues(secrets.encryptionKeys, "[encryption key]") + .replaceValues(secrets.socksPasswords, "[socks password]") + .replaceValues(secrets.socksUsernames, "[socks username]") + .replaceValues(secrets.serverDomains, "[server domain]") + .replaceValues(secrets.runtimePaths, "[runtime path]") + } + + private fun String.redactNamedSecretFields(): String { + return NamedSecretRegex.replace(this) { match -> + "${match.groupValues[1]}${match.groupValues[2]}[secret]" + } + } + + private fun String.replaceValues(values: List, replacement: String): String { + return values + .asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .sortedByDescending(String::length) + .fold(this) { text, value -> + if (value.length < MinimumWholeTokenLength) { + Regex("""(? = emptyList() + val profiles: List = listOf( + profile("@WhiteDNS", "v.whitedns1.shop", "c8328f9d541860df1637b9b3ed7b990e"), + profile("@WhiteDNS", "v.whitedns2.shop", "7ecd7b6271c47e348f6ab177517ee8fa"), + profile("@WhiteDNS", "v.whitedns3.shop", "9d7aedcaf1f94e784a24fdfc1348a337"), + profile("@WhiteDNS", "v.whitedns4.shop", "0ce14ab71acebbd46b8129e25593155a"), + profile("@WhiteDNS", "v.whitedns5.shop", "2dffd162cb02b278c1ab57ee17fe07ae"), + profile("@WhiteDNS", "v.whitedns6.shop", "e32afdaa30ca36ead696cd90d84ced15"), + profile("@WhiteDNS", "v.whitedns7.shop", "6394eec942238533798ec7524eb7ea66"), + profile("@WhiteDNS", "v.whitedns8.shop", "1c167e9850936655c480e4938b2c5c35"), + profile("@WhiteDNS", "v.whitedns.shop", "b07dc357360d6e6896509136d7097897"), + profile("@WhiteDNS", "v9.whitedns.shop", "a2f3348bfa02170639970f2d63f103dc"), + profile("@WhiteDNS", "v10.whitedns.shop", "1ecd5fdfe351a9313cea39fe1b9c59dd"), + profile("@WhiteDNS", "v11.whitedns.shop", "4c1669326cdbe898cab0965ace3010c0"), + profile("@WhiteDNS", "v12.whitedns.shop", "009c514b2a643ed40cbd7cc619389b0f"), + profile("@WhiteDNS", "v13.whitedns.shop", "e2918870ce88f05547fbfba99aee4ec5"), + profile("@WhiteDNS", "v14.whitedns.shop", "9b8672a352d040489d96be9df97ed966"), + profile("@WhiteDNS", "v15.whitedns.shop", "9aa68e7e9a7c22d91d8f466d64a35dff"), + profile("@WhiteDNS", "v16.whitedns.shop", "592845ca38f19461383d05733c33214c"), + profile("@WhiteDNS", "v17.whitedns.shop", "576550354a7309530b7fa25152dc672f"), + profile("@WhiteDNS", "v18.whitedns.shop", "d0538a3d545a7830b8bb2eb0c3748e95"), + profile("@WhiteDNS", "v.whitedns.space", "bad99364093861634030e96f11fe3132"), + profile("@WhiteDNS", "v.whitedns.site", "bad99364093861634030e96f11fe3132"), + + profile("@PersiaTMChannel", "t1.prs612.us", "3e80a0eb3e1fba2bf17e0e0eb5783dc5"), + profile("@PersiaTMChannel", "t2.prs612.us", "afc1bd5e98cd98cde7515adb7295122b"), + profile("@PersiaTMChannel", "t3.prs612.us", "8786a860148e2d4d55403ae3c80ba854"), + profile("@PersiaTMChannel", "t4.prs612.us", "943664f15fd1763e242ce12ba993d9c9"), + profile("@PersiaTMChannel", "t5.prs612.us", "6a9ab24a8bd3937378efbfb3c23e1358"), + profile("@PersiaTMChannel", "t6.prs612.us", "71201fedadfbc0b62189c08961bce651"), + profile("@PersiaTMChannel", "t7.prs612.us", "c4ff91174a79e9e03a4d6727878ada17"), + profile("@PersiaTMChannel", "t8.prs612.us", "ae5db6f03e485214e1fcc9d26a4c0a2f"), + profile("@PersiaTMChannel", "t9.prs612.us", "a01c03a340a3e684a2815961e086eb27"), + profile("@PersiaTMChannel", "t10.prs612.us", "f3a3c5d02bb7ce04f6a4f144fc35e9cb"), + profile("@PersiaTMChannel", "t11.prs612.us", "e7f2db07e778563d31ed1fc80d5fe612"), + + profile("Ali / @link_dakheli_app", "te.link-dakheli-app.shop", "TelegramChannel@link_dakheli_app"), + profile("Ali / @link_dakheli_app", "s.o4s.shop", "TelegramChannel@link_dakheli_app"), + profile("Ali / @link_dakheli_app", "s2.o5s.shop", "TelegramChannel@link_dakheli_app"), + + profile("@hamedvpns / @Config0plus", "ob.networks.icu", "3947d5ac68015a09a53a0b361bd82999"), + profile("@hamedvpns / @Config0plus", "ts.networks.icu", "e0e71e667e5dcfe3b18e3bce659773d2"), + profile("@hamedvpns / @Config0plus", "zw.networks.icu", "0798c0e8aa05080125e6c65282fd792b"), + + profile("Rouh Jangali", "v.0x0.guru", "33815e1bcb88873f2199c735828ea745"), + profile("Rouh Jangali", "v.iranmn.best", "aaed913b8367fce3e20910472d9e3e2d"), + profile("Rouh Jangali", "v.kmjhfilterchi.shop", "4f3d0262ba2bd7cc20b596bdbc77f761"), + + profile("Rasoul", "v.eshkaftak.vip", "8705eb75abeedcd99001ef8d01cf9fad"), + profile("Rasoul", "v3.eshkaftak.vip", "8705eb75abeedcd99001ef8d01cf9fad"), + profile("Rasoul", "v4.eshkaftak.vip", "8705eb75abeedcd99001ef8d01cf9fad"), + profile("Rasoul", "v5.eshkaftak.vip", "8705eb75abeedcd99001ef8d01cf9fad"), + profile("Rasoul", "v6.eshkaftak.vip", "8705eb75abeedcd99001ef8d01cf9fad"), + profile("Rasoul", "g1.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g2.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g3.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g4.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g5.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g6.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + profile("Rasoul", "g7.rmft.tech", "a337e9fa75161c44bebe7d717da36afc"), + + profile("@Masir_Sefid", "s.o4s.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "s2.o4s.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "s3.o4s.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "s4.o4s.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "s5.o4s.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "v1.masir-sefid-1.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "v2.masir-sefid-1.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "v3.masir-sefid-1.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "v4.masir-sefid-1.shop", "Telegram-Channel--->@Masir_Sefid"), + profile("@Masir_Sefid", "v5.masir-sefid-1.shop", "Telegram-Channel--->@Masir_Sefid"), + + profile("@pythash", "v1.abolfazlshahi.cloud", "a4c5649c628ac1103ad55c5208e0d74d"), + profile("@pythash", "v2.abolfazlshahi.cloud", "965a568dccef58af7afb86e8ee55eea6"), + ).withDonorCounters() + + private fun profile( + label: String, + domain: String, + encryptionKey: String, + ): StormDnsServerProfile { + return StormDnsServerProfile( + id = "preset-${label.toPresetIdSegment()}-${domain.toPresetIdSegment()}", + label = label, + domain = domain, + encryptionKey = encryptionKey, + encryptionMethod = 1, + ) + } + + private fun List.withDonorCounters(): List { + val donorCounts = groupingBy { it.label }.eachCount() + val seenDonors = mutableMapOf() + return map { profile -> + val donorTotal = donorCounts.getValue(profile.label) + if (donorTotal == 1) { + profile + } else { + val donorIndex = (seenDonors[profile.label] ?: 0) + 1 + seenDonors[profile.label] = donorIndex + val donorCounter = donorIndex.toString().padStart(2, '0') + val numberedLabel = "${profile.label} $donorCounter" + profile.copy( + id = "preset-${profile.label.toPresetIdSegment()}-$donorCounter-${profile.domain.toPresetIdSegment()}", + label = numberedLabel, + ) + } + } + } + + private fun String.toPresetIdSegment(): String { + return lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + } } diff --git a/app/src/main/java/shop/whitedns/client/ui/WhiteDnsScreen.kt b/app/src/main/java/shop/whitedns/client/ui/WhiteDnsScreen.kt index 2d01a60..a8b35d0 100644 --- a/app/src/main/java/shop/whitedns/client/ui/WhiteDnsScreen.kt +++ b/app/src/main/java/shop/whitedns/client/ui/WhiteDnsScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Edit @@ -144,6 +145,7 @@ import shop.whitedns.client.model.ConnectionStats import shop.whitedns.client.model.ConnectionStatus import shop.whitedns.client.model.ConnectionVerificationState import shop.whitedns.client.model.ConnectionVerificationStatus +import shop.whitedns.client.model.ResolvedWhiteDnsSettings import shop.whitedns.client.model.ResolverProfile import shop.whitedns.client.model.ResolverRuntimeState import shop.whitedns.client.model.WhiteDnsOptions @@ -159,6 +161,7 @@ import shop.whitedns.client.model.deleteConnectionProfile import shop.whitedns.client.model.deleteDuplicateConnectionProfiles import shop.whitedns.client.model.deleteAdvancedProfile import shop.whitedns.client.model.deleteResolverProfile +import shop.whitedns.client.model.duplicateConnectionProfile import shop.whitedns.client.model.duplicateConnectionProfileCount import shop.whitedns.client.model.exportAllStormDnsProfileLinks import shop.whitedns.client.model.exportStormDnsProfileLink @@ -183,10 +186,13 @@ import shop.whitedns.client.model.updateManualResolverText import shop.whitedns.client.model.upsertConnectionProfile import shop.whitedns.client.model.upsertAdvancedProfile import shop.whitedns.client.model.upsertResolverProfile +import shop.whitedns.client.model.validateConnectionSettings import shop.whitedns.client.model.validateResolverText import shop.whitedns.client.model.WhiteDnsAutoTunePresets import shop.whitedns.client.model.WhiteDnsParallelTest import shop.whitedns.client.model.syncSelectedConnectionProfileFields +import shop.whitedns.client.security.RedactionSecrets +import shop.whitedns.client.security.SecretRedactor import shop.whitedns.client.storm.StormDnsConfigRenderer import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType @@ -203,6 +209,7 @@ fun WhiteDnsScreen( onBatteryOptimizationClick: () -> Unit, onNotificationPermissionClick: () -> Unit, onConnectClick: () -> Unit, + onTestProfileClick: () -> Unit, onScanFileSelected: (Uri) -> Unit, onScanDefaultListSelected: () -> Unit, onScanStartClick: () -> Unit, @@ -235,6 +242,7 @@ fun WhiteDnsScreen( onBatteryOptimizationClick = onBatteryOptimizationClick, onNotificationPermissionClick = onNotificationPermissionClick, onConnectClick = onConnectClick, + onTestProfileClick = onTestProfileClick, onAddConnectionClick = { profileCreateRequest = ProfileCreateRequest.CONNECTION selectedTab = WhiteDnsTab.PROFILES @@ -321,6 +329,7 @@ private fun ConnectTabContent( onBatteryOptimizationClick: () -> Unit, onNotificationPermissionClick: () -> Unit, onConnectClick: () -> Unit, + onTestProfileClick: () -> Unit, onAddConnectionClick: () -> Unit, onAddResolverProfileClick: () -> Unit, onSettingsChange: (WhiteDnsSettings) -> Unit, @@ -355,6 +364,11 @@ private fun ConnectTabContent( val showInitialSetup = !hasInitialServerProfile || !hasInitialResolverProfile val selectedResolverProfile = remember(settings) { settings.selectedResolverProfile() } val resolverValidation = remember(settings.resolverText) { validateResolverText(settings.resolverText) } + val resolverIssue = when { + resolverValidation.invalidEntries.isNotEmpty() -> "Resolver list contains invalid entries" + resolverValidation.normalizedResolvers.isEmpty() -> "Add at least one valid resolver" + else -> null + } val context = LocalContext.current val splitTunnelApps = remember(context.packageName) { loadSplitTunnelAppOptions(context) @@ -552,6 +566,29 @@ private fun ConnectTabContent( } }, ) + AnimatedVisibility( + visible = uiState.connectionStatus == ConnectionStatus.DISCONNECTED, + enter = fadeIn(animationSpec = tween(180)) + expandVertically(animationSpec = tween(180)), + exit = fadeOut(animationSpec = tween(140)) + shrinkVertically(animationSpec = tween(140)), + ) { + Column { + Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) + ProfileReadinessPanel( + readiness = uiState.profileReadiness, + settings = settings, + resolvedSettings = resolvedSettings, + resolverCount = resolverValidation.normalizedResolvers.size, + resolverIssue = resolverIssue, + onTestProfileClick = onTestProfileClick, + onAddConnectionClick = onAddConnectionClick, + onAddResolverProfileClick = onAddResolverProfileClick, + onOpenAdvancedClick = { + showResolverRequiredMessage = false + showAdvancedEditDialog = true + }, + ) + } + } AnimatedVisibility( visible = resolvedSettings.connectionMode == "proxy" || resolvedSettings.connectionMode == "vpn", enter = fadeIn(animationSpec = tween(180)) + expandVertically(animationSpec = tween(180)), @@ -687,13 +724,7 @@ private fun ConnectTabContent( selectedConnectionProfile = selectedConnectionProfile, selectedResolverProfile = selectedResolverProfile, resolverCount = resolverValidation.normalizedResolvers.size, - resolverIssue = resolverValidation.invalidEntries.firstOrNull()?.let { invalidEntry -> - "Invalid resolver IP: $invalidEntry" - } ?: if (resolverValidation.normalizedResolvers.isEmpty()) { - "No resolvers configured" - } else { - null - }, + resolverIssue = resolverIssue, actionsEnabled = uiState.connectionStatus != ConnectionStatus.CONNECTING, onAddConnectionClick = onAddConnectionClick, onAddResolverProfileClick = onAddResolverProfileClick, @@ -1040,7 +1071,7 @@ private fun ParallelTestSelectionPanel( ) ParallelTestConfigRow( label = "WhiteDNS configs", - detail = "Adds 7 suggested configs", + detail = "Adds ${whiteDnsConfigIds.size} suggested configs", checked = allWhiteDnsSelected, enabled = canAddWhiteDnsConfigs, onToggle = { @@ -2735,6 +2766,216 @@ private fun HomeSelectorSheetRow( } } +@Composable +private fun ProfileReadinessPanel( + readiness: ConnectionVerificationState, + settings: WhiteDnsSettings, + resolvedSettings: ResolvedWhiteDnsSettings, + resolverCount: Int, + resolverIssue: String?, + onTestProfileClick: () -> Unit, + onAddConnectionClick: () -> Unit, + onAddResolverProfileClick: () -> Unit, + onOpenAdvancedClick: () -> Unit, +) { + val selectedProfile = settings.selectedConnectionProfile() + val validation = remember(settings) { validateConnectionSettings(settings) } + val serverReady = selectedProfile.customServerDomain.isNotBlank() && + selectedProfile.customServerEncryptionKey.isNotBlank() && + validation.fatalIssues.none { issue -> issue.field == "server" } + val resolverReady = resolverCount > 0 && resolverIssue == null && + validation.fatalIssues.none { issue -> issue.field == "resolvers" } + val localPortReady = when { + readiness.status == ConnectionVerificationStatus.Verified -> true + readiness.status == ConnectionVerificationStatus.Failed && + readiness.message.contains("local", ignoreCase = true) -> false + else -> null + } + val localPortDetail = buildString { + append("SOCKS ${resolvedSettings.listenPort}") + if (resolvedSettings.httpProxyEnabled) { + append(", HTTP ${resolvedSettings.httpProxyPort}") + } + if (resolvedSettings.localDnsEnabled) { + append(", DNS ${resolvedSettings.localDnsPort}") + } + } + val safetyReady = when { + validation.fatalIssues.any { issue -> issue.field == "socks5Authentication" } -> false + readiness.status == ConnectionVerificationStatus.Verified -> true + else -> null + } + val testRunning = readiness.status == ConnectionVerificationStatus.Checking + + InfoCard(title = "SETUP READINESS", compact = true) { + ConnectionVerificationSummary( + verification = readiness, + idleMessage = "Run Test Setup before connecting. It checks profile fields, resolver input, local ports, LAN exposure rules, and an advisory server address signal.", + checkingLabel = "Checking Setup", + verifiedLabel = "Ready To Connect", + failedLabel = "Setup Needs Attention", + ) + Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + ReadinessStepRow( + label = "Server", + detail = if (serverReady) { + "Domain and key are present" + } else { + "Add a valid domain and encryption key" + }, + ready = serverReady, + ) + ReadinessStepRow( + label = "Resolvers", + detail = if (resolverReady) { + resolverCountLabel(resolverCount) + } else { + resolverIssue ?: "Add at least one valid resolver" + }, + ready = resolverReady, + ) + ReadinessStepRow( + label = "Mode", + detail = WhiteDnsOptions.connectionModeLabel(resolvedSettings.connectionMode), + ready = resolvedSettings.connectionMode in setOf("proxy", "vpn"), + ) + ReadinessStepRow( + label = "Local Ports", + detail = localPortDetail, + ready = localPortReady, + ) + ReadinessStepRow( + label = "Safety", + detail = if (safetyReady == false) { + "LAN proxy exposure needs a SOCKS username and password" + } else { + "Checks LAN exposure and credential rules" + }, + ready = safetyReady, + ) + ReadinessStepRow( + label = "Route Check", + detail = "Runs after connect to verify real proxy/VPN traffic", + ready = null, + ) + } + Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) + SetupActionButton( + label = if (testRunning) "Checking Setup" else "Test Setup", + supportingText = "Server, resolvers, ports, and safety", + icon = Icons.Rounded.Tune, + emphasized = true, + enabled = !testRunning, + onClick = onTestProfileClick, + ) + AnimatedVisibility( + visible = !serverReady || !resolverReady || safetyReady == false, + enter = fadeIn(animationSpec = tween(160)) + expandVertically(animationSpec = tween(160)), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(animationSpec = tween(120)), + ) { + Column( + modifier = Modifier.padding(top = WhiteDnsSpacing.sm), + verticalArrangement = Arrangement.spacedBy(WhiteDnsSpacing.sm), + ) { + if (!serverReady) { + SetupActionButton( + label = "Add Connection", + supportingText = "Create or choose a preset server", + icon = Icons.Rounded.Add, + emphasized = false, + enabled = true, + onClick = onAddConnectionClick, + ) + } + if (!resolverReady) { + SetupActionButton( + label = "Add Resolvers", + supportingText = "Create a clean resolver profile", + icon = Icons.Rounded.Link, + emphasized = false, + enabled = true, + onClick = onAddResolverProfileClick, + ) + } + if (safetyReady == false) { + SetupActionButton( + label = "Open Safety Settings", + supportingText = "Use loopback or add SOCKS credentials", + icon = Icons.Rounded.WarningAmber, + emphasized = false, + enabled = true, + onClick = onOpenAdvancedClick, + ) + } + } + } + } +} + +@Composable +private fun ReadinessStepRow( + label: String, + detail: String, + ready: Boolean?, +) { + val color = when (ready) { + true -> WhiteDnsPalette.Success + false -> WhiteDnsPalette.WarningText + null -> WhiteDnsPalette.Muted + } + val surface = when (ready) { + true -> WhiteDnsPalette.SuccessSurface + false -> WhiteDnsPalette.WarningSurface + null -> WhiteDnsPalette.SurfaceAlt + } + val icon = when (ready) { + true -> Icons.Rounded.Check + false -> Icons.Rounded.WarningAmber + null -> Icons.Rounded.Tune + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(9.dp)) + .background(surface) + .border(1.dp, color.copy(alpha = 0.16f), RoundedCornerShape(9.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 11.sp, + color = WhiteDnsPalette.Ink, + fontWeight = FontWeight.Bold, + ), + ) + Text( + text = detail, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 10.sp, + lineHeight = 14.sp, + color = WhiteDnsPalette.Description, + fontWeight = FontWeight.Medium, + ), + ) + } + } +} + @Composable private fun ConnectionSetupCard( selectedConnectionProfile: ConnectionProfile, @@ -3177,6 +3418,11 @@ private fun ConnectionProfilesSettings( onExport = { exportProfile = profile }, + onDuplicate = { + if (canEdit) { + onSettingsChange(settings.duplicateConnectionProfile(profile.id)) + } + }, onEdit = { dialogProfile = profile }, @@ -4307,6 +4553,15 @@ private fun ConnectionProfileExportDialog( Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) val link = linkResult.getOrNull() if (link != null) { + Text( + text = "This export includes connection secrets. Only share it with people or devices you trust.", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 11.sp, + color = WhiteDnsPalette.Warning, + lineHeight = 15.sp, + ), + ) + Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) if (showQr && !link.contains('\n')) { ProfileQrPreview(link = link) Spacer(modifier = Modifier.height(WhiteDnsSpacing.md)) @@ -4574,6 +4829,7 @@ private fun ConnectionProfileRow( onDragEnd: () -> Unit, onDragCancel: () -> Unit, onExport: () -> Unit, + onDuplicate: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) { @@ -4650,6 +4906,13 @@ private fun ConnectionProfileRow( enabled = profile.customServerDomain.isNotBlank() && profile.customServerEncryptionKey.isNotBlank(), onClick = onExport, ), + ProfileMenuAction( + label = "Duplicate profile", + icon = Icons.Rounded.ContentCopy, + contentDescription = "Duplicate connection profile", + enabled = canEdit, + onClick = onDuplicate, + ), ProfileMenuAction( label = if (selected) "Edit profile (selected)" else "Edit profile", icon = Icons.Rounded.Edit, @@ -5081,18 +5344,37 @@ private fun MtuSettingsGroup( ), ) } - WhiteDnsTextField( - label = "Resolver Parallel", - value = settings.mtuTestParallelismResolvers, - onValueChange = { - onSettingsChange(settings.copy(mtuTestParallelismResolvers = it.filter(Char::isDigit))) + val resolverParallelPreset = remember(settings.mtuTestParallelismResolvers) { + WhiteDnsOptions.resolverTestParallelismPreset(settings.mtuTestParallelismResolvers) + } + WhiteDnsDropdownField( + label = "Resolver Parallel Preset", + value = resolverParallelPreset, + options = WhiteDnsOptions.resolverTestParallelismPresets, + onValueChange = { preset -> + if (preset != WhiteDnsOptions.ResolverTestParallelismCustom) { + onSettingsChange(settings.copy(mtuTestParallelismResolvers = preset)) + } }, - placeholder = "100", - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - capitalization = KeyboardCapitalization.None, - ), ) + AnimatedVisibility( + visible = resolverParallelPreset == WhiteDnsOptions.ResolverTestParallelismCustom, + enter = fadeIn(animationSpec = tween(160)) + expandVertically(animationSpec = tween(160)), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(animationSpec = tween(120)), + ) { + WhiteDnsTextField( + label = "Resolver Parallel", + value = settings.mtuTestParallelismResolvers, + onValueChange = { + onSettingsChange(settings.copy(mtuTestParallelismResolvers = it.filter(Char::isDigit))) + }, + placeholder = "100", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + capitalization = KeyboardCapitalization.None, + ), + ) + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { WhiteDnsTextField( modifier = Modifier.weight(1f), @@ -6614,16 +6896,20 @@ private fun ResolverRuntimeValue( private fun ConnectionVerificationSummary( verification: ConnectionVerificationState, modifier: Modifier = Modifier, + idleMessage: String = "Connection verification has not run yet", + checkingLabel: String = "Verifying", + verifiedLabel: String = "Verified", + failedLabel: String = "Needs Attention", ) { val statusText = when (verification.status) { - ConnectionVerificationStatus.Checking -> "Verifying" - ConnectionVerificationStatus.Verified -> "Verified" - ConnectionVerificationStatus.Failed -> "Needs Attention" + ConnectionVerificationStatus.Checking -> checkingLabel + ConnectionVerificationStatus.Verified -> verifiedLabel + ConnectionVerificationStatus.Failed -> failedLabel else -> "Pending" } val message = verification.message.ifBlank { if (verification.status == ConnectionVerificationStatus.Idle) { - "Connection verification has not run yet" + idleMessage } else { "Checking tunnel route" } @@ -6770,36 +7056,77 @@ private fun ResolverRuntimeDialog( private fun LiveSpeedStrip( stats: ConnectionStats, ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) .background(WhiteDnsPalette.Surface) .border(2.dp, WhiteDnsPalette.Border, RoundedCornerShape(18.dp)) .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - SpeedIndicator( - icon = Icons.Filled.Download, - iconContentDescription = stringResource(R.string.cd_icon_download), - label = "Down", - value = formatDataSpeed(stats.downloadSpeedBytesPerSecond), - modifier = Modifier.weight(1f), - ) - SpeedIndicator( - icon = Icons.Filled.Upload, - iconContentDescription = stringResource(R.string.cd_icon_upload), - label = "Up", - value = formatDataSpeed(stats.uploadSpeedBytesPerSecond), - modifier = Modifier.weight(1f), - ) - SpeedIndicator( - icon = Icons.Filled.DataUsage, - iconContentDescription = stringResource(R.string.cd_icon_data_usage), - label = "Total Usage", - value = formatDataSize(stats.totalDataUsageBytes), - modifier = Modifier.weight(1f), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SpeedIndicator( + icon = Icons.Filled.Download, + iconContentDescription = stringResource(R.string.cd_icon_download), + label = "Down", + value = formatDataSpeed(stats.downloadSpeedBytesPerSecond), + modifier = Modifier.weight(1f), + ) + SpeedIndicator( + icon = Icons.Filled.Upload, + iconContentDescription = stringResource(R.string.cd_icon_upload), + label = "Up", + value = formatDataSpeed(stats.uploadSpeedBytesPerSecond), + modifier = Modifier.weight(1f), + ) + SpeedIndicator( + icon = Icons.Filled.DataUsage, + iconContentDescription = stringResource(R.string.cd_icon_data_usage), + label = if (stats.hasEstimatedPayloadTraffic) "Tunnel Total" else "Total Usage", + value = formatDataSize(stats.totalDataUsageBytes), + modifier = Modifier.weight(1f), + ) + } + if (stats.hasEstimatedPayloadTraffic) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SpeedIndicator( + icon = Icons.Filled.Download, + iconContentDescription = stringResource(R.string.cd_icon_download), + label = "Payload Down", + value = formatDataSpeed(stats.estimatedPayloadDownloadSpeedBytesPerSecond), + modifier = Modifier.weight(1f), + ) + SpeedIndicator( + icon = Icons.Filled.Upload, + iconContentDescription = stringResource(R.string.cd_icon_upload), + label = "Payload Up", + value = formatDataSpeed(stats.estimatedPayloadUploadSpeedBytesPerSecond), + modifier = Modifier.weight(1f), + ) + SpeedIndicator( + icon = Icons.Filled.DataUsage, + iconContentDescription = stringResource(R.string.cd_icon_data_usage), + label = "Payload Est.", + value = formatDataSize(stats.estimatedPayloadTotalBytes), + modifier = Modifier.weight(1f), + ) + } + Text( + text = "Tunnel usage includes duplicate DNS packets, warmup, retries, and protocol overhead. Payload estimate divides tunnel counters by the configured duplication counts.", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + color = WhiteDnsPalette.Muted, + lineHeight = 14.sp, + ), + ) + } } } @@ -7601,7 +7928,7 @@ private fun buildDiagnosticsText( val verification = uiState.connectionVerification val appVersion = appVersionName(context) - return buildString { + val rawDiagnostics = buildString { appendLine("WhiteDNS diagnostics") appendLine("App version: ${appVersion.ifBlank { "unknown" }}") appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") @@ -7623,6 +7950,18 @@ private fun buildDiagnosticsText( appendLine("Traffic total: ${formatDataSize(uiState.connectionStats.totalDataUsageBytes)}") appendLine("Traffic down: ${formatDataSpeed(uiState.connectionStats.downloadSpeedBytesPerSecond)}") appendLine("Traffic up: ${formatDataSpeed(uiState.connectionStats.uploadSpeedBytesPerSecond)}") + if (uiState.connectionStats.hasEstimatedPayloadTraffic) { + appendLine("Estimated payload total: ${formatDataSize(uiState.connectionStats.estimatedPayloadTotalBytes)}") + appendLine( + "Estimated payload down: " + + formatDataSpeed(uiState.connectionStats.estimatedPayloadDownloadSpeedBytesPerSecond), + ) + appendLine( + "Estimated payload up: " + + formatDataSpeed(uiState.connectionStats.estimatedPayloadUploadSpeedBytesPerSecond), + ) + appendLine("Traffic note: tunnel counters include duplication and DNS/protocol overhead") + } appendLine("Connected apps: ${uiState.connectionStats.connectedApps}") appendLine("Active resolvers: ${uiState.resolverRuntimeState.activeResolvers.size}") appendLine("Valid resolvers: ${uiState.resolverRuntimeState.validResolvers.size}") @@ -7635,7 +7974,30 @@ private fun buildDiagnosticsText( uiState.connectionLogs.forEach { log -> appendLine(log) } - }.trimEnd() + } + return SecretRedactor.redact( + source = rawDiagnostics, + secrets = diagnosticRedactionSecrets(context, settings), + ).trimEnd() +} + +private fun diagnosticRedactionSecrets( + context: Context, + settings: WhiteDnsSettings, +): RedactionSecrets { + val profiles = settings.normalizedConnectionProfiles() + val resolvedSettings = settings.resolve() + return RedactionSecrets( + serverDomains = profiles.map { it.customServerDomain }, + encryptionKeys = profiles.map { it.customServerEncryptionKey }, + socksUsernames = listOf(resolvedSettings.socksUsername), + socksPasswords = listOf(resolvedSettings.socksPassword), + runtimePaths = listOf( + context.filesDir.absolutePath, + context.cacheDir.absolutePath, + context.noBackupFilesDir.absolutePath, + ), + ) } private fun readResolverTextFromUri(context: Context, uri: Uri): Result { diff --git a/app/src/main/java/shop/whitedns/client/ui/WhiteDnsViewModel.kt b/app/src/main/java/shop/whitedns/client/ui/WhiteDnsViewModel.kt index 6110015..6b3bcfd 100644 --- a/app/src/main/java/shop/whitedns/client/ui/WhiteDnsViewModel.kt +++ b/app/src/main/java/shop/whitedns/client/ui/WhiteDnsViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.net.Inet4Address +import java.net.InetAddress import java.net.InetSocketAddress import java.net.NetworkInterface import java.net.ServerSocket @@ -58,6 +59,7 @@ import shop.whitedns.client.model.WhiteDnsParallelTest import shop.whitedns.client.model.applyAdvancedProfile import shop.whitedns.client.model.applyAutoTunePreset import shop.whitedns.client.model.importStormDnsProfileLink +import shop.whitedns.client.model.normalizeServerDomains import shop.whitedns.client.model.normalizedAdvancedProfiles import shop.whitedns.client.model.normalizedConnectionProfiles import shop.whitedns.client.model.normalizedResolverProfiles @@ -65,6 +67,7 @@ import shop.whitedns.client.model.resolve import shop.whitedns.client.model.runtimeConnectionSettings import shop.whitedns.client.model.selectedConnectionProfile import shop.whitedns.client.model.syncSelectedConnectionProfileFields +import shop.whitedns.client.model.validateConnectionSettings import shop.whitedns.client.proxy.WhiteDnsProxyEvent import shop.whitedns.client.proxy.WhiteDnsProxyEvents import shop.whitedns.client.proxy.WhiteDnsProxyService @@ -73,10 +76,13 @@ import shop.whitedns.client.runtime.WhiteDnsRuntimeState import shop.whitedns.client.runtime.WhiteDnsRuntimeStateStore import shop.whitedns.client.runtime.WhiteDnsTrafficWarmup import shop.whitedns.client.runtime.RuntimeLaunchRequestStore +import shop.whitedns.client.runtime.estimateDeduplicatedTraffic import shop.whitedns.client.runtime.formatTrafficSpeed import shop.whitedns.client.runtime.parseStormDnsConnectionProgressLine import shop.whitedns.client.runtime.parseStormDnsResolverStateLine import shop.whitedns.client.runtime.parseStormDnsTrafficStatsLine +import shop.whitedns.client.security.RedactionSecrets +import shop.whitedns.client.security.SecretRedactor import shop.whitedns.client.scan.WhiteDnsScanLaunchRequest import shop.whitedns.client.scan.WhiteDnsScanRequestStore import shop.whitedns.client.scan.WhiteDnsScanService @@ -126,6 +132,7 @@ class WhiteDnsViewModel( private var runtimeRefreshJob: Job? = null private var batteryOptimizationRefreshJob: Job? = null private var verificationJob: Job? = null + private var readinessJob: Job? = null private var scanLaunchJob: Job? = null private var lastScannerResultProfileText = "" private var activeServerProfile: StormDnsServerProfile? = null @@ -222,6 +229,9 @@ class WhiteDnsViewModel( settings = normalizedSettings, requestedProfileId = uiState.scanConnectionProfileId, ) + if (normalizedSettings != previousSettings) { + readinessJob?.cancel() + } if (scanConnectionProfileId != uiState.scanConnectionProfileId) { scanSettingsStore.saveConnectionProfileId(scanConnectionProfileId) } @@ -230,6 +240,11 @@ class WhiteDnsViewModel( settings = normalizedSettings, networkIpAddress = findDeviceNetworkIpAddress(), scanConnectionProfileId = scanConnectionProfileId, + profileReadiness = if (normalizedSettings != previousSettings) { + ConnectionVerificationState() + } else { + uiState.profileReadiness + }, ) if (shouldReconfigureActiveVpn(previousSettings, normalizedSettings)) { reconfigureActiveVpnSplitTunnel(normalizedSettings) @@ -242,10 +257,12 @@ class WhiteDnsViewModel( .importStormDnsProfileLink(rawLink) .syncSelectedConnectionProfileFields() }.onSuccess { importedSettings -> + readinessJob?.cancel() settingsStore.save(importedSettings) uiState = uiState.copy( settings = importedSettings, networkIpAddress = findDeviceNetworkIpAddress(), + profileReadiness = ConnectionVerificationState(), ) appendLog("Imported connection profile") }.onFailure { error -> @@ -280,6 +297,29 @@ class WhiteDnsViewModel( ) } + fun testCurrentProfileReadiness() { + if (uiState.connectionStatus != ConnectionStatus.DISCONNECTED) { + return + } + val settingsSnapshot = uiState.settings.syncSelectedConnectionProfileFields() + readinessJob?.cancel() + uiState = uiState.copy( + profileReadiness = ConnectionVerificationState( + status = ConnectionVerificationStatus.Checking, + message = "Checking setup", + ), + ) + readinessJob = viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + verifyProfileReadiness(settingsSnapshot) + } + if (uiState.connectionStatus != ConnectionStatus.DISCONNECTED) { + return@launch + } + uiState = uiState.copy(profileReadiness = result) + } + } + fun refreshRuntimeConnectionStatus() { runtimeRefreshJob?.cancel() runtimeRefreshJob = viewModelScope.launch { @@ -315,6 +355,7 @@ class WhiteDnsViewModel( statsJob?.cancel() runtimeRefreshJob?.cancel() verificationJob?.cancel() + readinessJob?.cancel() val sessionId = UUID.randomUUID().toString() activeRuntimeSessionId = sessionId uiState = uiState.copy( @@ -322,6 +363,7 @@ class WhiteDnsViewModel( connectionStats = ConnectionStats(), resolverRuntimeState = ResolverRuntimeState(), connectionProgress = ConnectionProgressState(phase = "preparing", percent = 3), + profileReadiness = ConnectionVerificationState(), connectionVerification = ConnectionVerificationState(), autoTuneTrialResults = emptyList(), connectionLogs = listOf("Starting StormDNS"), @@ -1299,6 +1341,7 @@ class WhiteDnsViewModel( statsJob?.cancel() runtimeRefreshJob?.cancel() verificationJob?.cancel() + readinessJob?.cancel() scanLaunchJob?.cancel() stopAutoTuneTrialManagers() WhiteDnsProxyEvents.removeListener(proxyEventListener) @@ -1577,9 +1620,7 @@ class WhiteDnsViewModel( } private fun prependConnectionLog(message: String): List { - val cleanMessage = message - .replace(Regex("\\u001B\\[[;\\d]*m"), "") - .trim() + val cleanMessage = redactRuntimeText(message).trim() if (cleanMessage.isEmpty()) { return uiState.connectionLogs } @@ -1798,6 +1839,58 @@ class WhiteDnsViewModel( ) } + private fun verifyProfileReadiness(settings: WhiteDnsSettings): ConnectionVerificationState { + val normalizedSettings = settings.syncSelectedConnectionProfileFields() + val validation = validateConnectionSettings(normalizedSettings) + if (!validation.canConnect) { + return failedVerification("Setup needs attention: ${validation.fatalIssues.first().message}") + } + + val runtimeSettings = normalizedSettings.runtimeConnectionSettings() + val resolvedSettings = runtimeSettings.resolve() + val connectionProfile = normalizedSettings.selectedConnectionProfile() + val serverDomains = normalizeServerDomains(connectionProfile.customServerDomain) + val resolvedDomainCount = serverDomains.count { domain -> hasServerAddressRecord(domain) } + + val busyPorts = buildList { + add("SOCKS ${resolvedSettings.listenPort}" to resolvedSettings.listenPort) + if (resolvedSettings.httpProxyEnabled) { + add("HTTP ${resolvedSettings.httpProxyPort}" to resolvedSettings.httpProxyPort) + } + if (resolvedSettings.localDnsEnabled) { + add("DNS ${resolvedSettings.localDnsPort}" to resolvedSettings.localDnsPort) + } + }.filter { (_, port) -> canConnectToLocalPort(port) } + if (busyPorts.isNotEmpty()) { + return failedVerification( + "Setup needs attention: local ${busyPorts.joinToString { it.first }} already appears to be in use", + ) + } + + val domainSummary = if (serverDomains.size <= 1) { + if (resolvedDomainCount == 1) { + "server address lookup is confirmed" + } else { + "server domain is configured" + } + } else { + "$resolvedDomainCount/${serverDomains.size} server address lookups confirmed" + } + val resolverCount = resolvedSettings.resolverEntries.size + val warnings = buildList { + addAll(validation.warnings.take(2).map { issue -> issue.message }) + if (serverDomains.isNotEmpty() && resolvedDomainCount == 0) { + add("normal server address lookup was not confirmed; the route check will prove the live tunnel after connect") + } + }.joinToString("; ") + val warningSuffix = if (warnings.isBlank()) "" else " Warning: $warnings." + return ConnectionVerificationState( + status = ConnectionVerificationStatus.Verified, + message = "Setup ready: $domainSummary, $resolverCount resolver${if (resolverCount == 1) "" else "s"}, and local ports are free. Connect next for the route check.$warningSuffix", + checkedAtMillis = System.currentTimeMillis(), + ) + } + private fun startConnectionVerification(expectedConnectionMode: String) { verificationJob?.cancel() uiState = uiState.copy( @@ -1867,6 +1960,16 @@ class WhiteDnsViewModel( ) } + private fun hasServerAddressRecord(domain: String): Boolean { + val lookupDomain = domain.trim().removeSurrounding("[", "]") + if (lookupDomain.isBlank()) { + return false + } + return runCatching { + InetAddress.getAllByName(lookupDomain).isNotEmpty() + }.getOrDefault(false) + } + private suspend fun repeatBooleanAttempt( attempts: Int, block: () -> Boolean, @@ -1888,6 +1991,11 @@ class WhiteDnsViewModel( countTrackedSocksStreams(), ) stormDnsTrafficAccounting.latest()?.let { stats -> + val resolvedSettings = uiState.settings.resolve() + val estimatedPayloadStats = stats.estimateDeduplicatedTraffic( + uploadDuplication = resolvedSettings.uploadDuplication, + downloadDuplication = resolvedSettings.downloadDuplication, + ) val peakSpeed = maxOf( uiState.connectionStats.peakSpeedBytesPerSecond, stats.downloadSpeedBytesPerSecond + stats.uploadSpeedBytesPerSecond, @@ -1900,6 +2008,13 @@ class WhiteDnsViewModel( uploadSpeedBytesPerSecond = stats.uploadSpeedBytesPerSecond, peakSpeedBytesPerSecond = peakSpeed, connectedApps = connectedApps, + estimatedPayloadDownloadBytes = estimatedPayloadStats.downloadBytes, + estimatedPayloadUploadBytes = estimatedPayloadStats.uploadBytes, + estimatedPayloadTotalBytes = estimatedPayloadStats.downloadBytes + estimatedPayloadStats.uploadBytes, + estimatedPayloadDownloadSpeedBytesPerSecond = estimatedPayloadStats.downloadSpeedBytesPerSecond, + estimatedPayloadUploadSpeedBytesPerSecond = estimatedPayloadStats.uploadSpeedBytesPerSecond, + hasEstimatedPayloadTraffic = resolvedSettings.uploadDuplication > 1 || + resolvedSettings.downloadDuplication > 1, ) } @@ -1939,6 +2054,12 @@ class WhiteDnsViewModel( uploadSpeedBytesPerSecond = uploadSpeed, peakSpeedBytesPerSecond = peakSpeed, connectedApps = connectedApps, + estimatedPayloadDownloadBytes = downloadBytes, + estimatedPayloadUploadBytes = uploadBytes, + estimatedPayloadTotalBytes = downloadBytes + uploadBytes, + estimatedPayloadDownloadSpeedBytesPerSecond = downloadSpeed, + estimatedPayloadUploadSpeedBytesPerSecond = uploadSpeed, + hasEstimatedPayloadTraffic = false, ) } @@ -2217,9 +2338,7 @@ class WhiteDnsViewModel( } private fun appendLogOnMain(message: String) { - val cleanMessage = message - .replace(Regex("\\u001B\\[[;\\d]*m"), "") - .trim() + val cleanMessage = redactRuntimeText(message).trim() if (cleanMessage.isEmpty()) { return } @@ -2227,6 +2346,21 @@ class WhiteDnsViewModel( uiState = uiState.copy(connectionLogs = nextLogs) } + private fun redactRuntimeText(message: String): String { + val settings = uiState.settings + val profiles = settings.normalizedConnectionProfiles() + val resolvedSettings = settings.resolve() + return SecretRedactor.redact( + source = message, + secrets = RedactionSecrets( + serverDomains = profiles.map { it.customServerDomain }, + encryptionKeys = profiles.map { it.customServerEncryptionKey }, + socksUsernames = listOf(resolvedSettings.socksUsername), + socksPasswords = listOf(resolvedSettings.socksPassword), + ), + ) + } + private companion object { const val MaxConnectionLogs = 100 const val RuntimeProgressUiUpdateIntervalMillis = 250L diff --git a/app/src/test/java/shop/whitedns/client/model/WhiteDnsModelsTest.kt b/app/src/test/java/shop/whitedns/client/model/WhiteDnsModelsTest.kt index 9e1ef5c..e78163d 100644 --- a/app/src/test/java/shop/whitedns/client/model/WhiteDnsModelsTest.kt +++ b/app/src/test/java/shop/whitedns/client/model/WhiteDnsModelsTest.kt @@ -20,6 +20,92 @@ class WhiteDnsModelsTest { assertEquals("custom", settings.serverMode) } + @Test + fun duplicateConnectionProfileCopiesRuntimeFieldsAndSelectsCopy() { + val resolverProfile = ResolverProfile( + id = "resolver-main", + name = "Main resolvers", + resolverText = "1.1.1.1", + ) + val sourceProfile = ConnectionProfile( + id = "profile-main", + name = "Main server", + customServerDomain = "main.example.com", + customServerEncryptionKey = "secret-key", + customServerEncryptionMethod = 2, + resolverProfileId = resolverProfile.id, + connectionMode = "vpn", + ) + val settings = WhiteDnsSettings( + selectedConnectionProfileId = sourceProfile.id, + connectionProfiles = listOf(sourceProfile), + resolverProfiles = listOf(resolverProfile), + ) + + val duplicatedSettings = settings.duplicateConnectionProfile( + profileId = sourceProfile.id, + nowMillis = 1234L, + ) + val profiles = duplicatedSettings.normalizedConnectionProfiles() + val copiedProfile = duplicatedSettings.selectedConnectionProfile() + + assertEquals(2, profiles.size) + assertEquals("profile-copy-1234", copiedProfile.id) + assertEquals("Main server Copy", copiedProfile.name) + assertEquals(sourceProfile.customServerDomain, copiedProfile.customServerDomain) + assertEquals(sourceProfile.customServerEncryptionKey, copiedProfile.customServerEncryptionKey) + assertEquals(sourceProfile.customServerEncryptionMethod, copiedProfile.customServerEncryptionMethod) + assertEquals(sourceProfile.resolverProfileId, copiedProfile.resolverProfileId) + assertEquals(sourceProfile.connectionMode, copiedProfile.connectionMode) + assertEquals(copiedProfile.id, duplicatedSettings.selectedConnectionProfileId) + } + + @Test + fun duplicateConnectionProfileLeavesSettingsUnchangedForUnknownProfile() { + val sourceProfile = ConnectionProfile( + id = "profile-main", + name = "Main server", + customServerDomain = "main.example.com", + ) + val settings = WhiteDnsSettings( + selectedConnectionProfileId = sourceProfile.id, + connectionProfiles = listOf(sourceProfile), + ) + + val duplicatedSettings = settings.duplicateConnectionProfile( + profileId = "missing-profile", + nowMillis = 1234L, + ) + + assertEquals(settings.syncSelectedConnectionProfileFields(), duplicatedSettings) + } + + @Test + fun duplicateConnectionProfileKeepsCopyIdsAndNamesUnique() { + val sourceProfile = ConnectionProfile( + id = "profile-main", + name = "Main server", + customServerDomain = "main.example.com", + ) + val existingCopy = sourceProfile.copy( + id = "profile-copy-1234", + name = "Main server Copy", + ) + val settings = WhiteDnsSettings( + selectedConnectionProfileId = sourceProfile.id, + connectionProfiles = listOf(sourceProfile, existingCopy), + ) + + val duplicatedSettings = settings.duplicateConnectionProfile( + profileId = sourceProfile.id, + nowMillis = 1234L, + ) + val copiedProfile = duplicatedSettings.selectedConnectionProfile() + + assertEquals("profile-copy-1234-2", copiedProfile.id) + assertEquals("Main server Copy 2", copiedProfile.name) + } + @Test fun runtimeConnectionSettingsEnableTunneledDnsForProxyMode() { val runtimeSettings = WhiteDnsSettings( @@ -736,11 +822,42 @@ class WhiteDnsModelsTest { assertEquals(WhiteDnsParallelTest.MaxSelectedConfigs, cappedIds.size) assertEquals( WhiteDnsParallelTest.defaultConfigIds + - profiles.take(3).map { WhiteDnsParallelTest.settingConfigId(it.id) }, + profiles.take(WhiteDnsParallelTest.MaxSelectedConfigs - WhiteDnsParallelTest.defaultConfigIds.size) + .map { WhiteDnsParallelTest.settingConfigId(it.id) }, cappedIds, ) } + @Test + fun applyMiladTelegramPresetCarriesFieldTestedSettings() { + val preset = WhiteDnsAutoTunePresets.all.first { it.id == "field-milad-telegram" } + val settings = WhiteDnsSettings() + .applyAutoTunePreset(preset) + val resolvedSettings = settings.resolve() + + assertEquals("Milad Telegram Proxy", preset.label) + assertEquals("127.0.0.1", resolvedSettings.listenIp) + assertEquals(10886, resolvedSettings.listenPort) + assertEquals(true, resolvedSettings.httpProxyEnabled) + assertEquals(10887, resolvedSettings.httpProxyPort) + assertEquals(false, resolvedSettings.socks5Authentication) + assertEquals(3, resolvedSettings.balancingStrategy) + assertEquals(3, resolvedSettings.uploadDuplication) + assertEquals(4, resolvedSettings.downloadDuplication) + assertEquals(5, resolvedSettings.minUploadMtu) + assertEquals(30, resolvedSettings.maxUploadMtu) + assertEquals(300, resolvedSettings.minDownloadMtu) + assertEquals(300, resolvedSettings.maxDownloadMtu) + assertEquals(3, resolvedSettings.mtuTestRetriesResolvers) + assertEquals(3.0, resolvedSettings.mtuTestTimeoutResolvers, 0.0) + assertEquals(100, resolvedSettings.mtuTestParallelismResolvers) + assertEquals(32, resolvedSettings.mtuTestParallelismLogs) + assertEquals(64, resolvedSettings.dnsResponseFragmentStoreCapacity) + assertEquals(30, resolvedSettings.pingWatchdogSeconds) + assertEquals(true, resolvedSettings.trafficWarmupEnabled) + assertEquals("WARN", resolvedSettings.logLevel) + } + @Test fun resolveUsesFullScanStartupAndBoundsNativeReliabilitySettings() { val settings = WhiteDnsSettings( @@ -1115,6 +1232,78 @@ class WhiteDnsModelsTest { assertEquals(freshState, recovered) } + @Test + fun validateConnectionSettingsRequiresServerProfileAndResolvers() { + val validation = validateConnectionSettings(WhiteDnsSettings()) + + assertEquals(false, validation.canConnect) + assertTrue(validation.fatalIssues.any { it.field == "server" }) + assertTrue(validation.fatalIssues.any { it.field == "resolvers" }) + } + + @Test + fun validateConnectionSettingsAcceptsCompleteSetup() { + val settings = WhiteDnsSettings( + customServerDomain = "example.com", + customServerEncryptionKey = "secret", + resolverText = "1.1.1.1\n8.8.8.8", + ).syncSelectedConnectionProfileFields() + + val validation = validateConnectionSettings(settings) + + assertEquals(true, validation.canConnect) + assertEquals(emptyList(), validation.fatalIssues) + } + + @Test + fun validateConnectionSettingsRejectsExposedProxyWithoutCompleteCredentials() { + val settings = WhiteDnsSettings( + customServerDomain = "example.com", + customServerEncryptionKey = "secret", + resolverText = "1.1.1.1", + listenIp = "0.0.0.0", + socks5Authentication = true, + socksUsername = "", + socksPassword = "password", + ).syncSelectedConnectionProfileFields() + + val validation = validateConnectionSettings(settings) + + assertEquals(false, validation.canConnect) + assertTrue(validation.fatalIssues.any { it.field == "socks5Authentication" }) + assertEquals(false, settings.resolve().socks5Authentication) + } + + @Test + fun validateConnectionSettingsWarnsOnLargeResolverParallelism() { + val settings = WhiteDnsSettings( + customServerDomain = "example.com", + customServerEncryptionKey = "secret", + resolverText = "1.1.1.1", + mtuTestParallelismResolvers = "129", + ).syncSelectedConnectionProfileFields() + + val validation = validateConnectionSettings(settings) + + assertEquals(true, validation.canConnect) + assertTrue(validation.warnings.any { it.field == "resolverTestParallelism" }) + } + + @Test + fun resolverTestParallelismPresetRecognizesBuiltInsAndCustomValues() { + assertEquals("64", WhiteDnsOptions.resolverTestParallelismPreset("64")) + assertEquals("128", WhiteDnsOptions.resolverTestParallelismPreset("128")) + assertEquals(WhiteDnsOptions.ResolverTestParallelismCustom, WhiteDnsOptions.resolverTestParallelismPreset("256")) + } + + @Test + fun normalizeServerDomainsAcceptsCommaSpaceAndBracketedText() { + assertEquals( + listOf("one.example.com", "two.example.com", "three.example.com"), + normalizeServerDomains("[one.example.com, two.example.com] 'three.example.com'"), + ) + } + private fun decodeStormDnsProfilePayload(link: String): String { val payload = link.removePrefix("stormdns://") val paddedPayload = payload.padEnd(payload.length + ((4 - payload.length % 4) % 4), '=') diff --git a/app/src/test/java/shop/whitedns/client/runtime/StormDnsTrafficStatsTest.kt b/app/src/test/java/shop/whitedns/client/runtime/StormDnsTrafficStatsTest.kt index c06bf83..4ecb838 100644 --- a/app/src/test/java/shop/whitedns/client/runtime/StormDnsTrafficStatsTest.kt +++ b/app/src/test/java/shop/whitedns/client/runtime/StormDnsTrafficStatsTest.kt @@ -115,4 +115,22 @@ class StormDnsTrafficStatsTest { assertEquals(25L, next.downloadBytes) assertEquals(10L, next.uploadBytes) } + + @Test + fun estimateDeduplicatedTrafficDividesTunnelCountersByDirectionDuplication() { + val stats = StormDnsTrafficStats( + downloadBytes = 401L, + uploadBytes = 301L, + downloadSpeedBytesPerSecond = 81L, + uploadSpeedBytesPerSecond = 61L, + ).estimateDeduplicatedTraffic( + uploadDuplication = 3, + downloadDuplication = 4, + ) + + assertEquals(101L, stats.downloadBytes) + assertEquals(101L, stats.uploadBytes) + assertEquals(21L, stats.downloadSpeedBytesPerSecond) + assertEquals(21L, stats.uploadSpeedBytesPerSecond) + } } diff --git a/app/src/test/java/shop/whitedns/client/security/SecretRedactorTest.kt b/app/src/test/java/shop/whitedns/client/security/SecretRedactorTest.kt new file mode 100644 index 0000000..ab894f5 --- /dev/null +++ b/app/src/test/java/shop/whitedns/client/security/SecretRedactorTest.kt @@ -0,0 +1,72 @@ +package shop.whitedns.client.security + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SecretRedactorTest { + + @Test + fun redactsConfiguredSecretsAndProfileLinks() { + val text = """ + Server: example.whitedns.test + Encryption key: tiny + SOCKS user: alice + SOCKS pass: p + Link: stormdns://abcdef + Path: /data/user/0/shop.whitedns.client/no_backup/runtime-launch/request.json + """.trimIndent() + + val redacted = SecretRedactor.redact( + text, + RedactionSecrets( + serverDomains = listOf("example.whitedns.test"), + encryptionKeys = listOf("tiny"), + socksUsernames = listOf("alice"), + socksPasswords = listOf("p"), + runtimePaths = listOf("/data/user/0/shop.whitedns.client/no_backup"), + ), + ) + + assertFalse(redacted.contains("example.whitedns.test")) + assertFalse(redacted.contains("tiny")) + assertFalse(redacted.contains("alice")) + assertFalse(redacted.contains("stormdns://abcdef")) + assertFalse(redacted.contains("/data/user/0/shop.whitedns.client/no_backup")) + assertTrue(redacted.contains("[server domain]")) + assertTrue(redacted.contains("[encryption key]")) + assertTrue(redacted.contains("[socks username]")) + assertTrue(redacted.contains("[socks password]")) + assertTrue(redacted.contains("[profile link]")) + assertTrue(redacted.contains("[runtime path]")) + } + + @Test + fun redactsNamedTomlAndLogSecretFields() { + val text = """ + ENCRYPTION_KEY = "abc123" + SOCKS5_PASS=password123 + apiToken: secret-token + harmless = value + """.trimIndent() + + val redacted = SecretRedactor.redact(text) + + assertFalse(redacted.contains("abc123")) + assertFalse(redacted.contains("password123")) + assertFalse(redacted.contains("secret-token")) + assertTrue(redacted.contains("harmless = value")) + } + + @Test + fun stripsAnsiAndIsIdempotent() { + val text = "\u001B[31mENCRYPTION_KEY=abc123\u001B[0m" + + val once = SecretRedactor.redact(text) + val twice = SecretRedactor.redact(once) + + assertFalse(once.contains("\u001B")) + assertFalse(once.contains("abc123")) + assertTrue(once == twice) + } +} diff --git a/app/src/test/java/shop/whitedns/client/storm/StormDnsBuiltInPoolTest.kt b/app/src/test/java/shop/whitedns/client/storm/StormDnsBuiltInPoolTest.kt new file mode 100644 index 0000000..d46f3f2 --- /dev/null +++ b/app/src/test/java/shop/whitedns/client/storm/StormDnsBuiltInPoolTest.kt @@ -0,0 +1,35 @@ +package shop.whitedns.client.storm + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StormDnsBuiltInPoolTest { + @Test + fun builtInServerProfilesHaveStableUniqueIds() { + val profiles = StormDnsBuiltInPool.profiles + + assertTrue(profiles.isNotEmpty()) + assertEquals(profiles.size, profiles.map { it.id }.distinct().size) + } + + @Test + fun repeatedDonorLabelsAreNumbered() { + val whiteDnsProfiles = StormDnsBuiltInPool.profiles + .filter { it.label.startsWith("@WhiteDNS ") } + + assertTrue(whiteDnsProfiles.size > 1) + assertEquals("@WhiteDNS 01", whiteDnsProfiles.first().label) + assertTrue(whiteDnsProfiles.any { it.label == "@WhiteDNS 02" }) + } + + @Test + fun presetLabelsUseDonorNames() { + val labels = StormDnsBuiltInPool.profiles.map { it.label }.toSet() + + assertTrue(labels.any { it.startsWith("@PersiaTMChannel ") }) + assertTrue(labels.any { it.startsWith("Ali / @link_dakheli_app ") }) + assertTrue(labels.any { it.startsWith("@Masir_Sefid ") }) + assertTrue(labels.any { it.startsWith("@pythash ") }) + } +}