diff --git a/app/src/main/java/org/monogram/app/components/ProxyConfirmSheet.kt b/app/src/main/java/org/monogram/app/components/ProxyConfirmSheet.kt index 5ccfaba0..749dfd71 100644 --- a/app/src/main/java/org/monogram/app/components/ProxyConfirmSheet.kt +++ b/app/src/main/java/org/monogram/app/components/ProxyConfirmSheet.kt @@ -1,13 +1,27 @@ package org.monogram.app.components -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -67,6 +81,15 @@ fun ProxyConfirmSheet(root: RootComponent) { else -> stringResource(R.string.proxy_unknown) } DetailRow(stringResource(R.string.proxy_type), typeName) + + val ping = proxyConfirmState.ping + val (statusText, statusColor) = when { + proxyConfirmState.isChecking -> "Checking..." to MaterialTheme.colorScheme.onSurfaceVariant + ping == null -> "Unknown" to MaterialTheme.colorScheme.onSurfaceVariant + ping < 0L -> "Offline" to MaterialTheme.colorScheme.error + else -> "${ping} ms" to Color(0xFF34A853) + } + DetailRow("Status", statusText, statusColor) } } @@ -98,6 +121,7 @@ fun ProxyConfirmSheet(root: RootComponent) { proxyConfirmState.type!!, ) }, + enabled = !proxyConfirmState.isChecking && proxyConfirmState.ping != -1L, modifier = Modifier .weight(1f) .height(56.dp), @@ -116,7 +140,11 @@ fun ProxyConfirmSheet(root: RootComponent) { } @Composable -private fun DetailRow(label: String, value: String) { +private fun DetailRow( + label: String, + value: String, + valueColor: Color = MaterialTheme.colorScheme.onSurface +) { Row( modifier = Modifier .fillMaxWidth() @@ -134,7 +162,7 @@ private fun DetailRow(label: String, value: String) { text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, + color = valueColor, ) } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ProxyRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ProxyRemoteDataSource.kt index 6b009067..a953c867 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/ProxyRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/ProxyRemoteDataSource.kt @@ -13,5 +13,7 @@ interface ProxyRemoteDataSource { suspend fun removeProxy(proxyId: Int) suspend fun pingProxy(server: String, port: Int, type: ProxyTypeModel): Long suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long + suspend fun testProxyAtDc(server: String, port: Int, type: ProxyTypeModel, dcId: Int): Long + suspend fun testDirectDc(dcId: Int): Long suspend fun setOption(key: String, value: TdApi.OptionValue) } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdProxyRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdProxyRemoteDataSource.kt index fbeac836..86ce9c6c 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdProxyRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdProxyRemoteDataSource.kt @@ -1,16 +1,34 @@ package org.monogram.data.datasource.remote -import org.monogram.data.core.coRunCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.toApi import org.monogram.data.mapper.toDomain import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI class TdProxyRemoteDataSource( private val gateway: TelegramGateway ) : ProxyRemoteDataSource { + private companion object { + val PRODUCTION_DC_IDS = intArrayOf(2, 1, 3, 4, 5) + const val TEST_TIMEOUT_SECONDS = 10.0 + const val DIRECT_CONNECT_TIMEOUT_MS = 10_000 + private val WEB_DC_URLS = mapOf( + 1 to "wss://pluto.web.telegram.org/apiws", + 2 to "wss://venus.web.telegram.org/apiws", + 3 to "wss://aurora.web.telegram.org/apiws", + 4 to "wss://vesta.web.telegram.org/apiws", + 5 to "wss://flora.web.telegram.org/apiws" + ) + } + override suspend fun getProxies(): List = gateway.execute(TdApi.GetProxies()).proxies.map { it.toDomain() } @@ -41,11 +59,49 @@ class TdProxyRemoteDataSource( } override suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long { + var lastError: Throwable? = null + + PRODUCTION_DC_IDS.forEach { dcId -> + val result = coRunCatching { testProxyAtDc(server, port, type, dcId) } + if (result.isSuccess) return result.getOrThrow() + lastError = result.exceptionOrNull() + } + + val proxy = TdApi.Proxy(server, port, type.toApi()) + val pingResult = coRunCatching { gateway.execute(TdApi.PingProxy(proxy)) } + if (pingResult.isSuccess) return (pingResult.getOrThrow().seconds * 1000).toLong() + throw (lastError ?: pingResult.exceptionOrNull() + ?: IllegalStateException("Proxy test failed")) + } + + override suspend fun testProxyAtDc( + server: String, + port: Int, + type: ProxyTypeModel, + dcId: Int + ): Long { val start = System.currentTimeMillis() - gateway.execute(TdApi.TestProxy(TdApi.Proxy(server, port, type.toApi()), 0, 10.0)) + gateway.execute( + TdApi.TestProxy( + TdApi.Proxy(server, port, type.toApi()), + dcId, + TEST_TIMEOUT_SECONDS + ) + ) return System.currentTimeMillis() - start } + override suspend fun testDirectDc(dcId: Int): Long = withContext(Dispatchers.IO) { + val endpoint = WEB_DC_URLS[dcId] ?: error("Unsupported DC id: $dcId") + val host = URI(endpoint).host ?: error("Invalid DC endpoint for id: $dcId") + + val start = System.currentTimeMillis() + Socket().use { socket -> + socket.connect(InetSocketAddress(host, 443), DIRECT_CONNECT_TIMEOUT_MS) + } + System.currentTimeMillis() - start + } + override suspend fun setOption(key: String, value: TdApi.OptionValue) { gateway.execute(TdApi.SetOption(key, value)) } diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 15c3ad88..26cdb5b2 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -91,7 +91,6 @@ import org.monogram.data.repository.ChatInfoRepositoryImpl import org.monogram.data.repository.ChatStatisticsRepositoryImpl import org.monogram.data.repository.ChatsListRepositoryImpl import org.monogram.data.repository.EmojiRepositoryImpl -import org.monogram.data.repository.ExternalProxyRepositoryImpl import org.monogram.data.repository.GifRepositoryImpl import org.monogram.data.repository.LinkHandlerRepositoryImpl import org.monogram.data.repository.LinkParser @@ -103,6 +102,8 @@ import org.monogram.data.repository.PollRepositoryImpl import org.monogram.data.repository.PremiumRepositoryImpl import org.monogram.data.repository.PrivacyRepositoryImpl import org.monogram.data.repository.ProfilePhotoRepositoryImpl +import org.monogram.data.repository.ProxyDiagnosticsRepositoryImpl +import org.monogram.data.repository.ProxyRepositoryImpl import org.monogram.data.repository.PushDebugRepositoryImpl import org.monogram.data.repository.SessionRepositoryImpl import org.monogram.data.repository.SponsorRepositoryImpl @@ -127,7 +128,6 @@ import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository import org.monogram.domain.repository.EmojiRepository -import org.monogram.domain.repository.ExternalProxyRepository import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository @@ -144,6 +144,8 @@ import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.ProxyDiagnosticsRepository +import org.monogram.domain.repository.ProxyRepository import org.monogram.domain.repository.PushDebugRepository import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository @@ -775,13 +777,19 @@ val dataModule = module { ) } - single { - ExternalProxyRepositoryImpl( + single { + ProxyRepositoryImpl( remote = get(), appPreferences = get() ) } + single { + ProxyDiagnosticsRepositoryImpl( + remote = get() + ) + } + single { LocationRepositoryImpl( remote = get() diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index f5e845d7..7c30861d 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -29,9 +30,12 @@ import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.gateway.isExpectedProxyFailure import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ConnectionStatus +import org.monogram.domain.repository.MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES +import org.monogram.domain.repository.MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySmartSwitchMode import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.defaultProxyNetworkMode import kotlin.random.Random @@ -220,6 +224,8 @@ class ConnectionManager( private fun startProxyManagement() { proxyModeWatcherJob?.cancel() proxyModeWatcherJob = scope.launch { + syncEnabledProxyPreferenceFromTdlib("startup_sync") + appPreferences.enabledProxyId.value?.let { proxyId -> if (!enableProxy(proxyId, getCurrentNetworkType(), "startup_restore")) { appPreferences.setEnabledProxyId(null) @@ -241,7 +247,28 @@ class ConnectionManager( } launch { - appPreferences.isAutoBestProxyEnabled.collect { autoBest -> + appPreferences.preferIpv6.collect { preferIpv6 -> + coRunCatching { + proxyRemoteSource.setOption( + "prefer_ipv6", + TdApi.OptionValueBoolean(preferIpv6) + ) + }.onFailure { error -> + if (error.isExpectedProxyFailure()) { + Log.w(tag, "Failed to apply prefer_ipv6 option: ${error.message}") + } else { + Log.e(tag, "Failed to apply prefer_ipv6 option", error) + } + } + } + } + + launch { + appPreferences.isAutoBestProxyEnabled + .combine(appPreferences.proxyAutoCheckIntervalMinutes) { autoBest, intervalMinutes -> + autoBest to intervalMinutes + } + .collect { (autoBest, _) -> autoBestJob?.cancel() if (autoBest) { @@ -252,10 +279,36 @@ class ConnectionManager( } } + private suspend fun syncEnabledProxyPreferenceFromTdlib(reason: String) { + coRunCatching { proxyRemoteSource.getProxies() } + .onSuccess { proxies -> + val enabledId = proxies.firstOrNull { it.isEnabled }?.id + if (appPreferences.enabledProxyId.value != enabledId) { + Log.d( + tag, + "Syncing enabled proxy id from TDLib ($reason): ${appPreferences.enabledProxyId.value} -> $enabledId" + ) + appPreferences.setEnabledProxyId(enabledId) + } + } + .onFailure { error -> + if (error.isExpectedProxyFailure()) { + Log.w(tag, "Failed to sync enabled proxy id ($reason): ${error.message}") + } else { + Log.e(tag, "Failed to sync enabled proxy id ($reason)", error) + } + } + } + private fun launchAutoBestLoop(): Job = scope.launch(dispatchers.default) { while (isActive) { applyNetworkProxyRuleSafely("auto_best_loop") - delay(300_000L) + val intervalMinutes = appPreferences.proxyAutoCheckIntervalMinutes.value + .coerceIn( + MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES, + MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES + ) + delay(intervalMinutes * 60_000L) } } @@ -337,7 +390,7 @@ class ConnectionManager( return false } - val best = coroutineScope { + val proxyChecks = coroutineScope { proxies.map { proxy -> async { val ping = coRunCatching { @@ -362,24 +415,31 @@ class ConnectionManager( proxy to ping } }.awaitAll() - }.minByOrNull { it.second } ?: return false + } - if (best.second == Long.MAX_VALUE) { + val reachable = proxyChecks.filter { it.second != Long.MAX_VALUE } + if (reachable.isEmpty()) { Log.w(tag, "All proxies are unreachable, switching to direct connection") disableProxyIfNeeded("$reason:all_unreachable") return false } + val mode = appPreferences.proxySmartSwitchMode.value + val selected = when (mode) { + ProxySmartSwitchMode.BEST_PING -> reachable.minByOrNull { it.second } + ProxySmartSwitchMode.RANDOM_AVAILABLE -> reachable.randomOrNull() + } ?: return false + val currentEnabled = proxies.find { it.isEnabled } - if (best.first.id != currentEnabled?.id) { + if (selected.first.id != currentEnabled?.id) { Log.d( tag, - "Switching to best proxy ${best.first.server}:${best.first.port} (${best.second}ms) ($reason)" + "Switching proxy (${mode.name}) to ${selected.first.server}:${selected.first.port} (${selected.second}ms) ($reason)" ) - return enableProxy(best.first.id, networkType, "$reason:switch") + return enableProxy(selected.first.id, networkType, "$reason:switch") } - appPreferences.setLastUsedProxyIdForNetwork(networkType, best.first.id) + appPreferences.setLastUsedProxyIdForNetwork(networkType, selected.first.id) return true } @@ -388,11 +448,6 @@ class ConnectionManager( networkType: ProxyNetworkType, reason: String ): Boolean { - if (appPreferences.enabledProxyId.value == proxyId) { - appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) - return true - } - val enabled = coRunCatching { withContext(dispatchers.io) { proxyRemoteSource.enableProxy(proxyId) diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt deleted file mode 100644 index a67e3fd3..00000000 --- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.monogram.data.repository - -import kotlinx.coroutines.withTimeoutOrNull -import org.monogram.data.core.coRunCatching -import org.monogram.data.datasource.remote.ProxyRemoteDataSource -import org.monogram.data.gateway.toProxyFailureMessage -import org.monogram.domain.models.ProxyModel -import org.monogram.domain.models.ProxyTypeModel -import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.ExternalProxyRepository -import org.monogram.domain.repository.ProxyTestResult - -class ExternalProxyRepositoryImpl( - private val remote: ProxyRemoteDataSource, - private val appPreferences: AppPreferencesProvider -) : ExternalProxyRepository { - - override suspend fun getProxies(): List = coRunCatching { - remote.getProxies() - }.getOrElse { emptyList() } - - override suspend fun addProxy( - server: String, port: Int, enable: Boolean, type: ProxyTypeModel - ): ProxyModel? = coRunCatching { - val proxy = remote.addProxy(server, port, enable, type) - if (enable) appPreferences.setEnabledProxyId(proxy.id) - proxy - }.getOrNull() - - override suspend fun editProxy( - proxyId: Int, server: String, port: Int, enable: Boolean, type: ProxyTypeModel - ): ProxyModel? = coRunCatching { - val proxy = remote.editProxy(proxyId, server, port, enable, type) - if (enable) appPreferences.setEnabledProxyId(proxy.id) - proxy - }.getOrNull() - - override suspend fun enableProxy(proxyId: Int): Boolean = coRunCatching { - remote.enableProxy(proxyId) - appPreferences.setEnabledProxyId(proxyId) - true - }.getOrDefault(false) - - override suspend fun disableProxy(): Boolean = coRunCatching { - remote.disableProxy() - appPreferences.setEnabledProxyId(null) - true - }.getOrDefault(false) - - override suspend fun removeProxy(proxyId: Int): Boolean = coRunCatching { - remote.removeProxy(proxyId) - if (appPreferences.enabledProxyId.value == proxyId) appPreferences.setEnabledProxyId(null) - true - }.getOrDefault(false) - - override suspend fun pingProxy(proxyId: Int): Long? = withTimeoutOrNull(10_000L) { - when (val result = pingProxyDetailed(proxyId)) { - is ProxyTestResult.Success -> result.ping - is ProxyTestResult.Failure -> null - } - } - - override suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult = - withTimeoutOrNull(10_000L) { - coRunCatching { - val proxy = remote.getProxies().find { it.id == proxyId } ?: return@coRunCatching null - remote.pingProxy(proxy.server, proxy.port, proxy.type) - }.fold( - onSuccess = { ping -> - if (ping == null) ProxyTestResult.Failure("Proxy is unreachable") - else ProxyTestResult.Success(ping) - }, - onFailure = { - ProxyTestResult.Failure(it.toProxyFailureMessage() ?: "Proxy is unreachable") - } - ) - } ?: ProxyTestResult.Failure("Proxy is unreachable") - - override suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? = - when (val result = testProxyDetailed(server, port, type)) { - is ProxyTestResult.Success -> result.ping - is ProxyTestResult.Failure -> null - } - - override suspend fun testProxyDetailed( - server: String, - port: Int, - type: ProxyTypeModel - ): ProxyTestResult = - withTimeoutOrNull(10_000L) { - coRunCatching { remote.testProxy(server, port, type) } - .fold( - onSuccess = { ProxyTestResult.Success(it) }, - onFailure = { - ProxyTestResult.Failure( - it.toProxyFailureMessage() ?: "Proxy is unreachable" - ) - } - ) - } ?: ProxyTestResult.Failure("Proxy is unreachable") - - override fun setPreferIpv6(enabled: Boolean) { - appPreferences.setPreferIpv6(enabled) - } - -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/LinkParser.kt b/data/src/main/java/org/monogram/data/repository/LinkParser.kt index 6c77602a..8983b4fe 100644 --- a/data/src/main/java/org/monogram/data/repository/LinkParser.kt +++ b/data/src/main/java/org/monogram/data/repository/LinkParser.kt @@ -4,15 +4,21 @@ import androidx.core.net.toUri import org.monogram.data.core.coRunCatching import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.proxy.MtprotoSecretNormalizer +import java.net.URLDecoder +import java.nio.charset.StandardCharsets class LinkParser { fun normalize(link: String): String = when { - link.startsWith("tg://") -> link - link.startsWith("https://t.me/") -> link - link.startsWith("http://t.me/") -> link.replace("http://", "https://") - link.startsWith("t.me/") -> "https://$link" - else -> link + normalizeForParsing(link).startsWith("tg://") -> normalizeForParsing(link) + normalizeForParsing(link).startsWith("https://t.me/") -> normalizeForParsing(link) + normalizeForParsing(link).startsWith("http://t.me/") -> normalizeForParsing(link).replace( + "http://", + "https://" + ) + + normalizeForParsing(link).startsWith("t.me/") -> "https://${normalizeForParsing(link)}" + else -> normalizeForParsing(link) } fun parsePrimary(link: String): ParsedLink? { @@ -76,30 +82,173 @@ class LinkParser { } private fun parseProxyLink(link: String): ParsedLink.AddProxy? { - val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null + val normalizedLink = normalizeTelegramScheme(link.trim()) + val uri = coRunCatching { normalizedLink.toUri() }.getOrNull() + + val scheme = uri?.scheme?.lowercase() + val host = uri?.host?.lowercase() + val pathType = uri?.pathSegments?.firstOrNull()?.lowercase() + val schemeSpecificType = uri?.schemeSpecificPart + ?.substringBefore('?') + ?.removePrefix("//") + ?.substringBefore('/') + ?.lowercase() + + val tgType = if (scheme == "tg") { + when (host ?: pathType ?: schemeSpecificType) { + "proxy" -> "proxy" + "socks" -> "socks" + "http" -> "http" + else -> null + } + } else { + null + } + + val httpsType = if ( + (scheme == "https" || scheme == "http") && + (host == "t.me" || host == "www.t.me" || host == "telegram.me" || host == "www.telegram.me") + ) { + when (pathType) { + "proxy" -> "proxy" + "socks" -> "socks" + "http" -> "http" + else -> null + } + } else { + null + } - val isProxy = link.contains("/proxy?") || link.startsWith("tg://proxy") - val isSocks = link.contains("/socks?") || link.startsWith("tg://socks") - val isHttp = link.contains("/http?") || link.startsWith("tg://http") - if (!isProxy && !isSocks && !isHttp) return null + val manualType = detectProxyTypeFromString(normalizedLink.lowercase()) + val proxyType = tgType ?: httpsType ?: manualType ?: return null + val queryMap = if (uri != null) { + parseQueryMap(uri, normalizedLink) + } else { + parseQueryMapFromLink(normalizedLink) + } - val server = uri.getQueryParameter("server") ?: return null - val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null - val secret = uri.getQueryParameter("secret") - val user = uri.getQueryParameter("user") - val pass = uri.getQueryParameter("pass") + val server = queryMap["server"] ?: return null + val port = queryMap["port"]?.toIntOrNull() ?: return null + if (server.isBlank() || port !in 1..65535) return null + val secret = queryMap["secret"] + val user = queryMap["user"] ?: queryMap["username"] + val pass = queryMap["pass"] ?: queryMap["password"] val type = when { secret != null -> { val normalized = MtprotoSecretNormalizer.normalize(secret) ?: return null ProxyTypeModel.Mtproto(normalized) } - isHttp -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) + proxyType == "http" -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) else -> ProxyTypeModel.Socks5(user ?: "", pass ?: "") } return ParsedLink.AddProxy(server, port, type) } + private fun normalizeTelegramScheme(link: String): String { + if (link.startsWith("tg://", ignoreCase = true)) return link + if (link.startsWith("tg:", ignoreCase = true)) { + return "tg://${link.substringAfter(':')}" + } + return link + } + + private fun normalizeForParsing(link: String): String { + var sanitized = link.trim() + .removeSurrounding("<", ">") + .removeSurrounding("\"") + .removeSurrounding("'") + + while (sanitized.isNotEmpty() && sanitized.last() in setOf( + ')', + ']', + '}', + '.', + ',', + ';', + '!', + '?' + ) + ) { + sanitized = sanitized.dropLast(1) + } + return sanitized + } + + private fun parseQueryMap(uri: android.net.Uri, originalLink: String): Map { + val rawQuery = uri.encodedQuery + ?: originalLink.substringAfter('?', missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?: return emptyMap() + return parseQueryMapFromRawQuery(rawQuery) + } + + private fun parseQueryMapFromLink(originalLink: String): Map { + val rawQuery = originalLink.substringAfter('?', missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?: return emptyMap() + return parseQueryMapFromRawQuery(rawQuery) + } + + private fun parseQueryMapFromRawQuery(rawQuery: String): Map { + return rawQuery.split('&') + .mapNotNull { pair -> + if (pair.isBlank()) return@mapNotNull null + val key = pair.substringBefore('=') + if (key.isBlank()) return@mapNotNull null + val value = pair.substringAfter('=', missingDelimiterValue = "") + decode(key).lowercase() to decode(value) + }.toMap() + } + + private fun detectProxyTypeFromString(linkLower: String): String? = when { + linkLower.startsWith("tg://proxy?") || + linkLower.startsWith("tg:proxy?") || + linkLower.startsWith("tg://proxy/") -> "proxy" + + linkLower.startsWith("tg://socks?") || + linkLower.startsWith("tg:socks?") || + linkLower.startsWith("tg://socks/") -> "socks" + + linkLower.startsWith("tg://http?") || + linkLower.startsWith("tg:http?") || + linkLower.startsWith("tg://http/") -> "http" + + linkLower.startsWith("https://t.me/proxy?") || + linkLower.startsWith("http://t.me/proxy?") || + linkLower.startsWith("https://www.t.me/proxy?") || + linkLower.startsWith("http://www.t.me/proxy?") || + linkLower.startsWith("https://telegram.me/proxy?") || + linkLower.startsWith("http://telegram.me/proxy?") || + linkLower.startsWith("https://www.telegram.me/proxy?") || + linkLower.startsWith("http://www.telegram.me/proxy?") -> "proxy" + + linkLower.startsWith("https://t.me/socks?") || + linkLower.startsWith("http://t.me/socks?") || + linkLower.startsWith("https://www.t.me/socks?") || + linkLower.startsWith("http://www.t.me/socks?") || + linkLower.startsWith("https://telegram.me/socks?") || + linkLower.startsWith("http://telegram.me/socks?") || + linkLower.startsWith("https://www.telegram.me/socks?") || + linkLower.startsWith("http://www.telegram.me/socks?") -> "socks" + + linkLower.startsWith("https://t.me/http?") || + linkLower.startsWith("http://t.me/http?") || + linkLower.startsWith("https://www.t.me/http?") || + linkLower.startsWith("http://www.t.me/http?") || + linkLower.startsWith("https://telegram.me/http?") || + linkLower.startsWith("http://telegram.me/http?") || + linkLower.startsWith("https://www.telegram.me/http?") || + linkLower.startsWith("http://www.telegram.me/http?") -> "http" + + else -> null + } + + private fun decode(value: String): String { + return runCatching { URLDecoder.decode(value, StandardCharsets.UTF_8.toString()) } + .getOrDefault(value) + } + private fun parseUserLink(link: String): ParsedLink.OpenUser? { val uri = coRunCatching { link.toUri() }.getOrNull() ?: return null if (!uri.scheme.equals("tg", ignoreCase = true)) return null diff --git a/data/src/main/java/org/monogram/data/repository/ProxyDiagnosticsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProxyDiagnosticsRepositoryImpl.kt new file mode 100644 index 00000000..9a0aefe2 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ProxyDiagnosticsRepositoryImpl.kt @@ -0,0 +1,126 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.remote.ProxyRemoteDataSource +import org.monogram.data.gateway.toProxyFailureMessage +import org.monogram.domain.models.ProxyCheckResult +import org.monogram.domain.models.ProxyFailureReason +import org.monogram.domain.models.ProxyInput +import org.monogram.domain.models.toProxyTypeModel +import org.monogram.domain.repository.ProxyDiagnosticsRepository + +class ProxyDiagnosticsRepositoryImpl( + private val remote: ProxyRemoteDataSource +) : ProxyDiagnosticsRepository { + private companion object { + const val DIAGNOSTIC_TIMEOUT_MS = 10_000L + } + + override suspend fun pingProxy(proxyId: Int): ProxyCheckResult = + withTimeoutOrNull(DIAGNOSTIC_TIMEOUT_MS) { + coRunCatching { + val proxy = + remote.getProxies().find { it.id == proxyId } ?: return@coRunCatching null + remote.pingProxy(proxy.server, proxy.port, proxy.type) + }.fold( + onSuccess = { latencyMs -> + if (latencyMs == null) { + ProxyCheckResult.Failure( + reason = ProxyFailureReason.UNREACHABLE, + message = "Proxy is unreachable" + ) + } else { + ProxyCheckResult.Success(latencyMs = latencyMs) + } + }, + onFailure = { error -> + val message = error.toProxyFailureMessage() ?: "Proxy is unreachable" + ProxyCheckResult.Failure( + reason = message.toFailureReason(), + message = message + ) + } + ) + } ?: ProxyCheckResult.Failure( + reason = ProxyFailureReason.TIMEOUT, + message = "Proxy check timed out" + ) + + override suspend fun testProxy(input: ProxyInput): ProxyCheckResult = + withTimeoutOrNull(DIAGNOSTIC_TIMEOUT_MS) { + coRunCatching { + remote.testProxy( + server = input.server, + port = input.port, + type = input.type.toProxyTypeModel() + ) + }.fold( + onSuccess = { latencyMs -> ProxyCheckResult.Success(latencyMs = latencyMs) }, + onFailure = { error -> + val message = error.toProxyFailureMessage() ?: "Proxy is unreachable" + ProxyCheckResult.Failure( + reason = message.toFailureReason(), + message = message + ) + } + ) + } ?: ProxyCheckResult.Failure( + reason = ProxyFailureReason.TIMEOUT, + message = "Proxy check timed out" + ) + + override suspend fun testProxyAtDc(input: ProxyInput, dcId: Int): ProxyCheckResult = + withTimeoutOrNull(DIAGNOSTIC_TIMEOUT_MS) { + coRunCatching { + remote.testProxyAtDc( + server = input.server, + port = input.port, + type = input.type.toProxyTypeModel(), + dcId = dcId + ) + }.fold( + onSuccess = { latencyMs -> ProxyCheckResult.Success(latencyMs = latencyMs) }, + onFailure = { error -> + val message = error.toProxyFailureMessage() ?: "Proxy is unreachable" + ProxyCheckResult.Failure( + reason = message.toFailureReason(), + message = message + ) + } + ) + } ?: ProxyCheckResult.Failure( + reason = ProxyFailureReason.TIMEOUT, + message = "Proxy check timed out" + ) + + override suspend fun testDirectDc(dcId: Int): ProxyCheckResult = + withTimeoutOrNull(DIAGNOSTIC_TIMEOUT_MS) { + coRunCatching { remote.testDirectDc(dcId) }.fold( + onSuccess = { latencyMs -> ProxyCheckResult.Success(latencyMs = latencyMs) }, + onFailure = { error -> + val message = + error.toProxyFailureMessage() ?: "Direct route to DC $dcId is unreachable" + ProxyCheckResult.Failure( + reason = message.toFailureReason(), + message = message + ) + } + ) + } ?: ProxyCheckResult.Failure( + reason = ProxyFailureReason.TIMEOUT, + message = "Direct route check timed out" + ) + + private fun String.toFailureReason(): ProxyFailureReason { + val normalized = lowercase() + return when { + normalized.contains("resolved") -> ProxyFailureReason.DNS_FAILURE + normalized.contains("secret") -> ProxyFailureReason.INVALID_SECRET + normalized.contains("auth") || normalized.contains("password") -> ProxyFailureReason.AUTH_FAILED + normalized.contains("timed out") || normalized.contains("timeout") -> ProxyFailureReason.TIMEOUT + normalized.contains("unreachable") || normalized.contains("refused") -> ProxyFailureReason.UNREACHABLE + else -> ProxyFailureReason.UNKNOWN + } + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProxyRepositoryImpl.kt new file mode 100644 index 00000000..cda160fb --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ProxyRepositoryImpl.kt @@ -0,0 +1,64 @@ +package org.monogram.data.repository + +import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.remote.ProxyRemoteDataSource +import org.monogram.domain.models.Proxy +import org.monogram.domain.models.ProxyInput +import org.monogram.domain.models.toDomainProxy +import org.monogram.domain.models.toProxyTypeModel +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ProxyRepository + +class ProxyRepositoryImpl( + private val remote: ProxyRemoteDataSource, + private val appPreferences: AppPreferencesProvider +) : ProxyRepository { + + override suspend fun getProxies(): List = coRunCatching { + remote.getProxies().map { it.toDomainProxy() } + }.getOrElse { emptyList() } + + override suspend fun addProxy(input: ProxyInput, enable: Boolean): Proxy? = coRunCatching { + val proxy = remote.addProxy( + server = input.server, + port = input.port, + enable = enable, + type = input.type.toProxyTypeModel() + ) + if (enable) appPreferences.setEnabledProxyId(proxy.id) + proxy.toDomainProxy() + }.getOrNull() + + override suspend fun editProxy(proxyId: Int, input: ProxyInput, enable: Boolean): Proxy? = + coRunCatching { + val proxy = remote.editProxy( + proxyId = proxyId, + server = input.server, + port = input.port, + enable = enable, + type = input.type.toProxyTypeModel() + ) + if (enable) appPreferences.setEnabledProxyId(proxy.id) + proxy.toDomainProxy() + }.getOrNull() + + override suspend fun enableProxy(proxyId: Int): Boolean = coRunCatching { + remote.enableProxy(proxyId) + appPreferences.setEnabledProxyId(proxyId) + true + }.getOrDefault(false) + + override suspend fun disableProxy(): Boolean = coRunCatching { + remote.disableProxy() + appPreferences.setEnabledProxyId(null) + true + }.getOrDefault(false) + + override suspend fun removeProxy(proxyId: Int): Boolean = coRunCatching { + remote.removeProxy(proxyId) + if (appPreferences.enabledProxyId.value == proxyId) { + appPreferences.setEnabledProxyId(null) + } + true + }.getOrDefault(false) +} diff --git a/data/src/test/java/org/monogram/data/repository/LinkParserTest.kt b/data/src/test/java/org/monogram/data/repository/LinkParserTest.kt new file mode 100644 index 00000000..6d727f3f --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/LinkParserTest.kt @@ -0,0 +1,58 @@ +package org.monogram.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ProxyTypeModel + +class LinkParserTest { + + private val parser = LinkParser() + + @Test + fun `parsePrimary parses mtproto link 1`() { + val link = "tg://proxy?server=example.com&port=443&secret=b16cbabb9281b9fc71f8352bf68523c2" + + val parsed = parser.parsePrimary(link) + + assertTrue(parsed is ParsedLink.AddProxy) + parsed as ParsedLink.AddProxy + assertEquals("example.com", parsed.server) + assertEquals(443, parsed.port) + assertTrue(parsed.type is ProxyTypeModel.Mtproto) + val type = parsed.type as ProxyTypeModel.Mtproto + assertEquals("b16cbabb9281b9fc71f8352bf68523c2", type.secret) + } + + @Test + fun `parsePrimary parses mtproto link 2`() { + val link = + "tg://proxy?server=example.com&port=443&secret=ddb16cbabb9281b9fc71f8352bf68523c2" + + val parsed = parser.parsePrimary(link) + + assertTrue(parsed is ParsedLink.AddProxy) + parsed as ParsedLink.AddProxy + assertEquals("example.com", parsed.server) + assertEquals(443, parsed.port) + assertTrue(parsed.type is ProxyTypeModel.Mtproto) + val type = parsed.type as ProxyTypeModel.Mtproto + assertEquals("ddb16cbabb9281b9fc71f8352bf68523c2", type.secret) + } + + @Test + fun `parsePrimary parses mtproto link 3`() { + val link = + "tg://proxy?server=example.com&port=443&secret=eeb16cbabb9281b9fc71f8352bf68523c26578616d706c652e636f6e" + + val parsed = parser.parsePrimary(link) + + assertTrue(parsed is ParsedLink.AddProxy) + parsed as ParsedLink.AddProxy + assertEquals("example.com", parsed.server) + assertEquals(443, parsed.port) + assertTrue(parsed.type is ProxyTypeModel.Mtproto) + val type = parsed.type as ProxyTypeModel.Mtproto + assertEquals("eeb16cbabb9281b9fc71f8352bf68523c26578616d706c652e636f6e", type.secret) + } +} \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/models/Proxy.kt b/domain/src/main/java/org/monogram/domain/models/Proxy.kt new file mode 100644 index 00000000..44451ae4 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/Proxy.kt @@ -0,0 +1,27 @@ +package org.monogram.domain.models + +data class Proxy( + val id: Int, + val server: String, + val port: Int, + val lastUsedDate: Int, + val isEnabled: Boolean, + val type: ProxyType +) + +sealed interface ProxyType { + data class Socks5( + val username: String, + val password: String + ) : ProxyType + + data class Http( + val username: String, + val password: String, + val httpOnly: Boolean + ) : ProxyType + + data class Mtproto( + val secret: String + ) : ProxyType +} diff --git a/domain/src/main/java/org/monogram/domain/models/ProxyCheckResult.kt b/domain/src/main/java/org/monogram/domain/models/ProxyCheckResult.kt new file mode 100644 index 00000000..92aa5522 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProxyCheckResult.kt @@ -0,0 +1,15 @@ +package org.monogram.domain.models + +sealed interface ProxyCheckResult { + data class Success(val latencyMs: Long) : ProxyCheckResult + data class Failure(val reason: ProxyFailureReason, val message: String) : ProxyCheckResult +} + +enum class ProxyFailureReason { + UNREACHABLE, + INVALID_SECRET, + DNS_FAILURE, + AUTH_FAILED, + TIMEOUT, + UNKNOWN +} diff --git a/domain/src/main/java/org/monogram/domain/models/ProxyInput.kt b/domain/src/main/java/org/monogram/domain/models/ProxyInput.kt new file mode 100644 index 00000000..e0459ac0 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProxyInput.kt @@ -0,0 +1,7 @@ +package org.monogram.domain.models + +data class ProxyInput( + val server: String, + val port: Int, + val type: ProxyType +) diff --git a/domain/src/main/java/org/monogram/domain/models/ProxyMapping.kt b/domain/src/main/java/org/monogram/domain/models/ProxyMapping.kt new file mode 100644 index 00000000..c8ff28c9 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProxyMapping.kt @@ -0,0 +1,41 @@ +package org.monogram.domain.models + +fun ProxyModel.toDomainProxy(): Proxy = Proxy( + id = id, + server = server, + port = port, + lastUsedDate = lastUsedDate, + isEnabled = isEnabled, + type = type.toDomainProxyType() +) + +fun Proxy.toProxyModel(): ProxyModel = ProxyModel( + id = id, + server = server, + port = port, + lastUsedDate = lastUsedDate, + isEnabled = isEnabled, + type = type.toProxyTypeModel() +) + +fun ProxyTypeModel.toDomainProxyType(): ProxyType = when (this) { + is ProxyTypeModel.Socks5 -> ProxyType.Socks5(username = username, password = password) + is ProxyTypeModel.Http -> ProxyType.Http( + username = username, + password = password, + httpOnly = httpOnly + ) + + is ProxyTypeModel.Mtproto -> ProxyType.Mtproto(secret = secret) +} + +fun ProxyType.toProxyTypeModel(): ProxyTypeModel = when (this) { + is ProxyType.Socks5 -> ProxyTypeModel.Socks5(username = username, password = password) + is ProxyType.Http -> ProxyTypeModel.Http( + username = username, + password = password, + httpOnly = httpOnly + ) + + is ProxyType.Mtproto -> ProxyTypeModel.Mtproto(secret = secret) +} diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 7449b32c..5055fe84 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -34,6 +34,11 @@ enum class ProxyUnavailableFallback { KEEP_CURRENT } +enum class ProxySmartSwitchMode { + BEST_PING, + RANDOM_AVAILABLE +} + data class ProxyNetworkRule( val mode: ProxyNetworkMode, val specificProxyId: Int? = null, @@ -48,6 +53,10 @@ fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode { } } +const val DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES = 5 +const val MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES = 1 +const val MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES = 60 + interface AppPreferencesProvider { val autoDownloadMobile: StateFlow val autoDownloadWifi: StateFlow @@ -84,6 +93,8 @@ interface AppPreferencesProvider { val enabledProxyId: StateFlow val isAutoBestProxyEnabled: StateFlow + val proxySmartSwitchMode: StateFlow + val proxyAutoCheckIntervalMinutes: StateFlow val preferIpv6: StateFlow val proxySortMode: StateFlow val proxyUnavailableFallback: StateFlow @@ -133,6 +144,8 @@ interface AppPreferencesProvider { fun setEnabledProxyId(proxyId: Int?) fun setAutoBestProxyEnabled(enabled: Boolean) + fun setProxySmartSwitchMode(mode: ProxySmartSwitchMode) + fun setProxyAutoCheckIntervalMinutes(minutes: Int) fun setPreferIpv6(enabled: Boolean) fun setProxySortMode(mode: ProxySortMode) fun setProxyUnavailableFallback(fallback: ProxyUnavailableFallback) diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt deleted file mode 100644 index af7d7be4..00000000 --- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.monogram.domain.repository - -import org.monogram.domain.models.ProxyModel -import org.monogram.domain.models.ProxyTypeModel - -sealed interface ProxyTestResult { - data class Success(val ping: Long) : ProxyTestResult - data class Failure(val message: String) : ProxyTestResult -} - -interface ExternalProxyRepository { - suspend fun getProxies(): List - suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? - suspend fun editProxy(proxyId: Int, server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? - suspend fun enableProxy(proxyId: Int): Boolean - suspend fun disableProxy(): Boolean - suspend fun removeProxy(proxyId: Int): Boolean - suspend fun pingProxy(proxyId: Int): Long? - suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult - suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? - suspend fun testProxyDetailed(server: String, port: Int, type: ProxyTypeModel): ProxyTestResult - fun setPreferIpv6(enabled: Boolean) -} diff --git a/domain/src/main/java/org/monogram/domain/repository/ProxyDiagnosticsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ProxyDiagnosticsRepository.kt new file mode 100644 index 00000000..70b1a344 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ProxyDiagnosticsRepository.kt @@ -0,0 +1,11 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.ProxyCheckResult +import org.monogram.domain.models.ProxyInput + +interface ProxyDiagnosticsRepository { + suspend fun pingProxy(proxyId: Int): ProxyCheckResult + suspend fun testProxy(input: ProxyInput): ProxyCheckResult + suspend fun testProxyAtDc(input: ProxyInput, dcId: Int): ProxyCheckResult + suspend fun testDirectDc(dcId: Int): ProxyCheckResult +} diff --git a/domain/src/main/java/org/monogram/domain/repository/ProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ProxyRepository.kt new file mode 100644 index 00000000..2f1551a3 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ProxyRepository.kt @@ -0,0 +1,13 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.Proxy +import org.monogram.domain.models.ProxyInput + +interface ProxyRepository { + suspend fun getProxies(): List + suspend fun addProxy(input: ProxyInput, enable: Boolean): Proxy? + suspend fun editProxy(proxyId: Int, input: ProxyInput, enable: Boolean): Proxy? + suspend fun enableProxy(proxyId: Int): Boolean + suspend fun disableProxy(): Boolean + suspend fun removeProxy(proxyId: Int): Boolean +} diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTile.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTile.kt index 6368d8c5..74c0ce0f 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTile.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsTile.kt @@ -2,7 +2,15 @@ package org.monogram.presentation.core.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -12,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -27,6 +36,7 @@ fun SettingsTile( titleColor: Color = MaterialTheme.colorScheme.onSurface, position: ItemPosition = ItemPosition.STANDALONE, onClick: () -> Unit, + enabled: Boolean = true, trailingContent: @Composable (() -> Unit)? = null ) { val cornerRadius = 24.dp @@ -56,7 +66,8 @@ fun SettingsTile( modifier = Modifier .fillMaxWidth() .clip(shape) - .clickable(onClick = onClick) + .alpha(if (enabled) 1f else 0.5f) + .clickable(enabled = enabled, onClick = onClick) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index 6af73078..02b823c7 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -11,9 +11,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES +import org.monogram.domain.repository.MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES +import org.monogram.domain.repository.MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySmartSwitchMode import org.monogram.domain.repository.ProxySortMode import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.PushProvider @@ -343,6 +347,29 @@ class AppPreferences( private val _isAutoBestProxyEnabled = MutableStateFlow(prefs.getBoolean(KEY_AUTO_BEST_PROXY, false)) override val isAutoBestProxyEnabled: StateFlow = _isAutoBestProxyEnabled + private val _proxySmartSwitchMode = MutableStateFlow( + runCatching { + ProxySmartSwitchMode.valueOf( + prefs.getString( + KEY_PROXY_SMART_SWITCH_MODE, + ProxySmartSwitchMode.RANDOM_AVAILABLE.name + ) ?: ProxySmartSwitchMode.RANDOM_AVAILABLE.name + ) + }.getOrDefault(ProxySmartSwitchMode.RANDOM_AVAILABLE) + ) + override val proxySmartSwitchMode: StateFlow = _proxySmartSwitchMode + + private val _proxyAutoCheckIntervalMinutes = MutableStateFlow( + prefs.getInt( + KEY_PROXY_AUTO_CHECK_INTERVAL_MINUTES, + DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES + ).coerceIn( + MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES, + MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES + ) + ) + override val proxyAutoCheckIntervalMinutes: StateFlow = _proxyAutoCheckIntervalMinutes + private val _preferIpv6 = MutableStateFlow(prefs.getBoolean(KEY_PREFER_IPV6, false)) override val preferIpv6: StateFlow = _preferIpv6 @@ -968,6 +995,20 @@ class AppPreferences( _isAutoBestProxyEnabled.value = enabled } + override fun setProxySmartSwitchMode(mode: ProxySmartSwitchMode) { + prefs.edit().putString(KEY_PROXY_SMART_SWITCH_MODE, mode.name).apply() + _proxySmartSwitchMode.value = mode + } + + override fun setProxyAutoCheckIntervalMinutes(minutes: Int) { + val safeMinutes = minutes.coerceIn( + MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES, + MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES + ) + prefs.edit().putInt(KEY_PROXY_AUTO_CHECK_INTERVAL_MINUTES, safeMinutes).apply() + _proxyAutoCheckIntervalMinutes.value = safeMinutes + } + override fun setPreferIpv6(enabled: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, enabled).apply() _preferIpv6.value = enabled @@ -1125,6 +1166,8 @@ class AppPreferences( _adBlockWhitelistedChannels.value = emptySet() _enabledProxyId.value = null _isAutoBestProxyEnabled.value = false + _proxySmartSwitchMode.value = ProxySmartSwitchMode.RANDOM_AVAILABLE + _proxyAutoCheckIntervalMinutes.value = DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES _preferIpv6.value = false _proxySortMode.value = ProxySortMode.LOWEST_PING _proxyUnavailableFallback.value = ProxyUnavailableFallback.BEST_PROXY @@ -1263,6 +1306,9 @@ class AppPreferences( private const val KEY_ENABLED_PROXY_ID = "enabled_proxy_id" private const val KEY_AUTO_BEST_PROXY = "auto_best_proxy" + private const val KEY_PROXY_SMART_SWITCH_MODE = "proxy_smart_switch_mode" + private const val KEY_PROXY_AUTO_CHECK_INTERVAL_MINUTES = + "proxy_auto_check_interval_minutes" private const val KEY_PREFER_IPV6 = "prefer_ipv6" private const val KEY_PROXY_SORT_MODE = "proxy_sort_mode" private const val KEY_PROXY_UNAVAILABLE_FALLBACK = "proxy_unavailable_fallback" diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index f5187e46..320e29ed 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -27,7 +27,6 @@ import org.monogram.domain.repository.ChatStatisticsRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.ExternalNavigator -import org.monogram.domain.repository.ExternalProxyRepository import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository @@ -43,6 +42,8 @@ import org.monogram.domain.repository.PaymentRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.ProxyDiagnosticsRepository +import org.monogram.domain.repository.ProxyRepository import org.monogram.domain.repository.PushDebugRepository import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository @@ -107,7 +108,8 @@ interface RepositoriesContainer { val locationRepository: LocationRepository val privacyRepository: PrivacyRepository val linkHandlerRepository: LinkHandlerRepository - val externalProxyRepository: ExternalProxyRepository + val proxyRepository: ProxyRepository + val proxyDiagnosticsRepository: ProxyDiagnosticsRepository val stickerRepository: StickerRepository val gifRepository: GifRepository val emojiRepository: EmojiRepository diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index b8291666..f9282e3a 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -28,7 +28,6 @@ import org.monogram.domain.repository.ChatStatisticsRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.ExternalNavigator -import org.monogram.domain.repository.ExternalProxyRepository import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository @@ -44,6 +43,8 @@ import org.monogram.domain.repository.PaymentRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.ProxyDiagnosticsRepository +import org.monogram.domain.repository.ProxyRepository import org.monogram.domain.repository.PushDebugRepository import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository @@ -108,7 +109,8 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val locationRepository: LocationRepository by lazy { koin.get() } override val privacyRepository: PrivacyRepository by lazy { koin.get() } override val linkHandlerRepository: LinkHandlerRepository by lazy { koin.get() } - override val externalProxyRepository: ExternalProxyRepository by lazy { koin.get() } + override val proxyRepository: ProxyRepository by lazy { koin.get() } + override val proxyDiagnosticsRepository: ProxyDiagnosticsRepository by lazy { koin.get() } override val stickerRepository: StickerRepository by lazy { koin.get() } override val gifRepository: GifRepository by lazy { koin.get() } override val emojiRepository: EmojiRepository by lazy { koin.get() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt index d64f798b..f72cb8de 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt @@ -219,10 +219,12 @@ fun isEmojiLegacy(codePoint: Int): Boolean { } fun normalizeUrl(url: String): String { - return if (url.startsWith("http://") || url.startsWith("https://")) { - url + val trimmed = url.trim() + val hasExplicitScheme = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:").containsMatchIn(trimmed) + return if (hasExplicitScheme) { + trimmed } else { - "https://$url" + "https://$trimmed" } } diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 6976b6b6..21b053a4 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -29,18 +29,26 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +import org.json.JSONObject import org.monogram.domain.managers.PhoneManager import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.Proxy +import org.monogram.domain.models.ProxyCheckResult +import org.monogram.domain.models.ProxyInput +import org.monogram.domain.models.ProxyType import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.models.toDomainProxyType import org.monogram.domain.repository.AuthRepository import org.monogram.domain.repository.AuthStep import org.monogram.domain.repository.CacheProvider import org.monogram.domain.repository.ExternalNavigator -import org.monogram.domain.repository.ExternalProxyRepository import org.monogram.domain.repository.LinkAction import org.monogram.domain.repository.LinkHandlerRepository import org.monogram.domain.repository.MessageDisplayer import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.ProxyDiagnosticsRepository +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxyRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StorageRepository import org.monogram.domain.repository.UpdateRepository @@ -90,7 +98,9 @@ class DefaultRootComponent( private val messageRepository: MessageRepository = container.repositories.messageRepository private val storageRepository: StorageRepository = container.repositories.storageRepository private val linkHandlerRepository: LinkHandlerRepository = container.repositories.linkHandlerRepository - private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository + private val proxyRepository: ProxyRepository = container.repositories.proxyRepository + private val proxyDiagnosticsRepository: ProxyDiagnosticsRepository = + container.repositories.proxyDiagnosticsRepository private val stickerRepository: StickerRepository = container.repositories.stickerRepository private val messageDisplayer: MessageDisplayer = container.utils.messageDisplayer() private val externalNavigator: ExternalNavigator = container.utils.externalNavigator() @@ -111,6 +121,7 @@ class DefaultRootComponent( private val _proxyToConfirm = MutableStateFlow(RootComponent.ProxyConfirmState()) override val proxyToConfirm = _proxyToConfirm.asStateFlow() + private var proxyCheckRequestToken = 0L private val _chatToConfirmJoin = MutableStateFlow(RootComponent.ChatConfirmJoinState()) override val chatToConfirmJoin = _chatToConfirmJoin.asStateFlow() @@ -325,14 +336,23 @@ class DefaultRootComponent( } override fun dismissProxyConfirm() { + proxyCheckRequestToken++ _proxyToConfirm.update { RootComponent.ProxyConfirmState() } } override fun confirmProxy(server: String, port: Int, type: ProxyTypeModel) { + proxyCheckRequestToken++ scope.launch { - val proxy = externalProxyRepository.addProxy(server, port, true, type) + val proxy = proxyRepository.addProxy( + input = ProxyInput(server = server, port = port, type = type.toDomainProxyType()), + enable = true + ) dismissProxyConfirm() if (proxy != null) { + addProxyToBackup(proxy) + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) + } messageDisplayer.show("Proxy added and enabled") } else { messageDisplayer.show("Failed to add proxy") @@ -345,24 +365,63 @@ class DefaultRootComponent( val server = currentState.server ?: return val port = currentState.port ?: return val type = currentState.type ?: return + val requestToken = ++proxyCheckRequestToken _proxyToConfirm.update { it.copy(isChecking = true, ping = null) } scope.launch { - val ping = try { - withContext(Dispatchers.IO) { - externalProxyRepository.testProxy(server, port, type) - } ?: -1L - } catch (e: Exception) { - -1L + val ping = when ( + val result = withContext(Dispatchers.IO) { + proxyDiagnosticsRepository.testProxy( + ProxyInput(server = server, port = port, type = type.toDomainProxyType()) + ) + } + ) { + is ProxyCheckResult.Success -> result.latencyMs + is ProxyCheckResult.Failure -> -1L } - if (_proxyToConfirm.value.server == server && _proxyToConfirm.value.port == port) { + if ( + requestToken == proxyCheckRequestToken && + _proxyToConfirm.value.server == server && + _proxyToConfirm.value.port == port && + _proxyToConfirm.value.type == type + ) { _proxyToConfirm.update { it.copy(ping = ping, isChecking = false) } } } } + private fun addProxyToBackup(proxy: Proxy) { + val current = appPreferences.userProxyBackups.value.toMutableSet() + current.add(serializeProxyBackup(proxy)) + appPreferences.setUserProxyBackups(current) + } + + private fun serializeProxyBackup(proxy: Proxy): String = JSONObject().apply { + put("server", proxy.server) + put("port", proxy.port) + when (val type = proxy.type) { + is ProxyType.Mtproto -> { + put("type", "mtproto") + put("secret", type.secret) + } + + is ProxyType.Socks5 -> { + put("type", "socks5") + put("username", type.username) + put("password", type.password) + } + + is ProxyType.Http -> { + put("type", "http") + put("username", type.username) + put("password", type.password) + put("httpOnly", type.httpOnly) + } + } + }.toString() + override fun dismissChatConfirmJoin() { _chatToConfirmJoin.update { RootComponent.ChatConfirmJoinState() } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 3fa4374c..b91a213a 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -12,16 +12,25 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject +import org.monogram.domain.models.ProxyCheckResult +import org.monogram.domain.models.ProxyInput import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.models.toDomainProxyType +import org.monogram.domain.models.toProxyModel import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES +import org.monogram.domain.repository.LinkAction +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.ProxyDiagnosticsRepository import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxyRepository +import org.monogram.domain.repository.ProxySmartSwitchMode import org.monogram.domain.repository.ProxySortMode -import org.monogram.domain.repository.ProxyTestResult import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope @@ -40,13 +49,15 @@ interface ProxyComponent { fun onRemoveProxy(proxyId: Int) fun onPingAll() fun onPingProxy(proxyId: Int) - fun onTestProxy(server: String, port: Int, type: ProxyTypeModel) + fun onPingDatacenters() fun onAddProxy(server: String, port: Int, type: ProxyTypeModel) fun onEditProxy(proxyId: Int, server: String, port: Int, type: ProxyTypeModel) fun onDismissDeleteConfirmation() fun onConfirmDelete() fun onDismissAddEdit() fun onAutoBestProxyToggled(enabled: Boolean) + fun onSmartSwitchModeChanged(mode: ProxySmartSwitchMode) + fun onSmartSwitchAutoCheckIntervalChanged(minutes: Int) fun onPreferIpv6Toggled(enabled: Boolean) fun onProxySortModeChanged(mode: ProxySortMode) fun onProxyUnavailableFallbackChanged(fallback: ProxyUnavailableFallback) @@ -54,6 +65,7 @@ interface ProxyComponent { fun onToggleFavoriteProxy(proxyId: Int) fun exportProxiesJson(): String fun importProxiesJson(json: String) + fun importProxiesFromText(rawText: String) fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode) fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int) fun onClearUnavailableProxies() @@ -69,6 +81,8 @@ interface ProxyComponent { val isLoading: Boolean = false, val isAddingProxy: Boolean = false, val isAutoBestProxyEnabled: Boolean = false, + val smartSwitchMode: ProxySmartSwitchMode = ProxySmartSwitchMode.RANDOM_AVAILABLE, + val smartSwitchAutoCheckIntervalMinutes: Int = DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES, val preferIpv6: Boolean = false, val proxySortMode: ProxySortMode = ProxySortMode.LOWEST_PING, val proxyUnavailableFallback: ProxyUnavailableFallback = ProxyUnavailableFallback.BEST_PROXY, @@ -79,13 +93,14 @@ interface ProxyComponent { }, val proxyToEdit: ProxyModel? = null, val proxyToDelete: ProxyModel? = null, - val testPing: Long? = null, - val testError: String? = null, + val isDcTesting: Boolean = false, + val dcPingByDcId: Map = emptyMap(), + val dcPingErrorsByDcId: Map = emptyMap(), val proxyErrors: Map = emptyMap(), - val isTesting: Boolean = false, val toastMessage: String? = null, val showClearOfflineConfirmation: Boolean = false, - val showRemoveAllConfirmation: Boolean = false + val showRemoveAllConfirmation: Boolean = false, + val checkingProxyIds: Set = emptySet() ) } @@ -93,9 +108,17 @@ class DefaultProxyComponent( context: AppComponentContext, private val onBack: () -> Unit ) : ProxyComponent, AppComponentContext by context { + private companion object { + val DC_IDS = listOf(2, 1, 3, 4, 5) + } private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences - private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository + private val proxyRepository: ProxyRepository = container.repositories.proxyRepository + private val linkHandlerRepository: LinkHandlerRepository = + container.repositories.linkHandlerRepository + private val stringProvider: StringProvider = container.utils.stringProvider() + private val proxyDiagnosticsRepository: ProxyDiagnosticsRepository = + container.repositories.proxyDiagnosticsRepository private val _state = MutableValue(ProxyComponent.State()) override val state: Value = _state @@ -103,6 +126,12 @@ class DefaultProxyComponent( private var restoreAttempted = false private var lastToastMessage: String? = null private var lastToastAtMs: Long = 0L + private var pingRequestToken = 0L + private val pingRequestByProxyId = mutableMapOf() + + private fun tr(resName: String, vararg args: Any): String { + return stringProvider.getString(resName, *args) + } private fun showToastThrottled(message: String, throttleMs: Long = 1500L) { val now = System.currentTimeMillis() @@ -113,30 +142,48 @@ class DefaultProxyComponent( _state.update { it.copy(toastMessage = message) } } + private fun reservePingRequest(proxyIds: Collection): Long { + val token = ++pingRequestToken + proxyIds.forEach { proxyId -> pingRequestByProxyId[proxyId] = token } + return token + } + + private fun isLatestPingRequest(proxyId: Int, token: Long): Boolean { + return pingRequestByProxyId[proxyId] == token + } + init { scope.launch { coRunCatching { refreshProxies(shouldPing = true) } .onFailure { _state.update { state -> state.copy(isLoading = false) } - showToastThrottled("Failed to load proxies") + showToastThrottled(tr("proxy_load_failed")) } } combine( appPreferences.isAutoBestProxyEnabled, + appPreferences.proxySmartSwitchMode, + appPreferences.proxyAutoCheckIntervalMinutes, appPreferences.preferIpv6, - appPreferences.proxySortMode, - appPreferences.proxyUnavailableFallback, - appPreferences.hideOfflineProxies, - ) { autoBest, ipv6, sortMode, fallback, hideOffline -> + appPreferences.proxySortMode + ) { autoBest, switchMode, checkIntervalMinutes, ipv6, sortMode -> ProxyPreferencesBaseState( autoBest = autoBest, + switchMode = switchMode, + checkIntervalMinutes = checkIntervalMinutes, preferIpv6 = ipv6, sortMode = sortMode, - fallback = fallback, - hideOffline = hideOffline + fallback = ProxyUnavailableFallback.BEST_PROXY, + hideOffline = false ) } + .combine(appPreferences.proxyUnavailableFallback) { base, fallback -> + base.copy(fallback = fallback) + } + .combine(appPreferences.hideOfflineProxies) { base, hideOffline -> + base.copy(hideOffline = hideOffline) + } .combine(appPreferences.favoriteProxyId) { base, favoriteProxyId -> base to favoriteProxyId } @@ -144,6 +191,8 @@ class DefaultProxyComponent( val (base, favoriteProxyId) = baseWithFavorite ProxyPreferencesState( autoBest = base.autoBest, + switchMode = base.switchMode, + checkIntervalMinutes = base.checkIntervalMinutes, preferIpv6 = base.preferIpv6, sortMode = base.sortMode, fallback = base.fallback, @@ -163,6 +212,8 @@ class DefaultProxyComponent( ) current.copy( isAutoBestProxyEnabled = prefs.autoBest, + smartSwitchMode = prefs.switchMode, + smartSwitchAutoCheckIntervalMinutes = prefs.checkIntervalMinutes, preferIpv6 = prefs.preferIpv6, proxySortMode = prefs.sortMode, proxyUnavailableFallback = prefs.fallback, @@ -177,6 +228,8 @@ class DefaultProxyComponent( private data class ProxyPreferencesBaseState( val autoBest: Boolean, + val switchMode: ProxySmartSwitchMode, + val checkIntervalMinutes: Int, val preferIpv6: Boolean, val sortMode: ProxySortMode, val fallback: ProxyUnavailableFallback, @@ -185,6 +238,8 @@ class DefaultProxyComponent( private data class ProxyPreferencesState( val autoBest: Boolean, + val switchMode: ProxySmartSwitchMode, + val checkIntervalMinutes: Int, val preferIpv6: Boolean, val sortMode: ProxySortMode, val fallback: ProxyUnavailableFallback, @@ -197,8 +252,8 @@ class DefaultProxyComponent( _state.update { it.copy(isLoading = true) } val restoredProxies = coRunCatching { restoreUserProxiesIfNeeded() } .getOrElse { emptyList() } - val allProxies = coRunCatching { externalProxyRepository.getProxies() } - .onFailure { showToastThrottled("Failed to load proxies") } + val allProxies = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } } + .onFailure { showToastThrottled(tr("proxy_load_failed")) } .getOrElse { emptyList() } .ifEmpty { restoredProxies } _state.update { @@ -212,6 +267,7 @@ class DefaultProxyComponent( it.favoriteProxyId ), proxyErrors = it.proxyErrors.filterKeys { id -> id in availableIds }, + checkingProxyIds = it.checkingProxyIds.filter { id -> id in availableIds }.toSet(), isLoading = false ) } @@ -330,7 +386,7 @@ class DefaultProxyComponent( return emptyList() } - val existing = coRunCatching { externalProxyRepository.getProxies() } + val existing = coRunCatching { proxyRepository.getProxies() } .getOrElse { emptyList() } if (existing.isNotEmpty()) { restoreAttempted = true @@ -338,12 +394,14 @@ class DefaultProxyComponent( } val restored = backups.mapNotNull { parseProxyBackup(it) }.mapNotNull { backup -> - externalProxyRepository.addProxy( - server = backup.server, - port = backup.port, - enable = false, - type = backup.type - ) + proxyRepository.addProxy( + input = ProxyInput( + server = backup.server, + port = backup.port, + type = backup.type.toDomainProxyType() + ), + enable = false + )?.toProxyModel() } restoreAttempted = true return restored @@ -491,10 +549,7 @@ class DefaultProxyComponent( _state.update { it.copy( isAddingProxy = true, - proxyToEdit = null, - testPing = null, - testError = null, - isTesting = false + proxyToEdit = null ) } } @@ -503,10 +558,7 @@ class DefaultProxyComponent( _state.update { it.copy( proxyToEdit = proxy, - isAddingProxy = false, - testPing = null, - testError = null, - isTesting = false + isAddingProxy = false ) } } @@ -543,8 +595,8 @@ class DefaultProxyComponent( override fun importProxiesJson(json: String) { scope.launch { - val existing = coRunCatching { externalProxyRepository.getProxies() } - .onFailure { showToastThrottled("Failed to load existing proxies") } + val existing = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } } + .onFailure { showToastThrottled(tr("proxy_existing_load_failed")) } .getOrElse { emptyList() } val fingerprintToId = existing.associate { proxy -> proxyFingerprint(proxy.server, proxy.port, proxy.type) to proxy.id @@ -568,7 +620,7 @@ class DefaultProxyComponent( }.getOrNull() if (parsedEntries == null) { - _state.update { it.copy(toastMessage = "Import failed: invalid file") } + _state.update { it.copy(toastMessage = tr("proxy_import_invalid_file")) } return@launch } @@ -594,12 +646,14 @@ class DefaultProxyComponent( return@forEach } - val proxy = externalProxyRepository.addProxy( - server = backup.server, - port = backup.port, - enable = false, - type = backup.type - ) + val proxy = proxyRepository.addProxy( + input = ProxyInput( + server = backup.server, + port = backup.port, + type = backup.type.toDomainProxyType() + ), + enable = false + )?.toProxyModel() if (proxy != null) { addProxyToBackup(proxy) @@ -614,19 +668,165 @@ class DefaultProxyComponent( } favoriteProxyIdToSet?.let { appPreferences.setFavoriteProxyId(it) } - refreshProxies(shouldPing = false) + refreshProxies(shouldPing = true) _state.update { - it.copy(toastMessage = "Imported: $added, skipped: $skipped, invalid: $invalid") + it.copy(toastMessage = tr("proxy_import_summary_format", added, skipped, invalid)) } } } + override fun importProxiesFromText(rawText: String) { + scope.launch { + val normalized = rawText.trim() + if (normalized.isBlank()) { + showToastThrottled(tr("proxy_clipboard_empty")) + return@launch + } + + val candidates = extractProxyLinkCandidates(rawText) + if (candidates.isEmpty()) { + showToastThrottled(tr("proxy_no_links_found")) + return@launch + } + + val existing = coRunCatching { proxyRepository.getProxies().map { it.toProxyModel() } } + .onFailure { showToastThrottled(tr("proxy_existing_load_failed")) } + .getOrElse { emptyList() } + val knownFingerprints = existing + .mapTo(mutableSetOf()) { proxyFingerprint(it.server, it.port, it.type) } + + var added = 0 + var skipped = 0 + var invalid = 0 + + candidates.forEach { candidate -> + val action = runCatching { linkHandlerRepository.handleLink(candidate) }.getOrNull() + val proxy = action as? LinkAction.AddProxy + if (proxy == null) { + invalid++ + return@forEach + } + + val fingerprint = proxyFingerprint(proxy.server, proxy.port, proxy.type) + if (!knownFingerprints.add(fingerprint)) { + skipped++ + return@forEach + } + + val addedProxy = proxyRepository.addProxy( + input = ProxyInput( + server = proxy.server, + port = proxy.port, + type = proxy.type.toDomainProxyType() + ), + enable = false + )?.toProxyModel() + + if (addedProxy != null) { + addProxyToBackup(addedProxy) + added++ + } else { + knownFingerprints.remove(fingerprint) + invalid++ + } + } + + if (added > 0) { + refreshProxies(shouldPing = true) + } + + showToastThrottled(tr("proxy_import_summary_format", added, skipped, invalid)) + } + } + + private fun extractProxyLinkCandidates(rawText: String): List { + val linkRegex = Regex( + pattern = """(?i)(tg:(?://)?[^\s]+|https?://(?:t\.me|www\.t\.me|telegram\.me|www\.telegram\.me)/[^\s]+)""" + ) + val fromRegex = linkRegex.findAll(rawText).map { it.value } + val fromLines = rawText.lineSequence() + return (fromRegex + fromLines) + .map { normalizeForParsing(it) } + .map { normalizeTelegramScheme(it) } + .filter { it.isNotBlank() && looksLikeProxyLink(it) } + .distinct() + .toList() + } + + private fun normalizeTelegramScheme(link: String): String { + if (link.startsWith("tg://", ignoreCase = true)) return link + if (link.startsWith("tg:", ignoreCase = true)) { + return "tg://${link.substringAfter(':')}" + } + return link + } + + private fun normalizeForParsing(link: String): String { + var sanitized = link.trim() + .removeSurrounding("<", ">") + .removeSurrounding("\"") + .removeSurrounding("'") + + while (sanitized.isNotEmpty() && sanitized.last() in setOf( + ')', + ']', + '}', + '.', + ',', + ';', + '!', + '?' + ) + ) { + sanitized = sanitized.dropLast(1) + } + return sanitized + } + + private fun looksLikeProxyLink(link: String): Boolean { + val linkLower = link.lowercase() + return linkLower.startsWith("tg://proxy?") || + linkLower.startsWith("tg:proxy?") || + linkLower.startsWith("tg://proxy/") || + linkLower.startsWith("tg://socks?") || + linkLower.startsWith("tg:socks?") || + linkLower.startsWith("tg://socks/") || + linkLower.startsWith("tg://http?") || + linkLower.startsWith("tg:http?") || + linkLower.startsWith("tg://http/") || + linkLower.startsWith("https://t.me/proxy?") || + linkLower.startsWith("http://t.me/proxy?") || + linkLower.startsWith("https://www.t.me/proxy?") || + linkLower.startsWith("http://www.t.me/proxy?") || + linkLower.startsWith("https://telegram.me/proxy?") || + linkLower.startsWith("http://telegram.me/proxy?") || + linkLower.startsWith("https://www.telegram.me/proxy?") || + linkLower.startsWith("http://www.telegram.me/proxy?") || + linkLower.startsWith("https://t.me/socks?") || + linkLower.startsWith("http://t.me/socks?") || + linkLower.startsWith("https://www.t.me/socks?") || + linkLower.startsWith("http://www.t.me/socks?") || + linkLower.startsWith("https://telegram.me/socks?") || + linkLower.startsWith("http://telegram.me/socks?") || + linkLower.startsWith("https://www.telegram.me/socks?") || + linkLower.startsWith("http://www.telegram.me/socks?") || + linkLower.startsWith("https://t.me/http?") || + linkLower.startsWith("http://t.me/http?") || + linkLower.startsWith("https://www.t.me/http?") || + linkLower.startsWith("http://www.t.me/http?") || + linkLower.startsWith("https://telegram.me/http?") || + linkLower.startsWith("http://telegram.me/http?") || + linkLower.startsWith("https://www.telegram.me/http?") || + linkLower.startsWith("http://www.telegram.me/http?") + } + override fun onEnableProxy(proxyId: Int) { scope.launch { - if (externalProxyRepository.enableProxy(proxyId)) { + if (proxyRepository.enableProxy(proxyId)) { ProxyNetworkType.entries.forEach { networkType -> appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) } + resetDcDiagnostics() refreshProxies(shouldPing = false) onPingProxy(proxyId) } @@ -635,7 +835,8 @@ class DefaultProxyComponent( override fun onDisableProxy() { scope.launch { - if (externalProxyRepository.disableProxy()) { + if (proxyRepository.disableProxy()) { + resetDcDiagnostics() refreshProxies(shouldPing = false) } } @@ -654,26 +855,52 @@ class DefaultProxyComponent( private suspend fun performPingAll() { val allProxies = _state.value.proxies + if (allProxies.isEmpty()) return + val proxyIds = allProxies.map { it.id } + val requestToken = reservePingRequest(proxyIds) + + _state.update { + val updatedProxies = it.proxies.map { proxy -> + if (proxy.id in proxyIds) proxy.copy(ping = null) else proxy + } + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ), + checkingProxyIds = it.checkingProxyIds + proxyIds + ) + } + val pingResults = coroutineScope { allProxies.map { proxy -> proxy.id to async { - externalProxyRepository.pingProxyDetailed(proxy.id) + proxyDiagnosticsRepository.pingProxy(proxy.id) } }.associate { (id, job) -> id to job.await() } } - val updatedProxies = _state.value.proxies.map { proxy -> - when (val result = pingResults[proxy.id]) { - is ProxyTestResult.Success -> proxy.copy(ping = result.ping) - is ProxyTestResult.Failure -> proxy.copy(ping = -1L) - else -> proxy - } - } - _state.update { val updatedErrors = it.proxyErrors.toMutableMap() + val finishedIds = mutableSetOf() + val updatedProxies = it.proxies.map { proxy -> + val result = pingResults[proxy.id] + if (!isLatestPingRequest(proxy.id, requestToken) || result == null) { + proxy + } else { + finishedIds += proxy.id + when (result) { + is ProxyCheckResult.Success -> proxy.copy(ping = result.latencyMs) + is ProxyCheckResult.Failure -> proxy.copy(ping = -1L) + } + } + } pingResults.forEach { (proxyId, result) -> - if (result is ProxyTestResult.Failure) { + if (!isLatestPingRequest(proxyId, requestToken)) return@forEach + if (result is ProxyCheckResult.Failure) { updatedErrors[proxyId] = result.message } else { updatedErrors.remove(proxyId) @@ -687,22 +914,40 @@ class DefaultProxyComponent( it.hideOfflineProxies, it.favoriteProxyId ), - proxyErrors = updatedErrors + proxyErrors = updatedErrors, + checkingProxyIds = it.checkingProxyIds - finishedIds ) } } override fun onPingProxy(proxyId: Int) { scope.launch { - val result = externalProxyRepository.pingProxyDetailed(proxyId) - val ping = if (result is ProxyTestResult.Success) result.ping else -1L - val errorMessage = (result as? ProxyTestResult.Failure)?.message - - val updatedProxies = _state.value.proxies.map { - if (it.id == proxyId) it.copy(ping = ping) else it + val requestToken = reservePingRequest(listOf(proxyId)) + _state.update { + val updatedProxies = it.proxies.map { proxy -> + if (proxy.id == proxyId) proxy.copy(ping = null) else proxy + } + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ), + checkingProxyIds = it.checkingProxyIds + proxyId + ) } + val result = proxyDiagnosticsRepository.pingProxy(proxyId) + val ping = if (result is ProxyCheckResult.Success) result.latencyMs else -1L + val errorMessage = (result as? ProxyCheckResult.Failure)?.message + _state.update { + if (!isLatestPingRequest(proxyId, requestToken)) return@update it + val updatedProxies = it.proxies.map { proxy -> + if (proxy.id == proxyId) proxy.copy(ping = ping) else proxy + } val updatedErrors = it.proxyErrors.toMutableMap() if (errorMessage != null) updatedErrors[proxyId] = errorMessage else updatedErrors.remove(proxyId) @@ -714,51 +959,78 @@ class DefaultProxyComponent( it.hideOfflineProxies, it.favoriteProxyId ), - proxyErrors = updatedErrors + proxyErrors = updatedErrors, + checkingProxyIds = it.checkingProxyIds - proxyId ) } } } - override fun onTestProxy(server: String, port: Int, type: ProxyTypeModel) { - _state.update { it.copy(isTesting = true, testPing = null, testError = null) } + override fun onPingDatacenters() { scope.launch { - when (val result = externalProxyRepository.testProxyDetailed(server, port, type)) { - is ProxyTestResult.Success -> { - _state.update { - it.copy( - isTesting = false, - testPing = result.ping, - testError = null - ) + val activeProxy = _state.value.proxies.firstOrNull { it.isEnabled } + _state.update { + it.copy( + isDcTesting = true, + dcPingByDcId = DC_IDS.associateWith { null }, + dcPingErrorsByDcId = emptyMap() + ) + } + + val results = coroutineScope { + DC_IDS.map { dcId -> + dcId to async { + if (activeProxy != null) { + val input = ProxyInput( + server = activeProxy.server, + port = activeProxy.port, + type = activeProxy.type.toDomainProxyType() + ) + proxyDiagnosticsRepository.testProxyAtDc(input, dcId) + } else { + proxyDiagnosticsRepository.testDirectDc(dcId) + } } - } + }.associate { (dcId, job) -> dcId to job.await() } + } - is ProxyTestResult.Failure -> { - _state.update { - it.copy( - isTesting = false, - testPing = -1L, - testError = result.message - ) + _state.update { + val pings = mutableMapOf() + val errors = mutableMapOf() + results.forEach { (dcId, result) -> + when (result) { + is ProxyCheckResult.Success -> pings[dcId] = result.latencyMs + is ProxyCheckResult.Failure -> { + pings[dcId] = -1L + errors[dcId] = result.message + } } } + it.copy( + isDcTesting = false, + dcPingByDcId = pings, + dcPingErrorsByDcId = errors + ) } } } override fun onAddProxy(server: String, port: Int, type: ProxyTypeModel) { scope.launch { - val proxy = externalProxyRepository.addProxy(server, port, true, type) + val proxy = proxyRepository.addProxy( + input = ProxyInput(server = server, port = port, type = type.toDomainProxyType()), + enable = true + )?.toProxyModel() if (proxy != null) { addProxyToBackup(proxy) ProxyNetworkType.entries.forEach { networkType -> appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } + resetDcDiagnostics() upsertProxyLocally(proxy) onPingProxy(proxy.id) } else { - showToastThrottled("Failed to add proxy") + showToastThrottled(tr("proxy_add_failed")) } } } @@ -766,7 +1038,11 @@ class DefaultProxyComponent( override fun onEditProxy(proxyId: Int, server: String, port: Int, type: ProxyTypeModel) { scope.launch { val oldProxy = _state.value.proxies.find { it.id == proxyId } - val proxy = externalProxyRepository.editProxy(proxyId, server, port, true, type) + val proxy = proxyRepository.editProxy( + proxyId = proxyId, + input = ProxyInput(server = server, port = port, type = type.toDomainProxyType()), + enable = true + )?.toProxyModel() if (proxy != null) { replaceProxyInBackup(oldProxy, proxy) ProxyNetworkType.entries.forEach { networkType -> @@ -777,10 +1053,11 @@ class DefaultProxyComponent( appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } } + resetDcDiagnostics() upsertProxyLocally(proxy, replaceId = proxyId, closeEditor = true) onPingProxy(proxy.id) } else { - showToastThrottled("Failed to save proxy") + showToastThrottled(tr("proxy_save_failed")) } } } @@ -792,7 +1069,7 @@ class DefaultProxyComponent( override fun onConfirmDelete() { val proxy = _state.value.proxyToDelete ?: return scope.launch { - if (externalProxyRepository.removeProxy(proxy.id)) { + if (proxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) if (appPreferences.favoriteProxyId.value == proxy.id) { appPreferences.setFavoriteProxyId(null) @@ -816,10 +1093,7 @@ class DefaultProxyComponent( _state.update { it.copy( isAddingProxy = false, - proxyToEdit = null, - testPing = null, - testError = null, - isTesting = false + proxyToEdit = null ) } } @@ -828,8 +1102,16 @@ class DefaultProxyComponent( appPreferences.setAutoBestProxyEnabled(enabled) } + override fun onSmartSwitchModeChanged(mode: ProxySmartSwitchMode) { + appPreferences.setProxySmartSwitchMode(mode) + } + + override fun onSmartSwitchAutoCheckIntervalChanged(minutes: Int) { + appPreferences.setProxyAutoCheckIntervalMinutes(minutes) + } + override fun onPreferIpv6Toggled(enabled: Boolean) { - externalProxyRepository.setPreferIpv6(enabled) + appPreferences.setPreferIpv6(enabled) } override fun onProxySortModeChanged(mode: ProxySortMode) { @@ -864,16 +1146,28 @@ class DefaultProxyComponent( override fun onConfirmClearUnavailableProxies() { scope.launch { - val proxiesToDelete = _state.value.proxies.filter { it.ping == -1L } + val checkingIds = _state.value.checkingProxyIds + val proxiesToDelete = _state.value.proxies.filter { + (it.ping == -1L || it.ping == null) && it.id !in checkingIds + } val deletedIds = proxiesToDelete.map { it.id }.toSet() proxiesToDelete.forEach { proxy -> - if (externalProxyRepository.removeProxy(proxy.id)) { + if (proxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) } } if (appPreferences.favoriteProxyId.value in deletedIds) { appPreferences.setFavoriteProxyId(null) } + ProxyNetworkType.entries.forEach { networkType -> + val rule = appPreferences.proxyNetworkRules.value[networkType] + if (rule?.specificProxyId in deletedIds) { + appPreferences.setSpecificProxyIdForNetwork(networkType, null) + } + if (rule?.lastUsedProxyId in deletedIds) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, null) + } + } _state.update { it.copy(showClearOfflineConfirmation = false) } refreshProxies(shouldPing = false) } @@ -891,7 +1185,7 @@ class DefaultProxyComponent( override fun onConfirmRemoveAllProxies() { scope.launch { _state.value.proxies.forEach { proxy -> - if (externalProxyRepository.removeProxy(proxy.id)) { + if (proxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) } } @@ -917,4 +1211,15 @@ class DefaultProxyComponent( ) } } + + private fun resetDcDiagnostics() { + _state.update { + it.copy( + isDcTesting = false, + dcPingByDcId = emptyMap(), + dcPingErrorsByDcId = emptyMap() + ) + } + } + } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index bd6c53b0..305fa834 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -2,14 +2,11 @@ package org.monogram.presentation.settings.proxy -import android.content.ClipData -import android.widget.Toast +import android.Manifest +import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -17,12 +14,10 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,7 +29,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -44,42 +38,28 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ContentPaste import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.LinkOff import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.Password -import androidx.compose.material.icons.rounded.Pause -import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.QrCodeScanner import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Sort -import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material.icons.rounded.Star -import androidx.compose.material.icons.rounded.StarBorder import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Upload import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -88,25 +68,21 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -118,45 +94,149 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.core.content.ContextCompat import com.arkivanov.decompose.extensions.compose.subscribeAsState -import org.monogram.domain.models.ProxyModel -import org.monogram.domain.models.ProxyTypeModel -import org.monogram.domain.proxy.MtprotoSecretNormalizer +import kotlinx.coroutines.launch import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySmartSwitchMode import org.monogram.domain.repository.ProxySortMode import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.R +import org.monogram.presentation.core.ui.IntegratedQRScanner import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown -import java.net.URLEncoder -import java.nio.charset.StandardCharsets + +private enum class ProxyTab( + val titleRes: Int, + val icon: ImageVector +) { + Proxy( + titleRes = R.string.proxy_tab_proxy, + icon = Icons.Rounded.Language + ), + DcPing( + titleRes = R.string.proxy_tab_dc_ping, + icon = Icons.Rounded.Bolt + ), + Settings( + titleRes = R.string.proxy_tab_settings, + icon = Icons.Rounded.Tune + ) +} + +private data class DcDescriptor( + val id: Int, + val name: String, + val location: String +) + +private val DcCatalog = listOf( + DcDescriptor(id = 1, name = "Pluto", location = "Miami"), + DcDescriptor(id = 2, name = "Venus", location = "Amsterdam"), + DcDescriptor(id = 3, name = "Aurora", location = "Miami"), + DcDescriptor(id = 4, name = "Vesta", location = "Amsterdam"), + DcDescriptor(id = 5, name = "Flora", location = "Singapore") +) + +@Composable +private fun DcPingRow( + dc: DcDescriptor, + ping: Long?, + error: String?, + isChecking: Boolean, +) { + val statusColor = when { + isChecking -> MaterialTheme.colorScheme.primary + ping == null || ping < 0L -> MaterialTheme.colorScheme.error + ping < 250L -> Color(0xFF2E7D32) + ping < 700L -> Color(0xFFF9A825) + else -> MaterialTheme.colorScheme.error + } + + val statusText = when { + isChecking -> stringResource(R.string.proxy_checking) + ping == null || ping < 0L -> stringResource(R.string.proxy_offline) + else -> stringResource(R.string.proxy_ping_format, ping.toInt()) + } + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "DC${dc.id} ${dc.name}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = error ?: dc.location, + style = MaterialTheme.typography.bodySmall, + color = if (error == null) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error + ) + } + Surface( + shape = RoundedCornerShape(10.dp), + color = statusColor.copy(alpha = 0.16f) + ) { + Text( + text = statusText, + color = statusColor, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + } + } +} @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ProxyContent(component: ProxyComponent) { val state by component.state.subscribeAsState() val snackbarHostState = remember { SnackbarHostState() } + val uiScope = rememberCoroutineScope() val context = LocalContext.current - LocalClipboard.current + val clipboard = LocalClipboard.current + val activeProxy = state.proxies.firstOrNull { it.isEnabled } + val prioritizedVisibleProxies = remember(state.visibleProxies) { + state.visibleProxies.sortedByDescending { it.isEnabled } + } var expandedNetworkMenu by remember { mutableStateOf(null) } + var smartSwitchModeMenuExpanded by remember { mutableStateOf(false) } + var smartSwitchCheckIntervalMenuExpanded by remember { mutableStateOf(false) } var sortMenuExpanded by remember { mutableStateOf(false) } var fallbackMenuExpanded by remember { mutableStateOf(false) } var showTopMenu by remember { mutableStateOf(false) } + var showQrScanner by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(ProxyTab.Proxy) } + val smartSwitchCheckIntervalOptions = remember { listOf(1, 2, 5, 10, 15, 30, 60) } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + showQrScanner = true + } + } val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> @@ -166,17 +246,13 @@ fun ProxyContent(component: ProxyComponent) { writer.write(component.exportProxiesJson()) } }.onSuccess { - Toast.makeText( - context, - context.getString(R.string.proxy_export_success), - Toast.LENGTH_SHORT - ).show() + uiScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.proxy_export_success)) + } }.onFailure { - Toast.makeText( - context, - context.getString(R.string.proxy_export_failed), - Toast.LENGTH_SHORT - ).show() + uiScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.proxy_export_failed)) + } } } @@ -189,11 +265,9 @@ fun ProxyContent(component: ProxyComponent) { }.onSuccess { json -> component.importProxiesJson(json) }.onFailure { - Toast.makeText( - context, - context.getString(R.string.proxy_import_failed), - Toast.LENGTH_SHORT - ).show() + uiScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.proxy_import_failed)) + } } } @@ -204,267 +278,366 @@ fun ProxyContent(component: ProxyComponent) { } } + LaunchedEffect(state.isAutoBestProxyEnabled) { + if (!state.isAutoBestProxyEnabled) { + smartSwitchModeMenuExpanded = false + smartSwitchCheckIntervalMenuExpanded = false + } + } + + fun startQrScanWithPermission() { + when (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)) { + PackageManager.PERMISSION_GRANTED -> showQrScanner = true + else -> cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + fun importFromClipboard() { + val clip = clipboard.nativeClipboard.primaryClip + val rawText = if (clip != null && clip.itemCount > 0) { + clip.getItemAt(0).text?.toString().orEmpty() + } else { + "" + } + component.importProxiesFromText(rawText) + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - title = { - Text( - stringResource(R.string.proxy_settings_header), - fontSize = 22.sp, - fontWeight = FontWeight.Bold - ) - }, - navigationIcon = { - IconButton(onClick = component::onBackClicked) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.cd_back) - ) - } - }, - actions = { - IconButton(onClick = component::onPingAll) { - Icon(Icons.Rounded.Refresh, contentDescription = stringResource(R.string.refresh_pings_cd)) - } - IconButton(onClick = { showTopMenu = true }) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.more_options_cd) + Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { + TopAppBar( + title = { + Text( + stringResource(R.string.proxy_settings_header), + fontSize = 22.sp, + fontWeight = FontWeight.Bold ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background + }, + navigationIcon = { + IconButton(onClick = { + if (showQrScanner) showQrScanner = false else component.onBackClicked() + }) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + }, + actions = { + IconButton( + onClick = { + if (selectedTab == ProxyTab.DcPing) component.onPingDatacenters() + else component.onPingAll() + } + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.refresh_pings_cd) + ) + } + IconButton(onClick = { showTopMenu = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background + ) ) - ) + + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .selectableGroup() + .fillMaxWidth() + .height(48.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape) + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + ProxyTab.entries.forEach { tab -> + val selected = selectedTab == tab + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .selectable( + selected = selected, + onClick = { selectedTab = tab }, + role = Role.Tab + ), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = tab.icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(tab.titleRes), + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ) + } + } + } + } + } }, floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { component.onAddProxyClicked() }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.add_proxy_button)) } - ) + if (selectedTab == ProxyTab.Proxy) { + ExtendedFloatingActionButton( + onClick = { component.onAddProxyClicked() }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.add_proxy_button)) } + ) + } }, containerColor = MaterialTheme.colorScheme.background ) { padding -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(top = padding.calculateTopPadding()), - contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding() + 16.dp + ), verticalArrangement = Arrangement.spacedBy(4.dp) ) { + if (selectedTab == ProxyTab.Proxy) { + item { + ProxyConnectionSummaryCard( + activeProxy = activeProxy, + checkingProxyIds = state.checkingProxyIds, + proxyErrors = state.proxyErrors, + isAutoBestProxyEnabled = state.isAutoBestProxyEnabled, + proxyCount = state.proxies.size, + onRefresh = component::onPingAll, + onPrimaryAction = { + if (activeProxy != null) { + component.onDisableProxy() + } else { + component.onAddProxyClicked() + } + } + ) + } + } + + if (selectedTab == ProxyTab.Settings) { item { SectionHeader(stringResource(R.string.connection_section_header)) } item { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - SettingsSwitchTile( - icon = Icons.Rounded.Bolt, - title = stringResource(R.string.smart_switching_title), - subtitle = stringResource(R.string.smart_switching_subtitle), - checked = state.isAutoBestProxyEnabled, - iconColor = Color(0xFFAF52DE), - position = ItemPosition.TOP, - onCheckedChange = component::onAutoBestProxyToggled - ) - - SettingsSwitchTile( - icon = Icons.Rounded.Public, - title = stringResource(R.string.prefer_ipv6_title), - subtitle = stringResource(R.string.prefer_ipv6_subtitle), - checked = state.preferIpv6, - iconColor = Color(0xFF34A853), - position = ItemPosition.MIDDLE, - onCheckedChange = component::onPreferIpv6Toggled - ) + SettingsSwitchTile( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.smart_switching_title), + subtitle = stringResource(R.string.smart_switching_subtitle), + checked = state.isAutoBestProxyEnabled, + iconColor = Color(0xFFAF52DE), + position = ItemPosition.TOP, + onCheckedChange = component::onAutoBestProxyToggled + ) - val isDirect = state.proxies.none { it.isEnabled } + Box { SettingsTile( - icon = Icons.Rounded.LinkOff, - title = stringResource(R.string.disable_proxy_title), - subtitle = if (isDirect) stringResource(R.string.connected_directly_subtitle) else stringResource( - R.string.switch_to_direct_subtitle - ), - iconColor = if (isDirect) Color(0xFF34A853) else MaterialTheme.colorScheme.error, - position = ItemPosition.BOTTOM, - onClick = { component.onDisableProxy() }, + icon = Icons.Rounded.SwapHoriz, + title = stringResource(R.string.smart_switch_mode_title), + subtitle = stringResource(R.string.smart_switch_mode_subtitle), + iconColor = Color(0xFF7E57C2), + position = ItemPosition.MIDDLE, + onClick = { smartSwitchModeMenuExpanded = true }, + enabled = state.isAutoBestProxyEnabled, trailingContent = { - if (isDirect) { - Icon(Icons.Rounded.Check, contentDescription = null, tint = Color(0xFF34A853)) - } + DropdownSelectionTrailing( + text = stringResource( + smartSwitchModeLabelRes(state.smartSwitchMode) + ) + ) } ) - } - } - - item { - SectionHeader( - text = stringResource(R.string.proxy_network_rules_header), - subtitle = stringResource(R.string.proxy_network_rules_subtitle) - ) - } - item { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - ProxyNetworkType.entries.forEachIndexed { index, networkType -> - val rule = state.proxyNetworkRules[networkType] ?: ProxyNetworkRule( - defaultProxyNetworkMode(networkType) - ) - val position = itemPosition(index, ProxyNetworkType.entries.size) - Box { - SettingsTile( - icon = Icons.Rounded.Wifi, - title = stringResource(networkTitleRes(networkType)), - subtitle = stringResource(networkRuleSubtitleRes(rule)), - iconColor = Color(0xFF1E88E5), - position = position, - onClick = { expandedNetworkMenu = networkType }, - trailingContent = { - DropdownSelectionTrailing( - text = stringResource( - networkModeLabelRes(rule.mode) - ) + StyledDropdownMenu( + expanded = smartSwitchModeMenuExpanded, + onDismissRequest = { smartSwitchModeMenuExpanded = false } + ) { + ProxySmartSwitchMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) + }, + text = { Text(stringResource(smartSwitchModeLabelRes(mode))) }, + trailingIcon = { + if (state.smartSwitchMode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onSmartSwitchModeChanged(mode) + smartSwitchModeMenuExpanded = false } ) + } + } + } - StyledDropdownMenu( - expanded = expandedNetworkMenu == networkType, - onDismissRequest = { expandedNetworkMenu = null } - ) { - ProxyNetworkMode.entries.forEach { mode -> - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = networkModeIcon(mode), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - }, - text = { Text(stringResource(networkModeLabelRes(mode))) }, - trailingIcon = { - if (rule.mode == mode) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - }, - onClick = { - component.onProxyNetworkModeChanged(networkType, mode) - expandedNetworkMenu = null - } + Box { + SettingsTile( + icon = Icons.Rounded.Refresh, + title = stringResource(R.string.smart_switch_check_interval_title), + subtitle = stringResource(R.string.smart_switch_check_interval_subtitle), + iconColor = Color(0xFF1E88E5), + position = ItemPosition.MIDDLE, + onClick = { smartSwitchCheckIntervalMenuExpanded = true }, + enabled = state.isAutoBestProxyEnabled, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + R.string.smart_switch_check_interval_format, + state.smartSwitchAutoCheckIntervalMinutes + ) + ) + } + ) + + StyledDropdownMenu( + expanded = smartSwitchCheckIntervalMenuExpanded, + onDismissRequest = { smartSwitchCheckIntervalMenuExpanded = false } + ) { + smartSwitchCheckIntervalOptions.forEach { minutes -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) - } - if (state.proxies.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + }, + text = { + Text( + stringResource( + R.string.smart_switch_check_interval_format, + minutes + ) ) - state.proxies.forEach { proxy -> - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Tune, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - }, - text = { - Text( - stringResource( - R.string.proxy_specific_target_format, - proxy.server, - proxy.port - ) - ) - }, - trailingIcon = { - if (rule.mode == ProxyNetworkMode.SPECIFIC_PROXY && rule.specificProxyId == proxy.id) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - }, - onClick = { - component.onSpecificProxyForNetworkSelected( - networkType, - proxy.id - ) - expandedNetworkMenu = null - } + }, + trailingIcon = { + if (state.smartSwitchAutoCheckIntervalMinutes == minutes) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } + }, + onClick = { + component.onSmartSwitchAutoCheckIntervalChanged(minutes) + smartSwitchCheckIntervalMenuExpanded = false } - } + ) } } } + + SettingsSwitchTile( + icon = Icons.Rounded.Public, + title = stringResource(R.string.prefer_ipv6_title), + subtitle = stringResource(R.string.prefer_ipv6_subtitle), + checked = state.preferIpv6, + iconColor = Color(0xFF34A853), + position = ItemPosition.MIDDLE, + onCheckedChange = component::onPreferIpv6Toggled + ) + + val isDirect = state.proxies.none { it.isEnabled } + SettingsTile( + icon = Icons.Rounded.LinkOff, + title = stringResource(R.string.disable_proxy_title), + subtitle = if (isDirect) stringResource(R.string.connected_directly_subtitle) else stringResource( + R.string.switch_to_direct_subtitle + ), + iconColor = if (isDirect) Color(0xFF34A853) else MaterialTheme.colorScheme.error, + position = ItemPosition.BOTTOM, + onClick = { component.onDisableProxy() }, + trailingContent = { + if (isDirect) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = Color(0xFF34A853) + ) + } + } + ) } item { - SectionHeader(stringResource(R.string.proxy_list_behavior_header)) + SectionHeader( + text = stringResource(R.string.proxy_network_rules_header), + subtitle = stringResource(R.string.proxy_network_rules_subtitle) + ) } item { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { + ProxyNetworkType.entries.forEachIndexed { index, networkType -> + val rule = state.proxyNetworkRules[networkType] ?: ProxyNetworkRule( + defaultProxyNetworkMode(networkType) + ) + val position = itemPosition(index, ProxyNetworkType.entries.size) Box { SettingsTile( - icon = Icons.Rounded.Sort, - title = stringResource(R.string.proxy_sort_mode_title), - subtitle = stringResource(R.string.proxy_sort_mode_subtitle), - iconColor = Color(0xFFF9A825), - position = ItemPosition.TOP, - onClick = { sortMenuExpanded = true }, + icon = Icons.Rounded.Wifi, + title = stringResource(networkTitleRes(networkType)), + subtitle = stringResource(networkRuleSubtitleRes(rule)), + iconColor = Color(0xFF1E88E5), + position = position, + onClick = { expandedNetworkMenu = networkType }, trailingContent = { DropdownSelectionTrailing( text = stringResource( - sortModeLabelRes( - state.proxySortMode - ) + networkModeLabelRes(rule.mode) ) ) } ) StyledDropdownMenu( - expanded = sortMenuExpanded, - onDismissRequest = { sortMenuExpanded = false } + expanded = expandedNetworkMenu == networkType, + onDismissRequest = { expandedNetworkMenu = null } ) { - ProxySortMode.entries.forEach { mode -> + ProxyNetworkMode.entries.forEach { mode -> DropdownMenuItem( leadingIcon = { Icon( - imageVector = sortModeIcon(mode), + imageVector = networkModeIcon(mode), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) }, - text = { Text(stringResource(sortModeLabelRes(mode))) }, + text = { Text(stringResource(networkModeLabelRes(mode))) }, trailingIcon = { - if (state.proxySortMode == mode) { + if (rule.mode == mode) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, @@ -473,118 +646,340 @@ fun ProxyContent(component: ProxyComponent) { } }, onClick = { - component.onProxySortModeChanged(mode) - sortMenuExpanded = false + component.onProxyNetworkModeChanged(networkType, mode) + expandedNetworkMenu = null } ) } - } - } - - Box { - SettingsTile( - icon = Icons.Rounded.SwapHoriz, - title = stringResource(R.string.proxy_unavailable_fallback_title), - subtitle = stringResource(R.string.proxy_unavailable_fallback_subtitle), - iconColor = Color(0xFF6A1B9A), - position = ItemPosition.MIDDLE, - onClick = { fallbackMenuExpanded = true }, - trailingContent = { - DropdownSelectionTrailing( - text = stringResource( - fallbackLabelRes( - state.proxyUnavailableFallback - ) - ) + if (state.proxies.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) - } - ) - - StyledDropdownMenu( - expanded = fallbackMenuExpanded, - onDismissRequest = { fallbackMenuExpanded = false } - ) { - ProxyUnavailableFallback.entries.forEach { fallback -> - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = fallbackIcon(fallback), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - }, - text = { Text(stringResource(fallbackLabelRes(fallback))) }, - trailingIcon = { - if (state.proxyUnavailableFallback == fallback) { + state.proxies.forEach { proxy -> + DropdownMenuItem( + leadingIcon = { Icon( - imageVector = Icons.Rounded.Check, + imageVector = Icons.Rounded.Tune, contentDescription = null, tint = MaterialTheme.colorScheme.primary ) + }, + text = { + Text( + stringResource( + R.string.proxy_specific_target_format, + proxy.server, + proxy.port + ) + ) + }, + trailingIcon = { + if (rule.mode == ProxyNetworkMode.SPECIFIC_PROXY && rule.specificProxyId == proxy.id) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onSpecificProxyForNetworkSelected( + networkType, + proxy.id + ) + expandedNetworkMenu = null } - }, - onClick = { - component.onProxyUnavailableFallbackChanged(fallback) - fallbackMenuExpanded = false - } - ) + ) + } } } } - - SettingsSwitchTile( - icon = Icons.Rounded.VisibilityOff, - title = stringResource(R.string.hide_offline_proxies_title), - subtitle = stringResource(R.string.hide_offline_proxies_subtitle), - checked = state.hideOfflineProxies, - iconColor = Color(0xFF00897B), - position = ItemPosition.BOTTOM, - onCheckedChange = component::onHideOfflineProxiesToggled - ) } } item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, bottom = 8.dp, top = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.your_proxies_header), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + SectionHeader(stringResource(R.string.proxy_list_behavior_header)) + } + + item { + Box { + SettingsTile( + icon = Icons.AutoMirrored.Rounded.Sort, + title = stringResource(R.string.proxy_sort_mode_title), + subtitle = stringResource(R.string.proxy_sort_mode_subtitle), + iconColor = Color(0xFFF9A825), + position = ItemPosition.TOP, + onClick = { sortMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + sortModeLabelRes( + state.proxySortMode + ) + ) + ) + } ) - if (state.proxies.isNotEmpty()) { - Row { - IconButton(onClick = { component.onClearUnavailableProxies() }) { - Icon( - Icons.Rounded.DeleteSweep, - stringResource(R.string.clear_offline_cd), - tint = MaterialTheme.colorScheme.onSurfaceVariant + StyledDropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + ProxySortMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = sortModeIcon(mode), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(sortModeLabelRes(mode))) }, + trailingIcon = { + if (state.proxySortMode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxySortModeChanged(mode) + sortMenuExpanded = false + } + ) + } + } + } + + Box { + SettingsTile( + icon = Icons.Rounded.SwapHoriz, + title = stringResource(R.string.proxy_unavailable_fallback_title), + subtitle = stringResource(R.string.proxy_unavailable_fallback_subtitle), + iconColor = Color(0xFF6A1B9A), + position = ItemPosition.MIDDLE, + onClick = { fallbackMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + fallbackLabelRes( + state.proxyUnavailableFallback + ) ) + ) + } + ) + + StyledDropdownMenu( + expanded = fallbackMenuExpanded, + onDismissRequest = { fallbackMenuExpanded = false } + ) { + ProxyUnavailableFallback.entries.forEach { fallback -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = fallbackIcon(fallback), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(fallbackLabelRes(fallback))) }, + trailingIcon = { + if (state.proxyUnavailableFallback == fallback) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxyUnavailableFallbackChanged(fallback) + fallbackMenuExpanded = false + } + ) + } + } + } + + SettingsSwitchTile( + icon = Icons.Rounded.VisibilityOff, + title = stringResource(R.string.hide_offline_proxies_title), + subtitle = stringResource(R.string.hide_offline_proxies_subtitle), + checked = state.hideOfflineProxies, + iconColor = Color(0xFF00897B), + position = ItemPosition.BOTTOM, + onCheckedChange = component::onHideOfflineProxiesToggled + ) + } + } + + if (selectedTab == ProxyTab.DcPing) { + val activeProxyForDc = state.proxies.firstOrNull { it.isEnabled } + val dcOrder = DcCatalog.sortedBy { it.id } + val readyResults = dcOrder.mapNotNull { dc -> + state.dcPingByDcId[dc.id]?.takeIf { it >= 0L } + } + val successCount = dcOrder.count { dc -> + (state.dcPingByDcId[dc.id] ?: -1L) >= 0L + } + val averagePing = + if (readyResults.isNotEmpty()) readyResults.average().toLong() else null + item { + SectionHeader( + text = stringResource(R.string.proxy_dc_ping_header), + subtitle = if (activeProxyForDc != null) { + stringResource( + R.string.proxy_dc_ping_route_proxy, + activeProxyForDc.server, + activeProxyForDc.port + ) + } else { + stringResource(R.string.proxy_dc_ping_route_direct) + } + ) + } + + item { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.proxy_dc_ping_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 10.dp + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.proxy_dc_ping_reachable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "$successCount/${dcOrder.size}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 10.dp + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.proxy_dc_ping_average), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = averagePing?.let { + stringResource( + R.string.proxy_ping_format, + it.toInt() + ) + } ?: "—", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } } - IconButton(onClick = { component.onRemoveAllProxies() }) { - Icon( - Icons.Rounded.DeleteForever, - stringResource(R.string.remove_all_cd), - tint = MaterialTheme.colorScheme.error - ) + OutlinedButton( + onClick = component::onPingDatacenters, + enabled = !state.isDcTesting, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isDcTesting) { + LoadingIndicator(modifier = Modifier.size(18.dp)) + } else { + Text(stringResource(R.string.proxy_dc_ping_run)) + } } } } } + item { + Spacer(modifier = Modifier.height(12.dp)) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + dcOrder.forEach { dc -> + val ping = state.dcPingByDcId[dc.id] + val error = state.dcPingErrorsByDcId[dc.id] + val isChecking = + state.isDcTesting && !state.dcPingByDcId.containsKey(dc.id) + DcPingRow( + dc = dc, + ping = ping, + error = error, + isChecking = isChecking + ) + } + } + } } - itemsIndexed(state.visibleProxies, key = { _, it -> it.id }) { index, proxy -> + if (selectedTab == ProxyTab.Proxy) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 4.dp, bottom = 8.dp, top = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = if (state.proxies.isNotEmpty()) { + "${stringResource(R.string.your_proxies_header)} (${prioritizedVisibleProxies.size})" + } else { + stringResource(R.string.your_proxies_header) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + + itemsIndexed(prioritizedVisibleProxies, key = { _, it -> it.id }) { index, proxy -> val position = when { - state.visibleProxies.size == 1 -> ItemPosition.STANDALONE + prioritizedVisibleProxies.size == 1 -> ItemPosition.STANDALONE index == 0 -> ItemPosition.TOP - index == state.visibleProxies.size - 1 -> ItemPosition.BOTTOM + index == prioritizedVisibleProxies.size - 1 -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE } @@ -594,6 +989,7 @@ fun ProxyContent(component: ProxyComponent) { ProxyItem( proxy = proxy, errorMessage = state.proxyErrors[proxy.id], + isChecking = proxy.id in state.checkingProxyIds, isFavorite = state.favoriteProxyId == proxy.id, position = position, onClick = { component.onProxyClicked(proxy) }, @@ -604,7 +1000,7 @@ fun ProxyContent(component: ProxyComponent) { } } - if (state.visibleProxies.isEmpty() && !state.isLoading) { + if (prioritizedVisibleProxies.isEmpty() && !state.isLoading) { item { Column( modifier = Modifier @@ -625,11 +1021,14 @@ fun ProxyContent(component: ProxyComponent) { style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(Modifier.height(16.dp)) + Button(onClick = component::onAddProxyClicked) { + Text(stringResource(R.string.add_proxy_button)) + } } } + } } - - item { Spacer(modifier = Modifier.height(80.dp)) } } } @@ -667,6 +1066,26 @@ fun ProxyContent(component: ProxyComponent) { .padding(top = 56.dp, end = 16.dp) ) { ViewerSettingsDropdown { + MenuOptionRow( + icon = Icons.Rounded.ContentPaste, + title = stringResource(R.string.proxy_paste_from_clipboard_action), + onClick = { + showTopMenu = false + importFromClipboard() + } + ) + MenuOptionRow( + icon = Icons.Rounded.QrCodeScanner, + title = stringResource(R.string.proxy_scan_qr_action), + onClick = { + showTopMenu = false + startQrScanWithPermission() + } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) MenuOptionRow( icon = Icons.Rounded.Upload, title = stringResource(R.string.proxy_export_action), @@ -683,20 +1102,57 @@ fun ProxyContent(component: ProxyComponent) { importLauncher.launch(arrayOf("application/json", "text/plain")) } ) + if (state.proxies.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + MenuOptionRow( + icon = Icons.Rounded.DeleteSweep, + title = stringResource(R.string.clear_offline_cd), + onClick = { + showTopMenu = false + component.onClearUnavailableProxies() + }, + iconTint = MaterialTheme.colorScheme.onSurfaceVariant + ) + MenuOptionRow( + icon = Icons.Rounded.DeleteForever, + title = stringResource(R.string.remove_all_cd), + onClick = { + showTopMenu = false + component.onRemoveAllProxies() + }, + iconTint = MaterialTheme.colorScheme.error, + textColor = MaterialTheme.colorScheme.error + ) + } } } } } } + if (showQrScanner) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + IntegratedQRScanner( + onCodeDetected = { code -> + showQrScanner = false + component.importProxiesFromText(code) + }, + onBackClicked = { showQrScanner = false } + ) + } + } + if (state.isAddingProxy || state.proxyToEdit != null) { ProxyAddEditSheet( proxy = state.proxyToEdit, onDismiss = component::onDismissAddEdit, - onTest = component::onTestProxy, - testPing = state.testPing, - testError = state.testError, - isTesting = state.isTesting, isFavorite = state.proxyToEdit?.id == state.favoriteProxyId, onToggleFavorite = { state.proxyToEdit?.let { component.onToggleFavoriteProxy(it.id) } @@ -782,688 +1238,3 @@ fun ProxyContent(component: ProxyComponent) { } -private fun itemPosition(index: Int, total: Int): ItemPosition { - return when { - total <= 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == total - 1 -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - } -} - -@Composable -private fun DropdownSelectionTrailing(text: String) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.widthIn(max = 180.dp) - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Icon( - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -private fun StyledDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - content: @Composable ColumnScope.() -> Unit -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = DpOffset(x = 0.dp, y = 8.dp), - shape = RoundedCornerShape(22.dp), - containerColor = Color.Transparent, - tonalElevation = 0.dp, - shadowElevation = 0.dp - ) { - Surface( - modifier = Modifier.widthIn(min = 220.dp, max = 320.dp), - shape = RoundedCornerShape(22.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Column( - modifier = Modifier.padding(vertical = 8.dp), - content = content - ) - } - } -} - -private fun networkModeIcon(mode: ProxyNetworkMode): ImageVector = when (mode) { - ProxyNetworkMode.DIRECT -> Icons.Rounded.LinkOff - ProxyNetworkMode.BEST_PROXY -> Icons.Rounded.Bolt - ProxyNetworkMode.LAST_USED -> Icons.Rounded.History - ProxyNetworkMode.SPECIFIC_PROXY -> Icons.Rounded.Tune -} - -private fun sortModeIcon(mode: ProxySortMode): ImageVector = when (mode) { - ProxySortMode.ACTIVE_FIRST -> Icons.Rounded.CheckCircle - ProxySortMode.LOWEST_PING -> Icons.Rounded.Speed - ProxySortMode.SERVER_NAME -> Icons.Rounded.Language - ProxySortMode.PROXY_TYPE -> Icons.Rounded.Tune - ProxySortMode.STATUS -> Icons.Rounded.Info -} - -private fun fallbackIcon(fallback: ProxyUnavailableFallback): ImageVector = when (fallback) { - ProxyUnavailableFallback.BEST_PROXY -> Icons.Rounded.Bolt - ProxyUnavailableFallback.DIRECT -> Icons.Rounded.LinkOff - ProxyUnavailableFallback.KEEP_CURRENT -> Icons.Rounded.Pause -} - -@StringRes -private fun networkTitleRes(networkType: ProxyNetworkType): Int = when (networkType) { - ProxyNetworkType.WIFI -> R.string.proxy_network_wifi - ProxyNetworkType.MOBILE -> R.string.proxy_network_mobile - ProxyNetworkType.VPN -> R.string.proxy_network_vpn - ProxyNetworkType.OTHER -> R.string.proxy_network_other -} - -@StringRes -private fun networkModeLabelRes(mode: ProxyNetworkMode): Int = when (mode) { - ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct - ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best - ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used - ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific -} - -@StringRes -private fun networkRuleSubtitleRes(rule: ProxyNetworkRule): Int = when (rule.mode) { - ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct_subtitle - ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best_subtitle - ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used_subtitle - ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific_subtitle -} - -@StringRes -private fun sortModeLabelRes(mode: ProxySortMode): Int = when (mode) { - ProxySortMode.ACTIVE_FIRST -> R.string.proxy_sort_mode_active_first - ProxySortMode.LOWEST_PING -> R.string.proxy_sort_mode_lowest_ping - ProxySortMode.SERVER_NAME -> R.string.proxy_sort_mode_server_name - ProxySortMode.PROXY_TYPE -> R.string.proxy_sort_mode_proxy_type - ProxySortMode.STATUS -> R.string.proxy_sort_mode_status -} - -@StringRes -private fun fallbackLabelRes(fallback: ProxyUnavailableFallback): Int = when (fallback) { - ProxyUnavailableFallback.BEST_PROXY -> R.string.proxy_fallback_best_proxy - ProxyUnavailableFallback.DIRECT -> R.string.proxy_fallback_direct - ProxyUnavailableFallback.KEEP_CURRENT -> R.string.proxy_fallback_keep_current -} - -private fun proxyToDeepLink(proxy: ProxyModel): String { - fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) - - return when (val type = proxy.type) { - is ProxyTypeModel.Mtproto -> { - "tg://proxy?server=${encode(proxy.server)}&port=${proxy.port}&secret=${encode(type.secret)}" - } - - is ProxyTypeModel.Socks5 -> { - buildString { - append("tg://socks?server=${encode(proxy.server)}&port=${proxy.port}") - if (type.username.isNotBlank()) append("&user=${encode(type.username)}") - if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") - } - } - - is ProxyTypeModel.Http -> { - buildString { - append("tg://http?server=${encode(proxy.server)}&port=${proxy.port}") - if (type.username.isNotBlank()) append("&user=${encode(type.username)}") - if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ProxyItem( - proxy: ProxyModel, - errorMessage: String?, - isFavorite: Boolean, - position: ItemPosition, - onClick: () -> Unit, - onLongClick: () -> Unit, - onRefreshPing: () -> Unit, - onOpenMenu: () -> Unit -) { - val typeName = when (proxy.type) { - is ProxyTypeModel.Mtproto -> "MTProto" - is ProxyTypeModel.Socks5 -> "SOCKS5" - is ProxyTypeModel.Http -> "HTTP" - } - - val isEnabled = proxy.isEnabled - - val cornerRadius = 24.dp - val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = cornerRadius, - topEnd = cornerRadius, - bottomStart = 4.dp, - bottomEnd = 4.dp - ) - - ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = cornerRadius, - bottomEnd = cornerRadius, - topStart = 4.dp, - topEnd = 4.dp - ) - - ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) - } - - val backgroundColor by animateColorAsState( - if (isEnabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - else MaterialTheme.colorScheme.surfaceContainer, - label = "bg" - ) - - Surface( - color = backgroundColor, - shape = shape, - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(44.dp) - .background( - color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isEnabled) Icons.Rounded.Check else Icons.Rounded.Language, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = proxy.server, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f, fill = false) - ) - if (isFavorite) { - Spacer(Modifier.width(6.dp)) - Icon( - imageVector = Icons.Rounded.Star, - contentDescription = stringResource(R.string.proxy_action_remove_favorite), - tint = Color(0xFFFFB300), - modifier = Modifier.size(16.dp) - ) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = typeName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Port ${proxy.port}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) - } - if (!errorMessage.isNullOrBlank()) { - Spacer(Modifier.height(4.dp)) - Text( - text = errorMessage, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - - Column(horizontalAlignment = Alignment.End) { - ProxyPingIndicator( - ping = proxy.ping, - isChecking = proxy.ping == null, - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onRefreshPing, modifier = Modifier.size(32.dp)) { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.refresh_list_title), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - IconButton(onClick = onOpenMenu, modifier = Modifier.size(32.dp)) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = stringResource(R.string.more_options_cd), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun SwipeToDeleteContainer( - onDelete: () -> Unit, - enabled: Boolean = true, - content: @Composable () -> Unit -) { - if (!enabled) { - content() - return - } - - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { - if (it == SwipeToDismissBoxValue.EndToStart) { - onDelete() - true - } else false - } - ) - - SwipeToDismissBox( - state = dismissState, - enableDismissFromStartToEnd = false, - backgroundContent = { - val color by animateColorAsState( - (if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) - MaterialTheme.colorScheme.errorContainer - else Color.Transparent), - label = "color" - ) - Box( - Modifier - .fillMaxSize() - .background(color) - .padding(horizontal = 20.dp), - contentAlignment = Alignment.CenterEnd - ) { - if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { - Icon( - Icons.Rounded.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - }, - content = { content() } - ) -} - -@Composable -private fun SectionHeader( - text: String, - subtitle: String? = null, - onSubtitleClick: (() -> Unit)? = null -) { - Column(modifier = Modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp)) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = if (onSubtitleClick != null) { - Modifier.clickable(onClick = onSubtitleClick) - } else { - Modifier - } - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProxyAddEditSheet( - proxy: ProxyModel?, - onDismiss: () -> Unit, - onTest: (String, Int, ProxyTypeModel) -> Unit, - testPing: Long?, - testError: String?, - isTesting: Boolean, - isFavorite: Boolean, - onToggleFavorite: () -> Unit, - onDelete: () -> Unit, - onSave: (String, Int, ProxyTypeModel) -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val context = LocalContext.current - val clipboard = LocalClipboard.current - - var server by remember { mutableStateOf(proxy?.server ?: "") } - var port by remember { mutableStateOf(proxy?.port?.toString() ?: "") } - var type by remember { - mutableStateOf( - when (proxy?.type) { - is ProxyTypeModel.Socks5 -> "SOCKS5" - is ProxyTypeModel.Http -> "HTTP" - else -> "MTProto" - } - ) - } - - var secret by remember { mutableStateOf((proxy?.type as? ProxyTypeModel.Mtproto)?.secret ?: "") } - var username by remember { - mutableStateOf( - when (val t = proxy?.type) { - is ProxyTypeModel.Socks5 -> t.username - is ProxyTypeModel.Http -> t.username - else -> "" - } - ) - } - var password by remember { - mutableStateOf( - when (val t = proxy?.type) { - is ProxyTypeModel.Socks5 -> t.password - is ProxyTypeModel.Http -> t.password - else -> "" - } - ) - } - - val normalizedMtprotoSecret = remember(secret) { MtprotoSecretNormalizer.normalize(secret) } - - val currentProxyType = remember(type, normalizedMtprotoSecret, secret, username, password) { - when (type) { - "MTProto" -> ProxyTypeModel.Mtproto(normalizedMtprotoSecret ?: secret.trim()) - "SOCKS5" -> ProxyTypeModel.Socks5(username, password) - else -> ProxyTypeModel.Http(username, password, false) - } - } - - val isInputValid = - server.isNotBlank() && port.isNotBlank() && (type != "MTProto" || normalizedMtprotoSecret != null) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 32.dp) - ) { - Text( - text = if (proxy == null) stringResource(R.string.new_proxy_title) else stringResource(R.string.edit_proxy_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - Modifier - .selectableGroup() - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - RoundedCornerShape(50) - ) - .padding(4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - listOf("MTProto", "SOCKS5", "HTTP").forEach { text -> - val selected = (text == type) - Box( - Modifier - .weight(1f) - .height(44.dp) - .clip(RoundedCornerShape(50)) - .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) - .selectable( - selected = selected, - onClick = { type = text }, - role = Role.RadioButton - ), - contentAlignment = Alignment.Center - ) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - SettingsTextField( - value = server, - onValueChange = { server = it }, - placeholder = stringResource(R.string.server_address_placeholder), - icon = Icons.Rounded.Language, - position = ItemPosition.TOP, - singleLine = true - ) - - SettingsTextField( - value = port, - onValueChange = { if (it.all { char -> char.isDigit() }) port = it }, - placeholder = stringResource(R.string.port_placeholder), - icon = Icons.Rounded.Numbers, - position = ItemPosition.BOTTOM, - singleLine = true - ) - - Spacer(modifier = Modifier.height(16.dp)) - - AnimatedContent(targetState = type, label = "TypeFields") { targetType -> - Column { - when (targetType) { - "MTProto" -> { - SettingsTextField( - value = secret, - onValueChange = { secret = it }, - placeholder = stringResource(R.string.secret_hex_placeholder), - icon = Icons.Rounded.Key, - position = ItemPosition.STANDALONE, - singleLine = true - ) - } - - "SOCKS5", "HTTP" -> { - SettingsTextField( - value = username, - onValueChange = { username = it }, - placeholder = stringResource(R.string.username_optional_placeholder), - icon = Icons.Rounded.Person, - position = ItemPosition.TOP, - singleLine = true - ) - SettingsTextField( - value = password, - onValueChange = { password = it }, - placeholder = stringResource(R.string.password_optional_placeholder), - icon = Icons.Rounded.Password, - position = ItemPosition.BOTTOM, - singleLine = true - ) - } - } - } - } - - if (proxy != null) { - Spacer(modifier = Modifier.height(20.dp)) - Surface( - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - MenuOptionRow( - icon = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, - title = stringResource( - if (isFavorite) R.string.proxy_action_remove_favorite else R.string.proxy_action_set_favorite - ), - iconTint = if (isFavorite) Color(0xFFFFB300) else MaterialTheme.colorScheme.primary, - onClick = onToggleFavorite - ) - MenuOptionRow( - icon = Icons.Rounded.ContentCopy, - title = stringResource(R.string.proxy_action_copy_link), - onClick = { - val link = proxyToDeepLink(proxy) - clipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(link)) - ) - Toast.makeText( - context, - context.getString(R.string.proxy_link_copied), - Toast.LENGTH_SHORT - ).show() - } - ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - MenuOptionRow( - icon = Icons.Rounded.Delete, - title = stringResource(R.string.proxy_action_delete), - textColor = MaterialTheme.colorScheme.error, - iconTint = MaterialTheme.colorScheme.error, - onClick = onDelete - ) - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - if (testPing != null || isTesting) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.test_proxy_result), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - ProxyPingIndicator( - ping = testPing, - isChecking = isTesting - ) - } - } - - if (!testError.isNullOrBlank()) { - Text( - text = testError, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = { - val p = port.toIntOrNull() ?: 443 - onTest(server, p, currentProxyType) - }, - enabled = isInputValid && !isTesting, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - if (isTesting) { - LoadingIndicator( - modifier = Modifier.size(18.dp), - ) - } else { - Text(stringResource(R.string.test_proxy_button)) - } - } - - Button( - onClick = { - val p = port.toIntOrNull() ?: 443 - onSave(server, p, currentProxyType) - }, - enabled = isInputValid, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text( - if (proxy == null) stringResource(R.string.add_proxy_button) else stringResource(R.string.save_changes_button), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - } - } - } - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyPingIndicator.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyPingIndicator.kt index adfad522..ac6c148b 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyPingIndicator.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyPingIndicator.kt @@ -1,8 +1,18 @@ package org.monogram.presentation.settings.proxy -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -22,6 +32,7 @@ import org.monogram.presentation.R fun ProxyPingIndicator( ping: Long?, isChecking: Boolean, + showText: Boolean = true, modifier: Modifier = Modifier ) { if (!isChecking && ping == null) return @@ -49,12 +60,14 @@ fun ProxyPingIndicator( .alpha(alpha) .background(MaterialTheme.colorScheme.outline, CircleShape) ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.proxy_checking), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) + if (showText) { + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.proxy_checking), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } } else { val (color, text) = when { ping == null || ping == -1L -> Color(0xFFEA4335) to stringResource(R.string.proxy_offline) @@ -68,14 +81,16 @@ fun ProxyPingIndicator( .size(8.dp) .background(color, CircleShape) ) - Spacer(Modifier.width(4.dp)) - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = color, - fontSize = 11.sp - ) + if (showText) { + Spacer(Modifier.width(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = color, + fontSize = 11.sp + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt new file mode 100644 index 00000000..c0a32140 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyAddEditSheet.kt @@ -0,0 +1,363 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + +package org.monogram.presentation.settings.proxy + +import android.content.ClipData +import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.Password +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.monogram.domain.models.ProxyModel +import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxyAddEditSheet( + proxy: ProxyModel?, + onDismiss: () -> Unit, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, + onDelete: () -> Unit, + onSave: (String, Int, ProxyTypeModel) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val clipboard = LocalClipboard.current + + var server by remember { mutableStateOf(proxy?.server ?: "") } + var port by remember { mutableStateOf(proxy?.port?.toString() ?: "") } + var type by remember { + mutableStateOf( + when (proxy?.type) { + is ProxyTypeModel.Socks5 -> "SOCKS5" + is ProxyTypeModel.Http -> "HTTP" + else -> "MTProto" + } + ) + } + + var secret by remember { + mutableStateOf( + (proxy?.type as? ProxyTypeModel.Mtproto)?.secret ?: "" + ) + } + var username by remember { + mutableStateOf( + when (val t = proxy?.type) { + is ProxyTypeModel.Socks5 -> t.username + is ProxyTypeModel.Http -> t.username + else -> "" + } + ) + } + var password by remember { + mutableStateOf( + when (val t = proxy?.type) { + is ProxyTypeModel.Socks5 -> t.password + is ProxyTypeModel.Http -> t.password + else -> "" + } + ) + } + + val normalizedMtprotoSecret = remember(secret) { MtprotoSecretNormalizer.normalize(secret) } + val portNumber = port.toIntOrNull() + val isServerValid = server.isNotBlank() + val isPortValid = portNumber != null && portNumber in 1..65535 + val isSecretValid = type != "MTProto" || normalizedMtprotoSecret != null + + val currentProxyType = remember(type, normalizedMtprotoSecret, secret, username, password) { + when (type) { + "MTProto" -> ProxyTypeModel.Mtproto(normalizedMtprotoSecret ?: secret.trim()) + "SOCKS5" -> ProxyTypeModel.Socks5(username, password) + else -> ProxyTypeModel.Http( + username = username, + password = password, + httpOnly = (proxy?.type as? ProxyTypeModel.Http)?.httpOnly ?: false + ) + } + } + + val isInputValid = isServerValid && isPortValid && isSecretValid + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = if (proxy == null) stringResource(R.string.new_proxy_title) else stringResource( + R.string.edit_proxy_title + ), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = when (type) { + "MTProto" -> "Use for Telegram proxy links with a valid secret." + "SOCKS5" -> "Best when the server uses optional username and password authentication." + else -> "Use for standard HTTP proxy servers." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + Modifier + .selectableGroup() + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(50) + ) + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + listOf("MTProto", "SOCKS5", "HTTP").forEach { text -> + val selected = text == type + Box( + Modifier + .weight(1f) + .height(44.dp) + .clip(RoundedCornerShape(50)) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .selectable( + selected = selected, + onClick = { type = text }, + role = Role.RadioButton + ), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsTextField( + value = server, + onValueChange = { server = it }, + placeholder = stringResource(R.string.server_address_placeholder), + icon = Icons.Rounded.Language, + position = ItemPosition.TOP, + singleLine = true + ) + + SettingsTextField( + value = port, + onValueChange = { if (it.all { char -> char.isDigit() }) port = it }, + placeholder = stringResource(R.string.port_placeholder), + icon = Icons.Rounded.Numbers, + position = ItemPosition.BOTTOM, + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (!isServerValid) { + Text( + text = "Server address is required.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + if (port.isNotBlank() && !isPortValid) { + Text( + text = "Port must be between 1 and 65535.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + AnimatedContent(targetState = type, label = "TypeFields") { targetType -> + Column { + when (targetType) { + "MTProto" -> { + SettingsTextField( + value = secret, + onValueChange = { secret = it }, + placeholder = stringResource(R.string.secret_hex_placeholder), + icon = Icons.Rounded.Key, + position = ItemPosition.STANDALONE, + singleLine = true + ) + } + + "SOCKS5", "HTTP" -> { + SettingsTextField( + value = username, + onValueChange = { username = it }, + placeholder = stringResource(R.string.username_optional_placeholder), + icon = Icons.Rounded.Person, + position = ItemPosition.TOP, + singleLine = true + ) + SettingsTextField( + value = password, + onValueChange = { password = it }, + placeholder = stringResource(R.string.password_optional_placeholder), + icon = Icons.Rounded.Password, + position = ItemPosition.BOTTOM, + singleLine = true + ) + } + } + } + } + + if (type == "MTProto" && secret.isNotBlank() && !isSecretValid) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enter a valid MTProto secret.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + if (proxy != null) { + Spacer(modifier = Modifier.height(20.dp)) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + MenuOptionRow( + icon = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + title = stringResource( + if (isFavorite) R.string.proxy_action_remove_favorite else R.string.proxy_action_set_favorite + ), + iconTint = if (isFavorite) Color(0xFFFFB300) else MaterialTheme.colorScheme.primary, + onClick = onToggleFavorite + ) + MenuOptionRow( + icon = Icons.Rounded.ContentCopy, + title = stringResource(R.string.proxy_action_copy_link), + onClick = { + val link = proxyToDeepLink(proxy) + clipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + Toast.makeText( + context, + context.getString(R.string.proxy_link_copied), + Toast.LENGTH_SHORT + ).show() + } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + MenuOptionRow( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.proxy_action_delete), + textColor = MaterialTheme.colorScheme.error, + iconTint = MaterialTheme.colorScheme.error, + onClick = onDelete + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + val p = port.toIntOrNull() ?: 443 + onSave(server, p, currentProxyType) + }, + enabled = isInputValid, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + if (proxy == null) stringResource(R.string.add_proxy_button) else stringResource( + R.string.save_changes_button + ), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyContentHelpers.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyContentHelpers.kt new file mode 100644 index 00000000..41b35ffe --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyContentHelpers.kt @@ -0,0 +1,189 @@ +package org.monogram.presentation.settings.proxy + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LinkOff +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.ProxyModel +import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySmartSwitchMode +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ItemPosition +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +internal fun itemPosition(index: Int, total: Int): ItemPosition = when { + total <= 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == total - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE +} + +@Composable +internal fun DropdownSelectionTrailing(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.widthIn(max = 180.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +internal fun StyledDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + offset = DpOffset(x = 0.dp, y = 8.dp), + shape = RoundedCornerShape(22.dp), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Surface( + modifier = Modifier.widthIn(min = 220.dp, max = 320.dp), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp), + content = content + ) + } + } +} + +internal fun networkModeIcon(mode: ProxyNetworkMode): ImageVector = when (mode) { + ProxyNetworkMode.DIRECT -> Icons.Rounded.LinkOff + ProxyNetworkMode.BEST_PROXY -> Icons.Rounded.Bolt + ProxyNetworkMode.LAST_USED -> Icons.Rounded.History + ProxyNetworkMode.SPECIFIC_PROXY -> Icons.Rounded.Tune +} + +internal fun sortModeIcon(mode: ProxySortMode): ImageVector = when (mode) { + ProxySortMode.ACTIVE_FIRST -> Icons.Rounded.CheckCircle + ProxySortMode.LOWEST_PING -> Icons.Rounded.Speed + ProxySortMode.SERVER_NAME -> Icons.Rounded.Language + ProxySortMode.PROXY_TYPE -> Icons.Rounded.Tune + ProxySortMode.STATUS -> Icons.Rounded.Info +} + +internal fun fallbackIcon(fallback: ProxyUnavailableFallback): ImageVector = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> Icons.Rounded.Bolt + ProxyUnavailableFallback.DIRECT -> Icons.Rounded.LinkOff + ProxyUnavailableFallback.KEEP_CURRENT -> Icons.Rounded.Pause +} + +@StringRes +internal fun networkTitleRes(networkType: ProxyNetworkType): Int = when (networkType) { + ProxyNetworkType.WIFI -> R.string.proxy_network_wifi + ProxyNetworkType.MOBILE -> R.string.proxy_network_mobile + ProxyNetworkType.VPN -> R.string.proxy_network_vpn + ProxyNetworkType.OTHER -> R.string.proxy_network_other +} + +@StringRes +internal fun networkModeLabelRes(mode: ProxyNetworkMode): Int = when (mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific +} + +@StringRes +internal fun networkRuleSubtitleRes(rule: ProxyNetworkRule): Int = when (rule.mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct_subtitle + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best_subtitle + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used_subtitle + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific_subtitle +} + +@StringRes +internal fun sortModeLabelRes(mode: ProxySortMode): Int = when (mode) { + ProxySortMode.ACTIVE_FIRST -> R.string.proxy_sort_mode_active_first + ProxySortMode.LOWEST_PING -> R.string.proxy_sort_mode_lowest_ping + ProxySortMode.SERVER_NAME -> R.string.proxy_sort_mode_server_name + ProxySortMode.PROXY_TYPE -> R.string.proxy_sort_mode_proxy_type + ProxySortMode.STATUS -> R.string.proxy_sort_mode_status +} + +@StringRes +internal fun fallbackLabelRes(fallback: ProxyUnavailableFallback): Int = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> R.string.proxy_fallback_best_proxy + ProxyUnavailableFallback.DIRECT -> R.string.proxy_fallback_direct + ProxyUnavailableFallback.KEEP_CURRENT -> R.string.proxy_fallback_keep_current +} + +@StringRes +internal fun smartSwitchModeLabelRes(mode: ProxySmartSwitchMode): Int = when (mode) { + ProxySmartSwitchMode.BEST_PING -> R.string.smart_switch_mode_best_ping + ProxySmartSwitchMode.RANDOM_AVAILABLE -> R.string.smart_switch_mode_random_available +} + +internal fun proxyToDeepLink(proxy: ProxyModel): String { + fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + + return when (val type = proxy.type) { + is ProxyTypeModel.Mtproto -> + "tg://proxy?server=${encode(proxy.server)}&port=${proxy.port}&secret=${encode(type.secret)}" + + is ProxyTypeModel.Socks5 -> buildString { + append("tg://socks?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + + is ProxyTypeModel.Http -> buildString { + append("tg://http?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyListItemComponents.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyListItemComponents.kt new file mode 100644 index 00000000..3ac046ed --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxyListItemComponents.kt @@ -0,0 +1,338 @@ +package org.monogram.presentation.settings.proxy + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.ProxyModel +import org.monogram.domain.models.ProxyTypeModel +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ItemPosition + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ProxyItem( + proxy: ProxyModel, + errorMessage: String?, + isChecking: Boolean, + isFavorite: Boolean, + position: ItemPosition, + onClick: () -> Unit, + onLongClick: () -> Unit, + onRefreshPing: () -> Unit, + onOpenMenu: () -> Unit +) { + val typeName = when (proxy.type) { + is ProxyTypeModel.Mtproto -> "MTProto" + is ProxyTypeModel.Socks5 -> "SOCKS5" + is ProxyTypeModel.Http -> "HTTP" + } + + val isEnabled = proxy.isEnabled + val ping = proxy.ping + val offlineLabel = stringResource(R.string.proxy_offline) + val detailErrorMessage = errorMessage + ?.takeIf { it.isNotBlank() } + ?.takeUnless { it.equals(offlineLabel, ignoreCase = true) } + val statusText = when { + isChecking -> stringResource(R.string.proxy_checking) + !errorMessage.isNullOrBlank() -> stringResource(R.string.proxy_offline) + ping != null && ping >= 0L -> stringResource(R.string.proxy_ping_format, ping.toInt()) + isEnabled -> stringResource(R.string.proxy_enabled) + else -> typeName + } + + val cornerRadius = 24.dp + val shape = when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + topStart = 4.dp, + topEnd = 4.dp + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) + } + + val backgroundColor by animateColorAsState( + if (isEnabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surfaceContainer, + label = "bg" + ) + + Surface( + color = backgroundColor, + shape = shape, + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(46.dp) + .background( + color = if (isEnabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.92f) else MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isEnabled) Icons.Rounded.Check else Icons.Rounded.Language, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = proxy.server, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) + ) + if (isFavorite) { + Spacer(Modifier.width(6.dp)) + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.proxy_action_remove_favorite), + tint = Color(0xFFFFB300), + modifier = Modifier.size(16.dp) + ) + } + } + + Spacer(Modifier.height(6.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + ProxyMetaChip(text = typeName) + ProxyMetaChip(text = "Port ${proxy.port}") + ProxyStatusPill( + text = statusText, + backgroundColor = when { + isChecking -> MaterialTheme.colorScheme.surfaceVariant + !errorMessage.isNullOrBlank() -> MaterialTheme.colorScheme.errorContainer + isEnabled -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = when { + !errorMessage.isNullOrBlank() -> MaterialTheme.colorScheme.onErrorContainer + isEnabled -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSecondaryContainer + } + ) + } + + if (!detailErrorMessage.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text( + text = detailErrorMessage, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(Modifier.width(8.dp)) + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ProxyPingIndicator( + ping = proxy.ping, + isChecking = isChecking, + showText = false, + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 2.dp, vertical = 1.dp) + ) { + IconButton(onClick = onRefreshPing, modifier = Modifier.size(30.dp)) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.refresh_list_title), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onOpenMenu, modifier = Modifier.size(30.dp)) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +@Composable +private fun ProxyMetaChip(text: String) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(7.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SwipeToDeleteContainer( + onDelete: () -> Unit, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + if (!enabled) { + content() + return + } + + val dismissState = rememberSwipeToDismissBoxState() + LaunchedEffect(dismissState.currentValue) { + if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { + onDelete() + dismissState.reset() + } + } + + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { + val color by animateColorAsState( + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { + MaterialTheme.colorScheme.errorContainer + } else { + Color.Transparent + }, + label = "color" + ) + Box( + Modifier + .fillMaxSize() + .background(color) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) { + Icon( + Icons.Rounded.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + }, + content = { content() } + ) +} + +@Composable +internal fun SectionHeader( + text: String, + subtitle: String? = null, + onSubtitleClick: (() -> Unit)? = null +) { + Column(modifier = Modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp)) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = if (onSubtitleClick != null) { + Modifier.clickable(onClick = onSubtitleClick) + } else { + Modifier + } + ) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxySummaryComponents.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxySummaryComponents.kt new file mode 100644 index 00000000..80efde3f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/components/ProxySummaryComponents.kt @@ -0,0 +1,268 @@ +package org.monogram.presentation.settings.proxy + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LinkOff +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.monogram.domain.models.ProxyModel +import org.monogram.domain.models.ProxyTypeModel +import org.monogram.presentation.R + +@Composable +internal fun ProxyConnectionSummaryCard( + activeProxy: ProxyModel?, + checkingProxyIds: Set, + proxyErrors: Map, + isAutoBestProxyEnabled: Boolean, + proxyCount: Int, + onRefresh: () -> Unit, + onPrimaryAction: () -> Unit +) { + val ping = activeProxy?.ping + val isChecking = activeProxy?.id in checkingProxyIds + val errorMessage = activeProxy?.id?.let(proxyErrors::get) + val offlineLabel = stringResource(R.string.proxy_offline) + val detailErrorMessage = errorMessage + ?.takeIf { it.isNotBlank() } + ?.takeUnless { it.equals(offlineLabel, ignoreCase = true) } + val statusText = when { + isChecking -> stringResource(R.string.proxy_checking) + !errorMessage.isNullOrBlank() -> stringResource(R.string.proxy_offline) + ping != null && ping >= 0L -> stringResource(R.string.proxy_ping_format, ping.toInt()) + activeProxy != null -> stringResource(R.string.proxy_enabled) + else -> stringResource(R.string.proxy_network_mode_direct) + } + val subtitle = when { + !detailErrorMessage.isNullOrBlank() -> detailErrorMessage + activeProxy != null -> buildString { + append( + when (activeProxy.type) { + is ProxyTypeModel.Mtproto -> "MTProto" + is ProxyTypeModel.Socks5 -> "SOCKS5" + is ProxyTypeModel.Http -> "HTTP" + } + ) + append(" • ") + append(activeProxy.server) + append(':') + append(activeProxy.port) + } + + proxyCount == 0 -> stringResource(R.string.no_proxies_label) + isAutoBestProxyEnabled -> stringResource(R.string.smart_switching_subtitle) + else -> stringResource(R.string.proxy_network_mode_direct_subtitle) + } + val containerColor by animateColorAsState( + targetValue = if (activeProxy != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.42f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + label = "summaryContainerColor" + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + shape = RoundedCornerShape(22.dp), + color = containerColor + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = if (activeProxy != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (activeProxy != null) Icons.Rounded.CheckCircle else Icons.Rounded.Language, + contentDescription = null, + tint = if (activeProxy != null) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = activeProxy?.server + ?: if (proxyCount == 0) { + stringResource(R.string.no_proxies_label) + } else { + stringResource(R.string.connected_directly_subtitle) + }, + transitionSpec = { + fadeIn() + scaleIn(initialScale = 0.96f) togetherWith fadeOut() + scaleOut( + targetScale = 0.96f + ) + }, + label = "summaryTitle" + ) { title -> + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + AnimatedContent( + targetState = statusText, + transitionSpec = { + fadeIn() + scaleIn(initialScale = 0.9f) togetherWith fadeOut() + scaleOut( + targetScale = 0.9f + ) + }, + label = "statusPillTransition" + ) { animatedStatus -> + ProxyStatusPill( + text = animatedStatus, + backgroundColor = when { + isChecking -> MaterialTheme.colorScheme.surfaceVariant + !errorMessage.isNullOrBlank() -> MaterialTheme.colorScheme.errorContainer + activeProxy != null -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.secondaryContainer + }, + contentColor = when { + !errorMessage.isNullOrBlank() -> MaterialTheme.colorScheme.onErrorContainer + activeProxy != null -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSecondaryContainer + } + ) + } + } + + Spacer(Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onRefresh, + modifier = Modifier.weight(1f), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.refresh_list_title), + style = MaterialTheme.typography.labelMedium, + fontSize = 12.sp + ) + } + + Button( + onClick = onPrimaryAction, + modifier = Modifier.weight(1f), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding + ) { + Icon( + imageVector = if (activeProxy != null) Icons.Rounded.LinkOff else Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + if (activeProxy != null) { + stringResource(R.string.disable_proxy_title) + } else { + stringResource(R.string.add_proxy_button) + }, + style = MaterialTheme.typography.labelMedium, + fontSize = 12.sp + ) + } + } + } + } +} + +@Composable +internal fun ProxyStatusPill( + text: String, + backgroundColor: Color, + contentColor: Color, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(999.dp)) + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index edcd602a..069c4580 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -480,7 +480,14 @@ Añadir Conexión Cambio Inteligente - Usar automáticamente el proxy más rápido + Cambiar automáticamente entre proxys disponibles + Modo de cambio inteligente + Cómo el cambio inteligente elige los proxies disponibles + Menor ping + Aleatorio disponible + Intervalo de comprobación automática + Con qué frecuencia el cambio inteligente comprueba la disponibilidad de proxies + %1$d min Preferir IPv6 Usar IPv6 cuando esté disponible Deshabilitar Proxy @@ -518,9 +525,19 @@ Mostrar solo proxys disponibles o sin comprobar Exportar proxys Importar proxys + Pegar proxys + Escanear QR de proxy Lista de proxys exportada Error al exportar la lista de proxys Error al leer el archivo de importación + No se pudieron cargar los proxys + No se pudieron cargar los proxys existentes + Importación fallida: archivo no válido + Importados: %1$d, omitidos: %2$d, inválidos: %3$d + El portapapeles está vacío + No se encontraron enlaces de proxy + No se pudo añadir el proxy + No se pudo guardar el proxy Marcar como favorito Quitar de favoritos Copiar como enlace @@ -1995,4 +2012,26 @@ Código de confirmación inválido Contraseña inválida Ocurrió un error inesperado + + + Ejecutar prueba de conexión + Prueba de conexión de Telegram + Comprueba la conexión de extremo a extremo a través del centro de datos de Telegram. + Ejecutando prueba de conexión... + La conexión se ve bien. Puedes guardar y activar este proxy. + La lista principal muestra solo el ping de transporte. Esta prueba es más amplia, por lo que los valores pueden ser mayores. + Proxy + Ping de DC + Ajustes + Ping de centros de datos + Ruta: vía %1$s:%2$d + Ruta: conexión directa + Comprueba la disponibilidad de los centros de datos de Telegram usando la ruta actual. Con un proxy habilitado, las comprobaciones se hacen a través de ese proxy; de lo contrario se usa ruta directa. + Ejecutar comprobaciones de DC + Accesibles + Promedio + DC %1$d + Sonda del centro de datos de Telegram a través del proxy activo + Ruta directa + Sonda de ruta directa de Telegram \ No newline at end of file diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index e93956a9..14ac40a6 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -460,7 +460,14 @@ Ավելացնել Միացում Խելացի փոխանջատում - Ավտոմատ օգտագործել ամենաարագ պրոքսին + Ավտոմատ փոխանջատվել հասանելի պրոքսիների միջև + Խելացի փոխանջատման ռեժիմ + Ինչպես է խելացի փոխանջատումը ընտրում հասանելի պրոքսիները + Ամենացածր ping + Պատահական հասանելի + Ինքնաստուգման ընդմիջում + Որքան հաճախ է խելացի փոխանջատումը ստուգում պրոքսիների հասանելիությունը + %1$d րոպե Նախապատվությունը տալ IPv6-ին Օգտագործել IPv6, եթե հասանելի է Անջատել պրոքսին @@ -498,9 +505,19 @@ Ցույց տալ միայն հասանելի կամ չստուգված պրոքսիները Արտահանել պրոքսիները Ներմուծել պրոքսիները + Կպցնել պրոքսիները + Սքանավորել պրոքսի QR Պրոքսիների ցուցակն արտահանվել է Չհաջողվեց արտահանել պրոքսիների ցուցակը Չհաջողվեց կարդալ ներմուծման ֆայլը + Չհաջողվեց բեռնել պրոքսիները + Չհաջողվեց բեռնել առկա պրոքսիները + Ներմուծումը ձախողվեց․ անվավեր ֆայլ + Ներմուծված՝ %1$d, բաց թողնված՝ %2$d, անվավեր՝ %3$d + Փոխանակման բուֆերը դատարկ է + Պրոքսի հղումներ չեն գտնվել + Չհաջողվեց ավելացնել պրոքսին + Չհաջողվեց պահպանել պրոքսին Դարձնել ընտրյալ Հեռացնել ընտրյալներից Պատճենել որպես հղում @@ -1831,4 +1848,26 @@ Խմբագրել Վերադասավորել Ջնջել + + + Գործարկել կապի թեստը + Telegram-ի կապի թեստ + Ստուգում է վերջից վերջ կապը Telegram-ի տվյալների կենտրոնի միջոցով։ + Կապի թեստը կատարվում է... + Կապը լավ է։ Կարող եք պահպանել և միացնել այս proxy-ը։ + Հիմնական ցանկը ցույց է տալիս միայն տրանսպորտային պինգը։ Այս թեստն ավելի լայն է, ուստի արժեքները կարող են ավելի բարձր լինել։ + Proxy + DC պինգ + Կարգավորումներ + Տվյալների կենտրոնների պինգ + Երթուղի՝ %1$s:%2$d միջոցով + Երթուղի՝ ուղիղ միացում + Ստուգում է Telegram-ի տվյալների կենտրոնների հասանելիությունը ընթացիկ երթուղով։ Երբ proxy-ը միացված է, ստուգումները կատարվում են դրա միջոցով, հակառակ դեպքում՝ ուղիղ երթուղով։ + Գործարկել DC ստուգումները + Հասանելի + Միջին + DC %1$d + Telegram-ի տվյալների կենտրոնի զոնդ ակտիվ proxy-ի միջոցով + Ուղիղ երթուղի + Telegram-ի ուղիղ երթուղու զոնդ \ No newline at end of file diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 7090ef0e..9df85751 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -480,7 +480,14 @@ Adicionar Conexão Troca inteligente - Use automaticamente o proxy mais rápido + Alterne automaticamente entre proxies disponíveis + Modo de troca inteligente + Como a troca inteligente escolhe os proxies disponíveis + Menor ping + Aleatório disponível + Intervalo de verificação automática + Com que frequência a troca inteligente verifica a disponibilidade dos proxies + %1$d min Preferir IPv6 Use IPv6 quando disponível Desativar proxy @@ -518,9 +525,19 @@ Mostrar apenas proxies disponíveis ou não verificados Exportar proxies Importar proxies + Colar proxies + Escanear QR de proxy Lista de proxies exportada Falha ao exportar a lista de proxies Falha ao ler o arquivo de importação + Falha ao carregar proxies + Falha ao carregar proxies existentes + Falha na importação: arquivo inválido + Importados: %1$d, ignorados: %2$d, inválidos: %3$d + A área de transferência está vazia + Nenhum link de proxy encontrado + Falha ao adicionar proxy + Falha ao salvar proxy Definir como favorito Remover dos favoritos Copiar como link @@ -2024,4 +2041,26 @@ Código de confirmação inválido Senha inválida Ocorreu um erro inesperado + + + Executar teste de conexão + Teste de conexão do Telegram + Verifica a conexão de ponta a ponta via datacenter do Telegram. + Executando teste de conexão... + A conexão parece boa. Você pode salvar e ativar este proxy. + A lista principal mostra apenas o ping de transporte. Este teste é mais amplo, então os valores podem ser maiores. + Proxy + Ping de DC + Configurações + Ping do datacenter + Rota: via %1$s:%2$d + Rota: conexão direta + Verifica a disponibilidade dos datacenters do Telegram usando a rota atual. Com proxy ativo, as verificações passam por ele; caso contrário, usa rota direta. + Executar verificações de DC + Acessíveis + Média + DC %1$d + Sonda do datacenter do Telegram via proxy ativo + Rota direta + Sonda de rota direta do Telegram diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index b3771e5f..ba02b38e 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -474,7 +474,14 @@ Добавить Соединение Умное переключение - Автоматически выбирать самый быстрый прокси + Автоматически переключаться между доступными прокси + Режим умного переключения + Как умное переключение выбирает доступные прокси + Минимальный пинг + Случайный доступный + Интервал автопроверки + Как часто умное переключение проверяет доступность прокси + %1$d мин Предпочитать IPv6 Использовать IPv6 при наличии Отключить прокси @@ -512,9 +519,19 @@ Показывать только доступные или непроверенные прокси Экспорт прокси Импорт прокси + Вставить прокси + Сканировать QR прокси Список прокси экспортирован Не удалось экспортировать список прокси Не удалось прочитать файл импорта + Не удалось загрузить прокси + Не удалось загрузить существующие прокси + Ошибка импорта: некорректный файл + Импортировано: %1$d, пропущено: %2$d, некорректно: %3$d + Буфер обмена пуст + Ссылки на прокси не найдены + Не удалось добавить прокси + Не удалось сохранить прокси Сделать избранным Убрать из избранного Скопировать ссылку @@ -2013,4 +2030,26 @@ Неверный код подтверждения Неверный пароль Произошла непредвиденная ошибка + + + Запустить тест подключения + Тест подключения Telegram + Проверяет сквозное подключение через дата-центр Telegram. + Выполняется тест подключения... + Соединение в порядке. Можно сохранить и включить этот прокси. + В основном списке показывается только транспортный пинг. Этот тест шире, поэтому значения могут быть выше. + Прокси + Пинг ДЦ + Настройки + Пинг дата-центров + Маршрут: через %1$s:%2$d + Маршрут: прямое подключение + Проверяет доступность дата-центров Telegram по текущему маршруту. При включенном прокси проверка идет через него, иначе используется прямой маршрут. + Запустить проверку ДЦ + Доступно + Среднее + ДЦ %1$d + Проверка дата-центра Telegram через активный прокси + Прямой маршрут + Проверка прямого маршрута Telegram diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 0d8bd0bd..efda6226 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -498,7 +498,14 @@ Pridať Pripojenie Inteligentné prepínanie - Automaticky použiť najrýchlejšie proxy + Automaticky prepínať medzi dostupnými proxy + Režim inteligentného prepínania + Ako inteligentné prepínanie vyberá dostupné proxy + Najnižší ping + Náhodné dostupné + Interval automatickej kontroly + Ako často inteligentné prepínanie kontroluje dostupnosť proxy + %1$d min Uprednostniť IPv6 Použiť IPv6, keď je dostupné Vypnúť proxy @@ -536,9 +543,19 @@ Zobraziť iba dostupné alebo neoverené proxy Exportovať proxy Importovať proxy + Vložiť proxy + Skenovať proxy QR Zoznam proxy bol exportovaný Export zoznamu proxy zlyhal Nepodarilo sa načítať importovaný súbor + Nepodarilo sa načítať proxy + Nepodarilo sa načítať existujúce proxy + Import zlyhal: neplatný súbor + Importované: %1$d, preskočené: %2$d, neplatné: %3$d + Schránka je prázdna + Nenašli sa žiadne proxy odkazy + Nepodarilo sa pridať proxy + Nepodarilo sa uložiť proxy Nastaviť ako obľúbené Odstrániť z obľúbených Kopírovať ako odkaz @@ -2143,4 +2160,26 @@ Neplatný overovací kód Neplatné heslo Vyskytla sa neočakávaná chyba + + + Spustiť test pripojenia + Test pripojenia Telegramu + Kontroluje end-to-end pripojenie cez dátové centrum Telegramu. + Prebieha test pripojenia... + Pripojenie vyzerá dobre. Tento proxy môžete uložiť a zapnúť. + Hlavný zoznam zobrazuje iba transportný ping. Tento test je širší, takže hodnoty môžu byť vyššie. + Proxy + DC ping + Nastavenia + Ping dátových centier + Trasa: cez %1$s:%2$d + Trasa: priame pripojenie + Kontroluje dostupnosť dátových centier Telegramu cez aktuálnu trasu. Pri zapnutom proxy idú kontroly cez neho, inak sa použije priama trasa. + Spustiť DC kontroly + Dostupné + Priemer + DC %1$d + Sonda dátového centra Telegramu cez aktívny proxy + Priama trasa + Sonda priamej trasy Telegramu diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index ad969e2b..882ebfba 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -480,7 +480,14 @@ Ekle Bağlantı Akıllı Geçiş - Otomatik olarak en hızlı proxy\'yi kullan + Kullanılabilir proxy\'ler arasında otomatik geçiş yap + Akıllı geçiş modu + Akıllı geçişin kullanılabilir proxy\'leri nasıl seçtiği + En düşük ping + Rastgele kullanılabilir + Otomatik kontrol aralığı + Akıllı geçişin proxy kullanılabilirliğini ne sıklıkla kontrol edeceği + %1$d dk IPv6\'yı Tercih Et Mümkün olduğunda IPv6 kullan Proxy\'yi Devre Dışı Bırak @@ -518,9 +525,19 @@ Sadece mevcut veya denetlenmemiş proxy\'leri göster Proxy\'leri dışa aktar Proxy\'leri içe aktar + Proxy\'leri yapıştır + Proxy QR tara Proxy listesi dışa aktarıldı Proxy listesi dışa aktarılamadı İçe aktarma dosyası okunamadı + Proxy\'ler yüklenemedi + Mevcut proxy\'ler yüklenemedi + İçe aktarma başarısız: geçersiz dosya + İçe aktarıldı: %1$d, atlandı: %2$d, geçersiz: %3$d + Pano boş + Proxy bağlantısı bulunamadı + Proxy eklenemedi + Proxy kaydedilemedi Favorilere ekle Favorilerden çıkar Bağlantı olarak kopyala @@ -2012,4 +2029,26 @@ Geçersiz onay kodu Geçersiz şifre Beklenmedik bir hata oluştu + + + Bağlantı testini çalıştır + Telegram bağlantı testi + Telegram veri merkezi üzerinden uçtan uca bağlantıyı kontrol eder. + Bağlantı testi çalışıyor... + Bağlantı iyi görünüyor. Bu proxiyi kaydedip etkinleştirebilirsiniz. + Ana liste yalnızca taşıma gecikmesini gösterir. Bu test daha kapsamlıdır, bu yüzden değerler daha yüksek olabilir. + Proxy + DC pingi + Ayarlar + Veri merkezi pingi + Rota: %1$s:%2$d üzerinden + Rota: doğrudan bağlantı + Geçerli rota kullanılarak Telegram veri merkezi erişilebilirliği kontrol edilir. Proxy etkinse kontroller bu proxy üzerinden yapılır; aksi halde doğrudan rota kullanılır. + DC kontrollerini çalıştır + Erişilebilir + Ortalama + DC %1$d + Etkin proxy üzerinden Telegram veri merkezi yoklaması + Doğrudan rota + Doğrudan Telegram rota yoklaması diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 329a52ec..a0595b73 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -474,7 +474,14 @@ Додати Підключення Розумне перемикання - Автоматично вибирати найшвидший проксі + Автоматично перемикатися між доступними проксі + Режим розумного перемикання + Як розумне перемикання обирає доступні проксі + Найменший пінг + Випадковий доступний + Інтервал автоперевірки + Як часто розумне перемикання перевіряє доступність проксі + %1$d хв Віддавати перевагу IPv6 Використовувати IPv6 за наявності Вимкнути проксі @@ -512,9 +519,19 @@ Показувати лише доступні або неперевірені проксі Експорт проксі Імпорт проксі + Вставити проксі + Сканувати QR проксі Список проксі експортовано Не вдалося експортувати список проксі Не вдалося прочитати файл імпорту + Не вдалося завантажити проксі + Не вдалося завантажити наявні проксі + Помилка імпорту: некоректний файл + Імпортовано: %1$d, пропущено: %2$d, некоректно: %3$d + Буфер обміну порожній + Посилання на проксі не знайдено + Не вдалося додати проксі + Не вдалося зберегти проксі Додати в обране Прибрати з обраного Скопіювати як посилання @@ -545,8 +562,8 @@ Перевірити Результат перевірки Видалити - 檢查中... - 離線 + Перевірка... + Офлайн %1$dms @@ -2013,4 +2030,26 @@ Невірний код підтвердження Невірний пароль Сталася непередбачена помилка + + + Запустити тест підключення + Тест підключення Telegram + Перевіряє наскрізне підключення через дата-центр Telegram. + Виконується тест підключення... + Зʼєднання виглядає добре. Ви можете зберегти та увімкнути цей проксі. + Основний список показує лише транспортний пінг. Цей тест ширший, тому значення можуть бути вищими. + Проксі + Пінг ДЦ + Налаштування + Пінг дата-центрів + Маршрут: через %1$s:%2$d + Маршрут: пряме підключення + Перевіряє доступність дата-центрів Telegram за поточним маршрутом. Якщо проксі увімкнено, перевірка йде через нього; інакше використовується прямий маршрут. + Запустити перевірку ДЦ + Доступні + Середнє + ДЦ %1$d + Перевірка дата-центру Telegram через активний проксі + Прямий маршрут + Перевірка прямого маршруту Telegram diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 41833daa..57711cd0 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -471,7 +471,14 @@ 添加 连接 智能切换 - 自动使用速度最快的代理 + 在可用代理之间自动切换 + 智能切换模式 + 智能切换如何选择可用代理 + 最低延迟 + 随机可用 + 自动检查间隔 + 智能切换检查代理可用性的频率 + %1$d 分钟 IPv6 优先 如果可用,优先使用 IPv6 禁用代理 @@ -509,9 +516,19 @@ 仅显示可用或未检测的代理 导出代理 导入代理 + 粘贴代理 + 扫描代理二维码 代理列表已导出 导出代理列表失败 读取导入文件失败 + 加载代理失败 + 加载现有代理失败 + 导入失败:文件无效 + 已导入:%1$d,已跳过:%2$d,无效:%3$d + 剪贴板为空 + 未找到代理链接 + 添加代理失败 + 保存代理失败 设为收藏 取消收藏 复制为链接 @@ -1992,4 +2009,26 @@ 验证码无效 密码无效 发生了意外错误 + + + 运行连接测试 + Telegram 连接测试 + 通过 Telegram 数据中心检查端到端连接。 + 正在运行连接测试... + 连接状况良好。你可以保存并启用此代理。 + 主列表仅显示传输层延迟。此测试更全面,因此数值可能更高。 + 代理 + 数据中心延迟 + 设置 + 数据中心延迟 + 路由:通过 %1$s:%2$d + 路由:直连 + 使用当前路由检查 Telegram 数据中心可达性。启用代理时,将通过该代理进行检查;否则使用直连路由。 + 运行数据中心检查 + 可达 + 平均 + 数据中心 %1$d + 通过当前代理探测 Telegram 数据中心 + 直连路由 + 探测 Telegram 直连路由 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index dd863ca3..f2c548a6 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -492,7 +492,14 @@ Add Connection Smart Switching - Automatically use the fastest proxy + Automatically switch between available proxies + Smart switch mode + How smart switching chooses available proxies + Lowest ping + Random available + Auto-check interval + How often smart switching checks proxy availability + %1$d min Prefer IPv6 Use IPv6 when available Disable Proxy @@ -530,9 +537,19 @@ Show only proxies that are available or unchecked Export proxies Import proxies + Paste proxies + Scan proxy QR Proxy list exported Failed to export proxy list Failed to read import file + Failed to load proxies + Failed to load existing proxies + Import failed: invalid file + Imported: %1$d, skipped: %2$d, invalid: %3$d + Clipboard is empty + No proxy links found + Failed to add proxy + Failed to save proxy Set as favorite Remove from favorites Copy as link @@ -562,6 +579,26 @@ Save Test Test Result + Run Connection Test + Telegram Connection Test + Checks end-to-end connection via Telegram datacenter. + Running connection test... + Connection looks good. You can save and enable this proxy. + Main list shows transport ping only. This test is broader, so values can be higher. + Proxy + DC Ping + Settings + Datacenter Ping + Route: via %1$s:%2$d + Route: direct connection + Checks Telegram datacenter reachability using the current route. With an enabled proxy, checks run through that proxy; otherwise a direct route check is used. + Run DC Checks + Reachable + Average + DC %1$d + Telegram datacenter probe via active proxy + Direct Route + Direct Telegram route probe Delete Checking... Offline