Update v1.2.0

This commit is contained in:
amurcanov
2026-05-26 22:48:52 +03:00
parent 63ba2cf1d9
commit bc0c8f5fc9
33 changed files with 1689 additions and 546 deletions
+3 -3
View File
@@ -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 {
+27
View File
@@ -29,6 +29,9 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.ACTION_QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<service
@@ -41,6 +44,17 @@
android:value="persistent_vpn_tunnel_transport" />
</service>
<service
android:name=".QuickToggleTileService"
android:exported="true"
android:icon="@drawable/ic_tile_logo_w"
android:label="WDTT"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<activity
android:name=".ManlCaptchaActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
@@ -52,6 +66,19 @@
android:name=".CaptchaCancelReceiver"
android:exported="false" />
<receiver
android:name=".VpnWidgetProvider"
android:label="WDTT ВПН Виджет"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.wdtt.client.ACTION_WIDGET_TOGGLE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/vpn_widget_info" />
</receiver>
</application>
</manifest>
@@ -190,17 +190,18 @@ class MainActivity : ComponentActivity() {
// ═══ Навигация ═══
private data class NavItem(
val id: Int,
val label: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
)
private val navItems = listOf(
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info),
NavItem(0, "Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem(1, "Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem(2, "Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem(3, "Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem(4, "Инфо", Icons.Filled.Info, Icons.Outlined.Info),
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +221,8 @@ fun MainScreen(
val context = LocalContext.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var dragTargetIndex by remember { mutableIntStateOf(-1) }
var dragProgress by remember { mutableFloatStateOf(0f) }
@@ -231,6 +234,24 @@ fun MainScreen(
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
val navOverlayReserve = safeBottomInset + 96.dp
val activeNavItems = remember(wdttLinkMode) {
if (wdttLinkMode) {
navItems.filter { it.id != 1 }
} else {
navItems
}
}
val actionsExpanded = rememberSaveable { mutableStateOf(false) }
val projectExpanded = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(wdttLinkMode) {
if (wdttLinkMode && selectedTab == 1) {
selectedTab = 0
}
}
LaunchedEffect(selectedTab) {
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
}
@@ -297,7 +318,7 @@ fun MainScreen(
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.pointerInput(selectedTab) {
.pointerInput(selectedTab, wdttLinkMode) {
var totalDrag = 0f
detectHorizontalDragGestures(
onDragStart = {
@@ -310,8 +331,8 @@ fun MainScreen(
dragProgress = 0f
},
onDragEnd = {
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) {
selectedTab = dragTargetIndex
if (dragTargetIndex in activeNavItems.indices && dragProgress >= 0.5f) {
selectedTab = activeNavItems[dragTargetIndex].id
if (selectedTab == 3) TunnelManager.clearUnreadErrors()
}
dragTargetIndex = -1
@@ -326,8 +347,9 @@ fun MainScreen(
return@detectHorizontalDragGestures
}
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1
if (candidate !in navItems.indices) {
val currentActiveIndex = activeNavItems.indexOfFirst { it.id == selectedTab }
val candidate = if (totalDrag < 0f) currentActiveIndex + 1 else currentActiveIndex - 1
if (candidate !in activeNavItems.indices) {
dragTargetIndex = -1
dragProgress = 0f
return@detectHorizontalDragGestures
@@ -350,15 +372,15 @@ fun MainScreen(
) { tab ->
when (tab) {
0 -> SettingsTab()
1 -> DeployTab()
1 -> if (!wdttLinkMode) DeployTab() else Spacer(modifier = Modifier.fillMaxSize())
2 -> ExceptionsTab()
3 -> LogsTab()
4 -> InfoTab()
4 -> InfoTab(actionsExpandedState = actionsExpanded, projectExpandedState = projectExpanded)
}
}
ProxyNavigationBar(
navItems = navItems,
navItems = activeNavItems,
selectedTab = selectedTab,
dragTargetIndex = dragTargetIndex,
dragProgress = dragProgress,
@@ -380,6 +402,10 @@ fun MainScreen(
// Floating theme toolbar overlay
FloatingToolbar(
activeProfile = activeProfile,
onActiveProfileChange = { profile ->
scope.launch { settingsStore.saveActiveProfile(profile) }
},
currentTheme = themeMode,
onThemeChange = onThemeChange,
isDynamicColor = isDynamicColor,
@@ -453,13 +479,16 @@ private fun ProxyNavigationBar(
} else {
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f)
}
val indicatorIndex = remember { Animatable(selectedTab.toFloat()) }
val selectedVisualIndex = remember(selectedTab, navItems) {
navItems.indexOfFirst { it.id == selectedTab }.coerceAtLeast(0)
}
val indicatorIndex = remember { Animatable(selectedVisualIndex.toFloat()) }
val dragVisualIndex = indicatorIndex.value
LaunchedEffect(selectedTab) {
LaunchedEffect(selectedVisualIndex) {
if (dragTargetIndex !in navItems.indices) {
indicatorIndex.animateTo(
targetValue = selectedTab.toFloat(),
targetValue = selectedVisualIndex.toFloat(),
animationSpec = tween(
durationMillis = 720,
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f)
@@ -468,9 +497,9 @@ private fun ProxyNavigationBar(
}
}
LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) {
LaunchedEffect(selectedVisualIndex, dragTargetIndex, dragProgress) {
if (dragTargetIndex in navItems.indices) {
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress
val target = selectedVisualIndex.toFloat() + (dragTargetIndex - selectedVisualIndex) * dragProgress
indicatorIndex.snapTo(target)
}
}
@@ -522,7 +551,7 @@ private fun ProxyNavigationBar(
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(22.dp))
.clickable { onTabSelected(index) },
.clickable { onTabSelected(item.id) },
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -533,7 +562,7 @@ private fun ProxyNavigationBar(
modifier = Modifier.size(22.dp),
tint = iconColor
)
if (index == 3 && unreadErrors > 0) {
if (item.id == 3 && unreadErrors > 0) {
Badge(
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
contentColor = colors.onPrimary,
@@ -0,0 +1,167 @@
package com.wdtt.client
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.net.VpnService
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class QuickToggleTileService : TileService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var stateJob: Job? = null
override fun onStartListening() {
super.onStartListening()
// Реактивно подписываемся на статус активности туннеля.
// Плитка будет строго отражать РЕАЛЬНОЕ состояние туннеля на 100% без рассинхронизаций.
stateJob?.cancel()
stateJob = scope.launch {
try {
TunnelManager.running.collect { running ->
updateTile(running)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Error collecting running state", e)
}
}
}
override fun onStopListening() {
stateJob?.cancel()
super.onStopListening()
}
override fun onClick() {
super.onClick()
runCatching {
if (TunnelManager.running.value) {
// Если запущен — останавливаем. Состояние плитки изменится автоматически,
// когда TunnelManager остановит процессы и обновит статус running в false.
val stopIntent = Intent(this, TunnelService::class.java).apply { action = "STOP" }
startService(stopIntent)
return
}
// Проверяем наличие выданного разрешения VPN перед стартом
if (VpnService.prepare(this) != null) {
Toast.makeText(this, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity()
return
}
// Запускаем старт туннеля в фоне
scope.launch {
try {
val intent = buildStartIntent()
if (intent == null) {
Toast.makeText(this@QuickToggleTileService, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity()
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
startForegroundService(intent)
} else {
startService(intent)
}
} catch (e: Exception) {
Log.e("QuickToggleTile", "Failed to start tunnel via QS tile", e)
Toast.makeText(this@QuickToggleTileService, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Crash prevented in onClick", e)
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private suspend fun buildStartIntent(): Intent? {
return runCatching {
val store = SettingsStore(applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
Intent(this, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}.getOrNull()
}
private fun updateTile(running: Boolean) {
runCatching {
qsTile?.apply {
label = "WDTT"
icon = Icon.createWithResource(this@QuickToggleTileService, R.drawable.ic_tile_logo_w)
state = if (running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= 29) {
subtitle = if (running) "Подключено" else "Отключено"
}
updateTile()
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to update QS tile state", e)
}
}
private fun openMainActivity() {
runCatching {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (Build.VERSION.SDK_INT >= 34) {
val pendingIntent = PendingIntent.getActivity(
this,
100,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}.onFailure { e ->
Log.e("QuickToggleTile", "Failed to open MainActivity", e)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -16,10 +16,18 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import android.os.Build
class SettingsStore(context: Context) {
private val appContext = context.applicationContext
companion object {
private val Context.dataStore by preferencesDataStore("settings")
private val ACTIVE_PROFILE = intPreferencesKey("active_profile")
private val SHOW_SYSTEM_APPS = booleanPreferencesKey("show_system_apps")
private val LOGGING_ENABLED = booleanPreferencesKey("logging_enabled")
private val WDTT_LINK = stringPreferencesKey("wdtt_link")
private val WDTT_LINK_MODE = booleanPreferencesKey("wdtt_link_mode")
private val PEER = stringPreferencesKey("peer")
private val VK_HASHES = stringPreferencesKey("vk_hashes")
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash")
@@ -83,6 +91,18 @@ class SettingsStore(context: Context) {
private val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version")
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action")
private val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at")
private fun <T> getProfileKey(baseKey: Preferences.Key<T>, profile: Int): Preferences.Key<T> {
if (profile == 0) return baseKey
val newName = "${baseKey.name}_$profile"
@Suppress("UNCHECKED_CAST")
return when (baseKey) {
PEER, VK_HASHES, SECONDARY_VK_HASH, PROTOCOL, SNI, USER_AGENT, DEPLOY_IP, DEPLOY_LOGIN, DEPLOY_PASSWORD, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_SSH_PORT, EXCLUDED_APPS, CONNECTION_PASSWORD, CONNECTION_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_ADMIN_ID, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_BOT_TOKEN, DEPLOY_BOT_TOKEN_ENCRYPTED, PROXY_MODE, PROXY_HOST, CAPTCHA_MODE, CAPTCHA_SOLVE_METHOD, CAPTCHA_WBV_SOLVE_METHOD, WDTT_LINK -> stringPreferencesKey(newName) as Preferences.Key<T>
WORKERS_PER_HASH, LISTEN_PORT, SERVER_DTLS_PORT, SERVER_WG_PORT, PROXY_PORT -> intPreferencesKey(newName) as Preferences.Key<T>
MANUAL_PORTS_ENABLED, NO_DTLS, NO_DNS, IS_WHITELIST, WDTT_LINK_MODE -> booleanPreferencesKey(newName) as Preferences.Key<T>
else -> throw IllegalArgumentException("Unsupported key type: ${baseKey.name}")
}
}
}
private val dataStore = appContext.dataStore
@@ -94,65 +114,151 @@ class SettingsStore(context: Context) {
}
}
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" }
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" }
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" }
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 }
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" }
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 }
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { it[MANUAL_PORTS_ENABLED] ?: false }
val serverDtlsPort: Flow<Int> = dataStore.data.map { it[SERVER_DTLS_PORT] ?: 56000 }
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 }
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" }
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false }
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" }
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" }
val deployPassword: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
val activeProfile: Flow<Int> = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 }
val showSystemApps: Flow<Boolean> = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true }
val loggingEnabled: Flow<Boolean> = dataStore.data.map { it[LOGGING_ENABLED] ?: true }
val wdttLink: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK, profile)] ?: ""
}
val wdttLinkMode: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false
}
val peer: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PEER, profile)] ?: ""
}
val vkHashes: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(VK_HASHES, profile)] ?: ""
}
val secondaryVkHash: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] ?: ""
}
val workersPerHash: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WORKERS_PER_HASH, profile)] ?: 16
}
val protocol: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROTOCOL, profile)] ?: "udp"
}
val listenPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(LISTEN_PORT, profile)] ?: 9000
}
val manualPortsEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] ?: false
}
val serverDtlsPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] ?: 56000
}
val serverWgPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_WG_PORT, profile)] ?: 56001
}
val sni: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SNI, profile)] ?: ""
}
val noDns: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(NO_DNS, profile)] ?: false
}
val userAgent: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] ?: ""
}
val deployIp: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_IP, profile)] ?: ""
}
val deployLogin: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_LOGIN, profile)] ?: ""
}
val deployPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, profile)
}
val deploySshPort: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] ?: ""
}
val excludedApps: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] ?: ""
}
val deploySshPort: Flow<String> = dataStore.data.map { it[DEPLOY_SSH_PORT] ?: "" }
val excludedApps: Flow<String> = dataStore.data.map { it[EXCLUDED_APPS] ?: "" }
val detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
// ═══ Пароли и Управление ═══
val connectionPassword: Flow<String> = dataStore.data.map {
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
val connectionPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile)
}
val deployMainPassword: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
val deployMainPassword: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile)
}
val deployAdminId: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
val deployAdminId: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile)
}
val deployBotToken: Flow<String> = dataStore.data.map {
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
val deployBotToken: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, profile)
}
// ═══ Proxy Mode ═══
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" }
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" }
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 }
val proxyMode: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_MODE, profile)] ?: "tun"
}
val proxyHost: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_HOST, profile)] ?: "127.0.0.1"
}
val proxyPort: Flow<Int> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_PORT, profile)] ?: 1080
}
// ═══ Captcha Solve Mode ═══
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" }
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" }
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" }
val captchaMode: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] ?: "auto"
}
val captchaSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] ?: "auto"
}
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] ?: "auto"
}
// ═══ VPN Exclusions Mode ═══
val isWhitelist: Flow<Boolean> = dataStore.data.map { it[IS_WHITELIST] ?: false }
val isWhitelist: Flow<Boolean> = dataStore.data.map { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] ?: false
}
// ═══ Theme Mode ═══
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" }
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: false }
val isDynamicColor: Flow<Boolean> = dataStore.data.map { it[IS_DYNAMIC_COLOR] ?: (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) }
val themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L }
val updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" }
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS }
val updateCheckIntervalHours: Flow<Int> = dataStore.data.map { it[UPDATE_CHECK_INTERVAL_HOURS] ?: 24 }
val updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" }
@@ -215,6 +321,38 @@ class SettingsStore(context: Context) {
}
}
suspend fun saveActiveProfile(profile: Int) {
dataStore.edit { prefs ->
prefs[ACTIVE_PROFILE] = profile
}
}
suspend fun saveShowSystemApps(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[SHOW_SYSTEM_APPS] = enabled
}
}
suspend fun saveLoggingEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[LOGGING_ENABLED] = enabled
}
}
suspend fun saveWdttLink(link: String) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK, profile)] = link
}
}
suspend fun saveWdttLinkMode(enabled: Boolean) {
dataStore.edit { prefs ->
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(WDTT_LINK_MODE, profile)] = enabled
}
}
suspend fun save(
peer: String,
vkHashes: String,
@@ -226,102 +364,115 @@ class SettingsStore(context: Context) {
noDns: Boolean = false
) {
dataStore.edit { prefs ->
prefs[PEER] = peer
prefs[VK_HASHES] = vkHashes
prefs[SECONDARY_VK_HASH] = secondaryVkHash
prefs[WORKERS_PER_HASH] = workersPerHash
prefs[PROTOCOL] = protocol
prefs[LISTEN_PORT] = listenPort
prefs[SNI] = sni
prefs[NO_DNS] = noDns
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PEER, profile)] = peer
prefs[getProfileKey(VK_HASHES, profile)] = vkHashes
prefs[getProfileKey(SECONDARY_VK_HASH, profile)] = secondaryVkHash
prefs[getProfileKey(WORKERS_PER_HASH, profile)] = workersPerHash
prefs[getProfileKey(PROTOCOL, profile)] = protocol
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
prefs[getProfileKey(SNI, profile)] = sni
prefs[getProfileKey(NO_DNS, profile)] = noDns
}
}
suspend fun saveManualPortsEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[MANUAL_PORTS_ENABLED] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(MANUAL_PORTS_ENABLED, profile)] = enabled
}
}
suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
dataStore.edit { prefs ->
prefs[SERVER_DTLS_PORT] = serverDtlsPort
prefs[SERVER_WG_PORT] = serverWgPort
prefs[LISTEN_PORT] = listenPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(SERVER_DTLS_PORT, profile)] = serverDtlsPort
prefs[getProfileKey(SERVER_WG_PORT, profile)] = serverWgPort
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
}
}
suspend fun saveUserAgent(ua: String) {
dataStore.edit { prefs ->
prefs[USER_AGENT] = ua
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(USER_AGENT, profile)] = ua
}
}
suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
dataStore.edit { prefs ->
prefs[DEPLOY_IP] = ip
prefs[DEPLOY_LOGIN] = login
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass)
prefs[DEPLOY_SSH_PORT] = sshPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DEPLOY_IP, profile)] = ip
prefs[getProfileKey(DEPLOY_LOGIN, profile)] = login
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
}
}
suspend fun saveExcludedApps(packages: String) {
dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
}
}
suspend fun saveDetailedLogs(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[DETAILED_LOGS] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DETAILED_LOGS, profile)] = enabled
}
}
// ═══ Сохранение пароля подключения ═══
suspend fun saveConnectionPassword(password: String) {
dataStore.edit { prefs ->
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password)
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, password, profile)
}
}
// ═══ Сохранение секретов деплоя ═══
suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
dataStore.edit { prefs ->
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass)
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken)
prefs[DEPLOY_SSH_PORT] = sshPort
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass, profile)
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId, profile)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
}
}
// ═══ Сохранение proxy mode ═══
suspend fun saveProxyMode(mode: String, host: String, port: Int) {
dataStore.edit { prefs ->
prefs[PROXY_MODE] = mode
prefs[PROXY_HOST] = host
prefs[PROXY_PORT] = port
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(PROXY_MODE, profile)] = mode
prefs[getProfileKey(PROXY_HOST, profile)] = host
prefs[getProfileKey(PROXY_PORT, profile)] = port
}
}
// ═══ Сохранение режима обхода капчи ═══
suspend fun saveCaptchaMode(mode: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_MODE] = mode
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] = mode
}
}
suspend fun saveCaptchaSolveMethod(method: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_SOLVE_METHOD] = method
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
}
}
suspend fun saveWbvCaptchaSolveMethod(method: String) {
dataStore.edit { prefs ->
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method
if (prefs[CAPTCHA_MODE] == "wv") {
prefs[CAPTCHA_SOLVE_METHOD] = method
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] = method
if (prefs[getProfileKey(CAPTCHA_MODE, profile)] == "wv") {
prefs[getProfileKey(CAPTCHA_SOLVE_METHOD, profile)] = method
}
}
}
@@ -329,47 +480,57 @@ class SettingsStore(context: Context) {
// ═══ Сохранение режима списка (ЧС/БС) ═══
suspend fun saveIsWhitelist(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[IS_WHITELIST] = enabled
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(IS_WHITELIST, profile)] = enabled
}
}
// Атомарное сохранение обоих параметров для исключения гонки при перезагрузке
suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages
prefs[IS_WHITELIST] = isWhitelist
val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
prefs[getProfileKey(IS_WHITELIST, profile)] = isWhitelist
}
}
private suspend fun migrateSecretsToKeystore() {
dataStore.edit { prefs ->
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD)
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD)
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD)
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID)
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN)
for (profile in 0..2) {
prefs.migrateSecret(getProfileKey(DEPLOY_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(CONNECTION_PASSWORD_ENCRYPTED, profile), getProfileKey(CONNECTION_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_MAIN_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_MAIN_PASSWORD, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_ADMIN_ID_ENCRYPTED, profile), getProfileKey(DEPLOY_ADMIN_ID, profile))
prefs.migrateSecret(getProfileKey(DEPLOY_BOT_TOKEN_ENCRYPTED, profile), getProfileKey(DEPLOY_BOT_TOKEN, profile))
}
}
}
private fun readSecret(
prefs: Preferences,
encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String>
legacyKey: Preferences.Key<String>,
profile: Int
): String {
return secureStore.decrypt(prefs[encryptedKey]) ?: prefs[legacyKey] ?: ""
val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
return secureStore.decrypt(prefs[profEncryptedKey]) ?: prefs[profLegacyKey] ?: ""
}
private fun MutablePreferences.putSecret(
encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String>,
value: String
value: String,
profile: Int
) {
val profEncryptedKey = getProfileKey(encryptedKey, profile)
val profLegacyKey = getProfileKey(legacyKey, profile)
if (value.isBlank()) {
remove(encryptedKey)
remove(legacyKey)
remove(profEncryptedKey)
remove(profLegacyKey)
} else {
this[encryptedKey] = secureStore.encrypt(value)
remove(legacyKey)
this[profEncryptedKey] = secureStore.encrypt(value)
remove(profLegacyKey)
}
}
@@ -40,7 +40,7 @@ object TunnelManager {
private var refusedCount = 0
private var currentHashErrorCount = 0
private var wrapAuthTimeoutCount = 0
private var processStartedAtMs = 0L
var processStartedAtMs = 0L
private var lastActiveAtMs = 0L
private var activeHashIndex = 0 // 0: primary, 1: secondary
private var currentParams: TunnelParams? = null
@@ -49,6 +49,9 @@ object TunnelManager {
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
@Volatile
var isLoggingEnabled = true
val running = MutableStateFlow(false)
val logs = MutableStateFlow<List<LogEntry>>(emptyList())
val unreadErrorCount = MutableStateFlow(0)
@@ -56,7 +59,7 @@ object TunnelManager {
val stats = MutableStateFlow("Ожидание данных...")
val activeWorkers = MutableStateFlow(0)
val cooldownSeconds = MutableStateFlow(0)
val cooldownActive = MutableStateFlow(false)
private var cooldownJob: Job? = null
fun clearUnreadErrors() {
@@ -75,6 +78,7 @@ object TunnelManager {
}
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
if (!isLoggingEnabled) return
if (isError) {
val list = logs.value
if (list.none { it.key == key }) {
@@ -152,13 +156,11 @@ object TunnelManager {
}
val hashCount = hashList.size.coerceIn(1, 3)
val totalWorkers = params.workersPerHash.coerceIn(1, 128) // Максимум ограничивается UI (80), но тут ставим хард-лимит побольше на случай запаса
val totalWorkers = params.workersPerHash.coerceIn(1, 128)
val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
val binaryFile = File(binaryPath)
@@ -182,15 +184,13 @@ object TunnelManager {
cmd.add("-password")
cmd.add(params.connectionPassword)
// Captcha mode: wv или rjs
cmd.add("-captcha-mode")
cmd.add(params.captchaMode)
val pb = ProcessBuilder(cmd)
pb.directory(context.filesDir) // Устанавливаем рабочую директорию
pb.directory(context.filesDir)
pb.redirectErrorStream(true)
// Set LD_LIBRARY_PATH
val env = pb.environment()
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
@@ -220,7 +220,6 @@ object TunnelManager {
var lastResetTime = System.currentTimeMillis()
reader.forEachLine { line ->
// Периодический сброс счетчиков ошибок (раз в 60 сек)
val now = System.currentTimeMillis()
if (now - lastResetTime > 60000) {
refusedCount = 0
@@ -230,13 +229,11 @@ object TunnelManager {
lastResetTime = now
}
// Чистим лог от даты из Go (например, "2023/10/24 12:34:56.123456 [ВОРКЕР...")
val msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
val lineTrim = msgPrefixReplaced.trim()
val isError = lineTrim.contains("Ошибка", true) || lineTrim.contains("error", true) || lineTrim.contains("FAIL", true) || lineTrim.contains("timeout", true) || lineTrim.contains("refused", true) || lineTrim.contains("FATAL_AUTH", true)
// 0. FATAL AUTH — мгновенная остановка
if (lineTrim.contains("FATAL_AUTH")) {
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
@@ -271,8 +268,6 @@ object TunnelManager {
return@forEachLine
}
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
if (activeWorkers.value > 0) {
wrapAuthTimeoutCount = 0
@@ -294,7 +289,6 @@ object TunnelManager {
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()
if (!running.value) {
activeWorkers.value = 0
}
fun startCooldown(seconds: Int) {
cooldownJob?.cancel()
cooldownSeconds.value = seconds
cooldownJob = scope.launch(Dispatchers.Main) {
while (cooldownSeconds.value > 0) {
delay(1000)
cooldownSeconds.update { it - 1 }
}
fun startCooldown(millis: Long) {
cooldownJob?.cancel()
cooldownActive.value = true
cooldownJob = scope.launch(Dispatchers.Main) {
delay(millis)
cooldownActive.value = false
}
}
@@ -831,6 +785,6 @@ data class TunnelParams(
val sni: String = "",
val connectionPassword: String = "",
val protocol: String = "udp",
val captchaMode: String = "auto", // "auto", "wv" или "rjs"
val captchaSolveMethod: String = "auto" // "manual" или "auto"
val captchaMode: String = "auto",
val captchaSolveMethod: String = "auto"
)
@@ -283,6 +283,15 @@ class TunnelService : Service() {
stopSelf()
break
}
if (TunnelManager.running.value && !isTunnelPaused) {
val helper = WireGuardHelper(applicationContext)
val startupWindow = System.currentTimeMillis() - TunnelManager.processStartedAtMs < 6000
if (!startupWindow && !helper.isTunnelUp()) {
Log.w("TunnelService", "Обнаружена пропажа или замена VPN-интерфейса! Экстренное выключение туннеля.")
stopTunnel()
break
}
}
if (!isTunnelPaused) {
updateNotification(buildTunnelNotificationText())
}
@@ -0,0 +1,190 @@
package com.wdtt.client
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.util.Log
import android.widget.RemoteViews
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class VpnWidgetProvider : AppWidgetProvider() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
companion object {
const val ACTION_WIDGET_TOGGLE = "com.wdtt.client.ACTION_WIDGET_TOGGLE"
fun updateAllWidgets(context: Context) {
runCatching {
val appWidgetManager = AppWidgetManager.getInstance(context)
val thisWidget = ComponentName(context, VpnWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
if (appWidgetIds.isNotEmpty()) {
val intent = Intent(context, VpnWidgetProvider::class.java).apply {
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
}
context.sendBroadcast(intent)
}
}
}
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
val running = TunnelManager.running.value
for (appWidgetId in appWidgetIds) {
updateWidgetState(context, appWidgetManager, appWidgetId, running)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == ACTION_WIDGET_TOGGLE) {
runCatching {
if (TunnelManager.running.value) {
// Останавливаем туннель
val stopIntent = Intent(context, TunnelService::class.java).apply { action = "STOP" }
context.startService(stopIntent)
updateAllWidgets(context)
return
}
if (VpnService.prepare(context) != null) {
Toast.makeText(context, "Откройте WDTT и выдайте VPN-разрешение", Toast.LENGTH_LONG).show()
openMainActivity(context)
return
}
// Запуск туннеля в фоне
scope.launch {
try {
val startIntent = buildStartIntent(context)
if (startIntent == null) {
Toast.makeText(context, "Заполните настройки подключения в WDTT", Toast.LENGTH_LONG).show()
openMainActivity(context)
return@launch
}
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(startIntent)
} else {
context.startService(startIntent)
}
} catch (e: Exception) {
Log.e("VpnWidget", "Failed to start tunnel from widget", e)
Toast.makeText(context, "Ошибка запуска: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
}
}.onFailure { e ->
Log.e("VpnWidget", "Error handling widget click", e)
}
}
}
private fun updateWidgetState(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
running: Boolean
) {
val views = RemoteViews(context.packageName, R.layout.vpn_widget)
// Обновляем текст статуса и неоновую иконку кнопки
if (running) {
views.setTextViewText(R.id.widget_status, "Подключено")
views.setTextColor(R.id.widget_status, 0xFF00E5FF.toInt()) // Неоновый голубой
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_active)
} else {
views.setTextViewText(R.id.widget_status, "Отключено")
views.setTextColor(R.id.widget_status, 0xFF888888.toInt()) // Матовый серый
views.setInt(R.id.widget_toggle_btn, "setBackgroundResource", R.drawable.bg_widget_button_inactive)
}
// Клик по всей карточке открывает приложение
val openIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val openPendingIntent = PendingIntent.getActivity(
context,
appWidgetId,
openIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_container, openPendingIntent)
// Клик по кнопке запускает/останавливает VPN
val toggleIntent = Intent(context, VpnWidgetProvider::class.java).apply {
action = ACTION_WIDGET_TOGGLE
}
val togglePendingIntent = PendingIntent.getBroadcast(
context,
appWidgetId + 1000,
toggleIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
views.setOnClickPendingIntent(R.id.widget_toggle_btn, togglePendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private suspend fun buildStartIntent(context: Context): Intent? {
val store = SettingsStore(context.applicationContext)
val basePeer = store.peer.first()
val hashes = store.vkHashes.first()
val password = store.connectionPassword.first()
if (basePeer.isBlank() || hashes.isBlank() || password.isBlank()) return null
val manualPortsEnabled = store.manualPortsEnabled.first()
val serverDtlsPort = if (manualPortsEnabled) store.serverDtlsPort.first() else 56000
val localPort = if (manualPortsEnabled) store.listenPort.first() else 9000
val peerWithPort = if (basePeer.contains(":")) basePeer else "$basePeer:$serverDtlsPort"
return Intent(context, TunnelService::class.java).apply {
action = "START"
putExtra("peer", peerWithPort)
putExtra("vk_hashes", hashes)
putExtra("secondary_vk_hash", store.secondaryVkHash.first())
putExtra("workers_per_hash", store.workersPerHash.first())
putExtra("port", localPort)
putExtra("sni", store.sni.first())
putExtra("connection_password", password)
putExtra("captcha_mode", sanitizeCaptchaMode(store.captchaMode.first()))
putExtra("captcha_solve_method", store.captchaSolveMethod.first())
}
}
private fun openMainActivity(context: Context) {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching {
val pendingIntent = PendingIntent.getActivity(
context,
200,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
pendingIntent.send()
}.onFailure {
context.startActivity(intent)
}
}
private fun sanitizeCaptchaMode(mode: String?): String {
return when (mode?.lowercase()) {
"auto" -> "auto"
"rjs" -> "rjs"
"wv" -> "wv"
else -> "auto"
}
}
}
@@ -2,7 +2,13 @@ package com.wdtt.client
import android.app.Application
import android.content.Context
import android.util.Log
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WdttApplication : Application() {
@Volatile
@@ -14,6 +20,43 @@ class WdttApplication : Application() {
override fun onCreate() {
super.onCreate()
DeployManager.init(this)
// Очищаем фантомный VPN при холодном старте приложения (например, после перезагрузки телефона).
// Если телефон перезагрузился, система Android пытается сама восстановить VpnService,
// что приводит к фантомному ключу без интернета. Этот код мгновенно сбрасывает статус в DOWN.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
runCatching {
val backend = getBackend(this@WdttApplication)
val tunnel = WireGuardHelper.WgTunnel()
backend.setState(tunnel, Tunnel.State.DOWN, null)
Log.d("WdttApp", "Успешно очищен фантомный VPN при холодном старте")
}.onFailure {
Log.w("WdttApp", "Не удалось очистить фантомный VPN: ${it.message}")
}
}
// Реактивно обновляем все виджеты на домашнем экране при изменении состояния туннеля
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
TunnelManager.running.collect {
VpnWidgetProvider.updateAllWidgets(this@WdttApplication)
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось обновить виджеты: ${e.message}")
}
}
// Реактивно отслеживаем флаг логирования
val settingsStore = SettingsStore(this)
CoroutineScope(SupervisorJob() + Dispatchers.Main).launch {
try {
settingsStore.loggingEnabled.collect { enabled ->
TunnelManager.isLoggingEnabled = enabled
}
} catch (e: Exception) {
Log.e("WdttApp", "Не удалось отслеживать флаг логирования: ${e.message}")
}
}
}
fun getBackend(context: Context): GoBackend {
@@ -137,6 +137,15 @@ class WireGuardHelper(context: Context) {
}
}
suspend fun isTunnelUp(): Boolean = wgMutex.withLock {
val current = sharedTunnel ?: return false
return try {
backend.getState(current) == Tunnel.State.UP
} catch (e: Exception) {
false
}
}
suspend fun stopTunnel() = wgMutex.withLock {
withContext(Dispatchers.IO) {
try {
@@ -51,8 +51,8 @@ fun AppSectionCard(
color = appSectionCardColor(),
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, appSectionCardBorderColor()),
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp,
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
modifier = modifier.fillMaxWidth()
) {
Column(
@@ -91,9 +91,9 @@ fun DeployTab() {
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp }
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin }
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword }
LaunchedEffect(savedIp) { ip = savedIp }
LaunchedEffect(savedLogin) { login = savedLogin }
LaunchedEffect(savedPassword) { password = savedPassword }
val animatedProgress by animateFloatAsState(
targetValue = deployProgress,
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
@@ -38,7 +38,8 @@ import androidx.compose.runtime.Stable
data class AppItem(
val name: String,
val packageName: String,
val icon: ImageBitmap?
val icon: ImageBitmap?,
val isSystem: Boolean
)
object AppCache {
@@ -61,6 +62,8 @@ fun ExceptionsTab() {
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
var searchQuery by remember { mutableStateOf("") }
val showSystemAppsOpt by settingsStore.showSystemApps.collectAsStateWithLifecycle(initialValue = null)
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
// Load Apps
@@ -76,10 +79,12 @@ fun ExceptionsTab() {
if (app.packageName != context.packageName &&
!app.packageName.contains("vkontakte") &&
!app.packageName.contains("vk.calls")) {
val isSys = (app.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
list.add(AppItem(
name = app.loadLabel(pm).toString(),
packageName = app.packageName,
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap()
icon = app.loadIcon(pm)?.toBitmap()?.asImageBitmap(),
isSystem = isSys
))
}
}
@@ -91,8 +96,17 @@ fun ExceptionsTab() {
val filteredApps by remember {
derivedStateOf {
if (searchQuery.isBlank()) appsList
else appsList.filter {
val showSystemApps = showSystemAppsOpt ?: true
val list = if (showSystemApps) {
appsList
} else {
appsList.filter {
!it.isSystem || it.packageName == "com.google.android.youtube" || it.packageName == "com.android.vending"
}
}
if (searchQuery.isBlank()) list
else list.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.packageName.contains(searchQuery, ignoreCase = true)
}
@@ -174,10 +188,37 @@ fun ExceptionsTab() {
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Системные приложения",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = showSystemAppsOpt ?: true,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveShowSystemApps(enabled)
}
}
)
}
}
// List
if (isLoading) {
if (isLoading || showSystemAppsOpt == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -36,8 +36,13 @@ import android.os.Build
import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@Composable
fun FloatingToolbar(
activeProfile: Int,
onActiveProfileChange: (Int) -> Unit,
currentTheme: String,
onThemeChange: (String) -> Unit,
isDynamicColor: Boolean,
@@ -55,6 +60,9 @@ fun FloatingToolbar(
with(density) { configuration.screenWidthDp.dp.toPx() }
}
var parentWidthPx by remember { mutableFloatStateOf(0f) }
var parentHeightPx by remember { mutableFloatStateOf(0f) }
var offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
var isRightSide by rememberSaveable { mutableStateOf(true) }
var isExpanded by rememberSaveable { mutableStateOf(false) }
@@ -76,12 +84,16 @@ fun FloatingToolbar(
} else {
effectiveTabHeightPx
}
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f
val currentParentHeight = if (parentHeightPx > 0f) parentHeightPx else screenHeightPx
val currentParentWidth = if (parentWidthPx > 0f) parentWidthPx else screenWidthPx
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (currentParentHeight - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (currentParentHeight * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) currentParentWidth - tabWidthPx else 0f
val animatedTabXPx by animateFloatAsState(
targetValue = targetXPx,
@@ -93,7 +105,14 @@ fun FloatingToolbar(
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY)
}
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
parentWidthPx = coordinates.size.width.toFloat()
parentHeightPx = coordinates.size.height.toFloat()
}
) {
Surface(
onClick = { isExpanded = !isExpanded },
modifier = Modifier
@@ -114,16 +133,16 @@ fun FloatingToolbar(
else
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shadowElevation = 6.dp,
tonalElevation = 4.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Box(
modifier = Modifier.size(tabWidthDp, tabHeightDp),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_palette),
contentDescription = "Тема",
imageVector = Icons.Filled.Settings,
contentDescription = "Настройки",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
@@ -151,13 +170,54 @@ fun FloatingToolbar(
},
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
tonalElevation = 4.dp,
shadowElevation = 0.dp,
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
"Профиль",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0, 1, 2).forEach { profile ->
val selected = profile == activeProfile
Surface(
onClick = { onActiveProfileChange(profile) },
shape = RoundedCornerShape(12.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Пр. $profile",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp
)
}
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
Text(
"Тема",
style = MaterialTheme.typography.labelMedium,
@@ -1,5 +1,7 @@
package com.wdtt.client.ui
import androidx.compose.runtime.MutableState
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -156,7 +158,10 @@ private fun openUrlInBrowser(context: Context, url: String) {
}
@Composable
fun InfoTab() {
fun InfoTab(
actionsExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) },
projectExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val settingsStore = remember { SettingsStore(context) }
@@ -165,8 +170,8 @@ fun InfoTab() {
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
var showHelpDialog by remember { mutableStateOf(false) }
var showDonateDialog by remember { mutableStateOf(false) }
var actionsExpanded by rememberSaveable { mutableStateOf(true) }
var projectExpanded by rememberSaveable { mutableStateOf(true) }
var actionsExpanded by actionsExpandedState
var projectExpanded by projectExpandedState
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) {
@@ -29,11 +29,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.wdtt.client.LogEntry
import com.wdtt.client.TunnelManager
import com.wdtt.client.WDTTColors
import com.wdtt.client.SettingsStore
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogsTab() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
val loggingEnabled by settingsStore.loggingEnabled.collectAsStateWithLifecycle(initialValue = true)
val scope = rememberCoroutineScope()
val currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
@@ -65,6 +70,38 @@ fun LogsTab() {
}
}
// Карточка-выключатель логирования
AppSectionCard(
modifier = Modifier.padding(bottom = 12.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Активное логирование",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
Switch(
checked = loggingEnabled,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveLoggingEnabled(enabled)
if (!enabled) {
TunnelManager.clearLogs()
}
}
}
)
}
}
// Logs container — адаптивный к теме
val isDark = isSystemInDarkTheme()
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
@@ -1,6 +1,9 @@
package com.wdtt.client.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -83,14 +86,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000)
val activeProfile by settingsStore.activeProfile.collectAsStateWithLifecycle(initialValue = 0)
val wdttLinkMode by settingsStore.wdttLinkMode.collectAsStateWithLifecycle(initialValue = false)
val wdttLink by settingsStore.wdttLink.collectAsStateWithLifecycle(initialValue = "")
val tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
val cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle()
val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
var wasRunning by remember { mutableStateOf(false) }
LaunchedEffect(tunnelRunning) {
if (wasRunning && !tunnelRunning) {
TunnelManager.startCooldown(5)
TunnelManager.startCooldown(1500L)
}
wasRunning = tunnelRunning
}
@@ -112,7 +119,18 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val allHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { listOf(vkHash1, vkHash2, vkHash3, vkHash4) }
val uniqueHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { allHashes.filter { it.isNotBlank() && it.length >= 16 }.distinct() }
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.size }
val parsedLinkHashes = remember(wdttLink) {
if (wdttLink.trim().startsWith("wdtt://")) {
val clean = wdttLink.trim().removePrefix("wdtt://")
val parts = clean.split(":")
if (parts.size >= 6) {
parts[5].split(",").filter { stripVkUrlStatic(it).isNotBlank() }
} else emptyList()
} else emptyList()
}
val filledHashCount = remember(vkHash1, vkHash2, vkHash3, vkHash4, wdttLinkMode, parsedLinkHashes) {
if (wdttLinkMode) parsedLinkHashes.size else uniqueHashes.size
}
val combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
var portInput by rememberSaveable { mutableStateOf("9000") }
@@ -156,7 +174,7 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.joinToString(",")
}
LaunchedEffect(Unit) {
LaunchedEffect(activeProfile) {
val peer = settingsStore.peer.first()
val hashes = settingsStore.vkHashes.first()
val workers = settingsStore.workersPerHash.first()
@@ -238,7 +256,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
val isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
val isHashesValid = combinedHashes.isNotBlank()
val isValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
val isLinkValid = wdttLink.trim().startsWith("wdtt://") && wdttLink.trim().split(":").size >= 6 && wdttLink.trim().split(":")[5].isNotBlank()
val isManualValid = isPeerValid && isHashesValid && savedConnectionPassword.isNotBlank() && !hasInputHashErrors
val isValid = if (wdttLinkMode) isLinkValid else isManualValid
val effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
@@ -255,15 +275,37 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
settingsStore.saveCaptchaMode(effectiveCaptchaMode)
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod)
}
var finalPeer = "$peerInput:$effectiveServerDtlsPort"
var finalHashes = combinedHashes
var finalLocalPort = effectiveLocalPort
var finalPassword = savedConnectionPassword
if (wdttLinkMode && wdttLink.trim().startsWith("wdtt://")) {
val clean = wdttLink.trim().removePrefix("wdtt://")
val parts = clean.split(":")
if (parts.size >= 5) {
val ip = parts[0]
val dtls = parts[1].toIntOrNull() ?: 56000
finalLocalPort = parts[3].toIntOrNull() ?: 9000
finalPassword = parts[4]
val hash = if (parts.size >= 6) parts[5] else ""
finalPeer = "$ip:$dtls"
val rawHash = stripVkUrlStatic(hash)
finalHashes = if (rawHash.isNotBlank()) rawHash else normalizeHashes(hash)
}
}
val intent = Intent(context, TunnelService::class.java).apply {
action = "START"
putExtra("peer", "$peerInput:$effectiveServerDtlsPort")
putExtra("vk_hashes", combinedHashes)
putExtra("peer", finalPeer)
putExtra("vk_hashes", finalHashes)
putExtra("secondary_vk_hash", "")
putExtra("workers_per_hash", workersInput.toInt())
putExtra("port", effectiveLocalPort)
putExtra("port", finalLocalPort)
putExtra("sni", sniInput)
putExtra("connection_password", savedConnectionPassword)
putExtra("connection_password", finalPassword)
putExtra("captcha_mode", effectiveCaptchaMode)
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
}
@@ -342,6 +384,9 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (!wdttLinkMode) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// ═══ Заголовок раздела ═══
Text(
"Настройки туннеля",
@@ -400,6 +445,8 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
)
}
}
}
}
// ═══ Мощность + Капча ═══
AppSectionCard(
@@ -575,17 +622,70 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
}
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Режим ссылки —
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Режим ссылки",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = wdttLinkMode,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveWdttLinkMode(enabled)
}
}
)
}
if (wdttLinkMode) {
Column {
var linkText by remember(wdttLink) { mutableStateOf(wdttLink) }
OutlinedTextField(
value = linkText,
onValueChange = {
linkText = it.trim()
scope.launch { settingsStore.saveWdttLink(it.trim()) }
},
label = { Text("Ссылка wdtt://") },
placeholder = { Text("Ссылка wdtt://") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
)
}
}
}
}
// ═══ Кнопки: Секреты + Подключить ═══
val tunnelSecretsMissing = savedConnectionPassword.isBlank()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!wdttLinkMode) {
OutlinedButton(
onClick = { showSecretsDialog = true },
modifier = Modifier.height(52.dp),
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
@@ -598,7 +698,8 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
) {
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Секреты", fontWeight = FontWeight.SemiBold)
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
)
}
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#00E5FF" />
<stroke android:width="1dp" android:color="#33F0FF" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#2C2C2C" />
<stroke android:width="1dp" android:color="#444444" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="24dp" />
<solid android:color="#1A1A1A" />
<stroke android:width="1dp" android:color="#333333" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,4 L6.5,4 L10,14.5 L12,9 L14,14.5 L17.5,4 L22,4 L17,19 L13,19 L12,14.5 L11,19 L7,19 Z" />
</vector>
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_widget_card"
android:padding="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="WDTT"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Отключено"
android:textColor="#888888"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
<ImageButton
android:id="@+id/widget_toggle_btn"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:background="@drawable/bg_widget_button_inactive"
android:src="@drawable/ic_tile_logo_w"
android:contentDescription="Toggle VPN"
android:padding="10dp"
android:scaleType="fitCenter" />
</RelativeLayout>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="110dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:initialLayout="@layout/vpn_widget"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
+4 -6
View File
@@ -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,
)
}
+27 -1
View File
@@ -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)
}
}
}
+1 -5
View File
@@ -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
+3 -5
View File
@@ -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()
+34 -12
View File
@@ -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
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)
}
+1 -7
View File
@@ -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{
+5 -2
View File
@@ -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
}
}
-2
View File
@@ -8,10 +8,8 @@ import (
type Stats struct {
ActiveConnections int32
Reconnects int64
TotalBytesUp int64
TotalBytesDown int64
CredsErrors int64
}
func NewStats() *Stats {
+6 -1
View File
@@ -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
+235 -51
View File
@@ -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"
@@ -68,19 +68,12 @@ type PasswordEntry struct {
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 {
@@ -1492,8 +1673,9 @@ type ObfsConfig struct {
type ObfsState struct {
mu sync.Mutex
seq uint16
ts uint32
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)
}