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)
}