diff --git a/android/app/src/main/java/com/masterdns/vpn/ui/settings/GlobalSettingsScreen.kt b/android/app/src/main/java/com/masterdns/vpn/ui/settings/GlobalSettingsScreen.kt index ff73a33..036f7ea 100644 --- a/android/app/src/main/java/com/masterdns/vpn/ui/settings/GlobalSettingsScreen.kt +++ b/android/app/src/main/java/com/masterdns/vpn/ui/settings/GlobalSettingsScreen.kt @@ -1,749 +1,813 @@ package com.masterdns.vpn.ui.settings +import android.util.Base64 import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -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.heightIn -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.shape.RoundedCornerShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.heightIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -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.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.IconButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -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.draw.shadow -import androidx.compose.ui.graphics.asImageBitmap -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.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.core.graphics.drawable.toBitmap -import androidx.lifecycle.viewmodel.compose.viewModel -import com.masterdns.vpn.R -import com.masterdns.vpn.ui.components.mdv.controls.MdvFilterChip -import com.masterdns.vpn.ui.components.mdv.controls.MdvPrimaryActionButton -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.GlobalSettings -import com.masterdns.vpn.util.SplitTunnelMode -import androidx.compose.ui.unit.Dp -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.draw.shadow +import androidx.compose.ui.graphics.asImageBitmap +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.viewmodel.compose.viewModel +import com.masterdns.vpn.R +import com.masterdns.vpn.ui.components.mdv.controls.MdvFilterChip +import com.masterdns.vpn.ui.components.mdv.controls.MdvPrimaryActionButton +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.GlobalSettings +import com.masterdns.vpn.util.SplitTunnelMode +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.runtime.LaunchedEffect import java.net.InetAddress - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) { - val context = LocalContext.current - val current by vm.settings.collectAsState() - val installedApps by vm.installedApps.collectAsState() - var draft by remember(current) { mutableStateOf(current) } - var sharingSocksPortText by remember(current.internetSharingSocksPort) { - mutableStateOf(current.internetSharingSocksPort.toString()) - } - var sharingHttpPortText by remember(current.internetSharingHttpPort) { - mutableStateOf(current.internetSharingHttpPort.toString()) - } - var modeExpanded by remember { mutableStateOf(false) } - var showAppPicker by remember { mutableStateOf(false) } - var availableQuery by remember { mutableStateOf("") } - var selectedQuery by remember { mutableStateOf("") } - var activeTab by remember { mutableStateOf("AVAILABLE") } - var draftAppSelection by remember { mutableStateOf(parseCsv(current.splitPackagesCsv).toMutableSet()) } - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val socksPortValue = sharingSocksPortText.toIntOrNull() - val httpPortValue = sharingHttpPortText.toIntOrNull() - val socksPortMissing = sharingSocksPortText.isBlank() - val httpPortMissing = sharingHttpPortText.isBlank() - val socksPortRequiresRoot = socksPortValue != null && socksPortValue in 1..1024 - val httpPortRequiresRoot = httpPortValue != null && httpPortValue in 1..1024 +import java.security.SecureRandom + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) { + val context = LocalContext.current + val current by vm.settings.collectAsState() + val installedApps by vm.installedApps.collectAsState() + var draft by remember(current) { mutableStateOf(current) } + var sharingSocksPortText by remember(current.internetSharingSocksPort) { + mutableStateOf(current.internetSharingSocksPort.toString()) + } + var sharingHttpPortText by remember(current.internetSharingHttpPort) { + mutableStateOf(current.internetSharingHttpPort.toString()) + } + var modeExpanded by remember { mutableStateOf(false) } + var showAppPicker by remember { mutableStateOf(false) } + var availableQuery by remember { mutableStateOf("") } + var selectedQuery by remember { mutableStateOf("") } + var activeTab by remember { mutableStateOf("AVAILABLE") } + var draftAppSelection by remember { mutableStateOf(parseCsv(current.splitPackagesCsv).toMutableSet()) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val socksPortValue = sharingSocksPortText.toIntOrNull() + val httpPortValue = sharingHttpPortText.toIntOrNull() + val socksPortMissing = sharingSocksPortText.isBlank() + val httpPortMissing = sharingHttpPortText.isBlank() + val socksPortRequiresRoot = socksPortValue != null && socksPortValue in 1..1024 + val httpPortRequiresRoot = httpPortValue != null && httpPortValue in 1..1024 val splitPackagesCount by remember(draft.splitPackagesCsv) { derivedStateOf { parseCsv(draft.splitPackagesCsv).size } } - fun saveGlobalSettings() { - if (socksPortMissing || httpPortMissing) { - scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_required_msg)) - } - return - } - if (socksPortValue !in 1025..65535 || httpPortValue !in 1025..65535) { - scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_range_msg)) - } - return - } - val customDnsServers = parseCsv(draft.customDnsServers) - val invalidDnsServers = customDnsServers.filterNot(::isValidIpLiteral) - if (invalidDnsServers.isNotEmpty()) { - scope.launch { - snackbarHostState.showSnackbar( - context.getString(R.string.global_custom_dns_invalid_msg, invalidDnsServers.joinToString(", ")) - ) - } - return - } - if (draft.internetSharingEnabled && - (draft.internetSharingUser.isBlank() || draft.internetSharingPass.isBlank()) - ) { - scope.launch { - snackbarHostState.showSnackbar(context.getString(R.string.global_sharing_credentials_required_msg)) - } - return - } - val safeSocksPort = socksPortValue ?: return - val safeHttpPort = httpPortValue ?: return - val sanitized = draft.copy( - internetSharingSocksPort = safeSocksPort, - internetSharingHttpPort = safeHttpPort, - customDnsServers = customDnsServers.joinToString(","), - internetSharingUser = draft.internetSharingUser.trim(), - internetSharingPass = draft.internetSharingPass.trim() + fun rotateSharingPassword() { + draft = draft.copy( + internetSharingUser = draft.internetSharingUser.ifBlank { "masterdns" }, + internetSharingPass = generateSharingPassword() ) - draft = sanitized - vm.save(normalize(sanitized)) - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.global_settings_saved_msg)) } } - Scaffold( - containerColor = MdvColor.Background, - topBar = { - MdvTopAppBar( - title = stringResource(R.string.settings_title), - actions = { - IconButton( - onClick = ::saveGlobalSettings - ) { - Icon( - imageVector = Icons.Filled.Save, - contentDescription = stringResource(R.string.action_save) - ) - } - } - ) - }, - 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 - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = maxContentWidth), - contentPadding = PaddingValues(MdvSpace.S4), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) - ) { - item { - Card(colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh)) { - Column(modifier = Modifier.padding(MdvSpace.S3), verticalArrangement = Arrangement.spacedBy(MdvSpace.S3)) { - ExposedDropdownMenuBox( - expanded = modeExpanded, - onExpandedChange = { modeExpanded = !modeExpanded } - ) { - OutlinedTextField( - value = draft.connectionMode, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.global_connection_mode)) }, - supportingText = { Text(stringResource(R.string.global_connection_mode_help)) }, - trailingIcon = { - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null - ) - }, - modifier = Modifier.menuAnchor().fillMaxWidth() - ) - DropdownMenu(expanded = modeExpanded, onDismissRequest = { modeExpanded = false }) { - listOf("VPN", "PROXY").forEach { mode -> - DropdownMenuItem( - text = { Text(mode) }, - onClick = { - draft = draft.copy(connectionMode = mode) - modeExpanded = false - } - ) - } - } - } - - RowSwitch( - title = stringResource(R.string.global_split_tunneling), - checked = draft.splitTunnelingEnabled, - onChecked = { draft = draft.copy(splitTunnelingEnabled = it) } - ) - - if (draft.splitTunnelingEnabled) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = MdvSpace.S3), - horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2) - ) { - MdvFilterChip( - selected = draft.splitTunnelMode == SplitTunnelMode.INCLUDE, - onClick = { draft = draft.copy(splitTunnelMode = SplitTunnelMode.INCLUDE) }, - label = "Proxy Selected Apps" - ) - MdvFilterChip( - selected = draft.splitTunnelMode == SplitTunnelMode.EXCLUDE, - onClick = { draft = draft.copy(splitTunnelMode = SplitTunnelMode.EXCLUDE) }, - label = "Bypass Selected Apps" - ) - } - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S2)) - Card( - onClick = { - draftAppSelection = parseCsv(draft.splitPackagesCsv).toMutableSet() - availableQuery = "" - selectedQuery = "" - activeTab = "AVAILABLE" - vm.loadInstalledAppsIfNeeded() - showAppPicker = true - }, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(MdvSpace.S3)) { - Text(stringResource(R.string.split_tunnel_apps_title)) - Text( - stringResource(R.string.split_tunnel_apps_selected_count, splitPackagesCount), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - } - } - } - - OutlinedTextField( - value = draft.customDnsServers, - onValueChange = { draft = draft.copy(customDnsServers = it) }, - label = { Text("Custom DNS Servers") }, - placeholder = { Text("e.g., 1.1.1.1, 8.8.8.8") }, - supportingText = { - Text( - "Comma-separated DNS servers for VPN mode. Leave empty for defaults. " + - "For remote DNS in Iran: use your VPS IP (if running DNS server) or use Proxy mode instead.", - style = MaterialTheme.typography.bodySmall - ) - }, - modifier = Modifier.fillMaxWidth(), - minLines = 1, - maxLines = 3, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) - ) - - RowSwitch( - title = "Fake DNS (Remote Resolution)", - checked = draft.fakeDnsEnabled, - onChecked = { draft = draft.copy(fakeDnsEnabled = it) } - ) - - if (draft.fakeDnsEnabled) { - Text( - "Intercepts DNS packets and resolves hostnames at the server. Best for bypassing DNS filtering in Iran. VPN mode only.", - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant, - modifier = Modifier.padding(start = MdvSpace.S3) - ) - } - - } - } - } - item { - Card(colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh)) { - Column( - modifier = Modifier.padding(MdvSpace.S3), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.global_sharing_internet), style = MaterialTheme.typography.titleMedium) - Switch( - checked = draft.internetSharingEnabled, - onCheckedChange = { draft = draft.copy(internetSharingEnabled = it) } - ) - } - - if (draft.internetSharingEnabled) { - val localIp = remember { getSystemLocalIp() } + fun generateSharingCredentials() { + draft = draft.copy( + internetSharingUser = "masterdns", + internetSharingPass = generateSharingPassword() + ) + } + fun saveGlobalSettings() { + if (socksPortMissing || httpPortMissing) { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_required_msg)) + } + return + } + if (socksPortValue !in 1025..65535 || httpPortValue !in 1025..65535) { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.global_settings_ports_range_msg)) + } + return + } + val customDnsServers = parseCsv(draft.customDnsServers) + val invalidDnsServers = customDnsServers.filterNot(::isValidIpLiteral) + if (invalidDnsServers.isNotEmpty()) { + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.global_custom_dns_invalid_msg, invalidDnsServers.joinToString(", ")) + ) + } + return + } + if (draft.internetSharingEnabled && + (draft.internetSharingUser.isBlank() || draft.internetSharingPass.isBlank()) + ) { + scope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.global_sharing_credentials_required_msg)) + } + return + } + val safeSocksPort = socksPortValue ?: return + val safeHttpPort = httpPortValue ?: return + val sanitized = draft.copy( + internetSharingSocksPort = safeSocksPort, + internetSharingHttpPort = safeHttpPort, + customDnsServers = customDnsServers.joinToString(","), + internetSharingUser = draft.internetSharingUser.trim(), + internetSharingPass = draft.internetSharingPass.trim() + ) + draft = sanitized + vm.save(normalize(sanitized)) + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.global_settings_saved_msg)) } + } + + Scaffold( + containerColor = MdvColor.Background, + topBar = { + MdvTopAppBar( + title = stringResource(R.string.settings_title), + actions = { + IconButton( + onClick = ::saveGlobalSettings + ) { + Icon( + imageVector = Icons.Filled.Save, + contentDescription = stringResource(R.string.action_save) + ) + } + } + ) + }, + 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 + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = maxContentWidth), + contentPadding = PaddingValues(MdvSpace.S4), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) + ) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh)) { + Column(modifier = Modifier.padding(MdvSpace.S3), verticalArrangement = Arrangement.spacedBy(MdvSpace.S3)) { + ExposedDropdownMenuBox( + expanded = modeExpanded, + onExpandedChange = { modeExpanded = !modeExpanded } + ) { + OutlinedTextField( + value = draft.connectionMode, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.global_connection_mode)) }, + supportingText = { Text(stringResource(R.string.global_connection_mode_help)) }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null + ) + }, + modifier = Modifier.menuAnchor().fillMaxWidth() + ) + DropdownMenu(expanded = modeExpanded, onDismissRequest = { modeExpanded = false }) { + listOf("VPN", "PROXY").forEach { mode -> + DropdownMenuItem( + text = { Text(mode) }, + onClick = { + draft = draft.copy(connectionMode = mode) + modeExpanded = false + } + ) + } + } + } + + RowSwitch( + title = stringResource(R.string.global_split_tunneling), + checked = draft.splitTunnelingEnabled, + onChecked = { draft = draft.copy(splitTunnelingEnabled = it) } + ) + + if (draft.splitTunnelingEnabled) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = MdvSpace.S3), + horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2) + ) { + MdvFilterChip( + selected = draft.splitTunnelMode == SplitTunnelMode.INCLUDE, + onClick = { draft = draft.copy(splitTunnelMode = SplitTunnelMode.INCLUDE) }, + label = "Proxy Selected Apps" + ) + MdvFilterChip( + selected = draft.splitTunnelMode == SplitTunnelMode.EXCLUDE, + onClick = { draft = draft.copy(splitTunnelMode = SplitTunnelMode.EXCLUDE) }, + label = "Bypass Selected Apps" + ) + } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(MdvSpace.S2)) + Card( + onClick = { + draftAppSelection = parseCsv(draft.splitPackagesCsv).toMutableSet() + availableQuery = "" + selectedQuery = "" + activeTab = "AVAILABLE" + vm.loadInstalledAppsIfNeeded() + showAppPicker = true + }, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(MdvSpace.S3)) { + Text(stringResource(R.string.split_tunnel_apps_title)) + Text( + stringResource(R.string.split_tunnel_apps_selected_count, splitPackagesCount), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + } + } + } + + OutlinedTextField( + value = draft.customDnsServers, + onValueChange = { draft = draft.copy(customDnsServers = it) }, + label = { Text("Custom DNS Servers") }, + placeholder = { Text("e.g., 1.1.1.1, 8.8.8.8") }, + supportingText = { + Text( + "Comma-separated DNS servers for VPN mode. Leave empty for defaults. " + + "For remote DNS in Iran: use your VPS IP (if running DNS server) or use Proxy mode instead.", + style = MaterialTheme.typography.bodySmall + ) + }, + modifier = Modifier.fillMaxWidth(), + minLines = 1, + maxLines = 3, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + + RowSwitch( + title = "Fake DNS (Remote Resolution)", + checked = draft.fakeDnsEnabled, + onChecked = { draft = draft.copy(fakeDnsEnabled = it) } + ) + + if (draft.fakeDnsEnabled) { + Text( + "Intercepts DNS packets and resolves hostnames at the server. Best for bypassing DNS filtering in Iran. VPN mode only.", + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant, + modifier = Modifier.padding(start = MdvSpace.S3) + ) + } + + } + } + } + item { + Card(colors = CardDefaults.cardColors(containerColor = MdvColor.SurfaceHigh)) { + Column( + modifier = Modifier.padding(MdvSpace.S3), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.global_sharing_internet), style = MaterialTheme.typography.titleMedium) + Switch( + checked = draft.internetSharingEnabled, + onCheckedChange = { draft = draft.copy(internetSharingEnabled = it) } + ) + } + + if (draft.internetSharingEnabled) { + val localIp = remember { getSystemLocalIp() } + if (localIp != null) { Text( stringResource(R.string.global_local_ip, localIp), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, color = MdvColor.PrimaryContainer ) + Text( + stringResource( + R.string.global_sharing_socks_endpoint, + localIp, + sharingSocksPortText.ifBlank { "?" } + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + Text( + stringResource( + R.string.global_sharing_http_endpoint, + localIp, + sharingHttpPortText.ifBlank { "?" } + ), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) } Text( stringResource(R.string.global_sharing_lan_warning), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.Error + style = MaterialTheme.typography.bodySmall, + color = MdvColor.Error + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = sharingSocksPortText, + onValueChange = { + if (it.any { ch -> !ch.isDigit() } || it.length > 5) { + return@OutlinedTextField + } + sharingSocksPortText = it + val port = it.toIntOrNull() + if (port != null && port in 1025..65535) { + draft = draft.copy(internetSharingSocksPort = port) + } + }, + label = { Text(stringResource(R.string.global_socks5_port)) }, + isError = socksPortMissing || socksPortRequiresRoot, + supportingText = { + when { + socksPortMissing -> Text(stringResource(R.string.global_socks5_port_required)) + socksPortRequiresRoot -> Text(stringResource(R.string.global_port_root_warning)) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = sharingHttpPortText, + onValueChange = { + if (it.any { ch -> !ch.isDigit() } || it.length > 5) { + return@OutlinedTextField + } + sharingHttpPortText = it + val port = it.toIntOrNull() + if (port != null && port in 1025..65535) { + draft = draft.copy(internetSharingHttpPort = port) + } + }, + label = { Text(stringResource(R.string.global_http_port)) }, + isError = httpPortMissing || httpPortRequiresRoot, + supportingText = { + when { + httpPortMissing -> Text(stringResource(R.string.global_http_port_required)) + httpPortRequiresRoot -> Text(stringResource(R.string.global_port_root_warning)) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + } + + OutlinedTextField( + value = draft.internetSharingUser, + onValueChange = { draft = draft.copy(internetSharingUser = it) }, + label = { Text(stringResource(R.string.global_username)) }, + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = draft.internetSharingPass, + onValueChange = { draft = draft.copy(internetSharingPass = it) }, + label = { Text(stringResource(R.string.global_password)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - OutlinedTextField( - value = sharingSocksPortText, - onValueChange = { - if (it.any { ch -> !ch.isDigit() } || it.length > 5) { - return@OutlinedTextField - } - sharingSocksPortText = it - val port = it.toIntOrNull() - if (port != null && port in 1025..65535) { - draft = draft.copy(internetSharingSocksPort = port) - } - }, - label = { Text(stringResource(R.string.global_socks5_port)) }, - isError = socksPortMissing || socksPortRequiresRoot, - supportingText = { - when { - socksPortMissing -> Text(stringResource(R.string.global_socks5_port_required)) - socksPortRequiresRoot -> Text(stringResource(R.string.global_port_root_warning)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + OutlinedButton( + onClick = ::generateSharingCredentials, modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = sharingHttpPortText, - onValueChange = { - if (it.any { ch -> !ch.isDigit() } || it.length > 5) { - return@OutlinedTextField - } - sharingHttpPortText = it - val port = it.toIntOrNull() - if (port != null && port in 1025..65535) { - draft = draft.copy(internetSharingHttpPort = port) - } - }, - label = { Text(stringResource(R.string.global_http_port)) }, - isError = httpPortMissing || httpPortRequiresRoot, - supportingText = { - when { - httpPortMissing -> Text(stringResource(R.string.global_http_port_required)) - httpPortRequiresRoot -> Text(stringResource(R.string.global_port_root_warning)) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) { + Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.global_generate_credentials)) + } + OutlinedButton( + onClick = ::rotateSharingPassword, modifier = Modifier.weight(1f) - ) + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.global_rotate_password)) + } } - OutlinedTextField( - value = draft.internetSharingUser, - onValueChange = { draft = draft.copy(internetSharingUser = it) }, - label = { Text(stringResource(R.string.global_username)) }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = draft.internetSharingPass, - onValueChange = { draft = draft.copy(internetSharingPass = it) }, - label = { Text(stringResource(R.string.global_password)) }, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - Text( stringResource(R.string.global_sharing_help), style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - } - } - } - } - item { - MdvPrimaryActionButton( - text = stringResource(R.string.global_settings_save_button), - onClick = ::saveGlobalSettings, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - } - - if (showAppPicker) { - val selectedApps by remember(installedApps, draftAppSelection) { - derivedStateOf { installedApps.filter { draftAppSelection.contains(it.packageName) } } - } - val availableApps by remember(installedApps, draftAppSelection) { - derivedStateOf { installedApps.filterNot { draftAppSelection.contains(it.packageName) } } - } - - val selectedFiltered by remember(selectedApps, selectedQuery) { - derivedStateOf { - val q = selectedQuery.trim().lowercase() - selectedApps.filter { - q.isEmpty() || - it.label.lowercase().contains(q) || - it.packageName.lowercase().contains(q) - }.sortedWith(compareBy({ it.label.lowercase() }, { it.packageName })) - } - } - - val availableFiltered by remember(availableApps, availableQuery) { - derivedStateOf { - val q = availableQuery.trim().lowercase() - availableApps.filter { - q.isEmpty() || - it.label.lowercase().contains(q) || - it.packageName.lowercase().contains(q) - }.sortedWith(compareBy({ it.label.lowercase() }, { it.packageName })) - } - } - - Dialog(onDismissRequest = { showAppPicker = false }) { - Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 640.dp), - shape = RoundedCornerShape(16.dp), - color = MdvColor.Surface - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 640.dp) - .padding(MdvSpace.S3), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) - ) { - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) - ) { - Text(stringResource(R.string.split_tunnel_dialog_title), style = MaterialTheme.typography.titleMedium) - Text( - stringResource(R.string.split_tunnel_dialog_desc), - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant - ) - - Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { - MdvFilterChip( - selected = activeTab == "SELECTED", - onClick = { activeTab = "SELECTED" }, - label = stringResource(R.string.split_tunnel_selected_count, selectedApps.size) - ) - MdvFilterChip( - selected = activeTab == "AVAILABLE", - onClick = { activeTab = "AVAILABLE" }, - label = stringResource(R.string.split_tunnel_available_count, availableApps.size) - ) - } - - if (activeTab == "SELECTED") { - OutlinedTextField( - value = selectedQuery, - onValueChange = { selectedQuery = it }, - label = { Text(stringResource(R.string.split_tunnel_search_selected)) }, - modifier = Modifier.fillMaxWidth() - ) - } else { - OutlinedTextField( - value = availableQuery, - onValueChange = { availableQuery = it }, - label = { Text(stringResource(R.string.split_tunnel_search_available)) }, - modifier = Modifier.fillMaxWidth() - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { - OutlinedButton( - onClick = { - draftAppSelection = draftAppSelection.toMutableSet().apply { - addAll(availableFiltered.map { it.packageName }) - } - }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.split_tunnel_select_visible)) - } - OutlinedButton( - onClick = { draftAppSelection = mutableSetOf() }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.split_tunnel_select_none)) - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MdvColor.SurfaceHigh - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) - ) { - val appsToShow = if (activeTab == "SELECTED") selectedFiltered else availableFiltered - val emptyText = if (activeTab == "SELECTED") { - stringResource(R.string.split_tunnel_empty_selected) - } else { - stringResource(R.string.split_tunnel_empty_available) - } - - Text( - if (activeTab == "SELECTED") { - stringResource(R.string.split_tunnel_selected_apps) - } else { - stringResource(R.string.split_tunnel_available_apps) - }, - style = MaterialTheme.typography.labelLarge, - color = MdvColor.PrimaryContainer - ) - - if (appsToShow.isEmpty()) { - Text( - emptyText, - style = MaterialTheme.typography.bodySmall, - color = MdvColor.OnSurfaceVariant, - modifier = Modifier.padding(top = 8.dp) - ) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 220.dp, max = 340.dp) - ) { - items(appsToShow, key = { it.packageName }) { app -> - AppRow( - app = app, - checked = draftAppSelection.contains(app.packageName), - onToggle = { - draftAppSelection = draftAppSelection.toMutableSet().apply { - if (!add(app.packageName)) remove(app.packageName) - } - } - ) - } - } - } - } - } - } - Spacer(modifier = Modifier.height(2.dp)) - - Surface( - modifier = Modifier - .fillMaxWidth() - .shadow(6.dp, RoundedCornerShape(12.dp)) - .navigationBarsPadding(), - shape = RoundedCornerShape(12.dp), - color = MdvColor.SurfaceHigh - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = MdvSpace.S2, vertical = MdvSpace.S2), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = { showAppPicker = false }) { - Text(stringResource(R.string.action_cancel)) - } - Button( - onClick = { - draft = draft.copy(splitPackagesCsv = draftAppSelection.sorted().joinToString(",")) - showAppPicker = false - } - ) { - Text(stringResource(R.string.action_apply)) - } - } - } - } - } - } - } -} - -@Composable -private fun AppRow( - app: GlobalSettingsViewModel.AppEntry, - checked: Boolean, - onToggle: () -> Unit -) { - val context = LocalContext.current - val appIconBitmap = remember(app.packageName) { - runCatching { - context.packageManager.getApplicationIcon(app.packageName).toBitmap(32, 32) - }.getOrNull() - } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggle() } - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - if (appIconBitmap != null) { - Image( - bitmap = appIconBitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } else { - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - Spacer(modifier = Modifier.size(8.dp)) - Column { - Text(text = app.label) - Text(text = app.packageName) - } - } - Checkbox( - checked = checked, - onCheckedChange = { onToggle() } - ) - } -} - -@Composable -private fun RowSwitch(title: String, checked: Boolean, onChecked: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(title) - Switch(checked = checked, onCheckedChange = onChecked) - } -} - -private fun parseCsv(value: String): Set { - return value.split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .toSet() -} - -private fun normalize(settings: GlobalSettings): GlobalSettings { - return settings.copy( - connectionMode = settings.connectionMode.uppercase(), - customDnsServers = parseCsv(settings.customDnsServers).joinToString(","), - internetSharingUser = settings.internetSharingUser.trim(), - internetSharingPass = settings.internetSharingPass.trim(), - splitPackagesCsv = settings.splitPackagesCsv - .split(",") - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - .joinToString(",") - ) -} - + color = MdvColor.OnSurfaceVariant + ) + } + } + } + } + item { + MdvPrimaryActionButton( + text = stringResource(R.string.global_settings_save_button), + onClick = ::saveGlobalSettings, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + + if (showAppPicker) { + val selectedApps by remember(installedApps, draftAppSelection) { + derivedStateOf { installedApps.filter { draftAppSelection.contains(it.packageName) } } + } + val availableApps by remember(installedApps, draftAppSelection) { + derivedStateOf { installedApps.filterNot { draftAppSelection.contains(it.packageName) } } + } + + val selectedFiltered by remember(selectedApps, selectedQuery) { + derivedStateOf { + val q = selectedQuery.trim().lowercase() + selectedApps.filter { + q.isEmpty() || + it.label.lowercase().contains(q) || + it.packageName.lowercase().contains(q) + }.sortedWith(compareBy({ it.label.lowercase() }, { it.packageName })) + } + } + + val availableFiltered by remember(availableApps, availableQuery) { + derivedStateOf { + val q = availableQuery.trim().lowercase() + availableApps.filter { + q.isEmpty() || + it.label.lowercase().contains(q) || + it.packageName.lowercase().contains(q) + }.sortedWith(compareBy({ it.label.lowercase() }, { it.packageName })) + } + } + + Dialog(onDismissRequest = { showAppPicker = false }) { + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 640.dp), + shape = RoundedCornerShape(16.dp), + color = MdvColor.Surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 640.dp) + .padding(MdvSpace.S3), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) + ) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(MdvSpace.S3) + ) { + Text(stringResource(R.string.split_tunnel_dialog_title), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(R.string.split_tunnel_dialog_desc), + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant + ) + + Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { + MdvFilterChip( + selected = activeTab == "SELECTED", + onClick = { activeTab = "SELECTED" }, + label = stringResource(R.string.split_tunnel_selected_count, selectedApps.size) + ) + MdvFilterChip( + selected = activeTab == "AVAILABLE", + onClick = { activeTab = "AVAILABLE" }, + label = stringResource(R.string.split_tunnel_available_count, availableApps.size) + ) + } + + if (activeTab == "SELECTED") { + OutlinedTextField( + value = selectedQuery, + onValueChange = { selectedQuery = it }, + label = { Text(stringResource(R.string.split_tunnel_search_selected)) }, + modifier = Modifier.fillMaxWidth() + ) + } else { + OutlinedTextField( + value = availableQuery, + onValueChange = { availableQuery = it }, + label = { Text(stringResource(R.string.split_tunnel_search_available)) }, + modifier = Modifier.fillMaxWidth() + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(MdvSpace.S2)) { + OutlinedButton( + onClick = { + draftAppSelection = draftAppSelection.toMutableSet().apply { + addAll(availableFiltered.map { it.packageName }) + } + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.split_tunnel_select_visible)) + } + OutlinedButton( + onClick = { draftAppSelection = mutableSetOf() }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.split_tunnel_select_none)) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MdvColor.SurfaceHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + val appsToShow = if (activeTab == "SELECTED") selectedFiltered else availableFiltered + val emptyText = if (activeTab == "SELECTED") { + stringResource(R.string.split_tunnel_empty_selected) + } else { + stringResource(R.string.split_tunnel_empty_available) + } + + Text( + if (activeTab == "SELECTED") { + stringResource(R.string.split_tunnel_selected_apps) + } else { + stringResource(R.string.split_tunnel_available_apps) + }, + style = MaterialTheme.typography.labelLarge, + color = MdvColor.PrimaryContainer + ) + + if (appsToShow.isEmpty()) { + Text( + emptyText, + style = MaterialTheme.typography.bodySmall, + color = MdvColor.OnSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 220.dp, max = 340.dp) + ) { + items(appsToShow, key = { it.packageName }) { app -> + AppRow( + app = app, + checked = draftAppSelection.contains(app.packageName), + onToggle = { + draftAppSelection = draftAppSelection.toMutableSet().apply { + if (!add(app.packageName)) remove(app.packageName) + } + } + ) + } + } + } + } + } + } + Spacer(modifier = Modifier.height(2.dp)) + + Surface( + modifier = Modifier + .fillMaxWidth() + .shadow(6.dp, RoundedCornerShape(12.dp)) + .navigationBarsPadding(), + shape = RoundedCornerShape(12.dp), + color = MdvColor.SurfaceHigh + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MdvSpace.S2, vertical = MdvSpace.S2), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { showAppPicker = false }) { + Text(stringResource(R.string.action_cancel)) + } + Button( + onClick = { + draft = draft.copy(splitPackagesCsv = draftAppSelection.sorted().joinToString(",")) + showAppPicker = false + } + ) { + Text(stringResource(R.string.action_apply)) + } + } + } + } + } + } + } +} + +@Composable +private fun AppRow( + app: GlobalSettingsViewModel.AppEntry, + checked: Boolean, + onToggle: () -> Unit +) { + val context = LocalContext.current + val appIconBitmap = remember(app.packageName) { + runCatching { + context.packageManager.getApplicationIcon(app.packageName).toBitmap(32, 32) + }.getOrNull() + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + if (appIconBitmap != null) { + Image( + bitmap = appIconBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Column { + Text(text = app.label) + Text(text = app.packageName) + } + } + Checkbox( + checked = checked, + onCheckedChange = { onToggle() } + ) + } +} + +@Composable +private fun RowSwitch(title: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(title) + Switch(checked = checked, onCheckedChange = onChecked) + } +} + +private fun parseCsv(value: String): Set { + return value.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .toSet() +} + +private fun normalize(settings: GlobalSettings): GlobalSettings { + return settings.copy( + connectionMode = settings.connectionMode.uppercase(), + customDnsServers = parseCsv(settings.customDnsServers).joinToString(","), + internetSharingUser = settings.internetSharingUser.trim(), + internetSharingPass = settings.internetSharingPass.trim(), + splitPackagesCsv = settings.splitPackagesCsv + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .joinToString(",") + ) +} + private fun isValidIpLiteral(value: String): Boolean { val text = value.trim() if (text.isBlank()) return false - val numericCandidate = when { - "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) - ":" in text -> text.matches(Regex("[0-9A-Fa-f:.]+")) - else -> false - } + val numericCandidate = when { + "." in text && ":" !in text -> text.matches(Regex("\\d{1,3}(\\.\\d{1,3}){3}")) + ":" in text -> text.matches(Regex("[0-9A-Fa-f:.]+")) + else -> false + } if (!numericCandidate) return false return runCatching { InetAddress.getByName(text) }.isSuccess } -private fun getSystemLocalIp(): String? { - return try { - val interfaces = java.net.NetworkInterface.getNetworkInterfaces() - while (interfaces.hasMoreElements()) { - val iface = interfaces.nextElement() - val addresses = iface.inetAddresses - while (addresses.hasMoreElements()) { - val addr = addresses.nextElement() - if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { - return addr.hostAddress - } - } - } - null - } catch (_: Exception) { - null - } +private fun generateSharingPassword(): String { + val bytes = ByteArray(18) + SecureRandom().nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } + +private fun getSystemLocalIp(): String? { + return try { + val interfaces = java.net.NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + val addresses = iface.inetAddresses + while (addresses.hasMoreElements()) { + val addr = addresses.nextElement() + if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { + return addr.hostAddress + } + } + } + null + } catch (_: Exception) { + null + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bdb4d26..865a0ae 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,169 +1,173 @@ - - 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 - 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). + + 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 + 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 + Generate + Rotate + SOCKS5: %1$s:%2$s + HTTP: %1$s:%2$s 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 - + 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 +