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 { defaultConfig {
applicationId = "com.wdtt.client" applicationId = "com.wdtt.client"
minSdk = 29 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 118 versionCode = 120
versionName = "1.1.8" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
+27
View File
@@ -29,6 +29,9 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.ACTION_QS_TILE_PREFERENCES" />
</intent-filter>
</activity> </activity>
<service <service
@@ -41,6 +44,17 @@
android:value="persistent_vpn_tunnel_transport" /> android:value="persistent_vpn_tunnel_transport" />
</service> </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 <activity
android:name=".ManlCaptchaActivity" android:name=".ManlCaptchaActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar" android:theme="@android:style/Theme.Translucent.NoTitleBar"
@@ -51,6 +65,19 @@
<receiver <receiver
android:name=".CaptchaCancelReceiver" android:name=".CaptchaCancelReceiver"
android:exported="false" /> 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> </application>
@@ -190,17 +190,18 @@ class MainActivity : ComponentActivity() {
// ═══ Навигация ═══ // ═══ Навигация ═══
private data class NavItem( private data class NavItem(
val id: Int,
val label: String, val label: String,
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
) )
private val navItems = listOf( private val navItems = listOf(
NavItem("Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey), NavItem(0, "Туннель", Icons.Filled.VpnKey, Icons.Outlined.VpnKey),
NavItem("Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud), NavItem(1, "Деплой", Icons.Filled.Cloud, Icons.Outlined.Cloud),
NavItem("Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList), NavItem(2, "Исключ.", Icons.Filled.FilterList, Icons.Outlined.FilterList),
NavItem("Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal), NavItem(3, "Логи", Icons.Filled.Terminal, Icons.Outlined.Terminal),
NavItem("Инфо", Icons.Filled.Info, Icons.Outlined.Info), NavItem(4, "Инфо", Icons.Filled.Info, Icons.Outlined.Info),
) )
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -220,6 +221,8 @@ fun MainScreen(
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() 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 selectedTab by rememberSaveable { mutableIntStateOf(0) }
var dragTargetIndex by remember { mutableIntStateOf(-1) } var dragTargetIndex by remember { mutableIntStateOf(-1) }
var dragProgress by remember { mutableFloatStateOf(0f) } var dragProgress by remember { mutableFloatStateOf(0f) }
@@ -231,6 +234,24 @@ fun MainScreen(
val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() } val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() }
val navOverlayReserve = safeBottomInset + 96.dp 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) { LaunchedEffect(selectedTab) {
if (selectedTab == 3) TunnelManager.clearUnreadErrors() if (selectedTab == 3) TunnelManager.clearUnreadErrors()
} }
@@ -297,7 +318,7 @@ fun MainScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
.pointerInput(selectedTab) { .pointerInput(selectedTab, wdttLinkMode) {
var totalDrag = 0f var totalDrag = 0f
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragStart = { onDragStart = {
@@ -310,8 +331,8 @@ fun MainScreen(
dragProgress = 0f dragProgress = 0f
}, },
onDragEnd = { onDragEnd = {
if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) { if (dragTargetIndex in activeNavItems.indices && dragProgress >= 0.5f) {
selectedTab = dragTargetIndex selectedTab = activeNavItems[dragTargetIndex].id
if (selectedTab == 3) TunnelManager.clearUnreadErrors() if (selectedTab == 3) TunnelManager.clearUnreadErrors()
} }
dragTargetIndex = -1 dragTargetIndex = -1
@@ -326,8 +347,9 @@ fun MainScreen(
return@detectHorizontalDragGestures return@detectHorizontalDragGestures
} }
val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1 val currentActiveIndex = activeNavItems.indexOfFirst { it.id == selectedTab }
if (candidate !in navItems.indices) { val candidate = if (totalDrag < 0f) currentActiveIndex + 1 else currentActiveIndex - 1
if (candidate !in activeNavItems.indices) {
dragTargetIndex = -1 dragTargetIndex = -1
dragProgress = 0f dragProgress = 0f
return@detectHorizontalDragGestures return@detectHorizontalDragGestures
@@ -350,15 +372,15 @@ fun MainScreen(
) { tab -> ) { tab ->
when (tab) { when (tab) {
0 -> SettingsTab() 0 -> SettingsTab()
1 -> DeployTab() 1 -> if (!wdttLinkMode) DeployTab() else Spacer(modifier = Modifier.fillMaxSize())
2 -> ExceptionsTab() 2 -> ExceptionsTab()
3 -> LogsTab() 3 -> LogsTab()
4 -> InfoTab() 4 -> InfoTab(actionsExpandedState = actionsExpanded, projectExpandedState = projectExpanded)
} }
} }
ProxyNavigationBar( ProxyNavigationBar(
navItems = navItems, navItems = activeNavItems,
selectedTab = selectedTab, selectedTab = selectedTab,
dragTargetIndex = dragTargetIndex, dragTargetIndex = dragTargetIndex,
dragProgress = dragProgress, dragProgress = dragProgress,
@@ -380,6 +402,10 @@ fun MainScreen(
// Floating theme toolbar overlay // Floating theme toolbar overlay
FloatingToolbar( FloatingToolbar(
activeProfile = activeProfile,
onActiveProfileChange = { profile ->
scope.launch { settingsStore.saveActiveProfile(profile) }
},
currentTheme = themeMode, currentTheme = themeMode,
onThemeChange = onThemeChange, onThemeChange = onThemeChange,
isDynamicColor = isDynamicColor, isDynamicColor = isDynamicColor,
@@ -453,13 +479,16 @@ private fun ProxyNavigationBar(
} else { } else {
lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f) 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 val dragVisualIndex = indicatorIndex.value
LaunchedEffect(selectedTab) { LaunchedEffect(selectedVisualIndex) {
if (dragTargetIndex !in navItems.indices) { if (dragTargetIndex !in navItems.indices) {
indicatorIndex.animateTo( indicatorIndex.animateTo(
targetValue = selectedTab.toFloat(), targetValue = selectedVisualIndex.toFloat(),
animationSpec = tween( animationSpec = tween(
durationMillis = 720, durationMillis = 720,
easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f) 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) { if (dragTargetIndex in navItems.indices) {
val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress val target = selectedVisualIndex.toFloat() + (dragTargetIndex - selectedVisualIndex) * dragProgress
indicatorIndex.snapTo(target) indicatorIndex.snapTo(target)
} }
} }
@@ -522,7 +551,7 @@ private fun ProxyNavigationBar(
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.clip(RoundedCornerShape(22.dp)) .clip(RoundedCornerShape(22.dp))
.clickable { onTabSelected(index) }, .clickable { onTabSelected(item.id) },
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -533,7 +562,7 @@ private fun ProxyNavigationBar(
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = iconColor tint = iconColor
) )
if (index == 3 && unreadErrors > 0) { if (item.id == 3 && unreadErrors > 0) {
Badge( Badge(
containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning, containerColor = if (tunnelRunning) colors.primary else WDTTColors.warning,
contentColor = colors.onPrimary, 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.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.os.Build
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
companion object { companion object {
private val Context.dataStore by preferencesDataStore("settings") 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 PEER = stringPreferencesKey("peer")
private val VK_HASHES = stringPreferencesKey("vk_hashes") private val VK_HASHES = stringPreferencesKey("vk_hashes")
private val SECONDARY_VK_HASH = stringPreferencesKey("secondary_vk_hash") 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_VERSION = stringPreferencesKey("update_dialog_last_action_version")
private val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action") 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 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 private val dataStore = appContext.dataStore
@@ -94,65 +114,151 @@ class SettingsStore(context: Context) {
} }
} }
val peer: Flow<String> = dataStore.data.map { it[PEER] ?: "" } val activeProfile: Flow<Int> = dataStore.data.map { it[ACTIVE_PROFILE] ?: 0 }
val vkHashes: Flow<String> = dataStore.data.map { it[VK_HASHES] ?: "" } val showSystemApps: Flow<Boolean> = dataStore.data.map { it[SHOW_SYSTEM_APPS] ?: true }
val secondaryVkHash: Flow<String> = dataStore.data.map { it[SECONDARY_VK_HASH] ?: "" } val loggingEnabled: Flow<Boolean> = dataStore.data.map { it[LOGGING_ENABLED] ?: true }
val workersPerHash: Flow<Int> = dataStore.data.map { it[WORKERS_PER_HASH] ?: 16 } val wdttLink: Flow<String> = dataStore.data.map { prefs ->
val protocol: Flow<String> = dataStore.data.map { it[PROTOCOL] ?: "udp" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val listenPort: Flow<Int> = dataStore.data.map { it[LISTEN_PORT] ?: 9000 } prefs[getProfileKey(WDTT_LINK, profile)] ?: ""
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 wdttLinkMode: Flow<Boolean> = dataStore.data.map { prefs ->
val serverWgPort: Flow<Int> = dataStore.data.map { it[SERVER_WG_PORT] ?: 56001 } val profile = prefs[ACTIVE_PROFILE] ?: 0
val sni: Flow<String> = dataStore.data.map { it[SNI] ?: "" } prefs[getProfileKey(WDTT_LINK_MODE, profile)] ?: false
val noDns: Flow<Boolean> = dataStore.data.map { it[NO_DNS] ?: false } }
val userAgent: Flow<String> = dataStore.data.map { it[USER_AGENT] ?: "" }
val peer: Flow<String> = dataStore.data.map { prefs ->
val deployIp: Flow<String> = dataStore.data.map { it[DEPLOY_IP] ?: "" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val deployLogin: Flow<String> = dataStore.data.map { it[DEPLOY_LOGIN] ?: "" } prefs[getProfileKey(PEER, profile)] ?: ""
val deployPassword: Flow<String> = dataStore.data.map { }
readSecret(it, DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) 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 detailedLogs: Flow<Boolean> = dataStore.data.map { it[DETAILED_LOGS] ?: false }
// ═══ Пароли и Управление ═══ // ═══ Пароли и Управление ═══
val connectionPassword: Flow<String> = dataStore.data.map { val connectionPassword: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD, profile)
} }
val deployMainPassword: Flow<String> = dataStore.data.map { val deployMainPassword: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, profile)
} }
val deployAdminId: Flow<String> = dataStore.data.map { val deployAdminId: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, profile)
} }
val deployBotToken: Flow<String> = dataStore.data.map { val deployBotToken: Flow<String> = dataStore.data.map { prefs ->
readSecret(it, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) val profile = prefs[ACTIVE_PROFILE] ?: 0
readSecret(prefs, DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, profile)
} }
// ═══ Proxy Mode ═══ // ═══ Proxy Mode ═══
val proxyMode: Flow<String> = dataStore.data.map { it[PROXY_MODE] ?: "tun" } val proxyMode: Flow<String> = dataStore.data.map { prefs ->
val proxyHost: Flow<String> = dataStore.data.map { it[PROXY_HOST] ?: "127.0.0.1" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val proxyPort: Flow<Int> = dataStore.data.map { it[PROXY_PORT] ?: 1080 } 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 ═══ // ═══ Captcha Solve Mode ═══
val captchaMode: Flow<String> = dataStore.data.map { it[CAPTCHA_MODE] ?: "auto" } val captchaMode: Flow<String> = dataStore.data.map { prefs ->
val captchaSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_SOLVE_METHOD] ?: "auto" } val profile = prefs[ACTIVE_PROFILE] ?: 0
val captchaWbvSolveMethod: Flow<String> = dataStore.data.map { it[CAPTCHA_WBV_SOLVE_METHOD] ?: "auto" } 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 ═══ // ═══ 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 ═══ // ═══ Theme Mode ═══
val themeMode: Flow<String> = dataStore.data.map { it[THEME_MODE] ?: "system" } 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 themePalette: Flow<String> = dataStore.data.map { it[THEME_PALETTE] ?: "indigo" }
val updateLastCheckAt: Flow<Long> = dataStore.data.map { it[UPDATE_LAST_CHECK_AT] ?: 0L } 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 updateLatestVersion: Flow<String> = dataStore.data.map { it[UPDATE_LATEST_VERSION] ?: "" }
val updateLastError: Flow<String> = dataStore.data.map { it[UPDATE_LAST_ERROR] ?: "" } 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 updatePostponeUntil: Flow<Long> = dataStore.data.map { it[UPDATE_POSTPONE_UNTIL] ?: 0L }
val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" } val updatePostponeVersion: Flow<String> = dataStore.data.map { it[UPDATE_POSTPONE_VERSION] ?: "" }
val updateDialogLastShownVersion: Flow<String> = dataStore.data.map { it[UPDATE_DIALOG_LAST_SHOWN_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( suspend fun save(
peer: String, peer: String,
vkHashes: String, vkHashes: String,
@@ -226,102 +364,115 @@ class SettingsStore(context: Context) {
noDns: Boolean = false noDns: Boolean = false
) { ) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PEER] = peer val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[VK_HASHES] = vkHashes prefs[getProfileKey(PEER, profile)] = peer
prefs[SECONDARY_VK_HASH] = secondaryVkHash prefs[getProfileKey(VK_HASHES, profile)] = vkHashes
prefs[WORKERS_PER_HASH] = workersPerHash prefs[getProfileKey(SECONDARY_VK_HASH, profile)] = secondaryVkHash
prefs[PROTOCOL] = protocol prefs[getProfileKey(WORKERS_PER_HASH, profile)] = workersPerHash
prefs[LISTEN_PORT] = listenPort prefs[getProfileKey(PROTOCOL, profile)] = protocol
prefs[SNI] = sni prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
prefs[NO_DNS] = noDns prefs[getProfileKey(SNI, profile)] = sni
prefs[getProfileKey(NO_DNS, profile)] = noDns
} }
} }
suspend fun saveManualPortsEnabled(enabled: Boolean) { suspend fun saveManualPortsEnabled(enabled: Boolean) {
dataStore.edit { prefs -> 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) { suspend fun savePorts(serverDtlsPort: Int, serverWgPort: Int, listenPort: Int) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[SERVER_DTLS_PORT] = serverDtlsPort val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[SERVER_WG_PORT] = serverWgPort prefs[getProfileKey(SERVER_DTLS_PORT, profile)] = serverDtlsPort
prefs[LISTEN_PORT] = listenPort prefs[getProfileKey(SERVER_WG_PORT, profile)] = serverWgPort
prefs[getProfileKey(LISTEN_PORT, profile)] = listenPort
} }
} }
suspend fun saveUserAgent(ua: String) { suspend fun saveUserAgent(ua: String) {
dataStore.edit { prefs -> 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) { suspend fun saveDeploy(ip: String, login: String, pass: String, sshPort: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[DEPLOY_IP] = ip val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[DEPLOY_LOGIN] = login prefs[getProfileKey(DEPLOY_IP, profile)] = ip
prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass) prefs[getProfileKey(DEPLOY_LOGIN, profile)] = login
prefs[DEPLOY_SSH_PORT] = sshPort prefs.putSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD, pass, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
} }
} }
suspend fun saveExcludedApps(packages: String) { suspend fun saveExcludedApps(packages: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
} }
} }
suspend fun saveDetailedLogs(enabled: Boolean) { suspend fun saveDetailedLogs(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[DETAILED_LOGS] = enabled val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(DETAILED_LOGS, profile)] = enabled
} }
} }
// ═══ Сохранение пароля подключения ═══ // ═══ Сохранение пароля подключения ═══
suspend fun saveConnectionPassword(password: String) { suspend fun saveConnectionPassword(password: String) {
dataStore.edit { prefs -> 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) { suspend fun saveDeploySecrets(mainPass: String, adminId: String, botToken: String, sshPort: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass) val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId) prefs.putSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD, mainPass, profile)
prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken) prefs.putSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID, adminId, profile)
prefs[DEPLOY_SSH_PORT] = sshPort prefs.putSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN, botToken, profile)
prefs[getProfileKey(DEPLOY_SSH_PORT, profile)] = sshPort
} }
} }
// ═══ Сохранение proxy mode ═══ // ═══ Сохранение proxy mode ═══
suspend fun saveProxyMode(mode: String, host: String, port: Int) { suspend fun saveProxyMode(mode: String, host: String, port: Int) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[PROXY_MODE] = mode val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[PROXY_HOST] = host prefs[getProfileKey(PROXY_MODE, profile)] = mode
prefs[PROXY_PORT] = port prefs[getProfileKey(PROXY_HOST, profile)] = host
prefs[getProfileKey(PROXY_PORT, profile)] = port
} }
} }
// ═══ Сохранение режима обхода капчи ═══ // ═══ Сохранение режима обхода капчи ═══
suspend fun saveCaptchaMode(mode: String) { suspend fun saveCaptchaMode(mode: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[CAPTCHA_MODE] = mode val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[getProfileKey(CAPTCHA_MODE, profile)] = mode
} }
} }
suspend fun saveCaptchaSolveMethod(method: String) { suspend fun saveCaptchaSolveMethod(method: String) {
dataStore.edit { prefs -> 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) { suspend fun saveWbvCaptchaSolveMethod(method: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[CAPTCHA_WBV_SOLVE_METHOD] = method val profile = prefs[ACTIVE_PROFILE] ?: 0
if (prefs[CAPTCHA_MODE] == "wv") { prefs[getProfileKey(CAPTCHA_WBV_SOLVE_METHOD, profile)] = method
prefs[CAPTCHA_SOLVE_METHOD] = 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) { suspend fun saveIsWhitelist(enabled: Boolean) {
dataStore.edit { prefs -> 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) { suspend fun saveExceptionsMode(packages: String, isWhitelist: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[EXCLUDED_APPS] = packages val profile = prefs[ACTIVE_PROFILE] ?: 0
prefs[IS_WHITELIST] = isWhitelist prefs[getProfileKey(EXCLUDED_APPS, profile)] = packages
prefs[getProfileKey(IS_WHITELIST, profile)] = isWhitelist
} }
} }
private suspend fun migrateSecretsToKeystore() { private suspend fun migrateSecretsToKeystore() {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs.migrateSecret(DEPLOY_PASSWORD_ENCRYPTED, DEPLOY_PASSWORD) for (profile in 0..2) {
prefs.migrateSecret(CONNECTION_PASSWORD_ENCRYPTED, CONNECTION_PASSWORD) prefs.migrateSecret(getProfileKey(DEPLOY_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_MAIN_PASSWORD_ENCRYPTED, DEPLOY_MAIN_PASSWORD) prefs.migrateSecret(getProfileKey(CONNECTION_PASSWORD_ENCRYPTED, profile), getProfileKey(CONNECTION_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_ADMIN_ID_ENCRYPTED, DEPLOY_ADMIN_ID) prefs.migrateSecret(getProfileKey(DEPLOY_MAIN_PASSWORD_ENCRYPTED, profile), getProfileKey(DEPLOY_MAIN_PASSWORD, profile))
prefs.migrateSecret(DEPLOY_BOT_TOKEN_ENCRYPTED, DEPLOY_BOT_TOKEN) 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( private fun readSecret(
prefs: Preferences, prefs: Preferences,
encryptedKey: Preferences.Key<String>, encryptedKey: Preferences.Key<String>,
legacyKey: Preferences.Key<String> legacyKey: Preferences.Key<String>,
profile: Int
): String { ): 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( private fun MutablePreferences.putSecret(
encryptedKey: Preferences.Key<String>, encryptedKey: Preferences.Key<String>,
legacyKey: 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()) { if (value.isBlank()) {
remove(encryptedKey) remove(profEncryptedKey)
remove(legacyKey) remove(profLegacyKey)
} else { } else {
this[encryptedKey] = secureStore.encrypt(value) this[profEncryptedKey] = secureStore.encrypt(value)
remove(legacyKey) remove(profLegacyKey)
} }
} }
@@ -40,7 +40,7 @@ object TunnelManager {
private var refusedCount = 0 private var refusedCount = 0
private var currentHashErrorCount = 0 private var currentHashErrorCount = 0
private var wrapAuthTimeoutCount = 0 private var wrapAuthTimeoutCount = 0
private var processStartedAtMs = 0L var processStartedAtMs = 0L
private var lastActiveAtMs = 0L private var lastActiveAtMs = 0L
private var activeHashIndex = 0 // 0: primary, 1: secondary private var activeHashIndex = 0 // 0: primary, 1: secondary
private var currentParams: TunnelParams? = null private var currentParams: TunnelParams? = null
@@ -49,6 +49,9 @@ object TunnelManager {
private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs" private var currentCaptchaMode = "wv" // режим обхода капчи: "wv" или "rjs"
private var currentCaptchaSolveMethod = "auto" // "manual" или "auto" private var currentCaptchaSolveMethod = "auto" // "manual" или "auto"
@Volatile
var isLoggingEnabled = true
val running = MutableStateFlow(false) val running = MutableStateFlow(false)
val logs = MutableStateFlow<List<LogEntry>>(emptyList()) val logs = MutableStateFlow<List<LogEntry>>(emptyList())
val unreadErrorCount = MutableStateFlow(0) val unreadErrorCount = MutableStateFlow(0)
@@ -56,7 +59,7 @@ object TunnelManager {
val stats = MutableStateFlow("Ожидание данных...") val stats = MutableStateFlow("Ожидание данных...")
val activeWorkers = MutableStateFlow(0) val activeWorkers = MutableStateFlow(0)
val cooldownSeconds = MutableStateFlow(0) val cooldownActive = MutableStateFlow(false)
private var cooldownJob: Job? = null private var cooldownJob: Job? = null
fun clearUnreadErrors() { fun clearUnreadErrors() {
@@ -75,6 +78,7 @@ object TunnelManager {
} }
private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) { private fun updateLog(key: String, message: String, priority: Int, isError: Boolean = false) {
if (!isLoggingEnabled) return
if (isError) { if (isError) {
val list = logs.value val list = logs.value
if (list.none { it.key == key }) { if (list.none { it.key == key }) {
@@ -152,13 +156,11 @@ object TunnelManager {
} }
val hashCount = hashList.size.coerceIn(1, 3) 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 "Запасной" val hashMode = if (activeHashIndex == 0) "Основной" else "Запасной"
updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1) updateLog("config_info", "[$hashMode] Хешей=$hashCount, Потоков=$totalWorkers", 1)
// CRITICAL FIX: Use nativeLibraryDir with extractNativeLibs="true"
val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so" val binaryPath = context.applicationInfo.nativeLibraryDir + "/libclient.so"
val binaryFile = File(binaryPath) val binaryFile = File(binaryPath)
@@ -182,15 +184,13 @@ object TunnelManager {
cmd.add("-password") cmd.add("-password")
cmd.add(params.connectionPassword) cmd.add(params.connectionPassword)
// Captcha mode: wv или rjs
cmd.add("-captcha-mode") cmd.add("-captcha-mode")
cmd.add(params.captchaMode) cmd.add(params.captchaMode)
val pb = ProcessBuilder(cmd) val pb = ProcessBuilder(cmd)
pb.directory(context.filesDir) // Устанавливаем рабочую директорию pb.directory(context.filesDir)
pb.redirectErrorStream(true) pb.redirectErrorStream(true)
// Set LD_LIBRARY_PATH
val env = pb.environment() val env = pb.environment()
env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir env["LD_LIBRARY_PATH"] = context.applicationInfo.nativeLibraryDir
@@ -220,7 +220,6 @@ object TunnelManager {
var lastResetTime = System.currentTimeMillis() var lastResetTime = System.currentTimeMillis()
reader.forEachLine { line -> reader.forEachLine { line ->
// Периодический сброс счетчиков ошибок (раз в 60 сек)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastResetTime > 60000) { if (now - lastResetTime > 60000) {
refusedCount = 0 refusedCount = 0
@@ -230,13 +229,11 @@ object TunnelManager {
lastResetTime = now 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 msgPrefixReplaced = line.replace(Regex("^\\d{4}/\\d{2}/\\d{2}\\s\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\s"), "")
val lineTrim = msgPrefixReplaced.trim() 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) 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")) { if (lineTrim.contains("FATAL_AUTH")) {
val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) || val isWrapHandshakeTimeout = lineTrim.contains("DTLS timeout", true) ||
lineTrim.contains("WRAP_AUTH_TIMEOUT", true) lineTrim.contains("WRAP_AUTH_TIMEOUT", true)
@@ -271,30 +268,27 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 0a. WRAP auth timeout — не фатально для отдельного воркера.
// Критичным считаем только ситуацию, когда за стартовое окно не поднялся ни один поток.
if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) { if (lineTrim.contains("WRAP_AUTH_TIMEOUT", true)) {
if (activeWorkers.value > 0) { if (activeWorkers.value > 0) {
wrapAuthTimeoutCount = 0 wrapAuthTimeoutCount = 0
updateLog( updateLog(
"wrap_timeout_recovered", "wrap_timeout_recovered",
"[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем", "[WRAP] Один поток не прошёл handshake, активных=${activeWorkers.value}; повторяем",
50, 50,
true true
) )
} else { } else {
wrapAuthTimeoutCount++ wrapAuthTimeoutCount++
updateLog( updateLog(
"wrap_timeout_wait", "wrap_timeout_wait",
"[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)", "[WRAP] Handshake не подтвердился, проверяем пароль/сеть ($wrapAuthTimeoutCount)",
50, 50,
true true
) )
} }
return@forEachLine return@forEachLine
} }
// 0b. CAPTCHA_SOLVE — запрос от Go для WBV-режима.
if (lineTrim.startsWith("CAPTCHA_SOLVE|")) { if (lineTrim.startsWith("CAPTCHA_SOLVE|")) {
val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|") val payload = lineTrim.substringAfter("CAPTCHA_SOLVE|")
val parts = payload.split("|", limit = 3) val parts = payload.split("|", limit = 3)
@@ -321,7 +315,6 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 1. ПРЕДОХРАНИТЕЛЬ (Circuit Breaker)
if (isError) { if (isError) {
when { when {
lineTrim.contains("Flood control", true) -> { lineTrim.contains("Flood control", true) -> {
@@ -339,7 +332,6 @@ object TunnelManager {
} }
} }
lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> { lineTrim.contains("connection refused", true) || lineTrim.contains("timeout", true) -> {
// Огромный лимит, потому что каждый воркер кидает эту ошибку при смене сети
refusedCount++ refusedCount++
if (refusedCount >= 400) { if (refusedCount >= 400) {
handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.") handleCriticalError("Критическое отсутствие сети (400+ таймаутов). Отключение.")
@@ -348,7 +340,6 @@ object TunnelManager {
} }
lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> { lineTrim.contains("9000") || lineTrim.contains("Call not found", true) -> {
currentHashErrorCount++ currentHashErrorCount++
// Нужно больше попыток, так как 1 воркер может спамить
if (currentHashErrorCount >= 10) { if (currentHashErrorCount >= 10) {
handleHashError() handleHashError()
return@forEachLine return@forEachLine
@@ -357,7 +348,6 @@ object TunnelManager {
} }
} }
// 1. Статистика (Обновляемая строка)
if (lineTrim.contains("[СТАТИСТИКА]")) { if (lineTrim.contains("[СТАТИСТИКА]")) {
val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim() val msg = lineTrim.substringAfter("[СТАТИСТИКА]").trim()
stats.value = msg stats.value = msg
@@ -376,10 +366,7 @@ object TunnelManager {
return@forEachLine return@forEachLine
} }
// 2. Этапы подключения и Ошибки
when { when {
// ═══ Авто-оркестратор капчи ═══
lineTrim.contains("[КАПЧА] AUTO:") -> { lineTrim.contains("[КАПЧА] AUTO:") -> {
var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim() var text = lineTrim.substringAfter("[КАПЧА] AUTO:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -399,9 +386,7 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr) updateLog(stableKey, "[КАПЧА AUTO] $text", 5, isErr)
} }
// ═══ RJS капча логи: [КАПЧА RJS] со стабильными ключами-шагами ═══
lineTrim.contains("[КАПЧА] RJS:") -> { lineTrim.contains("[КАПЧА] RJS:") -> {
// Удаляем тайминги и лишние скобки: (123мс), (diff=2), (общее время...)
var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim() var text = lineTrim.substringAfter("[КАПЧА] RJS:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
@@ -417,15 +402,14 @@ object TunnelManager {
updateLog(stableKey, "[КАПЧА RJS] $text", 5, false) updateLog(stableKey, "[КАПЧА RJS] $text", 5, false)
} }
// ═══ WV капча логи от Go: [КАПЧА WBV] со стабильными ключами ═══
lineTrim.contains("[КАПЧА] WBV:") -> { lineTrim.contains("[КАПЧА] WBV:") -> {
var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim() var text = lineTrim.substringAfter("[КАПЧА] WBV:").trim()
text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim() text = text.replace(Regex("\\s*\\([^)]+\\)\\s*"), " ").trim()
val isErr = text.contains("Ошибка") val isErr = text.contains("Ошибка")
val stableKey = when { val stableKey = when {
text.contains("Запрос") -> "captcha_wv_step_2" // Step 2 (после создания WV) text.contains("Запрос") -> "captcha_wv_step_2"
text.contains("Токен") -> "captcha_wv_step_5" // Step 5 (перед уничтожением) text.contains("Токен") -> "captcha_wv_step_5"
isErr -> "captcha_wv_err" isErr -> "captcha_wv_err"
else -> "captcha_wv_go_other" else -> "captcha_wv_go_other"
} }
@@ -462,9 +446,7 @@ object TunnelManager {
lineTrim.contains("Активна ✓") -> lineTrim.contains("Активна ✓") ->
updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false) updateLog("ready", "[READY] Туннель готов к работе ✓", 2, false)
// Ошибки (в конец)
isError -> { isError -> {
// Формируем уникальный ключ ошибки на основе её типа (группируем по типу ошибки)
val errorKey = when { val errorKey = when {
lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns" lineTrim.contains("lookup login.vk.ru", true) -> "err_vk_dns"
lineTrim.contains("connection refused") -> "err_conn_refused" lineTrim.contains("connection refused") -> "err_conn_refused"
@@ -482,7 +464,6 @@ object TunnelManager {
} }
} }
// 3. Обработка конфига (Скрываем от пользователя)
if (line.contains("") && line.contains("WireGuard")) { if (line.contains("") && line.contains("WireGuard")) {
collectingConfig = true collectingConfig = true
configBuilder.clear() configBuilder.clear()
@@ -528,7 +509,7 @@ object TunnelManager {
val context = lastContext ?: return val context = lastContext ?: return
currentHashErrorCount = 0 currentHashErrorCount = 0
forceRegenerateUA = true // Перегенерируем UA при следующих ошибках forceRegenerateUA = true
if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) { if (params.secondaryVkHash.isNotEmpty() && activeHashIndex == 0) {
updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true) updateLog("hash_switch", "Основной хеш мертв. Переключение на запасной...", 50, true)
@@ -541,18 +522,14 @@ object TunnelManager {
} }
} }
// ==================== WATCHDOG ====================
// Проверяет, жив ли Go-процесс. Если умер — перезапускает.
// Если процесс жив, но 0 воркеров уже 30 сек — тоже перезапуск (зомби).
private fun startWatchdog(context: Context, params: TunnelParams) { private fun startWatchdog(context: Context, params: TunnelParams) {
watchdogJob?.cancel() watchdogJob?.cancel()
watchdogJob = scope.launch { watchdogJob = scope.launch {
var zeroWorkersSince = 0L var zeroWorkersSince = 0L
delay(10_000) // Даём 10 сек на старт delay(10_000)
while (isActive && running.value) { while (isActive && running.value) {
val proc = process val proc = process
if (proc == null || !proc.isAlive) { if (proc == null || !proc.isAlive) {
// Go-процесс мёртв!
updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true) updateLog("watchdog", "⚠ Процесс упал. Перезапуск...", 50, true)
activeWorkers.value = 0 activeWorkers.value = 0
forceRegenerateUA = true forceRegenerateUA = true
@@ -561,10 +538,9 @@ object TunnelManager {
if (running.value) { if (running.value) {
start(context, params, isSwitching = true) start(context, params, isSwitching = true)
} }
return@launch // startWatchdog будет перезапущен из start() return@launch
} }
// Детекция зомби: процесс жив, но 0 воркеров
val workers = activeWorkers.value val workers = activeWorkers.value
if (workers <= 0) { if (workers <= 0) {
if (zeroWorkersSince == 0L) { if (zeroWorkersSince == 0L) {
@@ -601,7 +577,7 @@ object TunnelManager {
val params = currentParams ?: return val params = currentParams ?: return
val context = lastContext ?: return val context = lastContext ?: return
updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false) updateLog("network_restart", "[СЕТЬ] Перезапуск транспорта из-за смены сети...", 50, false)
killProcess() // Только убиваем процесс, running не трогаем! killProcess()
scope.launch { scope.launch {
delay(1500) delay(1500)
start(context, params, isSwitching = true) start(context, params, isSwitching = true)
@@ -610,7 +586,7 @@ object TunnelManager {
fun pause() { fun pause() {
if (!running.value) return if (!running.value) return
killProcess() // Не ставим running=false, чтоб сервис не умер killProcess()
activeWorkers.value = 0 activeWorkers.value = 0
} }
@@ -622,7 +598,6 @@ object TunnelManager {
} }
} }
// Убивает процесс без изменения running
private fun killProcess() { private fun killProcess() {
watchdogJob?.cancel() watchdogJob?.cancel()
readerJob?.cancel() readerJob?.cancel()
@@ -630,7 +605,6 @@ object TunnelManager {
process = null process = null
if (proc != null) { if (proc != null) {
try { proc.destroy() } catch (_: Exception) {} try { proc.destroy() } catch (_: Exception) {}
// Даём 500мс на graceful shutdown
try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {} try { proc.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS) } catch (_: Exception) {}
if (proc.isAlive) { if (proc.isAlive) {
try { proc.destroyForcibly() } catch (_: Exception) {} try { proc.destroyForcibly() } catch (_: Exception) {}
@@ -644,10 +618,6 @@ object TunnelManager {
running.value = false running.value = false
} }
private fun log(message: String) {
updateLog("internal_${message.hashCode()}", message, 50, false)
}
fun stop() { fun stop() {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
wgHelper?.stopTunnel() wgHelper?.stopTunnel()
@@ -659,9 +629,7 @@ object TunnelManager {
ManlCaptchaWebViewManager.cancelCaptcha() ManlCaptchaWebViewManager.cancelCaptcha()
} }
// Suspend-версия: гарантирует что процесс мёртв и порт свободен
suspend fun stopAndWait() { suspend fun stopAndWait() {
// Сначала останавливаем WireGuard и ждём завершения
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
wgHelper?.stopTunnel() wgHelper?.stopTunnel()
} }
@@ -671,11 +639,10 @@ object TunnelManager {
activeWorkers.value = 0 activeWorkers.value = 0
currentParams = null currentParams = null
ManlCaptchaWebViewManager.cancelCaptcha() ManlCaptchaWebViewManager.cancelCaptcha()
// Ждём освобождения порта 9000 (до 3 секунд)
repeat(30) { repeat(30) {
try { try {
java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() } java.net.ServerSocket(9000, 1, java.net.InetAddress.getByName("127.0.0.1")).use { it.close() }
return@withContext // Порт свободен! return@withContext
} catch (_: Exception) { } catch (_: Exception) {
delay(100) 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) { private suspend fun handleCaptchaSolve(requestMode: String, redirectUri: String, sessionToken: String) {
val ctx = lastContext ?: run { val ctx = lastContext ?: run {
writeCaptchaResult("error:context is null") writeCaptchaResult("error:context is null")
@@ -743,7 +701,6 @@ object TunnelManager {
writeCaptchaResult("error:$errorMsg") writeCaptchaResult("error:$errorMsg")
} }
// WebView уничтожен в finally блоке соответствующего менеджера.
updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false) updateLog("captcha_wv_step_6", "[КАПЧА WBV] WebView уничтожен", 5, false)
} }
@@ -785,9 +742,6 @@ object TunnelManager {
return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken) return ManlCaptchaWebViewManager.solveCaptchaAsync(ctx, redirectUri, sessionToken)
} }
/**
* Записывает результат решения капчи в stdin Go-процесса.
*/
private fun writeCaptchaResult(result: String) { private fun writeCaptchaResult(result: String) {
val proc = process val proc = process
if (proc == null || !proc.isAlive) return if (proc == null || !proc.isAlive) return
@@ -802,17 +756,17 @@ object TunnelManager {
fun clearLogs() { fun clearLogs() {
logs.value = emptyList() logs.value = emptyList()
activeWorkers.value = 0 if (!running.value) {
activeWorkers.value = 0
}
} }
fun startCooldown(seconds: Int) { fun startCooldown(millis: Long) {
cooldownJob?.cancel() cooldownJob?.cancel()
cooldownSeconds.value = seconds cooldownActive.value = true
cooldownJob = scope.launch(Dispatchers.Main) { cooldownJob = scope.launch(Dispatchers.Main) {
while (cooldownSeconds.value > 0) { delay(millis)
delay(1000) cooldownActive.value = false
cooldownSeconds.update { it - 1 }
}
} }
} }
@@ -831,6 +785,6 @@ data class TunnelParams(
val sni: String = "", val sni: String = "",
val connectionPassword: String = "", val connectionPassword: String = "",
val protocol: String = "udp", val protocol: String = "udp",
val captchaMode: String = "auto", // "auto", "wv" или "rjs" val captchaMode: String = "auto",
val captchaSolveMethod: String = "auto" // "manual" или "auto" val captchaSolveMethod: String = "auto"
) )
@@ -283,6 +283,15 @@ class TunnelService : Service() {
stopSelf() stopSelf()
break 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) { if (!isTunnelPaused) {
updateNotification(buildTunnelNotificationText()) 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.app.Application
import android.content.Context import android.content.Context
import android.util.Log
import com.wireguard.android.backend.GoBackend 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() { class WdttApplication : Application() {
@Volatile @Volatile
@@ -14,6 +20,43 @@ class WdttApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DeployManager.init(this) 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 { 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 { suspend fun stopTunnel() = wgMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -51,8 +51,8 @@ fun AppSectionCard(
color = appSectionCardColor(), color = appSectionCardColor(),
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, appSectionCardBorderColor()), border = BorderStroke(1.dp, appSectionCardBorderColor()),
shadowElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 2.dp else 10.dp, shadowElevation = 0.dp,
tonalElevation = if (MaterialTheme.colorScheme.background.luminance() < 0.22f) 0.dp else 2.dp, tonalElevation = 0.dp,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
Column( Column(
@@ -91,9 +91,9 @@ fun DeployTab() {
val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle() val deployProgress by DeployManager.deployProgress.collectAsStateWithLifecycle()
val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle() val currentStep by DeployManager.currentStep.collectAsStateWithLifecycle()
LaunchedEffect(savedIp) { if (savedIp.isNotEmpty()) ip = savedIp } LaunchedEffect(savedIp) { ip = savedIp }
LaunchedEffect(savedLogin) { if (savedLogin.isNotEmpty()) login = savedLogin } LaunchedEffect(savedLogin) { login = savedLogin }
LaunchedEffect(savedPassword) { if (savedPassword.isNotEmpty()) password = savedPassword } LaunchedEffect(savedPassword) { password = savedPassword }
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = deployProgress, targetValue = deployProgress,
animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing), animationSpec = tween(durationMillis = 1200, easing = androidx.compose.animation.core.FastOutSlowInEasing),
@@ -38,7 +38,8 @@ import androidx.compose.runtime.Stable
data class AppItem( data class AppItem(
val name: String, val name: String,
val packageName: String, val packageName: String,
val icon: ImageBitmap? val icon: ImageBitmap?,
val isSystem: Boolean
) )
object AppCache { object AppCache {
@@ -61,6 +62,8 @@ fun ExceptionsTab() {
var isLoading by remember { mutableStateOf(AppCache.cachedList == null) } var isLoading by remember { mutableStateOf(AppCache.cachedList == null) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
val showSystemAppsOpt by settingsStore.showSystemApps.collectAsStateWithLifecycle(initialValue = null)
val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false) val isWhitelist by settingsStore.isWhitelist.collectAsStateWithLifecycle(initialValue = false)
// Load Apps // Load Apps
@@ -76,10 +79,12 @@ fun ExceptionsTab() {
if (app.packageName != context.packageName && if (app.packageName != context.packageName &&
!app.packageName.contains("vkontakte") && !app.packageName.contains("vkontakte") &&
!app.packageName.contains("vk.calls")) { !app.packageName.contains("vk.calls")) {
val isSys = (app.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
list.add(AppItem( list.add(AppItem(
name = app.loadLabel(pm).toString(), name = app.loadLabel(pm).toString(),
packageName = app.packageName, 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 { val filteredApps by remember {
derivedStateOf { derivedStateOf {
if (searchQuery.isBlank()) appsList val showSystemApps = showSystemAppsOpt ?: true
else appsList.filter { 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.name.contains(searchQuery, ignoreCase = true) ||
it.packageName.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 // List
if (isLoading) { if (isLoading || showSystemAppsOpt == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -36,8 +36,13 @@ import android.os.Build
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
@Composable @Composable
fun FloatingToolbar( fun FloatingToolbar(
activeProfile: Int,
onActiveProfileChange: (Int) -> Unit,
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit, onThemeChange: (String) -> Unit,
isDynamicColor: Boolean, isDynamicColor: Boolean,
@@ -55,6 +60,9 @@ fun FloatingToolbar(
with(density) { configuration.screenWidthDp.dp.toPx() } 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 offsetY by rememberSaveable { mutableFloatStateOf(-1f) }
var isRightSide by rememberSaveable { mutableStateOf(true) } var isRightSide by rememberSaveable { mutableStateOf(true) }
var isExpanded by rememberSaveable { mutableStateOf(false) } var isExpanded by rememberSaveable { mutableStateOf(false) }
@@ -76,12 +84,16 @@ fun FloatingToolbar(
} else { } else {
effectiveTabHeightPx effectiveTabHeightPx
} }
val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx) val currentParentHeight = if (parentHeightPx > 0f) parentHeightPx else screenHeightPx
.coerceAtLeast(minOffsetY) val currentParentWidth = if (parentWidthPx > 0f) parentWidthPx else screenWidthPx
val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f val minOffsetY = safeTopPx + edgePaddingPx
val maxOffsetY = (currentParentHeight - safeBottomPx - floatingHeightPx - edgePaddingPx)
.coerceAtLeast(minOffsetY)
val defaultOffsetY = (currentParentHeight * 0.24f).coerceIn(minOffsetY, maxOffsetY)
val targetXPx = if (isRightSide) currentParentWidth - tabWidthPx else 0f
val animatedTabXPx by animateFloatAsState( val animatedTabXPx by animateFloatAsState(
targetValue = targetXPx, targetValue = targetXPx,
@@ -93,7 +105,14 @@ fun FloatingToolbar(
offsetY = if (offsetY < 0f) defaultOffsetY else offsetY.coerceIn(minOffsetY, maxOffsetY) 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( Surface(
onClick = { isExpanded = !isExpanded }, onClick = { isExpanded = !isExpanded },
modifier = Modifier modifier = Modifier
@@ -114,16 +133,16 @@ fun FloatingToolbar(
else else
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shadowElevation = 6.dp, shadowElevation = 0.dp,
tonalElevation = 4.dp, tonalElevation = 0.dp,
) { ) {
Box( Box(
modifier = Modifier.size(tabWidthDp, tabHeightDp), modifier = Modifier.size(tabWidthDp, tabHeightDp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_palette), imageVector = Icons.Filled.Settings,
contentDescription = "Тема", contentDescription = "Настройки",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = MaterialTheme.colorScheme.onPrimaryContainer
) )
@@ -151,13 +170,54 @@ fun FloatingToolbar(
}, },
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp, shadowElevation = 0.dp,
tonalElevation = 4.dp, tonalElevation = 0.dp,
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp), modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
verticalArrangement = Arrangement.spacedBy(4.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( Text(
"Тема", "Тема",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -1,5 +1,7 @@
package com.wdtt.client.ui package com.wdtt.client.ui
import androidx.compose.runtime.MutableState
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -156,7 +158,10 @@ private fun openUrlInBrowser(context: Context, url: String) {
} }
@Composable @Composable
fun InfoTab() { fun InfoTab(
actionsExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) },
projectExpandedState: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val settingsStore = remember { SettingsStore(context) } val settingsStore = remember { SettingsStore(context) }
@@ -165,8 +170,8 @@ fun InfoTab() {
var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) } var pendingManualRelease by remember { mutableStateOf<com.wdtt.client.AppReleaseInfo?>(null) }
var showHelpDialog by remember { mutableStateOf(false) } var showHelpDialog by remember { mutableStateOf(false) }
var showDonateDialog by remember { mutableStateOf(false) } var showDonateDialog by remember { mutableStateOf(false) }
var actionsExpanded by rememberSaveable { mutableStateOf(true) } var actionsExpanded by actionsExpandedState
var projectExpanded by rememberSaveable { mutableStateOf(true) } var projectExpanded by projectExpandedState
val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "") val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "")
val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "") val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "")
val updateStatus = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) { 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.LogEntry
import com.wdtt.client.TunnelManager import com.wdtt.client.TunnelManager
import com.wdtt.client.WDTTColors import com.wdtt.client.WDTTColors
import com.wdtt.client.SettingsStore
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogsTab() { fun LogsTab() {
val context = LocalContext.current 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 currentLogs by TunnelManager.logs.collectAsStateWithLifecycle()
val listState = rememberLazyListState() 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 — адаптивный к теме // Logs container — адаптивный к теме
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg val terminalBg = if (isDark) WDTTColors.terminalBgDark else WDTTColors.terminalBg
@@ -1,6 +1,9 @@
package com.wdtt.client.ui package com.wdtt.client.ui
import androidx.compose.animation.AnimatedVisibility 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.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween 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 savedServerWgPort by settingsStore.serverWgPort.collectAsStateWithLifecycle(initialValue = 56001)
val savedListenPort by settingsStore.listenPort.collectAsStateWithLifecycle(initialValue = 9000) 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 tunnelRunning by TunnelManager.running.collectAsStateWithLifecycle()
val cooldownSeconds by TunnelManager.cooldownSeconds.collectAsStateWithLifecycle() val cooldownActive by TunnelManager.cooldownActive.collectAsStateWithLifecycle()
var wasRunning by remember { mutableStateOf(false) } var wasRunning by remember { mutableStateOf(false) }
LaunchedEffect(tunnelRunning) { LaunchedEffect(tunnelRunning) {
if (wasRunning && !tunnelRunning) { if (wasRunning && !tunnelRunning) {
TunnelManager.startCooldown(5) TunnelManager.startCooldown(1500L)
} }
wasRunning = tunnelRunning 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 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 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 combinedHashes = remember(vkHash1, vkHash2, vkHash3, vkHash4) { uniqueHashes.joinToString(",") }
val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() } val dynamicMaxWorkers = remember(filledHashCount) { (filledHashCount.coerceAtLeast(1) * 27).toFloat() }
var portInput by rememberSaveable { mutableStateOf("9000") } var portInput by rememberSaveable { mutableStateOf("9000") }
@@ -156,7 +174,7 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.joinToString(",") .joinToString(",")
} }
LaunchedEffect(Unit) { LaunchedEffect(activeProfile) {
val peer = settingsStore.peer.first() val peer = settingsStore.peer.first()
val hashes = settingsStore.vkHashes.first() val hashes = settingsStore.vkHashes.first()
val workers = settingsStore.workersPerHash.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 isPeerValid = peerInput.isNotBlank() && !peerInput.contains(":")
val isHashesValid = combinedHashes.isNotBlank() 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 effectiveServerDtlsPort = if (manualPortsEnabled) serverDtlsPortInput.toIntOrNull()?.coerceIn(1, 65535) ?: 56000 else 56000
val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000 val effectiveLocalPort = if (manualPortsEnabled) portInput.toIntOrNull()?.coerceIn(1, 65535) ?: 9000 else 9000
var pendingStartAfterVpnPermission by remember { mutableStateOf(false) } var pendingStartAfterVpnPermission by remember { mutableStateOf(false) }
@@ -255,15 +275,37 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
settingsStore.saveCaptchaMode(effectiveCaptchaMode) settingsStore.saveCaptchaMode(effectiveCaptchaMode)
settingsStore.saveCaptchaSolveMethod(effectiveCaptchaSolveMethod) 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 { val intent = Intent(context, TunnelService::class.java).apply {
action = "START" action = "START"
putExtra("peer", "$peerInput:$effectiveServerDtlsPort") putExtra("peer", finalPeer)
putExtra("vk_hashes", combinedHashes) putExtra("vk_hashes", finalHashes)
putExtra("secondary_vk_hash", "") putExtra("secondary_vk_hash", "")
putExtra("workers_per_hash", workersInput.toInt()) putExtra("workers_per_hash", workersInput.toInt())
putExtra("port", effectiveLocalPort) putExtra("port", finalLocalPort)
putExtra("sni", sniInput) putExtra("sni", sniInput)
putExtra("connection_password", savedConnectionPassword) putExtra("connection_password", finalPassword)
putExtra("captcha_mode", effectiveCaptchaMode) putExtra("captcha_mode", effectiveCaptchaMode)
putExtra("captcha_solve_method", effectiveCaptchaSolveMethod) putExtra("captcha_solve_method", effectiveCaptchaSolveMethod)
} }
@@ -342,263 +384,322 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ═══ Заголовок раздела ═══ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( if (!wdttLinkMode) {
"Настройки туннеля", Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), // ═══ Заголовок раздела ═══
color = MaterialTheme.colorScheme.onSurface
)
// ═══ Настройки туннеля ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = peerInput,
onValueChange = {
peerInput = it.filter { c -> c != ' ' }
scheduleSave()
},
label = { Text("IP сервера или домен (без порта)") },
placeholder = { Text("1.2.3.4 (или test.com)") },
singleLine = true,
isError = !isPeerValid && peerInput.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
)
OutlinedButton(
onClick = { showHashesDialog = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onSurface
),
border = BorderStroke(
1.dp,
if (hasInputHashErrors) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
) {
Icon(Icons.Default.Tag, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold)
}
val errorTexts = hashErrors.filter { !it.contains("короткий") }
if (errorTexts.isNotEmpty()) {
Text(
text = errorTexts.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
// ═══ Мощность + Капча ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// — Мощность —
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
"Мощность", "Настройки туннеля",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${currentWorkers.toInt()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.onSurface
) )
}
Spacer(Modifier.height(4.dp)) // ═══ Настройки туннеля ═══
AppSectionCard(
val maxWorkers = dynamicMaxWorkers contentPadding = PaddingValues(16.dp),
val minWorkers = WORKERS_PER_GROUP.toFloat() verticalArrangement = Arrangement.spacedBy(12.dp)
val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers) ) {
OutlinedTextField(
CompactSteppedSlider( value = peerInput,
value = currentWorkersVal, onValueChange = {
onValueChange = { raw -> peerInput = it.filter { c -> c != ' ' }
workersInput = roundToGroup(raw, maxWorkers) scheduleSave()
scheduleSave() },
}, label = { Text("IP сервера или домен (без порта)") },
valueRange = minWorkers..maxWorkers, placeholder = { Text("1.2.3.4 (или test.com)") },
stepSize = WORKERS_PER_GROUP.toFloat(), singleLine = true,
enabled = !tunnelRunning, isError = !isPeerValid && peerInput.isNotEmpty(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
// — Разделитель — focusedBorderColor = MaterialTheme.colorScheme.primary,
HorizontalDivider( unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(vertical = 4.dp), )
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Авто капча —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
if (autoCaptchaEnabled) "Авто капча" else "Ручная капча",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = autoCaptchaEnabled,
onCheckedChange = { enabled ->
autoCaptchaEnabled = enabled
scope.launch {
if (enabled) {
settingsStore.saveCaptchaMode("auto")
settingsStore.saveCaptchaSolveMethod("auto")
} else {
val mode = if (useWVCaptcha) "wv" else "rjs"
settingsStore.saveCaptchaMode(mode)
settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto")
}
}
}
)
}
AnimatedVisibility(
visible = !autoCaptchaEnabled,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
) )
// — Метод обхода капчи — OutlinedButton(
Row( onClick = { showHashesDialog = true },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
verticalAlignment = Alignment.CenterVertically, shape = RoundedCornerShape(16.dp),
horizontalArrangement = Arrangement.SpaceBetween colors = ButtonDefaults.outlinedButtonColors(
) { containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
Text( contentColor = MaterialTheme.colorScheme.onSurface
"Метод обхода капчи", ),
style = MaterialTheme.typography.bodyMedium, border = BorderStroke(
fontWeight = FontWeight.Medium, 1.dp,
modifier = Modifier.weight(1f) if (hasInputHashErrors) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ) {
ProtocolChip("WBV", useWVCaptcha, enabled = true) { Icon(Icons.Default.Tag, null, Modifier.size(18.dp))
useWVCaptcha = true Spacer(Modifier.width(8.dp))
isManualMode = wbvManualMode Text("Настройка VK Хешей ($filledHashCount/4)", fontWeight = FontWeight.SemiBold)
scope.launch { }
settingsStore.saveCaptchaMode("wv")
settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto") val errorTexts = hashErrors.filter { !it.contains("короткий") }
} if (errorTexts.isNotEmpty()) {
} Text(
ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) { text = errorTexts.joinToString(", "),
useWVCaptcha = false style = MaterialTheme.typography.bodySmall,
isManualMode = false color = MaterialTheme.colorScheme.error
scope.launch { )
settingsStore.saveCaptchaMode("rjs") }
}
}
}
// ═══ Мощность + Капча ═══
AppSectionCard(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// — Мощность —
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Мощность",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Text(
text = "${currentWorkers.toInt()}",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(4.dp))
val maxWorkers = dynamicMaxWorkers
val minWorkers = WORKERS_PER_GROUP.toFloat()
val currentWorkersVal = roundToGroup(currentWorkers.coerceIn(minWorkers, maxWorkers), maxWorkers)
CompactSteppedSlider(
value = currentWorkersVal,
onValueChange = { raw ->
workersInput = roundToGroup(raw, maxWorkers)
scheduleSave()
},
valueRange = minWorkers..maxWorkers,
stepSize = WORKERS_PER_GROUP.toFloat(),
enabled = !tunnelRunning,
modifier = Modifier.fillMaxWidth()
)
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Авто капча —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
if (autoCaptchaEnabled) "Авто капча" else "Ручная капча",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = autoCaptchaEnabled,
onCheckedChange = { enabled ->
autoCaptchaEnabled = enabled
scope.launch {
if (enabled) {
settingsStore.saveCaptchaMode("auto")
settingsStore.saveCaptchaSolveMethod("auto") settingsStore.saveCaptchaSolveMethod("auto")
} else {
val mode = if (useWVCaptcha) "wv" else "rjs"
settingsStore.saveCaptchaMode(mode)
settingsStore.saveCaptchaSolveMethod(if (mode == "wv" && isManualMode) "manual" else "auto")
} }
} }
} }
}
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
) )
}
// — Режим обхода — AnimatedVisibility(
Row( visible = !autoCaptchaEnabled,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), enter = fadeIn() + expandVertically(),
verticalAlignment = Alignment.CenterVertically, exit = fadeOut() + shrinkVertically()
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
Text( // — Разделитель —
"Режим обхода", HorizontalDivider(
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 4.dp),
fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
modifier = Modifier.weight(1f)
) )
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (useWVCaptcha) { // — Метод обхода капчи —
ProtocolChip( Row(
"РУЧ", modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
isManualMode, verticalAlignment = Alignment.CenterVertically,
enabled = true, horizontalArrangement = Arrangement.SpaceBetween
isError = false ) {
) { Text(
isManualMode = true "Метод обхода капчи",
wbvManualMode = true style = MaterialTheme.typography.bodyMedium,
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") } fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ProtocolChip("WBV", useWVCaptcha, enabled = true) {
useWVCaptcha = true
isManualMode = wbvManualMode
scope.launch {
settingsStore.saveCaptchaMode("wv")
settingsStore.saveCaptchaSolveMethod(if (wbvManualMode) "manual" else "auto")
}
} }
ProtocolChip( ProtocolChip("RJS", !useWVCaptcha, enabled = true, isError = false) {
"АВТ", useWVCaptcha = false
!isManualMode,
enabled = true,
isError = false
) {
isManualMode = false isManualMode = false
wbvManualMode = false scope.launch {
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") } settingsStore.saveCaptchaMode("rjs")
settingsStore.saveCaptchaSolveMethod("auto")
}
}
}
}
// — Разделитель —
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Режим обхода —
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Режим обхода",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (useWVCaptcha) {
ProtocolChip(
"РУЧ",
isManualMode,
enabled = true,
isError = false
) {
isManualMode = true
wbvManualMode = true
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("manual") }
}
ProtocolChip(
"АВТ",
!isManualMode,
enabled = true,
isError = false
) {
isManualMode = false
wbvManualMode = false
scope.launch { settingsStore.saveWbvCaptchaSolveMethod("auto") }
}
} else {
ProtocolChip(
"АВТ",
selected = true,
enabled = true,
isError = false
) {}
} }
} else {
ProtocolChip(
"АВТ",
selected = true,
enabled = true,
isError = false
) {}
} }
} }
} }
} }
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
)
// — Режим ссылки —
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Режим ссылки",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Switch(
checked = wdttLinkMode,
onCheckedChange = { enabled ->
scope.launch {
settingsStore.saveWdttLinkMode(enabled)
}
}
)
}
if (wdttLinkMode) {
Column {
var linkText by remember(wdttLink) { mutableStateOf(wdttLink) }
OutlinedTextField(
value = linkText,
onValueChange = {
linkText = it.trim()
scope.launch { settingsStore.saveWdttLink(it.trim()) }
},
label = { Text("Ссылка wdtt://") },
placeholder = { Text("Ссылка wdtt://") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
)
}
}
} }
} }
// ═══ Кнопки: Секреты + Подключить ═══ // ═══ Кнопки: Секреты + Подключить ═══
val tunnelSecretsMissing = savedConnectionPassword.isBlank() val tunnelSecretsMissing = savedConnectionPassword.isBlank()
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
OutlinedButton( if (!wdttLinkMode) {
onClick = { showSecretsDialog = true }, OutlinedButton(
modifier = Modifier.height(52.dp), onClick = { showSecretsDialog = true },
shape = RoundedCornerShape(16.dp), modifier = Modifier.weight(1f).height(52.dp),
colors = ButtonDefaults.outlinedButtonColors( shape = RoundedCornerShape(16.dp),
containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface containerColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface,
), contentColor = if (tunnelSecretsMissing) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface
border = BorderStroke( ),
1.dp, border = BorderStroke(
if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) 1.dp,
) if (tunnelSecretsMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
) { )
Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp)) ) {
Spacer(modifier = Modifier.width(8.dp)) Icon(imageVector = Icons.Default.Key, contentDescription = null, modifier = Modifier.size(18.dp))
Text("Секреты", fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.width(8.dp))
Text("Секреты", fontWeight = FontWeight.SemiBold, maxLines = 1)
}
} }
val buttonColor by animateColorAsState( val buttonColor by animateColorAsState(
@@ -617,8 +718,10 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
requestVpnAndStart() requestVpnAndStart()
} }
}, },
enabled = (isValid && cooldownSeconds == 0) || tunnelRunning, enabled = (isValid && !cooldownActive) || tunnelRunning,
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier
.weight(1f)
.height(52.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = buttonColor, containerColor = buttonColor,
@@ -634,14 +737,14 @@ fun SettingsTabContent(context: android.content.Context, scope: kotlinx.coroutin
Text( Text(
text = when { text = when {
tunnelRunning -> "Остановить" tunnelRunning -> "Остановить"
cooldownSeconds > 0 -> "Подождите ($cooldownSeconds)" cooldownActive -> "Подождите..."
else -> "Подключить" 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 { func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
w := bounds.Dx() / gridSize
h := bounds.Dy() / gridSize
col := index % gridSize col := index % gridSize
row := index / gridSize row := index / gridSize
return image.Rect( return image.Rect(
bounds.Min.X+col*w, bounds.Min.X+(col*bounds.Dx())/gridSize,
bounds.Min.Y+row*h, bounds.Min.Y+(row*bounds.Dy())/gridSize,
bounds.Min.X+(col+1)*w, bounds.Min.X+((col+1)*bounds.Dx())/gridSize,
bounds.Min.Y+(row+1)*h, bounds.Min.Y+((row+1)*bounds.Dy())/gridSize,
) )
} }
+27 -1
View File
@@ -9,6 +9,27 @@ import (
"time" "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 ( const (
returnChBuf = 384 returnChBuf = 384
@@ -125,13 +146,14 @@ func (d *Dispatcher) readLoop() {
d.clientAddr.Store(&addr) d.clientAddr.Store(&addr)
atomic.AddInt64(&d.stats.TotalBytesUp, int64(n)) atomic.AddInt64(&d.stats.TotalBytesUp, int64(n))
pkt := make([]byte, n) pkt := getPktBuf(n)
copy(pkt, buf[:n]) copy(pkt, buf[:n])
d.mu.Lock() d.mu.Lock()
nw := len(d.workers) nw := len(d.workers)
if nw == 0 { if nw == 0 {
d.mu.Unlock() d.mu.Unlock()
putPktBuf(pkt)
continue continue
} }
@@ -169,6 +191,7 @@ func (d *Dispatcher) readLoop() {
// Все workers перегружены — сдвигаем указатель, пакет дропается // Все workers перегружены — сдвигаем указатель, пакет дропается
d.rrIndex = (idx + 1) % nw d.rrIndex = (idx + 1) % nw
d.rrCount = 0 d.rrCount = 0
putPktBuf(pkt)
} }
d.mu.Unlock() d.mu.Unlock()
} }
@@ -184,15 +207,18 @@ func (d *Dispatcher) writeLoop() {
case pkt := <-d.ReturnCh: case pkt := <-d.ReturnCh:
addrPtr := d.clientAddr.Load() addrPtr := d.clientAddr.Load()
if addrPtr == nil { if addrPtr == nil {
putPktBuf(pkt)
continue continue
} }
addr := *addrPtr addr := *addrPtr
if _, err := d.localConn.WriteTo(pkt, addr); err != nil { if _, err := d.localConn.WriteTo(pkt, addr); err != nil {
if d.ctx.Err() != nil { if d.ctx.Err() != nil {
putPktBuf(pkt)
return return
} }
} }
atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt))) atomic.AddInt64(&d.stats.TotalBytesDown, int64(len(pkt)))
putPktBuf(pkt)
} }
} }
} }
+1 -5
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"math/rand" "math/rand"
"net" "net"
@@ -12,7 +11,6 @@ import (
"time" "time"
) )
var groupAuthMutex sync.Mutex
const ( const (
workersPerGroup = 9 workersPerGroup = 9
@@ -32,7 +30,6 @@ func WorkerGroup(
getConfig bool, getConfig bool,
configCh chan<- string, configCh chan<- string,
workerIDs []int, workerIDs []int,
cycleDuration time.Duration,
pauseFlag *int32, pauseFlag *int32,
deviceID, password string, deviceID, password string,
stats *Stats, stats *Stats,
@@ -296,5 +293,4 @@ type Credentials struct {
CacheStreamID int CacheStreamID int
} }
// Unused import suppressor
var _ = fmt.Sprintf
+3 -5
View File
@@ -13,7 +13,6 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"time"
) )
// CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView) // CaptchaResultChan — канал для получения токена капчи из внешнего решателя (WebView)
@@ -284,18 +283,17 @@ func main() {
} }
gID := g + 1 gID := g + 1
cycle := time.Duration(defaultCycleSecs) * time.Second
var cc chan<- string var cc chan<- string
if isFirst { if isFirst {
cc = configCh cc = configCh
} }
wg.Add(1) 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() defer wg.Done()
WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort, WorkerGroup(ctx, groupID, startHashIndex, tp, peer, disp, localPort,
isFirstGroup, configChan, workerIds, cycleDir, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR) isFirstGroup, configChan, workerIds, &pauseFlag, *deviceID, *connPassword, stats, waitR, sigR)
}(gID, cycle, isFirst, cc, ids, g, myWaitReady, mySignalReady) }(gID, isFirst, cc, ids, g, myWaitReady, mySignalReady)
} }
wg.Wait() wg.Wait()
+35 -13
View File
@@ -12,6 +12,7 @@
package main package main
import ( import (
"crypto/cipher"
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"errors" "errors"
@@ -21,6 +22,24 @@ import (
"golang.org/x/crypto/chacha20poly1305" "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 ─── // ─── Configuration ───
// ObfsConfig holds per-session obfuscation parameters. // ObfsConfig holds per-session obfuscation parameters.
@@ -43,20 +62,22 @@ func NewObfsConfig() *ObfsConfig {
// ─── Per-direction state (sequence + timestamp counters) ─── // ─── 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 { type ObfsState struct {
mu sync.Mutex mu sync.Mutex
seq uint16 initSeq uint16
ts uint32 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 { func NewObfsState() *ObfsState {
var buf [6]byte var buf [6]byte
rand.Read(buf[:]) rand.Read(buf[:])
return &ObfsState{ return &ObfsState{
seq: binary.BigEndian.Uint16(buf[0:2]), initSeq: binary.BigEndian.Uint16(buf[0:2]),
ts: binary.BigEndian.Uint32(buf[2:6]), 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() state.mu.Lock()
seq := state.seq c := state.count
ts := state.ts state.count++
state.seq++
state.ts += 960 // 20ms frame @ 48kHz (OPUS standard)
state.mu.Unlock() state.mu.Unlock()
seq := state.initSeq + uint16(c)
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
// Build nonce from RTP fields // Build nonce from RTP fields
nonce := obfsBuildNonce(cfg.SSRC, seq, ts) 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[4:8], ts)
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return nil, fmt.Errorf("obfs: cipher init: %w", err) 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 // Build nonce and decrypt
nonce := obfsBuildNonce(ssrc, seq, ts) nonce := obfsBuildNonce(ssrc, seq, ts)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return 0, fmt.Errorf("obfs: cipher init: %w", err) return 0, fmt.Errorf("obfs: cipher init: %w", err)
} }
+1 -7
View File
@@ -35,13 +35,7 @@ func LoadProfileFromDisk() (*SavedProfile, error) {
return &sp, nil 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. // profileList contains paired User-Agent and Client Hints strings.
var profileList = []Profile{ var profileList = []Profile{
+5 -2
View File
@@ -371,7 +371,9 @@ func RunSession(
return return
} }
_ = dtlsConn.SetWriteDeadline(time.Now().Add(sessionReadTimeout)) _ = 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) log.Printf("[ВОРКЕР #%d] Ошибка Writer: %v", sessionID, writeErr)
return return
} }
@@ -403,11 +405,12 @@ func RunSession(
continue continue
} }
pkt := make([]byte, n) pkt := getPktBuf(n)
copy(pkt, b[:n]) copy(pkt, b[:n])
select { select {
case d.ReturnCh <- pkt: case d.ReturnCh <- pkt:
case <-sessCtx.Done(): case <-sessCtx.Done():
putPktBuf(pkt)
return return
} }
} }
-2
View File
@@ -8,10 +8,8 @@ import (
type Stats struct { type Stats struct {
ActiveConnections int32 ActiveConnections int32
Reconnects int64
TotalBytesUp int64 TotalBytesUp int64
TotalBytesDown int64 TotalBytesDown int64
CredsErrors int64
} }
func NewStats() *Stats { 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.useAndroidX=true
android.nonTransitiveRClass=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
+240 -56
View File
@@ -27,6 +27,8 @@ import (
"syscall" "syscall"
"time" "time"
"crypto/cipher"
"github.com/pion/dtls/v3" "github.com/pion/dtls/v3"
"github.com/pion/dtls/v3/pkg/crypto/selfsign" "github.com/pion/dtls/v3/pkg/crypto/selfsign"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
@@ -45,8 +47,6 @@ import (
const ( const (
wgIfaceName = "wdtt0" wgIfaceName = "wdtt0"
wgServerAddr = "10.66.66.1" wgServerAddr = "10.66.66.1"
wgClientAddr = "10.66.66.2"
wgClientCIDR = wgClientAddr + "/32"
wgServerCIDR = wgServerAddr + "/24" wgServerCIDR = wgServerAddr + "/24"
defaultInternalWGPort = 56001 defaultInternalWGPort = 56001
dns = "1.1.1.1" dns = "1.1.1.1"
@@ -64,23 +64,16 @@ type ClientDevice struct {
} }
type PasswordEntry struct { type PasswordEntry struct {
DeviceID string `json:"device_id"` // пусто = ещё не привязан DeviceID string `json:"device_id"` // пусто = ещё не привязан
ExpiresAt int64 `json:"expires_at"` // unix timestamp ExpiresAt int64 `json:"expires_at"` // unix timestamp
DownBytes int64 `json:"down_bytes"` // скачано клиентом DownBytes int64 `json:"down_bytes"` // скачано клиентом
UpBytes int64 `json:"up_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 { type Database struct {
MainPassword string `json:"main_password"` MainPassword string `json:"main_password"`
@@ -120,6 +113,37 @@ func generatePassword() string {
return string(b) 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 { type wrapKeyEntry struct {
id string id string
key []byte key []byte
@@ -340,7 +364,7 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
// Устанавливаем команды для синей кнопки Menu // Устанавливаем команды для синей кнопки Menu
go func() { 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)) resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds))
if err == nil { if err == nil {
resp.Body.Close() resp.Body.Close()
@@ -350,8 +374,14 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
offset := 0 offset := 0
client := &http.Client{Timeout: 65 * time.Second} client := &http.Client{Timeout: 65 * time.Second}
// Состояние ожидания ввода дней // Состояние ожидания ввода
var waitingForDays bool var waitingForDays bool
var waitingForPorts bool
var waitingForHash bool
var targetPassword string
var tempDays int
var tempPorts string // "dtls,wg,tun"
for { for {
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset) 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 continue
} }
txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass) 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 { if entry.ExpiresAt > 0 {
expireTime := time.Unix(entry.ExpiresAt, 0) expireTime := time.Unix(entry.ExpiresAt, 0)
remaining := time.Until(expireTime) remaining := time.Until(expireTime)
@@ -421,6 +467,8 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
} else { } else {
txt += "⏰ Бессрочный ♾\n" 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" txt += "\n📱 *Привязанное устройство:*\n"
var kb []map[string]interface{} var kb []map[string]interface{}
if entry.DeviceID == "" { if entry.DeviceID == "" {
@@ -438,6 +486,17 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
}) })
} }
dbMutex.Unlock() 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{}{ kb = append(kb, map[string]interface{}{
"text": "❌ Удалить пароль", "text": "❌ Удалить пароль",
"callback_data": "delpass_" + pass, "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}) 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_") { } else if strings.HasPrefix(data, "unbind_") {
pass := strings.TrimPrefix(data, "unbind_") pass := strings.TrimPrefix(data, "unbind_")
dbMutex.Lock() dbMutex.Lock()
@@ -509,6 +606,13 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
} else if data == "backlist" { } else if data == "backlist" {
sendPasswordList(token, adminID, wgDev) 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) sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil)
continue 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() dbMutex.Lock()
if cleanupExpiredPasswordsLocked(wgDev) > 0 { if cleanupExpiredPasswordsLocked(wgDev) > 0 {
saveDB() saveDB()
@@ -556,11 +721,21 @@ func botLoop(token string, adminIDstr string, wgDev *device.Device) {
sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil) sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil)
continue 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() saveDB()
dbMutex.Unlock() dbMutex.Unlock()
expDate := time.Unix(expiresAt, 0).Format("02.01.2006") 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 continue
} }
@@ -677,6 +852,10 @@ func sendPasswordList(token string, adminID int64, wgDev *device.Device) {
txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword) txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword)
var inlineKb []map[string]interface{} var inlineKb []map[string]interface{}
inlineKb = append(inlineKb, map[string]interface{}{
"text": "🔗 Ссылка на главный пароль",
"callback_data": "mainlink",
})
if len(db.Passwords) == 0 { if len(db.Passwords) == 0 {
txt += "_Нет сгенерированных паролей._\n" txt += "_Нет сгенерированных паролей._\n"
@@ -1224,7 +1403,6 @@ func main() {
func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) { func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) {
atomic.AddInt64(&totalConns, 1) atomic.AddInt64(&totalConns, 1)
var connDeviceID string
var connPassword string var connPassword string
var connIsMainPass bool var connIsMainPass bool
@@ -1276,14 +1454,16 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
entry, isGenPass := db.Passwords[password] entry, isGenPass := db.Passwords[password]
valid := isMainPass || (isGenPass && !isPasswordExpired(entry)) valid := isMainPass || (isGenPass && !isPasswordExpired(entry))
// Для сгенерированных паролей — проверяем привязку к устройству if valid && isGenPass && entry.IsDeactivated {
if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID { 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")) clientConn.Write([]byte("DENIED:device_mismatch"))
log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID) log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID)
dbMutex.Unlock() dbMutex.Unlock()
} else if valid { } else if valid {
connDeviceID = deviceID
connPassword = password connPassword = password
connIsMainPass = isMainPass connIsMainPass = isMainPass
@@ -1364,20 +1544,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
} }
atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket))) 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) pctx, pcancel := context.WithCancel(ctx)
defer pcancel() defer pcancel()
@@ -1413,9 +1580,7 @@ func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgD
} }
atomic.AddInt64(&totalBytesFromClient, int64(nn)) atomic.AddInt64(&totalBytesFromClient, int64(nn))
// Per-password upload tracking // Per-password upload tracking
if connIsMainPass { if connPassword != "" && !connIsMainPass {
atomic.AddInt64(&mainPassUp, int64(nn))
} else if connPassword != "" {
dbMutex.Lock() dbMutex.Lock()
e, ok := db.Passwords[connPassword] e, ok := db.Passwords[connPassword]
if !ok || e == nil || isPasswordExpired(e) { 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)) atomic.AddInt64(&totalBytesToClient, int64(nn))
// Per-password download tracking // Per-password download tracking
if connIsMainPass { if connPassword != "" && !connIsMainPass {
atomic.AddInt64(&mainPassDown, int64(nn))
} else if connPassword != "" {
dbMutex.Lock() dbMutex.Lock()
e, ok := db.Passwords[connPassword] e, ok := db.Passwords[connPassword]
if !ok || e == nil || isPasswordExpired(e) { if !ok || e == nil || isPasswordExpired(e) {
@@ -1482,6 +1645,24 @@ const (
wrapKeyLen = 32 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 Обфускация ==================== // ==================== RTP Обфускация ====================
type ObfsConfig struct { type ObfsConfig struct {
@@ -1491,9 +1672,10 @@ type ObfsConfig struct {
} }
type ObfsState struct { type ObfsState struct {
mu sync.Mutex mu sync.Mutex
seq uint16 initSeq uint16
ts uint32 initTs uint32
count uint64
} }
func NewObfsConfig() *ObfsConfig { func NewObfsConfig() *ObfsConfig {
@@ -1510,8 +1692,9 @@ func NewObfsState() *ObfsState {
var buf [6]byte var buf [6]byte
rand.Read(buf[:]) rand.Read(buf[:])
return &ObfsState{ return &ObfsState{
seq: binary.BigEndian.Uint16(buf[0:2]), initSeq: binary.BigEndian.Uint16(buf[0:2]),
ts: binary.BigEndian.Uint32(buf[2:6]), 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") return nil, errors.New("obfs: empty payload")
} }
state.mu.Lock() state.mu.Lock()
seq := state.seq c := state.count
ts := state.ts state.count++
state.seq++
state.ts += 960
state.mu.Unlock() state.mu.Unlock()
seq := state.initSeq + uint16(c)
ts := state.initTs + uint32(c)*960 + uint32(c>>16)
nonce := obfsBuildNonce(cfg.SSRC, seq, ts) nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
padRand := 0 padRand := 0
if cfg.PaddingMax > 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[4:8], ts)
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC) binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return nil, fmt.Errorf("obfs: cipher init: %w", err) 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") return 0, errors.New("obfs: dst buffer too small")
} }
nonce := obfsBuildNonce(ssrc, seq, ts) nonce := obfsBuildNonce(ssrc, seq, ts)
aead, err := chacha20poly1305.New(key) aead, err := getAEAD(key)
if err != nil { if err != nil {
return 0, fmt.Errorf("obfs: cipher init: %w", err) return 0, fmt.Errorf("obfs: cipher init: %w", err)
} }