diff --git a/android/app/src/main/java/com/masterdns/vpn/data/local/IdentityCipher.kt b/android/app/src/main/java/com/masterdns/vpn/data/local/IdentityCipher.kt new file mode 100644 index 0000000..5d063eb --- /dev/null +++ b/android/app/src/main/java/com/masterdns/vpn/data/local/IdentityCipher.kt @@ -0,0 +1,54 @@ +package com.masterdns.vpn.data.local + +import android.util.Base64 +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Encrypts and decrypts identity fields for locked profile export. + * This deters casual disclosure in shared TOML files; it is not a DRM boundary. + */ +object IdentityCipher { + private const val PREFIX = "ENC:" + private const val GCM_TAG_BITS = 128 + private const val IV_BYTES = 12 + + private val keyBytes: ByteArray by lazy { + val a = byteArrayOf( + 0x4D, 0x61, 0x73, 0x74, 0x65, 0x72, 0x44, 0x6E, + 0x73, 0x56, 0x50, 0x4E, 0x2D, 0x47, 0x47, 0x2D + ) + val b = byteArrayOf( + 0x4C, 0x6F, 0x63, 0x6B, 0x65, 0x64, 0x49, 0x64, + 0x65, 0x6E, 0x74, 0x69, 0x74, 0x79, 0x4B, 0x31 + ) + a + b + } + + private fun keySpec() = SecretKeySpec(keyBytes, "AES") + + fun encrypt(plaintext: String): String { + val iv = ByteArray(IV_BYTES).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keySpec(), GCMParameterSpec(GCM_TAG_BITS, iv)) + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return PREFIX + Base64.encodeToString(iv + ciphertext, Base64.NO_WRAP) + } + + fun decrypt(encoded: String): String? { + if (!isEncrypted(encoded)) return null + return runCatching { + val combined = Base64.decode(encoded.removePrefix(PREFIX), Base64.NO_WRAP) + if (combined.size <= IV_BYTES) return null + val iv = combined.copyOfRange(0, IV_BYTES) + val ciphertext = combined.copyOfRange(IV_BYTES, combined.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, keySpec(), GCMParameterSpec(GCM_TAG_BITS, iv)) + String(cipher.doFinal(ciphertext), Charsets.UTF_8) + }.getOrNull() + } + + fun isEncrypted(value: String): Boolean = value.startsWith(PREFIX) +} diff --git a/android/app/src/main/java/com/masterdns/vpn/data/repository/ProfileRepository.kt b/android/app/src/main/java/com/masterdns/vpn/data/repository/ProfileRepository.kt index 258445f..b51af4b 100644 --- a/android/app/src/main/java/com/masterdns/vpn/data/repository/ProfileRepository.kt +++ b/android/app/src/main/java/com/masterdns/vpn/data/repository/ProfileRepository.kt @@ -1,7 +1,10 @@ package com.masterdns.vpn.data.repository +import com.masterdns.vpn.data.local.IdentityCipher import com.masterdns.vpn.data.local.ProfileDao import com.masterdns.vpn.data.local.ProfileEntity +import com.masterdns.vpn.util.ConfigGenerator +import com.google.gson.Gson import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -10,6 +13,8 @@ import javax.inject.Singleton class ProfileRepository @Inject constructor( private val profileDao: ProfileDao ) { + private val gson = Gson() + fun getAllProfiles(): Flow> = profileDao.getAllProfiles() fun getSelectedProfileFlow(): Flow = profileDao.getSelectedProfileFlow() @@ -26,4 +31,155 @@ class ProfileRepository @Inject constructor( suspend fun deleteProfile(profile: ProfileEntity) = profileDao.deleteProfile(profile) suspend fun setSelectedProfile(id: Long) = profileDao.setSelectedProfile(id) + + /** + * Export a profile as a TOML string. + * If [lockIdentity] is true, DOMAINS and ENCRYPTION_KEY are AES-256-GCM encrypted + * so the recipient can use the profile without seeing the actual values. + */ + suspend fun exportProfileToml(id: Long, lockIdentity: Boolean = false): String? { + val profile = profileDao.getProfileById(id) ?: return null + return ConfigGenerator.exportToml(profile, lockIdentity) + } + + /** + * Import a profile from a TOML string. + * If IDENTITY_LOCKED = true in the TOML, domains and key are decrypted before storage. + */ + suspend fun importProfileFromToml(tomlContent: String, name: String): ProfileEntity? { + val profile = parseTomlToProfile(tomlContent, name) ?: return null + val id = profileDao.insertProfile(profile) + return profile.copy(id = id) + } + + fun previewProfileFromToml(tomlContent: String, name: String): ProfileEntity? { + return parseTomlToProfile(tomlContent, name) + } + + private fun parseTomlToProfile(tomlContent: String, name: String): ProfileEntity? { + val values = mutableMapOf() + tomlContent.lineSequence().forEach { raw -> + val line = raw.substringBefore("#").trim() + if (line.isEmpty() || "=" !in line) return@forEach + val key = line.substringBefore("=").trim() + val valueRaw = line.substringAfter("=").trim() + val parsed = when { + key == "DOMAINS" -> { + if (valueRaw.startsWith("[")) { + // Array format: ["a.com", "b.com"] + valueRaw + .removePrefix("[").removeSuffix("]") + .split(",") + .map { it.trim().removeSurrounding("\"") } + .filter { it.isNotBlank() } + .joinToString(", ") + } else { + // Single string format: "ENC:..." or "value" + valueRaw.removeSurrounding("\"") + } + } + valueRaw.startsWith("\"") && valueRaw.endsWith("\"") -> + valueRaw.removeSurrounding("\"") + else -> valueRaw + } + values[key] = parsed + } + + val isLocked = values["IDENTITY_LOCKED"]?.trim()?.equals("true", ignoreCase = true) == true + + val rawDomains = values["DOMAINS"]?.takeIf { it.isNotBlank() } ?: return null + val rawKey = values["ENCRYPTION_KEY"]?.takeIf { it.isNotBlank() } ?: return null + + val finalDomains: String + val finalKey: String + + if (isLocked) { + // Try single-encrypted format first (new format: whole domains string encrypted at once) + val trimmedDomains = rawDomains.trim() + if (IdentityCipher.isEncrypted(trimmedDomains)) { + val decrypted = IdentityCipher.decrypt(trimmedDomains) + if (decrypted != null) { + finalDomains = gson.toJson(decrypted.split(",").map { it.trim() }.filter { it.isNotEmpty() }) + } else { + // Decryption failed — do not store encrypted garbage + return null + } + } else { + // Legacy format: each domain encrypted separately + val decryptedDomains = rawDomains.split(",") + .map { it.trim() } + .map { d -> + if (IdentityCipher.isEncrypted(d)) { + IdentityCipher.decrypt(d) ?: return null // fail if any domain can't be decrypted + } else d + } + finalDomains = gson.toJson(decryptedDomains) + } + finalKey = if (IdentityCipher.isEncrypted(rawKey)) { + IdentityCipher.decrypt(rawKey) ?: return null + } else rawKey + } else { + finalDomains = gson.toJson(rawDomains.split(",").map { it.trim() }.filter { it.isNotEmpty() }) + finalKey = rawKey + } + + val advanced = mutableMapOf() + IMPORT_ADVANCED_KEYS.forEach { key -> values[key]?.let { advanced[key] = it.trim() } } + + return ProfileEntity( + name = name, + domains = finalDomains, + encryptionMethod = values["DATA_ENCRYPTION_METHOD"]?.toIntOrNull() ?: 1, + encryptionKey = finalKey, + protocolType = when (values["PROTOCOL_TYPE"]?.trim()?.uppercase()) { "TCP" -> "TCP" else -> "SOCKS5" }, + listenPort = values["LISTEN_PORT"]?.toIntOrNull()?.coerceIn(1, 65535) ?: 18000, + resolverBalancingStrategy = values["RESOLVER_BALANCING_STRATEGY"]?.toIntOrNull() ?: 2, + packetDuplicationCount = values["PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2, + setupPacketDuplicationCount = values["SETUP_PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2, + 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", + advancedJson = gson.toJson(advanced), + ) + } + + companion object { + private val IMPORT_ADVANCED_KEYS = setOf( + "LISTEN_IP", "SOCKS5_AUTH", "SOCKS5_USER", "SOCKS5_PASS", + "LOCAL_DNS_ENABLED", "LOCAL_DNS_IP", "LOCAL_DNS_PORT", + "LOCAL_DNS_CACHE_MAX_RECORDS", "LOCAL_DNS_CACHE_TTL_SECONDS", + "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", + "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", + "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "STREAM_RESOLVER_FAILOVER_COOLDOWN", + "RECHECK_INACTIVE_SERVERS_ENABLED", "AUTO_DISABLE_TIMEOUT_SERVERS", + "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "BASE_ENCODE_DATA", "COMPRESSION_MIN_SIZE", + "MIN_UPLOAD_MTU", "MIN_DOWNLOAD_MTU", "MAX_UPLOAD_MTU", "MAX_DOWNLOAD_MTU", + "MTU_TEST_RETRIES", "MTU_TEST_TIMEOUT", "MTU_TEST_PARALLELISM", + "SAVE_MTU_SERVERS_TO_FILE", "MTU_SERVERS_FILE_NAME", "MTU_SERVERS_FILE_FORMAT", + "MTU_USING_SECTION_SEPARATOR_TEXT", "MTU_REMOVED_SERVER_LOG_FORMAT", + "MTU_ADDED_SERVER_LOG_FORMAT", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", + "MTU_EXPORT_URI", + "RX_TX_WORKERS", "TUNNEL_PROCESS_WORKERS", "TUNNEL_PACKET_TIMEOUT_SECONDS", + "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "RX_CHANNEL_SIZE", + "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", + "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", + "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", + "SESSION_INIT_RETRY_BASE_SECONDS", "SESSION_INIT_RETRY_STEP_SECONDS", + "SESSION_INIT_RETRY_LINEAR_AFTER", "SESSION_INIT_RETRY_MAX_SECONDS", + "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "SESSION_INIT_RACING_COUNT", + "PING_AGGRESSIVE_INTERVAL_SECONDS", "PING_LAZY_INTERVAL_SECONDS", + "PING_COOLDOWN_INTERVAL_SECONDS", "PING_COLD_INTERVAL_SECONDS", + "PING_WARM_THRESHOLD_SECONDS", "PING_COOL_THRESHOLD_SECONDS", + "PING_COLD_THRESHOLD_SECONDS", + "MAX_PACKETS_PER_BATCH", "ARQ_WINDOW_SIZE", "ARQ_INITIAL_RTO_SECONDS", + "ARQ_MAX_RTO_SECONDS", "ARQ_CONTROL_INITIAL_RTO_SECONDS", + "ARQ_CONTROL_MAX_RTO_SECONDS", "ARQ_MAX_CONTROL_RETRIES", + "ARQ_MAX_DATA_RETRIES", "ARQ_DATA_PACKET_TTL_SECONDS", + "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ_DATA_NACK_MAX_GAP", + "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "ARQ_DATA_NACK_REPEAT_SECONDS", + "ARQ_INACTIVITY_TIMEOUT_SECONDS", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", + "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS" + ) + } } diff --git a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt index be6732e..f48e994 100644 --- a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt +++ b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt @@ -18,6 +18,7 @@ import com.masterdns.vpn.R import com.masterdns.vpn.data.local.AppDatabase import com.masterdns.vpn.util.ConfigGenerator import com.masterdns.vpn.util.GlobalSettingsStore +import com.masterdns.vpn.util.ResolverAnalyzer import com.masterdns.vpn.util.VpnManager import kotlinx.coroutines.* import java.io.File @@ -68,6 +69,7 @@ class MasterDnsVpnService : VpnService() { private var sharingSocksServer: java.net.ServerSocket? = null private var sharingHttpServer: java.net.ServerSocket? = null private var logTailJob: Job? = null + private var notificationStatsJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null private var mtuExportTargetUri: String? = null private var mtuConfigDir: File? = null @@ -113,6 +115,7 @@ class MasterDnsVpnService : VpnService() { // Show foreground notification startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.notification_connecting))) + startNotificationStats() acquireWakeLock() // Load profile from DB @@ -189,7 +192,16 @@ class MasterDnsVpnService : VpnService() { localDnsPortOverride = if (proxyMode) null else safeDnsPort ) ) - if (runtimeProfile.resolvers.isNotBlank()) { + val importedResolverFile = ResolverAnalyzer.profileImportedResolver(runtimeProfile) + ?.takeIf { File(it.cachedPath).isFile } + if (importedResolverFile != null) { + File(importedResolverFile.cachedPath).copyTo(resolversFile, overwrite = true) + VpnManager.appendLog("Using imported resolver file: ${importedResolverFile.displayName}") + VpnManager.appendLog("Resolver stats: ${importedResolverFile.stats.summary()}") + } else if (ResolverAnalyzer.profileImportedResolver(runtimeProfile) != null) { + VpnManager.appendLog("Imported resolver file is missing; falling back to inline resolvers") + resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile)) + } else if (runtimeProfile.resolvers.isNotBlank()) { resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile)) } else if (!resolversFile.exists() || resolversFile.readText().isBlank()) { resolversFile.writeText(ConfigGenerator.generateResolvers(runtimeProfile)) @@ -250,9 +262,7 @@ class MasterDnsVpnService : VpnService() { VpnManager.appendLog("Proxy mode active: skipping Android VpnService TUN setup") VpnManager.updateState(VpnManager.VpnState.CONNECTED) VpnManager.startTrafficMonitor(this@MasterDnsVpnService) - val notification = buildNotification("Proxy mode active on port $socksPort") - val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager - manager.notify(NOTIFICATION_ID, notification) + notifyStatus("Proxy mode active on port $socksPort") return@launch } @@ -360,9 +370,7 @@ class MasterDnsVpnService : VpnService() { VpnManager.appendLog("VPN connected successfully!") // Update notification - val notification = buildNotification(getString(R.string.notification_connected)) - val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager - manager.notify(NOTIFICATION_ID, notification) + notifyStatus(getString(R.string.notification_connected)) } catch (e: CancellationException) { VpnManager.appendLog("Connection canceled") @@ -409,6 +417,7 @@ class MasterDnsVpnService : VpnService() { httpProxyJob?.cancel() sharingSocksJob?.cancel() logTailJob?.cancel() + notificationStatsJob?.cancel() runCatching { sharingSocksServer?.close() } sharingSocksServer = null runCatching { sharingHttpServer?.close() } @@ -452,12 +461,73 @@ class MasterDnsVpnService : VpnService() { return NotificationCompat.Builder(this, App.CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setSmallIcon(R.drawable.ic_vpn_key) .setContentIntent(pendingIntent) .setOngoing(true) .build() } + private fun startNotificationStats() { + notificationStatsJob?.cancel() + notificationStatsJob = serviceScope.launch { + while (isActive) { + notifyStatus(notificationStatusText()) + delay(2000L) + } + } + } + + private fun notifyStatus(text: String) { + val manager = getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(text)) + } + + private fun notificationStatusText(): String { + val state = VpnManager.state.value + val scan = VpnManager.scanStatus.value + val down = formatBytesPerSecond(VpnManager.downloadSpeedBps.value) + val up = formatBytesPerSecond(VpnManager.uploadSpeedBps.value) + val totals = getString( + R.string.notification_traffic_totals, + formatBytes(VpnManager.downloadTotalBytes.value), + formatBytes(VpnManager.uploadTotalBytes.value) + ) + return when { + state == VpnManager.VpnState.CONNECTING && scan.scanning -> { + val scanned = scan.validCount + scan.rejectedCount + val total = scan.scanTotalFromCore.takeIf { it > 0 } + if (total != null) { + getString(R.string.notification_scanning_dns, scanned, total, down, up) + } else { + getString(R.string.notification_connecting_with_speed, down, up) + } + } + state == VpnManager.VpnState.CONNECTED -> { + scan.activeResolvers.takeIf { it > 0 }?.let { + getString(R.string.notification_connected_with_resolvers, down, up, totals, it) + } ?: getString(R.string.notification_connected_with_totals, down, up, totals) + } + state == VpnManager.VpnState.DISCONNECTING -> getString(R.string.notification_disconnecting_with_totals, totals) + state == VpnManager.VpnState.ERROR -> VpnManager.errorMessage.value ?: "Connection error" + else -> getString(R.string.notification_connecting) + } + } + + private fun formatBytesPerSecond(bytes: Long): String = "${formatBytes(bytes)}/s" + + private fun formatBytes(bytes: Long): String { + val kb = 1024.0 + val mb = kb * 1024.0 + val gb = mb * 1024.0 + return when { + bytes >= gb -> String.format("%.2f GB", bytes / gb) + bytes >= mb -> String.format("%.2f MB", bytes / mb) + bytes >= kb -> String.format("%.1f KB", bytes / kb) + else -> "$bytes B" + } + } + override fun onDestroy() { if (!isStopping) { try { @@ -473,6 +543,7 @@ class MasterDnsVpnService : VpnService() { vpnInterface = null } releaseWakeLock() + notificationStatsJob?.cancel() serviceScope.cancel() super.onDestroy() } diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt index e7ca32d..25de414 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,7 +49,9 @@ import com.masterdns.vpn.ui.theme.ConnectingAmber import com.masterdns.vpn.ui.theme.DisconnectedRed import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer import com.masterdns.vpn.util.VpnManager +import kotlinx.coroutines.delay private data class HomeLayoutMetrics( val horizontalPadding: androidx.compose.ui.unit.Dp, @@ -65,6 +68,9 @@ fun HomeScreen( val vpnState by VpnManager.state.collectAsState() val upBps by VpnManager.uploadSpeedBps.collectAsState() val downBps by VpnManager.downloadSpeedBps.collectAsState() + val upTotalBytes by VpnManager.uploadTotalBytes.collectAsState() + val downTotalBytes by VpnManager.downloadTotalBytes.collectAsState() + val connectedSinceMs by VpnManager.connectedSinceMs.collectAsState() val scanStatus by VpnManager.scanStatus.collectAsState() val selectedProfile by viewModel.selectedProfile.collectAsState() val error by VpnManager.errorMessage.collectAsState() @@ -78,6 +84,9 @@ fun HomeScreen( val socksAuthEnabled = advanced["SOCKS5_AUTH"].equals("true", ignoreCase = true) val socksUser = advanced["SOCKS5_USER"]?.trim().orEmpty() val socksPass = advanced["SOCKS5_PASS"]?.trim().orEmpty() + val preConnectWarnings = remember(selectedProfile?.id, selectedProfile?.resolvers, selectedProfile?.advancedJson) { + buildPreConnectWarnings(context, selectedProfile, advanced) + } val vpnPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -99,6 +108,25 @@ fun HomeScreen( } else { 0f } + val scanEtaText = estimateScanEta( + scannedCount = scannedCount, + totalResolvers = totalResolvers, + startedAtMs = scanStatus.scanStartedAtMs, + updatedAtMs = scanStatus.scanUpdatedAtMs + ) + val nowMs by produceState(System.currentTimeMillis(), vpnState, connectedSinceMs) { + while (vpnState == VpnManager.VpnState.CONNECTED && connectedSinceMs > 0L) { + value = System.currentTimeMillis() + delay(1000L) + } + } + val connectionDurationText = remember(vpnState, connectedSinceMs, nowMs) { + if (vpnState == VpnManager.VpnState.CONNECTED && connectedSinceMs > 0L) { + formatDuration(((nowMs - connectedSinceMs) / 1000L).coerceAtLeast(0L)) + } else { + "" + } + } val statusColor by animateColorAsState( targetValue = when (vpnState) { @@ -226,8 +254,12 @@ fun HomeScreen( scannedCount = scannedCount, totalResolvers = totalResolvers, scanProgress = scanProgress, + scanEtaText = scanEtaText, downBps = downBps, upBps = upBps, + downTotalBytes = downTotalBytes, + upTotalBytes = upTotalBytes, + connectionDurationText = connectionDurationText, proxyHost = proxyHost, proxyPort = proxyPort, socksAuthEnabled = socksAuthEnabled, @@ -240,6 +272,10 @@ fun HomeScreen( profileName = selectedProfile?.name ?: stringResource(R.string.profiles_create), onNavigateToProfiles = onNavigateToProfiles ) + if (!isConnected && !isConnecting && preConnectWarnings.isNotEmpty()) { + Spacer(modifier = Modifier.height(MdvSpace.S3)) + MdvPreConnectChecklist(warnings = preConnectWarnings) + } error?.let { msg -> Spacer(modifier = Modifier.height(MdvSpace.S4)) MdvErrorCard(msg = msg) @@ -295,8 +331,12 @@ fun HomeScreen( scannedCount = scannedCount, totalResolvers = totalResolvers, scanProgress = scanProgress, + scanEtaText = scanEtaText, downBps = downBps, upBps = upBps, + downTotalBytes = downTotalBytes, + upTotalBytes = upTotalBytes, + connectionDurationText = connectionDurationText, proxyHost = proxyHost, proxyPort = proxyPort, socksAuthEnabled = socksAuthEnabled, @@ -312,6 +352,11 @@ fun HomeScreen( onNavigateToProfiles = onNavigateToProfiles ) + if (!isConnected && !isConnecting && preConnectWarnings.isNotEmpty()) { + Spacer(modifier = Modifier.height(MdvSpace.S3)) + MdvPreConnectChecklist(warnings = preConnectWarnings) + } + error?.let { msg -> Spacer(modifier = Modifier.height(MdvSpace.S4)) MdvErrorCard(msg = msg) @@ -321,6 +366,62 @@ fun HomeScreen( } } +private fun buildPreConnectWarnings( + context: android.content.Context, + profile: com.masterdns.vpn.data.local.ProfileEntity?, + advanced: Map +): List { + if (profile == null) return listOf(context.getString(R.string.home_preconnect_no_profile)) + val warnings = mutableListOf() + val imported = ResolverAnalyzer.profileImportedResolver(profile) + if (imported != null) { + if (!java.io.File(imported.cachedPath).isFile) { + warnings += context.getString(R.string.home_preconnect_resolver_file_missing) + } else if (imported.stats.uniqueUsableIps <= 0) { + warnings += context.getString(R.string.home_preconnect_resolver_file_empty) + } + } else if (profile.resolvers.lineSequence().none { it.trim().isNotEmpty() }) { + warnings += context.getString(R.string.home_preconnect_no_inline_resolvers) + } + val localDnsEnabled = advanced["LOCAL_DNS_ENABLED"].equals("true", ignoreCase = true) + val localDnsPort = advanced["LOCAL_DNS_PORT"]?.toIntOrNull() ?: 5353 + if (localDnsEnabled && localDnsPort <= 1024) { + warnings += context.getString(R.string.home_preconnect_dns_port_remap, localDnsPort) + } + if (advanced["SAVE_MTU_SERVERS_TO_FILE"].equals("true", ignoreCase = true) && + advanced["MTU_EXPORT_URI"].isNullOrBlank() + ) { + warnings += context.getString(R.string.home_preconnect_mtu_export_missing) + } + return warnings +} + +private fun estimateScanEta( + scannedCount: Int, + totalResolvers: Int, + startedAtMs: Long, + updatedAtMs: Long +): String { + if (scannedCount < 3 || totalResolvers <= scannedCount || startedAtMs <= 0L || updatedAtMs <= startedAtMs) { + return "" + } + val elapsedMs = (updatedAtMs - startedAtMs).coerceAtLeast(1L) + val remaining = totalResolvers - scannedCount + val etaSeconds = ((elapsedMs / scannedCount.toDouble()) * remaining / 1000.0).toLong().coerceAtLeast(1L) + return formatDuration(etaSeconds) +} + +private fun formatDuration(totalSeconds: Long): String { + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m ${seconds}s" + else -> "${seconds}s" + } +} + private fun parseAdvanced(json: String): Map { return try { val type = object : TypeToken>() {}.type diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt index 1548d7b..6b02823 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/home/HomeStatusCards.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import com.masterdns.vpn.R import com.masterdns.vpn.ui.components.mdv.cards.MdvCardHigh import com.masterdns.vpn.ui.components.mdv.cards.MdvCardLow +import com.masterdns.vpn.ui.theme.ConnectingAmber import com.masterdns.vpn.ui.theme.ConnectedGreen import com.masterdns.vpn.ui.theme.DisconnectedRed import com.masterdns.vpn.ui.theme.MdvColor @@ -35,8 +36,12 @@ fun MdvConnectionTelemetryCard( scannedCount: Int, totalResolvers: Int, scanProgress: Float, + scanEtaText: String, downBps: Long, upBps: Long, + downTotalBytes: Long, + upTotalBytes: Long, + connectionDurationText: String, proxyHost: String, proxyPort: Int, socksAuthEnabled: Boolean, @@ -99,6 +104,14 @@ fun MdvConnectionTelemetryCard( style = MaterialTheme.typography.bodySmall, color = MdvColor.OnSurfaceVariant ) + if (scanEtaText.isNotBlank()) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.home_dns_scan_eta, scanEtaText), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S1)) LinearProgressIndicator( progress = { scanProgress }, @@ -135,6 +148,18 @@ fun MdvConnectionTelemetryCard( style = MaterialTheme.typography.bodySmall, color = MdvColor.OnSurfaceVariant ) + Text( + text = stringResource(R.string.home_traffic_totals, formatBytes(downTotalBytes), formatBytes(upTotalBytes)), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + if (connectionDurationText.isNotBlank()) { + Text( + text = stringResource(R.string.home_connection_duration, connectionDurationText), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S2)) Text( text = stringResource(R.string.home_socks_address, proxyHost, proxyPort), @@ -167,6 +192,28 @@ fun MdvConnectionTelemetryCard( } } +@Composable +fun MdvPreConnectChecklist(warnings: List) { + if (warnings.isEmpty()) return + MdvCardLow(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(MdvSpace.S3)) { + Text( + text = stringResource(R.string.home_preconnect_title), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = ConnectingAmber + ) + warnings.forEach { warning -> + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(4.dp)) + Text( + text = warning, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } +} + @Composable fun MdvProfileSelectorCard( profileName: String, @@ -208,22 +255,51 @@ fun MdvProfileSelectorCard( @Composable fun MdvErrorCard(msg: String) { MdvCardLow(modifier = Modifier.fillMaxWidth()) { - Text( - text = msg, - style = MaterialTheme.typography.bodyMedium, - color = DisconnectedRed, - modifier = Modifier.padding(MdvSpace.S3), - textAlign = TextAlign.Center - ) + Column(modifier = Modifier.padding(MdvSpace.S3)) { + Text( + text = msg, + style = MaterialTheme.typography.bodyMedium, + color = DisconnectedRed, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + errorSuggestion(msg)?.let { suggestion -> + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S1)) + Text( + text = suggestion, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun errorSuggestion(message: String): String? { + return when { + message.contains("SOCKS5", ignoreCase = true) -> stringResource(R.string.home_error_suggestion_socks) + message.contains("port", ignoreCase = true) -> stringResource(R.string.home_error_suggestion_port) + message.contains("resolver", ignoreCase = true) -> stringResource(R.string.home_error_suggestion_resolver) + message.contains("permission", ignoreCase = true) -> stringResource(R.string.home_error_suggestion_permission) + else -> null } } private fun formatSpeed(bps: Long): String { + return "${formatBytes(bps)}/s" +} + +private fun formatBytes(bytes: Long): String { val kb = 1024.0 val mb = kb * 1024.0 + val gb = mb * 1024.0 return when { - bps >= mb -> String.format("%.2f MB/s", bps / mb) - bps >= kb -> String.format("%.1f KB/s", bps / kb) - else -> "${bps} B/s" + bytes >= gb -> String.format("%.2f GB", bytes / gb) + bytes >= mb -> String.format("%.2f MB", bytes / mb) + bytes >= kb -> String.format("%.1f KB", bytes / kb) + else -> "$bytes B" } } diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/logs/LogsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/logs/LogsScreen.kt index e4db5bf..91e0338 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/logs/LogsScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/logs/LogsScreen.kt @@ -57,7 +57,11 @@ import java.util.Locale private enum class LogFilter(val labelRes: Int) { ALL(R.string.logs_filter_all), CORE(R.string.logs_filter_core), - ANDROID(R.string.logs_filter_android) + ANDROID(R.string.logs_filter_android), + ERRORS(R.string.logs_filter_errors), + WARNINGS(R.string.logs_filter_warnings), + DNS(R.string.logs_filter_dns), + MTU(R.string.logs_filter_mtu) } private enum class LogSeverity { @@ -104,6 +108,10 @@ fun LogsScreen(onBack: () -> Unit) { LogFilter.ALL -> logEntries LogFilter.CORE -> logEntries.filter { it.source == VpnManager.LogSource.CORE } LogFilter.ANDROID -> logEntries.filter { it.source == VpnManager.LogSource.ANDROID } + LogFilter.ERRORS -> logEntries.filter { inferSeverity(it.line) == LogSeverity.ERROR } + LogFilter.WARNINGS -> logEntries.filter { inferSeverity(it.line) == LogSeverity.WARN } + LogFilter.DNS -> logEntries.filter { it.line.contains("dns", ignoreCase = true) || it.line.contains("resolver", ignoreCase = true) } + LogFilter.MTU -> logEntries.filter { it.line.contains("mtu", ignoreCase = true) } } } val uiLogItems = remember(filteredLogs) { buildUiLogItems(filteredLogs) } diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt index bb76830..f34c6c9 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt @@ -1,15 +1,19 @@ package com.masterdns.vpn.ui.profiles import android.content.Context +import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -18,10 +22,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.gson.Gson @@ -31,12 +35,12 @@ import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.theme.ConnectedGreen import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ImportedResolverFile +import com.masterdns.vpn.util.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverStats +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch - -private data class ImportedProfileDraft( - val profile: ProfileEntity, - val domainList: List -) +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,42 +52,105 @@ fun ProfilesScreen( val profiles by viewModel.profiles.collectAsState() var showEditor by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } - var importedDraft by remember { mutableStateOf(null) } - var importedResolvers by remember { mutableStateOf(null) } + var importedResolvers by remember { mutableStateOf(null) } val context = androidx.compose.ui.platform.LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + + // Export dialog state + var exportTarget by remember { mutableStateOf(null) } + val importLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult val text = readTextFromUri(context, uri) - val draft = parseProfileTomlForImport( - fileName = readDisplayName(context, uri) ?: context.getString(R.string.profiles_imported_profile_default), - tomlContent = text - ) - if (draft == null) { + val name = readDisplayName(context, uri) + ?: context.getString(R.string.profiles_imported_profile_default) + if (text.isBlank()) { scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_invalid_toml_msg)) } return@rememberLauncherForActivityResult } - importedDraft = draft - editingProfile = null - showEditor = true - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_toml_imported_msg)) } + scope.launch { + val imported = viewModel.importProfileFromToml(text, name) + if (imported != null) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_toml_imported_msg)) + } else { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_invalid_toml_msg)) + } + } } + val importResolversLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - val text = readTextFromUri(context, uri).trim() - if (text.isBlank()) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) } - return@rememberLauncherForActivityResult + scope.launch { + val imported = withContext(Dispatchers.IO) { + ResolverAnalyzer.importUriToCache(context, uri) + } + if (imported == null) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) + return@launch + } + ResolverAnalyzer.discardImportedResolver(importedResolvers) + importedResolvers = imported + showEditor = true + snackbarHostState.showSnackbar( + context.getString(R.string.profiles_resolvers_imported_stats_msg, imported.stats.summary()) + ) } - importedResolvers = text - editingProfile = null - showEditor = true - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_imported_msg)) } + } + + var exportToSaveLocked by remember { mutableStateOf(null) } + val saveExportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/toml") + ) { uri -> + if (uri != null && exportTarget != null && exportToSaveLocked != null) { + scope.launch { + val config = viewModel.exportProfileToml(exportTarget!!.id, exportToSaveLocked!!) + if (config != null) { + runCatching { + context.grantUriPermission( + context.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + runCatching { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(config.toByteArray()) + outputStream.flush() + } + } + snackbarHostState.showSnackbar(context.getString(R.string.profiles_export_saved)) + } + } + } + exportTarget = null + exportToSaveLocked = null + } + + // Export dialog + if (exportTarget != null && exportToSaveLocked == null) { + ExportProfileDialog( + profileName = exportTarget!!.name, + onDismiss = { exportTarget = null }, + onShare = { locked -> + viewModel.exportProfile(context, exportTarget!!.id, exportTarget!!.name, lockIdentity = locked) + exportTarget = null + }, + onSaveToFile = { locked -> + exportToSaveLocked = locked + val safeName = exportTarget!!.name.replace(Regex("[^a-zA-Z0-9_-]"), "_") + val suffix = if (locked) "_locked" else "" + saveExportLauncher.launch("${safeName}_client_config$suffix.toml") + } + ) } Scaffold( @@ -94,9 +161,21 @@ fun ProfilesScreen( title = stringResource(R.string.title_profiles), onBack = onBack, actions = { + IconButton(onClick = { + importLauncher.launch( + arrayOf( + "application/toml", + "text/x-toml", + "text/plain", + "application/octet-stream", + "*/*" + ) + ) + }) { + Icon(Icons.Filled.UploadFile, contentDescription = stringResource(R.string.action_import_toml)) + } IconButton(onClick = { editingProfile = null - importedDraft = null importedResolvers = null showEditor = true }) { @@ -109,19 +188,7 @@ fun ProfilesScreen( if (showEditor) { ProfileEditorDialog( profile = editingProfile, - importedDraft = importedDraft, importedResolvers = importedResolvers, - onImportToml = { - importLauncher.launch( - arrayOf( - "application/toml", - "text/x-toml", - "text/plain", - "application/octet-stream", - "*/*" - ) - ) - }, onImportResolvers = { importResolversLauncher.launch( arrayOf( @@ -139,15 +206,16 @@ fun ProfilesScreen( } showEditor = false editingProfile = null - importedDraft = null importedResolvers = null }, onDismiss = { + ResolverAnalyzer.discardImportedResolver(importedResolvers) showEditor = false editingProfile = null - importedDraft = null importedResolvers = null - } + }, + viewModel = viewModel, + snackbarHostState = snackbarHostState ) } @@ -172,7 +240,6 @@ fun ProfilesScreen( Spacer(modifier = Modifier.height(8.dp)) FilledTonalButton(onClick = { editingProfile = null - importedDraft = null importedResolvers = null showEditor = true }) { @@ -193,9 +260,11 @@ fun ProfilesScreen( onSettings = { onOpenSettings(profile.id) }, onEdit = { editingProfile = profile + importedResolvers = null showEditor = true }, - onDelete = { viewModel.deleteProfile(profile) } + onDelete = { viewModel.deleteProfile(profile) }, + onExport = { exportTarget = profile } ) } } @@ -209,7 +278,8 @@ fun ProfileCard( onSelect: () -> Unit, onSettings: () -> Unit, onEdit: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onExport: () -> Unit ) { Card( onClick = onSelect, @@ -238,101 +308,140 @@ fun ProfileCard( } Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) - ) - val domainsList = try { - gson.fromJson>(profile.domains, object : com.google.gson.reflect.TypeToken>() {}.type) - } catch (e: Exception) { - profile.domains.removePrefix("[").removeSuffix("]").split(",").map { it.trim().removeSurrounding("\"") }.filter { it.isNotEmpty() } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = profile.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) } Text( - text = domainsList.joinToString(", "), + text = parseDomainList(profile.domains).joinToString(", "), style = MaterialTheme.typography.bodySmall, color = MdvColor.OnSurfaceVariant ) + ProfileHealthRow(profile) } - IconButton(onClick = onEdit, modifier = Modifier.size(48.dp)) { - Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.profiles_edit), modifier = Modifier.size(20.dp)) + IconButton(onClick = onExport, modifier = Modifier.size(40.dp)) { + Icon(Icons.Filled.Share, contentDescription = "Export", modifier = Modifier.size(18.dp)) } - IconButton(onClick = onSettings, modifier = Modifier.size(48.dp)) { - Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.profiles_settings), modifier = Modifier.size(20.dp)) + IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.profiles_edit), modifier = Modifier.size(18.dp)) } - IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.profiles_delete), modifier = Modifier.size(20.dp)) + IconButton(onClick = onSettings, modifier = Modifier.size(40.dp)) { + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.profiles_settings), modifier = Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.profiles_delete), modifier = Modifier.size(18.dp)) } } } } -@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileHealthRow(profile: ProfileEntity) { + val imported = ResolverAnalyzer.profileImportedResolver(profile) + val sourceText = if (imported != null) { + stringResource(R.string.profiles_source_file, imported.displayName) + } else { + stringResource(R.string.profiles_source_inline) + } + val resolverCount = imported?.stats?.uniqueUsableIps ?: ResolverAnalyzer.resolverCount(profile) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + AssistChip( + onClick = {}, + modifier = Modifier.weight(1f, fill = false), + label = { + Text( + text = sourceText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + leadingIcon = { + Icon( + if (imported != null) Icons.Filled.Description else Icons.Filled.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + ) + AssistChip( + onClick = {}, + label = { + Text( + text = stringResource(R.string.profiles_resolver_count_badge, resolverCount), + maxLines = 1 + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun ProfileEditorDialog( profile: ProfileEntity?, - importedDraft: ImportedProfileDraft?, - importedResolvers: String?, - onImportToml: () -> Unit, + importedResolvers: ImportedResolverFile?, onImportResolvers: () -> Unit, onSave: (ProfileEntity) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + viewModel: ProfilesViewModel, + snackbarHostState: SnackbarHostState ) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = rememberCoroutineScope() var name by remember { mutableStateOf(profile?.name.orEmpty()) } - val domainList = remember { - androidx.compose.runtime.mutableStateListOf().apply { - val domainsJson = profile?.domains - if (!domainsJson.isNullOrBlank()) { - val parsed = try { - gson.fromJson>(domainsJson, object : com.google.gson.reflect.TypeToken>() {}.type) - } catch (e: Exception) { - domainsJson.removePrefix("[").removeSuffix("]").split(",").map { it.trim().removeSurrounding("\"") }.filter { it.isNotEmpty() } - } - addAll(parsed) - } - } + // Multi-domain as list + var domainList by remember { + mutableStateOf( + if (profile != null) parseDomainList(profile.domains).toMutableList() + else mutableListOf("") + ) } - var newDomainInput by remember { mutableStateOf("") } - var isDomainInputFocused by remember { mutableStateOf(false) } + var domainInput by remember { mutableStateOf("") } var encryptionKey by remember { mutableStateOf(profile?.encryptionKey.orEmpty()) } var resolvers by remember { mutableStateOf(profile?.resolvers ?: "8.8.8.8") } + var resolverFile by remember(profile?.id) { mutableStateOf(profile?.let { ResolverAnalyzer.profileImportedResolver(it) }) } + var resolverStats by remember(profile?.id) { + mutableStateOf(resolverFile?.stats ?: ResolverAnalyzer.analyzeText(resolvers)) + } var showKey by remember { mutableStateOf(false) } var showResolversEditor by remember { mutableStateOf(false) } - val largeResolversText = resolvers.length > 6000 + val usingResolverFile = resolverFile != null + val largeResolversText = !usingResolverFile && resolvers.length > 6000 LaunchedEffect(profile?.id) { if (profile != null) { name = profile.name - domainList.clear() - val parsed = try { - gson.fromJson>(profile.domains, object : com.google.gson.reflect.TypeToken>() {}.type) - } catch (e: Exception) { - profile.domains.removePrefix("[").removeSuffix("]").split(",").map { it.trim().removeSurrounding("\"") }.filter { it.isNotEmpty() } - } - domainList.addAll(parsed) + domainList = parseDomainList(profile.domains).toMutableList() encryptionKey = profile.encryptionKey resolvers = profile.resolvers + resolverFile = ResolverAnalyzer.profileImportedResolver(profile) + resolverStats = resolverFile?.stats ?: ResolverAnalyzer.analyzeText(profile.resolvers) showResolversEditor = false } } - LaunchedEffect(importedDraft, importedResolvers) { - val importedProfile = importedDraft?.profile - if (importedProfile != null) { - if (name.isBlank()) { - // Keep user-entered profile name if they typed it before import. - name = importedProfile.name - } - domainList.clear() - domainList.addAll(importedDraft.domainList) - encryptionKey = importedProfile.encryptionKey - resolvers = importedProfile.resolvers - } - if (!importedResolvers.isNullOrBlank()) { - resolvers = importedResolvers + LaunchedEffect(importedResolvers) { + if (importedResolvers != null) { + resolverFile = importedResolvers + resolverStats = importedResolvers.stats + resolvers = "" } } + LaunchedEffect(resolvers, resolverFile?.cachedPath) { + resolverStats = resolverFile + ?.let { ResolverAnalyzer.analyzeCachedFile(it.cachedPath, it.displayName) ?: it.stats } + ?: ResolverAnalyzer.analyzeText(resolvers) + } + AlertDialog( onDismissRequest = onDismiss, title = { @@ -346,7 +455,9 @@ private fun ProfileEditorDialog( }, text = { Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { OutlinedTextField( @@ -356,108 +467,71 @@ private fun ProfileEditorDialog( singleLine = true, modifier = Modifier.fillMaxWidth() ) - if (profile == null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = onImportToml, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(vertical = 12.dp, horizontal = 4.dp) - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Filled.UploadFile, contentDescription = null) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(R.string.action_import_toml), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - style = MaterialTheme.typography.labelMedium - ) - } - } - OutlinedButton( - onClick = onImportResolvers, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(vertical = 12.dp, horizontal = 4.dp) - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Filled.Description, contentDescription = null) - Spacer(modifier = Modifier.height(4.dp)) - Text( - stringResource(R.string.profiles_import_resolvers_short), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - style = MaterialTheme.typography.labelMedium - ) - } + + // ── Multi-Domain editor ────────────────────────────────── + Text( + stringResource(R.string.profiles_domain_hint), + style = MaterialTheme.typography.labelMedium, + color = MdvColor.OnSurfaceVariant + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + domainList.forEachIndexed { index, domain -> + if (domain.isNotBlank()) { + InputChip( + selected = false, + onClick = { + val newList = domainList.toMutableList() + newList.removeAt(index) + domainList = newList + }, + label = { Text(domain, style = MaterialTheme.typography.bodySmall) }, + trailingIcon = { + Icon( + Icons.Filled.Close, + contentDescription = "Remove", + modifier = Modifier.size(14.dp) + ) + } + ) } } } - Column(modifier = Modifier.fillMaxWidth()) { - if (domainList.isNotEmpty()) { - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) - androidx.compose.foundation.layout.FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - domainList.forEach { domain -> - InputChip( - selected = false, - onClick = { }, - label = { Text(domain) }, - trailingIcon = { - IconButton( - onClick = { domainList.remove(domain) }, - modifier = Modifier.size(16.dp) - ) { - Icon(Icons.Filled.Close, contentDescription = "Remove") - } - } - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = domainInput, + onValueChange = { domainInput = it }, + label = { Text("Add domain") }, + singleLine = true, + modifier = Modifier.weight(1f), + placeholder = { Text("v.example.com") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) + ) + IconButton( + onClick = { + val d = domainInput.trim() + if (d.isNotEmpty() && !domainList.contains(d)) { + val newList = domainList.toMutableList() + newList.add(d) + domainList = newList } + domainInput = "" } - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - value = newDomainInput, - onValueChange = { newDomainInput = it }, - label = { Text(stringResource(R.string.profiles_domain_hint)) }, - placeholder = { - if (isDomainInputFocused) { - Text("(e.g. v.example.com)") - } - }, - singleLine = true, - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isDomainInputFocused = focusState.isFocused - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - Spacer(modifier = Modifier.width(12.dp)) - FilledIconButton( - onClick = { - val d = newDomainInput.trim() - if (d.isNotEmpty() && !domainList.contains(d)) { - domainList.add(d) - newDomainInput = "" - } - }, - modifier = Modifier.size(48.dp), - colors = IconButtonDefaults.filledIconButtonColors(containerColor = ConnectedGreen) - ) { - Icon(Icons.Filled.Add, contentDescription = "Add Domain", tint = MdvColor.Background) - } + Icon(Icons.Filled.AddCircle, contentDescription = "Add domain", tint = MdvColor.PrimaryContainer) } } + // ── Encryption Key ─────────────────────────────────────── OutlinedTextField( value = encryptionKey, onValueChange = { encryptionKey = it }, @@ -479,20 +553,81 @@ private fun ProfileEditorDialog( } ) - // Import Resolvers button for Edit mode (placed above the resolvers field) - if (profile != null) { + // ── Import Actions ─────────────────────────────────────────── + val importTomlLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val text = readTextFromUri(context, uri).trim() + if (text.isBlank()) { + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_invalid_toml_msg)) } + return@rememberLauncherForActivityResult + } + scope.launch { + val parsed = viewModel.previewProfileFromToml(text, name.ifEmpty { "Imported" }) + if (parsed != null) { + name = parsed.name + domainList = parseDomainList(parsed.domains).toMutableList() + encryptionKey = parsed.encryptionKey + resolvers = parsed.resolvers + snackbarHostState.showSnackbar(context.getString(R.string.profiles_toml_imported_msg)) + } else { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_invalid_toml_msg)) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + importTomlLauncher.launch(arrayOf("application/toml", "text/x-toml", "text/plain", "application/octet-stream", "*/*")) + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.action_import_toml)) + } + OutlinedButton( onClick = onImportResolvers, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 8.dp) + modifier = Modifier.weight(1f) ) { - Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) + Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text(stringResource(R.string.profiles_import_resolvers_short)) } } - if (!showResolversEditor && largeResolversText) { + if (usingResolverFile) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + if (resolverFile?.cachedPath == importedResolvers?.cachedPath) { + ResolverAnalyzer.discardImportedResolver(importedResolvers) + } + resolverFile = null + resolvers = "8.8.8.8" + showResolversEditor = true + }, + modifier = Modifier.weight(1f) + ) { + Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.profiles_use_inline_resolvers)) + } + } + ResolverStatsCard( + title = stringResource(R.string.profiles_imported_resolver_file, resolverFile?.displayName.orEmpty()), + stats = resolverStats + ) + } else if (!showResolversEditor && largeResolversText) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) @@ -514,6 +649,7 @@ private fun ProfileEditorDialog( value = resolvers, onValueChange = { resolvers = it }, label = { Text(stringResource(R.string.profiles_resolvers_label)) }, + supportingText = { Text(stringResource(R.string.profiles_resolvers_stats_line, resolverStats.summary())) }, modifier = Modifier.fillMaxWidth().height(120.dp), maxLines = 6, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) @@ -524,24 +660,26 @@ private fun ProfileEditorDialog( confirmButton = { FilledTonalButton( onClick = { - val d = newDomainInput.trim() - val finalDomainList = if (d.isNotEmpty() && !domainList.contains(d)) { - domainList + d - } else { - domainList.toList() + val baseProfile = profile ?: ProfileEntity(name = "", domains = "") + val effectiveDomains = domainList.filter { it.isNotBlank() }.toMutableList() + val dInput = domainInput.trim() + if (dInput.isNotEmpty() && !effectiveDomains.contains(dInput)) { + effectiveDomains.add(dInput) } - val baseProfile = profile ?: importedDraft?.profile ?: ProfileEntity(name = "", domains = "") - val domainJson = gson.toJson(finalDomainList) + if (effectiveDomains.isEmpty()) effectiveDomains.add("v.domain.com") // fallback + val domainJson = gson.toJson(effectiveDomains) + val preparedProfile = baseProfile.copy( + name = name.trim().ifEmpty { "Profile" }, + domains = domainJson, + encryptionKey = encryptionKey + ) onSave( - baseProfile.copy( - name = name.trim().ifEmpty { "Profile" }, - domains = domainJson, - encryptionKey = encryptionKey, - resolvers = resolvers.trim() - ) + resolverFile + ?.let { ResolverAnalyzer.withImportedResolver(preparedProfile, it) } + ?: ResolverAnalyzer.withInlineResolvers(preparedProfile, resolvers) ) }, - enabled = name.isNotBlank() && (domainList.isNotEmpty() || newDomainInput.isNotBlank()) + enabled = name.isNotBlank() && (domainList.any { it.isNotBlank() } || domainInput.isNotBlank()) ) { Text(stringResource(R.string.action_save)) } @@ -554,8 +692,114 @@ private fun ProfileEditorDialog( ) } +@Composable +private fun ResolverStatsCard(title: String, stats: ResolverStats?) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(title, fontWeight = FontWeight.SemiBold) + if (stats == null) { + Text( + stringResource(R.string.profiles_resolver_stats_unavailable), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } else { + Text( + stats.summary(), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + Text( + stringResource( + R.string.profiles_resolver_stats_detail, + stats.rawLines, + stats.blankLines, + stats.commentLines, + stats.customPorts, + stats.skippedCidrs + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ExportProfileDialog( + profileName: String, + onDismiss: () -> Unit, + onShare: (Boolean) -> Unit, + onSaveToFile: (Boolean) -> Unit +) { + var lockIdentity by remember { mutableStateOf(true) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.profiles_export_title, profileName)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = lockIdentity, + onCheckedChange = { lockIdentity = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = stringResource(R.string.profiles_export_lock_identity), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.profiles_export_lock_identity_desc), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } + }, + confirmButton = { + FilledTonalButton(onClick = { onShare(lockIdentity) }) { + Text(stringResource(R.string.profiles_export_share)) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onSaveToFile(lockIdentity) }) { + Text(stringResource(R.string.profiles_export_save_file)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + } + ) +} + private val gson = Gson() +private fun parseDomainList(json: String): List { + return try { + val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, String::class.java).type + val list = gson.fromJson>(json, type) + list?.filter { it.isNotBlank() } ?: listOf(json.trim()) + } catch (_: Exception) { + listOf(json.trim().removeSurrounding("\"")) + } +} + private fun readTextFromUri(context: Context, uri: Uri): String { return context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }.orEmpty() } @@ -567,139 +811,3 @@ private fun readDisplayName(context: Context, uri: Uri): String? { cursor.getString(nameIndex) }?.substringBeforeLast(".")?.trim()?.takeIf { it.isNotEmpty() } } - -private fun parseProfileTomlForImport(fileName: String, tomlContent: String): ImportedProfileDraft? { - val values = mutableMapOf() - var parsedDomainsList = emptyList() - tomlContent.lineSequence().forEach { raw -> - val line = raw.substringBefore("#").trim() - if (line.isEmpty() || "=" !in line) return@forEach - val key = line.substringBefore("=").trim() - val valueRaw = line.substringAfter("=").trim() - if (key == "DOMAINS") { - parsedDomainsList = valueRaw - .removePrefix("[") - .removeSuffix("]") - .split(",") - .map { it.trim().removeSurrounding("\"") } - .filter { it.isNotBlank() } - return@forEach - } - val parsed = when { - valueRaw.startsWith("\"") && valueRaw.endsWith("\"") -> - valueRaw.removeSurrounding("\"") - else -> valueRaw - } - values[key] = parsed - } - - val parsedDomain = if (parsedDomainsList.isNotEmpty()) parsedDomainsList else return null - val parsedKey = values["ENCRYPTION_KEY"]?.takeIf { it.isNotBlank() } ?: return null - - val advanced = mutableMapOf() - IMPORT_ADVANCED_KEYS.forEach { key -> - values[key]?.let { advanced[key] = it.trim() } - } - - val importedProfile = ProfileEntity( - name = fileName, - domains = gson.toJson(parsedDomain), - encryptionMethod = values["DATA_ENCRYPTION_METHOD"]?.toIntOrNull() ?: 1, - encryptionKey = parsedKey, - protocolType = normalizeProtocol(values["PROTOCOL_TYPE"]), - listenPort = values["LISTEN_PORT"]?.toIntOrNull()?.coerceIn(1, 65535) ?: 18000, - resolverBalancingStrategy = values["RESOLVER_BALANCING_STRATEGY"]?.toIntOrNull() ?: 2, - packetDuplicationCount = values["PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2, - setupPacketDuplicationCount = values["SETUP_PACKET_DUPLICATION_COUNT"]?.toIntOrNull() ?: 2, - 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", - advancedJson = gson.toJson(advanced) - ) - - return ImportedProfileDraft( - profile = importedProfile, - domainList = parsedDomain - ) -} - -private fun normalizeProtocol(value: String?): String { - return when (value?.trim()?.uppercase()) { - "TCP" -> "TCP" - else -> "SOCKS5" - } -} - -private val IMPORT_ADVANCED_KEYS = setOf( - "LISTEN_IP", - "SOCKS5_AUTH", - "SOCKS5_USER", - "SOCKS5_PASS", - "LOCAL_DNS_ENABLED", - "LOCAL_DNS_IP", - "LOCAL_DNS_PORT", - "LOCAL_DNS_CACHE_MAX_RECORDS", - "LOCAL_DNS_CACHE_TTL_SECONDS", - "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", - "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", - "LOCAL_DNS_CACHE_PERSIST_TO_FILE", - "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", - "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", - "STREAM_RESOLVER_FAILOVER_COOLDOWN", - "RECHECK_INACTIVE_SERVERS_ENABLED", - "AUTO_DISABLE_TIMEOUT_SERVERS", - "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", - "BASE_ENCODE_DATA", - "COMPRESSION_MIN_SIZE", - "MIN_UPLOAD_MTU", - "MIN_DOWNLOAD_MTU", - "MAX_UPLOAD_MTU", - "MAX_DOWNLOAD_MTU", - "MTU_TEST_RETRIES", - "MTU_TEST_TIMEOUT", - "MTU_TEST_PARALLELISM", - "SAVE_MTU_SERVERS_TO_FILE", - "MTU_SERVERS_FILE_NAME", - "MTU_SERVERS_FILE_FORMAT", - "MTU_USING_SECTION_SEPARATOR_TEXT", - "MTU_REMOVED_SERVER_LOG_FORMAT", - "MTU_ADDED_SERVER_LOG_FORMAT", - "RX_TX_WORKERS", - "TUNNEL_PROCESS_WORKERS", - "TUNNEL_PACKET_TIMEOUT_SECONDS", - "RX_CHANNEL_SIZE", - "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", - "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", - "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", - "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", - "SESSION_INIT_RETRY_BASE_SECONDS", - "SESSION_INIT_RETRY_STEP_SECONDS", - "SESSION_INIT_RETRY_LINEAR_AFTER", - "SESSION_INIT_RETRY_MAX_SECONDS", - "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", - "SESSION_INIT_RACING_COUNT", - "PING_AGGRESSIVE_INTERVAL_SECONDS", - "PING_LAZY_INTERVAL_SECONDS", - "PING_COOLDOWN_INTERVAL_SECONDS", - "PING_COLD_INTERVAL_SECONDS", - "PING_WARM_THRESHOLD_SECONDS", - "PING_COOL_THRESHOLD_SECONDS", - "PING_COLD_THRESHOLD_SECONDS", - "MAX_PACKETS_PER_BATCH", - "ARQ_WINDOW_SIZE", - "ARQ_INITIAL_RTO_SECONDS", - "ARQ_MAX_RTO_SECONDS", - "ARQ_CONTROL_INITIAL_RTO_SECONDS", - "ARQ_CONTROL_MAX_RTO_SECONDS", - "ARQ_MAX_CONTROL_RETRIES", - "ARQ_MAX_DATA_RETRIES", - "ARQ_DATA_PACKET_TTL_SECONDS", - "ARQ_CONTROL_PACKET_TTL_SECONDS", - "ARQ_DATA_NACK_MAX_GAP", - "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", - "ARQ_DATA_NACK_REPEAT_SECONDS", - "ARQ_INACTIVITY_TIMEOUT_SECONDS", - "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", - "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS" -) diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesViewModel.kt b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesViewModel.kt index 3e50d39..a1916d2 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesViewModel.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesViewModel.kt @@ -1,9 +1,12 @@ package com.masterdns.vpn.ui.profiles +import android.content.Context +import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.masterdns.vpn.data.local.ProfileEntity import com.masterdns.vpn.data.repository.ProfileRepository +import com.masterdns.vpn.util.ResolverAnalyzer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -38,6 +41,7 @@ class ProfilesViewModel @Inject constructor( fun deleteProfile(profile: ProfileEntity) { viewModelScope.launch { + ResolverAnalyzer.discardImportedResolver(ResolverAnalyzer.profileImportedResolver(profile)) profileRepository.deleteProfile(profile) } } @@ -47,4 +51,28 @@ class ProfilesViewModel @Inject constructor( profileRepository.setSelectedProfile(id) } } + + suspend fun importProfileFromToml(tomlContent: String, name: String): ProfileEntity? { + return profileRepository.importProfileFromToml(tomlContent, name) + } + + fun previewProfileFromToml(tomlContent: String, name: String): ProfileEntity? { + return profileRepository.previewProfileFromToml(tomlContent, name) + } + + suspend fun exportProfileToml(profileId: Long, lockIdentity: Boolean): String? { + return profileRepository.exportProfileToml(profileId, lockIdentity) + } + + fun exportProfile(context: Context, profileId: Long, profileName: String, lockIdentity: Boolean) { + viewModelScope.launch { + val toml = profileRepository.exportProfileToml(profileId, lockIdentity) ?: return@launch + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, toml) + putExtra(Intent.EXTRA_SUBJECT, "$profileName.toml") + } + context.startActivity(Intent.createChooser(intent, "Export Profile")) + } + } } diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt index ecde924..3e01089 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt @@ -72,7 +72,10 @@ import com.masterdns.vpn.ui.components.mdv.controls.MdvBackTopAppBar import com.masterdns.vpn.ui.components.mdv.controls.MdvTopAppBar import com.masterdns.vpn.ui.theme.MdvColor import com.masterdns.vpn.ui.theme.MdvSpace +import com.masterdns.vpn.util.ResolverAnalyzer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private enum class FieldType { TEXT, BOOL, OPTION } @@ -236,6 +239,7 @@ fun SettingsScreen( val scope = rememberCoroutineScope() val sections = remember { configFields.groupBy { it.section } } val sectionOrder = remember { configFields.map { it.section }.distinct() } + var settingsQuery by remember { mutableStateOf("") } val sectionExpanded = remember { mutableStateMapOf().apply { sectionOrder.forEach { put(it, it == "Identity") } @@ -280,9 +284,19 @@ fun SettingsScreen( ) { uri -> val selected = profile if (uri != null && selected != null) { - val text = readTextFromUri(context, uri) - viewModel.importResolvers(selected, text) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_imported_msg)) } + scope.launch { + val imported = withContext(Dispatchers.IO) { + ResolverAnalyzer.importUriToCache(context, uri) + } + if (imported == null) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) + return@launch + } + viewModel.importResolvers(selected, imported) + snackbarHostState.showSnackbar( + context.getString(R.string.settings_resolvers_imported_stats_msg, imported.stats.summary()) + ) + } } } val pickMtuExportLauncher = rememberLauncherForActivityResult( @@ -378,6 +392,19 @@ fun SettingsScreen( } return@Box } + val normalizedQuery = settingsQuery.trim() + val visibleSectionOrder = if (normalizedQuery.isBlank()) { + sectionOrder + } else { + sectionOrder.filter { section -> + section.contains(normalizedQuery, ignoreCase = true) || + sections[section].orEmpty().any { field -> + field.key.contains(normalizedQuery, ignoreCase = true) || + field.label.contains(normalizedQuery, ignoreCase = true) || + field.helper.contains(normalizedQuery, ignoreCase = true) + } + } + } LazyColumn( modifier = Modifier @@ -394,6 +421,14 @@ fun SettingsScreen( color = MdvColor.OnSurface ) Spacer(modifier = Modifier.height(MdvSpace.S1)) + OutlinedTextField( + value = settingsQuery, + onValueChange = { settingsQuery = it }, + label = { Text(stringResource(R.string.settings_search_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(MdvSpace.S1)) Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { MdvPrimaryActionButton( text = stringResource(R.string.action_import_toml), @@ -430,9 +465,28 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(MdvSpace.S1)) MdvPrimaryActionButton( text = stringResource(R.string.action_import_resolvers), - onClick = { importResolversLauncher.launch(arrayOf("text/*")) }, + onClick = { + importResolversLauncher.launch( + arrayOf("text/plain", "application/octet-stream", "*/*") + ) + }, icon = Icons.Filled.UploadFile ) + ResolverAnalyzer.profileImportedResolver(selected)?.let { imported -> + Spacer(modifier = Modifier.height(MdvSpace.S1)) + ResolverStatsCard( + title = stringResource(R.string.profiles_imported_resolver_file, imported.displayName), + summary = imported.stats.summary(), + detail = stringResource( + R.string.profiles_resolver_stats_detail, + imported.stats.rawLines, + imported.stats.blankLines, + imported.stats.commentLines, + imported.stats.customPorts, + imported.stats.skippedCidrs + ) + ) + } Spacer(modifier = Modifier.height(MdvSpace.S1)) MdvPrimaryActionButton( text = stringResource(R.string.action_pick_mtu_destination), @@ -445,8 +499,8 @@ fun SettingsScreen( } val socksAuthEnabled = fieldsState["SOCKS5_AUTH"].equals("true", ignoreCase = true) - items(sectionOrder, key = { "section_$it" }) { section -> - val expanded = sectionExpanded[section] ?: false + items(visibleSectionOrder, key = { "section_$it" }) { section -> + val expanded = if (normalizedQuery.isBlank()) sectionExpanded[section] ?: false else true MdvSectionCard( title = section, expanded = expanded, @@ -479,7 +533,15 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(MdvSpace.S2)) } } - sections[section].orEmpty().forEach { field -> + sections[section].orEmpty() + .filter { field -> + normalizedQuery.isBlank() || + section.contains(normalizedQuery, ignoreCase = true) || + field.key.contains(normalizedQuery, ignoreCase = true) || + field.label.contains(normalizedQuery, ignoreCase = true) || + field.helper.contains(normalizedQuery, ignoreCase = true) + } + .forEach { field -> if ((field.key == "SOCKS5_USER" || field.key == "SOCKS5_PASS") && !socksAuthEnabled) { return@forEach } @@ -510,6 +572,39 @@ fun SettingsScreen( } } +@Composable +private fun ResolverStatsCard( + title: String, + summary: String, + detail: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MdvColor.OnSurface + ) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfigFieldCard( diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt index 2f11b3b..ecef90d 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsViewModel.kt @@ -8,6 +8,8 @@ import com.google.gson.reflect.TypeToken import com.masterdns.vpn.data.local.ProfileEntity import com.masterdns.vpn.data.repository.ProfileRepository import com.masterdns.vpn.util.ConfigGenerator +import com.masterdns.vpn.util.ImportedResolverFile +import com.masterdns.vpn.util.ResolverAnalyzer import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -73,9 +75,9 @@ class SettingsViewModel @Inject constructor( return result } - fun importResolvers(profile: ProfileEntity, resolversText: String) { + fun importResolvers(profile: ProfileEntity, imported: ImportedResolverFile) { viewModelScope.launch { - profileRepository.updateProfile(profile.copy(resolvers = resolversText.trim())) + profileRepository.updateProfile(ResolverAnalyzer.withImportedResolver(profile, imported)) } } diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt index bfa1ed2..7718f5f 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/ConfigGenerator.kt @@ -1,5 +1,6 @@ package com.masterdns.vpn.util +import com.masterdns.vpn.data.local.IdentityCipher import com.masterdns.vpn.data.local.ProfileEntity import com.google.gson.Gson import com.google.gson.reflect.TypeToken @@ -12,6 +13,63 @@ object ConfigGenerator { private val gson = Gson() + /** + * Generate a shareable TOML string from a ProfileEntity. + * If [lockIdentity] is true, domains and encryptionKey are encrypted. + */ + fun exportToml(profile: ProfileEntity, lockIdentity: Boolean = false): String { + val domains = parseDomains(profile.domains) + val domainsToml: String + val keyToml: String + + if (lockIdentity) { + domainsToml = "\"${IdentityCipher.encrypt(domains.joinToString(","))}\"" + keyToml = IdentityCipher.encrypt(profile.encryptionKey) + } else { + domainsToml = domains.joinToString(", ") { "\"${escapeToml(it)}\"" } + keyToml = escapeToml(profile.encryptionKey) + } + + val advanced = parseAdvanced(profile.advancedJson) + fun cfg(key: String): String = advanced[key]?.trim().orEmpty() + + return buildString { + appendLine("# MasterDnsVPN Profile Export") + appendLine("# Exported by MasterDnsVPN Android") + if (lockIdentity) { + appendLine("# IDENTITY_LOCKED = true (domains and key are encrypted)") + } + appendLine() + + if (lockIdentity) { + appendLine("DOMAINS = $domainsToml") + } else { + appendLine("DOMAINS = [$domainsToml]") + } + appendLine("DATA_ENCRYPTION_METHOD = ${profile.encryptionMethod}") + appendLine("ENCRYPTION_KEY = \"$keyToml\"") + if (lockIdentity) appendLine("IDENTITY_LOCKED = true") + appendLine() + + appendLine("PROTOCOL_TYPE = \"${escapeToml(profile.protocolType)}\"") + appendLine("LISTEN_PORT = ${profile.listenPort}") + appendLine("RESOLVER_BALANCING_STRATEGY = ${profile.resolverBalancingStrategy}") + appendLine("PACKET_DUPLICATION_COUNT = ${profile.packetDuplicationCount}") + appendLine("SETUP_PACKET_DUPLICATION_COUNT = ${profile.setupPacketDuplicationCount}") + appendLine("UPLOAD_COMPRESSION_TYPE = ${profile.uploadCompression}") + appendLine("DOWNLOAD_COMPRESSION_TYPE = ${profile.downloadCompression}") + appendLine("LOG_LEVEL = \"${escapeToml(profile.logLevel)}\"") + appendLine() + + EXPORT_ADVANCED_KEYS.forEach { key -> + val value = cfg(key) + if (value.isNotEmpty()) { + appendLine("$key = \"${escapeToml(value)}\"") + } + } + } + } + /** * Generate client_config.toml content from a ProfileEntity. */ @@ -24,8 +82,15 @@ object ConfigGenerator { localDnsIpOverride: String? = null, localDnsPortOverride: Int? = null ): String { - val domains = parseDomains(profile.domains) + val domains = parseDomains(profile.domains).map { domain -> + if (IdentityCipher.isEncrypted(domain)) IdentityCipher.decrypt(domain) ?: domain else domain + } val domainsStr = domains.joinToString(", ") { "\"$it\"" } + val encryptionKey = if (IdentityCipher.isEncrypted(profile.encryptionKey)) { + IdentityCipher.decrypt(profile.encryptionKey) ?: profile.encryptionKey + } else { + profile.encryptionKey + } val advanced = parseAdvanced(profile.advancedJson) fun cfg(key: String, fallback: String): String { @@ -45,7 +110,7 @@ object ConfigGenerator { // Section 1: Tunnel Identity & Security appendLine("DOMAINS = [$domainsStr]") appendLine("DATA_ENCRYPTION_METHOD = ${profile.encryptionMethod}") - appendLine("ENCRYPTION_KEY = \"${escapeToml(profile.encryptionKey)}\"") + appendLine("ENCRYPTION_KEY = \"${escapeToml(encryptionKey)}\"") appendLine() // Section 2: Local Proxy Listener @@ -193,4 +258,41 @@ object ConfigGenerator { private fun escapeToml(s: String): String { return s.replace("\\", "\\\\").replace("\"", "\\\"") } + + private val EXPORT_ADVANCED_KEYS = listOf( + "LISTEN_IP", "SOCKS5_AUTH", "SOCKS5_USER", "SOCKS5_PASS", + "LOCAL_DNS_ENABLED", "LOCAL_DNS_IP", "LOCAL_DNS_PORT", + "LOCAL_DNS_CACHE_MAX_RECORDS", "LOCAL_DNS_CACHE_TTL_SECONDS", + "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", + "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", + "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "STREAM_RESOLVER_FAILOVER_COOLDOWN", + "RECHECK_INACTIVE_SERVERS_ENABLED", "AUTO_DISABLE_TIMEOUT_SERVERS", + "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "BASE_ENCODE_DATA", "COMPRESSION_MIN_SIZE", + "MIN_UPLOAD_MTU", "MIN_DOWNLOAD_MTU", "MAX_UPLOAD_MTU", "MAX_DOWNLOAD_MTU", + "MTU_TEST_RETRIES", "MTU_TEST_TIMEOUT", "MTU_TEST_PARALLELISM", + "SAVE_MTU_SERVERS_TO_FILE", "MTU_SERVERS_FILE_NAME", "MTU_SERVERS_FILE_FORMAT", + "MTU_USING_SECTION_SEPARATOR_TEXT", "MTU_REMOVED_SERVER_LOG_FORMAT", + "MTU_ADDED_SERVER_LOG_FORMAT", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", + "MTU_EXPORT_URI", + "RX_TX_WORKERS", "TUNNEL_PROCESS_WORKERS", "TUNNEL_PACKET_TIMEOUT_SECONDS", + "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "RX_CHANNEL_SIZE", + "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", + "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", + "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", + "SESSION_INIT_RETRY_BASE_SECONDS", "SESSION_INIT_RETRY_STEP_SECONDS", + "SESSION_INIT_RETRY_LINEAR_AFTER", "SESSION_INIT_RETRY_MAX_SECONDS", + "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "SESSION_INIT_RACING_COUNT", + "PING_AGGRESSIVE_INTERVAL_SECONDS", "PING_LAZY_INTERVAL_SECONDS", + "PING_COOLDOWN_INTERVAL_SECONDS", "PING_COLD_INTERVAL_SECONDS", + "PING_WARM_THRESHOLD_SECONDS", "PING_COOL_THRESHOLD_SECONDS", + "PING_COLD_THRESHOLD_SECONDS", + "MAX_PACKETS_PER_BATCH", "ARQ_WINDOW_SIZE", "ARQ_INITIAL_RTO_SECONDS", + "ARQ_MAX_RTO_SECONDS", "ARQ_CONTROL_INITIAL_RTO_SECONDS", + "ARQ_CONTROL_MAX_RTO_SECONDS", "ARQ_MAX_CONTROL_RETRIES", + "ARQ_MAX_DATA_RETRIES", "ARQ_DATA_PACKET_TTL_SECONDS", + "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ_DATA_NACK_MAX_GAP", + "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "ARQ_DATA_NACK_REPEAT_SECONDS", + "ARQ_INACTIVITY_TIMEOUT_SECONDS", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", + "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS" + ) } diff --git a/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt new file mode 100644 index 0000000..3eda650 --- /dev/null +++ b/android/app/src/main/java/com/masterdns/vpn/util/ResolverAnalyzer.kt @@ -0,0 +1,377 @@ +package com.masterdns.vpn.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.masterdns.vpn.data.local.ProfileEntity +import java.io.File +import java.math.BigInteger +import java.net.InetAddress + +data class ResolverStats( + val fileName: String = "", + val rawLines: Int = 0, + val blankLines: Int = 0, + val commentLines: Int = 0, + val validIps: Int = 0, + val duplicateIps: Int = 0, + val invalidLines: Int = 0, + val cidrRanges: Int = 0, + val cidrExpandedIps: Int = 0, + val skippedCidrs: Int = 0, + val customPorts: Int = 0, + val defaultPorts: Int = 0, + val uniqueUsableIps: Int = 0 +) { + fun summary(): String { + return "IPs: $uniqueUsableIps, duplicates: $duplicateIps, invalid: $invalidLines, CIDR: $cidrRanges" + } +} + +data class ImportedResolverFile( + val displayName: String, + val cachedPath: String, + val stats: ResolverStats +) + +object ResolverAnalyzer { + const val SOURCE_KEY = "ANDROID_RESOLVER_SOURCE" + const val FILE_NAME_KEY = "ANDROID_RESOLVER_FILE_NAME" + const val CACHED_PATH_KEY = "ANDROID_RESOLVER_CACHED_PATH" + const val STATS_JSON_KEY = "ANDROID_RESOLVER_STATS_JSON" + const val IMPORTED_AT_KEY = "ANDROID_RESOLVER_IMPORTED_AT" + const val SOURCE_INLINE = "INLINE" + const val SOURCE_FILE = "FILE" + + private const val DEFAULT_PORT = 53 + private const val MAX_CIDR_HOSTS = 65536 + private val gson = Gson() + + fun analyzeText(text: String, fileName: String = ""): ResolverStats { + val seen = linkedSetOf() + var rawLines = 0 + var blankLines = 0 + var commentLines = 0 + var validIps = 0 + var duplicateIps = 0 + var invalidLines = 0 + var cidrRanges = 0 + var cidrExpandedIps = 0 + var skippedCidrs = 0 + var customPorts = 0 + var defaultPorts = 0 + + text.lineSequence().forEach { raw -> + rawLines++ + val trimmedRaw = raw.trim() + val line = raw.substringBefore("#").trim() + when { + trimmedRaw.isEmpty() -> { + blankLines++ + return@forEach + } + line.isEmpty() -> { + commentLines++ + return@forEach + } + } + + val entry = parseEntry(line) + if (entry == null) { + invalidLines++ + return@forEach + } + + if (entry.host.contains("/")) { + cidrRanges++ + val hosts = expandCidr(entry.host) + if (hosts == null) { + invalidLines++ + return@forEach + } + if (hosts.isEmpty()) { + skippedCidrs++ + return@forEach + } + hosts.forEach { ip -> + cidrExpandedIps++ + if (!seen.add(ip)) duplicateIps++ else validIps++ + } + } else { + val ip = parseIp(entry.host) + if (ip == null) { + invalidLines++ + return@forEach + } + if (!seen.add(ip)) duplicateIps++ else validIps++ + } + + if (entry.port == DEFAULT_PORT && !entry.hasExplicitPort) { + defaultPorts++ + } else { + customPorts++ + } + } + + return ResolverStats( + fileName = fileName, + rawLines = rawLines, + blankLines = blankLines, + commentLines = commentLines, + validIps = validIps, + duplicateIps = duplicateIps, + invalidLines = invalidLines, + cidrRanges = cidrRanges, + cidrExpandedIps = cidrExpandedIps, + skippedCidrs = skippedCidrs, + customPorts = customPorts, + defaultPorts = defaultPorts, + uniqueUsableIps = seen.size + ) + } + + fun importUriToCache(context: Context, uri: Uri): ImportedResolverFile? { + val name = readDisplayName(context, uri) ?: "client_resolvers.txt" + val text = context.contentResolver.openInputStream(uri) + ?.bufferedReader() + ?.use { it.readText() } + .orEmpty() + if (text.isBlank()) return null + + val stats = analyzeText(text, name) + if (stats.uniqueUsableIps <= 0) return null + val runtimeText = normalizeTextForRuntime(text).takeIf { it.isNotBlank() } ?: return null + + val resolverDir = File(context.filesDir, "resolver_sources").apply { mkdirs() } + val safeName = name.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "client_resolvers.txt" } + val outFile = File(resolverDir, "${System.currentTimeMillis()}_$safeName") + outFile.writeText(runtimeText) + + return ImportedResolverFile( + displayName = name, + cachedPath = outFile.absolutePath, + stats = stats + ) + } + + fun readDisplayName(context: Context, uri: Uri): String? { + return context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex < 0 || !cursor.moveToFirst()) return@use null + cursor.getString(nameIndex) + }?.trim()?.takeIf { it.isNotEmpty() } + } + + fun statsToJson(stats: ResolverStats): String = gson.toJson(stats) + + fun statsFromJson(json: String?): ResolverStats? { + if (json.isNullOrBlank()) return null + return runCatching { gson.fromJson(json, ResolverStats::class.java) }.getOrNull() + } + + fun profileImportedResolver(profile: ProfileEntity): ImportedResolverFile? { + val advanced = parseAdvanced(profile.advancedJson) + if (!advanced[SOURCE_KEY].equals(SOURCE_FILE, ignoreCase = true)) return null + val cachedPath = advanced[CACHED_PATH_KEY].orEmpty() + if (cachedPath.isBlank()) return null + val displayName = advanced[FILE_NAME_KEY].orEmpty().ifBlank { "client_resolvers.txt" } + val stats = statsFromJson(advanced[STATS_JSON_KEY]) + ?: analyzeCachedFile(cachedPath, displayName) + ?: ResolverStats(fileName = displayName) + return ImportedResolverFile(displayName, cachedPath, stats) + } + + fun resolverCount(profile: ProfileEntity): Int { + val imported = profileImportedResolver(profile) + if (imported != null && imported.stats.uniqueUsableIps > 0) { + return imported.stats.uniqueUsableIps + } + return profile.resolvers + .lineSequence() + .map { it.trim() } + .count { it.isNotEmpty() } + .coerceAtLeast(1) + } + + fun withImportedResolver(profile: ProfileEntity, imported: ImportedResolverFile): ProfileEntity { + val previous = profileImportedResolver(profile) + if (previous != null && previous.cachedPath != imported.cachedPath) { + deleteCachedResolverFile(previous.cachedPath) + } + val advanced = parseAdvanced(profile.advancedJson).toMutableMap() + advanced[SOURCE_KEY] = SOURCE_FILE + advanced[FILE_NAME_KEY] = imported.displayName + advanced[CACHED_PATH_KEY] = imported.cachedPath + advanced[STATS_JSON_KEY] = statsToJson(imported.stats) + advanced[IMPORTED_AT_KEY] = System.currentTimeMillis().toString() + return profile.copy( + resolvers = "", + advancedJson = gson.toJson(advanced) + ) + } + + fun withInlineResolvers(profile: ProfileEntity, resolvers: String): ProfileEntity { + val previous = profileImportedResolver(profile) + previous?.let { deleteCachedResolverFile(it.cachedPath) } + val advanced = parseAdvanced(profile.advancedJson).toMutableMap() + advanced[SOURCE_KEY] = SOURCE_INLINE + advanced.remove(FILE_NAME_KEY) + advanced.remove(CACHED_PATH_KEY) + advanced.remove(STATS_JSON_KEY) + advanced.remove(IMPORTED_AT_KEY) + return profile.copy( + resolvers = resolvers.trim(), + advancedJson = gson.toJson(advanced) + ) + } + + fun discardImportedResolver(imported: ImportedResolverFile?) { + imported?.let { deleteCachedResolverFile(it.cachedPath) } + } + + fun analyzeCachedFile(path: String, fileName: String = ""): ResolverStats? { + val file = File(path) + if (!file.isFile) return null + return runCatching { analyzeText(file.readText(), fileName) }.getOrNull() + } + + private fun normalizeTextForRuntime(text: String): String { + val seen = linkedSetOf() + val normalized = mutableListOf() + text.lineSequence().forEach { raw -> + val line = raw.substringBefore("#").trim() + if (line.isEmpty()) return@forEach + + val entry = parseEntry(line) ?: return@forEach + if (entry.host.contains("/")) { + val hosts = expandCidr(entry.host).orEmpty() + hosts.forEach { ip -> + if (seen.add(ip)) { + normalized += formatRuntimeResolver(ip, entry) + } + } + return@forEach + } + + val ip = parseIp(entry.host) ?: return@forEach + if (seen.add(ip)) { + normalized += formatRuntimeResolver(ip, entry) + } + } + return normalized.joinToString(separator = "\n", postfix = "\n") + } + + private fun formatRuntimeResolver(ip: String, entry: Entry): String { + if (!entry.hasExplicitPort || entry.port == DEFAULT_PORT) return ip + return if (":" in ip) "[$ip]:${entry.port}" else "$ip:${entry.port}" + } + + private fun deleteCachedResolverFile(path: String) { + if (path.isBlank()) return + runCatching { + val file = File(path) + if (file.path.contains("${File.separator}resolver_sources${File.separator}") && file.isFile) { + file.delete() + } + } + } + + private fun parseAdvanced(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + } + + private data class Entry( + val host: String, + val port: Int, + val hasExplicitPort: Boolean + ) + + private fun parseEntry(line: String): Entry? { + val text = line.trim() + if (text.isEmpty()) return null + if (text.startsWith("[")) { + val end = text.indexOf(']') + if (end <= 0) return null + val host = text.substring(1, end).trim() + val remainder = text.substring(end + 1).trim() + if (remainder.isEmpty()) return Entry(host, DEFAULT_PORT, false) + if (!remainder.startsWith(":")) return null + val port = remainder.drop(1).trim().toIntOrNull() ?: return null + return if (port in 1..65535) Entry(host, port, true) else null + } + + val slashBeforeColon = text.contains("/") && text.lastIndexOf(':') > text.lastIndexOf('/') + val hasSingleColon = text.count { it == ':' } == 1 + val canHavePort = hasSingleColon || slashBeforeColon + if (canHavePort) { + val idx = text.lastIndexOf(':') + val host = text.substring(0, idx).trim() + val port = text.substring(idx + 1).trim().toIntOrNull() + if (host.isNotEmpty() && port != null && port in 1..65535) { + return Entry(host, port, true) + } + } + return Entry(text, DEFAULT_PORT, false) + } + + private fun parseIp(host: String): String? { + val text = host.trim() + val numericCandidate = when { + "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) + ":" in text -> text.matches(Regex("[0-9A-Fa-f:.]+")) + else -> false + } + if (!numericCandidate) return null + return runCatching { InetAddress.getByName(text).hostAddress }.getOrNull() + } + + private fun expandCidr(value: String): List? { + val parts = value.split("/") + if (parts.size != 2) return null + val normalizedBase = parseIp(parts[0].trim()) ?: return null + val base = runCatching { InetAddress.getByName(normalizedBase) }.getOrNull() ?: return null + val bytes = base.address + val totalBits = bytes.size * 8 + val prefixBits = parts[1].trim().toIntOrNull() ?: return null + if (prefixBits !in 0..totalBits) return null + val hostBits = totalBits - prefixBits + if (hostBits > 16) return emptyList() + + val total = BigInteger.ONE.shiftLeft(hostBits) + val usableStart = if (bytes.size == 4 && prefixBits < 31) BigInteger.ONE else BigInteger.ZERO + val usableEndExclusive = if (bytes.size == 4 && prefixBits < 31) total.subtract(BigInteger.ONE) else total + val usableCount = usableEndExclusive.subtract(usableStart) + if (usableCount <= BigInteger.ZERO || usableCount > BigInteger.valueOf(MAX_CIDR_HOSTS.toLong())) { + return emptyList() + } + + val baseInt = BigInteger(1, bytes) + val mask = BigInteger.ONE.shiftLeft(totalBits).subtract(BigInteger.ONE).xor( + BigInteger.ONE.shiftLeft(hostBits).subtract(BigInteger.ONE) + ) + val network = baseInt.and(mask) + val result = ArrayList(usableCount.toInt()) + var offset = usableStart + while (offset < usableEndExclusive) { + result += addressFromBigInteger(network.add(offset), bytes.size).hostAddress + offset = offset.add(BigInteger.ONE) + } + return result + } + + private fun addressFromBigInteger(value: BigInteger, byteCount: Int): InetAddress { + val raw = value.toByteArray() + val out = ByteArray(byteCount) + val copyStart = maxOf(0, raw.size - byteCount) + val copyLength = raw.size - copyStart + System.arraycopy(raw, copyStart, out, byteCount - copyLength, copyLength) + return InetAddress.getByAddress(out) + } +} diff --git a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt index 2b7e116..ecd1a31 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt @@ -58,6 +58,12 @@ object VpnManager { val uploadSpeedBps: StateFlow = _uploadSpeedBps.asStateFlow() private val _downloadSpeedBps = MutableStateFlow(0L) val downloadSpeedBps: StateFlow = _downloadSpeedBps.asStateFlow() + private val _uploadTotalBytes = MutableStateFlow(0L) + val uploadTotalBytes: StateFlow = _uploadTotalBytes.asStateFlow() + private val _downloadTotalBytes = MutableStateFlow(0L) + val downloadTotalBytes: StateFlow = _downloadTotalBytes.asStateFlow() + private val _connectedSinceMs = MutableStateFlow(0L) + val connectedSinceMs: StateFlow = _connectedSinceMs.asStateFlow() data class ScanStatus( val scanning: Boolean = false, @@ -68,7 +74,9 @@ object VpnManager { val scanTotalFromCore: Int = 0, val activeResolvers: Int = 0, val syncedUploadMtu: Int = 0, - val syncedDownloadMtu: Int = 0 + val syncedDownloadMtu: Int = 0, + val scanStartedAtMs: Long = 0L, + val scanUpdatedAtMs: Long = 0L ) private val _scanStatus = MutableStateFlow(ScanStatus()) @@ -82,17 +90,23 @@ object VpnManager { fun updateState(newState: VpnState) { _state.value = newState if (newState == VpnState.CONNECTED) { + if (_connectedSinceMs.value == 0L) { + _connectedSinceMs.value = System.currentTimeMillis() + } _scanStatus.value = _scanStatus.value.copy( lastResolver = "", lastDecision = "", scanning = false ) + } else if (newState == VpnState.DISCONNECTED || newState == VpnState.ERROR) { + _connectedSinceMs.value = 0L } } fun setError(message: String) { _errorMessage.value = message _state.value = VpnState.ERROR + _connectedSinceMs.value = 0L } fun clearError() { @@ -142,14 +156,20 @@ object VpnManager { var prevTx = TrafficStats.getUidTxBytes(uid).coerceAtLeast(0L) var prevRx = TrafficStats.getUidRxBytes(uid).coerceAtLeast(0L) var prevTime = System.currentTimeMillis() + _uploadTotalBytes.value = 0L + _downloadTotalBytes.value = 0L while (isActive) { delay(1000L) val now = System.currentTimeMillis() val tx = TrafficStats.getUidTxBytes(uid).coerceAtLeast(0L) val rx = TrafficStats.getUidRxBytes(uid).coerceAtLeast(0L) val dt = (now - prevTime).coerceAtLeast(1L) - _uploadSpeedBps.value = ((tx - prevTx).coerceAtLeast(0L) * 1000L) / dt - _downloadSpeedBps.value = ((rx - prevRx).coerceAtLeast(0L) * 1000L) / dt + val txDelta = (tx - prevTx).coerceAtLeast(0L) + val rxDelta = (rx - prevRx).coerceAtLeast(0L) + _uploadSpeedBps.value = (txDelta * 1000L) / dt + _downloadSpeedBps.value = (rxDelta * 1000L) / dt + _uploadTotalBytes.value += txDelta + _downloadTotalBytes.value += rxDelta prevTx = tx prevRx = rx prevTime = now @@ -172,7 +192,15 @@ object VpnManager { updateState(VpnState.CONNECTING) clearError() - _scanStatus.value = ScanStatus(scanning = true) + val now = System.currentTimeMillis() + _connectedSinceMs.value = 0L + _uploadTotalBytes.value = 0L + _downloadTotalBytes.value = 0L + _scanStatus.value = ScanStatus( + scanning = true, + scanStartedAtMs = now, + scanUpdatedAtMs = now + ) val intent = Intent(context, MasterDnsVpnService::class.java).apply { action = MasterDnsVpnService.ACTION_CONNECT @@ -208,6 +236,12 @@ object VpnManager { } private fun parseScanLine(line: String) { + fun currentScanTimestamps(): Pair { + val now = System.currentTimeMillis() + val started = _scanStatus.value.scanStartedAtMs.takeIf { it > 0L } ?: now + return started to now + } + val indexedProgressMatch = Regex( "(?:scan|scanning|resolver|resolvers|mtu|accepted|rejected).{0,40}?(\\d+)\\s*/\\s*(\\d+)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) @@ -215,7 +249,12 @@ object VpnManager { if (indexedProgressMatch != null) { val total = indexedProgressMatch.groupValues[2].toIntOrNull() if (total != null && total > 0) { - _scanStatus.value = _scanStatus.value.copy(scanTotalFromCore = total) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanTotalFromCore = total, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) } } @@ -226,7 +265,12 @@ object VpnManager { if (totalCandidatesMatch != null) { val total = totalCandidatesMatch.groupValues[1].toIntOrNull() if (total != null && total > 0) { - _scanStatus.value = _scanStatus.value.copy(scanTotalFromCore = total) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanTotalFromCore = total, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) } } @@ -243,12 +287,15 @@ object VpnManager { line.contains("Rejected", ignoreCase = true) -> "Rejected" else -> "" } + val (started, updated) = currentScanTimestamps() _scanStatus.value = _scanStatus.value.copy( scanning = true, lastResolver = resolver, lastDecision = decision, validCount = valid, - rejectedCount = rejected + rejectedCount = rejected, + scanStartedAtMs = started, + scanUpdatedAtMs = updated ) return } @@ -299,7 +346,12 @@ object VpnManager { } if (line.contains("Testing MTU sizes", ignoreCase = true)) { - _scanStatus.value = _scanStatus.value.copy(scanning = true) + val (started, updated) = currentScanTimestamps() + _scanStatus.value = _scanStatus.value.copy( + scanning = true, + scanStartedAtMs = started, + scanUpdatedAtMs = updated + ) return } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 64dc1e5..8dc05ba 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -8,16 +8,18 @@ Show Import TOML Export TOML - Import client_resolvers.txt + Import resolver file Pick MTU export destination Save Settings No selected profile Create/select a profile in Profiles tab, then configure client_config values here. + Search settings Editing profile: %1$s Profile settings saved and applied TOML exported TOML imported to form - Resolvers imported into profile + Resolver file imported into profile + Resolver file imported: %1$s MTU export destination selected ⚠ Port %1$d requires root access on Android. The app will automatically use port 5353 instead. Global settings saved and applied @@ -61,9 +63,23 @@ Valid: Rejected: DNS Scan Progress: %1$d / %2$d + ETA: %1$s Synced MTU: UP %1$d / DOWN %2$d Active Resolvers: %1$d Download: %1$s Upload: %2$s + Session: Down %1$s Up %2$s + Connected: %1$s + Before connecting + No profile selected. + Imported resolver file is missing. + Imported resolver file has no usable resolvers. + No inline resolvers are configured. + Local DNS port %1$d will be remapped to 5353 on Android. + MTU export is enabled without a file destination. + Check SOCKS5 auth and proxy settings. + Change the conflicting port in profile settings. + Re-import the resolver file or switch to text input. + Grant VPN permission and try again. SOCKS5: %1$s:%2$d SOCKS5 authentication Username: %1$s @@ -91,10 +107,26 @@ To avoid UI lag, tap Edit to open the text box. Edit Resolvers Resolvers (one per line) + Stats: %1$s + Import from file + Use text input + Resolver file: %1$s + Resolver stats unavailable + Lines: %1$d, blank: %2$d, comments: %3$d, custom ports: %4$d, skipped CIDR: %5$d + Inline + File: %1$s + %1$d resolvers + Export %1$s + Lock identity fields + Hide domains and encryption key in the exported TOML. + Share + Save to file + Profile TOML exported Invalid TOML: DOMAIN/ENCRYPTION_KEY not found TOML imported into profile form - Resolvers file is empty - Resolvers imported into profile form + Resolver file has no usable resolvers + Resolver file imported into profile form + Resolver file imported: %1$s Imported Profile Share Logs Auto @@ -106,6 +138,10 @@ All Core Android + Errors + Warnings + DNS + MTU Entries Errors Warnings @@ -140,6 +176,12 @@ VPN is connected Connecting… VPN disconnected + %1$s down / %2$s up + Scanning DNS %1$d/%2$d | Down %3$s | Up %4$s + Connecting | Down %1$s | Up %2$s + Connected | Down %1$s | Up %2$s | %3$s + Connected | Down %1$s | Up %2$s | %3$s | %4$d active resolvers + Disconnecting | %1$s github.com/masterking32/MasterDnsVPN t.me/masterdnsvpn github.com/Hidden-Node/MasterDnsVPN-AndroidClient