mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-26 01:11:43 +03:00
Update V1.0.6
не придирайтесь к ver name и другим символическим значениям. лень некоторые вещи менять.
This commit is contained in:
@@ -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")
|
||||
|
||||
16
app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt
Normal file
16
app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
58
app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt
Normal file
58
app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt
Normal file
187
app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
322
app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt
Normal file
322
app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt
Normal file
149
app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
655
app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt
Normal file
655
app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
173
app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt
Normal file
173
app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
9
app/src/main/res/drawable/ic_auto.xml
Normal file
9
app/src/main/res/drawable/ic_auto.xml
Normal 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>
|
||||
22
app/src/main/res/drawable/ic_da.xml
Normal file
22
app/src/main/res/drawable/ic_da.xml
Normal 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>
|
||||
10
app/src/main/res/drawable/ic_da_black.xml
Normal file
10
app/src/main/res/drawable/ic_da_black.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_dark_mode.xml
Normal file
9
app/src/main/res/drawable/ic_dark_mode.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_github.xml
Normal file
9
app/src/main/res/drawable/ic_github.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_heart.xml
Normal file
9
app/src/main/res/drawable/ic_heart.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_light_mode.xml
Normal file
9
app/src/main/res/drawable/ic_light_mode.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_palette.xml
Normal file
9
app/src/main/res/drawable/ic_palette.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_settings.xml
Normal file
9
app/src/main/res/drawable/ic_settings.xml
Normal 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>
|
||||
25
app/src/main/res/drawable/ic_yoomoney.xml
Normal file
25
app/src/main/res/drawable/ic_yoomoney.xml
Normal 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>
|
||||
BIN
app/src/main/res/font/inter_bold.ttf
Normal file
BIN
app/src/main/res/font/inter_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
BIN
app/src/main/res/font/inter_medium.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
BIN
app/src/main/res/font/inter_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
BIN
app/src/main/res/font/inter_semibold.ttf
Normal file
Binary file not shown.
@@ -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
4
go.mod
@@ -1,7 +1,3 @@
|
||||
module tg-ws-proxy
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.31.0
|
||||
)
|
||||
1258
tg-ws-proxy.go
1258
tg-ws-proxy.go
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user