Update V1.0.6

не придирайтесь к ver name и другим символическим значениям. лень некоторые вещи менять.
This commit is contained in:
amurcanov
2026-04-12 22:14:53 +03:00
parent e3483e15c1
commit 6953665a69
28 changed files with 2940 additions and 1001 deletions

View File

@@ -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")

View File

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

View File

@@ -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<List<String>>(emptyList())
val logs = MutableStateFlow<List<LogEntry>>(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<LogEntry>(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<LogEntry>()
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<LogEntry>, batch: List<LogEntry>): List<LogEntry> {
// Use a pre-sized ArrayList to avoid re-allocation
val result = ArrayList<LogEntry>(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
)
}
}

View File

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

View File

@@ -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()

View File

@@ -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<Preferences> 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<String> = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" }
val isDcAuto: Flow<Boolean> = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true }
val dc2: Flow<String> = context.dataStore.data.map { it[Keys.DC2] ?: "" }
val dc4: Flow<String> = context.dataStore.data.map { it[Keys.DC4] ?: "149.154.167.220" }
val port: Flow<String> = context.dataStore.data.map { it[Keys.PORT] ?: "1443" }
val poolSize: Flow<Int> = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 }
val cfproxyEnabled: Flow<Boolean> = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true }
val secretKey: Flow<String> = 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
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Job?>(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)
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20V4c4.42,0 8,3.58 8,8s-3.58,8 -8,8z"/>
</vector>

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="20dp"
android:height="23dp"
android:viewportWidth="69"
android:viewportHeight="80">
<path
android:fillType="nonZero"
android:pathData="M34.8586103,46.5882227 L29.4383109,46.5882227 C29.1227896,46.5896404 28.8214241,46.4709816 28.6091788,46.2617625 C28.3969336,46.0525435 28.2937566,45.7724279 28.325313,45.4910942 L28.8039021,40.6038855 C28.8504221,40.0844231 29.3353656,39.684617 29.9168999,39.6862871 L35.3371993,39.6862871 C35.6527206,39.6848694 35.9540862,39.8035282 36.1663314,40.0127473 C36.3785767,40.2219663 36.4817536,40.5020819 36.4501972,40.7834156 L35.9716081,45.6706244 C35.9250881,46.1900867 35.4401446,46.5898928 34.8586103,46.5882227 Z M35.7273704,37.0196078 L30.2062624,37.0196078 C29.5964177,37.0196078 29.1020408,36.5436108 29.1020408,35.9564386 L30.6037822,19.445421 C30.6711655,18.9085577 31.1463683,18.5059261 31.7080038,18.5098321 L37.2291117,18.5098321 C37.8389565,18.5098321 38.3333333,18.9858292 38.3333333,19.5730013 L36.7874231,36.0946506 C36.71732,36.6114677 36.2684318,37.0031485 35.7273704,37.0196078 Z M68.1907868,17.7655375 C68.7783609,18.4477209 69.0654113,19.3359027 68.9874243,20.2304671 L66.8294442,44.7595227 C66.7602449,45.5618649 66.4022166,46.3125114 65.8210422,46.8737509 L48.1740082,63.9078173 C47.5440361,64.5136721 46.7011436,64.8515656 45.8244317,64.849701 L27.0379034,64.849701 L10.5001116,80 L11.780782,64.8396809 L3.37070981,64.8396809 C2.42614186,64.8404154 1.52467299,64.4469919 0.886144518,63.7553546 C0.247616048,63.0637173 -0.0692823639,62.1374374 0.0127313353,61.2024067 L5.14549723,3.00601995 C5.32162283,1.29571214 6.77326374,-0.00377492304 8.50347571,8.23911697e-06 L51.3303063,8.23911697e-06 C52.3155567,-0.000274507348 53.2515294,0.428127083 53.8916472,1.17235281 L68.1907868,17.7655375 Z M55.1118136,40.0801644 L56.4832402,24.9398854 C56.5364472,24.0422509 56.2238954,23.1611024 55.6160145,22.4949959 L47.548799,13.2364798 C46.9095165,12.5050105 45.982605,12.084699 45.0076261,12.0841753 L19.5958971,12.0841753 C17.8656851,12.0803922 16.4140442,13.3798792 16.2379186,15.090187 L13.2127128,49.1583198 C13.1430685,50.0904539 13.4642119,51.0097453 14.1001037,51.6985265 C14.7359954,52.3873077 15.6300918,52.7843324 16.5706913,52.795594 L41.5588914,52.795594 C42.4258216,52.7976549 43.2601656,52.4674704 43.8882999,51.8737504 L54.1034116,42.2044127 C54.6867718,41.6406314 55.0449815,40.8860452 55.1118136,40.0801644 Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="60.16"
android:startY="0"
android:endX="8.835"
android:endY="80"
android:type="linear">
<item android:offset="0" android:color="#F59C07"/>
<item android:offset="1" android:color="#F57507"/>
</gradient>
</aapt:attr>
</path>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="23dp"
android:viewportWidth="69"
android:viewportHeight="80">
<path
android:fillColor="#000000"
android:fillType="nonZero"
android:pathData="M34.8586103,46.5882227 L29.4383109,46.5882227 C29.1227896,46.5896404 28.8214241,46.4709816 28.6091788,46.2617625 C28.3969336,46.0525435 28.2937566,45.7724279 28.325313,45.4910942 L28.8039021,40.6038855 C28.8504221,40.0844231 29.3353656,39.684617 29.9168999,39.6862871 L35.3371993,39.6862871 C35.6527206,39.6848694 35.9540862,39.8035282 36.1663314,40.0127473 C36.3785767,40.2219663 36.4817536,40.5020819 36.4501972,40.7834156 L35.9716081,45.6706244 C35.9250881,46.1900867 35.4401446,46.5898928 34.8586103,46.5882227 Z M35.7273704,37.0196078 L30.2062624,37.0196078 C29.5964177,37.0196078 29.1020408,36.5436108 29.1020408,35.9564386 L30.6037822,19.445421 C30.6711655,18.9085577 31.1463683,18.5059261 31.7080038,18.5098321 L37.2291117,18.5098321 C37.8389565,18.5098321 38.3333333,18.9858292 38.3333333,19.5730013 L36.7874231,36.0946506 C36.71732,36.6114677 36.2684318,37.0031485 35.7273704,37.0196078 Z M68.1907868,17.7655375 C68.7783609,18.4477209 69.0654113,19.3359027 68.9874243,20.2304671 L66.8294442,44.7595227 C66.7602449,45.5618649 66.4022166,46.3125114 65.8210422,46.8737509 L48.1740082,63.9078173 C47.5440361,64.5136721 46.7011436,64.8515656 45.8244317,64.849701 L27.0379034,64.849701 L10.5001116,80 L11.780782,64.8396809 L3.37070981,64.8396809 C2.42614186,64.8404154 1.52467299,64.4469919 0.886144518,63.7553546 C0.247616048,63.0637173 -0.0692823639,62.1374374 0.0127313353,61.2024067 L5.14549723,3.00601995 C5.32162283,1.29571214 6.77326374,-0.00377492304 8.50347571,8.23911697e-06 L51.3303063,8.23911697e-06 C52.3155567,-0.000274507348 53.2515294,0.428127083 53.8916472,1.17235281 L68.1907868,17.7655375 Z M55.1118136,40.0801644 L56.4832402,24.9398854 C56.5364472,24.0422509 56.2238954,23.1611024 55.6160145,22.4949959 L47.548799,13.2364798 C46.9095165,12.5050105 45.982605,12.084699 45.0076261,12.0841753 L19.5958971,12.0841753 C17.8656851,12.0803922 16.4140442,13.3798792 16.2379186,15.090187 L13.2127128,49.1583198 C13.1430685,50.0904539 13.4642119,51.0097453 14.1001037,51.6985265 C14.7359954,52.3873077 15.6300918,52.7843324 16.5706913,52.795594 L41.5588914,52.795594 C42.4258216,52.7976549 43.2601656,52.4674704 43.8882999,51.8737504 L54.1034116,42.2044127 C54.6867718,41.6406314 55.0449815,40.8860452 55.1118136,40.0801644 Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3c-4.97,0 -9,4.03 -9,9s4.03,9 9,9 9,-4.03 9,-9c0,-0.46 -0.04,-0.92 -0.1,-1.36 -0.98,1.37 -2.58,2.26 -4.4,2.26 -2.98,0 -5.4,-2.42 -5.4,-5.4 0,-1.81 0.89,-3.42 2.26,-4.4C12.92,3.04 12.46,3 12,3z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:fillColor="#FFFFFF"
android:pathData="M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9a127.5 127.5 0 0 1 38.1 91v112.5c.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"
android:fillColor="#8B3FFD"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5 5,-2.24 5,-5 -2.24,-5 -5,-5zM2,13h2c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H2c-0.55,0 -1,0.45 -1,1s0.45,1 1,1zM20,13h2c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-2c-0.55,0 -1,0.45 -1,1s0.45,1 1,1zM11,2v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1V2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1zM11,20v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1zM5.99,4.58c-0.39,-0.39 -1.03,-0.39 -1.42,0 -0.39,0.39 -0.39,1.03 0,1.42l1.06,1.06c0.39,0.39 1.03,0.39 1.42,0s0.39,-1.03 0,-1.42L5.99,4.58zM18.36,16.95c-0.39,-0.39 -1.03,-0.39 -1.42,0 -0.39,0.39 -0.39,1.03 0,1.42l1.06,1.06c0.39,0.39 1.03,0.39 1.42,0 0.39,-0.39 0.39,-1.03 0,-1.42l-1.06,-1.06zM19.42,5.99c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.03,-0.39 -1.42,0l-1.06,1.06c-0.39,0.39 -0.39,1.03 0,1.42s1.03,0.39 1.42,0l1.06,-1.06zM7.05,18.36c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.03,-0.39 -1.42,0l-1.06,1.06c-0.39,0.39 -0.39,1.03 0,1.42s1.03,0.39 1.42,0l1.06,-1.06z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.49,2 2,6.49 2,12s4.49,10 10,10c1.38,0 2.5,-1.12 2.5,-2.5 0,-0.61 -0.23,-1.2 -0.64,-1.67 -0.08,-0.1 -0.13,-0.21 -0.13,-0.33 0,-0.28 0.22,-0.5 0.5,-0.5H16c3.31,0 6,-2.69 6,-6C22,6.04 17.51,2 12,2zM6.5,13C5.67,13 5,12.33 5,11.5S5.67,10 6.5,10 8,10.67 8,11.5 7.33,13 6.5,13zM9.5,9C8.67,9 8,8.33 8,7.5S8.67,6 9.5,6 11,6.67 11,7.5 10.33,9 9.5,9zM14.5,9C13.67,9 13,8.33 13,7.5S13.67,6 14.5,6 16,6.67 16,7.5 15.33,9 14.5,9zM17.5,13c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L13.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L8.25,5.35C7.66,5.59 7.13,5.91 6.63,6.29L4.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L1.73,8.87c-0.11,0.2 -0.06,0.47 0.12,0.61l2.03,1.58C3.84,11.36 3.82,11.68 3.82,12c0,0.32 0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.11,-0.2 0.06,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View File

@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="138dp"
android:height="30dp"
android:viewportWidth="92"
android:viewportHeight="20">
<path
android:pathData="M33.9287 15.222H36.2782V9.5425C36.2782 8.34 37.1107 7.9145 37.9247 7.9145C38.8127 7.9145 39.4047 8.4695 39.4047 9.5425V15.222H41.7542V9.5425C41.7542 8.3215 42.5867 7.9145 43.4007 7.9145C44.2887 7.9145 44.8807 8.4695 44.8807 9.5425V15.222H47.2302V9.3575C47.2302 6.9895 45.7502 5.75 43.9187 5.75C42.6977 5.75 41.8282 6.305 41.1622 7.119C40.5702 6.2125 39.6082 5.75 38.5167 5.75C37.5177 5.75 36.7592 6.194 36.1672 6.86L36.0377 5.9535H33.9287V15.222Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M53.9553 5.75C51.1063 5.75 49.0157 7.8035 49.0157 10.5785C49.0157 13.372 51.1063 15.407 53.9553 15.407C56.8043 15.407 58.8948 13.372 58.8948 10.5785C58.8948 7.8035 56.8043 5.75 53.9553 5.75ZM53.9553 7.9145C55.4538 7.9145 56.4342 8.9875 56.4342 10.5785C56.4342 12.188 55.4538 13.261 53.9553 13.261C52.4568 13.261 51.4577 12.188 51.4577 10.5785C51.4577 8.9875 52.4568 7.9145 53.9553 7.9145Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M60.8838 15.222H63.2333V10.005C63.2333 8.451 64.3063 7.933 65.2868 7.933C66.3783 7.933 67.1553 8.636 67.1553 10.005V15.222H69.5048V9.635C69.5048 7.082 67.8398 5.75 65.8603 5.75C64.8243 5.75 63.8808 6.1385 63.1408 6.8785L63.0113 5.9535H60.8838V15.222Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M76.1941 15.407C78.4326 15.407 80.0421 14.3525 80.5971 12.5025L78.4141 12.0585C78.1181 12.8355 77.4151 13.372 76.1941 13.372C74.8621 13.372 73.9186 12.743 73.6966 11.3H80.5786C80.6526 10.9485 80.6896 10.5785 80.6896 10.2455C80.6896 7.563 78.8026 5.75 76.0831 5.75C73.2711 5.75 71.2916 7.822 71.2916 10.634C71.2916 13.3905 73.1231 15.407 76.1941 15.407ZM76.0831 7.6925C77.2671 7.6925 78.0256 8.34 78.2291 9.5055H73.7706C74.1036 8.2845 75.0101 7.6925 76.0831 7.6925Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M81.2328 5.9535L85.2103 15.1665L83.6193 19.292H86.0428L91.1673 5.9535H88.6698L86.4313 12.2065L83.8413 5.9535H81.2328Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M8.23663 9.97273C8.25147 4.47884 12.7503 0 18.4037 0C24.002 0 28.6351 4.49367 28.5708 10C28.5708 15.5063 24.002 20 18.4037 20C12.8145 20 8.25158 15.5841 8.23663 10.0274V17.4683H4.6331L0 2.91138H8.23663V9.97273ZM14.6071 10C14.6071 12.0253 16.3445 13.7342 18.4037 13.7342C20.5272 13.7342 22.2002 12.0253 22.2002 10C22.2002 7.97469 20.4628 6.26582 18.4037 6.26582C16.3445 6.26582 14.6071 7.97469 14.6071 10Z"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"/>
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

4
go.mod
View File

@@ -1,7 +1,3 @@
module tg-ws-proxy
go 1.25
require (
golang.org/x/crypto v0.31.0
)

File diff suppressed because it is too large Load Diff