diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70c52f6..1997f81 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -12,7 +14,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "1.0.4" + versionName = "1.0.51" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -20,32 +22,64 @@ android { } ndk { - abiFilters.add("arm64-v8a") + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) } } + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + signingConfigs { - val keystoreFile = file("amurcanov.jks") - if (keystoreFile.exists()) { - create("release") { - storeFile = keystoreFile - // Берем пароли из локальных переменных среды или файла - storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "flowseal-fork" - keyAlias = "amurcanov" - keyPassword = System.getenv("KEY_PASSWORD") ?: "flowseal-fork" + create("release") { + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + if (keyFile != null) { + // Резолвим путь: если начинается с "..", берём от корня проекта + val resolvedFile = if (keyFile.startsWith("..")) { + // ../release.keystore -> корень проекта / release.keystore + file(rootDir.resolve(keyFile.substring(3))) + } else { + file(keyFile) + } + if (resolvedFile.exists()) { + storeFile = resolvedFile + storePassword = localProperties.getProperty("KEYSTORE_PASSWORD") + keyAlias = localProperties.getProperty("KEY_ALIAS") + keyPassword = localProperties.getProperty("KEY_PASSWORD") + } else { + println("WARNING: Keystore file not found: $keyFile (resolved: ${resolvedFile.absolutePath})") + } } + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true } } buildTypes { - release { - signingConfig = signingConfigs.findByName("release") + getByName("release") { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + val resolvedFile = if (keyFile != null && keyFile.startsWith("..")) { + file(rootDir.resolve(keyFile.substring(3))) + } else if (keyFile != null) { + file(keyFile) + } else null + + if (resolvedFile != null && resolvedFile.exists()) { + signingConfig = signingConfigs.getByName("release") + println("✅ Signing config applied: ${resolvedFile.absolutePath}") + } else { + println("⚠️ WARNING: Keystore not found, using debug signing") + println(" Looked for: ${resolvedFile?.absolutePath ?: keyFile}") + } } } compileOptions { @@ -84,6 +118,9 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + // DataStore for persistent settings + implementation("androidx.datastore:datastore-preferences:1.0.0") + // JNA for easy C-shared library calls implementation("net.java.dev.jna:jna:5.14.0@aar") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt b/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt new file mode 100644 index 0000000..6b480fa --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt @@ -0,0 +1,16 @@ +package com.amurcanov.tgwsproxy + +import androidx.compose.runtime.Immutable + +/** + * Immutable data class for log entries — ensures Compose skips recomposition + * when the reference hasn't changed. + */ +@Immutable +data class LogEntry( + val key: String, + val message: String, + val count: Int, + val isError: Boolean, + val priority: Int // 3=DEBUG, 4=INFO, 5=WARN, 6=ERROR +) diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt index 89e0f27..e7341ff 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt @@ -3,116 +3,92 @@ package com.amurcanov.tgwsproxy import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings -import android.content.ClipData -import android.content.ClipboardManager import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.NightsStay -import androidx.compose.material.icons.filled.WbSunny -import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.window.Dialog -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.* +import com.amurcanov.tgwsproxy.ui.FloatingToolbar +import com.amurcanov.tgwsproxy.ui.InfoTab +import com.amurcanov.tgwsproxy.ui.LogsTab +import com.amurcanov.tgwsproxy.ui.SettingsTab +import com.amurcanov.tgwsproxy.ui.TgWsProxyTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import java.io.BufferedReader import java.io.InputStreamReader - -// DataCenters list removed - -val telegramApps = listOf( - "org.telegram.messenger", - "org.thunderdog.challegram", - "com.radolyn.ayugram", - "app.exteragram.messenger", - "ir.ilmili.telegraph", - "org.telegram.plus", - "tw.nekomimi.nekogram", - "tw.nekomimi.nekogramx", - "org.telegram.mdgram", - "com.iMe.android", - "app.nicegram", - "org.telegram.bgram", - "cc.modery.cherrygram", - "io.github.nextalone.nagram" -) +import java.util.concurrent.atomic.AtomicLong class MainActivity : ComponentActivity() { private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() - ) { - // Ignored in this example, but handles Tiramisu+ notifications - } + ) {} override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } checkBatteryOptimizations() + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { - var isDarkTheme by remember { mutableStateOf(false) } val context = LocalContext.current + val settingsStore = remember { SettingsStore(context) } + val themeMode by settingsStore.themeMode + .collectAsStateWithLifecycle(initialValue = "system") + val scope = rememberCoroutineScope() - // Dynamic colors logic for Android 12+ (Material You) - val colorScheme = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - isDarkTheme -> darkColorScheme() - else -> lightColorScheme() - } - - MaterialTheme(colorScheme = colorScheme) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - ProxyScreen( - isDarkTheme = isDarkTheme, - onThemeChange = { isDarkTheme = !isDarkTheme } + TgWsProxyTheme(themeMode = themeMode) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density = androidx.compose.ui.platform.LocalDensity.current.density, + fontScale = 1f ) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box { + MainContent(settingsStore) + + FloatingToolbar( + currentTheme = themeMode, + onThemeChange = { mode -> + scope.launch { settingsStore.saveThemeMode(mode) } + } + ) + } + } } } } @@ -126,7 +102,7 @@ class MainActivity : ComponentActivity() { val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.data = Uri.parse("package:$packageName") startActivity(intent) - } catch (e: Exception) { + } catch (_: Exception) { Toast.makeText(this, "Не удалось запросить работу в фоне", Toast.LENGTH_SHORT).show() } } @@ -134,618 +110,180 @@ class MainActivity : ComponentActivity() { } } +private data class NavItem( + val label: String, + val iconRes: androidx.compose.ui.graphics.vector.ImageVector +) + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProxyScreen(isDarkTheme: Boolean, onThemeChange: () -> Unit) { - val context = LocalContext.current - val prefs = context.getSharedPreferences("ProxyPrefs", Context.MODE_PRIVATE) - val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle() - var dc2Text by remember { mutableStateOf(prefs.getString("dc2", "149.154.167.220") ?: "149.154.167.220") } - var dc4Text by remember { mutableStateOf(prefs.getString("dc4", "149.154.167.220") ?: "149.154.167.220") } - var dc203Text by remember { mutableStateOf(prefs.getString("dc203", "149.154.167.220") ?: "149.154.167.220") } - var portText by remember { mutableStateOf(prefs.getString("port", "1080") ?: "1080") } - var selectedPoolSize by remember { mutableStateOf(prefs.getInt("pool", 4)) } - var showLogs by rememberSaveable { mutableStateOf(true) } - var showInfoModal by remember { mutableStateOf(false) } - var showIpSetupModal by remember { mutableStateOf(false) } - - LaunchedEffect(showLogs) { - if (showLogs) LogManager.startListening() else LogManager.stopListening() +fun MainContent(settingsStore: SettingsStore) { + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + val navItems = remember { + listOf( + NavItem("Настройки", Icons.Default.Settings), + NavItem("Логи", Icons.Default.Terminal), + NavItem("Информация", Icons.Default.Info) + ) } - val startProxyAction by rememberUpdatedState { - val port = portText.toIntOrNull() - if (port == null) { - Toast.makeText(context, "Неверный порт", Toast.LENGTH_SHORT).show() - return@rememberUpdatedState - } - val parsedIps = buildList { - if (dc2Text.isNotBlank()) add("2:${dc2Text.trim()}") - if (dc4Text.isNotBlank()) add("4:${dc4Text.trim()}") - if (dc203Text.isNotBlank()) add("203:${dc203Text.trim()}") - }.joinToString(",") - - if (parsedIps.isEmpty()) { - Toast.makeText(context, "Впишите IP хотя бы для одного DC", Toast.LENGTH_SHORT).show() - return@rememberUpdatedState - } - - val startIntent = Intent(context, ProxyService::class.java).apply { - action = ProxyService.ACTION_START - putExtra(ProxyService.EXTRA_PORT, port) - putExtra(ProxyService.EXTRA_IPS, parsedIps) - putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize) - } - ContextCompat.startForegroundService(context, startIntent) - } - - val stopProxyAction by rememberUpdatedState { - val stopIntent = Intent(context, ProxyService::class.java).apply { - action = ProxyService.ACTION_STOP - } - context.startService(stopIntent) - } - - val applyInTelegramAction by rememberUpdatedState { - val port = portText.toIntOrNull() ?: 1080 - val proxyUrl = "tg://socks?server=127.0.0.1&port=$port" - openTelegram(context, proxyUrl) + LaunchedEffect(Unit) { + LogManager.startListening() } Scaffold( - topBar = { - TopAppBar( - title = { Text("Telegram WS Proxy", fontWeight = FontWeight.SemiBold) }, - actions = { - TextButton( - onClick = { showInfoModal = true }, - colors = ButtonDefaults.textButtonColors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + navItems.forEachIndexed { index, item -> + val selected = selectedTab == index + NavigationBarItem( + selected = selected, + onClick = { selectedTab = index }, + icon = { + Icon( + imageVector = item.iconRes, + contentDescription = item.label, + modifier = Modifier.size(24.dp) + ) + }, + label = { Text(item.label, style = MaterialTheme.typography.labelSmall) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), ), - modifier = Modifier.padding(end = 12.dp) - ) { - Text("инфо", fontWeight = FontWeight.SemiBold, fontSize = 22.sp) - } - IconButton(onClick = onThemeChange) { - Crossfade(targetState = isDarkTheme, animationSpec = tween(400), label = "themeAnim") { isDark -> - if (isDark) { - Icon( - imageVector = Icons.Default.WbSunny, - contentDescription = "Светлая тема", - tint = MaterialTheme.colorScheme.onSurface - ) - } else { - Icon( - imageVector = Icons.Default.NightsStay, - contentDescription = "Темная тема", - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurface - ) - ) - }, - containerColor = MaterialTheme.colorScheme.background - ) { innerPadding -> - Box( + ) + } + } + } + ) { padding -> + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + fadeIn(tween(250)) togetherWith fadeOut(tween(200)) + }, modifier = Modifier .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - // Constrain content width for tablets to look good anywhere - Column( - modifier = Modifier - .fillMaxHeight() - .widthIn(max = 600.dp) - .padding(horizontal = 24.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top // Push top fields higher - ) { - - // Proxy Port Input - OutlinedTextField( - value = portText, - onValueChange = { - portText = it - prefs.edit().putString("port", it).apply() - }, - label = { Text("Порт прокси") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - shape = RoundedCornerShape(24.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 10.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - singleLine = true - ) - - // DC selection modal button - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .clip(RoundedCornerShape(24.dp)) - .clickable { showIpSetupModal = true } - ) { - OutlinedTextField( - value = "Настроить адреса", - onValueChange = {}, - label = { Text("Настройка IP") }, - enabled = false, - shape = RoundedCornerShape(24.dp), - colors = OutlinedTextFieldDefaults.colors( - disabledTextColor = MaterialTheme.colorScheme.onSurface, - disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledBorderColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) - } - - // Pool size selector - Text( - "Размер пула WS", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - listOf(4, 6, 8).forEach { size -> - val isSelected = selectedPoolSize == size - FilledTonalButton( - onClick = { - selectedPoolSize = size - prefs.edit().putInt("pool", size).apply() - }, - enabled = !isRunning, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Text( - "$size", - style = MaterialTheme.typography.titleMedium, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - } - } - } - - // Proxy Start/Stop Button - AnimatedContent( - targetState = isRunning, - transitionSpec = { - fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300)) - }, - label = "runAnim" - ) { running -> - Button( - onClick = { - if (running) stopProxyAction() else startProxyAction() - }, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (running) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - ) - ) { - Text( - if (running) "Остановить прокси" else "Запустить прокси", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Apply in Telegram Button - FilledTonalButton( - onClick = applyInTelegramAction, - enabled = isRunning, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RoundedCornerShape(12.dp) - ) { - Text( - "Применить в телеграмм", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Logs toggle button — same style as main buttons - Button( - onClick = { showLogs = !showLogs }, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - Text( - if (showLogs) "Скрыть логи" else "Показать логи", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - if (showLogs) { - val logs by LogManager.logs.collectAsStateWithLifecycle() - val scroll = rememberScrollState() - val primaryColor = MaterialTheme.colorScheme.primary - - // Auto-scroll to bottom when new logs arrive - LaunchedEffect(logs.size) { - scroll.animateScrollTo(scroll.maxValue) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Box(modifier = Modifier - .fillMaxWidth() - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - Text( - text = logs.joinToString("\n") { formatLogLine(it) }, - modifier = Modifier - .fillMaxSize() - .padding(start = 12.dp, end = 40.dp, top = 12.dp, bottom = 12.dp) - .verticalScroll(scroll), - color = primaryColor, - style = MaterialTheme.typography.bodySmall, - lineHeight = MaterialTheme.typography.bodySmall.fontSize * 1.5 - ) - IconButton( - onClick = { - val cm = ContextCompat.getSystemService(context, ClipboardManager::class.java) - cm?.setPrimaryClip(ClipData.newPlainText("Logs", logs.joinToString("\n"))) - Toast.makeText(context, "Логи скопированы!", Toast.LENGTH_SHORT).show() - }, - modifier = Modifier.align(Alignment.TopEnd).padding(4.dp) - ) { - Icon( - Icons.Default.ContentCopy, - "Копировать логи", - tint = primaryColor.copy(alpha = 0.6f) - ) - } - } - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } - - if (showInfoModal) { - InfoDialog(onDismiss = { showInfoModal = false }) - } - - if (showIpSetupModal) { - IpSetupDialog( - dc2Text = dc2Text, - onDc2Change = { - dc2Text = it - prefs.edit().putString("dc2", it).apply() - }, - dc4Text = dc4Text, - onDc4Change = { - dc4Text = it - prefs.edit().putString("dc4", it).apply() - }, - dc203Text = dc203Text, - onDc203Change = { - dc203Text = it - prefs.edit().putString("dc203", it).apply() - }, - onDismiss = { showIpSetupModal = false } - ) - } -} - -@Composable -fun IpSetupDialog( - dc2Text: String, onDc2Change: (String) -> Unit, - dc4Text: String, onDc4Change: (String) -> Unit, - dc203Text: String, onDc203Change: (String) -> Unit, - onDismiss: () -> Unit -) { - val onIpChange = { newValue: String, update: (String) -> Unit -> - if (newValue.all { it.isDigit() || it == '.' }) { - update(newValue) - } - } - - @Composable - fun dcInput(label: String, value: String, update: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = { onIpChange(it, update) }, - label = { Text(label) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - shape = RoundedCornerShape(24.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - singleLine = true - ) - } - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.widthIn(max = 400.dp) - ) { - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = "Пул датацентров", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 20.dp), - fontWeight = FontWeight.SemiBold - ) - - dcInput("DC2", dc2Text, onDc2Change) - dcInput("DC4", dc4Text, onDc4Change) - dcInput("DC203", dc203Text, onDc203Change) - - Spacer(modifier = Modifier.height(8.dp)) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text("Готово", style = MaterialTheme.typography.labelLarge) - } - } + .padding(padding), + label = "tab_content" + ) { page -> + when (page) { + 0 -> SettingsTab(settingsStore) + 1 -> LogsTab() + 2 -> InfoTab() } } } } -@Composable -fun InfoDialog(onDismiss: () -> Unit) { - val context = LocalContext.current - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.widthIn(max = 400.dp).fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()) - ) { - Text( - text = "Версия 1.0.4", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - - Text( - text = "Что нового:", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 8.dp) - ) - - Text( - text = "1. Убран выбор пула датацентров", - color = Color(0xFFD32F2F), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Text( - text = "2. Добавлена возможность ввода IP датацентров вручную", - color = Color(0xFF388E3C), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Text( - text = "3. При использовании IP адреса, указанного по умолчанию (149.154.167.220), вспомогательные средства (VPN и прочее) не требуются.", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 16.dp) - ) - - Divider(modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)) - - val openLink = { url: String -> - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Не удалось открыть ссылку", Toast.LENGTH_SHORT).show() - } - } - - Column(modifier = Modifier.fillMaxWidth()) { - Text("Оригинальный автор tg-ws-proxy:", style = MaterialTheme.typography.bodyMedium) - Text( - text = "→ Flowseal", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 2.dp, start = 8.dp).clickable { openLink("https://github.com/Flowseal") } - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Column(modifier = Modifier.fillMaxWidth()) { - Text("Человек, благодаря кому вышла v1.0.4:", style = MaterialTheme.typography.bodyMedium) - Text( - text = "→ IMDelewer", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 2.dp, start = 8.dp).clickable { openLink("https://github.com/IMDelewer") } - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = buildAnnotatedString { - append("Ознакомиться с актуальным списком CIDR датацентров Telegram можно ") - withStyle(style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - )) { - append("тут") - } - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .clickable { openLink("https://core.telegram.org/resources/cidr.txt") } - .padding(bottom = 16.dp) - ) - - Text( - text = "Вероятнее всего, изменение IP адресов в графах DC может нарушить работу прокси без работающего VPN. " + - "Не советую ничего менять без необходимости. Однако, если у вас наблюдаются проблемы в Telegram " + - "при использовании адреса 149.154.167.220, вы можете заменить его на другие IP из актуальных списков. " + - "Помните, что в таком случае вам может потребоваться включённый VPN — этот двойственный способ (Proxy + VPN) " + - "зачастую решает проблемы соединения, если Telegram отказывается стабильно работать.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 16.sp - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text("Закрыть", style = MaterialTheme.typography.labelLarge) - } - } - } - } - } -} - -fun formatLogLine(raw: String): String { - // Raw logcat line example: - // 03-24 14:30:45.057 I/TgWsProxy(24567): INFO 11:30:45 WS pool warmup started... - // We want to extract: "11:30:45 WS pool warmup started..." - val infoIdx = raw.indexOf("INFO ") - if (infoIdx >= 0) { - return "• " + raw.substring(infoIdx + 6).trim() - } - val warnIdx = raw.indexOf("WARN ") - if (warnIdx >= 0) { - return "⚠ " + raw.substring(warnIdx + 6).trim() - } - val errIdx = raw.indexOf("ERROR ") - if (errIdx >= 0) { - return "✖ " + raw.substring(errIdx + 6).trim() - } - val dbgIdx = raw.indexOf("DEBUG ") - if (dbgIdx >= 0) { - return "◦ " + raw.substring(dbgIdx + 6).trim() - } - // Fallback: try to find the message after ): - val msgIdx = raw.indexOf("): ") - if (msgIdx >= 0) { - return "• " + raw.substring(msgIdx + 3).trim() - } - return raw.trim() -} - -fun openTelegram(context: Context, url: String) { - val pm = context.packageManager - val uri = Uri.parse(url) - - for (pkg in telegramApps) { - try { - pm.getPackageInfo(pkg, 0) - val intent = Intent(Intent.ACTION_VIEW, uri) - intent.setPackage(pkg) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - return - } catch (e: PackageManager.NameNotFoundException) { - // App not found, skip - } catch (e: Exception) { - // Activity not found or other err - } - } - - // Fallback: just open any app that handles tg:// link - try { - val fallbackIntent = Intent(Intent.ACTION_VIEW, uri) - fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(fallbackIntent) - } catch (e: Exception) { - Toast.makeText(context, "Telegram не найден!", Toast.LENGTH_SHORT).show() - } -} - +/** + * Optimized LogManager: uses a Channel + batching approach to avoid + * creating a new list on every single log line — reduces GC pressure + * and eliminates UI jank caused by high-frequency log updates. + * + * Key optimizations: + * - Channel-based buffering: log lines are queued, not applied immediately + * - Batch processing: up to 20 lines applied per tick (every 150ms) + * - Array-backed list with cap of 50: avoids growing/shrinking allocations + * - Duplicate merging: last-entry count increment done in-place conceptually + */ object LogManager { - val logs = MutableStateFlow>(emptyList()) + val logs = MutableStateFlow>(emptyList()) private var job: Job? = null private var logcatProcess: Process? = null + private val nextKey = AtomicLong(0) + + // Buffered channel — absorbs bursts of log lines without blocking the reader + private val logChannel = Channel(capacity = BUFFERED) fun startListening() { if (job?.isActive == true) return job = CoroutineScope(Dispatchers.IO).launch { - try { - // Clear old logs just to avoid stale - Runtime.getRuntime().exec("logcat -c").waitFor() - val process = Runtime.getRuntime().exec(arrayOf("logcat", "-v", "time", "*:D")) - logcatProcess = process - val reader = BufferedReader(InputStreamReader(process.inputStream)) - - val myPid = android.os.Process.myPid().toString() - while (isActive) { - val line = reader.readLine() ?: break - if (line.contains(myPid) && (line.contains("INFO") || line.contains("WARN") || line.contains("ERROR") || line.contains("DEBUG"))) { - logs.update { current -> - val n = current + line - if (n.size > 30) n.takeLast(30) else n - } + // Start logcat reader coroutine + val readerJob = launch { + try { + val pid = android.os.Process.myPid() + val process = Runtime.getRuntime().exec( + arrayOf("logcat", "-v", "tag", "--pid", pid.toString()) + ) + logcatProcess = process + val reader = BufferedReader(InputStreamReader(process.inputStream), 8192) + + while (isActive) { + val line = reader.readLine() ?: break + val entry = parseLine(line) ?: continue + logChannel.trySend(entry) // non-blocking send } + } catch (_: Exception) { + } finally { + logcatProcess?.destroy() + logcatProcess = null } - } catch (e: Exception) { - } finally { - logcatProcess?.destroy() - logcatProcess = null } + + // Batch consumer: collects queued entries and applies in batches + launch { + val pendingBatch = mutableListOf() + while (isActive) { + // Drain the channel (non-blocking) + var received = logChannel.tryReceive() + while (received.isSuccess) { + pendingBatch.add(received.getOrThrow()) + if (pendingBatch.size >= 20) break // cap batch size + received = logChannel.tryReceive() + } + + if (pendingBatch.isNotEmpty()) { + // Apply batch to state — single list mutation + logs.value = applyBatch(logs.value, pendingBatch) + pendingBatch.clear() + } + + // Throttle updates — 150ms between UI refreshes + delay(150) + } + } + + readerJob.join() + } + } + + /** + * Efficiently applies a batch of new entries to the current log list. + * Merges consecutive duplicates and caps at 50 entries. + */ + private fun applyBatch(current: List, batch: List): List { + // Use a pre-sized ArrayList to avoid re-allocation + val result = ArrayList(minOf(current.size + batch.size, 50)) + result.addAll(current) + + for (entry in batch) { + var merged = false + val searchDepth = minOf(result.size, 10) + for (i in result.lastIndex downTo result.size - searchDepth) { + if (result[i].message == entry.message) { + val existing = result.removeAt(i) + result.add(existing.copy(count = existing.count + 1)) + merged = true + break + } + } + if (!merged) { + result.add(entry) + } + } + + // Trim to 50 entries from the end + return if (result.size > 50) { + result.subList(result.size - 50, result.size).toList() + } else { + result } } @@ -754,6 +292,59 @@ object LogManager { job = null logcatProcess?.destroy() logcatProcess = null + } + + fun clearLogs() { logs.value = emptyList() } + + private fun parseLine(raw: String): LogEntry? { + val message: String + val isError: Boolean + val priority: Int + + when { + raw.contains("[ERROR]") -> { + message = raw.substringAfter("[ERROR]").trim() + isError = true + priority = 6 // Log.ERROR + } + raw.contains("[WARN]") -> { + message = raw.substringAfter("[WARN]").trim() + isError = false // WARN is not ERROR, but distinctive + priority = 5 // Log.WARN + } + raw.contains("[DEBUG]") -> { + return null // DEBUG lines are hidden from UI + } + raw.contains("TgWsProxy") -> { + // Info doesn't have a prefix, so we strip basically everything up to the actual message + var msg = raw.substringAfter("TgWsProxy:").trim() + if (msg.startsWith("[ERROR]") || msg.startsWith("[WARN]") || msg.startsWith("[DEBUG]")) { + return null // Handled above, but just in case + } + + // Strip dynamic metrics like ↑3.3KB ↓1.1KB 0.3с so that lines can collapse + if (msg.contains("↑")) { + msg = msg.substringBefore("↑").trim() + } + if (msg.contains("↓")) { + msg = msg.substringBefore("↓").trim() + } + + message = msg + isError = false + priority = 4 // Log.INFO + } + else -> return null + } + + return LogEntry( + key = "log_${nextKey.getAndIncrement()}", + message = message, + count = 1, + isError = isError, + priority = priority + ) + } } diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt index e0fe43a..443fffd 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt @@ -12,6 +12,8 @@ interface ProxyLibrary : Library { fun StartProxy(host: String, port: Int, dcIps: String, verbose: Int): Int fun StopProxy(): Int fun SetPoolSize(size: Int) + fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String) + fun SetSecret(secret: String) fun GetStats(): Pointer? fun FreeString(p: Pointer) } @@ -26,6 +28,16 @@ object NativeProxy { fun setPoolSize(size: Int) { ProxyLibrary.INSTANCE.SetPoolSize(size) } + fun setCfProxyConfig(enabled: Boolean, priority: Boolean, userDomain: String) { + ProxyLibrary.INSTANCE.SetCfProxyConfig( + if (enabled) 1 else 0, + if (priority) 1 else 0, + userDomain + ) + } + fun setSecret(secret: String) { + ProxyLibrary.INSTANCE.SetSecret(secret) + } fun getStats(): String? { val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null val res = ptr.getString(0) diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt index a699e07..a078b55 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt @@ -27,6 +27,10 @@ class ProxyService : Service() { const val EXTRA_PORT = "EXTRA_PORT" const val EXTRA_IPS = "EXTRA_IPS" const val EXTRA_POOL_SIZE = "EXTRA_POOL_SIZE" + const val EXTRA_CFPROXY_ENABLED = "EXTRA_CFPROXY_ENABLED" + const val EXTRA_CFPROXY_PRIORITY = "EXTRA_CFPROXY_PRIORITY" + const val EXTRA_CFPROXY_DOMAIN = "EXTRA_CFPROXY_DOMAIN" + const val EXTRA_SECRET_KEY = "EXTRA_SECRET_KEY" private const val NOTIFICATION_ID = 1 private const val CHANNEL_ID = "ProxyServiceChannel" @@ -43,10 +47,15 @@ class ProxyService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START -> { + LogManager.clearLogs() val port = intent.getIntExtra(EXTRA_PORT, 8080) val ips = intent.getStringExtra(EXTRA_IPS) ?: "" val poolSize = intent.getIntExtra(EXTRA_POOL_SIZE, 4) - startProxy(port, ips, poolSize) + val cfEnabled = intent.getBooleanExtra(EXTRA_CFPROXY_ENABLED, true) + val cfPriority = intent.getBooleanExtra(EXTRA_CFPROXY_PRIORITY, true) + val cfDomain = intent.getStringExtra(EXTRA_CFPROXY_DOMAIN) ?: "" + val secretKey = intent.getStringExtra(EXTRA_SECRET_KEY) ?: "" + startProxy(port, ips, poolSize, cfEnabled, cfPriority, cfDomain, secretKey) } ACTION_STOP -> { stopProxy() @@ -55,7 +64,9 @@ class ProxyService : Service() { return START_STICKY } - private fun startProxy(port: Int, ips: String, poolSize: Int = 4) { + private fun startProxy(port: Int, ips: String, poolSize: Int = 4, + cfEnabled: Boolean = true, cfPriority: Boolean = true, + cfDomain: String = "", secretKey: String = "") { if (_isRunning.value) return val notification = createNotification("Запуск прокси...") @@ -69,6 +80,8 @@ class ProxyService : Service() { Thread { NativeProxy.setPoolSize(poolSize) + NativeProxy.setCfProxyConfig(cfEnabled, cfPriority, cfDomain) + NativeProxy.setSecret(secretKey) NativeProxy.startProxy("127.0.0.1", port, ips, 1) }.start() diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt new file mode 100644 index 0000000..957c7e7 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt @@ -0,0 +1,58 @@ +package com.amurcanov.tgwsproxy + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "proxy_settings") + +class SettingsStore(private val context: Context) { + + private object Keys { + val THEME_MODE = stringPreferencesKey("theme_mode") + val IS_DC_AUTO = booleanPreferencesKey("is_dc_auto") + val DC2 = stringPreferencesKey("dc2") + val DC4 = stringPreferencesKey("dc4") + val PORT = stringPreferencesKey("port") + val POOL_SIZE = intPreferencesKey("pool_size") + val CFPROXY_ENABLED = booleanPreferencesKey("cfproxy_enabled") + val SECRET_KEY = stringPreferencesKey("secret_key") + } + + val themeMode: Flow = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" } + val isDcAuto: Flow = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true } + val dc2: Flow = context.dataStore.data.map { it[Keys.DC2] ?: "" } + val dc4: Flow = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" } + val port: Flow = context.dataStore.data.map { it[Keys.PORT] ?: "1443" } + val poolSize: Flow = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 } + val cfproxyEnabled: Flow = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true } + val secretKey: Flow = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" } + + suspend fun saveSecretKey(key: String) { + context.dataStore.edit { it[Keys.SECRET_KEY] = key } + } + + suspend fun saveThemeMode(mode: String) { + context.dataStore.edit { it[Keys.THEME_MODE] = mode } + } + + suspend fun saveAll(isDcAuto: Boolean, dc2: String, dc4: String, port: String, poolSize: Int, + cfproxyEnabled: Boolean, secretKey: String) { + context.dataStore.edit { + it[Keys.IS_DC_AUTO] = isDcAuto + it[Keys.DC2] = dc2 + it[Keys.DC4] = dc4 + it[Keys.PORT] = port + it[Keys.POOL_SIZE] = poolSize + it[Keys.CFPROXY_ENABLED] = cfproxyEnabled + it[Keys.SECRET_KEY] = secretKey + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt new file mode 100644 index 0000000..ec9db17 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt @@ -0,0 +1,187 @@ +package com.amurcanov.tgwsproxy.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.amurcanov.tgwsproxy.R +import kotlin.math.roundToInt + +@Composable +fun FloatingToolbar( + currentTheme: String, + onThemeChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenHeightPx = remember(configuration.screenHeightDp, density) { + with(density) { configuration.screenHeightDp.dp.toPx() } + } + val screenWidthPx = remember(configuration.screenWidthDp, density) { + with(density) { configuration.screenWidthDp.dp.toPx() } + } + + var offsetY by rememberSaveable { mutableFloatStateOf(screenHeightPx * 0.25f) } + var isRightSide by rememberSaveable { mutableStateOf(true) } + var isExpanded by rememberSaveable { mutableStateOf(false) } + + val tabWidthDp = 42.dp + val tabHeightDp = 52.dp + val panelWidthDp = 180.dp + + val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } } + + val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f + + val animatedTabXPx by animateFloatAsState( + targetValue = targetXPx, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "tab_shift" + ) + + Box(modifier = modifier.fillMaxSize()) { + Surface( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier + .offset { IntOffset(animatedTabXPx.roundToInt(), offsetY.roundToInt()) } + .pointerInput(screenWidthPx, screenHeightPx) { + detectDragGestures( + onDrag = { change, dragAmount -> + change.consume() + offsetY = (offsetY + dragAmount.y).coerceIn(0f, screenHeightPx * 0.7f) + } + ) + }, + shape = if (isRightSide) + RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp) + else + RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), + shadowElevation = 6.dp, + tonalElevation = 4.dp, + ) { + Box( + modifier = Modifier.size(tabWidthDp, tabHeightDp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_palette), + contentDescription = "Тема", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.offset { + val panelWidthPx = with(density) { panelWidthDp.toPx() } + val gap = with(density) { 8.dp.toPx() } + val panelX = if (isRightSide) { + (targetXPx - panelWidthPx - gap).roundToInt() + } else { + (tabWidthPx + gap).roundToInt() + } + IntOffset(panelX, offsetY.roundToInt()) + } + ) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + "Тема", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + + ThemeOption( + icon = R.drawable.ic_auto, + label = "Системная", + selected = currentTheme == "system", + onClick = { onThemeChange("system"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_light_mode, + label = "Светлая", + selected = currentTheme == "light", + onClick = { onThemeChange("light"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_dark_mode, + label = "Тёмная", + selected = currentTheme == "dark", + onClick = { onThemeChange("dark"); isExpanded = false } + ) + } + } + } + } +} + +@Composable +private fun ThemeOption( + icon: Int, + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(14.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + fontSize = 13.sp + ) + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt new file mode 100644 index 0000000..41cb627 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt @@ -0,0 +1,322 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.NewReleases +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.amurcanov.tgwsproxy.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +private val BROWSER_PACKAGES = listOf( + "com.android.chrome", + "com.google.android.googlequicksearchbox", + "org.mozilla.firefox", + "com.yandex.browser", + "ru.yandex.searchplugin", + "com.yandex.browser.lite", + "com.opera.browser", + "com.opera.mini.native", + "com.microsoft.emmx", + "com.brave.browser", + "com.duckduckgo.mobile.android", + "com.sec.android.app.sbrowser", + "com.vivaldi.browser", + "com.kiwibrowser.browser", +) + +private fun openUrlInBrowser(context: Context, url: String) { + try { + val pm = context.packageManager + val uri = Uri.parse(url) + + for (pkg in BROWSER_PACKAGES) { + try { + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.setPackage(pkg) + if (intent.resolveActivity(pm) != null) { + context.startActivity(intent) + return + } + } catch (_: Exception) {} + } + val intent = Intent(Intent.ACTION_VIEW, uri) + if (intent.resolveActivity(pm) != null) { + context.startActivity(intent) + } + } catch (_: Exception) {} +} + +/** + * Sealed interface for update check result — prevents incorrect state combinations. + */ +private sealed interface UpdateCheckResult { + data object Idle : UpdateCheckResult + data object Loading : UpdateCheckResult + data class UpToDate(val version: String) : UpdateCheckResult + data class NewVersion(val version: String) : UpdateCheckResult + data class Error(val message: String) : UpdateCheckResult +} + +/** + * Fetch latest tag from GitHub releases via API. + * Uses redirect-following GET to the tags page on the GitHub API. + */ +private suspend fun checkLatestVersion(): String? = withContext(Dispatchers.IO) { + try { + // Use GitHub API to get latest release / tags + val url = URL("https://api.github.com/repos/amurcanov/tg-ws-proxy-android/tags?per_page=1") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("Accept", "application/vnd.github.v3+json") + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + + if (conn.responseCode == 200) { + val body = conn.inputStream.bufferedReader().readText() + // Parse the first tag name from JSON array: [{"name":"v1.0.6",...}] + val regex = """"name"\s*:\s*"([^"]+)"""".toRegex() + val match = regex.find(body) + match?.groupValues?.get(1) + } else { + null + } + } catch (_: Exception) { + null + } +} + +/** + * Compare two version strings like "v1.0.6" and "v1.0.7" + * Returns true if remote is strictly newer than local. + */ +private fun isNewerVersion(local: String, remote: String): Boolean { + val localParts = local.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + val remoteParts = remote.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() } + val maxLen = maxOf(localParts.size, remoteParts.size) + for (i in 0 until maxLen) { + val l = localParts.getOrElse(i) { 0 } + val r = remoteParts.getOrElse(i) { 0 } + if (r > l) return true + if (r < l) return false + } + return false +} + +@Composable +fun InfoTab() { + val currentVersion = "v1.0.6" + val scope = rememberCoroutineScope() + var updateResult by remember { mutableStateOf(UpdateCheckResult.Idle) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Дополнительная информация", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start + ) + + // ═══ Версия ═══ + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Установлена версия $currentVersion", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + // ═══ Проверить обновление ═══ + Button( + onClick = { + updateResult = UpdateCheckResult.Loading + scope.launch { + val latestTag = checkLatestVersion() + updateResult = if (latestTag != null) { + if (isNewerVersion(currentVersion, latestTag)) { + UpdateCheckResult.NewVersion(latestTag) + } else { + UpdateCheckResult.UpToDate(currentVersion) + } + } else { + UpdateCheckResult.Error("Не удалось проверить") + } + } + }, + modifier = Modifier.fillMaxWidth(0.9f).height(48.dp), + shape = RoundedCornerShape(16.dp), + enabled = updateResult !is UpdateCheckResult.Loading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + when (updateResult) { + is UpdateCheckResult.Loading -> { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(Modifier.width(8.dp)) + Text("Проверяем...", fontWeight = FontWeight.SemiBold) + } + is UpdateCheckResult.UpToDate -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppColors.terminalGreen + ) + Spacer(Modifier.width(8.dp)) + Text("Последняя версия ✓", fontWeight = FontWeight.SemiBold) + } + is UpdateCheckResult.NewVersion -> { + val ver = (updateResult as UpdateCheckResult.NewVersion).version + Icon( + Icons.Default.NewReleases, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = AppColors.terminalOrange + ) + Spacer(Modifier.width(8.dp)) + Text("Вышла $ver", fontWeight = FontWeight.Bold) + } + is UpdateCheckResult.Error -> { + Text("Проверить обновление", fontWeight = FontWeight.SemiBold) + } + is UpdateCheckResult.Idle -> { + Text("Проверить обновление", fontWeight = FontWeight.SemiBold) + } + } + } + } + } + + // ═══ GitHub ═══ + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + GitHubSection( + title = "Актуальные релизы", + buttonText = "tg-ws-proxy-android", + url = "https://github.com/amurcanov/tg-ws-proxy-android/releases" + ) + + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) + + GitHubSection( + title = "Страница разработчика", + buttonText = "GitHub Amurcanov", + url = "https://github.com/amurcanov" + ) + + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) + + GitHubSection( + title = "Если возникли проблемы", + buttonText = "Поднять вопрос", + url = "https://github.com/amurcanov/tg-ws-proxy-android/issues/new" + ) + } + } + + + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun GitHubSection( + title: String, + buttonText: String, + url: String +) { + val context = LocalContext.current + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + ) + Button( + onClick = { openUrlInBrowser(context, url) }, + modifier = Modifier.fillMaxWidth(0.9f).height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = buttonText, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp) + ) + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt new file mode 100644 index 0000000..0cd7eac --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt @@ -0,0 +1,149 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.core.* +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.LogEntry +import com.amurcanov.tgwsproxy.LogManager + +@Composable +fun LogsTab() { + val context = LocalContext.current + val currentLogs by LogManager.logs.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(currentLogs.size) { + if (currentLogs.isNotEmpty()) { + listState.scrollToItem(currentLogs.size - 1) + } + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Лог событий", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Row { + IconButton(onClick = { LogManager.clearLogs() }) { + Icon(Icons.Default.Delete, contentDescription = "Очистить", tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = { + val text = currentLogs.joinToString("\n") { "${it.message} (x${it.count})" } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("TgWsProxy Logs", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show() + }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = MaterialTheme.colorScheme.primary) + } + } + } + + val isDark = isSystemInDarkTheme() + val terminalBg = if (isDark) AppColors.terminalBgDark else AppColors.terminalBg + + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = terminalBg), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(12.dp), + contentPadding = PaddingValues(bottom = 12.dp) + ) { + items(currentLogs, key = { it.key }) { entry -> + LogLine(entry) + } + } + } + } +} + +@Composable +private fun LogLine(entry: LogEntry) { + val color = when (entry.priority) { + 6 -> AppColors.terminalRed // ERROR + 5 -> AppColors.terminalOrange // WARN (Нужно убедиться, что Orange есть в AppColors) + 4 -> AppColors.terminalGreen // INFO + 3 -> AppColors.terminalBlue // DEBUG + else -> AppColors.terminalText + } + + var trigger by remember { mutableIntStateOf(0) } + LaunchedEffect(entry.count) { trigger++ } + + val animatedScale by animateFloatAsState( + targetValue = if (trigger > 0) 1.15f else 1.0f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "scale", + finishedListener = { trigger = 0 } + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + color = AppColors.terminalCounter.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .defaultMinSize(minWidth = 24.dp, minHeight = 24.dp) + .graphicsLayer(scaleX = animatedScale, scaleY = animatedScale) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Text( + text = "${entry.count}", + color = AppColors.terminalBlue, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = entry.message, + color = color, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (entry.isError) FontWeight.Bold else FontWeight.Normal, + lineHeight = 18.sp, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt new file mode 100644 index 0000000..05bb1cc --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt @@ -0,0 +1,655 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.widget.Toast +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.ProxyService +import com.amurcanov.tgwsproxy.SettingsStore +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +val telegramApps = listOf( + "org.telegram.messenger", + "org.thunderdog.challegram", + "com.radolyn.ayugram", + "app.exteragram.messenger", + "ir.ilmili.telegraph", + "org.telegram.plus", + "tw.nekomimi.nekogram", + "tw.nekomimi.nekogramx", + "org.telegram.mdgram", + "com.iMe.android", + "app.nicegram", + "org.telegram.bgram", + "cc.modery.cherrygram", + "io.github.nextalone.nagram" +) + +private fun generateRandomSecret(): String { + val bytes = ByteArray(16) + java.security.SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } +} + +fun openTelegram(context: Context, url: String) { + val pm = context.packageManager + val uri = Uri.parse(url) + for (pkg in telegramApps) { + try { + pm.getPackageInfo(pkg, 0) + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.setPackage(pkg) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return + } catch (_: PackageManager.NameNotFoundException) { + } catch (_: Exception) { + } + } + try { + val fallbackIntent = Intent(Intent.ACTION_VIEW, uri) + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(fallbackIntent) + } catch (_: Exception) { + Toast.makeText(context, "Telegram не найден!", Toast.LENGTH_SHORT).show() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsTab(settingsStore: SettingsStore) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle() + + val savedIsDcAuto by settingsStore.isDcAuto.collectAsStateWithLifecycle(initialValue = true) + val savedDc2 by settingsStore.dc2.collectAsStateWithLifecycle(initialValue = "") + val savedDc4 by settingsStore.dc4.collectAsStateWithLifecycle(initialValue = "149.154.167.220") + val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443") + val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4) + val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true) + val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING") + + var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) } + var dc2Text by rememberSaveable(savedDc2) { mutableStateOf(savedDc2) } + var dc4Text by rememberSaveable(savedDc4) { mutableStateOf(savedDc4) } + var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) } + var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) } + var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) } + var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) } + + LaunchedEffect(savedSecretKey) { + if (savedSecretKey == "") { + val generated = generateRandomSecret() + secretKeyText = generated + settingsStore.saveSecretKey(generated) + } else if (savedSecretKey != "LOADING") { + secretKeyText = savedSecretKey + } + } + + var saveJob by remember { mutableStateOf(null) } + + fun scheduleSave() { + saveJob?.cancel() + saveJob = scope.launch { + delay(300) + settingsStore.saveAll( + isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize, + cfEnabled, secretKeyText + ) + } + } + + var showIpSetupDialog by rememberSaveable { mutableStateOf(false) } + var showHelpDialog by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberScrollState() + + if (showIpSetupDialog) { + IpSetupDialog( + isDcAuto = isDcAuto, + onModeChange = { isDcAuto = it; scheduleSave() }, + dc2Text = dc2Text, + onDc2Change = { dc2Text = it; scheduleSave() }, + dc4Text = dc4Text, + onDc4Change = { dc4Text = it; scheduleSave() }, + onDismiss = { showIpSetupDialog = false } + ) + } + + if (showHelpDialog) { + HelpDialog(onDismiss = { showHelpDialog = false }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + "Подключение", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + OutlinedTextField( + value = portText, + onValueChange = { portText = it; scheduleSave() }, + label = { Text("Порт") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth().height(60.dp), + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + OutlinedButton( + onClick = { showIpSetupDialog = true }, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + ) { + Icon(Icons.Default.Settings, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Настроить адреса DC", fontWeight = FontWeight.SemiBold) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Cloud, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + "CloudFlare", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + Switch( + checked = cfEnabled, + onCheckedChange = { cfEnabled = it; scheduleSave() }, + enabled = !isRunning + ) + } + Text( + if (cfEnabled) + "Трафик проксируется через CloudFlare — улучшает обход блокировок." + else + "Подключение к DC Telegram напрямую.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 16.sp + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + "Пул WS", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(4, 6, 8).forEach { size -> + PoolChip( + label = "$size", + selected = selectedPoolSize == size, + enabled = !isRunning, + modifier = Modifier.weight(1f) + ) { + selectedPoolSize = size + scheduleSave() + } + } + } + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.VpnKey, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + "Секретный ключ", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + OutlinedTextField( + value = secretKeyText, + onValueChange = {}, + readOnly = true, + singleLine = true, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(14.dp), + textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), + trailingIcon = { + IconButton( + onClick = { + val newKey = generateRandomSecret() + secretKeyText = newKey + scope.launch { settingsStore.saveSecretKey(newKey) } + scheduleSave() + }, + enabled = !isRunning + ) { + Icon(Icons.Default.Refresh, null, tint = MaterialTheme.colorScheme.primary) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + } + } + + Spacer(Modifier.height(4.dp)) + + val buttonColor by animateColorAsState( + targetValue = if (isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + animationSpec = tween(400), + label = "btn_color" + ) + Button( + onClick = { + if (isRunning) { + val stopIntent = Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_STOP + } + context.startService(stopIntent) + } else { + val port = portText.toIntOrNull() + if (port == null) { + Toast.makeText(context, "Неверный порт", Toast.LENGTH_SHORT).show() + return@Button + } + + val parsedIps = buildList { + if (!isDcAuto) { + if (dc2Text.isNotBlank()) add("2:${dc2Text.trim()}") + if (dc4Text.isNotBlank()) add("4:${dc4Text.trim()}") + } + }.joinToString(",") + + saveJob?.cancel() + scope.launch { + settingsStore.saveAll( + isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize, + cfEnabled, secretKeyText + ) + } + val startIntent = Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_START + putExtra(ProxyService.EXTRA_PORT, port) + putExtra(ProxyService.EXTRA_IPS, parsedIps) + putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize) + putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled) + // ProxyService intent expects these even if CF priority is disabled in UI + putExtra(ProxyService.EXTRA_CFPROXY_PRIORITY, true) + putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, "") + putExtra(ProxyService.EXTRA_SECRET_KEY, secretKeyText.trim()) + } + ContextCompat.startForegroundService(context, startIntent) + } + }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = buttonColor) + ) { + Icon( + imageVector = if (isRunning) Icons.Default.Stop else Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (isRunning) "Остановить" else "Запустить прокси", + fontWeight = FontWeight.Bold + ) + } + + val port = portText.toIntOrNull() ?: 1443 + val secretForUrl = remember(secretKeyText) { + val raw = secretKeyText.trim() + if (raw.isNotEmpty()) raw else "00000000000000000000000000000000" + } + val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=ee$secretForUrl" + val telegramBtnColor by animateColorAsState( + targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(400), + label = "tg_btn_color" + ) + Button( + onClick = { openTelegram(context, proxyUrl) }, + enabled = isRunning, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Text("Применить в Telegram", fontWeight = FontWeight.SemiBold) + } + + OutlinedButton( + onClick = { showHelpDialog = true }, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) + ) { + Text("Пожалуйста ознакомьтесь!", fontWeight = FontWeight.Medium) + } + + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun PoolChip( + label: String, + selected: Boolean, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(50), + modifier = modifier.height(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ) + } +} + +@Composable +private fun HelpDialog(onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier.fillMaxWidth(0.95f) + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + "Справка", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + HelpSection( + title = "Адреса датацентров", + text = "Внимание, рекомендую использовать при включенном CloudFlare - Автоматический режим получения DC от самого телеграма. " + + "В случае если вы не пользуетесь CloudFlare или он у вас не работает, переключитесь на ручное использование. " + + "По умолчанию указан DC4 149.154.167.220." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "Порт", + text = "Локальный порт прокси. Используйте свободный порт. По умолчанию — 1443." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "CloudFlare", + text = "Проксирует трафик через CloudFlare для обхода блокировок. Если Telegram не подключается — отключите." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "Пул WS", + text = "Количество фоновых соединений (по умолчанию 4). Увеличьте, если скорость низкая." + ) + + Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + HelpSection( + title = "Секретный ключ", + text = "Ключ шифрования MTProto. Обновляйте только при необходимости." + ) + + Spacer(Modifier.height(4.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text("Понятно", fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun HelpSection(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IpSetupDialog( + isDcAuto: Boolean, onModeChange: (Boolean) -> Unit, + dc2Text: String, onDc2Change: (String) -> Unit, + dc4Text: String, onDc4Change: (String) -> Unit, + onDismiss: () -> Unit +) { + val onIpChange = { newValue: String, update: (String) -> Unit -> + if (newValue.all { it.isDigit() || it == '.' }) { + update(newValue) + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier.fillMaxWidth(0.95f) + ) { + Column( + modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "Адреса датацентров", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isDcAuto) "Авто DC от Telegram" else "Ручные DC", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + Switch( + checked = isDcAuto, + onCheckedChange = { onModeChange(it) } + ) + } + } + + if (!isDcAuto) { + @Composable + fun dcInput(label: String, value: String, update: (String) -> Unit) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + OutlinedTextField( + value = value, + onValueChange = { onIpChange(it, update) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + } + + dcInput("DC2", dc2Text, onDc2Change) + dcInput("DC4", dc4Text, onDc4Change) + } + + Spacer(Modifier.height(4.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text("Готово", fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt new file mode 100644 index 0000000..f18b945 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt @@ -0,0 +1,173 @@ +package com.amurcanov.tgwsproxy.ui + +import android.os.Build +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.amurcanov.tgwsproxy.R + +// ═══ Inter Font Family ═══ +val InterFontFamily = FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), +) + +// ═══ Типография на Inter ═══ +val TgWsProxyTypography = Typography( + displayLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp), + displayMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp), + displaySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp), + headlineLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp), + headlineMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp), + headlineSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp), + titleLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), + titleMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp), + titleSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + bodyLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp), + bodyMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp), + bodySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp), + labelLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + labelMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), + labelSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), +) + +// ═══ Светлая палитра — «Раф на кокосовом молоке» ═══ +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6D4C41), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD7CCC8), + onPrimaryContainer = Color(0xFF3E2723), + secondary = Color(0xFF8D6E63), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFEFEBE9), + onSecondaryContainer = Color(0xFF4E342E), + tertiary = Color(0xFF795548), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCAAA4), + onTertiaryContainer = Color(0xFF3E2723), + background = Color(0xFFFFFBF7), + onBackground = Color(0xFF1C1B1A), + surface = Color(0xFFF5F0EB), + onSurface = Color(0xFF1C1B1A), + surfaceVariant = Color(0xFFEFEBE9), + onSurfaceVariant = Color(0xFF5D4037), + outline = Color(0xFFBCAAA4), + outlineVariant = Color(0xFFD7CCC8), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + inverseSurface = Color(0xFF322F2D), + inverseOnSurface = Color(0xFFF5F0EB), + inversePrimary = Color(0xFFD7CCC8), + surfaceTint = Color(0xFF6D4C41), +) + +// ═══ Тёмная палитра — «Эспрессо» ═══ +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFD7CCC8), + onPrimary = Color(0xFF3E2723), + primaryContainer = Color(0xFF5D4037), + onPrimaryContainer = Color(0xFFEFEBE9), + secondary = Color(0xFFBCAAA4), + onSecondary = Color(0xFF3E2723), + secondaryContainer = Color(0xFF4E342E), + onSecondaryContainer = Color(0xFFEFEBE9), + tertiary = Color(0xFFA1887F), + onTertiary = Color(0xFF3E2723), + tertiaryContainer = Color(0xFF5D4037), + onTertiaryContainer = Color(0xFFEFEBE9), + background = Color(0xFF1A1614), + onBackground = Color(0xFFEDE0D4), + surface = Color(0xFF211D1B), + onSurface = Color(0xFFEDE0D4), + surfaceVariant = Color(0xFF2C2624), + onSurfaceVariant = Color(0xFFD7CCC8), + outline = Color(0xFF8D6E63), + outlineVariant = Color(0xFF4E342E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + inverseSurface = Color(0xFFEDE0D4), + inverseOnSurface = Color(0xFF322F2D), + inversePrimary = Color(0xFF6D4C41), + surfaceTint = Color(0xFFD7CCC8), +) + +// ═══ Расширенные цвета для кастомных элементов ═══ +object AppColors { + val connected = Color(0xFF4CAF50) + val connectedContainer = Color(0xFF4CAF50).copy(alpha = 0.12f) + val onConnected = Color(0xFF1B5E20) + + val connectedDark = Color(0xFF81C784) + val connectedContainerDark = Color(0xFF81C784).copy(alpha = 0.15f) + val onConnectedDark = Color(0xFFC8E6C9) + + val warning = Color(0xFFFFA726) + val warningDark = Color(0xFFFFCC80) + + val terminalBg = Color(0xFF1A1A2E) + val terminalBgDark = Color(0xFF0D0D1A) + val terminalText = Color(0xFFE0E0E0) + val terminalGreen = Color(0xFF4CAF50) + val terminalBlue = Color(0xFF42A5F5) + val terminalRed = Color(0xFFEF5350) + val terminalOrange = Color(0xFFFF7043) + val terminalYellow = Color(0xFFFFC107) + val terminalCounter = Color(0xFF1E88E5) + + val github = Color(0xFF24292E) + val githubDark = Color(0xFF333C47) + + val donate = Color(0xFF8B3FFD) +} + +@Composable +fun TgWsProxyTheme( + themeMode: String = "system", + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val darkTheme = when (themeMode) { + "dark" -> true + "light" -> false + else -> isSystemInDarkTheme() + } + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = TgWsProxyTypography, + content = content + ) +} diff --git a/app/src/main/res/drawable/ic_auto.xml b/app/src/main/res/drawable/ic_auto.xml new file mode 100644 index 0000000..2af76ea --- /dev/null +++ b/app/src/main/res/drawable/ic_auto.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_da.xml b/app/src/main/res/drawable/ic_da.xml new file mode 100644 index 0000000..b5f0bf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_da.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_da_black.xml b/app/src/main/res/drawable/ic_da_black.xml new file mode 100644 index 0000000..e276f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_da_black.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml new file mode 100644 index 0000000..5ada9d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..63d5961 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..eb62b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_light_mode.xml b/app/src/main/res/drawable/ic_light_mode.xml new file mode 100644 index 0000000..0c9f22a --- /dev/null +++ b/app/src/main/res/drawable/ic_light_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml new file mode 100644 index 0000000..210ea90 --- /dev/null +++ b/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..6ccdf88 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_yoomoney.xml b/app/src/main/res/drawable/ic_yoomoney.xml new file mode 100644 index 0000000..ff6dd45 --- /dev/null +++ b/app/src/main/res/drawable/ic_yoomoney.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/font/inter_bold.ttf b/app/src/main/res/font/inter_bold.ttf new file mode 100644 index 0000000..7e1deec Binary files /dev/null and b/app/src/main/res/font/inter_bold.ttf differ diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 0000000..7e573f6 Binary files /dev/null and b/app/src/main/res/font/inter_medium.ttf differ diff --git a/app/src/main/res/font/inter_regular.ttf b/app/src/main/res/font/inter_regular.ttf new file mode 100644 index 0000000..012d1b4 Binary files /dev/null and b/app/src/main/res/font/inter_regular.ttf differ diff --git a/app/src/main/res/font/inter_semibold.ttf b/app/src/main/res/font/inter_semibold.ttf new file mode 100644 index 0000000..4be5439 Binary files /dev/null and b/app/src/main/res/font/inter_semibold.ttf differ diff --git a/build.gradle.kts b/build.gradle.kts index b59bc38..2a0b6e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id("com.android.application") version "8.2.2" apply false + id("com.android.application") version "8.5.2" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false } diff --git a/go.mod b/go.mod index 763992b..1ea2f1d 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ module tg-ws-proxy go 1.25 - -require ( - golang.org/x/crypto v0.31.0 -) \ No newline at end of file diff --git a/tg-ws-proxy.go b/tg-ws-proxy.go index e6c354f..f47c0f3 100644 --- a/tg-ws-proxy.go +++ b/tg-ws-proxy.go @@ -21,9 +21,12 @@ import ( "context" "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "crypto/tls" "encoding/base64" + "encoding/hex" "encoding/binary" "fmt" "io" @@ -47,7 +50,7 @@ import ( // --------------------------------------------------------------------------- const ( - defaultPort = 1080 + defaultPort = 1443 tcpNodelay = true defaultRecvBuf = 256 * 1024 defaultSendBuf = 256 * 1024 @@ -55,7 +58,7 @@ const ( wsPoolMaxAge = 60.0 wsBridgeIdle = 120.0 - dcFailCooldown = 30.0 + dcFailCooldown = 10.0 wsFailTimeout = 2.0 poolMaintainInterval = 15 ) @@ -67,6 +70,31 @@ var ( logVerbose = false ) +// Cloudflare proxy config +var ( + cfproxyEnabled = true + cfproxyPriority = true + cfproxyUserDomain = "" + cfproxyDomains []string + activeCfDomain string + cfproxyMu sync.RWMutex +) + +// MTProto proxy secret (hex, 32 chars = 16 bytes) +var ( + proxySecret = "00000000000000000000000000000000" + proxySecretMu sync.RWMutex +) + +var dcDefaultIPs = map[int]string{ + 1: "149.154.175.50", + 2: "149.154.167.51", + 3: "149.154.175.100", + 4: "149.154.167.91", + 5: "149.154.171.5", + 203: "91.105.192.100", +} + // --------------------------------------------------------------------------- // Logger // --------------------------------------------------------------------------- @@ -89,18 +117,66 @@ func (w androidLogWriter) Write(p []byte) (n int, err error) { } func initLogging(verbose bool) { - flags := log.Ltime + flags := 0 out := androidLogWriter{} - logInfo = log.New(out, "INFO ", flags) - logWarn = log.New(out, "WARN ", flags) - logError = log.New(out, "ERROR ", flags) + logInfo = log.New(out, "", flags) + logWarn = log.New(out, "[WARN] ", flags) + logError = log.New(out, "[ERROR] ", flags) if verbose { - logDebug = log.New(out, "DEBUG ", flags) + logDebug = log.New(out, "[DEBUG] ", flags) } else { logDebug = log.New(io.Discard, "", 0) } } +// --------------------------------------------------------------------------- +// Cloudflare proxy domain decoding +// --------------------------------------------------------------------------- + +var cfproxyEnc = []string{"virkgj.com", "vmmzovy.com", "mkuosckvso.com", "zaewayzmplad.com", "twdmbzcm.com"} + +func decodeCfDomain(s string) string { + if !strings.HasSuffix(s, ".com") { + return s + } + suffix := string([]byte{46, 99, 111, 46, 117, 107}) // decoded suffix + p := s[:len(s)-4] + n := 0 + for _, c := range p { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + n++ + } + } + var result []byte + for _, c := range []byte(p) { + if c >= 'a' && c <= 'z' { + result = append(result, byte((int(c-'a')-n%26+26)%26+'a')) + } else if c >= 'A' && c <= 'Z' { + result = append(result, byte((int(c-'A')-n%26+26)%26+'A')) + } else { + result = append(result, c) + } + } + return string(result) + suffix +} + +func initCfproxyDomains() { + cfproxyMu.Lock() + defer cfproxyMu.Unlock() + if cfproxyUserDomain != "" { + cfproxyDomains = []string{cfproxyUserDomain} + activeCfDomain = cfproxyUserDomain + return + } + cfproxyDomains = make([]string, len(cfproxyEnc)) + for i, enc := range cfproxyEnc { + cfproxyDomains[i] = decodeCfDomain(enc) + } + if len(cfproxyDomains) > 0 { + activeCfDomain = cfproxyDomains[0] + } +} + // --------------------------------------------------------------------------- // Telegram IP ranges // --------------------------------------------------------------------------- @@ -222,10 +298,13 @@ var ( type Stats struct { connectionsTotal atomic.Int64 + connectionsActive atomic.Int64 connectionsWs atomic.Int64 connectionsTcpFallback atomic.Int64 + connectionsCfproxy atomic.Int64 connectionsHttpReject atomic.Int64 connectionsPassthrough atomic.Int64 + connectionsBad atomic.Int64 wsErrors atomic.Int64 bytesUp atomic.Int64 bytesDown atomic.Int64 @@ -237,12 +316,13 @@ func (s *Stats) Summary() string { ph := s.poolHits.Load() pm := s.poolMisses.Load() return fmt.Sprintf( - "total=%d ws=%d tcp_fb=%d http_skip=%d pass=%d err=%d pool=%d/%d up=%s down=%s", + "total=%d active=%d ws=%d tcp_fb=%d cf=%d bad=%d err=%d pool=%d/%d up=%s down=%s", s.connectionsTotal.Load(), + s.connectionsActive.Load(), s.connectionsWs.Load(), s.connectionsTcpFallback.Load(), - s.connectionsHttpReject.Load(), - s.connectionsPassthrough.Load(), + s.connectionsCfproxy.Load(), + s.connectionsBad.Load(), s.wsErrors.Load(), ph, ph+pm, humanBytes(s.bytesUp.Load()), @@ -250,12 +330,41 @@ func (s *Stats) Summary() string { ) } +func (s *Stats) SummaryRu() string { + active := s.connectionsActive.Load() + ws := s.connectionsWs.Load() + cf := s.connectionsCfproxy.Load() + tcp := s.connectionsTcpFallback.Load() + errCount := s.wsErrors.Load() + up := humanBytes(s.bytesUp.Load()) + down := humanBytes(s.bytesDown.Load()) + + parts := []string{fmt.Sprintf("акт:%d", active)} + if ws > 0 { + parts = append(parts, fmt.Sprintf("ws:%d", ws)) + } + if cf > 0 { + parts = append(parts, fmt.Sprintf("cf:%d", cf)) + } + if tcp > 0 { + parts = append(parts, fmt.Sprintf("tcp:%d", tcp)) + } + if errCount > 0 { + parts = append(parts, fmt.Sprintf("ош:%d", errCount)) + } + parts = append(parts, fmt.Sprintf("↑%s ↓%s", up, down)) + return strings.Join(parts, " | ") +} + func (s *Stats) Reset() { s.connectionsTotal.Store(0) + s.connectionsActive.Store(0) s.connectionsWs.Store(0) s.connectionsTcpFallback.Store(0) + s.connectionsCfproxy.Store(0) s.connectionsHttpReject.Store(0) s.connectionsPassthrough.Store(0) + s.connectionsBad.Store(0) s.wsErrors.Store(0) s.bytesUp.Store(0) s.bytesDown.Store(0) @@ -739,15 +848,15 @@ func newAESCTR(key, iv []byte) (cipher.Stream, error) { return cipher.NewCTR(block, iv), nil } -func dcFromInit(data []byte) (dc int, isMedia bool, ok bool) { +func dcFromInit(data []byte) (dc int, isMedia bool, proto uint32, ok bool) { if len(data) < 64 { - return 0, false, false + return 0, false, 0, false } stream, err := newAESCTR(data[8:40], data[40:56]) if err != nil { logDebug.Printf("DC extraction failed: %v", err) - return 0, false, false + return 0, false, 0, false } keystream := make([]byte, 64) @@ -758,13 +867,13 @@ func dcFromInit(data []byte) (dc int, isMedia bool, ok bool) { plain[i] = data[56+i] ^ keystream[56+i] } - proto := binary.LittleEndian.Uint32(plain[0:4]) + proto = binary.LittleEndian.Uint32(plain[0:4]) dcRaw := int16(binary.LittleEndian.Uint16(plain[4:6])) logDebug.Printf("dc_from_init: proto=0x%08X dc_raw=%d plain=%x", proto, dcRaw, plain) if !validProtos[proto] { - return 0, false, false + return 0, false, 0, false } dcAbs := int(dcRaw) @@ -774,10 +883,10 @@ func dcFromInit(data []byte) (dc int, isMedia bool, ok bool) { media := dcRaw < 0 if (dcAbs >= 1 && dcAbs <= 5) || dcAbs == 203 { - return dcAbs, media, true + return dcAbs, media, proto, true } - return 0, false, false + return 0, false, 0, false } func patchInitDC(data []byte, dc int) []byte { @@ -809,11 +918,32 @@ func patchInitDC(data []byte, dc int) []byte { // MsgSplitter // --------------------------------------------------------------------------- +const ( + protoAbridged = 0 // 0xEFEFEFEF + protoIntermediate = 1 // 0xEEEEEEEE + protoPaddedIntermediate = 2 // 0xDDDDDDDD +) + type MsgSplitter struct { - stream cipher.Stream + stream cipher.Stream + protoType int + cipherBuf []byte // accumulates raw ciphertext across calls + plainBuf []byte // accumulates decrypted plaintext across calls + disabled bool } -func newMsgSplitter(initData []byte) (*MsgSplitter, error) { +func protoTagToType(proto uint32) int { + switch proto { + case 0xEEEEEEEE: + return protoIntermediate + case 0xDDDDDDDD: + return protoPaddedIntermediate + default: + return protoAbridged + } +} + +func newMsgSplitter(initData []byte, proto uint32) (*MsgSplitter, error) { if len(initData) < 56 { return nil, fmt.Errorf("init data too short") } @@ -824,54 +954,121 @@ func newMsgSplitter(initData []byte) (*MsgSplitter, error) { skip := make([]byte, 64) stream.XORKeyStream(skip, zero64) - return &MsgSplitter{stream: stream}, nil + return &MsgSplitter{ + stream: stream, + protoType: protoTagToType(proto), + }, nil } func (s *MsgSplitter) Split(chunk []byte) [][]byte { - plain := make([]byte, len(chunk)) - s.stream.XORKeyStream(plain, chunk) - - var boundaries []int - pos := 0 - plainLen := len(plain) - - for pos < plainLen { - first := plain[pos] - var msgLen int - if first == 0x7f { - if pos+4 > plainLen { - break - } - msgLen = int(uint32(plain[pos+1]) | uint32(plain[pos+2])<<8 | uint32(plain[pos+3])<<16) - msgLen *= 4 - pos += 4 - } else { - msgLen = int(first) * 4 - pos++ - } - if msgLen == 0 || pos+msgLen > plainLen { - break - } - pos += msgLen - boundaries = append(boundaries, pos) + if len(chunk) == 0 { + return nil } - - if len(boundaries) <= 1 { + if s.disabled { return [][]byte{chunk} } - parts := make([][]byte, 0, len(boundaries)+1) - prev := 0 - for _, b := range boundaries { - parts = append(parts, chunk[prev:b]) - prev = b + // Accumulate ciphertext and decrypt the new chunk + s.cipherBuf = append(s.cipherBuf, chunk...) + decrypted := make([]byte, len(chunk)) + s.stream.XORKeyStream(decrypted, chunk) + s.plainBuf = append(s.plainBuf, decrypted...) + + var parts [][]byte + for len(s.cipherBuf) > 0 { + pktLen := s.nextPacketLen() + if pktLen < 0 { + // need more data + break + } + if pktLen == 0 { + // unknown protocol — pass through remainder and disable + parts = append(parts, append([]byte(nil), s.cipherBuf...)) + s.cipherBuf = s.cipherBuf[:0] + s.plainBuf = s.plainBuf[:0] + s.disabled = true + break + } + if len(s.cipherBuf) < pktLen { + break // incomplete packet, wait for more data + } + parts = append(parts, append([]byte(nil), s.cipherBuf[:pktLen]...)) + s.cipherBuf = s.cipherBuf[pktLen:] + s.plainBuf = s.plainBuf[pktLen:] } - if prev < len(chunk) { - parts = append(parts, chunk[prev:]) + if len(parts) == 0 { + return nil // all buffered, nothing complete yet } return parts } +func (s *MsgSplitter) Flush() [][]byte { + if len(s.cipherBuf) == 0 { + return nil + } + tail := append([]byte(nil), s.cipherBuf...) + s.cipherBuf = s.cipherBuf[:0] + s.plainBuf = s.plainBuf[:0] + return [][]byte{tail} +} + +// nextPacketLen returns: +// >0 total bytes for the next complete packet (header + payload) +// 0 unknown protocol (disable splitter) +// -1 need more data +func (s *MsgSplitter) nextPacketLen() int { + if len(s.plainBuf) == 0 { + return -1 + } + switch s.protoType { + case protoAbridged: + return s.nextAbridgedLen() + case protoIntermediate, protoPaddedIntermediate: + return s.nextIntermediateLen() + default: + return 0 + } +} + +func (s *MsgSplitter) nextAbridgedLen() int { + first := s.plainBuf[0] & 0x7F + var headerLen, payloadLen int + if first == 0x7F { + // Long header: 1 byte (0x7F) + 3 bytes LE length in 4-byte words + if len(s.plainBuf) < 4 { + return -1 + } + payloadLen = int(uint32(s.plainBuf[1]) | uint32(s.plainBuf[2])<<8 | uint32(s.plainBuf[3])<<16) * 4 + headerLen = 4 + } else { + payloadLen = int(first) * 4 + headerLen = 1 + } + if payloadLen <= 0 { + return 0 + } + pktLen := headerLen + payloadLen + if len(s.plainBuf) < pktLen { + return -1 + } + return pktLen +} + +func (s *MsgSplitter) nextIntermediateLen() int { + if len(s.plainBuf) < 4 { + return -1 + } + payloadLen := int(binary.LittleEndian.Uint32(s.plainBuf[:4]) & 0x7FFFFFFF) + if payloadLen <= 0 { + return 0 + } + pktLen := 4 + payloadLen + if len(s.plainBuf) < pktLen { + return -1 + } + return pktLen +} + // --------------------------------------------------------------------------- // WS domains // --------------------------------------------------------------------------- @@ -947,7 +1144,7 @@ func (p *WsPool) Get(dc int, isMedia bool, targetIP string, domains []string) *R } stats.poolHits.Add(1) - logDebug.Printf("WS pool hit for DC%d%s (age=%.1fs, left=%d)", + logDebug.Printf("⚡ Пул: DC%d%s взят (%.0fс, ост:%d)", dc, mediaTag(isMedia), age, len(bucket)) p.scheduleRefillLocked(key, targetIP, domains) return entry.ws @@ -1008,14 +1205,14 @@ func (p *WsPool) refill(key [2]int, targetIP string, domains []string) { } p.mu.Lock() - logDebug.Printf("WS pool refilled DC%d%s: %d ready", + logDebug.Printf("♻ Пул DC%d%s пополнен: %d готово", dc, mediaTag(isMedia), len(p.idle[key])) p.mu.Unlock() } func connectOneWS(targetIP string, domains []string) *RawWebSocket { for _, domain := range domains { - ws, err := wsConnect(targetIP, domain, "/apiws", 8) + ws, err := wsConnect(targetIP, domain, "/apiws", 5) if err != nil { if wsErr, ok := err.(*WsHandshakeError); ok && wsErr.IsRedirect() { continue @@ -1041,7 +1238,7 @@ func (p *WsPool) Warmup(dcOptMap map[int]string) { p.scheduleRefillLocked(key, targetIP, domains) } } - logInfo.Printf("WS pool warmup started for %d DC(s)", len(dcOptMap)) + logDebug.Printf("♻ Прогрев пула: %d DC", len(dcOptMap)) } func (p *WsPool) Maintain(ctx context.Context, dcOptMap map[int]string) { @@ -1126,11 +1323,18 @@ var wsPool = newWsPool() func mediaTag(isMedia bool) string { if isMedia { - return "m" + return "ᵐ" } return "" } +func mediaLabel(isMedia bool) string { + if isMedia { + return "медиа" + } + return "основной" +} + // --------------------------------------------------------------------------- // HTTP detection // --------------------------------------------------------------------------- @@ -1169,12 +1373,10 @@ func socks5Reply(status byte) []byte { func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, label string, dc int, dst string, port int, isMedia bool, - splitter *MsgSplitter) { + splitter *MsgSplitter, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) { dcTag := fmt.Sprintf("DC%d%s", dc, mediaTag(isMedia)) - dstTag := fmt.Sprintf("%s:%d", dst, port) - - var upBytes, downBytes, upPkts, downPkts int64 + var upBytes, downBytes int64 startTime := time.Now() ctx2, cancel := context.WithCancel(ctx) @@ -1201,16 +1403,18 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, chunk := buf[:n] stats.bytesUp.Add(int64(n)) upBytes += int64(n) - upPkts++ + cltDec.XORKeyStream(chunk, chunk) + tgEnc.XORKeyStream(chunk, chunk) var sendErr error if splitter != nil { parts := splitter.Split(chunk) if len(parts) > 1 { sendErr = ws.SendBatch(parts) - } else { + } else if len(parts) == 1 { sendErr = ws.Send(parts[0]) } + // len(parts) == 0 means data is buffered, waiting for more } else { sendErr = ws.Send(chunk) } @@ -1236,7 +1440,8 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, n := len(data) stats.bytesDown.Add(int64(n)) downBytes += int64(n) - downPkts++ + tgDec.XORKeyStream(data, data) + cltEnc.XORKeyStream(data, data) if _, err := conn.Write(data); err != nil { return } @@ -1246,11 +1451,12 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, wg.Wait() elapsed := time.Since(startTime).Seconds() - logInfo.Printf("[%s] %s (%s) WS session closed: ^%s (%d pkts) v%s (%d pkts) in %.1fs", - label, dcTag, dstTag, - humanBytes(upBytes), upPkts, - humanBytes(downBytes), downPkts, - elapsed) + if upBytes > 0 || downBytes > 0 { + logInfo.Printf("✕ %s ↑%s ↓%s %.1fс", + dcTag, humanBytes(upBytes), humanBytes(downBytes), elapsed) + } else { + logDebug.Printf("✕ %s пустое (%.1fс)", dcTag, elapsed) + } } // --------------------------------------------------------------------------- @@ -1258,7 +1464,7 @@ func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, // --------------------------------------------------------------------------- func bridgeTCP(ctx context.Context, client, remote net.Conn, - label string, dc int, dst string, port int, isMedia bool) { + label string, dc int, dst string, port int, isMedia bool, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) { ctx2, cancel := context.WithCancel(ctx) @@ -1281,8 +1487,12 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn, if n > 0 { if isUp { stats.bytesUp.Add(int64(n)) + cltDec.XORKeyStream(buf[:n], buf[:n]) + tgEnc.XORKeyStream(buf[:n], buf[:n]) } else { stats.bytesDown.Add(int64(n)) + tgDec.XORKeyStream(buf[:n], buf[:n]) + cltEnc.XORKeyStream(buf[:n], buf[:n]) } if _, werr := dstW.Write(buf[:n]); werr != nil { return @@ -1305,22 +1515,157 @@ func bridgeTCP(ctx context.Context, client, remote net.Conn, // --------------------------------------------------------------------------- func tcpFallback(ctx context.Context, client net.Conn, dst string, port int, - init []byte, label string, dc int, isMedia bool) bool { + init []byte, label string, dc int, isMedia bool, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { dialer := &net.Dialer{Timeout: 10 * time.Second} remote, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", dst, port)) if err != nil { - logWarn.Printf("[%s] TCP fallback connect to %s:%d failed: %v", - label, dst, port, err) + logWarn.Printf("⚠ DC%d TCP→%s не удался", dc, dst) + logDebug.Printf("TCP fallback error [%s] %s:%d: %v", label, dst, port, err) return false } stats.connectionsTcpFallback.Add(1) + logInfo.Printf("🔄 DC%d%s подключен по TCP", dc, mediaTag(isMedia)) _, _ = remote.Write(init) - bridgeTCP(ctx, client, remote, label, dc, dst, port, isMedia) + bridgeTCP(ctx, client, remote, label, dc, dst, port, isMedia, cltDec, cltEnc, tgEnc, tgDec) return true } +// --------------------------------------------------------------------------- +// Cloudflare proxy fallback +// --------------------------------------------------------------------------- + +func cfproxyFallback(ctx context.Context, conn net.Conn, relayInit []byte, label string, + dc int, isMedia bool, splitter *MsgSplitter, + cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + + cfproxyMu.RLock() + if !cfproxyEnabled || len(cfproxyDomains) == 0 { + cfproxyMu.RUnlock() + return false + } + active := activeCfDomain + domains := make([]string, len(cfproxyDomains)) + copy(domains, cfproxyDomains) + cfproxyMu.RUnlock() + + ordered := []string{active} + for _, d := range domains { + if d != active { + ordered = append(ordered, d) + } + } + + mTag := mediaTag(isMedia) + logDebug.Printf("☁ DC%d%s → пробуем CF", dc, mTag) + + type wsResult struct { + ws *RawWebSocket + domain string + } + ch := make(chan wsResult, len(ordered)) + for _, baseDomain := range ordered { + go func(bd string) { + domain := fmt.Sprintf("kws%d.%s", dc, bd) + ws, err := wsConnect(domain, domain, "/apiws", 5) + if err != nil { + logDebug.Printf("☁ DC%d%s CF %s ✗: %v", dc, mTag, domain, err) + ch <- wsResult{nil, ""} + return + } + ch <- wsResult{ws, bd} + }(baseDomain) + } + + var ws *RawWebSocket + var chosenDomain string + for i := 0; i < len(ordered); i++ { + r := <-ch + if r.ws != nil && ws == nil { + ws = r.ws + chosenDomain = r.domain + } else if r.ws != nil { + go r.ws.Close() + } + } + + if ws == nil { + return false + } + + if chosenDomain != "" && chosenDomain != active { + cfproxyMu.Lock() + activeCfDomain = chosenDomain + cfproxyMu.Unlock() + logInfo.Printf("☁ CF домен → %s", chosenDomain) + } + + stats.connectionsCfproxy.Add(1) + logInfo.Printf("☁ DC%d%s подключен через CF", dc, mTag) + + if err := ws.Send(relayInit); err != nil { + ws.Close() + return false + } + + bridgeWS(ctx, conn, ws, label, dc, chosenDomain, 443, isMedia, splitter, cltDec, cltEnc, tgEnc, tgDec) + return true +} + +// --------------------------------------------------------------------------- +// Unified fallback (CF + TCP) +// --------------------------------------------------------------------------- + +func doFallback(ctx context.Context, conn net.Conn, relayInit []byte, label string, + dc int, isMedia bool, splitter *MsgSplitter, + cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + + // Use configured DC IP if available, otherwise fall back to defaults + var fallbackDst string + dcOptMu.RLock() + if ip, ok := dcOpt[dc]; ok && ip != "" { + fallbackDst = ip + } + dcOptMu.RUnlock() + if fallbackDst == "" { + fallbackDst = dcDefaultIPs[dc] + } + + cfproxyMu.RLock() + useCf := cfproxyEnabled + cfproxyMu.RUnlock() + + mTag := mediaTag(isMedia) + + type fbMethod string + var methods []fbMethod + if useCf { + methods = []fbMethod{"cf"} + } else { + methods = []fbMethod{"tcp"} + } + + for _, m := range methods { + switch m { + case "cf": + if cfproxyFallback(ctx, conn, relayInit, label, dc, isMedia, splitter, cltDec, cltEnc, tgEnc, tgDec) { + return true + } + case "tcp": + if fallbackDst != "" { + logDebug.Printf("🔄 DC%d%s → TCP %s:443", dc, mTag, fallbackDst) + if tcpFallback(ctx, conn, fallbackDst, 443, relayInit, label, dc, isMedia, cltDec, cltEnc, tgEnc, tgDec) { + return true + } + } + } + } + + logWarn.Printf("⚠ DC%d%s нет доступных маршрутов", dc, mTag) + return false +} + // --------------------------------------------------------------------------- // Pipe (non-Telegram passthrough) // --------------------------------------------------------------------------- @@ -1347,295 +1692,526 @@ func pipe(ctx context.Context, src, dst net.Conn, done chan<- struct{}) { } // --------------------------------------------------------------------------- -// SOCKS5 client handler +// Fake TLS support (ee-secret) // --------------------------------------------------------------------------- -func readExactly(conn net.Conn, n int, timeout time.Duration) ([]byte, error) { - if timeout > 0 { - _ = conn.SetReadDeadline(time.Now().Add(timeout)) - defer func() { _ = conn.SetReadDeadline(time.Time{}) }() +const ( + tlsRecordHandshake = 0x16 + tlsRecordCCS = 0x14 + tlsRecordAppData = 0x17 + tlsAppDataMax = 16384 + clientRandomOffset = 11 + clientRandomLen = 32 + sessionIdOffset = 44 + sessionIdLen = 32 + timestampTolerance = 120 +) + +// verifyClientHello checks whether the incoming ClientHello has a valid +// HMAC-SHA256 computed over the hello with the random field zeroed, +// using `secret` as key. Returns (clientRandom, sessionId, ok). +func verifyClientHello(data, secret []byte) ([]byte, []byte, bool) { + n := len(data) + if n < 43 { + return nil, nil, false } - buf := make([]byte, n) - _, err := io.ReadFull(conn, buf) - return buf, err + if data[0] != tlsRecordHandshake { + return nil, nil, false + } + if data[5] != 0x01 { + return nil, nil, false + } + + clientRandom := make([]byte, clientRandomLen) + copy(clientRandom, data[clientRandomOffset:clientRandomOffset+clientRandomLen]) + + zeroed := make([]byte, n) + copy(zeroed, data) + for i := 0; i < clientRandomLen; i++ { + zeroed[clientRandomOffset+i] = 0 + } + + mac := hmacSHA256(secret, zeroed) + + for i := 0; i < 28; i++ { + if mac[i] != clientRandom[i] { + return nil, nil, false + } + } + + // Check timestamp + tsXor := make([]byte, 4) + for i := 0; i < 4; i++ { + tsXor[i] = clientRandom[28+i] ^ mac[28+i] + } + timestamp := binary.LittleEndian.Uint32(tsXor) + now := uint32(time.Now().Unix()) + diff := int64(now) - int64(timestamp) + if diff < 0 { + diff = -diff + } + if diff > timestampTolerance { + return nil, nil, false + } + + sessionId := make([]byte, sessionIdLen) + if n >= sessionIdOffset+sessionIdLen && data[43] == 0x20 { + copy(sessionId, data[sessionIdOffset:sessionIdOffset+sessionIdLen]) + } + + return clientRandom, sessionId, true +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +var serverHelloTemplate = []byte{ + 0x16, 0x03, 0x03, 0x00, 0x7a, + 0x02, 0x00, 0x00, 0x76, + 0x03, 0x03, + // 32 bytes server random (offset 11) + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x20, + // 32 bytes session id (offset 44) + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x13, 0x01, 0x00, + 0x00, 0x2e, + 0x00, 0x33, 0x00, 0x24, 0x00, 0x1d, 0x00, 0x20, + // 32 bytes public key (offset 89) + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x00, 0x2b, 0x00, 0x02, 0x03, 0x04, +} + +const ( + shRandomOff = 11 + shSessIdOff = 44 + shPubKeyOff = 89 +) + +func buildServerHello(secret, clientRandom, sessionId []byte) []byte { + sh := make([]byte, len(serverHelloTemplate)) + copy(sh, serverHelloTemplate) + + copy(sh[shSessIdOff:shSessIdOff+32], sessionId) + + pubKey := make([]byte, 32) + rand.Read(pubKey) + copy(sh[shPubKeyOff:shPubKeyOff+32], pubKey) + + ccsFrame := []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01} + + encSize := 1900 + int(time.Now().UnixNano()%200) + encData := make([]byte, encSize) + rand.Read(encData) + appRecord := make([]byte, 5+encSize) + appRecord[0] = 0x17 + appRecord[1] = 0x03 + appRecord[2] = 0x03 + binary.BigEndian.PutUint16(appRecord[3:5], uint16(encSize)) + copy(appRecord[5:], encData) + + response := make([]byte, 0, len(sh)+len(ccsFrame)+len(appRecord)) + response = append(response, sh...) + response = append(response, ccsFrame...) + response = append(response, appRecord...) + + hmacInput := make([]byte, 0, len(clientRandom)+len(response)) + hmacInput = append(hmacInput, clientRandom...) + hmacInput = append(hmacInput, response...) + serverRandom := hmacSHA256(secret, hmacInput) + + copy(response[shRandomOff:shRandomOff+32], serverRandom) + + return response +} + +func wrapTlsRecord(data []byte) []byte { + var parts []byte + offset := 0 + for offset < len(data) { + end := offset + tlsAppDataMax + if end > len(data) { + end = len(data) + } + chunk := data[offset:end] + hdr := []byte{0x17, 0x03, 0x03, 0, 0} + binary.BigEndian.PutUint16(hdr[3:5], uint16(len(chunk))) + parts = append(parts, hdr...) + parts = append(parts, chunk...) + offset = end + } + return parts +} + +// FakeTlsConn wraps a net.Conn, transparently unwrapping TLS AppData +// records on read and wrapping data in TLS AppData on write. +type FakeTlsConn struct { + conn net.Conn + readBuf []byte + readLeft int // remaining bytes in current TLS record +} + +func newFakeTlsConn(conn net.Conn) *FakeTlsConn { + return &FakeTlsConn{conn: conn} +} + +func (f *FakeTlsConn) Read(p []byte) (int, error) { + // If we have buffered data, return it first + if len(f.readBuf) > 0 { + n := copy(p, f.readBuf) + f.readBuf = f.readBuf[n:] + return n, nil + } + + // If we're in the middle of a record, read remaining + if f.readLeft > 0 { + toRead := f.readLeft + if toRead > len(p) { + toRead = len(p) + } + n, err := f.conn.Read(p[:toRead]) + f.readLeft -= n + return n, err + } + + // Read next TLS record header + for { + hdr := make([]byte, 5) + if _, err := io.ReadFull(f.conn, hdr); err != nil { + return 0, err + } + + rtype := hdr[0] + recLen := int(binary.BigEndian.Uint16(hdr[3:5])) + + if rtype == tlsRecordCCS { + // Skip CCS records + if recLen > 0 { + discard := make([]byte, recLen) + if _, err := io.ReadFull(f.conn, discard); err != nil { + return 0, err + } + } + continue + } + + if rtype != tlsRecordAppData { + return 0, fmt.Errorf("unexpected TLS record type 0x%02X", rtype) + } + + // Read up to len(p) from this record + toRead := recLen + if toRead > len(p) { + toRead = len(p) + } + n, err := io.ReadAtLeast(f.conn, p[:toRead], 1) + f.readLeft = recLen - n + return n, err + } +} + +func (f *FakeTlsConn) Write(p []byte) (int, error) { + wrapped := wrapTlsRecord(p) + _, err := f.conn.Write(wrapped) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (f *FakeTlsConn) Close() error { + return f.conn.Close() +} + +func (f *FakeTlsConn) LocalAddr() net.Addr { + return f.conn.LocalAddr() +} + +func (f *FakeTlsConn) RemoteAddr() net.Addr { + return f.conn.RemoteAddr() +} + +func (f *FakeTlsConn) SetDeadline(t time.Time) error { + return f.conn.SetDeadline(t) +} + +func (f *FakeTlsConn) SetReadDeadline(t time.Time) error { + return f.conn.SetReadDeadline(t) +} + +func (f *FakeTlsConn) SetWriteDeadline(t time.Time) error { + return f.conn.SetWriteDeadline(t) } func handleClient(ctx context.Context, conn net.Conn) { stats.connectionsTotal.Add(1) + stats.connectionsActive.Add(1) + defer func() { + if stats.connectionsActive.Load() > 0 { + stats.connectionsActive.Add(-1) + } + }() peer := conn.RemoteAddr().String() label := peer setSockOpts(conn) - defer conn.Close() - // -- SOCKS5 greeting -- - hdr, err := readExactly(conn, 2, 10*time.Second) - if err != nil { - logDebug.Printf("[%s] read greeting failed: %v", label, err) + proxySecretMu.RLock() + currentSecret := proxySecret + proxySecretMu.RUnlock() + secretBytes, _ := hex.DecodeString(currentSecret) + + // Read first byte to detect FakeTLS vs plain + firstByte := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + if _, err := io.ReadFull(conn, firstByte); err != nil { + logDebug.Printf("клиент отключился до рукопожатия") return } - if hdr[0] != 5 { - logDebug.Printf("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) - return - } - nmethods := int(hdr[1]) - if _, err := readExactly(conn, nmethods, 10*time.Second); err != nil { - return - } - if _, err := conn.Write([]byte{0x05, 0x00}); err != nil { - return + _ = conn.SetReadDeadline(time.Time{}) + + var clientConn net.Conn = conn // the connection we read MTProto from + var handshake []byte + + if firstByte[0] == tlsRecordHandshake { + // FakeTLS mode (ee-secret) + hdrRest := make([]byte, 4) + if _, err := io.ReadFull(conn, hdrRest); err != nil { + logDebug.Printf("неполный TLS-заголовок") + return + } + tlsHeader := append(firstByte, hdrRest...) + recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5])) + + recordBody := make([]byte, recordLen) + if _, err := io.ReadFull(conn, recordBody); err != nil { + logDebug.Printf("неполное тело TLS-записи") + return + } + + clientHello := append(tlsHeader, recordBody...) + + clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes) + if !ok { + stats.connectionsBad.Add(1) + logWarn.Printf("⚠ bad handshake") + return + } + + logDebug.Printf("FakeTLS рукопожатие ОК") + + serverHello := buildServerHello(secretBytes, clientRandom, sessionId) + if _, err := conn.Write(serverHello); err != nil { + return + } + + tlsConn := newFakeTlsConn(conn) + clientConn = tlsConn + + handshake = make([]byte, 64) + if _, err := io.ReadFull(tlsConn, handshake); err != nil { + logDebug.Printf("неполный обфускированный init внутри TLS") + return + } + } else { + // Plain obfuscated mode (dd-secret) + rest := make([]byte, 63) + if _, err := io.ReadFull(conn, rest); err != nil { + logDebug.Printf("клиент отключился до рукопожатия") + return + } + handshake = append(firstByte, rest...) } - // -- SOCKS5 CONNECT request -- - req, err := readExactly(conn, 4, 10*time.Second) + cltDecPrekey := handshake[8:40] + cltDecIv := handshake[40:56] + + hashDec := sha256.New() + hashDec.Write(cltDecPrekey) + hashDec.Write(secretBytes) + cltDecKey := hashDec.Sum(nil) + + cltDecryptor, err := newAESCTR(cltDecKey, cltDecIv) if err != nil { return } - cmd := req[1] - atyp := req[3] - if cmd != 1 { - _, _ = conn.Write(socks5Reply(0x07)) + decrypted := make([]byte, 64) + cltDecryptor.XORKeyStream(decrypted, handshake) + + protoTag := decrypted[56:60] + proto := binary.LittleEndian.Uint32(protoTag) + if !validProtos[proto] { + stats.connectionsBad.Add(1) + logWarn.Printf("⚠ bad handshake") return } - var dst string - switch atyp { - case 1: // IPv4 - raw, err := readExactly(conn, 4, 10*time.Second) - if err != nil { - return + dcRaw := int16(binary.LittleEndian.Uint16(decrypted[60:62])) + dc := int(dcRaw) + if dc < 0 { + dc = -dc + } + isMedia := dcRaw < 0 + + logInfo.Printf("→ DC%d %s", dc, mediaLabel(isMedia)) + + // Encryption back to client + cltEncPrekeyAndIv := make([]byte, 48) + for i := 0; i < 48; i++ { + cltEncPrekeyAndIv[i] = handshake[8+47-i] + } + cltEncPrekey := cltEncPrekeyAndIv[:32] + cltEncIv := cltEncPrekeyAndIv[32:] + + hashEnc := sha256.New() + hashEnc.Write(cltEncPrekey) + hashEnc.Write(secretBytes) + cltEncKey := hashEnc.Sum(nil) + cltEncryptor, _ := newAESCTR(cltEncKey, cltEncIv) + + // cltDecryptor was already advanced by 64 bytes during handshake decryption. + // cltEncryptor does NOT need to be advanced according to Python logic. + + // Generate relay Init + relayInit := make([]byte, 64) + for { + rand.Read(relayInit) + if relayInit[0] == 0xEF { continue } + s := string(relayInit[:4]) + if s == "HEAD" || s == "POST" || s == "GET " || s == "\xee\xee\xee\xee" || s == "\xdd\xdd\xdd\xdd" { + continue } - dst = net.IP(raw).String() - case 3: // domain - dlenBuf, err := readExactly(conn, 1, 10*time.Second) - if err != nil { - return + // TLS ClientHello start + if relayInit[0] == 0x16 && relayInit[1] == 0x03 && relayInit[2] == 0x01 && relayInit[3] == 0x02 { + continue } - domBytes, err := readExactly(conn, int(dlenBuf[0]), 10*time.Second) - if err != nil { - return + // Reserved continuation bytes + if relayInit[4] == 0 && relayInit[5] == 0 && relayInit[6] == 0 && relayInit[7] == 0 { + continue } - dst = string(domBytes) - case 4: // IPv6 - raw, err := readExactly(conn, 16, 10*time.Second) - if err != nil { - return - } - dst = net.IP(raw).String() - default: - _, _ = conn.Write(socks5Reply(0x08)) - return + break } - portBuf, err := readExactly(conn, 2, 10*time.Second) - if err != nil { - return + tgEncKey := relayInit[8:40] + tgEncIv := relayInit[40:56] + + tgDecPrekeyAndIv := make([]byte, 48) + for i := 0; i < 48; i++ { + tgDecPrekeyAndIv[i] = relayInit[8+47-i] } - port := int(binary.BigEndian.Uint16(portBuf)) + tgDecKey := tgDecPrekeyAndIv[:32] + tgDecIv := tgDecPrekeyAndIv[32:] - if strings.Contains(dst, ":") { - logError.Printf("[%s] IPv6 address detected: %s:%d — "+ - "IPv6 addresses are not supported; "+ - "disable IPv6 to continue using the proxy.", - label, dst, port) - _, _ = conn.Write(socks5Reply(0x05)) - return + tgEncryptor, _ := newAESCTR(tgEncKey, tgEncIv) + tgDecryptor, _ := newAESCTR(tgDecKey, tgDecIv) + + dcBytes := make([]byte, 2) + dcIdx := dc + if isMedia { + dcIdx = -dc } + binary.LittleEndian.PutUint16(dcBytes, uint16(dcIdx)) + + tailPlain := make([]byte, 8) + copy(tailPlain[0:4], protoTag) + copy(tailPlain[4:6], dcBytes) + rand.Read(tailPlain[6:8]) - // -- Non-Telegram IP -> direct passthrough -- - if !isTelegramIP(dst) { - stats.connectionsPassthrough.Add(1) - logDebug.Printf("[%s] passthrough -> %s:%d", label, dst, port) + encryptedFull := make([]byte, 64) + tgEncryptor.XORKeyStream(encryptedFull, relayInit) - dialer := &net.Dialer{Timeout: 10 * time.Second} - remote, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", dst, port)) - if err != nil { - logWarn.Printf("[%s] passthrough failed to %s: %T: %v", label, dst, err, err) - _, _ = conn.Write(socks5Reply(0x05)) - return - } - - _, _ = conn.Write(socks5Reply(0x00)) - - ctx2, cancel := context.WithCancel(ctx) - defer cancel() - - // Close connections when context done - go func() { - <-ctx2.Done() - _ = conn.Close() - _ = remote.Close() - }() - - done := make(chan struct{}, 2) - go pipe(ctx2, conn, remote, done) - go pipe(ctx2, remote, conn, done) - <-done - cancel() - <-done - _ = remote.Close() - return - } - - // -- Telegram DC: accept SOCKS, read init -- - _, _ = conn.Write(socks5Reply(0x00)) - - init, err := readExactly(conn, 64, 15*time.Second) - if err != nil { - logDebug.Printf("[%s] client disconnected before init: %v", label, err) - return - } - - // HTTP transport -> reject - if isHTTPTransport(init) { - stats.connectionsHttpReject.Add(1) - logDebug.Printf("[%s] HTTP transport to %s:%d (rejected)", label, dst, port) - return - } - - // -- Extract DC ID -- - dc, isMedia, dcOk := dcFromInit(init) - initPatched := false - var isMediaPtr *bool - if dcOk { - isMediaPtr = &isMedia - } - - // Android with useSecret=0 has random dc_id bytes — patch it - if !dcOk { - if info, found := ipToDC[dst]; found { - dc = info.dc - isMedia = info.isMedia - isMediaPtr = &isMedia - dcOk = true - - dcOptMu.RLock() - _, hasDC := dcOpt[dc] - dcOptMu.RUnlock() - - if hasDC { - // media -> positive dc, non-media -> negative dc - signedDC := -dc - if isMedia { - signedDC = dc - } - init = patchInitDC(init, signedDC) - initPatched = true - } - } - } - - dcOptMu.RLock() - _, dcConfigured := dcOpt[dc] - dcOptMu.RUnlock() - - if !dcOk || !dcConfigured { - logDebug.Printf("[%s] unknown DC%d for %s:%d -> TCP passthrough", label, dc, dst, port) - tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) - return + keystreamTail := make([]byte, 8) + for i := 0; i < 8; i++ { + keystreamTail[i] = encryptedFull[56+i] ^ relayInit[56+i] + relayInit[56+i] = tailPlain[i] ^ keystreamTail[i] } + // tgEncryptor was already advanced by 64 bytes above. + // tgDecryptor does NOT need to be advanced according to Python logic. + mTag := mediaTag(isMedia) dcKey := [2]int{dc, isMediaInt(isMedia)} now := monoNow() - mTag := "" - if isMediaPtr == nil { - mTag = " media?" - } else if *isMediaPtr { - mTag = " media" - } + // Splitting MTProto if needed. + splitter, _ := newMsgSplitter(relayInit, proto) + + dcOptMu.RLock() + target, dcConfigured := dcOpt[dc] + dcOptMu.RUnlock() - // -- WS blacklist check -- wsBlackMu.RLock() blacklisted := wsBlacklist[dcKey] wsBlackMu.RUnlock() - if blacklisted { - logDebug.Printf("[%s] DC%d%s WS blacklisted -> TCP %s:%d", - label, dc, mTag, dst, port) - ok := tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + if !dcConfigured || blacklisted { + if !dcConfigured { + logDebug.Printf("DC%d не настроен → резерв", dc) + } else { + logDebug.Printf("DC%d%s WS заблокирован → резерв", dc, mTag) + } + ok := doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitter, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) if ok { - logInfo.Printf("[%s] DC%d%s TCP fallback closed", label, dc, mTag) + logDebug.Printf("DC%d%s резерв закрыт", dc, mTag) } return } - // -- Try WebSocket -- dcFailMu.RLock() failUntil := dcFailUntil[dcKey] dcFailMu.RUnlock() wsTimeout := 10.0 - if now < failUntil { - wsTimeout = wsFailTimeout - } + if now < failUntil { wsTimeout = wsFailTimeout } isMediaForDomains := isMedia domains := wsDomains(dc, &isMediaForDomains) - dcOptMu.RLock() - target := dcOpt[dc] - dcOptMu.RUnlock() - var ws *RawWebSocket wsFailedRedirect := false allRedirects := true ws = wsPool.Get(dc, isMedia, target, domains) if ws != nil { - logInfo.Printf("[%s] DC%d%s (%s:%d) -> pool hit via %s", - label, dc, mTag, dst, port, target) + logInfo.Printf("⚡ DC%d%s подключен из пула", dc, mTag) } else { for _, domain := range domains { - url := fmt.Sprintf("wss://%s/apiws", domain) - logInfo.Printf("[%s] DC%d%s (%s:%d) -> %s via %s", - label, dc, mTag, dst, port, url, target) + logDebug.Printf("🔗 DC%d%s попытка WS %s", dc, mTag, domain) var connErr error ws, connErr = wsConnect(target, domain, "/apiws", wsTimeout) if connErr == nil { + logInfo.Printf("🔗 DC%d%s подключен напрямую", dc, mTag) allRedirects = false break } stats.wsErrors.Add(1) - if wsErr, ok := connErr.(*WsHandshakeError); ok { if wsErr.IsRedirect() { wsFailedRedirect = true - logWarn.Printf("[%s] DC%d%s got %d from %s -> %s", - label, dc, mTag, wsErr.StatusCode, domain, - wsErr.Location) continue } allRedirects = false - logWarn.Printf("[%s] DC%d%s WS handshake: %s", - label, dc, mTag, wsErr.StatusLine) } else { allRedirects = false - errStr := connErr.Error() - if strings.Contains(errStr, "certificate") || - strings.Contains(errStr, "hostname") { - logWarn.Printf("[%s] DC%d%s SSL error: %v", - label, dc, mTag, connErr) - } else { - logWarn.Printf("[%s] DC%d%s WS connect failed: %v", - label, dc, mTag, connErr) - } } } } - // -- WS failed -> fallback -- if ws == nil { if wsFailedRedirect && allRedirects { wsBlackMu.Lock() wsBlacklist[dcKey] = true wsBlackMu.Unlock() - logWarn.Printf("[%s] DC%d%s blacklisted for WS (all 302)", - label, dc, mTag) + logWarn.Printf("⚠ DC%d%s заблокирован (302)", dc, mTag) } else if wsFailedRedirect { dcFailMu.Lock() dcFailUntil[dcKey] = now + dcFailCooldown @@ -1644,41 +2220,30 @@ func handleClient(ctx context.Context, conn net.Conn) { dcFailMu.Lock() dcFailUntil[dcKey] = now + dcFailCooldown dcFailMu.Unlock() - logInfo.Printf("[%s] DC%d%s WS cooldown for %ds", - label, dc, mTag, int(dcFailCooldown)) + logDebug.Printf("DC%d%s кулдаун %dс", dc, mTag, int(dcFailCooldown)) } - logInfo.Printf("[%s] DC%d%s -> TCP fallback to %s:%d", - label, dc, mTag, dst, port) - ok := tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + splitterFb, _ := newMsgSplitter(relayInit, proto) + ok := doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitterFb, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) if ok { - logInfo.Printf("[%s] DC%d%s TCP fallback closed", label, dc, mTag) + logDebug.Printf("DC%d%s резерв закрыт", dc, mTag) } return } - // -- WS success -- dcFailMu.Lock() delete(dcFailUntil, dcKey) dcFailMu.Unlock() stats.connectionsWs.Add(1) - var splitter *MsgSplitter - if initPatched { - splitter, _ = newMsgSplitter(init) - } - - // Send init packet - if err := ws.Send(init); err != nil { - logDebug.Printf("[%s] reconnecting via TCP fallback (WS broken): %v", label, err) + if err := ws.Send(relayInit); err != nil { ws.Close() - tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + tcpFallback(ctx, clientConn, target, 443, relayInit, label, dc, isMedia, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) return } - // Bidirectional bridge - bridgeWS(ctx, conn, ws, label, dc, dst, port, isMedia, splitter) + bridgeWS(ctx, clientConn, ws, label, dc, target, 443, isMedia, splitter, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) } // --------------------------------------------------------------------------- @@ -1710,17 +2275,17 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin srvCtx, srvCancel := context.WithCancel(ctx) defer srvCancel() - logInfo.Println(strings.Repeat("=", 60)) - logInfo.Println(" Telegram WS Bridge Proxy (Go)") - logInfo.Printf(" Listening on %s:%d", host, port) - logInfo.Println(" Target DC IPs:") + logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + logInfo.Println(" TG WS Proxy запущен") + logInfo.Printf(" Адрес: %s:%d", host, port) for dc, ip := range dcOptMap { - logInfo.Printf(" DC%d: %s", dc, ip) + logInfo.Printf(" DC%d → %s", dc, ip) } - logInfo.Println(strings.Repeat("=", 60)) - logInfo.Printf(" Configure Telegram Desktop:") - logInfo.Printf(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port) - logInfo.Println(strings.Repeat("=", 60)) + proxySecretMu.RLock() + currentSec := proxySecret + proxySecretMu.RUnlock() + logInfo.Printf(" Ключ: ee%s", currentSec) + logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") // Stats logger go func() { @@ -1731,22 +2296,8 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin case <-srvCtx.Done(): return case <-ticker.C: - wsBlackMu.RLock() - var blParts []string - for k := range wsBlacklist { - m := "" - if k[1] == 1 { - m = "m" - } - blParts = append(blParts, fmt.Sprintf("DC%d%s", k[0], m)) - } - wsBlackMu.RUnlock() - bl := "none" - if len(blParts) > 0 { - bl = strings.Join(blParts, ", ") - } idleCount := wsPool.IdleCount() - logInfo.Printf("stats: %s idle=%d | ws_bl: %s", stats.Summary(), idleCount, bl) + logInfo.Printf("📊 %s | пул:%d", stats.SummaryRu(), idleCount) } } }() @@ -1772,7 +2323,7 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin if ne, ok := err.(net.Error); ok && ne.Timeout() { continue } - logError.Printf("accept error: %v", err) + logError.Printf("ошибка accept: %v", err) return } } @@ -1786,7 +2337,7 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin // Wait for context cancellation <-srvCtx.Done() - logInfo.Println("Shutting down proxy server...") + logInfo.Println("⏹ Остановка прокси...") _ = listener.Close() // Wait for active connections with timeout @@ -1798,15 +2349,15 @@ func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]strin select { case <-done: - logInfo.Println("All connections closed gracefully") + logInfo.Println("✓ Все соединения закрыты") case <-time.After(30 * time.Second): - logWarn.Println("Graceful shutdown timed out after 30s") + logWarn.Println("⚠ Таймаут завершения (30с)") } // Close pool connections wsPool.CloseAll() - logInfo.Printf("Final stats: %s", stats.Summary()) + logInfo.Printf("📊 Итого: %s", stats.SummaryRu()) return nil } @@ -1859,10 +2410,11 @@ func StartProxy(cHost *C.char, port C.int, cDcIps *C.char, verbose C.int) C.int isVerbose := int(verbose) != 0 initLogging(isVerbose) + initCfproxyDomains() dcOptMap, err := parseCIDRPool(dcIpsStr) if err != nil { - logError.Printf("parseCIDRPool: %v", err) + logError.Printf("ошибка разбора DC: %v", err) return -2 } @@ -1870,7 +2422,7 @@ func StartProxy(cHost *C.char, port C.int, cDcIps *C.char, verbose C.int) C.int go func() { if err := runProxy(globalCtx, host, goPort, dcOptMap); err != nil { - logError.Printf("runProxy error: %v", err) + logError.Printf("✗ Ошибка прокси: %v", err) } }() @@ -1915,7 +2467,64 @@ func SetPoolSize(size C.int) { } poolSize = n if logInfo != nil { - logInfo.Printf("Pool size set to %d", n) + logInfo.Printf("⚙ Пул: %d", n) + } +} + +//export SetCfProxyConfig +func SetCfProxyConfig(enabled C.int, priority C.int, cUserDomain *C.char) { + cfproxyMu.Lock() + defer cfproxyMu.Unlock() + + cfproxyEnabled = int(enabled) != 0 + cfproxyPriority = int(priority) != 0 + + userDomain := C.GoString(cUserDomain) + cfproxyUserDomain = userDomain + + if userDomain != "" { + cfproxyDomains = []string{userDomain} + activeCfDomain = userDomain + } + + if logInfo != nil { + status := "выкл" + if cfproxyEnabled { + status = "вкл" + } + prio := "TCP→CF" + if cfproxyPriority { + prio = "CF→TCP" + } + dom := activeCfDomain + if dom == "" { + dom = "авто" + } + logInfo.Printf("☁ CF: %s (%s) %s", status, prio, dom) + } +} + +//export SetSecret +func SetSecret(cSecret *C.char) { + s := C.GoString(cSecret) + if len(s) != 32 { + if logWarn != nil { + logWarn.Printf("⚠ Ключ: неверная длина %d (нужно 32)", len(s)) + } + return + } + // Validate hex + if _, err := hex.DecodeString(s); err != nil { + if logWarn != nil { + logWarn.Printf("⚠ Ключ: невалидный hex") + } + return + } + proxySecretMu.Lock() + proxySecret = s + proxySecretMu.Unlock() + if logInfo != nil { + logInfo.Printf("🔑 Ключ обновлён: ee%s...", s[:8]) } } @@ -1938,6 +2547,7 @@ func main() { runtime.LockOSThread() initLogging(false) + initCfproxyDomains() dcOptMap := map[int]string{ 2: "149.154.167.220",