Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions app/src/main/java/org/monogram/app/components/ProxyConfirmSheet.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -98,6 +121,7 @@ fun ProxyConfirmSheet(root: RootComponent) {
proxyConfirmState.type!!,
)
},
enabled = !proxyConfirmState.isChecking && proxyConfirmState.ping != -1L,
modifier = Modifier
.weight(1f)
.height(56.dp),
Expand All @@ -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()
Expand All @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<ProxyModel> =
gateway.execute(TdApi.GetProxies()).proxies.map { it.toDomain() }

Expand Down Expand Up @@ -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))
}
Expand Down
16 changes: 12 additions & 4 deletions data/src/main/java/org/monogram/data/di/dataModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -775,13 +777,19 @@ val dataModule = module {
)
}

single<ExternalProxyRepository> {
ExternalProxyRepositoryImpl(
single<ProxyRepository> {
ProxyRepositoryImpl(
remote = get(),
appPreferences = get()
)
}

single<ProxyDiagnosticsRepository> {
ProxyDiagnosticsRepositoryImpl(
remote = get()
)
}

single<LocationRepository> {
LocationRepositoryImpl(
remote = get()
Expand Down
83 changes: 69 additions & 14 deletions data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -337,7 +390,7 @@ class ConnectionManager(
return false
}

val best = coroutineScope {
val proxyChecks = coroutineScope {
proxies.map { proxy ->
async {
val ping = coRunCatching {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
Loading