mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-23 15:01:08 +03:00
Update V1.0.6
не придирайтесь к ver name и другим символическим значениям. лень некоторые вещи менять.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user