1203 lines
55 KiB
Kotlin
1203 lines
55 KiB
Kotlin
package com.wdtt.client.ui
|
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.animateContentSize
|
|
import androidx.compose.animation.core.Spring
|
|
import androidx.compose.animation.core.spring
|
|
import androidx.compose.animation.animateColorAsState
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.expandVertically
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.shrinkVertically
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.gestures.detectDragGestures
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.material.icons.filled.Key
|
|
import androidx.compose.material.icons.filled.PowerSettingsNew
|
|
import androidx.compose.material.icons.filled.Stop
|
|
import androidx.compose.material.icons.filled.Tag
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.runtime.saveable.rememberSaveable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.StrokeCap
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.input.KeyboardType
|
|
import androidx.compose.ui.unit.Density
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import com.wdtt.client.SettingsStore
|
|
import com.wdtt.client.TunnelManager
|
|
import com.wdtt.client.TunnelService
|
|
import com.wdtt.client.WDTTColors
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.flow.first
|
|
import android.content.Intent
|
|
import android.net.VpnService
|
|
import android.os.Build
|
|
import android.widget.Toast
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import kotlin.math.roundToInt
|
|
|
|
private const val WORKERS_PER_GROUP = 9
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun SettingsTab() {
|
|
val context = LocalContext.current
|
|
val scope = rememberCoroutineScope()
|
|
val settingsStore = remember { SettingsStore(context) }
|
|
|
|
val currentDensity = LocalDensity.current
|
|
CompositionLocalProvider(
|
|
LocalDensity provides Density(currentDensity.density, fontScale = 1f)
|
|
) {
|
|
SettingsTabContent(context, scope, settingsStore)
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutines.CoroutineScope, settingsStore: SettingsStore) {
|
|
val savedConnectionPassword by settingsStore.connectionPassword.collectAsStateWithLifecycle(initialValue = "")
|
|
val savedManualPortsEnabled by settingsStore.manualPortsEnabled.collectAsStateWithLifecycle(initialValue = false)
|
|
val savedServerDtlsPort by settingsStore.serverDtlsPort.collectAsStateWithLifecycle(initialValue = 56000)
|
|
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
|
|
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000)
|
|
|
|
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
|
|
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
|
|
val wdttLink by settingsStore.wdttLink.collectAsStateWithLifecycle(initialValue = "")
|
|
|
|
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
|
|
|
|
val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
|
|
var wasRunning by remember { mutableStateOf(false) }
|
|
|
|
LaunchedEffect(tunnelRunning) {
|
|
if (wasRunning && !tunnelRunning) {
|
|
TunnelManager.startCooldown(1500L)
|
|
}
|
|
wasRunning = tunnelRunning
|
|
}
|
|
|
|
var peerInput by rememberSaveable { mutableStateOf("") }
|
|
var vkHash1 by rememberSaveable { mutableStateOf("") }
|
|
var vkHash2 by rememberSaveable { mutableStateOf("") }
|
|
var vkHash3 by rememberSaveable { mutableStateOf("") }
|
|
var vkHash4 by rememberSaveable { mutableStateOf("") }
|
|
var workersInput by rememberSaveable { mutableFloatStateOf(18f) }
|
|
var showHashesDialog by rememberSaveable { mutableStateOf(false) }
|
|
var autoCaptchaEnabled by rememberSaveable { mutableStateOf(true) }
|
|
var useWVCaptcha by rememberSaveable { mutableStateOf(false) }
|
|
var isManualMode by rememberSaveable { mutableStateOf(true) }
|
|
var wbvManualMode by rememberSaveable { mutableStateOf(true) }
|
|
var manualPortsEnabled by rememberSaveable { mutableStateOf(false) }
|
|
var serverDtlsPortInput by rememberSaveable { mutableStateOf("56000") }
|
|
var serverWgPortInput by rememberSaveable { mutableStateOf("56001") }
|
|
|
|
val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) }
|
|
val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() }
|
|
val parsedLinkHashes = remember(wdttLink) {
|
|
if (wdttLink.trim().startsWith("wdtt://")) {
|
|
val clean = wdttLink.trim().removePrefix("wdtt://")
|
|
val parts = clean.split(":")
|
|
if (parts.size >= 6) {
|
|
parts[5].split(",").filter { stripVkUrlStatic(it).isNotBlank() }
|
|
} else emptyList()
|
|
} else emptyList()
|
|
}
|
|
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4, wdttLinkMode, parsedLinkHashes) {
|
|
if (wdttLinkMode) parsedLinkHashes.size else uniqueHashes.size
|
|
}
|
|
val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
|
|
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
|
|
var portInput by rememberSaveable { mutableStateOf("9000") }
|
|
var sniInput by rememberSaveable { mutableStateOf("") }
|
|
|
|
LaunchedEffect(dynamicMaxWorkers) {
|
|
if (workersInput > dynamicMaxWorkers) {
|
|
workersInput = dynamicMaxWorkers
|
|
}
|
|
}
|
|
|
|
val currentWorkers = workersInput.coerceIn(WORKERS_PER_GROUP.toFloat(), dynamicMaxWorkers)
|
|
|
|
val hashErrors = remember(vkHash1, vkHash2, vkHash3, vkHash4) {
|
|
buildList {
|
|
allHashes.forEachIndexed { i, h ->
|
|
if (h.isNotBlank() && h.length < 16) add("Хеш ${i + 1} — короткий")
|
|
}
|
|
val filled = allHashes.filter { it.isNotBlank() && it.length >= 16 }
|
|
if (filled.size != filled.distinct().size) add("Есть дубликаты хешей")
|
|
}
|
|
}
|
|
val hasInputHashErrors = remember(vkHash1, vkHash2, vkHash3, vkHash4) { hashErrors.isNotEmpty() }
|
|
|
|
var showSecretsDialog by rememberSaveable { mutableStateOf(false) }
|
|
var initialized by remember { mutableStateOf(false) }
|
|
|
|
fun parseHashes(raw: String) {
|
|
val parts = raw.split(Regex("[,\\s\\n]+")).map { stripVkUrlStatic(it) }.filter { it.isNotEmpty() }
|
|
vkHash1 = parts.getOrElse(0) { "" }
|
|
vkHash2 = parts.getOrElse(1) { "" }
|
|
vkHash3 = parts.getOrElse(2) { "" }
|
|
vkHash4 = parts.getOrElse(3) { "" }
|
|
}
|
|
|
|
fun normalizeHashes(vararg hashes: String): String {
|
|
return hashes
|
|
.map { stripVkUrlStatic(it) }
|
|
.filter { it.isNotBlank() && it.length >= 16 }
|
|
.distinct()
|
|
.joinToString(",")
|
|
}
|
|
|
|
LaunchedEffect(activeProfile) {
|
|
val peer = settingsStore.peer.first()
|
|
val hashes = settingsStore.vkHashes.first()
|
|
val workers = settingsStore.workersPerHash.first()
|
|
val port = settingsStore.listenPort.first()
|
|
val manualPorts = settingsStore.manualPortsEnabled.first()
|
|
val serverDtlsPort = settingsStore.serverDtlsPort.first()
|
|
val serverWgPort = settingsStore.serverWgPort.first()
|
|
val sni = settingsStore.sni.first()
|
|
val captchaMode = settingsStore.captchaMode.first()
|
|
val captchaMethod = settingsStore.captchaSolveMethod.first()
|
|
val wbvCaptchaMethod = settingsStore.captchaWbvSolveMethod.first()
|
|
|
|
peerInput = peer
|
|
parseHashes(hashes)
|
|
workersInput = roundToGroup(workers.toFloat(), (listOf(vkHash1, vkHash2, vkHash3, vkHash4).count { it.isNotBlank() }.coerceAtLeast(1) * 27).toFloat())
|
|
portInput = port.toString()
|
|
manualPortsEnabled = manualPorts
|
|
serverDtlsPortInput = serverDtlsPort.toString()
|
|
serverWgPortInput = serverWgPort.toString()
|
|
sniInput = sni
|
|
autoCaptchaEnabled = captchaMode == "auto"
|
|
useWVCaptcha = captchaMode != "rjs"
|
|
wbvManualMode = wbvCaptchaMethod != "auto"
|
|
isManualMode = if (captchaMode == "wv") wbvManualMode else captchaMethod != "auto"
|
|
|
|
initialized = true
|
|
}
|
|
|
|
LaunchedEffect(savedManualPortsEnabled) {
|
|
manualPortsEnabled = savedManualPortsEnabled
|
|
}
|
|
|
|
LaunchedEffect(savedServerDtlsPort) {
|
|
serverDtlsPortInput = savedServerDtlsPort.toString()
|
|
}
|
|
|
|
LaunchedEffect(savedServerWgPort) {
|
|
serverWgPortInput = savedServerWgPort.toString()
|
|
}
|
|
|
|
LaunchedEffect(savedListenPort) {
|
|
portInput = savedListenPort.toString()
|
|
}
|
|
|
|
if (!initialized) {
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
}
|
|
return
|
|
}
|
|
|
|
var saveJob by remember { mutableStateOf<Job?>(null) }
|
|
|
|
fun saveTunnelSettingsNow(hashes: String = combinedHashes, onSaved: (() -> Unit)? = null) {
|
|
saveJob?.cancel()
|
|
scope.launch {
|
|
val savedLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
|
|
settingsStore.save(
|
|
peerInput, hashes, "",
|
|
workersInput.toInt(), "udp", savedLocalPort, sniInput, false
|
|
)
|
|
onSaved?.invoke()
|
|
}
|
|
}
|
|
|
|
fun scheduleSave() {
|
|
saveJob?.cancel()
|
|
saveJob = scope.launch {
|
|
delay(300)
|
|
val savedLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
|
|
settingsStore.save(
|
|
peerInput, combinedHashes, "",
|
|
workersInput.toInt(), "udp", savedLocalPort, sniInput, false
|
|
)
|
|
}
|
|
}
|
|
|
|
val scrollState = rememberScrollState()
|
|
|
|
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
|
|
val isHashesValid = combinedHashes.isNotBlank()
|
|
val isLinkValid = wdttLink.trim().startsWith("wdtt://") && wdttLink.trim().split(":").size >= 6 && wdttLink.trim().split(":")[5].isNotBlank()
|
|
val isManualValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
|
|
val isValid = if (wdttLinkMode) isLinkValid else isManualValid
|
|
val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
|
|
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
|
|
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
|
|
|
|
fun startTunnelService() {
|
|
val effectiveCaptchaMode = if (autoCaptchaEnabled) "auto" else if (useWVCaptcha) "wv" else "rjs"
|
|
val effectiveCaptchaSolveMethod = if (!autoCaptchaEnabled && effectiveCaptchaMode == "wv" && isManualMode) "manual" else "auto"
|
|
saveJob?.cancel()
|
|
scope.launch {
|
|
settingsStore.save(
|
|
peerInput, combinedHashes, "",
|
|
workersInput.toInt(), "udp", effectiveLocalPort, sniInput, false
|
|
)
|
|
settingsStore.saveCaptchaMode(effectiveCaptchaMode)
|
|
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod)
|
|
}
|
|
|
|
var finalPeer = "$peerInput:$effectiveServerDtlsPort"
|
|
var finalHashes = combinedHashes
|
|
var finalLocalPort = effectiveLocalPort
|
|
var finalPassword = savedConnectionPassword
|
|
|
|
if (wdttLinkMode && wdttLink.trim().startsWith("wdtt://")) {
|
|
val clean = wdttLink.trim().removePrefix("wdtt://")
|
|
val parts = clean.split(":")
|
|
if (parts.size >= 5) {
|
|
val ip = parts[0]
|
|
val dtls = parts[1].toIntOrNull() ?: 56000
|
|
finalLocalPort = parts[3].toIntOrNull() ?: 9000
|
|
finalPassword = parts[4]
|
|
val hash = if (parts.size >= 6) parts[5] else ""
|
|
|
|
finalPeer = "$ip:$dtls"
|
|
val rawHash = stripVkUrlStatic(hash)
|
|
finalHashes = if (rawHash.isNotBlank()) rawHash else normalizeHashes(hash)
|
|
}
|
|
}
|
|
|
|
val intent = Intent(context, TunnelService::class.java).apply {
|
|
action = "START"
|
|
putExtra("peer", finalPeer)
|
|
putExtra("vk_hashes", finalHashes)
|
|
putExtra("secondary_vk_hash", "")
|
|
putExtra("workers_per_hash", workersInput.toInt())
|
|
putExtra("port", finalLocalPort)
|
|
putExtra("sni", sniInput)
|
|
putExtra("connection_password", finalPassword)
|
|
putExtra("captcha_mode", effectiveCaptchaMode)
|
|
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
|
|
}
|
|
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent)
|
|
else context.startService(intent)
|
|
}
|
|
|
|
val vpnPermissionLauncher = rememberLauncherForActivityResult(
|
|
ActivityResultContracts.StartActivityForResult()
|
|
) {
|
|
if (pendingStartAfterVpnPermission) {
|
|
pendingStartAfterVpnPermission = false
|
|
if (VpnService.prepare(context) == null) {
|
|
startTunnelService()
|
|
} else {
|
|
Toast.makeText(context, "VPN-разрешение не выдано", Toast.LENGTH_SHORT).show()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun requestVpnAndStart() {
|
|
val vpnIntent = VpnService.prepare(context)
|
|
if (vpnIntent != null) {
|
|
pendingStartAfterVpnPermission = true
|
|
vpnPermissionLauncher.launch(vpnIntent)
|
|
} else {
|
|
startTunnelService()
|
|
}
|
|
}
|
|
|
|
// ═══ Dialogs ═══
|
|
if (showSecretsDialog) {
|
|
SecretsDialog(
|
|
settingsStore = settingsStore,
|
|
initialPassword = savedConnectionPassword,
|
|
manualPortsEnabled = manualPortsEnabled,
|
|
initialServerDtlsPort = serverDtlsPortInput,
|
|
initialServerWgPort = serverWgPortInput,
|
|
initialLocalPort = portInput,
|
|
onSaved = { dtls, wg, local ->
|
|
serverDtlsPortInput = dtls
|
|
serverWgPortInput = wg
|
|
portInput = local
|
|
},
|
|
onDismiss = { showSecretsDialog = false }
|
|
)
|
|
}
|
|
|
|
if (showHashesDialog) {
|
|
HashesDialog(
|
|
hash1 = vkHash1,
|
|
hash2 = vkHash2,
|
|
hash3 = vkHash3,
|
|
hash4 = vkHash4,
|
|
onSave = { h1, h2, h3, h4 ->
|
|
val cleaned1 = stripVkUrlStatic(h1)
|
|
val cleaned2 = stripVkUrlStatic(h2)
|
|
val cleaned3 = stripVkUrlStatic(h3)
|
|
val cleaned4 = stripVkUrlStatic(h4)
|
|
vkHash1 = cleaned1
|
|
vkHash2 = cleaned2
|
|
vkHash3 = cleaned3
|
|
vkHash4 = cleaned4
|
|
saveTunnelSettingsNow(normalizeHashes(cleaned1, cleaned2, cleaned3, cleaned4)) {
|
|
showHashesDialog = false
|
|
}
|
|
},
|
|
onDismiss = { showHashesDialog = false }
|
|
)
|
|
}
|
|
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.verticalScroll(scrollState)
|
|
.padding(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
if (!wdttLinkMode) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
// ═══ Заголовок раздела ═══
|
|
Text(
|
|
"Настройки туннеля",
|
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
color = MaterialTheme.colorScheme.onSurface
|
|
)
|
|
|
|
// ═══ Настройки туннеля ═══
|
|
AppSectionCard(
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
OutlinedTextField(
|
|
value = peerInput,
|
|
onValueChange = {
|
|
peerInput = it.filter { c -> c != ' ' }
|
|
scheduleSave()
|
|
},
|
|
label = { Text("IP сервера или домен (без порта)") },
|
|
placeholder = { Text("1.2.3.4 (или test.com)") },
|
|
singleLine = true,
|
|
isError = !isPeerValid && peerInput.isNotEmpty(),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = OutlinedTextFieldDefaults.colors(
|
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
|
)
|
|
)
|
|
|
|
OutlinedButton(
|
|
onClick = { showHashesDialog = true },
|
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = ButtonDefaults.outlinedButtonColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
contentColor = MaterialTheme.colorScheme.onSurface
|
|
),
|
|
border = BorderStroke(
|
|
1.dp,
|
|
if (hasInputHashErrors) MaterialTheme.colorScheme.error
|
|
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
|
)
|
|
) {
|
|
Icon(Icons.Default.Tag, null, Modifier.size(18.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold)
|
|
}
|
|
|
|
val errorTexts = hashErrors.filter { !it.contains("короткий") }
|
|
if (errorTexts.isNotEmpty()) {
|
|
Text(
|
|
text = errorTexts.joinToString(", "),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══ Мощность + Капча ═══
|
|
AppSectionCard(
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
|
) {
|
|
// — Мощность —
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
"Мощность",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.SemiBold
|
|
)
|
|
Text(
|
|
text = "${currentWorkers.toInt()}",
|
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
color = MaterialTheme.colorScheme.primary
|
|
)
|
|
}
|
|
|
|
Spacer(Modifier.height(4.dp))
|
|
|
|
val maxWorkers = dynamicMaxWorkers
|
|
val minWorkers = WORKERS_PER_GROUP.toFloat()
|
|
val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers)
|
|
|
|
CompactSteppedSlider(
|
|
value = currentWorkersVal,
|
|
onValueChange = { raw ->
|
|
workersInput = roundToGroup(raw, maxWorkers)
|
|
scheduleSave()
|
|
},
|
|
valueRange = minWorkers..maxWorkers,
|
|
stepSize = WORKERS_PER_GROUP.toFloat(),
|
|
enabled = !tunnelRunning,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
|
|
// — Разделитель —
|
|
HorizontalDivider(
|
|
modifier = Modifier.padding(vertical = 4.dp),
|
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
|
)
|
|
|
|
// — Авто капча —
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
if (autoCaptchaEnabled) "Авто капча" else "Ручная капча",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.Medium,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Switch(
|
|
checked = autoCaptchaEnabled,
|
|
onCheckedChange = { enabled ->
|
|
autoCaptchaEnabled = enabled
|
|
scope.launch {
|
|
if (enabled) {
|
|
settingsStore.saveCaptchaMode("auto")
|
|
settingsStore.saveCaptchaSolveMethod("auto")
|
|
} else {
|
|
val mode = if (useWVCaptcha) "wv" else "rjs"
|
|
settingsStore.saveCaptchaMode(mode)
|
|
settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto")
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
AnimatedVisibility(
|
|
visible = !autoCaptchaEnabled,
|
|
enter = fadeIn() + expandVertically(),
|
|
exit = fadeOut() + shrinkVertically()
|
|
) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
|
// — Разделитель —
|
|
HorizontalDivider(
|
|
modifier = Modifier.padding(vertical = 4.dp),
|
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
|
)
|
|
|
|
// — Метод обхода капчи —
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
"Метод обхода капчи",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.Medium,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
ProtocolChip("WBV", useWVCaptcha, enabled = true) {
|
|
useWVCaptcha = true
|
|
isManualMode = wbvManualMode
|
|
scope.launch {
|
|
settingsStore.saveCaptchaMode("wv")
|
|
settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto")
|
|
}
|
|
}
|
|
ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) {
|
|
useWVCaptcha = false
|
|
isManualMode = false
|
|
scope.launch {
|
|
settingsStore.saveCaptchaMode("rjs")
|
|
settingsStore.saveCaptchaSolveMethod("auto")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// — Разделитель —
|
|
HorizontalDivider(
|
|
modifier = Modifier.padding(vertical = 4.dp),
|
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
|
)
|
|
|
|
// — Режим обхода —
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
"Режим обхода",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.Medium,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
if (useWVCaptcha) {
|
|
ProtocolChip(
|
|
"РУЧ",
|
|
isManualMode,
|
|
enabled = true,
|
|
isError = false
|
|
) {
|
|
isManualMode = true
|
|
wbvManualMode = true
|
|
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") }
|
|
}
|
|
ProtocolChip(
|
|
"АВТ",
|
|
!isManualMode,
|
|
enabled = true,
|
|
isError = false
|
|
) {
|
|
isManualMode = false
|
|
wbvManualMode = false
|
|
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") }
|
|
}
|
|
} else {
|
|
ProtocolChip(
|
|
"АВТ",
|
|
selected = true,
|
|
enabled = true,
|
|
isError = false
|
|
) {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HorizontalDivider(
|
|
modifier = Modifier.padding(vertical = 4.dp),
|
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
|
)
|
|
|
|
// — Режим ссылки —
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(top = 8.dp, bottom = 4.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.SpaceBetween
|
|
) {
|
|
Text(
|
|
"Режим ссылки",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
fontWeight = FontWeight.Medium,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Switch(
|
|
checked = wdttLinkMode,
|
|
onCheckedChange = { enabled ->
|
|
scope.launch {
|
|
settingsStore.saveWdttLinkMode(enabled)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
if (wdttLinkMode) {
|
|
Column {
|
|
var linkText by remember(wdttLink) { mutableStateOf(wdttLink) }
|
|
OutlinedTextField(
|
|
value = linkText,
|
|
onValueChange = {
|
|
linkText = it.trim()
|
|
scope.launch { settingsStore.saveWdttLink(it.trim()) }
|
|
},
|
|
label = { Text("Ссылка wdtt://") },
|
|
placeholder = { Text("Ссылка wdtt://") },
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = OutlinedTextFieldDefaults.colors(
|
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══ Кнопки: Секреты + Подключить ═══
|
|
val tunnelSecretsMissing = savedConnectionPassword.isBlank()
|
|
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
if (!wdttLinkMode) {
|
|
OutlinedButton(
|
|
onClick = { showSecretsDialog = true },
|
|
modifier = Modifier.weight(1f).height(52.dp),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = ButtonDefaults.outlinedButtonColors(
|
|
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
|
|
contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
|
|
),
|
|
border = BorderStroke(
|
|
1.dp,
|
|
if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
|
|
)
|
|
) {
|
|
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Text("Секреты", fontWeight = FontWeight.SemiBold, maxLines = 1)
|
|
}
|
|
}
|
|
|
|
val buttonColor by animateColorAsState(
|
|
targetValue = if (tunnelRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
|
animationSpec = tween(400),
|
|
label = "btn_color"
|
|
)
|
|
|
|
Button(
|
|
onClick = {
|
|
if (tunnelRunning) {
|
|
context.startService(
|
|
Intent(context, TunnelService::class.java).apply { action = "STOP" }
|
|
)
|
|
} else {
|
|
requestVpnAndStart()
|
|
}
|
|
},
|
|
enabled = (isValid && !cooldownActive) || tunnelRunning,
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.height(52.dp),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = buttonColor,
|
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
|
)
|
|
) {
|
|
Icon(
|
|
imageVector = if (tunnelRunning) Icons.Default.Stop else Icons.Default.PowerSettingsNew,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
text = when {
|
|
tunnelRunning -> "Остановить"
|
|
cooldownActive -> "Подождите..."
|
|
else -> "Подключить"
|
|
},
|
|
fontWeight = FontWeight.Bold,
|
|
maxLines = 1
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══ Reusable mode chip ═══
|
|
@Composable
|
|
private fun ProtocolChip(label: String, selected: Boolean, enabled: Boolean = true, isError: Boolean = false, onClick: () -> Unit) {
|
|
FilterChip(
|
|
selected = selected,
|
|
onClick = onClick,
|
|
enabled = enabled,
|
|
label = {
|
|
Text(
|
|
label,
|
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
|
color = if (isError) MaterialTheme.colorScheme.error else (if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface)
|
|
)
|
|
},
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = FilterChipDefaults.filterChipColors(
|
|
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
labelColor = MaterialTheme.colorScheme.onSurface,
|
|
disabledLabelColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
),
|
|
border = FilterChipDefaults.filterChipBorder(
|
|
enabled = true,
|
|
selected = selected,
|
|
borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f),
|
|
selectedBorderColor = MaterialTheme.colorScheme.primary
|
|
)
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun CompactSteppedSlider(
|
|
value: Float,
|
|
onValueChange: (Float) -> Unit,
|
|
valueRange: ClosedFloatingPointRange<Float>,
|
|
stepSize: Float,
|
|
enabled: Boolean,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val activeColor = MaterialTheme.colorScheme.primary.copy(alpha = if (enabled) 1f else 0.38f)
|
|
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 1f else 0.55f)
|
|
val thumbStrokeColor = MaterialTheme.colorScheme.surface
|
|
val density = LocalDensity.current
|
|
val thumbRadiusPx = with(density) { 9.dp.toPx() }
|
|
val trackWidthPx = with(density) { 5.dp.toPx() }
|
|
|
|
fun snap(raw: Float): Float {
|
|
val min = valueRange.start
|
|
val max = valueRange.endInclusive
|
|
val snapped = (((raw - min) / stepSize).roundToInt() * stepSize) + min
|
|
return snapped.coerceIn(min, max)
|
|
}
|
|
|
|
fun positionToValue(x: Float, width: Float): Float {
|
|
val left = thumbRadiusPx
|
|
val right = (width - thumbRadiusPx).coerceAtLeast(left + 1f)
|
|
val fraction = ((x.coerceIn(left, right) - left) / (right - left)).coerceIn(0f, 1f)
|
|
return snap(valueRange.start + fraction * (valueRange.endInclusive - valueRange.start))
|
|
}
|
|
|
|
Canvas(
|
|
modifier = modifier
|
|
.height(34.dp)
|
|
.pointerInput(enabled, valueRange, stepSize) {
|
|
if (!enabled) return@pointerInput
|
|
detectTapGestures { offset ->
|
|
onValueChange(positionToValue(offset.x, size.width.toFloat()))
|
|
}
|
|
}
|
|
.pointerInput(enabled, valueRange, stepSize) {
|
|
if (!enabled) return@pointerInput
|
|
detectDragGestures { change, _ ->
|
|
onValueChange(positionToValue(change.position.x, size.width.toFloat()))
|
|
}
|
|
}
|
|
) {
|
|
val centerY = size.height / 2f
|
|
val left = thumbRadiusPx
|
|
val right = size.width - thumbRadiusPx
|
|
val range = (valueRange.endInclusive - valueRange.start).coerceAtLeast(1f)
|
|
val fraction = ((value - valueRange.start) / range).coerceIn(0f, 1f)
|
|
val thumbX = left + (right - left) * fraction
|
|
|
|
drawLine(
|
|
color = inactiveColor,
|
|
start = Offset(left, centerY),
|
|
end = Offset(right, centerY),
|
|
strokeWidth = trackWidthPx,
|
|
cap = StrokeCap.Round
|
|
)
|
|
drawLine(
|
|
color = activeColor,
|
|
start = Offset(left, centerY),
|
|
end = Offset(thumbX, centerY),
|
|
strokeWidth = trackWidthPx,
|
|
cap = StrokeCap.Round
|
|
)
|
|
|
|
val tickCount = (((valueRange.endInclusive - valueRange.start) / stepSize).roundToInt()).coerceAtLeast(1)
|
|
repeat(tickCount + 1) { index ->
|
|
val tickFraction = index / tickCount.toFloat()
|
|
val tickX = left + (right - left) * tickFraction
|
|
drawCircle(
|
|
color = if (tickX <= thumbX) activeColor else inactiveColor,
|
|
radius = 2.dp.toPx(),
|
|
center = Offset(tickX, centerY)
|
|
)
|
|
}
|
|
|
|
drawCircle(
|
|
color = activeColor,
|
|
radius = thumbRadiusPx,
|
|
center = Offset(thumbX, centerY)
|
|
)
|
|
drawCircle(
|
|
color = thumbStrokeColor,
|
|
radius = thumbRadiusPx,
|
|
center = Offset(thumbX, centerY),
|
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
|
|
)
|
|
}
|
|
}
|
|
|
|
// ═══ Important Info Dialog ═══
|
|
@Composable
|
|
fun ImportantInfoDialog(onDismiss: () -> Unit) {
|
|
Dialog(
|
|
onDismissRequest = onDismiss,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth(0.95f).padding(8.dp),
|
|
shape = RoundedCornerShape(24.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
tonalElevation = 6.dp,
|
|
) {
|
|
Column(modifier = Modifier.padding(24.dp).verticalScroll(rememberScrollState())) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text("Важная информация", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(Icons.Default.Close, null)
|
|
}
|
|
}
|
|
|
|
Spacer(Modifier.height(16.dp))
|
|
|
|
InfoSection("Капча ВК",
|
|
"По умолчанию в приложении установлен ручной режим (WBV + РУЧ), но его можно заменить на RJS-АВТ. Это продвинутый автоматический метод решения капчи без всплывающих окон и участия человека, основанный на реверс-инжиниринге JS-кода капчи. Он имитирует действия пользователя в фоновом режиме, обеспечивая бесперебойную работу.\n\nВАЖНО: Если в вашем случае RJS не проходит капчу или выдает ошибки (проблемы со связью или изменения на стороне ВК) — переключитесь обратно в ручной режим."
|
|
)
|
|
InfoSection("Как решать капчу",
|
|
"Она не сложная: нужно просто потянуть слайдер вправо так, чтобы все элементы (обычно это 3 слова) идеально сошлись в пазле."
|
|
)
|
|
InfoSection("Сетевое окружение",
|
|
"Отключите другие VPN/Прокси и «Приватный DNS» перед использованием."
|
|
)
|
|
InfoSection("Связь потоков и капч",
|
|
"Рекомендую выбирать 12-36 потока для меньшего количества капч. Если вам всё равно на частоту ввода капчи в фоне — ставьте 48 и более ради скорости."
|
|
)
|
|
|
|
Spacer(Modifier.height(20.dp))
|
|
Button(
|
|
onClick = onDismiss,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
|
|
) {
|
|
Text("Понятно")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun InfoSection(title: String, body: String) {
|
|
Spacer(Modifier.height(12.dp))
|
|
Text(
|
|
title,
|
|
style = MaterialTheme.typography.titleMedium,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(Modifier.height(4.dp))
|
|
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.height(4.dp))
|
|
}
|
|
|
|
// Округление до ближайшего кратного WORKERS_PER_GROUP
|
|
private fun roundToGroup(value: Float, maxW: Float = 96f): Float {
|
|
val rounded = (Math.round(value / WORKERS_PER_GROUP) * WORKERS_PER_GROUP).toFloat()
|
|
return rounded.coerceIn(WORKERS_PER_GROUP.toFloat(), maxW)
|
|
}
|
|
|
|
/** Извлекает хеш из VK ссылки */
|
|
private fun stripVkUrlStatic(input: String): String {
|
|
var s = input.trim()
|
|
val lower = s.lowercase()
|
|
val prefixes = listOf(
|
|
"https://vk.com/call/join/",
|
|
"http://vk.com/call/join/",
|
|
"https://m.vk.com/call/join/",
|
|
"http://m.vk.com/call/join/",
|
|
"m.vk.com/call/join/",
|
|
"vk.com/call/join/"
|
|
)
|
|
for (prefix in prefixes) {
|
|
if (lower.startsWith(prefix)) {
|
|
s = s.substring(prefix.length)
|
|
break
|
|
}
|
|
}
|
|
val qIdx = s.indexOf('?')
|
|
if (qIdx != -1) s = s.substring(0, qIdx)
|
|
val hIdx = s.indexOf('#')
|
|
if (hIdx != -1) s = s.substring(0, hIdx)
|
|
return s.trimEnd('/')
|
|
}
|
|
|
|
// ═══ Модальное окно хешей ═══
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun HashesDialog(
|
|
hash1: String,
|
|
hash2: String,
|
|
hash3: String,
|
|
hash4: String,
|
|
onSave: (String, String, String, String) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
var h1 by remember { mutableStateOf(hash1) }
|
|
var h2 by remember { mutableStateOf(hash2) }
|
|
var h3 by remember { mutableStateOf(hash3) }
|
|
var h4 by remember { mutableStateOf(hash4) }
|
|
|
|
Dialog(onDismissRequest = onDismiss) {
|
|
Surface(
|
|
shape = RoundedCornerShape(24.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
tonalElevation = 8.dp
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(Icons.Default.Tag, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp))
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("VK Хеши", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
}
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(Icons.Default.Close, contentDescription = "Закрыть")
|
|
}
|
|
}
|
|
|
|
Text(
|
|
text = "Больше хешей — выше лимит потоков и лучшее распределение нагрузки.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(bottom = 4.dp)
|
|
)
|
|
|
|
listOf(
|
|
Triple("VK Хеш 1 *", h1) { v: String -> h1 = v },
|
|
Triple("VK Хеш 2", h2) { v: String -> h2 = v },
|
|
Triple("VK Хеш 3", h3) { v: String -> h3 = v },
|
|
Triple("VK Хеш 4", h4) { v: String -> h4 = v }
|
|
).forEachIndexed { idx, (label, value, onChange) ->
|
|
val isShort = value.isNotBlank() && value.length < 16
|
|
OutlinedTextField(
|
|
value = value,
|
|
onValueChange = { raw ->
|
|
val cleaned = raw.filter { c -> c != ' ' && c != '\n' }
|
|
onChange(stripVkUrlStatic(cleaned))
|
|
},
|
|
label = { Text(label) },
|
|
placeholder = { Text("Ссылка звонка или хеш") },
|
|
singleLine = true,
|
|
isError = isShort,
|
|
supportingText = if (isShort) {
|
|
{ Text("Хеш ${idx + 1} — короткий (мин. 16)", color = MaterialTheme.colorScheme.error) }
|
|
} else null,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
)
|
|
}
|
|
|
|
Button(
|
|
onClick = {
|
|
onSave(h1, h2, h3, h4)
|
|
},
|
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
|
shape = RoundedCornerShape(16.dp),
|
|
enabled = h1.isNotBlank() && h1.length >= 16,
|
|
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
|
|
) {
|
|
Text("Сохранить", fontWeight = FontWeight.SemiBold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══ Модальное окно секретов ═══
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun SecretsDialog(
|
|
settingsStore: SettingsStore,
|
|
initialPassword: String,
|
|
manualPortsEnabled: Boolean,
|
|
initialServerDtlsPort: String,
|
|
initialServerWgPort: String,
|
|
initialLocalPort: String,
|
|
onSaved: (String, String, String) -> Unit,
|
|
onDismiss: () -> Unit
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var passwordInput by rememberSaveable { mutableStateOf(initialPassword) }
|
|
var serverDtlsPort by rememberSaveable { mutableStateOf(initialServerDtlsPort.ifBlank { "56000" }) }
|
|
var serverWgPort by rememberSaveable { mutableStateOf(initialServerWgPort.ifBlank { "56001" }) }
|
|
var localPort by rememberSaveable { mutableStateOf(initialLocalPort.ifBlank { "9000" }) }
|
|
|
|
fun normalizePort(value: String, fallback: String): String {
|
|
return value.toIntOrNull()?.takeIf { it in 1..65535 }?.toString() ?: fallback
|
|
}
|
|
|
|
Dialog(onDismissRequest = onDismiss) {
|
|
Surface(
|
|
shape = RoundedCornerShape(24.dp),
|
|
color = MaterialTheme.colorScheme.surface,
|
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
|
tonalElevation = 8.dp
|
|
) {
|
|
Column(
|
|
modifier = Modifier.padding(24.dp).fillMaxWidth().verticalScroll(rememberScrollState())
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Icon(
|
|
imageVector = Icons.Default.Key,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Text("Секреты", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
}
|
|
IconButton(onClick = onDismiss) {
|
|
Icon(imageVector = Icons.Default.Close, contentDescription = "Закрыть")
|
|
}
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
OutlinedTextField(
|
|
value = passwordInput,
|
|
onValueChange = { passwordInput = it },
|
|
label = { Text("Заданный пароль туннеля") },
|
|
placeholder = { Text("Придумайте надежный пароль") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
)
|
|
|
|
if (manualPortsEnabled) {
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
HorizontalDivider()
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Text("Порты", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = serverDtlsPort,
|
|
onValueChange = { serverDtlsPort = it.filter(Char::isDigit).take(5) },
|
|
label = { Text("Порт сервера DTLS") },
|
|
placeholder = { Text("56000") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = serverWgPort,
|
|
onValueChange = { serverWgPort = it.filter(Char::isDigit).take(5) },
|
|
label = { Text("Порт сервера WireGuard") },
|
|
placeholder = { Text("56001") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
OutlinedTextField(
|
|
value = localPort,
|
|
onValueChange = { localPort = it.filter(Char::isDigit).take(5) },
|
|
label = { Text("Локальный порт VPN") },
|
|
placeholder = { Text("9000") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
shape = RoundedCornerShape(16.dp),
|
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
)
|
|
}
|
|
|
|
Spacer(modifier = Modifier.height(20.dp))
|
|
|
|
Button(
|
|
onClick = {
|
|
val finalDtls = normalizePort(serverDtlsPort, "56000")
|
|
val finalWg = normalizePort(serverWgPort, "56001")
|
|
val finalLocal = normalizePort(localPort, "9000")
|
|
scope.launch {
|
|
settingsStore.saveConnectionPassword(passwordInput)
|
|
settingsStore.savePorts(finalDtls.toInt(), finalWg.toInt(), finalLocal.toInt())
|
|
onSaved(finalDtls, finalWg, finalLocal)
|
|
onDismiss()
|
|
}
|
|
},
|
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
|
shape = RoundedCornerShape(16.dp),
|
|
enabled = passwordInput.isNotEmpty(),
|
|
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
|
|
) {
|
|
Text("Сохранить", fontWeight = FontWeight.SemiBold)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// extension
|
|
private fun androidx.compose.ui.graphics.Color.luminance(): Float {
|
|
val r = red
|
|
val g = green
|
|
val b = blue
|
|
return 0.2126f * r + 0.7152f * g + 0.0722f * b
|
|
}
|