From bc0c8f5fc98dc2ddbfcb44ad57c4b16bfd1a8020 Mon Sep 17 00:00:00 2001 From: amurcanov Date: Tue, 26 May 2026 22:48:52 +0300 Subject: [PATCH] Update v1.2.0 --- app/build.gradle.kts | 6 +- app/src/main/AndroidManifest.xml | 27 + .../main/java/com/wdtt/client/MainActivity.kt | 69 +- .../com/wdtt/client/QuickToggleTileService.kt | 167 +++++ .../java/com/wdtt/client/SettingsStore.kt | 327 +++++++--- .../java/com/wdtt/client/TunnelManager.kt | 104 +--- .../java/com/wdtt/client/TunnelService.kt | 9 + .../java/com/wdtt/client/VpnWidgetProvider.kt | 190 ++++++ .../java/com/wdtt/client/WdttApplication.kt | 43 ++ .../java/com/wdtt/client/WireGuardHelper.kt | 9 + .../java/com/wdtt/client/ui/AppSectionCard.kt | 4 +- .../main/java/com/wdtt/client/ui/DeployTab.kt | 6 +- .../java/com/wdtt/client/ui/ExceptionsTab.kt | 51 +- .../com/wdtt/client/ui/FloatingToolbar.kt | 84 ++- .../main/java/com/wdtt/client/ui/InfoTab.kt | 11 +- .../main/java/com/wdtt/client/ui/LogsTab.kt | 37 ++ .../java/com/wdtt/client/ui/SettingsTab.kt | 587 ++++++++++-------- .../res/drawable/bg_widget_button_active.xml | 6 + .../drawable/bg_widget_button_inactive.xml | 6 + app/src/main/res/drawable/bg_widget_card.xml | 7 + app/src/main/res/drawable/ic_tile_logo_w.xml | 9 + app/src/main/res/layout/vpn_widget.xml | 47 ++ app/src/main/res/xml/vpn_widget_info.xml | 9 + go_client/captcha_v2_slider.go | 10 +- go_client/dispatcher.go | 28 +- go_client/group.go | 6 +- go_client/main.go | 8 +- go_client/obfs.go | 48 +- go_client/profiles.go | 8 +- go_client/session.go | 7 +- go_client/stats.go | 2 - gradle.properties | 7 +- server.go | 296 +++++++-- 33 files changed, 1689 insertions(+), 546 deletions(-) create mode 100644 app/src/main/java/com/wdtt/client/QuickToggleTileService.kt create mode 100644 app/src/main/java/com/wdtt/client/VpnWidgetProvider.kt create mode 100644 app/src/main/res/drawable/bg_widget_button_active.xml create mode 100644 app/src/main/res/drawable/bg_widget_button_inactive.xml create mode 100644 app/src/main/res/drawable/bg_widget_card.xml create mode 100644 app/src/main/res/drawable/ic_tile_logo_w.xml create mode 100644 app/src/main/res/layout/vpn_widget.xml create mode 100644 app/src/main/res/xml/vpn_widget_info.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4684a50..3196b64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,10 +11,10 @@ android { defaultConfig { applicationId = "com.wdtt.client" - minSdk = 29 + minSdk = 28 targetSdk = 35 - versionCode = 118 - versionName = "1.1.8" + versionCode = 120 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2952449..41f8338 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,9 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/wdtt/client/MainActivity.kt b/app/src/main/java/com/wdtt/client/MainActivity.kt index 6c34076..e7228ba 100644 --- a/app/src/main/java/com/wdtt/client/MainActivity.kt +++ b/app/src/main/java/com/wdtt/client/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/wdtt/client/QuickToggleTileService.kt b/app/src/main/java/com/wdtt/client/QuickToggleTileService.kt new file mode 100644 index 0000000..847b954 --- /dev/null +++ b/app/src/main/java/com/wdtt/client/QuickToggleTileService.kt @@ -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" + } + } +} diff --git a/app/src/main/java/com/wdtt/client/SettingsStore.kt b/app/src/main/java/com/wdtt/client/SettingsStore.kt index 2bf2d50..08c1561 100644 --- a/app/src/main/java/com/wdtt/client/SettingsStore.kt +++ b/app/src/main/java/com/wdtt/client/SettingsStore.kt @@ -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 getProfileKey(baseKey: Preferences.Key, profile: Int): Preferences.Key { + 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 + WORKERS_PER_HASH, LISTEN_PORT, SERVER_DTLS_PORT, SERVER_WG_PORT, PROXY_PORT -> intPreferencesKey(newName) as Preferences.Key + MANUAL_PORTS_ENABLED, NO_DTLS, NO_DNS, IS_WHITELIST, WDTT_LINK_MODE -> booleanPreferencesKey(newName) as Preferences.Key + else -> throw IllegalArgumentException("Unsupported key type: ${baseKey.name}") + } + } } private val dataStore = appContext.dataStore @@ -94,65 +114,151 @@ class SettingsStore(context: Context) { } } - val peer: Flow = dataStore.data.map { it[PEER] ?: "" } - val vkHashes: Flow = dataStore.data.map { it[VK_HASHES] ?: "" } - val secondaryVkHash: Flow = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" } - val workersPerHash: Flow = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 } - val protocol: Flow = dataStore.data.map { it[PROTOCOL] ?: "udp" } - val listenPort: Flow = dataStore.data.map { it[LISTEN_PORT] ?: 9000 } - val manualPortsEnabled: Flow = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false } - val serverDtlsPort: Flow = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 } - val serverWgPort: Flow = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 } - val sni: Flow = dataStore.data.map { it[SNI] ?: "" } - val noDns: Flow = dataStore.data.map { it[NO_DNS] ?: false } - val userAgent: Flow = dataStore.data.map { it[USER_AGENT] ?: "" } - - val deployIp: Flow = dataStore.data.map { it[DEPLOY_IP] ?: "" } - val deployLogin: Flow = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" } - val deployPassword: Flow = dataStore.data.map { - readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) + val activeProfile: Flow = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 } + val showSystemApps: Flow = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true } + val loggingEnabled: Flow = dataStore.data.map { it[LOGGING_ENABLED] ?: true } + val wdttLink: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(WDTT_LINK, profile)] ?: "" + } + val wdttLinkMode: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false + } + + val peer: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(PEER, profile)] ?: "" + } + val vkHashes: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(VK_HASHES, profile)] ?: "" + } + val secondaryVkHash: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(SECONDARY_VK_HASH, profile)] ?: "" + } + val workersPerHash: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(WORKERS_PER_HASH, profile)] ?: 16 + } + val protocol: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(PROTOCOL, profile)] ?: "udp" + } + val listenPort: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(LISTEN_PORT, profile)] ?: 9000 + } + val manualPortsEnabled: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] ?: false + } + val serverDtlsPort: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(SERVER_DTLS_PORT, profile)] ?: 56000 + } + val serverWgPort: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(SERVER_WG_PORT, profile)] ?: 56001 + } + val sni: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(SNI, profile)] ?: "" + } + val noDns: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(NO_DNS, profile)] ?: false + } + val userAgent: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(USER_AGENT, profile)] ?: "" + } + + val deployIp: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(DEPLOY_IP, profile)] ?: "" + } + val deployLogin: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(DEPLOY_LOGIN, profile)] ?: "" + } + val deployPassword: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + readSecret(prefs, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, profile) + } + val deploySshPort: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] ?: "" + } + val excludedApps: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(EXCLUDED_APPS, profile)] ?: "" } - val deploySshPort: Flow = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" } - val excludedApps: Flow = dataStore.data.map { it[EXCLUDED_APPS] ?: "" } val detailedLogs: Flow = dataStore.data.map { it[DETAILED_LOGS] ?: false } // ═══ Пароли и Управление ═══ - val connectionPassword: Flow = dataStore.data.map { - readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) + val connectionPassword: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile) } - val deployMainPassword: Flow = dataStore.data.map { - readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) + val deployMainPassword: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile) } - val deployAdminId: Flow = dataStore.data.map { - readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) + val deployAdminId: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile) } - val deployBotToken: Flow = dataStore.data.map { - readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) + val deployBotToken: Flow = 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 = dataStore.data.map { it[PROXY_MODE] ?: "tun" } - val proxyHost: Flow = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" } - val proxyPort: Flow = dataStore.data.map { it[PROXY_PORT] ?: 1080 } + val proxyMode: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(PROXY_MODE, profile)] ?: "tun" + } + val proxyHost: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(PROXY_HOST, profile)] ?: "127.0.0.1" + } + val proxyPort: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(PROXY_PORT, profile)] ?: 1080 + } // ═══ Captcha Solve Mode ═══ - val captchaMode: Flow = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" } - val captchaSolveMethod: Flow = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" } - val captchaWbvSolveMethod: Flow = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" } + val captchaMode: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(CAPTCHA_MODE, profile)] ?: "auto" + } + val captchaSolveMethod: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] ?: "auto" + } + val captchaWbvSolveMethod: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] ?: "auto" + } // ═══ VPN Exclusions Mode ═══ - val isWhitelist: Flow = dataStore.data.map { it[IS_WHITELIST] ?: false } + val isWhitelist: Flow = dataStore.data.map { prefs -> + val profile = prefs[ACTIVE_PROFILE] ?: 0 + prefs[getProfileKey(IS_WHITELIST, profile)] ?: false + } // ═══ Theme Mode ═══ val themeMode: Flow = dataStore.data.map { it[THEME_MODE] ?: "system" } - val isDynamicColor: Flow = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false } + val isDynamicColor: Flow = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) } val themePalette: Flow = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" } val updateLastCheckAt: Flow = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L } val updateLatestVersion: Flow = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" } val updateLastError: Flow = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" } - val updateCheckIntervalHours: Flow = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS } + val updateCheckIntervalHours: Flow = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: 24 } val updatePostponeUntil: Flow = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L } val updatePostponeVersion: Flow = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" } val updateDialogLastShownVersion: Flow = 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, - legacyKey: Preferences.Key + legacyKey: Preferences.Key, + 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, legacyKey: Preferences.Key, - 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) } } diff --git a/app/src/main/java/com/wdtt/client/TunnelManager.kt b/app/src/main/java/com/wdtt/client/TunnelManager.kt index 71693af..a103078 100644 --- a/app/src/main/java/com/wdtt/client/TunnelManager.kt +++ b/app/src/main/java/com/wdtt/client/TunnelManager.kt @@ -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>(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" ) diff --git a/app/src/main/java/com/wdtt/client/TunnelService.kt b/app/src/main/java/com/wdtt/client/TunnelService.kt index fa3507b..46b798a 100644 --- a/app/src/main/java/com/wdtt/client/TunnelService.kt +++ b/app/src/main/java/com/wdtt/client/TunnelService.kt @@ -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()) } diff --git a/app/src/main/java/com/wdtt/client/VpnWidgetProvider.kt b/app/src/main/java/com/wdtt/client/VpnWidgetProvider.kt new file mode 100644 index 0000000..52722da --- /dev/null +++ b/app/src/main/java/com/wdtt/client/VpnWidgetProvider.kt @@ -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" + } + } +} diff --git a/app/src/main/java/com/wdtt/client/WdttApplication.kt b/app/src/main/java/com/wdtt/client/WdttApplication.kt index 29585ee..fd2f466 100644 --- a/app/src/main/java/com/wdtt/client/WdttApplication.kt +++ b/app/src/main/java/com/wdtt/client/WdttApplication.kt @@ -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 { diff --git a/app/src/main/java/com/wdtt/client/WireGuardHelper.kt b/app/src/main/java/com/wdtt/client/WireGuardHelper.kt index 74147dd..0603b9e 100644 --- a/app/src/main/java/com/wdtt/client/WireGuardHelper.kt +++ b/app/src/main/java/com/wdtt/client/WireGuardHelper.kt @@ -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 { diff --git a/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt b/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt index 711f95f..f2f1ace 100644 --- a/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt +++ b/app/src/main/java/com/wdtt/client/ui/AppSectionCard.kt @@ -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( diff --git a/app/src/main/java/com/wdtt/client/ui/DeployTab.kt b/app/src/main/java/com/wdtt/client/ui/DeployTab.kt index e808397..a826e57 100644 --- a/app/src/main/java/com/wdtt/client/ui/DeployTab.kt +++ b/app/src/main/java/com/wdtt/client/ui/DeployTab.kt @@ -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), diff --git a/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt b/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt index ac58957..4469299 100644 --- a/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt +++ b/app/src/main/java/com/wdtt/client/ui/ExceptionsTab.kt @@ -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() } diff --git a/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt b/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt index 764a77f..257940d 100644 --- a/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt +++ b/app/src/main/java/com/wdtt/client/ui/FloatingToolbar.kt @@ -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, diff --git a/app/src/main/java/com/wdtt/client/ui/InfoTab.kt b/app/src/main/java/com/wdtt/client/ui/InfoTab.kt index f187b5a..2b7c332 100644 --- a/app/src/main/java/com/wdtt/client/ui/InfoTab.kt +++ b/app/src/main/java/com/wdtt/client/ui/InfoTab.kt @@ -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 = rememberSaveable { mutableStateOf(true) }, + projectExpandedState: MutableState = 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(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) { diff --git a/app/src/main/java/com/wdtt/client/ui/LogsTab.kt b/app/src/main/java/com/wdtt/client/ui/LogsTab.kt index 941d74d..df212c2 100644 --- a/app/src/main/java/com/wdtt/client/ui/LogsTab.kt +++ b/app/src/main/java/com/wdtt/client/ui/LogsTab.kt @@ -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 diff --git a/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt b/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt index 59ee179..1537e34 100644 --- a/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt +++ b/app/src/main/java/com/wdtt/client/ui/SettingsTab.kt @@ -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 ) } } - } } diff --git a/app/src/main/res/drawable/bg_widget_button_active.xml b/app/src/main/res/drawable/bg_widget_button_active.xml new file mode 100644 index 0000000..f5c5637 --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_button_active.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_widget_button_inactive.xml b/app/src/main/res/drawable/bg_widget_button_inactive.xml new file mode 100644 index 0000000..8e03873 --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_button_inactive.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_widget_card.xml b/app/src/main/res/drawable/bg_widget_card.xml new file mode 100644 index 0000000..25b00d5 --- /dev/null +++ b/app/src/main/res/drawable/bg_widget_card.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tile_logo_w.xml b/app/src/main/res/drawable/ic_tile_logo_w.xml new file mode 100644 index 0000000..846fb58 --- /dev/null +++ b/app/src/main/res/drawable/ic_tile_logo_w.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/vpn_widget.xml b/app/src/main/res/layout/vpn_widget.xml new file mode 100644 index 0000000..a46bd27 --- /dev/null +++ b/app/src/main/res/layout/vpn_widget.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/vpn_widget_info.xml b/app/src/main/res/xml/vpn_widget_info.xml new file mode 100644 index 0000000..781554b --- /dev/null +++ b/app/src/main/res/xml/vpn_widget_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/go_client/captcha_v2_slider.go b/go_client/captcha_v2_slider.go index 9184be6..92f5587 100644 --- a/go_client/captcha_v2_slider.go +++ b/go_client/captcha_v2_slider.go @@ -374,15 +374,13 @@ func applySliderSwapsV2(gridSize int, swaps []int) ([]int, error) { } func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { - w := bounds.Dx() / gridSize - h := bounds.Dy() / gridSize col := index % gridSize row := index / gridSize return image.Rect( - bounds.Min.X+col*w, - bounds.Min.Y+row*h, - bounds.Min.X+(col+1)*w, - bounds.Min.Y+(row+1)*h, + bounds.Min.X+(col*bounds.Dx())/gridSize, + bounds.Min.Y+(row*bounds.Dy())/gridSize, + bounds.Min.X+((col+1)*bounds.Dx())/gridSize, + bounds.Min.Y+((row+1)*bounds.Dy())/gridSize, ) } diff --git a/go_client/dispatcher.go b/go_client/dispatcher.go index ed14e31..4ba2580 100644 --- a/go_client/dispatcher.go +++ b/go_client/dispatcher.go @@ -9,6 +9,27 @@ import ( "time" ) +var pktPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 2048) + }, +} + +func getPktBuf(size int) []byte { + b := pktPool.Get().([]byte) + if cap(b) < size { + b = make([]byte, size) + } + return b[:size] +} + +func putPktBuf(b []byte) { + if cap(b) < 2048 { + return + } + pktPool.Put(b[:cap(b)]) +} + const ( returnChBuf = 384 @@ -125,13 +146,14 @@ func (d *Dispatcher) readLoop() { d.clientAddr.Store(&addr) atomic.AddInt64(&d.stats.TotalBytesUp, int64(n)) - pkt := make([]byte, n) + pkt := getPktBuf(n) copy(pkt, buf[:n]) d.mu.Lock() nw := len(d.workers) if nw == 0 { d.mu.Unlock() + putPktBuf(pkt) continue } @@ -169,6 +191,7 @@ func (d *Dispatcher) readLoop() { // Все workers перегружены — сдвигаем указатель, пакет дропается d.rrIndex = (idx + 1) % nw d.rrCount = 0 + putPktBuf(pkt) } d.mu.Unlock() } @@ -184,15 +207,18 @@ func (d *Dispatcher) writeLoop() { case pkt := <-d.ReturnCh: addrPtr := d.clientAddr.Load() if addrPtr == nil { + putPktBuf(pkt) continue } addr := *addrPtr if _, err := d.localConn.WriteTo(pkt, addr); err != nil { if d.ctx.Err() != nil { + putPktBuf(pkt) return } } atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt))) + putPktBuf(pkt) } } } diff --git a/go_client/group.go b/go_client/group.go index 64be21d..41ca8f7 100644 --- a/go_client/group.go +++ b/go_client/group.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log" "math/rand" "net" @@ -12,7 +11,6 @@ import ( "time" ) -var groupAuthMutex sync.Mutex const ( workersPerGroup = 9 @@ -32,7 +30,6 @@ func WorkerGroup( getConfig bool, configCh chan<- string, workerIDs []int, - cycleDuration time.Duration, pauseFlag *int32, deviceID, password string, stats *Stats, @@ -296,5 +293,4 @@ type Credentials struct { CacheStreamID int } -// Unused import suppressor -var _ = fmt.Sprintf + diff --git a/go_client/main.go b/go_client/main.go index 10fe629..e24f237 100644 --- a/go_client/main.go +++ b/go_client/main.go @@ -13,7 +13,6 @@ import ( "sync" "sync/atomic" "syscall" - "time" ) // CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView) @@ -284,18 +283,17 @@ func main() { } gID := g + 1 - cycle := time.Duration(defaultCycleSecs) * time.Second var cc chan<- string if isFirst { cc = configCh } wg.Add(1) - go func(groupID int, cycleDir time.Duration, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) { + go func(groupID int, isFirstGroup bool, configChan chan<- string, workerIds []int, startHashIndex int, waitR <-chan struct{}, sigR chan<- struct{}) { defer wg.Done() WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort, - isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR) - }(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady) + isFirstGroup, configChan, workerIds, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR) + }(gID, isFirst, cc, ids, g, myWaitReady, mySignalReady) } wg.Wait() diff --git a/go_client/obfs.go b/go_client/obfs.go index c180b49..46804a8 100644 --- a/go_client/obfs.go +++ b/go_client/obfs.go @@ -12,6 +12,7 @@ package main import ( + "crypto/cipher" "crypto/rand" "encoding/binary" "errors" @@ -21,6 +22,24 @@ import ( "golang.org/x/crypto/chacha20poly1305" ) +var aeadCache sync.Map + +func getAEAD(key []byte) (cipher.AEAD, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen) + } + keyStr := string(key) + if val, ok := aeadCache.Load(keyStr); ok { + return val.(cipher.AEAD), nil + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + aeadCache.Store(keyStr, aead) + return aead, nil +} + // ─── Configuration ─── // ObfsConfig holds per-session obfuscation parameters. @@ -43,20 +62,22 @@ func NewObfsConfig() *ObfsConfig { // ─── Per-direction state (sequence + timestamp counters) ─── -// ObfsState tracks monotonically increasing RTP sequence number and timestamp. +// ObfsState tracks monotonically increasing RTP sequence number and timestamp using a 48-bit packet counter. type ObfsState struct { - mu sync.Mutex - seq uint16 - ts uint32 + mu sync.Mutex + initSeq uint16 + initTs uint32 + count uint64 } -// NewObfsState creates a state with random initial seq/ts. +// NewObfsState creates a state with random initial seq/ts and count=0. func NewObfsState() *ObfsState { var buf [6]byte rand.Read(buf[:]) return &ObfsState{ - seq: binary.BigEndian.Uint16(buf[0:2]), - ts: binary.BigEndian.Uint32(buf[2:6]), + initSeq: binary.BigEndian.Uint16(buf[0:2]), + initTs: binary.BigEndian.Uint32(buf[2:6]), + count: 0, } } @@ -89,12 +110,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b } state.mu.Lock() - seq := state.seq - ts := state.ts - state.seq++ - state.ts += 960 // 20ms frame @ 48kHz (OPUS standard) + c := state.count + state.count++ state.mu.Unlock() + seq := state.initSeq + uint16(c) + ts := state.initTs + uint32(c)*960 + uint32(c>>16) + // Build nonce from RTP fields nonce := obfsBuildNonce(cfg.SSRC, seq, ts) @@ -118,7 +140,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b binary.BigEndian.PutUint32(out[4:8], ts) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) - aead, err := chacha20poly1305.New(key) + aead, err := getAEAD(key) if err != nil { return nil, fmt.Errorf("obfs: cipher init: %w", err) } @@ -178,7 +200,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) { // Build nonce and decrypt nonce := obfsBuildNonce(ssrc, seq, ts) - aead, err := chacha20poly1305.New(key) + aead, err := getAEAD(key) if err != nil { return 0, fmt.Errorf("obfs: cipher init: %w", err) } diff --git a/go_client/profiles.go b/go_client/profiles.go index 85da123..f9274f7 100644 --- a/go_client/profiles.go +++ b/go_client/profiles.go @@ -35,13 +35,7 @@ func LoadProfileFromDisk() (*SavedProfile, error) { return &sp, nil } -func SaveProfileToDisk(sp SavedProfile) error { - data, err := json.MarshalIndent(sp, "", " ") - if err != nil { - return err - } - return os.WriteFile(profileFile, data, 0644) -} + // profileList contains paired User-Agent and Client Hints strings. var profileList = []Profile{ diff --git a/go_client/session.go b/go_client/session.go index 48efe8d..b801d61 100644 --- a/go_client/session.go +++ b/go_client/session.go @@ -371,7 +371,9 @@ func RunSession( return } _ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout)) - if _, writeErr := dtlsConn.Write(pkt); writeErr != nil { + _, writeErr := dtlsConn.Write(pkt) + putPktBuf(pkt) + if writeErr != nil { log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr) return } @@ -403,11 +405,12 @@ func RunSession( continue } - pkt := make([]byte, n) + pkt := getPktBuf(n) copy(pkt, b[:n]) select { case d.ReturnCh <- pkt: case <-sessCtx.Done(): + putPktBuf(pkt) return } } diff --git a/go_client/stats.go b/go_client/stats.go index 30fa96e..211b579 100644 --- a/go_client/stats.go +++ b/go_client/stats.go @@ -8,10 +8,8 @@ import ( type Stats struct { ActiveConnections int32 - Reconnects int64 TotalBytesUp int64 TotalBytesDown int64 - CredsErrors int64 } func NewStats() *Stats { diff --git a/gradle.properties b/gradle.properties index 44d3097..6f2d376 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,8 @@ -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx8G -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m android.useAndroidX=true android.nonTransitiveRClass=true +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.configuration-cache=true +org.gradle.vfs.watch=true +kotlin.caching.enabled=true diff --git a/server.go b/server.go index 981b45c..f384f14 100644 --- a/server.go +++ b/server.go @@ -27,6 +27,8 @@ import ( "syscall" "time" + "crypto/cipher" + "github.com/pion/dtls/v3" "github.com/pion/dtls/v3/pkg/crypto/selfsign" "golang.org/x/crypto/chacha20poly1305" @@ -45,8 +47,6 @@ import ( const ( wgIfaceName = "wdtt0" wgServerAddr = "10.66.66.1" - wgClientAddr = "10.66.66.2" - wgClientCIDR = wgClientAddr + "/32" wgServerCIDR = wgServerAddr + "/24" defaultInternalWGPort = 56001 dns = "1.1.1.1" @@ -64,23 +64,16 @@ type ClientDevice struct { } type PasswordEntry struct { - DeviceID string `json:"device_id"` // пусто = ещё не привязан - ExpiresAt int64 `json:"expires_at"` // unix timestamp - DownBytes int64 `json:"down_bytes"` // скачано клиентом - UpBytes int64 `json:"up_bytes"` // отдано клиентом + DeviceID string `json:"device_id"` // пусто = ещё не привязан + ExpiresAt int64 `json:"expires_at"` // unix timestamp + DownBytes int64 `json:"down_bytes"` // скачано клиентом + UpBytes int64 `json:"up_bytes"` // отдано клиентом + VkHash string `json:"vk_hash,omitempty"` + Ports string `json:"ports,omitempty"` // "dtls,wg,tun" + IsDeactivated bool `json:"is_deactivated,omitempty"` } -// Трафик главного пароля (владельца) -var ( - mainPassDown int64 - mainPassUp int64 -) -// Онлайн-статус устройств -var ( - activeDevices = make(map[string]int32) // deviceID -> кол-во активных коннектов - activeDevicesMu sync.Mutex -) type Database struct { MainPassword string `json:"main_password"` @@ -120,6 +113,37 @@ func generatePassword() string { return string(b) } +var publicIP string = "" + +func getPublicIP() string { + if publicIP != "" { + return publicIP + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + return "YOUR_SERVER_IP" + } + defer resp.Body.Close() + ipBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "YOUR_SERVER_IP" + } + publicIP = string(bytes.TrimSpace(ipBytes)) + return publicIP +} + +func stripVkUrl(url string) string { + url = strings.TrimSpace(url) + if idx := strings.LastIndex(url, "/"); idx != -1 { + url = url[idx+1:] + } + if idx := strings.Index(url, "?"); idx != -1 { + url = url[:idx] + } + return strings.TrimSpace(url) +} + type wrapKeyEntry struct { id string key []byte @@ -340,7 +364,7 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { // Устанавливаем команды для синей кнопки Menu go func() { - cmds := `{"commands":[{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}` + cmds := `{"commands":[{"command":"start","description":"Главное меню"},{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}` resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds)) if err == nil { resp.Body.Close() @@ -350,8 +374,14 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { offset := 0 client := &http.Client{Timeout: 65 * time.Second} - // Состояние ожидания ввода дней + // Состояние ожидания ввода var waitingForDays bool + var waitingForPorts bool + var waitingForHash bool + var targetPassword string + + var tempDays int + var tempPorts string // "dtls,wg,tun" for { url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset) @@ -410,6 +440,22 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { continue } txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass) + if entry.VkHash != "" { + ports := entry.Ports + if ports == "" { + ports = "56000,56001,9000" + } + pts := strings.Split(ports, ",") + srvIP := getPublicIP() + link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], pass, entry.VkHash) + txt += fmt.Sprintf("🔗 *Быстрая ссылка:* `%s`\n", link) + } + if entry.IsDeactivated { + txt += "🔴 Статус: *ДЕАКТИВИРОВАН*\n" + } else { + txt += "🟢 Статус: *АКТИВЕН*\n" + } + if entry.ExpiresAt > 0 { expireTime := time.Unix(entry.ExpiresAt, 0) remaining := time.Until(expireTime) @@ -421,6 +467,8 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { } else { txt += "⏰ Бессрочный ♾\n" } + + txt += fmt.Sprintf("\n📊 *Трафик:*\n• Скачано: %.2f MB\n• Отдано: %.2f MB\n", float64(entry.DownBytes)/(1024*1024), float64(entry.UpBytes)/(1024*1024)) txt += "\n📱 *Привязанное устройство:*\n" var kb []map[string]interface{} if entry.DeviceID == "" { @@ -438,6 +486,17 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { }) } dbMutex.Unlock() + if entry.IsDeactivated { + kb = append(kb, map[string]interface{}{ + "text": "✅ Активировать", + "callback_data": "react_" + pass, + }) + } else { + kb = append(kb, map[string]interface{}{ + "text": "⏸ Деактивировать", + "callback_data": "deact_" + pass, + }) + } kb = append(kb, map[string]interface{}{ "text": "❌ Удалить пароль", "callback_data": "delpass_" + pass, @@ -452,6 +511,44 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { } sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard}) + } else if strings.HasPrefix(data, "deact_") { + pass := strings.TrimPrefix(data, "deact_") + dbMutex.Lock() + entry, exists := db.Passwords[pass] + if exists && entry != nil { + entry.IsDeactivated = true + // Отключаем активное устройство от WG если нужно + if entry.DeviceID != "" { + if dev, devExists := db.Devices[entry.DeviceID]; devExists { + pubHex, _ := b64ToHex(dev.PubKey) + wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex)) + } + } + saveDB() + } + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("⏸ Пароль `%s` деактивирован", pass), nil) + + } else if strings.HasPrefix(data, "react_") { + pass := strings.TrimPrefix(data, "react_") + dbMutex.Lock() + entry, exists := db.Passwords[pass] + if exists && entry != nil { + entry.IsDeactivated = false + saveDB() + } + dbMutex.Unlock() + sendTelegram(token, adminID, fmt.Sprintf("✅ Пароль `%s` активирован", pass), nil) + + } else if data == "mainlink" { + targetPassword = "main" + var keyboard [][]map[string]interface{} + keyboard = append(keyboard, []map[string]interface{}{ + {"text": "Да", "callback_data": "ports_def"}, + {"text": "Нет", "callback_data": "ports_custom"}, + }) + sendTelegram(token, adminID, "⚙️ Использовать стандартные порты для главного пароля (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard}) + } else if strings.HasPrefix(data, "unbind_") { pass := strings.TrimPrefix(data, "unbind_") dbMutex.Lock() @@ -509,6 +606,13 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { } else if data == "backlist" { sendPasswordList(token, adminID, wgDev) + } else if data == "ports_def" { + tempPorts = "56000,56001,9000" + waitingForHash = true + sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil) + } else if data == "ports_custom" { + waitingForPorts = true + sendTelegram(token, adminID, "⚙️ Укажите через запятую 3 порта (DTLS,WG,TUN):\nНапример: 56000,56001,9000", nil) } } @@ -528,7 +632,68 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil) continue } - expiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix() + tempDays = days + + var keyboard [][]map[string]interface{} + keyboard = append(keyboard, []map[string]interface{}{ + {"text": "Да", "callback_data": "ports_def"}, + {"text": "Нет", "callback_data": "ports_custom"}, + }) + sendTelegram(token, adminID, "⚙️ Использовать стандартные порты (56000, 56001, 9000)?", map[string]interface{}{"inline_keyboard": keyboard}) + continue + } + + if waitingForPorts { + parts := strings.Split(cmd, ",") + if len(parts) != 3 { + sendTelegram(token, adminID, "❌ Неверный формат. Укажите 3 порта через запятую (например: 56000,56001,9000):", nil) + continue + } + p1 := strings.TrimSpace(parts[0]) + p2 := strings.TrimSpace(parts[1]) + p3 := strings.TrimSpace(parts[2]) + + if _, err := strconv.Atoi(p1); err != nil { + sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil) + continue + } + if _, err := strconv.Atoi(p2); err != nil { + sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil) + continue + } + if _, err := strconv.Atoi(p3); err != nil { + sendTelegram(token, adminID, "❌ Неверный порт. Повторите ввод:", nil) + continue + } + + waitingForPorts = false + tempPorts = fmt.Sprintf("%s,%s,%s", p1, p2, p3) + waitingForHash = true + sendTelegram(token, adminID, "🔑 Укажите VK хеш (или несколько через запятую):", nil) + continue + } + + if waitingForHash { + hash := strings.ReplaceAll(cmd, " ", "") + if strings.Contains(hash, "http") || strings.Contains(hash, "/") { + sendTelegram(token, adminID, "❌ Пожалуйста, отправьте только хеш (или несколько хешей через запятую). Ссылки не поддерживаются.", nil) + continue + } + if hash == "" { + sendTelegram(token, adminID, "❌ Хеш не должен быть пустым.", nil) + continue + } + waitingForHash = false + + if targetPassword == "main" { + targetPassword = "" + srvIP := getPublicIP() + pts := strings.Split(tempPorts, ",") + link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], db.MainPassword, hash) + sendTelegram(token, adminID, fmt.Sprintf("🔗 *Ссылка для главного пароля:*\n`%s`", link), nil) + continue + } + dbMutex.Lock() if cleanupExpiredPasswordsLocked(wgDev) > 0 { saveDB() @@ -556,11 +721,21 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) { sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil) continue } - db.Passwords[newPass] = &PasswordEntry{ExpiresAt: expiresAt} + expiresAt := time.Now().Add(time.Duration(tempDays) * 24 * time.Hour).Unix() + db.Passwords[newPass] = &PasswordEntry{ + ExpiresAt: expiresAt, + VkHash: hash, + Ports: tempPorts, + } saveDB() dbMutex.Unlock() + expDate := time.Unix(expiresAt, 0).Format("02.01.2006") - sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения", newPass, days, expDate), nil) + srvIP := getPublicIP() + pts := strings.Split(tempPorts, ",") + link := fmt.Sprintf("wdtt://%s:%s:%s:%s:%s:%s", srvIP, pts[0], pts[1], pts[2], newPass, hash) + + sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения\n\n🔗 *Быстрая ссылка:* `%s`", newPass, tempDays, expDate, link), nil) continue } @@ -677,6 +852,10 @@ func sendPasswordList(token string, adminID int64, wgDev *device.Device) { txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword) var inlineKb []map[string]interface{} + inlineKb = append(inlineKb, map[string]interface{}{ + "text": "🔗 Ссылка на главный пароль", + "callback_data": "mainlink", + }) if len(db.Passwords) == 0 { txt += "_Нет сгенерированных паролей._\n" @@ -1224,7 +1403,6 @@ func main() { func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) { atomic.AddInt64(&totalConns, 1) - var connDeviceID string var connPassword string var connIsMainPass bool @@ -1276,14 +1454,16 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD entry, isGenPass := db.Passwords[password] valid := isMainPass || (isGenPass && !isPasswordExpired(entry)) - // Для сгенерированных паролей — проверяем привязку к устройству - if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID { + if valid && isGenPass && entry.IsDeactivated { + clientConn.Write([]byte("DENIED:deactivated")) + log.Printf("[WG] Отказ: пароль %s деактивирован, запрос от %s", maskPassword(password), deviceID) + dbMutex.Unlock() + } else if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID { // Пароль уже привязан к другому устройству clientConn.Write([]byte("DENIED:device_mismatch")) log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID) dbMutex.Unlock() } else if valid { - connDeviceID = deviceID connPassword = password connIsMainPass = isMainPass @@ -1364,20 +1544,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD } atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket))) - // Трекинг онлайн-статуса - if connDeviceID != "" { - activeDevicesMu.Lock() - activeDevices[connDeviceID]++ - activeDevicesMu.Unlock() - defer func() { - activeDevicesMu.Lock() - activeDevices[connDeviceID]-- - if activeDevices[connDeviceID] <= 0 { - delete(activeDevices, connDeviceID) - } - activeDevicesMu.Unlock() - }() - } + pctx, pcancel := context.WithCancel(ctx) defer pcancel() @@ -1413,9 +1580,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD } atomic.AddInt64(&totalBytesFromClient, int64(nn)) // Per-password upload tracking - if connIsMainPass { - atomic.AddInt64(&mainPassUp, int64(nn)) - } else if connPassword != "" { + if connPassword != "" && !connIsMainPass { dbMutex.Lock() e, ok := db.Passwords[connPassword] if !ok || e == nil || isPasswordExpired(e) { @@ -1456,9 +1621,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD } atomic.AddInt64(&totalBytesToClient, int64(nn)) // Per-password download tracking - if connIsMainPass { - atomic.AddInt64(&mainPassDown, int64(nn)) - } else if connPassword != "" { + if connPassword != "" && !connIsMainPass { dbMutex.Lock() e, ok := db.Passwords[connPassword] if !ok || e == nil || isPasswordExpired(e) { @@ -1482,6 +1645,24 @@ const ( wrapKeyLen = 32 ) +var aeadCache sync.Map + +func getAEAD(key []byte) (cipher.AEAD, error) { + if len(key) != wrapKeyLen { + return nil, fmt.Errorf("obfs: key must be %d bytes", wrapKeyLen) + } + keyStr := string(key) + if val, ok := aeadCache.Load(keyStr); ok { + return val.(cipher.AEAD), nil + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + aeadCache.Store(keyStr, aead) + return aead, nil +} + // ==================== RTP Обфускация ==================== type ObfsConfig struct { @@ -1491,9 +1672,10 @@ type ObfsConfig struct { } type ObfsState struct { - mu sync.Mutex - seq uint16 - ts uint32 + mu sync.Mutex + initSeq uint16 + initTs uint32 + count uint64 } func NewObfsConfig() *ObfsConfig { @@ -1510,8 +1692,9 @@ func NewObfsState() *ObfsState { var buf [6]byte rand.Read(buf[:]) return &ObfsState{ - seq: binary.BigEndian.Uint16(buf[0:2]), - ts: binary.BigEndian.Uint32(buf[2:6]), + initSeq: binary.BigEndian.Uint16(buf[0:2]), + initTs: binary.BigEndian.Uint32(buf[2:6]), + count: 0, } } @@ -1531,12 +1714,13 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b return nil, errors.New("obfs: empty payload") } state.mu.Lock() - seq := state.seq - ts := state.ts - state.seq++ - state.ts += 960 + c := state.count + state.count++ state.mu.Unlock() + seq := state.initSeq + uint16(c) + ts := state.initTs + uint32(c)*960 + uint32(c>>16) + nonce := obfsBuildNonce(cfg.SSRC, seq, ts) padRand := 0 if cfg.PaddingMax > 0 { @@ -1554,7 +1738,7 @@ func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]b binary.BigEndian.PutUint32(out[4:8], ts) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) - aead, err := chacha20poly1305.New(key) + aead, err := getAEAD(key) if err != nil { return nil, fmt.Errorf("obfs: cipher init: %w", err) } @@ -1597,7 +1781,7 @@ func obfsUnwrapPacket(key, wire, dst []byte) (int, error) { return 0, errors.New("obfs: dst buffer too small") } nonce := obfsBuildNonce(ssrc, seq, ts) - aead, err := chacha20poly1305.New(key) + aead, err := getAEAD(key) if err != nil { return 0, fmt.Errorf("obfs: cipher init: %w", err) }