Update v1.2.0

This commit is contained in:
amurcanov
2026-05-26 22:48:52 +03:00
parent 63ba2cf1d9
commit bc0c8f5fc9
33 changed files with 1689 additions and 546 deletions
@@ -190,17 +190,18 @@ class MainActivity : ComponentActivity() {
// ═══ Навигация ═══
private data class NavItem(
val id: Int,
val label: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
)
private val navItems = listOf(
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info),
NavItem(0, "Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem(1, "Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem(2, "Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem(3, "Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem(4, "Инфо", Icons.Filled.Info, Icons.Outlined.Info),
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +221,8 @@ fun MainScreen(
val context = LocalContext.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var dragTargetIndex by remember { mutableIntStateOf(-1) }
var dragProgress by remember { mutableFloatStateOf(0f) }
@@ -231,6 +234,24 @@ fun MainScreen(
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
val navOverlayReserve = safeBottomInset + 96.dp
val activeNavItems = remember(wdttLinkMode) {
if (wdttLinkMode) {
navItems.filter { it.id != 1 }
} else {
navItems
}
}
val actionsExpanded = rememberSaveable { mutableStateOf(false) }
val projectExpanded = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(wdttLinkMode) {
if (wdttLinkMode && selectedTab == 1) {
selectedTab = 0
}
}
LaunchedEffect(selectedTab) {
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
}
@@ -297,7 +318,7 @@ fun MainScreen(
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.pointerInput(selectedTab) {
.pointerInput(selectedTab, wdttLinkMode) {
var totalDrag = 0f
detectHorizontalDragGestures(
onDragStart = {
@@ -310,8 +331,8 @@ fun MainScreen(
dragProgress = 0f
},
onDragEnd = {
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) {
selectedTab = dragTargetIndex
if (dragTargetIndex in activeNavItems.indices && dragProgress >= 0.5f) {
selectedTab = activeNavItems[dragTargetIndex].id
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
}
dragTargetIndex = -1
@@ -326,8 +347,9 @@ fun MainScreen(
return@detectHorizontalDragGestures
}
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1
if (candidate !in navItems.indices) {
val currentActiveIndex = activeNavItems.indexOfFirst { it.id == selectedTab }
val candidate = if (totalDrag < 0f) currentActiveIndex + 1 else currentActiveIndex - 1
if (candidate !in activeNavItems.indices) {
dragTargetIndex = -1
dragProgress = 0f
return@detectHorizontalDragGestures
@@ -350,15 +372,15 @@ fun MainScreen(
) { tab ->
when (tab) {
0 -> SettingsTab()
1 -> DeployTab()
1 -> if (!wdttLinkMode) DeployTab() else Spacer(modifier = Modifier.fillMaxSize())
2 -> ExceptionsTab()
3 -> LogsTab()
4 -> InfoTab()
4 -> InfoTab(actionsExpandedState = actionsExpanded, projectExpandedState = projectExpanded)
}
}
ProxyNavigationBar(
navItems = navItems,
navItems = activeNavItems,
selectedTab = selectedTab,
dragTargetIndex = dragTargetIndex,
dragProgress = dragProgress,
@@ -380,6 +402,10 @@ fun MainScreen(
// Floating theme toolbar overlay
FloatingToolbar(
activeProfile = activeProfile,
onActiveProfileChange = { profile ->
scope.launch { settingsStore.saveActiveProfile(profile) }
},
currentTheme = themeMode,
onThemeChange = onThemeChange,
isDynamicColor = isDynamicColor,
@@ -453,13 +479,16 @@ private fun ProxyNavigationBar(
} else {
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
}
val indicatorIndex = remember { Animatable(selectedTab.toFloat()) }
val selectedVisualIndex = remember(selectedTab, navItems) {
navItems.indexOfFirst { it.id == selectedTab }.coerceAtLeast(0)
}
val indicatorIndex = remember { Animatable(selectedVisualIndex.toFloat()) }
val dragVisualIndex = indicatorIndex.value
LaunchedEffect(selectedTab) {
LaunchedEffect(selectedVisualIndex) {
if (dragTargetIndex !in navItems.indices) {
indicatorIndex.animateTo(
targetValue = selectedTab.toFloat(),
targetValue = selectedVisualIndex.toFloat(),
animationSpec = tween(
durationMillis = 720,
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
@@ -468,9 +497,9 @@ private fun ProxyNavigationBar(
}
}
LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) {
LaunchedEffect(selectedVisualIndex, dragTargetIndex, dragProgress) {
if (dragTargetIndex in navItems.indices) {
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress
val target = selectedVisualIndex.toFloat() + (dragTargetIndex - selectedVisualIndex) * dragProgress
indicatorIndex.snapTo(target)
}
}
@@ -522,7 +551,7 @@ private fun ProxyNavigationBar(
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(22.dp))
.clickable { onTabSelected(index) },
.clickable { onTabSelected(item.id) },
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -533,7 +562,7 @@ private fun ProxyNavigationBar(
modifier = Modifier.size(22.dp),
tint = iconColor
)
if (index == 3 && unreadErrors > 0) {
if (item.id == 3 && unreadErrors > 0) {
Badge(
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
contentColor = colors.onPrimary,
@@ -0,0 +1,167 @@
package com.wdtt.client
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.net.VpnService
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class QuickToggleTileService : TileService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var stateJob: Job? = null
override fun onStartListening() {
super.onStartListening()
// Реактивно подписываемся на статус активности туннеля.
// Плитка будет строго отражать РЕАЛЬНОЕ состояние туннеля на 100% без рассинхронизаций.
stateJob?.cancel()
stateJob = scope.launch {
try {
TunnelManager.running.collect { running ->
updateTile(running)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Error collecting running state", e)
}
}
}
override fun onStopListening() {
stateJob?.cancel()
super.onStopListening()
}
override fun onClick() {
super.onClick()
runCatching {
if (TunnelManager.running.value) {
// Если запущен — останавливаем. Состояние плитки изменится автоматически,
// когда TunnelManager остановит процессы и обновит статус running в false.
val stopIntent = Intent(this, TunnelService::class.java).apply { action = "STOP" }
startService(stopIntent)
return
}
// Проверяем наличие выданного разрешения VPN перед стартом
if (VpnService.prepare(this) != null) {
Toast.makeText(this, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity()
return
}
// Запускаем старт туннеля в фоне
scope.launch {
try {
val intent = buildStartIntent()
if (intent == null) {
Toast.makeText(this@QuickToggleTileService, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity()
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
startForegroundService(intent)
} else {
startService(intent)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Failed to start tunnel via QS tile", e)
Toast.makeText(this@QuickToggleTileService, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Crash prevented in onClick", e)
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private suspend fun buildStartIntent(): Intent? {
return runCatching {
val store = SettingsStore(applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
Intent(this, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}.getOrNull()
}
private fun updateTile(running: Boolean) {
runCatching {
qsTile?.apply {
label = "WDTT"
icon = Icon.createWithResource(this@QuickToggleTileService, R.drawable.ic_tile_logo_w)
state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= 29) {
subtitle = if (running) "Подключено" else "Отключено"
}
updateTile()
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to update QS tile state", e)
}
}
private fun openMainActivity() {
runCatching {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (Build.VERSION.SDK_INT >= 34) {
val pendingIntent = PendingIntent.getActivity(
this,
100,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to open MainActivity", e)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -16,10 +16,18 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import android.os.Build
class SettingsStore(context: Context) {
private val appContext = context.applicationContext
companion object {
private val Context.dataStore by preferencesDataStore("settings")
private val ACTIVE_PROFILE = intPreferencesKey("active_profile")
private val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
private val LOGGING_ENABLED = booleanPreferencesKey("logging_enabled")
private val WDTT_LINK = stringPreferencesKey("wdtt_link")
private val WDTT_LINK_MODE = booleanPreferencesKey("wdtt_link_mode")
private val PEER = stringPreferencesKey("peer")
private val VK_HASHES = stringPreferencesKey("vk_hashes")
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
@@ -83,6 +91,18 @@ class SettingsStore(context: Context) {
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
private fun <T> getProfileKey(baseKey: Preferences.Key<T>, profile: Int): Preferences.Key<T> {
if (profile == 0) return baseKey
val newName = "${baseKey.name}_$profile"
@Suppress("UNCHECKED_CAST")
return when (baseKey) {
PEER, VK_HASHES, SECONDARY_VK_HASH, PROTOCOL, SNI, USER_AGENT, DEPLOY_IP, DEPLOY_LOGIN, DEPLOY_PASSWORD, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_SSH_PORT, EXCLUDED_APPS, CONNECTION_PASSWORD, CONNECTION_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_ADMIN_ID, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_BOT_TOKEN, DEPLOY_BOT_TOKEN_ENCRYPTED, PROXY_MODE, PROXY_HOST, CAPTCHA_MODE, CAPTCHA_SOLVE_METHOD, CAPTCHA_WBV_SOLVE_METHOD, WDTT_LINK -> stringPreferencesKey(newName) as Preferences.Key<T>
WORKERS_PER_HASH, LISTEN_PORT, SERVER_DTLS_PORT, SERVER_WG_PORT, PROXY_PORT -> intPreferencesKey(newName) as Preferences.Key<T>
MANUAL_PORTS_ENABLED, NO_DTLS, NO_DNS, IS_WHITELIST, WDTT_LINK_MODE -> booleanPreferencesKey(newName) as Preferences.Key<T>
else -> throw IllegalArgumentException("Unsupported key type: ${baseKey.name}")
}
}
}
private val dataStore = appContext.dataStore
@@ -94,65 +114,151 @@ class SettingsStore(context: Context) {
}
}
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" }
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" }
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" }
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 }
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" }
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 }
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false }
val serverDtlsPort: Flow<Int> = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 }
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 }
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" }
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false }
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" }
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" }
val deployPassword: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
val activeProfile: Flow<Int> = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 }
val showSystemApps: Flow<Boolean> = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true }
val loggingEnabled: Flow<Boolean> = dataStore.data.map { it[LOGGING_ENABLED] ?: true }
val wdttLink: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK, profile)] ?: ""
}
val wdttLinkMode: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false
}
val peer: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PEER, profile)] ?: ""
}
val vkHashes: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(VK_HASHES, profile)] ?: ""
}
val secondaryVkHash: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] ?: ""
}
val workersPerHash: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WORKERS_PER_HASH, profile)] ?: 16
}
val protocol: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROTOCOL, profile)] ?: "udp"
}
val listenPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(LISTEN_PORT, profile)] ?: 9000
}
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] ?: false
}
val serverDtlsPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] ?: 56000
}
val serverWgPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_WG_PORT, profile)] ?: 56001
}
val sni: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SNI, profile)] ?: ""
}
val noDns: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(NO_DNS, profile)] ?: false
}
val userAgent: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] ?: ""
}
val deployIp: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_IP, profile)] ?: ""
}
val deployLogin: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_LOGIN, profile)] ?: ""
}
val deployPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, profile)
}
val deploySshPort: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] ?: ""
}
val excludedApps: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] ?: ""
}
val deploySshPort: Flow<String> = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" }
val excludedApps: Flow<String> = dataStore.data.map { it[EXCLUDED_APPS] ?: "" }
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
// ═══ Пароли и Управление ═══
val connectionPassword: Flow<String> = dataStore.data.map {
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
val connectionPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile)
}
val deployMainPassword: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
val deployMainPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile)
}
val deployAdminId: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
val deployAdminId: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile)
}
val deployBotToken: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
val deployBotToken: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, profile)
}
// ═══ Proxy Mode ═══
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" }
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" }
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 }
val proxyMode: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_MODE, profile)] ?: "tun"
}
val proxyHost: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_HOST, profile)] ?: "127.0.0.1"
}
val proxyPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_PORT, profile)] ?: 1080
}
// ═══ Captcha Solve Mode ═══
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" }
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" }
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" }
val captchaMode: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] ?: "auto"
}
val captchaSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] ?: "auto"
}
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] ?: "auto"
}
// ═══ VPN Exclusions Mode ═══
val isWhitelist: Flow<Boolean> = dataStore.data.map { it[IS_WHITELIST] ?: false }
val isWhitelist: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] ?: false
}
// ═══ Theme Mode ═══
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false }
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) }
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS }
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: 24 }
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
@@ -215,6 +321,38 @@ class SettingsStore(context: Context) {
}
}
suspend fun saveActiveProfile(profile: Int) {
dataStore.edit { prefs ->
prefs[ACTIVE_PROFILE] = profile
}
}
suspend fun saveShowSystemApps(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[SHOW_SYSTEM_APPS] = enabled
}
}
suspend fun saveLoggingEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[LOGGING_ENABLED] = enabled
}
}
suspend fun saveWdttLink(link: String) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK, profile)] = link
}
}
suspend fun saveWdttLinkMode(enabled: Boolean) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK_MODE, profile)] = enabled
}
}
suspend fun save(
peer: String,
vkHashes: String,
@@ -226,102 +364,115 @@ class SettingsStore(context: Context) {
noDns: Boolean = false
) {
dataStore.edit { prefs ->
prefs[PEER] = peer
prefs[VK_HASHES] = vkHashes
prefs[SECONDARY_VK_HASH] = secondaryVkHash
prefs[WORKERS_PER_HASH] = workersPerHash
prefs[PROTOCOL] = protocol
prefs[LISTEN_PORT] = listenPort
prefs[SNI] = sni
prefs[NO_DNS] = noDns
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PEER, profile)] = peer
prefs[getProfileKey(VK_HASHES, profile)] = vkHashes
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] = secondaryVkHash
prefs[getProfileKey(WORKERS_PER_HASH, profile)] = workersPerHash
prefs[getProfileKey(PROTOCOL, profile)] = protocol
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
prefs[getProfileKey(SNI, profile)] = sni
prefs[getProfileKey(NO_DNS, profile)] = noDns
}
}
suspend fun saveManualPortsEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[MANUAL_PORTS_ENABLED] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] = enabled
}
}
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
dataStore.edit { prefs ->
prefs[SERVER_DTLS_PORT] = serverDtlsPort
prefs[SERVER_WG_PORT] = serverWgPort
prefs[LISTEN_PORT] = listenPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] = serverDtlsPort
prefs[getProfileKey(SERVER_WG_PORT, profile)] = serverWgPort
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
}
}
suspend fun saveUserAgent(ua: String) {
dataStore.edit { prefs ->
prefs[USER_AGENT] = ua
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] = ua
}
}
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
dataStore.edit { prefs ->
prefs[DEPLOY_IP] = ip
prefs[DEPLOY_LOGIN] = login
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass)
prefs[DEPLOY_SSH_PORT] = sshPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_IP, profile)] = ip
prefs[getProfileKey(DEPLOY_LOGIN, profile)] = login
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
}
}
suspend fun saveExcludedApps(packages: String) {
dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
}
}
suspend fun saveDetailedLogs(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[DETAILED_LOGS] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DETAILED_LOGS, profile)] = enabled
}
}
// ═══ Сохранение пароля подключения ═══
suspend fun saveConnectionPassword(password: String) {
dataStore.edit { prefs ->
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password)
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password, profile)
}
}
// ═══ Сохранение секретов деплоя ═══
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
dataStore.edit { prefs ->
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass)
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken)
prefs[DEPLOY_SSH_PORT] = sshPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass, profile)
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId, profile)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
}
}
// ═══ Сохранение proxy mode ═══
suspend fun saveProxyMode(mode: String, host: String, port: Int) {
dataStore.edit { prefs ->
prefs[PROXY_MODE] = mode
prefs[PROXY_HOST] = host
prefs[PROXY_PORT] = port
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_MODE, profile)] = mode
prefs[getProfileKey(PROXY_HOST, profile)] = host
prefs[getProfileKey(PROXY_PORT, profile)] = port
}
}
// ═══ Сохранение режима обхода капчи ═══
suspend fun saveCaptchaMode(mode: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_MODE] = mode
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] = mode
}
}
suspend fun saveCaptchaSolveMethod(method: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_SOLVE_METHOD] = method
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
}
}
suspend fun saveWbvCaptchaSolveMethod(method: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method
if (prefs[CAPTCHA_MODE] == "wv") {
prefs[CAPTCHA_SOLVE_METHOD] = method
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] = method
if (prefs[getProfileKey(CAPTCHA_MODE, profile)] == "wv") {
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
}
}
}
@@ -329,47 +480,57 @@ class SettingsStore(context: Context) {
// ═══ Сохранение режима списка (ЧС/БС) ═══
suspend fun saveIsWhitelist(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[IS_WHITELIST] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] = enabled
}
}
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages
prefs[IS_WHITELIST] = isWhitelist
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
prefs[getProfileKey(IS_WHITELIST, profile)] = isWhitelist
}
}
private suspend fun migrateSecretsToKeystore() {
dataStore.edit { prefs ->
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
for (profile in 0..2) {
prefs.migrateSecret(getProfileKey(DEPLOY_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(CONNECTION_PASSWORD_ENCRYPTED, profile), getProfileKey(CONNECTION_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_MAIN_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_MAIN_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_ADMIN_ID_ENCRYPTED, profile), getProfileKey(DEPLOY_ADMIN_ID, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_BOT_TOKEN_ENCRYPTED, profile), getProfileKey(DEPLOY_BOT_TOKEN, profile))
}
}
}
private fun readSecret(
prefs: Preferences,
encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String>
legacyKey: Preferences.Key<String>,
profile: Int
): String {
return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: ""
val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
return secureStore.decrypt(prefs[profEncryptedKey]) ?: prefs[profLegacyKey] ?: ""
}
private fun MutablePreferences.putSecret(
encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String>,
value: String
value: String,
profile: Int
) {
val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
if (value.isBlank()) {
remove(encryptedKey)
remove(legacyKey)
remove(profEncryptedKey)
remove(profLegacyKey)
} else {
this[encryptedKey] = secureStore.encrypt(value)
remove(legacyKey)
this[profEncryptedKey] = secureStore.encrypt(value)
remove(profLegacyKey)
}
}
@@ -40,7 +40,7 @@ object TunnelManager {
private var refusedCount = 0
private var currentHashErrorCount = 0
private var wrapAuthTimeoutCount = 0
private var processStartedAtMs = 0L
var processStartedAtMs = 0L
private var lastActiveAtMs = 0L
private var activeHashIndex = 0 // 0: primary, 1: secondary
private var currentParams: TunnelParams? = null
@@ -49,6 +49,9 @@ object TunnelManager {
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
@Volatile
var isLoggingEnabled = true
val running = MutableStateFlow(false)
val logs = MutableStateFlow<List<LogEntry>>(emptyList())
val unreadErrorCount = MutableStateFlow(0)
@@ -56,7 +59,7 @@ object TunnelManager {
val stats = MutableStateFlow("Ожидание данных...")
val activeWorkers = MutableStateFlow(0)
val cooldownSeconds = MutableStateFlow(0)
val cooldownActive = MutableStateFlow(false)
private var cooldownJob: Job? = null
fun clearUnreadErrors() {
@@ -75,6 +78,7 @@ object TunnelManager {
}
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
if (!isLoggingEnabled) return
if (isError) {
val list = logs.value
if (list.none { it.key == key }) {
@@ -152,13 +156,11 @@ object TunnelManager {
}
val hashCount = hashList.size.coerceIn(1, 3)
val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса
val totalWorkers = params.workersPerHash.coerceIn(1, 128)
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
val binaryFile = File(binaryPath)
@@ -182,15 +184,13 @@ object TunnelManager {
cmd.add("-password")
cmd.add(params.connectionPassword)
// Captcha mode: wv или rjs
cmd.add("-captcha-mode")
cmd.add(params.captchaMode)
val pb = ProcessBuilder(cmd)
pb.directory(context.filesDir) // Устанавливаем рабочую директорию
pb.directory(context.filesDir)
pb.redirectErrorStream(true)
// Set LD_LIBRARY_PATH
val env = pb.environment()
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
@@ -220,7 +220,6 @@ object TunnelManager {
var lastResetTime = System.currentTimeMillis()
reader.forEachLine { line ->
// Периодический сброс счетчиков ошибок (раз в 60 сек)
val now = System.currentTimeMillis()
if (now - lastResetTime > 60000) {
refusedCount = 0
@@ -230,13 +229,11 @@ object TunnelManager {
lastResetTime = now
}
// Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...")
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
val lineTrim = msgPrefixReplaced.trim()
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
// 0. FATAL AUTH — мгновенная остановка
if (lineTrim.contains("FATAL_AUTH")) {
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
@@ -271,30 +268,27 @@ object TunnelManager {
return@forEachLine
}
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
if (activeWorkers.value > 0) {
wrapAuthTimeoutCount = 0
updateLog(
"wrap_timeout_recovered",
"[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем",
50,
true
50,
true
)
} else {
wrapAuthTimeoutCount++
updateLog(
"wrap_timeout_wait",
"[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)",
50,
true
50,
true
)
}
return@forEachLine
}
// 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима.
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
val parts = payload.split("|", limit = 3)
@@ -321,7 +315,6 @@ object TunnelManager {
return@forEachLine
}
// 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker)
if (isError) {
when {
lineTrim.contains("Flood control", true) -> {
@@ -339,7 +332,6 @@ object TunnelManager {
}
}
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
// Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети
refusedCount++
if (refusedCount >= 400) {
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
@@ -348,7 +340,6 @@ object TunnelManager {
}
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
currentHashErrorCount++
// Нужно больше попыток, так как 1 воркер может спамить
if (currentHashErrorCount >= 10) {
handleHashError()
return@forEachLine
@@ -357,7 +348,6 @@ object TunnelManager {
}
}
// 1. Статистика (Обновляемая строка)
if (lineTrim.contains("[СТАТИСТИКА]")) {
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
stats.value = msg
@@ -376,10 +366,7 @@ object TunnelManager {
return@forEachLine
}
// 2. Этапы подключения и Ошибки
when {
// ═══ Авто-оркестратор капчи ═══
lineTrim.contains("[КАПЧА] AUTO:") -> {
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -399,9 +386,7 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
}
// ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══
lineTrim.contains("[КАПЧА] RJS:") -> {
// Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...)
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -417,15 +402,14 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
}
// ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══
lineTrim.contains("[КАПЧА] WBV:") -> {
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
val isErr = text.contains("Ошибка")
val stableKey = when {
text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV)
text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением)
text.contains("Запрос") -> "captcha_wv_step_2"
text.contains("Токен") -> "captcha_wv_step_5"
isErr -> "captcha_wv_err"
else -> "captcha_wv_go_other"
}
@@ -462,9 +446,7 @@ object TunnelManager {
lineTrim.contains("Активна ✓") ->
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
// Ошибки (в конец)
isError -> {
// Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки)
val errorKey = when {
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
lineTrim.contains("connection refused") -> "err_conn_refused"
@@ -482,7 +464,6 @@ object TunnelManager {
}
}
// 3. Обработка конфига (Скрываем от пользователя)
if (line.contains("") && line.contains("WireGuard")) {
collectingConfig = true
configBuilder.clear()
@@ -528,7 +509,7 @@ object TunnelManager {
val context = lastContext ?: return
currentHashErrorCount = 0
forceRegenerateUA = true // Перегенерируем UA при следующих ошибках
forceRegenerateUA = true
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
@@ -541,18 +522,14 @@ object TunnelManager {
}
}
// ==================== WATCHDOG ====================
// Проверяет, жив ли Go-процесс. Если умер — перезапускает.
// Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби).
private fun startWatchdog(context: Context, params: TunnelParams) {
watchdogJob?.cancel()
watchdogJob = scope.launch {
var zeroWorkersSince = 0L
delay(10_000) // Даём 10 сек на старт
delay(10_000)
while (isActive && running.value) {
val proc = process
if (proc == null || !proc.isAlive) {
// Go-процесс мёртв!
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
activeWorkers.value = 0
forceRegenerateUA = true
@@ -561,10 +538,9 @@ object TunnelManager {
if (running.value) {
start(context, params, isSwitching = true)
}
return@launch // startWatchdog будет перезапущен из start()
return@launch
}
// Детекция зомби: процесс жив, но 0 воркеров
val workers = activeWorkers.value
if (workers <= 0) {
if (zeroWorkersSince == 0L) {
@@ -601,7 +577,7 @@ object TunnelManager {
val params = currentParams ?: return
val context = lastContext ?: return
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
killProcess() // Только убиваем процесс, running не трогаем!
killProcess()
scope.launch {
delay(1500)
start(context, params, isSwitching = true)
@@ -610,7 +586,7 @@ object TunnelManager {
fun pause() {
if (!running.value) return
killProcess() // Не ставим running=false, чтоб сервис не умер
killProcess()
activeWorkers.value = 0
}
@@ -622,7 +598,6 @@ object TunnelManager {
}
}
// Убивает процесс без изменения running
private fun killProcess() {
watchdogJob?.cancel()
readerJob?.cancel()
@@ -630,7 +605,6 @@ object TunnelManager {
process = null
if (proc != null) {
try { proc.destroy() } catch (_: Exception) {}
// Даём 500мс на graceful shutdown
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
if (proc.isAlive) {
try { proc.destroyForcibly() } catch (_: Exception) {}
@@ -644,10 +618,6 @@ object TunnelManager {
running.value = false
}
private fun log(message: String) {
updateLog("internal_${message.hashCode()}", message, 50, false)
}
fun stop() {
scope.launch(Dispatchers.Main) {
wgHelper?.stopTunnel()
@@ -659,9 +629,7 @@ object TunnelManager {
ManlCaptchaWebViewManager.cancelCaptcha()
}
// Suspend-версия: гарантирует что процесс мёртв и порт свободен
suspend fun stopAndWait() {
// Сначала останавливаем WireGuard и ждём завершения
withContext(Dispatchers.Main) {
wgHelper?.stopTunnel()
}
@@ -671,11 +639,10 @@ object TunnelManager {
activeWorkers.value = 0
currentParams = null
ManlCaptchaWebViewManager.cancelCaptcha()
// Ждём освобождения порта 9000 (до 3 секунд)
repeat(30) {
try {
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
return@withContext // Порт свободен!
return@withContext
} catch (_: Exception) {
delay(100)
}
@@ -691,15 +658,6 @@ object TunnelManager {
}
}
// ==================== CAPTCHA SOLVER (WebView Mode) ====================
/**
* Вызывается при получении CAPTCHA_SOLVE от Go-процесса.
* auto: одна короткая скрытая попытка для Go-оркестратора.
* manual: сразу видимый WebView.
* selected: старое поведение из UI, когда пользователь сам выбрал режим.
* Результат ВСЕГДА отправляется обратно в Go через writeCaptchaResult.
*/
private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
val ctx = lastContext ?: run {
writeCaptchaResult("error:context is null")
@@ -743,7 +701,6 @@ object TunnelManager {
writeCaptchaResult("error:$errorMsg")
}
// WebView уничтожен в finally блоке соответствующего менеджера.
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
}
@@ -785,9 +742,6 @@ object TunnelManager {
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
}
/**
* Записывает результат решения капчи в stdin Go-процесса.
*/
private fun writeCaptchaResult(result: String) {
val proc = process
if (proc == null || !proc.isAlive) return
@@ -802,17 +756,17 @@ object TunnelManager {
fun clearLogs() {
logs.value = emptyList()
activeWorkers.value = 0
if (!running.value) {
activeWorkers.value = 0
}
}
fun startCooldown(seconds: Int) {
fun startCooldown(millis: Long) {
cooldownJob?.cancel()
cooldownSeconds.value = seconds
cooldownActive.value = true
cooldownJob = scope.launch(Dispatchers.Main) {
while (cooldownSeconds.value > 0) {
delay(1000)
cooldownSeconds.update { it - 1 }
}
delay(millis)
cooldownActive.value = false
}
}
@@ -831,6 +785,6 @@ data class TunnelParams(
val sni: String = "",
val connectionPassword: String = "",
val protocol: String = "udp",
val captchaMode: String = "auto", // "auto", "wv" или "rjs"
val captchaSolveMethod: String = "auto" // "manual" или "auto"
val captchaMode: String = "auto",
val captchaSolveMethod: String = "auto"
)
@@ -283,6 +283,15 @@ class TunnelService : Service() {
stopSelf()
break
}
if (TunnelManager.running.value && !isTunnelPaused) {
val helper = WireGuardHelper(applicationContext)
val startupWindow = System.currentTimeMillis() - TunnelManager.processStartedAtMs < 6000
if (!startupWindow && !helper.isTunnelUp()) {
Log.w("TunnelService", "Обнаружена пропажа или замена VPN-интерфейса! Экстренное выключение туннеля.")
stopTunnel()
break
}
}
if (!isTunnelPaused) {
updateNotification(buildTunnelNotificationText())
}
@@ -0,0 +1,190 @@
package com.wdtt.client
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import android.widget.RemoteViews
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class VpnWidgetProvider : AppWidgetProvider() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
companion object {
const val ACTION_WIDGET_TOGGLE = "com.wdtt.client.ACTION_WIDGET_TOGGLE"
fun updateAllWidgets(context: Context) {
runCatching {
val appWidgetManager = AppWidgetManager.getInstance(context)
val thisWidget = ComponentName(context, VpnWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
if (appWidgetIds.isNotEmpty()) {
val intent = Intent(context, VpnWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
context.sendBroadcast(intent)
}
}
}
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
val running = TunnelManager.running.value
for (appWidgetId in appWidgetIds) {
updateWidgetState(context, appWidgetManager, appWidgetId, running)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_WIDGET_TOGGLE) {
runCatching {
if (TunnelManager.running.value) {
// Останавливаем туннель
val stopIntent = Intent(context, TunnelService::class.java).apply { action = "STOP" }
context.startService(stopIntent)
updateAllWidgets(context)
return
}
if (VpnService.prepare(context) != null) {
Toast.makeText(context, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity(context)
return
}
// Запуск туннеля в фоне
scope.launch {
try {
val startIntent = buildStartIntent(context)
if (startIntent == null) {
Toast.makeText(context, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity(context)
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(startIntent)
} else {
context.startService(startIntent)
}
} catch (e: Exception) {
Log.e("VpnWidget", "Failed to start tunnel from widget", e)
Toast.makeText(context, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("VpnWidget", "Error handling widget click", e)
}
}
}
private fun updateWidgetState(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
running: Boolean
) {
val views = RemoteViews(context.packageName, R.layout.vpn_widget)
// Обновляем текст статуса и неоновую иконку кнопки
if (running) {
views.setTextViewText(R.id.widget_status, "Подключено")
views.setTextColor(R.id.widget_status, 0xFF00E5FF.toInt()) // Неоновый голубой
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_active)
} else {
views.setTextViewText(R.id.widget_status, "Отключено")
views.setTextColor(R.id.widget_status, 0xFF888888.toInt()) // Матовый серый
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_inactive)
}
// Клик по всей карточке открывает приложение
val openIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val openPendingIntent = PendingIntent.getActivity(
context,
appWidgetId,
openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
// Клик по кнопке запускает/останавливает VPN
val toggleIntent = Intent(context, VpnWidgetProvider::class.java).apply {
action = ACTION_WIDGET_TOGGLE
}
val togglePendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId + 1000,
toggleIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_toggle_btn, togglePendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private suspend fun buildStartIntent(context: Context): Intent? {
val store = SettingsStore(context.applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
return Intent(context, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}
private fun openMainActivity(context: Context) {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching {
val pendingIntent = PendingIntent.getActivity(
context,
200,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
pendingIntent.send()
}.onFailure {
context.startActivity(intent)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -2,7 +2,13 @@ package com.wdtt.client
import android.app.Application
import android.content.Context
import android.util.Log
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WdttApplication : Application() {
@Volatile
@@ -14,6 +20,43 @@ class WdttApplication : Application() {
override fun onCreate() {
super.onCreate()
DeployManager.init(this)
// Очищаем фантомный VPN при холодном старте приложения (например, после перезагрузки телефона).
// Если телефон перезагрузился, система Android пытается сама восстановить VpnService,
// что приводит к фантомному ключу без интернета. Этот код мгновенно сбрасывает статус в DOWN.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
runCatching {
val backend = getBackend(this@WdttApplication)
val tunnel = WireGuardHelper.WgTunnel()
backend.setState(tunnel, Tunnel.State.DOWN, null)
Log.d("WdttApp", "Успешно очищен фантомный VPN при холодном старте")
}.onFailure {
Log.w("WdttApp", "Не удалось очистить фантомный VPN: ${it.message}")
}
}
// Реактивно обновляем все виджеты на домашнем экране при изменении состояния туннеля
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
TunnelManager.running.collect {
VpnWidgetProvider.updateAllWidgets(this@WdttApplication)
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось обновить виджеты: ${e.message}")
}
}
// Реактивно отслеживаем флаг логирования
val settingsStore = SettingsStore(this)
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
settingsStore.loggingEnabled.collect { enabled ->
TunnelManager.isLoggingEnabled = enabled
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось отслеживать флаг логирования: ${e.message}")
}
}
}
fun getBackend(context: Context): GoBackend {
@@ -137,6 +137,15 @@ class WireGuardHelper(context: Context) {
}
}
suspend fun isTunnelUp(): Boolean = wgMutex.withLock {
val current = sharedTunnel ?: return false
return try {
backend.getState(current) == Tunnel.State.UP
} catch (e: Exception) {
false
}
}
suspend fun stopTunnel() = wgMutex.withLock {
withContext(Dispatchers.IO) {
try {
@@ -51,8 +51,8 @@ fun AppSectionCard(
color = appSectionCardColor(),
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, appSectionCardBorderColor()),
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp,
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
modifier = modifier.fillMaxWidth()
) {
Column(
@@ -91,9 +91,9 @@ fun DeployTab() {
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp }
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin }
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword }
LaunchedEffect(savedIp) { ip = savedIp }
LaunchedEffect(savedLogin) { login = savedLogin }
LaunchedEffect(savedPassword) { password = savedPassword }
val animatedProgress by animateFloatAsState(
targetValue = deployProgress,
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
@@ -38,7 +38,8 @@ import androidx.compose.runtime.Stable
data class AppItem(
val name: String,
val packageName: String,
val icon: ImageBitmap?
val icon: ImageBitmap?,
val isSystem: Boolean
)
object AppCache {
@@ -61,6 +62,8 @@ fun ExceptionsTab() {
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
var searchQuery by remember { mutableStateOf("") }
val showSystemAppsOpt by settingsStore.showSystemApps.collectAsStateWithLifecycle(initialValue = null)
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
// Load Apps
@@ -76,10 +79,12 @@ fun ExceptionsTab() {
if (app.packageName != context.packageName &&
!app.packageName.contains("vkontakte") &&
!app.packageName.contains("vk.calls")) {
val isSys = (app.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
list.add(AppItem(
name = app.loadLabel(pm).toString(),
packageName = app.packageName,
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap()
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap(),
isSystem = isSys
))
}
}
@@ -91,8 +96,17 @@ fun ExceptionsTab() {
val filteredApps by remember {
derivedStateOf {
if (searchQuery.isBlank()) appsList
else appsList.filter {
val showSystemApps = showSystemAppsOpt ?: true
val list = if (showSystemApps) {
appsList
} else {
appsList.filter {
!it.isSystem || it.packageName == "com.google.android.youtube" || it.packageName == "com.android.vending"
}
}
if (searchQuery.isBlank()) list
else list.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.packageName.contains(searchQuery, ignoreCase = true)
}
@@ -174,10 +188,37 @@ fun ExceptionsTab() {
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Системные приложения",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = showSystemAppsOpt ?: true,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveShowSystemApps(enabled)
}
}
)
}
}
// List
if (isLoading) {
if (isLoading || showSystemAppsOpt == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -36,8 +36,13 @@ import android.os.Build
import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@Composable
fun FloatingToolbar(
activeProfile: Int,
onActiveProfileChange: (Int) -> Unit,
currentTheme: String,
onThemeChange: (String) -> Unit,
isDynamicColor: Boolean,
@@ -55,6 +60,9 @@ fun FloatingToolbar(
with(density) { configuration.screenWidthDp.dp.toPx() }
}
var parentWidthPx by remember { mutableFloatStateOf(0f) }
var parentHeightPx by remember { mutableFloatStateOf(0f) }
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
var isRightSide by rememberSaveable { mutableStateOf(true) }
var isExpanded by rememberSaveable { mutableStateOf(false) }
@@ -76,12 +84,16 @@ fun FloatingToolbar(
} else {
effectiveTabHeightPx
}
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val currentParentHeight = if (parentHeightPx > 0f) parentHeightPx else screenHeightPx
val currentParentWidth = if (parentWidthPx > 0f) parentWidthPx else screenWidthPx
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (currentParentHeight - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (currentParentHeight * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) currentParentWidth - tabWidthPx else 0f
val animatedTabXPx by animateFloatAsState(
targetValue = targetXPx,
@@ -93,7 +105,14 @@ fun FloatingToolbar(
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
}
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
parentWidthPx = coordinates.size.width.toFloat()
parentHeightPx = coordinates.size.height.toFloat()
}
) {
Surface(
onClick = { isExpanded = !isExpanded },
modifier = Modifier
@@ -114,16 +133,16 @@ fun FloatingToolbar(
else
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shadowElevation = 6.dp,
tonalElevation = 4.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Box(
modifier = Modifier.size(tabWidthDp, tabHeightDp),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_palette),
contentDescription = "Тема",
imageVector = Icons.Filled.Settings,
contentDescription = "Настройки",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
@@ -151,13 +170,54 @@ fun FloatingToolbar(
},
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
tonalElevation = 4.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
"Профиль",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0, 1, 2).forEach { profile ->
val selected = profile == activeProfile
Surface(
onClick = { onActiveProfileChange(profile) },
shape = RoundedCornerShape(12.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Пр. $profile",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp
)
}
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Text(
"Тема",
style = MaterialTheme.typography.labelMedium,
@@ -1,5 +1,7 @@
package com.wdtt.client.ui
import androidx.compose.runtime.MutableState
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -156,7 +158,10 @@ private fun openUrlInBrowser(context: Context, url: String) {
}
@Composable
fun InfoTab() {
fun InfoTab(
actionsExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) },
projectExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val settingsStore = remember { SettingsStore(context) }
@@ -165,8 +170,8 @@ fun InfoTab() {
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
var showHelpDialog by remember { mutableStateOf(false) }
var showDonateDialog by remember { mutableStateOf(false) }
var actionsExpanded by rememberSaveable { mutableStateOf(true) }
var projectExpanded by rememberSaveable { mutableStateOf(true) }
var actionsExpanded by actionsExpandedState
var projectExpanded by projectExpandedState
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
@@ -29,11 +29,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.wdtt.client.LogEntry
import com.wdtt.client.TunnelManager
import com.wdtt.client.WDTTColors
import com.wdtt.client.SettingsStore
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsTab() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
val loggingEnabled by settingsStore.loggingEnabled.collectAsStateWithLifecycle(initialValue = true)
val scope = rememberCoroutineScope()
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
@@ -65,6 +70,38 @@ fun LogsTab() {
}
}
// Карточка-выключатель логирования
AppSectionCard(
modifier = Modifier.padding(bottom = 12.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Активное логирование",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = loggingEnabled,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveLoggingEnabled(enabled)
if (!enabled) {
TunnelManager.clearLogs()
}
}
}
)
}
}
// Logs container — адаптивный к теме
val isDark = isSystemInDarkTheme()
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
@@ -1,6 +1,9 @@
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
@@ -83,14 +86,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
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 cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle()
val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
var wasRunning by remember { mutableStateOf(false) }
LaunchedEffect(tunnelRunning) {
if (wasRunning && !tunnelRunning) {
TunnelManager.startCooldown(5)
TunnelManager.startCooldown(1500L)
}
wasRunning = tunnelRunning
}
@@ -112,7 +119,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
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 filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.size }
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") }
@@ -156,7 +174,7 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.joinToString(",")
}
LaunchedEffect(Unit) {
LaunchedEffect(activeProfile) {
val peer = settingsStore.peer.first()
val hashes = settingsStore.vkHashes.first()
val workers = settingsStore.workersPerHash.first()
@@ -238,7 +256,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
val isHashesValid = combinedHashes.isNotBlank()
val isValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
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) }
@@ -255,15 +275,37 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
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", "$peerInput:$effectiveServerDtlsPort")
putExtra("vk_hashes", combinedHashes)
putExtra("peer", finalPeer)
putExtra("vk_hashes", finalHashes)
putExtra("secondary_vk_hash", "")
putExtra("workers_per_hash", workersInput.toInt())
putExtra("port", effectiveLocalPort)
putExtra("port", finalLocalPort)
putExtra("sni", sniInput)
putExtra("connection_password", savedConnectionPassword)
putExtra("connection_password", finalPassword)
putExtra("captcha_mode", effectiveCaptchaMode)
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
}
@@ -342,263 +384,322 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.padding(16.dp),
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
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (!wdttLinkMode) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// ═══ Заголовок раздела ═══
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
color = MaterialTheme.colorScheme.onSurface
)
}
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)
// ═══ Настройки туннеля ═══
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),
)
)
// — Метод обхода капчи —
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)
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)
)
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")
) {
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")
}
}
}
}
// — Разделитель —
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)
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(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (useWVCaptcha) {
ProtocolChip(
"РУЧ",
isManualMode,
enabled = true,
isError = false
) {
isManualMode = true
wbvManualMode = true
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") }
// — Метод обхода капчи —
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(
"АВТ",
!isManualMode,
enabled = true,
isError = false
) {
ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) {
useWVCaptcha = false
isManualMode = false
wbvManualMode = false
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") }
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
) {}
}
} 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)
) {
OutlinedButton(
onClick = { showSecretsDialog = true },
modifier = Modifier.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)
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(
@@ -617,8 +718,10 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
requestVpnAndStart()
}
},
enabled = (isValid && cooldownSeconds == 0) || tunnelRunning,
modifier = Modifier.weight(1f).height(52.dp),
enabled = (isValid && !cooldownActive) || tunnelRunning,
modifier = Modifier
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonColor,
@@ -634,14 +737,14 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
Text(
text = when {
tunnelRunning -> "Остановить"
cooldownSeconds > 0 -> "Подождите ($cooldownSeconds)"
cooldownActive -> "Подождите..."
else -> "Подключить"
},
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
}