diff --git a/android/app/src/main/java/com/masterdns/vpn/MainActivity.kt b/android/app/src/main/java/com/masterdns/vpn/MainActivity.kt index 25d272b..194418c 100644 --- a/android/app/src/main/java/com/masterdns/vpn/MainActivity.kt +++ b/android/app/src/main/java/com/masterdns/vpn/MainActivity.kt @@ -1,263 +1,302 @@ -package com.masterdns.vpn - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.provider.OpenableColumns +package com.masterdns.vpn + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.lifecycle.lifecycleScope -import com.google.gson.Gson -import com.masterdns.vpn.data.local.ProfileEntity -import com.masterdns.vpn.data.repository.ProfileRepository -import com.masterdns.vpn.ui.navigation.AppNavigation -import com.masterdns.vpn.ui.theme.MasterDnsVPNTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import com.masterdns.vpn.data.local.ProfileEntity +import com.masterdns.vpn.data.repository.ProfileRepository +import com.masterdns.vpn.ui.navigation.AppNavigation +import com.masterdns.vpn.ui.theme.MasterDnsVPNTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import java.io.IOException import javax.inject.Inject +private const val MAX_TOML_IMPORT_CHARS = 256 * 1024 +private const val MAX_PROFILE_NAME_LENGTH = 80 +private const val MAX_DOMAIN_LENGTH = 253 +private const val MAX_ENCRYPTION_KEY_LENGTH = 4096 +private const val MAX_ADVANCED_VALUE_LENGTH = 4096 + @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var profileRepository: ProfileRepository - - private val gson = Gson() - @Volatile - private var lastHandledImportUri: String? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - handleTomlImportIntent(intent) - enableEdgeToEdge() - setContent { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - MasterDnsVPNTheme { - AppNavigation() - } - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - handleTomlImportIntent(intent) - } - - private fun handleTomlImportIntent(intent: Intent?) { - val action = intent?.action ?: return - if (action != Intent.ACTION_VIEW && action != Intent.ACTION_SEND) return - - val uri = when { - intent.data != null -> intent.data - intent.clipData != null && intent.clipData!!.itemCount > 0 -> intent.clipData!!.getItemAt(0).uri - else -> null - } ?: return - - val uriToken = uri.toString() - if (lastHandledImportUri == uriToken) return - val mime = intent.type.orEmpty() - val isTomlLike = mime.contains("toml", ignoreCase = true) || - uri.toString().lowercase().endsWith(".toml") - if (!isTomlLike) return - - runCatching { - contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - + @Inject + lateinit var profileRepository: ProfileRepository + + private val gson = Gson() + @Volatile + private var lastHandledImportUri: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleTomlImportIntent(intent) + enableEdgeToEdge() + setContent { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + MasterDnsVPNTheme { + AppNavigation() + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleTomlImportIntent(intent) + } + + private fun handleTomlImportIntent(intent: Intent?) { + val action = intent?.action ?: return + if (action != Intent.ACTION_VIEW && action != Intent.ACTION_SEND) return + + val uri = when { + intent.data != null -> intent.data + intent.clipData != null && intent.clipData!!.itemCount > 0 -> intent.clipData!!.getItemAt(0).uri + else -> null + } ?: return + + val uriToken = uri.toString() + if (lastHandledImportUri == uriToken) return + val mime = intent.type.orEmpty() + val isTomlLike = mime.contains("toml", ignoreCase = true) || + uri.toString().lowercase().endsWith(".toml") + if (!isTomlLike) return + + runCatching { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + lifecycleScope.launch { - val content = readTextFromUri(uri) - if (content.isBlank()) { + val content = try { + readTextFromUri(uri, MAX_TOML_IMPORT_CHARS) + } catch (_: IOException) { Toast.makeText( this@MainActivity, - getString(R.string.profiles_invalid_toml_msg), + getString(R.string.profiles_toml_too_large_msg), Toast.LENGTH_LONG ).show() return@launch } - val imported = parseImportedProfile(uri, content) - if (imported == null) { + if (content.isBlank()) { Toast.makeText( - this@MainActivity, - getString(R.string.profiles_invalid_toml_msg), - Toast.LENGTH_LONG - ).show() - return@launch - } - lastHandledImportUri = uriToken - val id = profileRepository.insertProfile(imported) - profileRepository.setSelectedProfile(id) - Toast.makeText( - this@MainActivity, - getString(R.string.profiles_toml_imported_msg), - Toast.LENGTH_SHORT - ).show() - } - } - - private fun readTextFromUri(uri: Uri): String { + this@MainActivity, + getString(R.string.profiles_invalid_toml_msg), + Toast.LENGTH_LONG + ).show() + return@launch + } + val imported = parseImportedProfile(uri, content) + if (imported == null) { + Toast.makeText( + this@MainActivity, + getString(R.string.profiles_invalid_toml_msg), + Toast.LENGTH_LONG + ).show() + return@launch + } + lastHandledImportUri = uriToken + val id = profileRepository.insertProfile(imported) + profileRepository.setSelectedProfile(id) + Toast.makeText( + this@MainActivity, + getString(R.string.profiles_toml_imported_msg), + Toast.LENGTH_SHORT + ).show() + } + } + + private fun readTextFromUri(uri: Uri, maxChars: Int): String { return contentResolver.openInputStream(uri)?.use { stream -> - stream.bufferedReader(Charsets.UTF_8).use { it.readText() } + stream.bufferedReader(Charsets.UTF_8).use { reader -> + val buffer = CharArray(8192) + val out = StringBuilder() + while (true) { + val read = reader.read(buffer) + if (read == -1) break + if (out.length + read > maxChars) { + throw IOException("Import file is too large") + } + out.append(buffer, 0, read) + } + out.toString() + } }.orEmpty() } - - private fun parseImportedProfile(uri: Uri, tomlContent: String): ProfileEntity? { - val values = parseTomlValues(tomlContent) - val parsedDomain = values["DOMAINS"]?.takeIf { it.isNotBlank() } ?: values["DOMAIN"]?.takeIf { it.isNotBlank() } ?: return null - val parsedKey = values["ENCRYPTION_KEY"]?.takeIf { it.isNotBlank() } ?: return null - val domainList = parsedDomain.split(",").map { it.trim() }.filter { it.isNotEmpty() } + + private fun parseImportedProfile(uri: Uri, tomlContent: String): ProfileEntity? { + val values = parseTomlValues(tomlContent) + val parsedDomain = values["DOMAINS"]?.takeIf { it.isNotBlank() } ?: values["DOMAIN"]?.takeIf { it.isNotBlank() } ?: return null + val parsedKey = values["ENCRYPTION_KEY"] + ?.trim() + ?.takeIf { it.isNotBlank() && it.length <= MAX_ENCRYPTION_KEY_LENGTH } + ?: return null + val domainList = parsedDomain + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() && it.length <= MAX_DOMAIN_LENGTH } if (domainList.isEmpty()) return null val advanced = mutableMapOf() IMPORT_ADVANCED_KEYS.forEach { key -> - values[key]?.let { advanced[key] = it.trim() } - } - - val fileName = readDisplayName(uri) ?: "Imported Profile" - - return ProfileEntity( - name = fileName, - domains = gson.toJson(domainList), - 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) - ) - } - - private fun readDisplayName(uri: Uri): String? { - return runCatching { - contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (idx < 0 || !cursor.moveToFirst()) return@use null - cursor.getString(idx) - } - }.getOrNull() - ?.substringBeforeLast(".") - ?.trim() - ?.takeIf { it.isNotEmpty() } - } - - private fun parseTomlValues(tomlContent: String): Map { - 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" -> valueRaw - .removePrefix("[") - .removeSuffix("]") - .split(",") - .map { it.trim().removeSurrounding("\"") } - .filter { it.isNotBlank() } - .joinToString(", ") - valueRaw.startsWith("\"") && valueRaw.endsWith("\"") -> - valueRaw.removeSurrounding("\"") - else -> valueRaw - } - values[key] = parsed + values[key] + ?.trim() + ?.takeIf { it.length <= MAX_ADVANCED_VALUE_LENGTH } + ?.let { advanced[key] = it } } - return values - } - private fun normalizeProtocol(value: String?): String { - return when (value?.trim()?.uppercase()) { - "TCP" -> "TCP" - else -> "SOCKS5" - } - } - - 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", - "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" - ) - } -} + val fileName = readDisplayName(uri) + ?.take(MAX_PROFILE_NAME_LENGTH) + ?: "Imported Profile" + + return ProfileEntity( + name = fileName, + domains = gson.toJson(domainList), + 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) + ) + } + + private fun readDisplayName(uri: Uri): String? { + return runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx < 0 || !cursor.moveToFirst()) return@use null + cursor.getString(idx) + } + }.getOrNull() + ?.substringBeforeLast(".") + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + + private fun parseTomlValues(tomlContent: String): Map { + 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" -> valueRaw + .removePrefix("[") + .removeSuffix("]") + .split(",") + .map { it.trim().removeSurrounding("\"") } + .filter { it.isNotBlank() } + .joinToString(", ") + valueRaw.startsWith("\"") && valueRaw.endsWith("\"") -> + valueRaw.removeSurrounding("\"") + else -> valueRaw + } + values[key] = parsed + } + return values + } + + private fun normalizeProtocol(value: String?): String { + return when (value?.trim()?.uppercase()) { + "TCP" -> "TCP" + else -> "SOCKS5" + } + } + + 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", + "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/ProfilesScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/profiles/ProfilesScreen.kt index 0c606f5..b054846 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,836 +1,881 @@ -package com.masterdns.vpn.ui.profiles - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -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.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 -import com.masterdns.vpn.R -import com.masterdns.vpn.data.local.ProfileEntity -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.ResolverAnalyzer -import com.masterdns.vpn.util.ResolverImportResult +package com.masterdns.vpn.ui.profiles + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.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 +import com.masterdns.vpn.R +import com.masterdns.vpn.data.local.ProfileEntity +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.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverImportResult import com.masterdns.vpn.util.ResolverImportStats import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.IOException -private data class ImportedProfileDraft( - val profile: ProfileEntity, - val domainList: List -) +private const val MAX_TOML_IMPORT_CHARS = 256 * 1024 +private const val MAX_RESOLVER_IMPORT_CHARS = ResolverAnalyzer.MAX_IMPORT_BYTES +private const val MAX_PROFILE_NAME_LENGTH = 80 +private const val MAX_DOMAIN_LENGTH = 253 +private const val MAX_ENCRYPTION_KEY_LENGTH = 4096 +private const val MAX_ADVANCED_VALUE_LENGTH = 4096 -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfilesScreen( - viewModel: ProfilesViewModel = hiltViewModel(), - onBack: () -> Unit, - onOpenSettings: (Long) -> Unit -) { - 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 profilePendingDelete by remember { mutableStateOf(null) } - val context = androidx.compose.ui.platform.LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() +private data class ImportedProfileDraft( + val profile: ProfileEntity, + val domainList: List +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesScreen( + viewModel: ProfilesViewModel = hiltViewModel(), + onBack: () -> Unit, + onOpenSettings: (Long) -> Unit +) { + 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 profilePendingDelete by remember { mutableStateOf(null) } + val context = androidx.compose.ui.platform.LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() val importLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> if (uri == null) return@rememberLauncherForActivityResult - val text = readTextFromUri(context, uri) + val text = try { + readTextFromUri(context, uri, MAX_TOML_IMPORT_CHARS) + } catch (_: IOException) { + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_toml_too_large_msg)) } + return@rememberLauncherForActivityResult + } val draft = parseProfileTomlForImport( fileName = readDisplayName(context, uri) ?: context.getString(R.string.profiles_imported_profile_default), tomlContent = text - ) - if (draft == null) { - 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)) } - } - val importResolversLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> + ) + if (draft == null) { + 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)) } + } + val importResolversLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> if (uri == null) return@rememberLauncherForActivityResult scope.launch { val fileName = readDisplayName(context, uri) ?: "client_resolvers.txt" - val text = withContext(Dispatchers.IO) { readTextFromUri(context, uri) } - val result = withContext(Dispatchers.Default) { - ResolverAnalyzer.analyzeAndNormalize(text, fileName) - } - if (result == null || result.normalizedText.isBlank()) { - snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) - return@launch - } - importedResolvers = result - editingProfile = null - showEditor = true - snackbarHostState.showSnackbar( - context.getString(R.string.profiles_resolvers_imported_stats_msg, result.stats.summary()) - ) - } - } - - Scaffold( - containerColor = MdvColor.Background, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = { - MdvBackTopAppBar( - title = stringResource(R.string.title_profiles), - onBack = onBack, - actions = { - IconButton(onClick = { - editingProfile = null - importedDraft = null - importedResolvers = null - showEditor = true - }) { - Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.profiles_add)) - } - } - ) - } - ) { padding -> - 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( - "text/plain", - "application/octet-stream", - "*/*" - ) - ) - }, - onSave = { profile -> - if (editingProfile != null) { - viewModel.updateProfile(profile) - } else { - viewModel.addProfile(profile) - } - showEditor = false - editingProfile = null - importedDraft = null - importedResolvers = null - }, - onDismiss = { - showEditor = false - editingProfile = null - importedDraft = null - importedResolvers = null - } - ) - } - - if (profiles.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - Icons.Filled.PersonAdd, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MdvColor.OnSurfaceVariant.copy(alpha = 0.7f) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - stringResource(R.string.profiles_empty_title), - style = MaterialTheme.typography.titleMedium, - color = MdvColor.OnSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - FilledTonalButton(onClick = { - editingProfile = null - importedDraft = null - importedResolvers = null - showEditor = true - }) { - Text(stringResource(R.string.profiles_create)) - } - } - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(MdvSpace.S4), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S2) - ) { - items(profiles) { profile -> - ProfileCard( - profile = profile, - onSelect = { viewModel.selectProfile(profile.id) }, - onSettings = { onOpenSettings(profile.id) }, - onEdit = { - editingProfile = profile - showEditor = true - }, - onDelete = { profilePendingDelete = profile } - ) - } - } - } - } - - profilePendingDelete?.let { profile -> - AlertDialog( - onDismissRequest = { profilePendingDelete = null }, - title = { Text(stringResource(R.string.profiles_delete_confirm_title)) }, - text = { - Text( - stringResource( - R.string.profiles_delete_confirm_message, - profile.name.ifBlank { stringResource(R.string.profiles_dialog_new_title) } - ) - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.deleteProfile(profile) - profilePendingDelete = null - } - ) { - Text(stringResource(R.string.profiles_delete)) - } - }, - dismissButton = { - TextButton(onClick = { profilePendingDelete = null }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - } -} - -@Composable -fun ProfileCard( - profile: ProfileEntity, - onSelect: () -> Unit, - onSettings: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit -) { - Card( - onClick = onSelect, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = if (profile.isSelected) - MdvColor.PrimaryContainer.copy(alpha = 0.16f) - else - MdvColor.SurfaceHigh - ) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Selected indicator - if (profile.isSelected) { - Icon( - Icons.Filled.CheckCircle, - contentDescription = stringResource(R.string.profiles_selected), - tint = ConnectedGreen, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - } - - 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() } - } - Text( - text = domainsList.joinToString(", "), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - } - - 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 = onSettings, modifier = Modifier.size(48.dp)) { - Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.profiles_settings), modifier = Modifier.size(20.dp)) - } - IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.profiles_delete), modifier = Modifier.size(20.dp)) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ProfileEditorDialog( - profile: ProfileEntity?, - importedDraft: ImportedProfileDraft?, - importedResolvers: ResolverImportResult?, - onImportToml: () -> Unit, - onImportResolvers: () -> Unit, - onSave: (ProfileEntity) -> Unit, - onDismiss: () -> Unit -) { - 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) - } - } - } - var newDomainInput by remember { mutableStateOf("") } - var isDomainInputFocused by remember { mutableStateOf(false) } - var encryptionKey by remember { mutableStateOf(profile?.encryptionKey.orEmpty()) } - var resolvers by remember { mutableStateOf(profile?.resolvers.orEmpty()) } - var validationMessage by remember { mutableStateOf(null) } - var showKey by remember { mutableStateOf(false) } - var showResolversEditor by remember { mutableStateOf(false) } - val largeResolversText = resolvers.length > 6000 - val nameRequiredMsg = stringResource(R.string.profiles_name_required_msg) - val domainRequiredMsg = stringResource(R.string.profiles_domain_required_msg) - val keyRequiredMsg = stringResource(R.string.profiles_encryption_key_required_msg) - val resolverRequiredMsg = stringResource(R.string.profiles_resolvers_required_msg) - - LaunchedEffect(profile?.id) { - if (profile != null) { - 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) - encryptionKey = profile.encryptionKey - resolvers = profile.resolvers - validationMessage = null - 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 != null) { - resolvers = importedResolvers.normalizedText - } - validationMessage = null - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - if (profile != null) { - stringResource(R.string.profiles_dialog_edit_title) - } else { - stringResource(R.string.profiles_dialog_new_title) + val text = try { + withContext(Dispatchers.IO) { + readTextFromUri(context, uri, MAX_RESOLVER_IMPORT_CHARS) } - ) - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedTextField( - value = name, - onValueChange = { - name = it - validationMessage = null - }, - label = { Text(stringResource(R.string.profiles_name)) }, - isError = validationMessage == nameRequiredMsg, - 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 - ) - } - } - } - } - - 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(), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - value = newDomainInput, - onValueChange = { - newDomainInput = it - validationMessage = null - }, - label = { Text(stringResource(R.string.profiles_domain_hint)) }, - isError = validationMessage == domainRequiredMsg, - placeholder = { - if (isDomainInputFocused) { - Text("(e.g. v.example.com)") - } - }, - 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) - } - } - } - - OutlinedTextField( - value = encryptionKey, - onValueChange = { - encryptionKey = it - validationMessage = null - }, - label = { Text(stringResource(R.string.profiles_encryption_key)) }, - isError = validationMessage == keyRequiredMsg, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showKey = !showKey }) { - Icon( - if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = if (showKey) { - stringResource(R.string.profiles_hide_sensitive) - } else { - stringResource(R.string.profiles_show_sensitive) - } - ) - } - } - ) - - // Import Resolvers button for Edit mode (placed above the resolvers field) - if (profile != null) { - OutlinedButton( - onClick = onImportResolvers, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.profiles_import_resolvers_short)) - } - } - - if (!showResolversEditor && largeResolversText) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) - ) { - Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(stringResource(R.string.profiles_large_resolvers_title, resolvers.lines().size)) - Text( - stringResource(R.string.profiles_large_resolvers_desc), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - OutlinedButton(onClick = { showResolversEditor = true }) { - Text(stringResource(R.string.profiles_edit_resolvers)) - } - } - } - } else { - OutlinedTextField( - value = resolvers, - onValueChange = { - resolvers = it - validationMessage = null - }, - label = { Text(stringResource(R.string.profiles_resolvers_label)) }, - isError = validationMessage == resolverRequiredMsg, - modifier = Modifier.fillMaxWidth().height(120.dp), - maxLines = 6, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) - ) - } - importedResolvers?.stats?.let { stats -> - ResolverImportStatsCard(stats) - } - validationMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - confirmButton = { - FilledTonalButton( - onClick = { - val d = newDomainInput.trim() - val finalDomainList = if (d.isNotEmpty() && !domainList.contains(d)) { - domainList + d - } else { - domainList.toList() - } - validationMessage = when { - name.isBlank() -> nameRequiredMsg - finalDomainList.isEmpty() -> domainRequiredMsg - encryptionKey.isBlank() -> keyRequiredMsg - resolvers.isBlank() -> resolverRequiredMsg - else -> null - } - if (validationMessage != null) { - return@FilledTonalButton - } - val baseProfile = profile ?: importedDraft?.profile ?: ProfileEntity(name = "", domains = "") - val domainJson = gson.toJson(finalDomainList) - val updatedProfile = baseProfile.copy( - name = name.trim(), - domains = domainJson, - encryptionKey = encryptionKey.trim(), - resolvers = resolvers.trim() - ) - onSave( - if (importedResolvers != null) { - ResolverAnalyzer.withImportStats(updatedProfile, importedResolvers.stats) - } else { - updatedProfile - } - ) - } - ) { - Text(stringResource(R.string.action_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + } catch (_: IOException) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_too_large_msg)) + return@launch } - } - ) -} - -@Composable -private fun ResolverImportStatsCard(stats: ResolverImportStats) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) - ) { - Column( - modifier = Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - stringResource(R.string.profiles_resolvers_import_preview_title), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - stringResource( - R.string.profiles_resolvers_import_preview_body, - stats.uniqueResolvers, - stats.duplicateResolvers, - stats.invalidLines, - stats.cidrRanges - ), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - if (stats.truncated || stats.skippedCidrRanges > 0) { - Text( - stringResource( - R.string.profiles_resolvers_import_preview_limits, - stats.skippedCidrRanges - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) + val result = withContext(Dispatchers.Default) { + ResolverAnalyzer.analyzeAndNormalize(text, fileName) + } + if (result == null || result.normalizedText.isBlank()) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_empty_msg)) + return@launch + } + importedResolvers = result + editingProfile = null + showEditor = true + snackbarHostState.showSnackbar( + context.getString(R.string.profiles_resolvers_imported_stats_msg, result.stats.summary()) + ) + } + } + + Scaffold( + containerColor = MdvColor.Background, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + MdvBackTopAppBar( + title = stringResource(R.string.title_profiles), + onBack = onBack, + actions = { + IconButton(onClick = { + editingProfile = null + importedDraft = null + importedResolvers = null + showEditor = true + }) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.profiles_add)) + } + } + ) + } + ) { padding -> + 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( + "text/plain", + "application/octet-stream", + "*/*" + ) + ) + }, + onSave = { profile -> + if (editingProfile != null) { + viewModel.updateProfile(profile) + } else { + viewModel.addProfile(profile) + } + showEditor = false + editingProfile = null + importedDraft = null + importedResolvers = null + }, + onDismiss = { + showEditor = false + editingProfile = null + importedDraft = null + importedResolvers = null + } + ) + } + + if (profiles.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Filled.PersonAdd, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MdvColor.OnSurfaceVariant.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringResource(R.string.profiles_empty_title), + style = MaterialTheme.typography.titleMedium, + color = MdvColor.OnSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + FilledTonalButton(onClick = { + editingProfile = null + importedDraft = null + importedResolvers = null + showEditor = true + }) { + Text(stringResource(R.string.profiles_create)) + } + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(MdvSpace.S4), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S2) + ) { + items(profiles) { profile -> + ProfileCard( + profile = profile, + onSelect = { viewModel.selectProfile(profile.id) }, + onSettings = { onOpenSettings(profile.id) }, + onEdit = { + editingProfile = profile + showEditor = true + }, + onDelete = { profilePendingDelete = profile } + ) + } + } + } + } + + profilePendingDelete?.let { profile -> + AlertDialog( + onDismissRequest = { profilePendingDelete = null }, + title = { Text(stringResource(R.string.profiles_delete_confirm_title)) }, + text = { + Text( + stringResource( + R.string.profiles_delete_confirm_message, + profile.name.ifBlank { stringResource(R.string.profiles_dialog_new_title) } + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteProfile(profile) + profilePendingDelete = null + } + ) { + Text(stringResource(R.string.profiles_delete)) + } + }, + dismissButton = { + TextButton(onClick = { profilePendingDelete = null }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } +} + +@Composable +fun ProfileCard( + profile: ProfileEntity, + onSelect: () -> Unit, + onSettings: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + Card( + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (profile.isSelected) + MdvColor.PrimaryContainer.copy(alpha = 0.16f) + else + MdvColor.SurfaceHigh + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Selected indicator + if (profile.isSelected) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.profiles_selected), + tint = ConnectedGreen, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + 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() } + } + Text( + text = domainsList.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + + 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 = onSettings, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.profiles_settings), modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.profiles_delete), modifier = Modifier.size(20.dp)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileEditorDialog( + profile: ProfileEntity?, + importedDraft: ImportedProfileDraft?, + importedResolvers: ResolverImportResult?, + onImportToml: () -> Unit, + onImportResolvers: () -> Unit, + onSave: (ProfileEntity) -> Unit, + onDismiss: () -> Unit +) { + 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) + } + } + } + var newDomainInput by remember { mutableStateOf("") } + var isDomainInputFocused by remember { mutableStateOf(false) } + var encryptionKey by remember { mutableStateOf(profile?.encryptionKey.orEmpty()) } + var resolvers by remember { mutableStateOf(profile?.resolvers.orEmpty()) } + var validationMessage by remember { mutableStateOf(null) } + var showKey by remember { mutableStateOf(false) } + var showResolversEditor by remember { mutableStateOf(false) } + val largeResolversText = resolvers.length > 6000 + val nameRequiredMsg = stringResource(R.string.profiles_name_required_msg) + val domainRequiredMsg = stringResource(R.string.profiles_domain_required_msg) + val keyRequiredMsg = stringResource(R.string.profiles_encryption_key_required_msg) + val resolverRequiredMsg = stringResource(R.string.profiles_resolvers_required_msg) + + LaunchedEffect(profile?.id) { + if (profile != null) { + 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) + encryptionKey = profile.encryptionKey + resolvers = profile.resolvers + validationMessage = null + 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 != null) { + resolvers = importedResolvers.normalizedText + } + validationMessage = null + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (profile != null) { + stringResource(R.string.profiles_dialog_edit_title) + } else { + stringResource(R.string.profiles_dialog_new_title) + } + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { + name = it + validationMessage = null + }, + label = { Text(stringResource(R.string.profiles_name)) }, + isError = validationMessage == nameRequiredMsg, + 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 + ) + } + } + } + } + + 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(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newDomainInput, + onValueChange = { + newDomainInput = it + validationMessage = null + }, + label = { Text(stringResource(R.string.profiles_domain_hint)) }, + isError = validationMessage == domainRequiredMsg, + placeholder = { + if (isDomainInputFocused) { + Text("(e.g. v.example.com)") + } + }, + 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) + } + } + } + + OutlinedTextField( + value = encryptionKey, + onValueChange = { + encryptionKey = it + validationMessage = null + }, + label = { Text(stringResource(R.string.profiles_encryption_key)) }, + isError = validationMessage == keyRequiredMsg, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showKey) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (showKey) { + stringResource(R.string.profiles_hide_sensitive) + } else { + stringResource(R.string.profiles_show_sensitive) + } + ) + } + } + ) + + // Import Resolvers button for Edit mode (placed above the resolvers field) + if (profile != null) { + OutlinedButton( + onClick = onImportResolvers, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.profiles_import_resolvers_short)) + } + } + + if (!showResolversEditor && largeResolversText) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.profiles_large_resolvers_title, resolvers.lines().size)) + Text( + stringResource(R.string.profiles_large_resolvers_desc), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + OutlinedButton(onClick = { showResolversEditor = true }) { + Text(stringResource(R.string.profiles_edit_resolvers)) + } + } + } + } else { + OutlinedTextField( + value = resolvers, + onValueChange = { + resolvers = it + validationMessage = null + }, + label = { Text(stringResource(R.string.profiles_resolvers_label)) }, + isError = validationMessage == resolverRequiredMsg, + modifier = Modifier.fillMaxWidth().height(120.dp), + maxLines = 6, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) + ) + } + importedResolvers?.stats?.let { stats -> + ResolverImportStatsCard(stats) + } + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + FilledTonalButton( + onClick = { + val d = newDomainInput.trim() + val finalDomainList = if (d.isNotEmpty() && !domainList.contains(d)) { + domainList + d + } else { + domainList.toList() + } + validationMessage = when { + name.isBlank() -> nameRequiredMsg + finalDomainList.isEmpty() -> domainRequiredMsg + encryptionKey.isBlank() -> keyRequiredMsg + resolvers.isBlank() -> resolverRequiredMsg + else -> null + } + if (validationMessage != null) { + return@FilledTonalButton + } + val baseProfile = profile ?: importedDraft?.profile ?: ProfileEntity(name = "", domains = "") + val domainJson = gson.toJson(finalDomainList) + val updatedProfile = baseProfile.copy( + name = name.trim(), + domains = domainJson, + encryptionKey = encryptionKey.trim(), + resolvers = resolvers.trim() + ) + onSave( + if (importedResolvers != null) { + ResolverAnalyzer.withImportStats(updatedProfile, importedResolvers.stats) + } else { + updatedProfile + } + ) + } + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} + +@Composable +private fun ResolverImportStatsCard(stats: ResolverImportStats) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh) + ) { + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + stringResource(R.string.profiles_resolvers_import_preview_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource( + R.string.profiles_resolvers_import_preview_body, + stats.uniqueResolvers, + stats.duplicateResolvers, + stats.invalidLines, + stats.cidrRanges + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + if (stats.truncated || stats.skippedCidrRanges > 0) { + Text( + stringResource( + R.string.profiles_resolvers_import_preview_limits, + stats.skippedCidrRanges + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +private val gson = Gson() + +private fun readTextFromUri(context: Context, uri: Uri, maxChars: Int): String { + return context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> + val buffer = CharArray(8192) + val out = StringBuilder() + while (true) { + val read = reader.read(buffer) + if (read == -1) break + if (out.length + read > maxChars) { + throw IOException("Import file is too large") } + out.append(buffer, 0, read) } - } -} - -private val gson = Gson() - -private fun readTextFromUri(context: Context, uri: Uri): String { - return context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }.orEmpty() + out.toString() + }.orEmpty() } - -private 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) - }?.substringBeforeLast(".")?.trim()?.takeIf { it.isNotEmpty() } -} - + +private 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) + }?.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 + 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 + .map { it.trim() } + .filter { it.isNotBlank() && it.length <= MAX_DOMAIN_LENGTH } + } else { + return null } - - val parsedDomain = if (parsedDomainsList.isNotEmpty()) parsedDomainsList else return null - val parsedKey = values["ENCRYPTION_KEY"]?.takeIf { it.isNotBlank() } ?: return null + if (parsedDomain.isEmpty()) return null + val parsedKey = values["ENCRYPTION_KEY"] + ?.trim() + ?.takeIf { it.isNotBlank() && it.length <= MAX_ENCRYPTION_KEY_LENGTH } + ?: return null val advanced = mutableMapOf() IMPORT_ADVANCED_KEYS.forEach { key -> - values[key]?.let { advanced[key] = it.trim() } + values[key] + ?.trim() + ?.takeIf { it.length <= MAX_ADVANCED_VALUE_LENGTH } + ?.let { advanced[key] = it } } 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 = "", - 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" -) + name = fileName.take(MAX_PROFILE_NAME_LENGTH), + 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 = "", + 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/settings/SettingsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/SettingsScreen.kt index d99ca5f..c48bd42 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 @@ -1,748 +1,778 @@ -package com.masterdns.vpn.ui.settings - -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.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.masterdns.vpn.R -import com.masterdns.vpn.data.local.ProfileEntity -import com.masterdns.vpn.ui.components.mdv.cards.MdvSectionCard -import com.masterdns.vpn.ui.components.mdv.cards.MdvSettingFieldCard -import com.masterdns.vpn.ui.components.mdv.controls.MdvPrimaryActionButton -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 +package com.masterdns.vpn.ui.settings + +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.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.masterdns.vpn.R +import com.masterdns.vpn.data.local.ProfileEntity +import com.masterdns.vpn.ui.components.mdv.cards.MdvSectionCard +import com.masterdns.vpn.ui.components.mdv.cards.MdvSettingFieldCard +import com.masterdns.vpn.ui.components.mdv.controls.MdvPrimaryActionButton +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 +import java.io.IOException private enum class FieldType { TEXT, BOOL, OPTION } -private data class SettingField( - val section: String, - val key: String, - val label: String, - val helper: String, - val type: FieldType = FieldType.TEXT, - val keyboardType: KeyboardType = KeyboardType.Text, - val options: List = emptyList() -) - -private val configFields = listOf( - SettingField("Identity", "DOMAINS", "DOMAINS", "Comma-separated domains"), - SettingField("Identity", "ENCRYPTION_KEY", "ENCRYPTION_KEY", "Shared key with server"), - SettingField( - "Security", - "DATA_ENCRYPTION_METHOD", - "DATA_ENCRYPTION_METHOD", - "0=None, 1=XOR, 2=ChaCha20, 3-5=AES-GCM", - type = FieldType.OPTION, - options = listOf( - "0 - None", - "1 - XOR", - "2 - ChaCha20", - "3 - AES-128-GCM", - "4 - AES-192-GCM", - "5 - AES-256-GCM" - ) - ), - SettingField( - "Proxy", - "PROTOCOL_TYPE", - "PROTOCOL_TYPE", - "Client local proxy protocol", - type = FieldType.OPTION, - options = listOf("SOCKS5", "TCP") - ), - SettingField("Proxy", "LISTEN_IP", "LISTEN_IP", "Local bind IP"), - SettingField( - "Proxy", - "LISTEN_PORT", - "LISTEN_PORT", - "Local proxy port", - keyboardType = KeyboardType.Number - ), - SettingField("Proxy", "SOCKS5_AUTH", "SOCKS5_AUTH", "Enable SOCKS5 auth", type = FieldType.BOOL), - SettingField("Proxy", "SOCKS5_USER", "SOCKS5_USER", "SOCKS username"), - SettingField("Proxy", "SOCKS5_PASS", "SOCKS5_PASS", "SOCKS password"), - SettingField("DNS", "LOCAL_DNS_ENABLED", "LOCAL_DNS_ENABLED", "Enable local DNS mode", type = FieldType.BOOL), - SettingField("DNS", "LOCAL_DNS_IP", "LOCAL_DNS_IP", "Local DNS bind IP"), - SettingField("DNS", "LOCAL_DNS_PORT", "LOCAL_DNS_PORT", "Local DNS bind port", keyboardType = KeyboardType.Number), - SettingField("DNS", "LOCAL_DNS_CACHE_MAX_RECORDS", "LOCAL_DNS_CACHE_MAX_RECORDS", "Local DNS cache max records", keyboardType = KeyboardType.Number), - SettingField("DNS", "LOCAL_DNS_CACHE_TTL_SECONDS", "LOCAL_DNS_CACHE_TTL_SECONDS", "Local DNS cache TTL seconds", keyboardType = KeyboardType.Decimal), - SettingField("DNS", "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "Local DNS pending timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("DNS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "DNS response fragment timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("DNS", "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "Persist local DNS cache to file", type = FieldType.BOOL), - SettingField("DNS", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "Local DNS cache flush interval seconds", keyboardType = KeyboardType.Decimal), - SettingField( - "Resolver", - "RESOLVER_BALANCING_STRATEGY", - "RESOLVER_BALANCING_STRATEGY", - "Resolver balancing strategy", - type = FieldType.OPTION, - options = listOf( - "1 - Random", - "2 - Round Robin", - "3 - Least Loss", - "4 - Lowest Latency", - "5 - Hybrid Score", - "6 - Loss Then Latency", - "7 - Least Loss Top Random", - "8 - Least Loss Top Round Robin" - ) - ), - SettingField("Resolver", "PACKET_DUPLICATION_COUNT", "PACKET_DUPLICATION_COUNT", "Runtime packet duplication count", keyboardType = KeyboardType.Number), - SettingField("Resolver", "SETUP_PACKET_DUPLICATION_COUNT", "SETUP_PACKET_DUPLICATION_COUNT", "Setup packet duplication count", keyboardType = KeyboardType.Number), - SettingField("Resolver", "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "Resends before stream failover", keyboardType = KeyboardType.Number), - SettingField("Resolver", "STREAM_RESOLVER_FAILOVER_COOLDOWN", "STREAM_RESOLVER_FAILOVER_COOLDOWN", "Failover cooldown seconds", keyboardType = KeyboardType.Decimal), - SettingField("Resolver", "RECHECK_INACTIVE_SERVERS_ENABLED", "RECHECK_INACTIVE_SERVERS_ENABLED", "Re-check inactive resolvers", type = FieldType.BOOL), - SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_SERVERS", "AUTO_DISABLE_TIMEOUT_SERVERS", "Auto disable timeout resolvers", type = FieldType.BOOL), - SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "Timeout-only window seconds", keyboardType = KeyboardType.Decimal), - SettingField("Resolver", "BASE_ENCODE_DATA", "BASE_ENCODE_DATA", "Use base-encoded payloads", type = FieldType.BOOL), - SettingField("Compression", "UPLOAD_COMPRESSION_TYPE", "UPLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), - SettingField("Compression", "DOWNLOAD_COMPRESSION_TYPE", "DOWNLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), - SettingField("Compression", "COMPRESSION_MIN_SIZE", "COMPRESSION_MIN_SIZE", "Min bytes to trigger compression", keyboardType = KeyboardType.Number), - SettingField("MTU", "AUTO_REMOVE_LOW_MTU_SERVERS", "AUTO_REMOVE_LOW_MTU_SERVERS", "Auto remove low MTU servers", type = FieldType.BOOL), - SettingField("MTU", "MIN_UPLOAD_MTU", "MIN_UPLOAD_MTU", "Minimum upload MTU bytes", keyboardType = KeyboardType.Number), - SettingField("MTU", "MIN_DOWNLOAD_MTU", "MIN_DOWNLOAD_MTU", "Minimum download MTU bytes", keyboardType = KeyboardType.Number), - SettingField("MTU", "MAX_UPLOAD_MTU", "MAX_UPLOAD_MTU", "Maximum upload MTU bytes", keyboardType = KeyboardType.Number), - SettingField("MTU", "MAX_DOWNLOAD_MTU", "MAX_DOWNLOAD_MTU", "Maximum download MTU bytes", keyboardType = KeyboardType.Number), - SettingField("MTU", "MTU_TEST_RETRIES", "MTU_TEST_RETRIES", "Retries per MTU probe", keyboardType = KeyboardType.Number), - SettingField("MTU", "MTU_TEST_TIMEOUT", "MTU_TEST_TIMEOUT", "Probe timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("MTU", "MTU_TEST_PARALLELISM", "MTU_TEST_PARALLELISM", "Parallel probe workers", keyboardType = KeyboardType.Number), - SettingField("MTU", "SAVE_MTU_SERVERS_TO_FILE", "SAVE_MTU_SERVERS_TO_FILE", "Persist successful MTU resolvers to file", type = FieldType.BOOL), - SettingField("MTU", "MTU_SERVERS_FILE_NAME", "MTU_SERVERS_FILE_NAME", "Output file name/path (absolute path supported)"), - SettingField("MTU", "MTU_SERVERS_FILE_FORMAT", "MTU_SERVERS_FILE_FORMAT", "Format: {IP} {UP_MTU} {DOWN-MTU}"), - SettingField("MTU", "MTU_USING_SECTION_SEPARATOR_TEXT", "MTU_USING_SECTION_SEPARATOR_TEXT", "Optional separator text between sections"), - SettingField("MTU", "MTU_REMOVED_SERVER_LOG_FORMAT", "MTU_REMOVED_SERVER_LOG_FORMAT", "Log format when resolver is removed"), - SettingField("MTU", "MTU_ADDED_SERVER_LOG_FORMAT", "MTU_ADDED_SERVER_LOG_FORMAT", "Log format when resolver is re-added"), - SettingField("MTU", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "Log format when resolver is re-added after reactive recheck"), - SettingField("Runtime", "RX_TX_WORKERS", "RX_TX_WORKERS", "Combined RX/TX worker count", keyboardType = KeyboardType.Number), - SettingField("Runtime", "TUNNEL_PROCESS_WORKERS", "TUNNEL_PROCESS_WORKERS", "Processor worker count", keyboardType = KeyboardType.Number), - SettingField("Runtime", "TUNNEL_PACKET_TIMEOUT_SECONDS", "TUNNEL_PACKET_TIMEOUT_SECONDS", "Packet timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "RX_CHANNEL_SIZE", "RX_CHANNEL_SIZE", "RX channel size", keyboardType = KeyboardType.Number), - SettingField("Runtime", "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "Dispatcher idle poll interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "SOCKS UDP associate read timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "Terminal stream retention seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "Cancelled setup stream retention seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SESSION_INIT_RETRY_BASE_SECONDS", "SESSION_INIT_RETRY_BASE_SECONDS", "Session init retry base seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SESSION_INIT_RETRY_STEP_SECONDS", "SESSION_INIT_RETRY_STEP_SECONDS", "Session init retry step seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SESSION_INIT_RETRY_LINEAR_AFTER", "SESSION_INIT_RETRY_LINEAR_AFTER", "Session init retry linear-after", keyboardType = KeyboardType.Number), - SettingField("Runtime", "SESSION_INIT_RETRY_MAX_SECONDS", "SESSION_INIT_RETRY_MAX_SECONDS", "Session init retry max seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "Session busy retry interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "SESSION_INIT_RACING_COUNT", "SESSION_INIT_RACING_COUNT", "Session init racing count", keyboardType = KeyboardType.Number), - SettingField("Runtime", "PING_AGGRESSIVE_INTERVAL_SECONDS", "PING_AGGRESSIVE_INTERVAL_SECONDS", "Ping aggressive interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_LAZY_INTERVAL_SECONDS", "PING_LAZY_INTERVAL_SECONDS", "Ping lazy interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_COOLDOWN_INTERVAL_SECONDS", "PING_COOLDOWN_INTERVAL_SECONDS", "Ping cooldown interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_COLD_INTERVAL_SECONDS", "PING_COLD_INTERVAL_SECONDS", "Ping cold interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_WARM_THRESHOLD_SECONDS", "PING_WARM_THRESHOLD_SECONDS", "Ping warm threshold seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_COOL_THRESHOLD_SECONDS", "PING_COOL_THRESHOLD_SECONDS", "Ping cool threshold seconds", keyboardType = KeyboardType.Decimal), - SettingField("Runtime", "PING_COLD_THRESHOLD_SECONDS", "PING_COLD_THRESHOLD_SECONDS", "Ping cold threshold seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_WINDOW_SIZE", "ARQ_WINDOW_SIZE", "ARQ receive window size", keyboardType = KeyboardType.Number), - SettingField("ARQ", "MAX_PACKETS_PER_BATCH", "MAX_PACKETS_PER_BATCH", "Max packets per batch", keyboardType = KeyboardType.Number), - SettingField("ARQ", "ARQ_INITIAL_RTO_SECONDS", "ARQ_INITIAL_RTO_SECONDS", "Initial RTO seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_MAX_RTO_SECONDS", "ARQ_MAX_RTO_SECONDS", "Maximum RTO seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_CONTROL_INITIAL_RTO_SECONDS", "ARQ_CONTROL_INITIAL_RTO_SECONDS", "Control initial RTO seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_CONTROL_MAX_RTO_SECONDS", "ARQ_CONTROL_MAX_RTO_SECONDS", "Control packet max RTO seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_MAX_CONTROL_RETRIES", "ARQ_MAX_CONTROL_RETRIES", "Maximum retries per control packet", keyboardType = KeyboardType.Number), - SettingField("ARQ", "ARQ_MAX_DATA_RETRIES", "ARQ_MAX_DATA_RETRIES", "Maximum retries per data packet", keyboardType = KeyboardType.Number), - SettingField("ARQ", "ARQ_DATA_PACKET_TTL_SECONDS", "ARQ_DATA_PACKET_TTL_SECONDS", "ARQ data packet TTL seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ control packet TTL seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_DATA_NACK_MAX_GAP", "ARQ_DATA_NACK_MAX_GAP", "ARQ data NACK max gap", keyboardType = KeyboardType.Number), - SettingField("ARQ", "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "Initial delay before first NACK seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_DATA_NACK_REPEAT_SECONDS", "ARQ_DATA_NACK_REPEAT_SECONDS", "NACK repeat interval seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_INACTIVITY_TIMEOUT_SECONDS", "ARQ_INACTIVITY_TIMEOUT_SECONDS", "Inactivity timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "ARQ terminal drain timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField("ARQ", "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "ARQ terminal ACK wait timeout seconds", keyboardType = KeyboardType.Decimal), - SettingField( - "Logging", - "LOG_LEVEL", - "LOG_LEVEL", - "Client log level", - type = FieldType.OPTION, - options = listOf("DEBUG", "INFO", "WARN", "ERROR") - ) -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen( - viewModel: SettingsViewModel = hiltViewModel(), - onBack: (() -> Unit)? = null -) { - val context = LocalContext.current - val profile by viewModel.selectedProfile.collectAsState() - val fieldsState = remember { mutableStateMapOf() } - val snackbarHostState = remember { SnackbarHostState() } - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - val sections = remember { configFields.groupBy { it.section } } - val sectionOrder = remember { configFields.map { it.section }.distinct() } - val sectionExpanded = remember { - mutableStateMapOf().apply { - sectionOrder.forEach { put(it, it == "Identity") } - } - } - - var pendingExportContent by remember { mutableStateOf(null) } - val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("text/x-toml") - ) { uri -> - val content = pendingExportContent - if (uri != null && content != null) { - runCatching { - context.grantUriPermission( - context.packageName, - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - context.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { - it.write(content) - } - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_toml_exported_msg)) } - } - pendingExportContent = null - } - +private const val MAX_TOML_IMPORT_CHARS = 256 * 1024 +private const val MAX_RESOLVER_IMPORT_CHARS = ResolverAnalyzer.MAX_IMPORT_BYTES + +private data class SettingField( + val section: String, + val key: String, + val label: String, + val helper: String, + val type: FieldType = FieldType.TEXT, + val keyboardType: KeyboardType = KeyboardType.Text, + val options: List = emptyList() +) + +private val configFields = listOf( + SettingField("Identity", "DOMAINS", "DOMAINS", "Comma-separated domains"), + SettingField("Identity", "ENCRYPTION_KEY", "ENCRYPTION_KEY", "Shared key with server"), + SettingField( + "Security", + "DATA_ENCRYPTION_METHOD", + "DATA_ENCRYPTION_METHOD", + "0=None, 1=XOR, 2=ChaCha20, 3-5=AES-GCM", + type = FieldType.OPTION, + options = listOf( + "0 - None", + "1 - XOR", + "2 - ChaCha20", + "3 - AES-128-GCM", + "4 - AES-192-GCM", + "5 - AES-256-GCM" + ) + ), + SettingField( + "Proxy", + "PROTOCOL_TYPE", + "PROTOCOL_TYPE", + "Client local proxy protocol", + type = FieldType.OPTION, + options = listOf("SOCKS5", "TCP") + ), + SettingField("Proxy", "LISTEN_IP", "LISTEN_IP", "Local bind IP"), + SettingField( + "Proxy", + "LISTEN_PORT", + "LISTEN_PORT", + "Local proxy port", + keyboardType = KeyboardType.Number + ), + SettingField("Proxy", "SOCKS5_AUTH", "SOCKS5_AUTH", "Enable SOCKS5 auth", type = FieldType.BOOL), + SettingField("Proxy", "SOCKS5_USER", "SOCKS5_USER", "SOCKS username"), + SettingField("Proxy", "SOCKS5_PASS", "SOCKS5_PASS", "SOCKS password"), + SettingField("DNS", "LOCAL_DNS_ENABLED", "LOCAL_DNS_ENABLED", "Enable local DNS mode", type = FieldType.BOOL), + SettingField("DNS", "LOCAL_DNS_IP", "LOCAL_DNS_IP", "Local DNS bind IP"), + SettingField("DNS", "LOCAL_DNS_PORT", "LOCAL_DNS_PORT", "Local DNS bind port", keyboardType = KeyboardType.Number), + SettingField("DNS", "LOCAL_DNS_CACHE_MAX_RECORDS", "LOCAL_DNS_CACHE_MAX_RECORDS", "Local DNS cache max records", keyboardType = KeyboardType.Number), + SettingField("DNS", "LOCAL_DNS_CACHE_TTL_SECONDS", "LOCAL_DNS_CACHE_TTL_SECONDS", "Local DNS cache TTL seconds", keyboardType = KeyboardType.Decimal), + SettingField("DNS", "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "Local DNS pending timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("DNS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "DNS response fragment timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("DNS", "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "LOCAL_DNS_CACHE_PERSIST_TO_FILE", "Persist local DNS cache to file", type = FieldType.BOOL), + SettingField("DNS", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "Local DNS cache flush interval seconds", keyboardType = KeyboardType.Decimal), + SettingField( + "Resolver", + "RESOLVER_BALANCING_STRATEGY", + "RESOLVER_BALANCING_STRATEGY", + "Resolver balancing strategy", + type = FieldType.OPTION, + options = listOf( + "1 - Random", + "2 - Round Robin", + "3 - Least Loss", + "4 - Lowest Latency", + "5 - Hybrid Score", + "6 - Loss Then Latency", + "7 - Least Loss Top Random", + "8 - Least Loss Top Round Robin" + ) + ), + SettingField("Resolver", "PACKET_DUPLICATION_COUNT", "PACKET_DUPLICATION_COUNT", "Runtime packet duplication count", keyboardType = KeyboardType.Number), + SettingField("Resolver", "SETUP_PACKET_DUPLICATION_COUNT", "SETUP_PACKET_DUPLICATION_COUNT", "Setup packet duplication count", keyboardType = KeyboardType.Number), + SettingField("Resolver", "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "Resends before stream failover", keyboardType = KeyboardType.Number), + SettingField("Resolver", "STREAM_RESOLVER_FAILOVER_COOLDOWN", "STREAM_RESOLVER_FAILOVER_COOLDOWN", "Failover cooldown seconds", keyboardType = KeyboardType.Decimal), + SettingField("Resolver", "RECHECK_INACTIVE_SERVERS_ENABLED", "RECHECK_INACTIVE_SERVERS_ENABLED", "Re-check inactive resolvers", type = FieldType.BOOL), + SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_SERVERS", "AUTO_DISABLE_TIMEOUT_SERVERS", "Auto disable timeout resolvers", type = FieldType.BOOL), + SettingField("Resolver", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "Timeout-only window seconds", keyboardType = KeyboardType.Decimal), + SettingField("Resolver", "BASE_ENCODE_DATA", "BASE_ENCODE_DATA", "Use base-encoded payloads", type = FieldType.BOOL), + SettingField("Compression", "UPLOAD_COMPRESSION_TYPE", "UPLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), + SettingField("Compression", "DOWNLOAD_COMPRESSION_TYPE", "DOWNLOAD_COMPRESSION_TYPE", "0=Off, 1=Snappy, 2=LZ4, 3=ZSTD, 4=Gzip, 5=Zlib", keyboardType = KeyboardType.Number), + SettingField("Compression", "COMPRESSION_MIN_SIZE", "COMPRESSION_MIN_SIZE", "Min bytes to trigger compression", keyboardType = KeyboardType.Number), + SettingField("MTU", "AUTO_REMOVE_LOW_MTU_SERVERS", "AUTO_REMOVE_LOW_MTU_SERVERS", "Auto remove low MTU servers", type = FieldType.BOOL), + SettingField("MTU", "MIN_UPLOAD_MTU", "MIN_UPLOAD_MTU", "Minimum upload MTU bytes", keyboardType = KeyboardType.Number), + SettingField("MTU", "MIN_DOWNLOAD_MTU", "MIN_DOWNLOAD_MTU", "Minimum download MTU bytes", keyboardType = KeyboardType.Number), + SettingField("MTU", "MAX_UPLOAD_MTU", "MAX_UPLOAD_MTU", "Maximum upload MTU bytes", keyboardType = KeyboardType.Number), + SettingField("MTU", "MAX_DOWNLOAD_MTU", "MAX_DOWNLOAD_MTU", "Maximum download MTU bytes", keyboardType = KeyboardType.Number), + SettingField("MTU", "MTU_TEST_RETRIES", "MTU_TEST_RETRIES", "Retries per MTU probe", keyboardType = KeyboardType.Number), + SettingField("MTU", "MTU_TEST_TIMEOUT", "MTU_TEST_TIMEOUT", "Probe timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("MTU", "MTU_TEST_PARALLELISM", "MTU_TEST_PARALLELISM", "Parallel probe workers", keyboardType = KeyboardType.Number), + SettingField("MTU", "SAVE_MTU_SERVERS_TO_FILE", "SAVE_MTU_SERVERS_TO_FILE", "Persist successful MTU resolvers to file", type = FieldType.BOOL), + SettingField("MTU", "MTU_SERVERS_FILE_NAME", "MTU_SERVERS_FILE_NAME", "Output file name/path (absolute path supported)"), + SettingField("MTU", "MTU_SERVERS_FILE_FORMAT", "MTU_SERVERS_FILE_FORMAT", "Format: {IP} {UP_MTU} {DOWN-MTU}"), + SettingField("MTU", "MTU_USING_SECTION_SEPARATOR_TEXT", "MTU_USING_SECTION_SEPARATOR_TEXT", "Optional separator text between sections"), + SettingField("MTU", "MTU_REMOVED_SERVER_LOG_FORMAT", "MTU_REMOVED_SERVER_LOG_FORMAT", "Log format when resolver is removed"), + SettingField("MTU", "MTU_ADDED_SERVER_LOG_FORMAT", "MTU_ADDED_SERVER_LOG_FORMAT", "Log format when resolver is re-added"), + SettingField("MTU", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "Log format when resolver is re-added after reactive recheck"), + SettingField("Runtime", "RX_TX_WORKERS", "RX_TX_WORKERS", "Combined RX/TX worker count", keyboardType = KeyboardType.Number), + SettingField("Runtime", "TUNNEL_PROCESS_WORKERS", "TUNNEL_PROCESS_WORKERS", "Processor worker count", keyboardType = KeyboardType.Number), + SettingField("Runtime", "TUNNEL_PACKET_TIMEOUT_SECONDS", "TUNNEL_PACKET_TIMEOUT_SECONDS", "Packet timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "RX_CHANNEL_SIZE", "RX_CHANNEL_SIZE", "RX channel size", keyboardType = KeyboardType.Number), + SettingField("Runtime", "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "Dispatcher idle poll interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "SOCKS UDP associate read timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "Terminal stream retention seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "Cancelled setup stream retention seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SESSION_INIT_RETRY_BASE_SECONDS", "SESSION_INIT_RETRY_BASE_SECONDS", "Session init retry base seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SESSION_INIT_RETRY_STEP_SECONDS", "SESSION_INIT_RETRY_STEP_SECONDS", "Session init retry step seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SESSION_INIT_RETRY_LINEAR_AFTER", "SESSION_INIT_RETRY_LINEAR_AFTER", "Session init retry linear-after", keyboardType = KeyboardType.Number), + SettingField("Runtime", "SESSION_INIT_RETRY_MAX_SECONDS", "SESSION_INIT_RETRY_MAX_SECONDS", "Session init retry max seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "Session busy retry interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "SESSION_INIT_RACING_COUNT", "SESSION_INIT_RACING_COUNT", "Session init racing count", keyboardType = KeyboardType.Number), + SettingField("Runtime", "PING_AGGRESSIVE_INTERVAL_SECONDS", "PING_AGGRESSIVE_INTERVAL_SECONDS", "Ping aggressive interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_LAZY_INTERVAL_SECONDS", "PING_LAZY_INTERVAL_SECONDS", "Ping lazy interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_COOLDOWN_INTERVAL_SECONDS", "PING_COOLDOWN_INTERVAL_SECONDS", "Ping cooldown interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_COLD_INTERVAL_SECONDS", "PING_COLD_INTERVAL_SECONDS", "Ping cold interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_WARM_THRESHOLD_SECONDS", "PING_WARM_THRESHOLD_SECONDS", "Ping warm threshold seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_COOL_THRESHOLD_SECONDS", "PING_COOL_THRESHOLD_SECONDS", "Ping cool threshold seconds", keyboardType = KeyboardType.Decimal), + SettingField("Runtime", "PING_COLD_THRESHOLD_SECONDS", "PING_COLD_THRESHOLD_SECONDS", "Ping cold threshold seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_WINDOW_SIZE", "ARQ_WINDOW_SIZE", "ARQ receive window size", keyboardType = KeyboardType.Number), + SettingField("ARQ", "MAX_PACKETS_PER_BATCH", "MAX_PACKETS_PER_BATCH", "Max packets per batch", keyboardType = KeyboardType.Number), + SettingField("ARQ", "ARQ_INITIAL_RTO_SECONDS", "ARQ_INITIAL_RTO_SECONDS", "Initial RTO seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_MAX_RTO_SECONDS", "ARQ_MAX_RTO_SECONDS", "Maximum RTO seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_CONTROL_INITIAL_RTO_SECONDS", "ARQ_CONTROL_INITIAL_RTO_SECONDS", "Control initial RTO seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_CONTROL_MAX_RTO_SECONDS", "ARQ_CONTROL_MAX_RTO_SECONDS", "Control packet max RTO seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_MAX_CONTROL_RETRIES", "ARQ_MAX_CONTROL_RETRIES", "Maximum retries per control packet", keyboardType = KeyboardType.Number), + SettingField("ARQ", "ARQ_MAX_DATA_RETRIES", "ARQ_MAX_DATA_RETRIES", "Maximum retries per data packet", keyboardType = KeyboardType.Number), + SettingField("ARQ", "ARQ_DATA_PACKET_TTL_SECONDS", "ARQ_DATA_PACKET_TTL_SECONDS", "ARQ data packet TTL seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ_CONTROL_PACKET_TTL_SECONDS", "ARQ control packet TTL seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_DATA_NACK_MAX_GAP", "ARQ_DATA_NACK_MAX_GAP", "ARQ data NACK max gap", keyboardType = KeyboardType.Number), + SettingField("ARQ", "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "Initial delay before first NACK seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_DATA_NACK_REPEAT_SECONDS", "ARQ_DATA_NACK_REPEAT_SECONDS", "NACK repeat interval seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_INACTIVITY_TIMEOUT_SECONDS", "ARQ_INACTIVITY_TIMEOUT_SECONDS", "Inactivity timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "ARQ terminal drain timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField("ARQ", "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "ARQ terminal ACK wait timeout seconds", keyboardType = KeyboardType.Decimal), + SettingField( + "Logging", + "LOG_LEVEL", + "LOG_LEVEL", + "Client log level", + type = FieldType.OPTION, + options = listOf("DEBUG", "INFO", "WARN", "ERROR") + ) +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel = hiltViewModel(), + onBack: (() -> Unit)? = null +) { + val context = LocalContext.current + val profile by viewModel.selectedProfile.collectAsState() + val fieldsState = remember { mutableStateMapOf() } + val snackbarHostState = remember { SnackbarHostState() } + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val sections = remember { configFields.groupBy { it.section } } + val sectionOrder = remember { configFields.map { it.section }.distinct() } + val sectionExpanded = remember { + mutableStateMapOf().apply { + sectionOrder.forEach { put(it, it == "Identity") } + } + } + + var pendingExportContent by remember { mutableStateOf(null) } + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/x-toml") + ) { uri -> + val content = pendingExportContent + if (uri != null && content != null) { + runCatching { + context.grantUriPermission( + context.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + context.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { + it.write(content) + } + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_toml_exported_msg)) } + } + pendingExportContent = null + } + val importTomlLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> if (uri != null) { - val text = readTextFromUri(context, uri) + val text = try { + readTextFromUri(context, uri, MAX_TOML_IMPORT_CHARS) + } catch (_: IOException) { + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.profiles_toml_too_large_msg)) } + return@rememberLauncherForActivityResult + } val updated = viewModel.importTomlValues(text, fieldsState.toMap()) fieldsState.clear() fieldsState.putAll(updated) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_toml_imported_msg)) } - } - } - - val importResolversLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_toml_imported_msg)) } + } + } + + val importResolversLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() ) { uri -> val selected = profile if (uri != null && selected != null) { - val text = readTextFromUri(context, uri) - val fileName = readDisplayName(context, uri) ?: "client_resolvers.txt" scope.launch { - val result = withContext(Dispatchers.Default) { - ResolverAnalyzer.analyzeAndNormalize(text, fileName) - } - if (result == null || result.normalizedText.isBlank()) { - snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_empty_msg)) - return@launch - } - viewModel.importResolvers(selected, result) - snackbarHostState.showSnackbar( - context.getString(R.string.settings_resolvers_imported_stats_msg, result.stats.summary()) - ) - } - } - } - val pickMtuExportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("text/plain") - ) { uri -> - if (uri != null) { - runCatching { - context.grantUriPermission( - context.packageName, - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - } - fieldsState["MTU_EXPORT_URI"] = uri.toString() - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_mtu_destination_msg)) } - } - } - - LaunchedEffect(profile?.id) { - fieldsState.clear() - profile?.let { fieldsState.putAll(defaultValuesFor(it)) } - } - - Scaffold( - containerColor = MdvColor.Background, - topBar = { - val topActions: @Composable RowScope.() -> Unit = { - val selected = profile - if (selected != null) { - IconButton( - onClick = { - viewModel.saveSettings(selected, fieldsState.toMap()) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_saved_msg)) } - } - ) { - Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save)) - } - } - } - if (onBack != null) { - MdvBackTopAppBar( - title = stringResource(R.string.profile_settings_title), - onBack = onBack, - actions = topActions - ) - } else { - MdvTopAppBar( - title = stringResource(R.string.profile_settings_title), - actions = topActions - ) - } - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) } - ) { padding -> - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - val maxContentWidth = when { - maxWidth >= 1200.dp -> 980.dp - maxWidth >= 840.dp -> 840.dp - else -> Dp.Unspecified - } - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - val selected = profile - if (selected == null) { - Column( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = maxContentWidth) - .padding(MdvSpace.S6), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.settings_no_profile_title), style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(MdvSpace.S2)) - Text( - stringResource(R.string.settings_no_profile_desc), - style = MaterialTheme.typography.bodyMedium, - color = MdvColor.OnSurfaceVariant - ) - } - return@Box - } - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = maxContentWidth), - contentPadding = PaddingValues(MdvSpace.S4), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S3), - state = listState - ) { - item { - Text( - text = stringResource(R.string.settings_editing_profile, selected.name), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MdvColor.OnSurface - ) - Spacer(modifier = Modifier.height(MdvSpace.S1)) - Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { - MdvPrimaryActionButton( - text = stringResource(R.string.action_import_toml), - onClick = { - runCatching { - importTomlLauncher.launch( - arrayOf( - "application/toml", - "text/x-toml", - "text/plain", - "application/octet-stream", - "*/*" - ) - ) - }.onFailure { err -> - scope.launch { - snackbarHostState.showSnackbar( - "Cannot open file picker: ${err.message ?: "unknown error"}" - ) - } - } - }, - icon = Icons.Filled.UploadFile - ) - MdvPrimaryActionButton( - text = stringResource(R.string.action_export_toml), - onClick = { - pendingExportContent = viewModel.exportConfigToml(selected, fieldsState.toMap()) - exportLauncher.launch("${selected.name}_client_config.toml") - }, - icon = Icons.Filled.Download - ) - } - Spacer(modifier = Modifier.height(MdvSpace.S1)) - MdvPrimaryActionButton( - text = stringResource(R.string.action_import_resolvers), - onClick = { importResolversLauncher.launch(arrayOf("text/*")) }, - icon = Icons.Filled.UploadFile - ) - Spacer(modifier = Modifier.height(MdvSpace.S1)) - MdvPrimaryActionButton( - text = stringResource(R.string.action_pick_mtu_destination), - onClick = { - val selectedName = selected.name.trim().ifBlank { "profile" } - pickMtuExportLauncher.launch("${selectedName}_mtu_results.log") - }, - icon = Icons.Filled.Download - ) - } - - val socksAuthEnabled = fieldsState["SOCKS5_AUTH"].equals("true", ignoreCase = true) - items(sectionOrder, key = { "section_$it" }) { section -> - val expanded = sectionExpanded[section] ?: false - MdvSectionCard( - title = section, - expanded = expanded, - onToggle = { sectionExpanded[section] = !expanded } - ) - if (!expanded) return@items - - Spacer(modifier = Modifier.height(MdvSpace.S1)) - if (section == "DNS") { - val dnsEnabled = fieldsState["LOCAL_DNS_ENABLED"].equals("true", ignoreCase = true) - val dnsPort = fieldsState["LOCAL_DNS_PORT"]?.toIntOrNull() ?: 53 - if (dnsEnabled && dnsPort <= 1024) { - Card( - colors = CardDefaults.cardColors( - containerColor = MdvColor.ErrorContainer - ), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.settings_dns_root_warning, dnsPort), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.Error - ) - } - } - Spacer(modifier = Modifier.height(MdvSpace.S2)) - } - } - sections[section].orEmpty().forEach { field -> - if ((field.key == "SOCKS5_USER" || field.key == "SOCKS5_PASS") && !socksAuthEnabled) { - return@forEach - } - ConfigFieldCard( - field = field, - value = fieldsState[field.key].orEmpty(), - onChange = { fieldsState[field.key] = it } - ) - Spacer(modifier = Modifier.height(MdvSpace.S2)) - } - } - - item { - Spacer(modifier = Modifier.height(MdvSpace.S2)) - MdvPrimaryActionButton( - text = stringResource(R.string.action_save_settings), - onClick = { - viewModel.saveSettings(selected, fieldsState.toMap()) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_saved_msg)) } - }, - modifier = Modifier.fillMaxWidth(), - icon = Icons.Filled.Save - ) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ConfigFieldCard( - field: SettingField, - value: String, - onChange: (String) -> Unit -) { - MdvSettingFieldCard { - Column { - when (field.type) { - FieldType.BOOL -> { - val checked = value.equals("true", ignoreCase = true) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(field.label, fontWeight = FontWeight.SemiBold) - Text( - field.helper, - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - } - Switch( - checked = checked, - onCheckedChange = { onChange(if (it) "true" else "false") } - ) + val text = try { + withContext(Dispatchers.IO) { + readTextFromUri(context, uri, MAX_RESOLVER_IMPORT_CHARS) } + } catch (_: IOException) { + snackbarHostState.showSnackbar(context.getString(R.string.profiles_resolvers_too_large_msg)) + return@launch } - - FieldType.OPTION -> { - var expanded by remember(field.key) { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } - ) { - OutlinedTextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(field.label) }, - supportingText = { Text(field.helper) }, - trailingIcon = { - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null - ) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - field.options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onChange(option.substringBefore(" - ").trim()) - expanded = false - } - ) - } - } - } + val fileName = readDisplayName(context, uri) ?: "client_resolvers.txt" + val result = withContext(Dispatchers.Default) { + ResolverAnalyzer.analyzeAndNormalize(text, fileName) } - - FieldType.TEXT -> { - OutlinedTextField( - value = value, - onValueChange = onChange, - label = { Text(field.label) }, - supportingText = { Text(field.helper) }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = field.keyboardType) - ) + if (result == null || result.normalizedText.isBlank()) { + snackbarHostState.showSnackbar(context.getString(R.string.settings_resolvers_empty_msg)) + return@launch + } + viewModel.importResolvers(selected, result) + snackbarHostState.showSnackbar( + context.getString(R.string.settings_resolvers_imported_stats_msg, result.stats.summary()) + ) + } + } + } + val pickMtuExportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/plain") + ) { uri -> + if (uri != null) { + runCatching { + context.grantUriPermission( + context.packageName, + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } + fieldsState["MTU_EXPORT_URI"] = uri.toString() + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_mtu_destination_msg)) } + } + } + + LaunchedEffect(profile?.id) { + fieldsState.clear() + profile?.let { fieldsState.putAll(defaultValuesFor(it)) } + } + + Scaffold( + containerColor = MdvColor.Background, + topBar = { + val topActions: @Composable RowScope.() -> Unit = { + val selected = profile + if (selected != null) { + IconButton( + onClick = { + viewModel.saveSettings(selected, fieldsState.toMap()) + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_saved_msg)) } + } + ) { + Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.action_save)) + } + } + } + if (onBack != null) { + MdvBackTopAppBar( + title = stringResource(R.string.profile_settings_title), + onBack = onBack, + actions = topActions + ) + } else { + MdvTopAppBar( + title = stringResource(R.string.profile_settings_title), + actions = topActions + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { padding -> + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + val maxContentWidth = when { + maxWidth >= 1200.dp -> 980.dp + maxWidth >= 840.dp -> 840.dp + else -> Dp.Unspecified + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + val selected = profile + if (selected == null) { + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = maxContentWidth) + .padding(MdvSpace.S6), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.settings_no_profile_title), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(MdvSpace.S2)) + Text( + stringResource(R.string.settings_no_profile_desc), + style = MaterialTheme.typography.bodyMedium, + color = MdvColor.OnSurfaceVariant + ) + } + return@Box + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = maxContentWidth), + contentPadding = PaddingValues(MdvSpace.S4), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S3), + state = listState + ) { + item { + Text( + text = stringResource(R.string.settings_editing_profile, selected.name), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MdvColor.OnSurface + ) + Spacer(modifier = Modifier.height(MdvSpace.S1)) + Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { + MdvPrimaryActionButton( + text = stringResource(R.string.action_import_toml), + onClick = { + runCatching { + importTomlLauncher.launch( + arrayOf( + "application/toml", + "text/x-toml", + "text/plain", + "application/octet-stream", + "*/*" + ) + ) + }.onFailure { err -> + scope.launch { + snackbarHostState.showSnackbar( + "Cannot open file picker: ${err.message ?: "unknown error"}" + ) + } + } + }, + icon = Icons.Filled.UploadFile + ) + MdvPrimaryActionButton( + text = stringResource(R.string.action_export_toml), + onClick = { + pendingExportContent = viewModel.exportConfigToml(selected, fieldsState.toMap()) + exportLauncher.launch("${selected.name}_client_config.toml") + }, + icon = Icons.Filled.Download + ) + } + Spacer(modifier = Modifier.height(MdvSpace.S1)) + MdvPrimaryActionButton( + text = stringResource(R.string.action_import_resolvers), + onClick = { importResolversLauncher.launch(arrayOf("text/*")) }, + icon = Icons.Filled.UploadFile + ) + Spacer(modifier = Modifier.height(MdvSpace.S1)) + MdvPrimaryActionButton( + text = stringResource(R.string.action_pick_mtu_destination), + onClick = { + val selectedName = selected.name.trim().ifBlank { "profile" } + pickMtuExportLauncher.launch("${selectedName}_mtu_results.log") + }, + icon = Icons.Filled.Download + ) + } + + val socksAuthEnabled = fieldsState["SOCKS5_AUTH"].equals("true", ignoreCase = true) + items(sectionOrder, key = { "section_$it" }) { section -> + val expanded = sectionExpanded[section] ?: false + MdvSectionCard( + title = section, + expanded = expanded, + onToggle = { sectionExpanded[section] = !expanded } + ) + if (!expanded) return@items + + Spacer(modifier = Modifier.height(MdvSpace.S1)) + if (section == "DNS") { + val dnsEnabled = fieldsState["LOCAL_DNS_ENABLED"].equals("true", ignoreCase = true) + val dnsPort = fieldsState["LOCAL_DNS_PORT"]?.toIntOrNull() ?: 53 + if (dnsEnabled && dnsPort <= 1024) { + Card( + colors = CardDefaults.cardColors( + containerColor = MdvColor.ErrorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.settings_dns_root_warning, dnsPort), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.Error + ) + } + } + Spacer(modifier = Modifier.height(MdvSpace.S2)) + } + } + sections[section].orEmpty().forEach { field -> + if ((field.key == "SOCKS5_USER" || field.key == "SOCKS5_PASS") && !socksAuthEnabled) { + return@forEach + } + ConfigFieldCard( + field = field, + value = fieldsState[field.key].orEmpty(), + onChange = { fieldsState[field.key] = it } + ) + Spacer(modifier = Modifier.height(MdvSpace.S2)) + } + } + + item { + Spacer(modifier = Modifier.height(MdvSpace.S2)) + MdvPrimaryActionButton( + text = stringResource(R.string.action_save_settings), + onClick = { + viewModel.saveSettings(selected, fieldsState.toMap()) + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.settings_saved_msg)) } + }, + modifier = Modifier.fillMaxWidth(), + icon = Icons.Filled.Save + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigFieldCard( + field: SettingField, + value: String, + onChange: (String) -> Unit +) { + MdvSettingFieldCard { + Column { + when (field.type) { + FieldType.BOOL -> { + val checked = value.equals("true", ignoreCase = true) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(field.label, fontWeight = FontWeight.SemiBold) + Text( + field.helper, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + Switch( + checked = checked, + onCheckedChange = { onChange(if (it) "true" else "false") } + ) + } + } + + FieldType.OPTION -> { + var expanded by remember(field.key) { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(field.label) }, + supportingText = { Text(field.helper) }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null + ) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + field.options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onChange(option.substringBefore(" - ").trim()) + expanded = false + } + ) + } + } + } + } + + FieldType.TEXT -> { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(field.label) }, + supportingText = { Text(field.helper) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = field.keyboardType) + ) + } + } + } + } +} + +private fun readTextFromUri(context: Context, uri: Uri, maxChars: Int): String { + return runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> + val buffer = CharArray(8192) + val out = StringBuilder() + while (true) { + val read = reader.read(buffer) + if (read == -1) break + if (out.length + read > maxChars) { + throw IOException("Import file is too large") } + out.append(buffer, 0, read) } - } - } -} - -private fun readTextFromUri(context: Context, uri: Uri): String { - return runCatching { - val stream = context.contentResolver.openInputStream(uri) - stream?.bufferedReader()?.use { it.readText() } ?: "" - }.getOrDefault("") -} - -private 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() } -} - -private fun writeTextToUri(context: Context, uri: Uri, content: String) { - runCatching { - context.contentResolver.openOutputStream(uri, "w")?.bufferedWriter()?.use { - it.write(content) - } - } -} - -private fun defaultValuesFor(profile: ProfileEntity): Map { - val advanced = parseAdvanced(profile.advancedJson) - fun adv(key: String, fallback: String): String { - return advanced[key]?.trim().takeUnless { it.isNullOrEmpty() } ?: fallback - } - - return buildMap { - put("DOMAINS", parseDomains(profile.domains).joinToString(", ")) - put("DATA_ENCRYPTION_METHOD", profile.encryptionMethod.toString()) - put("ENCRYPTION_KEY", profile.encryptionKey) - put("PROTOCOL_TYPE", profile.protocolType) - put("LISTEN_IP", adv("LISTEN_IP", "127.0.0.1")) - put("LISTEN_PORT", profile.listenPort.toString()) - put("SOCKS5_AUTH", adv("SOCKS5_AUTH", "false")) - put("SOCKS5_USER", adv("SOCKS5_USER", "master_dns_vpn")) - put("SOCKS5_PASS", adv("SOCKS5_PASS", "master_dns_vpn")) - put("LOCAL_DNS_ENABLED", adv("LOCAL_DNS_ENABLED", "false")) - put("LOCAL_DNS_IP", adv("LOCAL_DNS_IP", "127.0.0.1")) - put("LOCAL_DNS_PORT", adv("LOCAL_DNS_PORT", "5353")) - put("LOCAL_DNS_CACHE_MAX_RECORDS", adv("LOCAL_DNS_CACHE_MAX_RECORDS", "10000")) - put("LOCAL_DNS_CACHE_TTL_SECONDS", adv("LOCAL_DNS_CACHE_TTL_SECONDS", "14400.0")) - put("LOCAL_DNS_PENDING_TIMEOUT_SECONDS", adv("LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "300.0")) - put("DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", adv("DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "60.0")) - put("LOCAL_DNS_CACHE_PERSIST_TO_FILE", adv("LOCAL_DNS_CACHE_PERSIST_TO_FILE", "true")) - put("LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", adv("LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "60.0")) - put("RESOLVER_BALANCING_STRATEGY", profile.resolverBalancingStrategy.takeIf { it != 0 }?.toString() ?: "3") - put("PACKET_DUPLICATION_COUNT", profile.packetDuplicationCount.toString()) - put("SETUP_PACKET_DUPLICATION_COUNT", profile.setupPacketDuplicationCount.toString()) - put("STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", adv("STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "2")) - put("STREAM_RESOLVER_FAILOVER_COOLDOWN", adv("STREAM_RESOLVER_FAILOVER_COOLDOWN", "2.5")) - put("RECHECK_INACTIVE_SERVERS_ENABLED", adv("RECHECK_INACTIVE_SERVERS_ENABLED", "true")) - put("AUTO_DISABLE_TIMEOUT_SERVERS", adv("AUTO_DISABLE_TIMEOUT_SERVERS", "true")) - put("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", adv("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "30.0")) - put("BASE_ENCODE_DATA", adv("BASE_ENCODE_DATA", "false")) - put("UPLOAD_COMPRESSION_TYPE", profile.uploadCompression.toString()) - put("DOWNLOAD_COMPRESSION_TYPE", profile.downloadCompression.toString()) - put("COMPRESSION_MIN_SIZE", adv("COMPRESSION_MIN_SIZE", "120")) - put("AUTO_REMOVE_LOW_MTU_SERVERS", adv("AUTO_REMOVE_LOW_MTU_SERVERS", "true")) - put("MIN_UPLOAD_MTU", adv("MIN_UPLOAD_MTU", "38")) - put("MIN_DOWNLOAD_MTU", adv("MIN_DOWNLOAD_MTU", "200")) - put("MAX_UPLOAD_MTU", adv("MAX_UPLOAD_MTU", "150")) - put("MAX_DOWNLOAD_MTU", adv("MAX_DOWNLOAD_MTU", "4000")) - put("MTU_TEST_RETRIES", adv("MTU_TEST_RETRIES", "2")) - put("MTU_TEST_TIMEOUT", adv("MTU_TEST_TIMEOUT", "2.0")) - put("MTU_TEST_PARALLELISM", adv("MTU_TEST_PARALLELISM", "32")) - put("SAVE_MTU_SERVERS_TO_FILE", adv("SAVE_MTU_SERVERS_TO_FILE", "false")) - put("MTU_SERVERS_FILE_NAME", adv("MTU_SERVERS_FILE_NAME", "masterdnsvpn_success_test_{time}.log")) - put("MTU_SERVERS_FILE_FORMAT", adv("MTU_SERVERS_FILE_FORMAT", "{IP} ({DOMAIN}) - UP: {UP_MTU} DOWN: {DOWN-MTU}")) - put("MTU_USING_SECTION_SEPARATOR_TEXT", adv("MTU_USING_SECTION_SEPARATOR_TEXT", "")) - put("MTU_REMOVED_SERVER_LOG_FORMAT", adv("MTU_REMOVED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) removed at {TIME} due to {CAUSE}")) - put("MTU_ADDED_SERVER_LOG_FORMAT", adv("MTU_ADDED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) added back at {TIME} (UP {UP_MTU}, DOWN {DOWN_MTU})")) - put("MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", adv("MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) added back at {TIME} after reactive recheck (UP {UP_MTU}, DOWN {DOWN_MTU})")) - put("MTU_EXPORT_URI", adv("MTU_EXPORT_URI", "")) - put("RX_TX_WORKERS", adv("RX_TX_WORKERS", "4")) - put("TUNNEL_PROCESS_WORKERS", adv("TUNNEL_PROCESS_WORKERS", "6")) - put("TUNNEL_PACKET_TIMEOUT_SECONDS", adv("TUNNEL_PACKET_TIMEOUT_SECONDS", "10.0")) - put("DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", adv("DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "0.020")) - put("RX_CHANNEL_SIZE", adv("RX_CHANNEL_SIZE", "4096")) - put("SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", adv("SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "30.0")) - put("CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", adv("CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "45.0")) - put("CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", adv("CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "120.0")) - put("SESSION_INIT_RETRY_BASE_SECONDS", adv("SESSION_INIT_RETRY_BASE_SECONDS", "1.0")) - put("SESSION_INIT_RETRY_STEP_SECONDS", adv("SESSION_INIT_RETRY_STEP_SECONDS", "1.0")) - put("SESSION_INIT_RETRY_LINEAR_AFTER", adv("SESSION_INIT_RETRY_LINEAR_AFTER", "5")) - put("SESSION_INIT_RETRY_MAX_SECONDS", adv("SESSION_INIT_RETRY_MAX_SECONDS", "60.0")) - put("SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", adv("SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "60.0")) - put("SESSION_INIT_RACING_COUNT", adv("SESSION_INIT_RACING_COUNT", "3")) - put("PING_AGGRESSIVE_INTERVAL_SECONDS", adv("PING_AGGRESSIVE_INTERVAL_SECONDS", "0.100")) - put("PING_LAZY_INTERVAL_SECONDS", adv("PING_LAZY_INTERVAL_SECONDS", "0.750")) - put("PING_COOLDOWN_INTERVAL_SECONDS", adv("PING_COOLDOWN_INTERVAL_SECONDS", "2.0")) - put("PING_COLD_INTERVAL_SECONDS", adv("PING_COLD_INTERVAL_SECONDS", "15.0")) - put("PING_WARM_THRESHOLD_SECONDS", adv("PING_WARM_THRESHOLD_SECONDS", "8.0")) - put("PING_COOL_THRESHOLD_SECONDS", adv("PING_COOL_THRESHOLD_SECONDS", "20.0")) - put("PING_COLD_THRESHOLD_SECONDS", adv("PING_COLD_THRESHOLD_SECONDS", "30.0")) - put("ARQ_WINDOW_SIZE", adv("ARQ_WINDOW_SIZE", "1000")) - put("MAX_PACKETS_PER_BATCH", adv("MAX_PACKETS_PER_BATCH", "8")) - put("ARQ_INITIAL_RTO_SECONDS", adv("ARQ_INITIAL_RTO_SECONDS", "0.5")) - put("ARQ_MAX_RTO_SECONDS", adv("ARQ_MAX_RTO_SECONDS", "3.0")) - put("ARQ_CONTROL_INITIAL_RTO_SECONDS", adv("ARQ_CONTROL_INITIAL_RTO_SECONDS", "0.5")) - put("ARQ_CONTROL_MAX_RTO_SECONDS", adv("ARQ_CONTROL_MAX_RTO_SECONDS", "2.0")) - put("ARQ_MAX_CONTROL_RETRIES", adv("ARQ_MAX_CONTROL_RETRIES", "126")) - put("ARQ_MAX_DATA_RETRIES", adv("ARQ_MAX_DATA_RETRIES", "126")) - put("ARQ_DATA_PACKET_TTL_SECONDS", adv("ARQ_DATA_PACKET_TTL_SECONDS", "2400.0")) - put("ARQ_CONTROL_PACKET_TTL_SECONDS", adv("ARQ_CONTROL_PACKET_TTL_SECONDS", "1200.0")) - put("ARQ_DATA_NACK_MAX_GAP", adv("ARQ_DATA_NACK_MAX_GAP", "32")) - put("ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", adv("ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "0.1")) - put("ARQ_DATA_NACK_REPEAT_SECONDS", adv("ARQ_DATA_NACK_REPEAT_SECONDS", "0.8")) - put("ARQ_INACTIVITY_TIMEOUT_SECONDS", adv("ARQ_INACTIVITY_TIMEOUT_SECONDS", "1800.0")) - put("ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", adv("ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "120.0")) - put("ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", adv("ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "90.0")) - put("LOG_LEVEL", profile.logLevel) - } -} - -private fun parseAdvanced(json: String): Map { - return try { - val type = object : TypeToken>() {}.type - Gson().fromJson>(json, type) ?: emptyMap() - } catch (_: Exception) { - emptyMap() - } -} - -private fun parseDomains(json: String): List { - return try { - val type = object : TypeToken>() {}.type - Gson().fromJson>(json, type) ?: emptyList() - } catch (_: Exception) { - listOf(json.trim().removeSurrounding("\"")).filter { it.isNotBlank() } + out.toString() + } ?: "" + }.getOrElse { error -> + if (error is IOException) throw error + "" } } + +private 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() } +} + +private fun writeTextToUri(context: Context, uri: Uri, content: String) { + runCatching { + context.contentResolver.openOutputStream(uri, "w")?.bufferedWriter()?.use { + it.write(content) + } + } +} + +private fun defaultValuesFor(profile: ProfileEntity): Map { + val advanced = parseAdvanced(profile.advancedJson) + fun adv(key: String, fallback: String): String { + return advanced[key]?.trim().takeUnless { it.isNullOrEmpty() } ?: fallback + } + + return buildMap { + put("DOMAINS", parseDomains(profile.domains).joinToString(", ")) + put("DATA_ENCRYPTION_METHOD", profile.encryptionMethod.toString()) + put("ENCRYPTION_KEY", profile.encryptionKey) + put("PROTOCOL_TYPE", profile.protocolType) + put("LISTEN_IP", adv("LISTEN_IP", "127.0.0.1")) + put("LISTEN_PORT", profile.listenPort.toString()) + put("SOCKS5_AUTH", adv("SOCKS5_AUTH", "false")) + put("SOCKS5_USER", adv("SOCKS5_USER", "master_dns_vpn")) + put("SOCKS5_PASS", adv("SOCKS5_PASS", "master_dns_vpn")) + put("LOCAL_DNS_ENABLED", adv("LOCAL_DNS_ENABLED", "false")) + put("LOCAL_DNS_IP", adv("LOCAL_DNS_IP", "127.0.0.1")) + put("LOCAL_DNS_PORT", adv("LOCAL_DNS_PORT", "5353")) + put("LOCAL_DNS_CACHE_MAX_RECORDS", adv("LOCAL_DNS_CACHE_MAX_RECORDS", "10000")) + put("LOCAL_DNS_CACHE_TTL_SECONDS", adv("LOCAL_DNS_CACHE_TTL_SECONDS", "14400.0")) + put("LOCAL_DNS_PENDING_TIMEOUT_SECONDS", adv("LOCAL_DNS_PENDING_TIMEOUT_SECONDS", "300.0")) + put("DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", adv("DNS_RESPONSE_FRAGMENT_TIMEOUT_SECONDS", "60.0")) + put("LOCAL_DNS_CACHE_PERSIST_TO_FILE", adv("LOCAL_DNS_CACHE_PERSIST_TO_FILE", "true")) + put("LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", adv("LOCAL_DNS_CACHE_FLUSH_INTERVAL_SECONDS", "60.0")) + put("RESOLVER_BALANCING_STRATEGY", profile.resolverBalancingStrategy.takeIf { it != 0 }?.toString() ?: "3") + put("PACKET_DUPLICATION_COUNT", profile.packetDuplicationCount.toString()) + put("SETUP_PACKET_DUPLICATION_COUNT", profile.setupPacketDuplicationCount.toString()) + put("STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", adv("STREAM_RESOLVER_FAILOVER_RESEND_THRESHOLD", "2")) + put("STREAM_RESOLVER_FAILOVER_COOLDOWN", adv("STREAM_RESOLVER_FAILOVER_COOLDOWN", "2.5")) + put("RECHECK_INACTIVE_SERVERS_ENABLED", adv("RECHECK_INACTIVE_SERVERS_ENABLED", "true")) + put("AUTO_DISABLE_TIMEOUT_SERVERS", adv("AUTO_DISABLE_TIMEOUT_SERVERS", "true")) + put("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", adv("AUTO_DISABLE_TIMEOUT_WINDOW_SECONDS", "30.0")) + put("BASE_ENCODE_DATA", adv("BASE_ENCODE_DATA", "false")) + put("UPLOAD_COMPRESSION_TYPE", profile.uploadCompression.toString()) + put("DOWNLOAD_COMPRESSION_TYPE", profile.downloadCompression.toString()) + put("COMPRESSION_MIN_SIZE", adv("COMPRESSION_MIN_SIZE", "120")) + put("AUTO_REMOVE_LOW_MTU_SERVERS", adv("AUTO_REMOVE_LOW_MTU_SERVERS", "true")) + put("MIN_UPLOAD_MTU", adv("MIN_UPLOAD_MTU", "38")) + put("MIN_DOWNLOAD_MTU", adv("MIN_DOWNLOAD_MTU", "200")) + put("MAX_UPLOAD_MTU", adv("MAX_UPLOAD_MTU", "150")) + put("MAX_DOWNLOAD_MTU", adv("MAX_DOWNLOAD_MTU", "4000")) + put("MTU_TEST_RETRIES", adv("MTU_TEST_RETRIES", "2")) + put("MTU_TEST_TIMEOUT", adv("MTU_TEST_TIMEOUT", "2.0")) + put("MTU_TEST_PARALLELISM", adv("MTU_TEST_PARALLELISM", "32")) + put("SAVE_MTU_SERVERS_TO_FILE", adv("SAVE_MTU_SERVERS_TO_FILE", "false")) + put("MTU_SERVERS_FILE_NAME", adv("MTU_SERVERS_FILE_NAME", "masterdnsvpn_success_test_{time}.log")) + put("MTU_SERVERS_FILE_FORMAT", adv("MTU_SERVERS_FILE_FORMAT", "{IP} ({DOMAIN}) - UP: {UP_MTU} DOWN: {DOWN-MTU}")) + put("MTU_USING_SECTION_SEPARATOR_TEXT", adv("MTU_USING_SECTION_SEPARATOR_TEXT", "")) + put("MTU_REMOVED_SERVER_LOG_FORMAT", adv("MTU_REMOVED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) removed at {TIME} due to {CAUSE}")) + put("MTU_ADDED_SERVER_LOG_FORMAT", adv("MTU_ADDED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) added back at {TIME} (UP {UP_MTU}, DOWN {DOWN_MTU})")) + put("MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", adv("MTU_REACTIVE_ADDED_SERVER_LOG_FORMAT", "Resolver {IP} ({DOMAIN}) added back at {TIME} after reactive recheck (UP {UP_MTU}, DOWN {DOWN_MTU})")) + put("MTU_EXPORT_URI", adv("MTU_EXPORT_URI", "")) + put("RX_TX_WORKERS", adv("RX_TX_WORKERS", "4")) + put("TUNNEL_PROCESS_WORKERS", adv("TUNNEL_PROCESS_WORKERS", "6")) + put("TUNNEL_PACKET_TIMEOUT_SECONDS", adv("TUNNEL_PACKET_TIMEOUT_SECONDS", "10.0")) + put("DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", adv("DISPATCHER_IDLE_POLL_INTERVAL_SECONDS", "0.020")) + put("RX_CHANNEL_SIZE", adv("RX_CHANNEL_SIZE", "4096")) + put("SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", adv("SOCKS_UDP_ASSOCIATE_READ_TIMEOUT_SECONDS", "30.0")) + put("CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", adv("CLIENT_TERMINAL_STREAM_RETENTION_SECONDS", "45.0")) + put("CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", adv("CLIENT_CANCELLED_SETUP_RETENTION_SECONDS", "120.0")) + put("SESSION_INIT_RETRY_BASE_SECONDS", adv("SESSION_INIT_RETRY_BASE_SECONDS", "1.0")) + put("SESSION_INIT_RETRY_STEP_SECONDS", adv("SESSION_INIT_RETRY_STEP_SECONDS", "1.0")) + put("SESSION_INIT_RETRY_LINEAR_AFTER", adv("SESSION_INIT_RETRY_LINEAR_AFTER", "5")) + put("SESSION_INIT_RETRY_MAX_SECONDS", adv("SESSION_INIT_RETRY_MAX_SECONDS", "60.0")) + put("SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", adv("SESSION_INIT_BUSY_RETRY_INTERVAL_SECONDS", "60.0")) + put("SESSION_INIT_RACING_COUNT", adv("SESSION_INIT_RACING_COUNT", "3")) + put("PING_AGGRESSIVE_INTERVAL_SECONDS", adv("PING_AGGRESSIVE_INTERVAL_SECONDS", "0.100")) + put("PING_LAZY_INTERVAL_SECONDS", adv("PING_LAZY_INTERVAL_SECONDS", "0.750")) + put("PING_COOLDOWN_INTERVAL_SECONDS", adv("PING_COOLDOWN_INTERVAL_SECONDS", "2.0")) + put("PING_COLD_INTERVAL_SECONDS", adv("PING_COLD_INTERVAL_SECONDS", "15.0")) + put("PING_WARM_THRESHOLD_SECONDS", adv("PING_WARM_THRESHOLD_SECONDS", "8.0")) + put("PING_COOL_THRESHOLD_SECONDS", adv("PING_COOL_THRESHOLD_SECONDS", "20.0")) + put("PING_COLD_THRESHOLD_SECONDS", adv("PING_COLD_THRESHOLD_SECONDS", "30.0")) + put("ARQ_WINDOW_SIZE", adv("ARQ_WINDOW_SIZE", "1000")) + put("MAX_PACKETS_PER_BATCH", adv("MAX_PACKETS_PER_BATCH", "8")) + put("ARQ_INITIAL_RTO_SECONDS", adv("ARQ_INITIAL_RTO_SECONDS", "0.5")) + put("ARQ_MAX_RTO_SECONDS", adv("ARQ_MAX_RTO_SECONDS", "3.0")) + put("ARQ_CONTROL_INITIAL_RTO_SECONDS", adv("ARQ_CONTROL_INITIAL_RTO_SECONDS", "0.5")) + put("ARQ_CONTROL_MAX_RTO_SECONDS", adv("ARQ_CONTROL_MAX_RTO_SECONDS", "2.0")) + put("ARQ_MAX_CONTROL_RETRIES", adv("ARQ_MAX_CONTROL_RETRIES", "126")) + put("ARQ_MAX_DATA_RETRIES", adv("ARQ_MAX_DATA_RETRIES", "126")) + put("ARQ_DATA_PACKET_TTL_SECONDS", adv("ARQ_DATA_PACKET_TTL_SECONDS", "2400.0")) + put("ARQ_CONTROL_PACKET_TTL_SECONDS", adv("ARQ_CONTROL_PACKET_TTL_SECONDS", "1200.0")) + put("ARQ_DATA_NACK_MAX_GAP", adv("ARQ_DATA_NACK_MAX_GAP", "32")) + put("ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", adv("ARQ_DATA_NACK_INITIAL_DELAY_SECONDS", "0.1")) + put("ARQ_DATA_NACK_REPEAT_SECONDS", adv("ARQ_DATA_NACK_REPEAT_SECONDS", "0.8")) + put("ARQ_INACTIVITY_TIMEOUT_SECONDS", adv("ARQ_INACTIVITY_TIMEOUT_SECONDS", "1800.0")) + put("ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", adv("ARQ_TERMINAL_DRAIN_TIMEOUT_SECONDS", "120.0")) + put("ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", adv("ARQ_TERMINAL_ACK_WAIT_TIMEOUT_SECONDS", "90.0")) + put("LOG_LEVEL", profile.logLevel) + } +} + +private fun parseAdvanced(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + Gson().fromJson>(json, type) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } +} + +private fun parseDomains(json: String): List { + return try { + val type = object : TypeToken>() {}.type + Gson().fromJson>(json, type) ?: emptyList() + } catch (_: Exception) { + listOf(json.trim().removeSurrounding("\"")).filter { it.isNotBlank() } + } +} 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 a7b8a7a..32c9d41 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 @@ -1,317 +1,324 @@ -package com.masterdns.vpn.ui.settings - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import com.google.gson.Gson -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.ResolverAnalyzer -import com.masterdns.vpn.util.ResolverImportResult -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +package com.masterdns.vpn.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +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.ResolverAnalyzer +import com.masterdns.vpn.util.ResolverImportResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val MAX_DOMAIN_LENGTH = 253 +private const val MAX_ENCRYPTION_KEY_LENGTH = 4096 +private const val MAX_ADVANCED_VALUE_LENGTH = 4096 + @HiltViewModel class SettingsViewModel @Inject constructor( - private val profileRepository: ProfileRepository, - savedStateHandle: SavedStateHandle -) : ViewModel() { - - private val gson = Gson() - - private val profileIdArg: Long? = savedStateHandle.get("profileId")?.toLongOrNull() - - val selectedProfile: StateFlow = ( - if (profileIdArg != null) profileRepository.getProfileByIdFlow(profileIdArg) - else profileRepository.getSelectedProfileFlow() - ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - - fun saveSettings(profile: ProfileEntity, values: Map) { - viewModelScope.launch { - profileRepository.updateProfile(buildUpdatedProfile(profile, values)) - } - } - - fun exportConfigToml(profile: ProfileEntity, values: Map): String { - val updated = buildUpdatedProfile(profile, values) - return ConfigGenerator.generateConfig( - profile = updated, - listenPort = updated.listenPort - ) - } - - fun importTomlValues( - tomlContent: String, - currentValues: Map - ): Map { - val result = currentValues.toMutableMap() - 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 !in TOML_IMPORT_KEYS) return@forEach - + private val profileRepository: ProfileRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val gson = Gson() + + private val profileIdArg: Long? = savedStateHandle.get("profileId")?.toLongOrNull() + + val selectedProfile: StateFlow = ( + if (profileIdArg != null) profileRepository.getProfileByIdFlow(profileIdArg) + else profileRepository.getSelectedProfileFlow() + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun saveSettings(profile: ProfileEntity, values: Map) { + viewModelScope.launch { + profileRepository.updateProfile(buildUpdatedProfile(profile, values)) + } + } + + fun exportConfigToml(profile: ProfileEntity, values: Map): String { + val updated = buildUpdatedProfile(profile, values) + return ConfigGenerator.generateConfig( + profile = updated, + listenPort = updated.listenPort + ) + } + + fun importTomlValues( + tomlContent: String, + currentValues: Map + ): Map { + val result = currentValues.toMutableMap() + 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 !in TOML_IMPORT_KEYS) return@forEach + val parsed = when { key == "DOMAINS" -> valueRaw .removePrefix("[") .removeSuffix("]") .split(",") .map { it.trim().removeSurrounding("\"") } - .filter { it.isNotBlank() } + .filter { it.isNotBlank() && it.length <= MAX_DOMAIN_LENGTH } .joinToString(", ") valueRaw.startsWith("\"") && valueRaw.endsWith("\"") -> valueRaw.removeSurrounding("\"") else -> valueRaw } + if (parsed.isBlank()) return@forEach + if (key == "ENCRYPTION_KEY" && parsed.length > MAX_ENCRYPTION_KEY_LENGTH) return@forEach + if (key in ADVANCED_SETTING_KEYS && parsed.length > MAX_ADVANCED_VALUE_LENGTH) return@forEach result[key] = parsed } return result - } - - fun importResolvers(profile: ProfileEntity, result: ResolverImportResult) { - viewModelScope.launch { - val updated = profile.copy(resolvers = result.normalizedText.trim()) - profileRepository.updateProfile(ResolverAnalyzer.withImportStats(updated, result.stats)) - } - } - - private fun parseAdvanced(json: String): Map { - return try { - val type = object : TypeToken>() {}.type - gson.fromJson>(json, type) ?: emptyMap() - } catch (_: Exception) { - emptyMap() - } - } - - private fun normalizeProtocol(value: String?, fallback: String): String { - val normalized = value?.trim()?.uppercase() - return when (normalized) { - "SOCKS5", "TCP" -> normalized - else -> fallback - } - } - - private fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int { - val parsed = value?.trim()?.toIntOrNull() - if (parsed != null && parsed in 1..8) return parsed - if (parsed == 0) return 2 - return if (fallback in 1..8) fallback else 2 - } - - private fun buildUpdatedProfile(profile: ProfileEntity, values: Map): ProfileEntity { - val mergedAdvanced = parseAdvanced(profile.advancedJson).toMutableMap() - values.forEach { (key, value) -> - if (key in ADVANCED_SETTING_KEYS) { - mergedAdvanced[key] = value.trim() - } - } - - return profile.copy( - domains = domainsToJson(values["DOMAINS"], profile.domains), - encryptionMethod = values["DATA_ENCRYPTION_METHOD"]?.toIntOrNull() - ?: profile.encryptionMethod, - encryptionKey = values["ENCRYPTION_KEY"] ?: profile.encryptionKey, - protocolType = normalizeProtocol(values["PROTOCOL_TYPE"], profile.protocolType), - listenPort = values["LISTEN_PORT"]?.toIntOrNull() - ?.coerceIn(1, 65535) ?: profile.listenPort, - resolverBalancingStrategy = normalizeResolverBalancingStrategy( - values["RESOLVER_BALANCING_STRATEGY"], - profile.resolverBalancingStrategy - ), - packetDuplicationCount = values["PACKET_DUPLICATION_COUNT"]?.toIntOrNull() - ?: profile.packetDuplicationCount, - setupPacketDuplicationCount = values["SETUP_PACKET_DUPLICATION_COUNT"]?.toIntOrNull() - ?: profile.setupPacketDuplicationCount, - uploadCompression = values["UPLOAD_COMPRESSION_TYPE"]?.toIntOrNull() - ?: profile.uploadCompression, - downloadCompression = values["DOWNLOAD_COMPRESSION_TYPE"]?.toIntOrNull() - ?: profile.downloadCompression, - logLevel = values["LOG_LEVEL"]?.trim().takeUnless { it.isNullOrBlank() } - ?: profile.logLevel, - advancedJson = gson.toJson(mergedAdvanced) - ) - } - - private fun domainsToJson(value: String?, fallbackJson: String): String { - val domains = value - ?.split(",") - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - - return if (domains.isEmpty()) fallbackJson else gson.toJson(domains) - } - - companion object { - val TOML_IMPORT_KEYS = setOf( - "DOMAINS", - "DATA_ENCRYPTION_METHOD", - "ENCRYPTION_KEY", - "PROTOCOL_TYPE", - "LISTEN_IP", - "LISTEN_PORT", - "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", - "RESOLVER_BALANCING_STRATEGY", - "PACKET_DUPLICATION_COUNT", - "SETUP_PACKET_DUPLICATION_COUNT", - "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", - "UPLOAD_COMPRESSION_TYPE", - "DOWNLOAD_COMPRESSION_TYPE", - "COMPRESSION_MIN_SIZE", - "AUTO_REMOVE_LOW_MTU_SERVERS", - "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", - "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", - "LOG_LEVEL" - ) - - val ADVANCED_SETTING_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", - "AUTO_REMOVE_LOW_MTU_SERVERS", - "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", - "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" - ) - } -} + } + + fun importResolvers(profile: ProfileEntity, result: ResolverImportResult) { + viewModelScope.launch { + val updated = profile.copy(resolvers = result.normalizedText.trim()) + profileRepository.updateProfile(ResolverAnalyzer.withImportStats(updated, result.stats)) + } + } + + private fun parseAdvanced(json: String): Map { + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + } + + private fun normalizeProtocol(value: String?, fallback: String): String { + val normalized = value?.trim()?.uppercase() + return when (normalized) { + "SOCKS5", "TCP" -> normalized + else -> fallback + } + } + + private fun normalizeResolverBalancingStrategy(value: String?, fallback: Int): Int { + val parsed = value?.trim()?.toIntOrNull() + if (parsed != null && parsed in 1..8) return parsed + if (parsed == 0) return 2 + return if (fallback in 1..8) fallback else 2 + } + + private fun buildUpdatedProfile(profile: ProfileEntity, values: Map): ProfileEntity { + val mergedAdvanced = parseAdvanced(profile.advancedJson).toMutableMap() + values.forEach { (key, value) -> + if (key in ADVANCED_SETTING_KEYS) { + mergedAdvanced[key] = value.trim() + } + } + + return profile.copy( + domains = domainsToJson(values["DOMAINS"], profile.domains), + encryptionMethod = values["DATA_ENCRYPTION_METHOD"]?.toIntOrNull() + ?: profile.encryptionMethod, + encryptionKey = values["ENCRYPTION_KEY"] ?: profile.encryptionKey, + protocolType = normalizeProtocol(values["PROTOCOL_TYPE"], profile.protocolType), + listenPort = values["LISTEN_PORT"]?.toIntOrNull() + ?.coerceIn(1, 65535) ?: profile.listenPort, + resolverBalancingStrategy = normalizeResolverBalancingStrategy( + values["RESOLVER_BALANCING_STRATEGY"], + profile.resolverBalancingStrategy + ), + packetDuplicationCount = values["PACKET_DUPLICATION_COUNT"]?.toIntOrNull() + ?: profile.packetDuplicationCount, + setupPacketDuplicationCount = values["SETUP_PACKET_DUPLICATION_COUNT"]?.toIntOrNull() + ?: profile.setupPacketDuplicationCount, + uploadCompression = values["UPLOAD_COMPRESSION_TYPE"]?.toIntOrNull() + ?: profile.uploadCompression, + downloadCompression = values["DOWNLOAD_COMPRESSION_TYPE"]?.toIntOrNull() + ?: profile.downloadCompression, + logLevel = values["LOG_LEVEL"]?.trim().takeUnless { it.isNullOrBlank() } + ?: profile.logLevel, + advancedJson = gson.toJson(mergedAdvanced) + ) + } + + private fun domainsToJson(value: String?, fallbackJson: String): String { + val domains = value + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + return if (domains.isEmpty()) fallbackJson else gson.toJson(domains) + } + + companion object { + val TOML_IMPORT_KEYS = setOf( + "DOMAINS", + "DATA_ENCRYPTION_METHOD", + "ENCRYPTION_KEY", + "PROTOCOL_TYPE", + "LISTEN_IP", + "LISTEN_PORT", + "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", + "RESOLVER_BALANCING_STRATEGY", + "PACKET_DUPLICATION_COUNT", + "SETUP_PACKET_DUPLICATION_COUNT", + "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", + "UPLOAD_COMPRESSION_TYPE", + "DOWNLOAD_COMPRESSION_TYPE", + "COMPRESSION_MIN_SIZE", + "AUTO_REMOVE_LOW_MTU_SERVERS", + "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", + "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", + "LOG_LEVEL" + ) + + val ADVANCED_SETTING_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", + "AUTO_REMOVE_LOW_MTU_SERVERS", + "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", + "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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bdb4d26..b001537 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,169 +1,171 @@ - - MDV-HN Edition - Settings - Profile Settings - Save - Back - Hide - Show - Import TOML - Export TOML - Import client_resolvers.txt - Pick MTU export destination - Save Settings - No selected profile - Create/select a profile in Profiles tab, then configure client_config values here. - Editing profile: %1$s - Profile settings saved and applied - TOML exported - TOML imported to form - Resolvers imported into profile - Resolvers imported: %1$s - No usable resolvers found in that file - 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 - Please enter both SOCKS5 and HTTP ports. - Ports must be between 1025 and 65535. - Invalid custom DNS server: %1$s - Internet sharing requires both username and password. - Save Global Settings - Split Tunnel Apps - %1$d selected apps - tap to choose - Select Split-Tunnel Apps - Choose apps that should use VPN tunnel - Selected %1$d - Available %1$d - Search selected apps - Search available apps - Select Visible - Select None - Selected Apps - Available Apps - No selected app matches your search - No available app matches your search - Cancel - Apply - Profiles - Logs - Info - Home - NETWORK STATUS - No profile selected - MDV-HN - Open info page - Connected - Connecting... - Disconnecting... - Error - Disconnected - CONNECTION STATUS - Connected and running - Preparing tunnel (tap again to cancel) - Error - check logs - Resolver: %1$s %2$s - Valid: - Rejected: - DNS Scan Progress: %1$d / %2$d - Synced MTU: UP %1$d / DOWN %2$d - Active Resolvers: %1$d - Resolver diagnostics - Configured lines: %1$d Core scan total: %2$d - No inline resolvers are configured for this profile. - Waiting for core resolver scan totals from runtime logs. - No accepted resolver yet. Check resolver format, network reachability, and profile settings. - Download: %1$s Upload: %2$s - Total: down %1$s / up %2$s - Session: %1$s - SOCKS5: %1$s:%2$d - SOCKS5 authentication - Username: %1$s - Password: %1$s - PROFILE - Connect - Disconnect - No profiles yet - Create Profile - Add Profile - Selected - Edit - Settings - Delete - Delete profile? - Delete "%1$s"? This cannot be undone. - Edit Profile - New Profile - Profile Name - Import Resolvers - Domain (e.g., v.domain.com) - Encryption Key - Encryption Method - Show sensitive value - Hide sensitive value - Resolvers list is large (%1$d lines) - To avoid UI lag, tap Edit to open the text box. - Edit Resolvers - Resolvers (one per line) - Invalid TOML: DOMAIN/ENCRYPTION_KEY not found - TOML imported into profile form + + MDV-HN Edition + Settings + Profile Settings + Save + Back + Hide + Show + Import TOML + Export TOML + Import client_resolvers.txt + Pick MTU export destination + Save Settings + No selected profile + Create/select a profile in Profiles tab, then configure client_config values here. + Editing profile: %1$s + Profile settings saved and applied + TOML exported + TOML imported to form + Resolvers imported into profile + Resolvers imported: %1$s + No usable resolvers found in that file + 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 + Please enter both SOCKS5 and HTTP ports. + Ports must be between 1025 and 65535. + Invalid custom DNS server: %1$s + Internet sharing requires both username and password. + Save Global Settings + Split Tunnel Apps + %1$d selected apps - tap to choose + Select Split-Tunnel Apps + Choose apps that should use VPN tunnel + Selected %1$d + Available %1$d + Search selected apps + Search available apps + Select Visible + Select None + Selected Apps + Available Apps + No selected app matches your search + No available app matches your search + Cancel + Apply + Profiles + Logs + Info + Home + NETWORK STATUS + No profile selected + MDV-HN + Open info page + Connected + Connecting... + Disconnecting... + Error + Disconnected + CONNECTION STATUS + Connected and running + Preparing tunnel (tap again to cancel) + Error - check logs + Resolver: %1$s %2$s + Valid: + Rejected: + DNS Scan Progress: %1$d / %2$d + Synced MTU: UP %1$d / DOWN %2$d + Active Resolvers: %1$d + Resolver diagnostics + Configured lines: %1$d Core scan total: %2$d + No inline resolvers are configured for this profile. + Waiting for core resolver scan totals from runtime logs. + No accepted resolver yet. Check resolver format, network reachability, and profile settings. + Download: %1$s Upload: %2$s + Total: down %1$s / up %2$s + Session: %1$s + SOCKS5: %1$s:%2$d + SOCKS5 authentication + Username: %1$s + Password: %1$s + PROFILE + Connect + Disconnect + No profiles yet + Create Profile + Add Profile + Selected + Edit + Settings + Delete + Delete profile? + Delete "%1$s"? This cannot be undone. + Edit Profile + New Profile + Profile Name + Import Resolvers + Domain (e.g., v.domain.com) + Encryption Key + Encryption Method + Show sensitive value + Hide sensitive value + Resolvers list is large (%1$d lines) + To avoid UI lag, tap Edit to open the text box. + Edit Resolvers + Resolvers (one per line) + Invalid TOML: DOMAIN/ENCRYPTION_KEY not found + TOML imported into profile form Resolvers file is empty Resolvers imported into profile form Resolvers imported: %1$s + TOML import is too large. + Resolvers file is too large. Resolver import preview - %1$d usable, %2$d duplicate, %3$d invalid, %4$d CIDR ranges - Large CIDR ranges skipped: %1$d. Import may be capped for stability. - Profile name is required. - Add at least one domain before saving. - Encryption key is required. - Add at least one resolver before saving. - Imported Profile - Share Logs - Auto - Clear Logs - Auto ON - Auto OFF - MasterDnsVPN Logs - Share Logs - All - Core - Android - Entries - Errors - Warnings - No logs yet - Connection and system events will appear here once activity starts. - Connection Mode - VPN mode or Proxy mode (SOCKS only) - Split Tunneling - Sharing Internet - Local IP: %1$s - SOCKS5 Port - SOCKS5 port is required. - Port must be greater than 1024 (ports <=1024 require root). - HTTP Port - HTTP port is required. - Username - Password - Sharing listens on your local network. Use a username and password before enabling it. - Use these endpoints to share your VPN connection with other devices or apps on the same network. - MasterDnsVPN - Project overview and build details - Build Information - Project Links - Main GitHub - Main Telegram - MDV-HN Android Client - Version Info - App Version - Upstream Engine - App logo - Open link - VPN Service - VPN is connected - Connecting… - VPN disconnected - github.com/masterking32/MasterDnsVPN - t.me/masterdnsvpn - github.com/Hidden-Node/MasterDnsVPN-AndroidClient - v2026.05.10.180256-27c7e11 - + %1$d usable, %2$d duplicate, %3$d invalid, %4$d CIDR ranges + Large CIDR ranges skipped: %1$d. Import may be capped for stability. + Profile name is required. + Add at least one domain before saving. + Encryption key is required. + Add at least one resolver before saving. + Imported Profile + Share Logs + Auto + Clear Logs + Auto ON + Auto OFF + MasterDnsVPN Logs + Share Logs + All + Core + Android + Entries + Errors + Warnings + No logs yet + Connection and system events will appear here once activity starts. + Connection Mode + VPN mode or Proxy mode (SOCKS only) + Split Tunneling + Sharing Internet + Local IP: %1$s + SOCKS5 Port + SOCKS5 port is required. + Port must be greater than 1024 (ports <=1024 require root). + HTTP Port + HTTP port is required. + Username + Password + Sharing listens on your local network. Use a username and password before enabling it. + Use these endpoints to share your VPN connection with other devices or apps on the same network. + MasterDnsVPN + Project overview and build details + Build Information + Project Links + Main GitHub + Main Telegram + MDV-HN Android Client + Version Info + App Version + Upstream Engine + App logo + Open link + VPN Service + VPN is connected + Connecting… + VPN disconnected + github.com/masterking32/MasterDnsVPN + t.me/masterdnsvpn + github.com/Hidden-Node/MasterDnsVPN-AndroidClient + v2026.05.10.180256-27c7e11 +