diff --git a/.gitignore b/.gitignore index 0fe67e4b5..2b4a17743 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ captures/ # Native / Rust native/*-rs/target/ +native/mtproto-transport/target/ vendor/libvpx/ native/vpx/build/ native/vpx/tmp/ diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 8a8a08e98..a8e9b1ab6 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -1,6 +1,7 @@ package org.monogram import android.app.Application +import androidx.compose.material3.ComposeMaterial3Flags import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -12,10 +13,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.monogram.core.common.AppLog +import org.monogram.core.common.DebugLog import org.monogram.core.common.DebugStats import org.monogram.core.common.Outcome -import org.monogram.core.common.SponsorRegistry import org.monogram.core.common.PerfLog +import org.monogram.core.common.SponsorRegistry import org.monogram.core.common.TelegramCredentials import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.database.DatabaseProvider @@ -29,7 +31,12 @@ import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.DownloadState import org.monogram.core.ui.ImageCache import org.monogram.core.ui.perf.perfSpan +import org.monogram.feature.settings.ProxySettingsStore +import org.monogram.mtproto.MtprotoNativeLoader import org.monogram.network.bridge.BridgedMtprotoClient +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig +import org.monogram.network.bridge.ProxyType import org.monogram.network.http.MediaFetchKind import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository @@ -90,7 +97,7 @@ class MonogramApp : Application() { @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) override fun onCreate() { super.onCreate() - androidx.compose.material3.ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true + ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true val startedAt = PerfLog.nowMs() perfSpan("app:settings") { AppLog.init(cacheDir) @@ -98,6 +105,7 @@ class MonogramApp : Application() { settingsScope.launch(Dispatchers.IO) { perfSpan("app:debugStats") { DebugStats.install(BuildConfig.DEBUG, cacheDir) + DebugLog.install(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "beta") } } settingsScope.launch(Dispatchers.IO) { @@ -139,6 +147,7 @@ class MonogramApp : Application() { } }, createClient = { + MtprotoNativeLoader.initializePlatformVerifier(this) val sessionFile = File(filesDir, "mtproto.session.json") perfSpan("app:tdlibImport") { runCatching { TdlibSessionImport.maybeImport(filesDir, sessionFile, cacheDir) } @@ -150,6 +159,32 @@ class MonogramApp : Application() { sessionPath = sessionFile.absolutePath, ) } + ProxySettingsStore.load(this)?.let { saved -> + val applied = runCatching { + val transportMode = + MtprotoTransportMode.valueOf(saved.transportMode.uppercase()) + val proxyType = ProxyType.valueOf(saved.kind) + if (proxyType == ProxyType.NONE) false else + client.setTransportMode(transportMode) is Outcome.Ok && + client.configureProxy( + ProxyConfig( + proxyType, + saved.host, + saved.port, + saved.username, + saved.password, + saved.secret + ) + ) is Outcome.Ok + }.getOrDefault(false) + if (!applied) { + runCatching { client.clearProxy() } + runCatching { client.setTransportMode(MtprotoTransportMode.PADDED_INTERMEDIATE) } + ProxySettingsStore.clearActive(this) + ProxySettingsStore.markStartupFailure(this) + AppLog.warn("proxy", "stored proxy settings rejected") + } + } }, installImageCache = { perfSpan("app:imageCache") { ImageCache.install(this) } @@ -164,8 +199,10 @@ class MonogramApp : Application() { when (val result = runCatching { client.connect() }.getOrNull()) { is Outcome.Ok -> PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=ok") + is Outcome.Err -> PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=err") + null -> PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=throw") } @@ -193,11 +230,28 @@ class MonogramApp : Application() { telegramFetcher = TelegramMediaFetcher { chatId, messageId, destPath, kind, priority -> when (kind) { MediaFetchKind.Thumb -> - client.downloadMessageThumb(chatId, messageId, destPath, priority) + client.downloadMessageThumb( + chatId, + messageId, + destPath, + priority + ) + MediaFetchKind.Display -> - client.downloadMessageDisplay(chatId, messageId, destPath, priority) + client.downloadMessageDisplay( + chatId, + messageId, + destPath, + priority + ) + MediaFetchKind.Full -> - client.downloadMessageMedia(chatId, messageId, destPath, priority) + client.downloadMessageMedia( + chatId, + messageId, + destPath, + priority + ) } }, customEmojiFetcher = { documentId, destPath, priority -> diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index c6898cd25..1a3211ac5 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -156,9 +156,9 @@ class RootComponent( } } - private fun openSettings(openFolders: Boolean) { + private fun openSettings(openFolders: Boolean = false, openProxy: Boolean = false) { navigation.navigate { configurations -> - uniqueStack(configurations, Config.Settings(openFolders = openFolders)) + uniqueStack(configurations, Config.Settings(openFolders = openFolders, openProxy = openProxy)) } } @@ -426,7 +426,8 @@ class RootComponent( mediaRepository = mediaRepository, onOpenChat = ::openChatFromList, onOpenProfile = ::openProfile, - onOpenSettings = { openSettings(openFolders = false) }, + onOpenSettings = { openSettings() }, + onOpenProxy = { openSettings(openProxy = true) }, onOpenFolders = { openSettings(openFolders = true) }, ), ) @@ -512,6 +513,7 @@ class RootComponent( debugNotifications = BuildConfig.DEBUG, notificationLocal = notificationLocal, openFolders = config.openFolders, + openProxy = config.openProxy, appUpdate = appUpdate, updatesEnabled = AppUpdate.inAppUpdatesEnabled(BuildConfig.BUILD_TYPE), ), @@ -561,7 +563,7 @@ class RootComponent( data class Profile(val peerId: Long) : Config @Serializable - data class Settings(val openFolders: Boolean = false) : Config + data class Settings(val openFolders: Boolean = false, val openProxy: Boolean = false) : Config } } @@ -604,6 +606,7 @@ class HomeComponent( onOpenChat: (PeerId, Boolean, Int) -> Unit, onOpenProfile: (PeerId) -> Unit, onOpenSettings: () -> Unit, + onOpenProxy: () -> Unit = {}, onOpenFolders: () -> Unit, ) : ComponentContext by componentContext { val chats = ChatsComponent( @@ -618,6 +621,7 @@ class HomeComponent( onOpenProfile = onOpenProfile, onOpenSelfProfile = { onOpenProfile(PeerId(0L)) }, onOpenSettings = onOpenSettings, + onOpenProxy = onOpenProxy, onOpenFolders = onOpenFolders, ) val folders = FoldersComponent(componentContext, storeFactory, client, warmup) diff --git a/core/common/src/main/java/org/monogram/core/common/AppLog.kt b/core/common/src/main/java/org/monogram/core/common/AppLog.kt index d2c2fbdf8..e2f0e4289 100644 --- a/core/common/src/main/java/org/monogram/core/common/AppLog.kt +++ b/core/common/src/main/java/org/monogram/core/common/AppLog.kt @@ -26,10 +26,12 @@ object AppLog { } fun api(op: String, detail: String) { + DebugLog.ingestApi(op, detail) runCatching { Log.i("$TAG.api", "$op ${redact(detail)}") } } fun warn(op: String, detail: String) { + DebugLog.ingestWarn(op, detail) runCatching { Log.w("$TAG.api", "$op ${redact(detail)}") } } @@ -39,6 +41,7 @@ object AppLog { append(" thread=").append(thread).append('\n') append(redact(stackTrace(error))) } + DebugLog.ingestCrash(thread, body) Log.e("$TAG.crash", body) runCatching { val file = crashFile ?: return@runCatching diff --git a/core/common/src/main/java/org/monogram/core/common/DebugLog.kt b/core/common/src/main/java/org/monogram/core/common/DebugLog.kt new file mode 100644 index 000000000..4484c262b --- /dev/null +++ b/core/common/src/main/java/org/monogram/core/common/DebugLog.kt @@ -0,0 +1,170 @@ +package org.monogram.core.common + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.text.SimpleDateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale + +enum class DebugLogKind { API, RECOMPOSITION, MEDIA, CONNECTION, ERROR } + +data class DebugLogEvent( + val id: Long, + val atEpochMs: Long, + val kind: DebugLogKind, + val summary: String, + val detail: String, +) + +object DebugLog { + const val MAX_EVENTS = 2_000 + private const val SUMMARY_LIMIT = 180 + private const val DETAIL_LIMIT = 8_000 + + private val events = ArrayDeque() + private val lock = Any() + private var nextId = 1L + private val revision = MutableStateFlow(0L) + + @Volatile + var enabled: Boolean = false + private set + + val changes: StateFlow = revision + + fun install(enabled: Boolean) { + this.enabled = enabled + if (!enabled) clear() + } + + fun resetForTests(enabled: Boolean = true) { + this.enabled = enabled + synchronized(lock) { + events.clear() + nextId = 1L + } + revision.value = 0L + } + + fun ingestApi(op: String, detail: String) { + if (!enabled) return + val safeDetail = redactBlock(detail) + add(DebugLogKind.API, oneLine(op, safeDetail, null), safeDetail) + } + + fun ingestWarn(op: String, detail: String) { + if (!enabled) return + val safeDetail = redactBlock(detail) + add(DebugLogKind.ERROR, oneLine(op, safeDetail, null), safeDetail) + } + + fun ingestCrash(thread: String, detail: String) { + if (!enabled) return + val safeDetail = redactBlock(detail) + add(DebugLogKind.ERROR, oneLine("crash", thread, null), safeDetail) + } + + fun ingestPerf(op: String, detail: String, elapsedMs: Long?) { + if (!enabled) return + val safeDetail = redactBlock(detail) + val kind = classify(op, safeDetail) + add(kind, oneLine(op, safeDetail, elapsedMs), safeDetail) + } + + fun query(kind: DebugLogKind?, text: String): List { + val needle = text.trim() + val newestFirst = synchronized(lock) { events.toList() }.asReversed() + if (kind == null && needle.isEmpty()) return newestFirst + return newestFirst.filter { event -> + (kind == null || event.kind == kind) && + (needle.isEmpty() || + event.summary.contains(needle, ignoreCase = true) || + event.detail.contains(needle, ignoreCase = true)) + } + } + + fun clear() { + synchronized(lock) { events.clear() } + revision.value = revision.value + 1L + } + + fun exportText(kind: DebugLogKind? = null, text: String = ""): String { + val format = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + return buildString { + query(kind, text).forEach { event -> + append(format.format(Date(event.atEpochMs))) + append(' ') + append(event.kind.name) + append(' ') + append(event.summary) + append('\n') + if (event.detail.isNotBlank() && event.detail != event.summary) { + append(event.detail) + append('\n') + } + } + } + } + + internal fun classify(op: String, detail: String): DebugLogKind { + val subject = namedOp(detail) ?: op + val name = subject.lowercase(Locale.US) + if (name == "recomp" || name.startsWith("recomp")) return DebugLogKind.RECOMPOSITION + if (hasFailure(detail)) return DebugLogKind.ERROR + return when { + isMedia(name) -> DebugLogKind.MEDIA + isConnection(name) -> DebugLogKind.CONNECTION + else -> DebugLogKind.API + } + } + + private fun add(kind: DebugLogKind, summary: String, detail: String) { + val row = DebugLogEvent( + id = 0L, + atEpochMs = System.currentTimeMillis(), + kind = kind, + summary = DebugStats.sanitize(summary).take(SUMMARY_LIMIT), + detail = detail.take(DETAIL_LIMIT), + ) + synchronized(lock) { + val stored = row.copy(id = nextId++) + events.addLast(stored) + while (events.size > MAX_EVENTS) events.removeFirst() + } + revision.value = revision.value + 1L + } + + private fun oneLine(op: String, detail: String, elapsedMs: Long?): String { + val body = detail.replace('\n', ' ').replace('\r', ' ').trim() + val head = buildString { + append(DebugStats.sanitize(op).ifBlank { "log" }) + if (elapsedMs != null) append(' ').append(elapsedMs.coerceAtLeast(0L)).append("ms") + } + return if (body.isEmpty()) head else "$head $body" + } + + private fun redactBlock(value: String): String = + value.lineSequence().joinToString("\n") { DebugStats.sanitize(it) } + + private fun namedOp(detail: String): String? = + NAMED_OP.find(detail)?.groupValues?.get(1) + + private fun hasFailure(detail: String): Boolean { + val result = RESULT.find(detail)?.groupValues?.get(1) ?: return false + return result != "ok" && result != "cancel" + } + + private fun isMedia(name: String): Boolean = + name.startsWith("download:") || + name.startsWith("stream") || + name.startsWith("cache") || + name.contains("upload") || + name.contains("queue_wait") + + private fun isConnection(name: String): Boolean = + name.contains("connect") || name.contains("prewarm") + + private val NAMED_OP = Regex("(?:^|\\s)op=(\\S+)") + private val RESULT = Regex("(?:^|\\s)result=(\\S+)") +} diff --git a/core/common/src/main/java/org/monogram/core/common/PerfLog.kt b/core/common/src/main/java/org/monogram/core/common/PerfLog.kt index c8b81cc89..478344cca 100644 --- a/core/common/src/main/java/org/monogram/core/common/PerfLog.kt +++ b/core/common/src/main/java/org/monogram/core/common/PerfLog.kt @@ -48,12 +48,14 @@ object PerfLog { fun mark(op: String, millis: Long, detail: String = "") { DebugStats.ingestPerfMark(op, millis, detail) + DebugLog.ingestPerf(op, detail, millis) if (!isEnabled()) return runCatching { Log.i(TAG, if (detail.isEmpty()) "$op ${millis}ms" else "$op ${millis}ms $detail") } } /** One log line per event; the harness counts these. Never pass secrets. */ fun event(op: String, detail: String = "") { + DebugLog.ingestPerf(op, detail, null) if (!isEnabled()) return runCatching { Log.i(TAG, if (detail.isEmpty()) op else "$op $detail") } } diff --git a/core/common/src/test/java/org/monogram/core/common/DebugLogTest.kt b/core/common/src/test/java/org/monogram/core/common/DebugLogTest.kt new file mode 100644 index 000000000..2dd7a74eb --- /dev/null +++ b/core/common/src/test/java/org/monogram/core/common/DebugLogTest.kt @@ -0,0 +1,95 @@ +package org.monogram.core.common + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DebugLogTest { + @After + fun tearDown() { + DebugLog.resetForTests(enabled = false) + } + + @Test + fun capsAndKeepsNewest() { + DebugLog.resetForTests(enabled = true) + repeat(DebugLog.MAX_EVENTS + 5) { index -> + DebugLog.ingestApi("rpc", "n=$index") + } + val rows = DebugLog.query(null, "") + assertEquals(DebugLog.MAX_EVENTS, rows.size) + assertTrue(rows.first().summary.contains("n=${DebugLog.MAX_EVENTS + 4}")) + assertFalse(rows.any { it.summary.contains("n=0 ") || it.summary.endsWith("n=0") }) + assertTrue(rows.zipWithNext().all { (newer, older) -> newer.id > older.id }) + } + + @Test + fun filtersByKindAndSearch() { + DebugLog.resetForTests(enabled = true) + DebugLog.ingestApi("messages.getHistory", "chat=1") + DebugLog.ingestPerf("recomp", "ChatRow", null) + DebugLog.ingestPerf("cache_hit", "photo", null) + DebugLog.ingestPerf("bridge:connect", "dc=2", 12L) + DebugLog.ingestWarn("updates", "gap") + assertEquals(1, DebugLog.query(DebugLogKind.API, "").size) + assertEquals(1, DebugLog.query(DebugLogKind.RECOMPOSITION, "chatrow").size) + assertEquals(1, DebugLog.query(DebugLogKind.MEDIA, "").size) + assertEquals(1, DebugLog.query(DebugLogKind.CONNECTION, "").size) + assertEquals(1, DebugLog.query(DebugLogKind.ERROR, "GAP").size) + assertTrue(DebugLog.query(DebugLogKind.API, "photo").isEmpty()) + } + + @Test + fun redactsSecretsSessionPathsAndTokens() { + DebugLog.resetForTests(enabled = true) + DebugLog.ingestApi( + "auth", + "api_hash=deadbeef password=secret files/session.json token=123456789:abcdefghijKLMNOPQRST_uv", + ) + val exported = DebugLog.exportText() + assertFalse(exported.contains("deadbeef")) + assertFalse(exported.contains("secret")) + assertFalse(exported.contains("session.json")) + assertFalse(exported.contains("abcdefghijKLMNOPQRST_uv")) + assertTrue(exported.contains("[redacted]")) + assertTrue(exported.contains("[session-path]")) + } + + @Test + fun collectsCategoriesOnlyWhenEnabled() { + DebugLog.resetForTests(enabled = false) + AppLog.api("messages.getHistory", "start") + PerfLog.event("recomp", "Dialog") + PerfLog.event("cache_hit", "photo") + PerfLog.mark("connect", 4, "result=ok") + PerfLog.mark("download:file", 9, "result=err") + AppLog.warn("updates", "gap") + assertTrue(DebugLog.query(null, "").isEmpty()) + + DebugLog.resetForTests(enabled = true) + AppLog.api("messages.getHistory", "start") + PerfLog.event("recomp", "Dialog") + PerfLog.event("cache_hit", "photo") + PerfLog.mark("connect", 4, "result=ok") + PerfLog.mark("download:file", 9, "result=err") + AppLog.warn("updates", "gap") + val rows = DebugLog.query(null, "") + assertEquals(DebugLogKind.ERROR, rows[0].kind) + assertEquals(DebugLogKind.ERROR, rows[1].kind) + assertEquals(DebugLogKind.CONNECTION, rows[2].kind) + assertEquals(DebugLogKind.MEDIA, rows[3].kind) + assertEquals(DebugLogKind.RECOMPOSITION, rows[4].kind) + assertEquals(DebugLogKind.API, rows[5].kind) + } + + @Test + fun clearDropsEvents() { + DebugLog.resetForTests(enabled = true) + DebugLog.ingestApi("rpc", "one") + DebugLog.clear() + assertTrue(DebugLog.query(null, "").isEmpty()) + assertEquals("", DebugLog.exportText()) + } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt b/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt index b3611ad13..df2db698a 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt @@ -2,6 +2,7 @@ package org.monogram.core.ui.perf import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import org.monogram.core.common.DebugLog import org.monogram.core.common.PerfLog /** @@ -24,29 +25,44 @@ import org.monogram.core.common.PerfLog */ @Composable inline fun RecompositionProbe(name: String) { - if (PerfLog.isEnabled()) { - SideEffect { PerfLog.event("recomp", name) } + if (PerfLog.isEnabled() || DebugLog.enabled) { + SideEffect { + if (PerfLog.isEnabled()) PerfLog.event("recomp", name) + else DebugLog.ingestPerf("recomp", name, null) + } } } @Composable inline fun RecompositionProbe(name: String, detail: Int?) { - if (PerfLog.isEnabled()) { - SideEffect { PerfLog.event("recomp", if (detail == null) name else "$name $detail") } + if (PerfLog.isEnabled() || DebugLog.enabled) { + SideEffect { + val line = if (detail == null) name else "$name $detail" + if (PerfLog.isEnabled()) PerfLog.event("recomp", line) + else DebugLog.ingestPerf("recomp", line, null) + } } } @Composable inline fun RecompositionProbe(name: String, detail: Int) { - if (PerfLog.isEnabled()) { - SideEffect { PerfLog.event("recomp", "$name $detail") } + if (PerfLog.isEnabled() || DebugLog.enabled) { + SideEffect { + val line = "$name $detail" + if (PerfLog.isEnabled()) PerfLog.event("recomp", line) + else DebugLog.ingestPerf("recomp", line, null) + } } } @Composable inline fun RecompositionProbe(name: String, detail: Long) { - if (PerfLog.isEnabled()) { - SideEffect { PerfLog.event("recomp", "$name $detail") } + if (PerfLog.isEnabled() || DebugLog.enabled) { + SideEffect { + val line = "$name $detail" + if (PerfLog.isEnabled()) PerfLog.event("recomp", line) + else DebugLog.ingestPerf("recomp", line, null) + } } } diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt index 7f1e63f9d..ad9eb3388 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ChatsComponent.kt @@ -12,13 +12,14 @@ import org.monogram.core.database.SessionMetadataStore import org.monogram.core.models.Message import org.monogram.core.models.PeerId import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.session.ProxyConnectionStatus import org.monogram.network.http.MediaRepository @OptIn(ExperimentalCoroutinesApi::class) class ChatsComponent( componentContext: ComponentContext, storeFactory: StoreFactory, - client: MtprotoClient, + private val client: MtprotoClient, warmup: OfflineWarmup?, sessionStore: SessionMetadataStore?, notifications: NotificationLocalStore? = null, @@ -27,6 +28,7 @@ class ChatsComponent( private val onOpenProfile: (PeerId) -> Unit = {}, private val onOpenSelfProfile: () -> Unit = {}, private val onOpenSettings: () -> Unit = {}, + private val onOpenProxy: () -> Unit = {}, private val onOpenFolders: () -> Unit = {}, ) : ComponentContext by componentContext { @@ -44,20 +46,26 @@ class ChatsComponent( fun onMarkRead(chatIds: List) = store.accept(ChatsStore.Intent.MarkRead(chatIds)) fun onMarkUnread(chatId: PeerId, unread: Boolean) = store.accept(ChatsStore.Intent.MarkUnread(chatId, unread)) + fun onFolderSelected(folderId: Int?) = store.accept(ChatsStore.Intent.FolderSelected(folderId)) fun onChatClick(id: PeerId) { openChat(id, messageId = 0) } + fun onSearchMessageClick(message: Message) { val (peer, messageId) = searchMessageJump(message) openChat(peer, messageId) } + private fun openChat(id: PeerId, messageId: Int) { val forum = state.value.chats.firstOrNull { it.id == id }?.isForum == true onOpenChat(id, forum, messageId) } + fun onPeerProfile(id: PeerId) = onOpenProfile(id) fun onOpenSelfProfile() = onOpenSelfProfile.invoke() fun onOpenSettings() = onOpenSettings.invoke() + fun onOpenProxy() = onOpenProxy.invoke() fun onOpenFolders() = onOpenFolders.invoke() + fun proxyConnectionStatus(): ProxyConnectionStatus = client.proxyConnectionStatus() } diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt index d8b910055..9aa4369d8 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatListAppBar.kt @@ -1,8 +1,8 @@ package org.monogram.feature.chats.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -10,33 +10,27 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith 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.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material.icons.outlined.MarkChatRead import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Sync import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import org.monogram.core.ui.menu.AppMenuGroup -import org.monogram.core.ui.menu.AppMenuItem -import org.monogram.core.ui.menu.AppMenuPopup -import org.monogram.core.ui.menu.AppMenuSurface import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,17 +40,18 @@ 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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import java.io.File import org.monogram.core.ui.components.PeerAvatar +import org.monogram.core.ui.menu.AppMenuGroup +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface +import org.monogram.network.bridge.session.ProxyConnectionStatus import org.monogram.network.http.MediaRepository -import org.monogram.core.ui.components.SearchField +import java.io.File /** * One single-row header. At the top of the list it shows the product name (with the account's @@ -83,6 +78,7 @@ internal fun ChatsTopBar( onToggleSearch: () -> Unit, onOpenProfile: () -> Unit, onOpenSettings: () -> Unit, + onOpenProxy: () -> Unit = {}, onMarkAllRead: () -> Unit, searchLabel: String, searchCloseLabel: String, @@ -91,6 +87,8 @@ internal fun ChatsTopBar( settingsLabel: String, markAllReadLabel: String, markAllReadEnabled: Boolean, + proxyStatus: ProxyConnectionStatus = ProxyConnectionStatus.Disabled, + proxyStatusLabel: String = "Proxy status", ) { // Pinned single-row bar: it never moves, only its surface tone and content change. val containerColor by animateColorAsState( @@ -127,6 +125,24 @@ internal fun ChatsTopBar( } }, actions = { + if (proxyStatus != ProxyConnectionStatus.Disabled) { + val icon = when (proxyStatus) { + ProxyConnectionStatus.Connected -> Icons.Outlined.CloudDone + ProxyConnectionStatus.Connecting -> Icons.Outlined.Sync + ProxyConnectionStatus.Disconnected, + ProxyConnectionStatus.Failed -> Icons.Outlined.CloudOff + + ProxyConnectionStatus.Disabled -> Icons.Outlined.CloudOff + } + val tint = when (proxyStatus) { + ProxyConnectionStatus.Connected -> MaterialTheme.colorScheme.primary + ProxyConnectionStatus.Connecting -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.error + } + IconButton(onClick = onOpenProxy) { + Icon(icon, contentDescription = proxyStatusLabel, tint = tint) + } + } IconButton(onClick = onToggleSearch) { Icon( imageVector = if (searchOpen) Icons.Outlined.Close else Icons.Outlined.Search, diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt index d2b4ead77..b8c564197 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsContent.kt @@ -2,6 +2,10 @@ package org.monogram.feature.chats.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -12,22 +16,35 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material.icons.outlined.Inventory2 import androidx.compose.material.icons.outlined.MarkChatRead import androidx.compose.material.icons.outlined.MarkChatUnread +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.SearchOff +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -45,84 +62,68 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.arkivanov.essenty.backhandler.BackCallback +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.ARCHIVE_FOLDER_ID import org.monogram.core.models.Chat import org.monogram.core.models.Folder import org.monogram.core.models.PeerId import org.monogram.core.models.Profile -import org.monogram.core.models.displayPreview import org.monogram.core.models.peerAvatarCacheKey import org.monogram.core.ui.AppearanceSettings +import org.monogram.core.ui.ExpressiveDefaults +import org.monogram.core.ui.collectWhenActive import org.monogram.core.ui.components.AppStatusBanner import org.monogram.core.ui.components.AppSyncStatus import org.monogram.core.ui.components.ChatListSkeleton import org.monogram.core.ui.components.FolderChipItem import org.monogram.core.ui.components.FolderChipRow import org.monogram.core.ui.components.FolderChips +import org.monogram.core.ui.components.SearchField import org.monogram.core.ui.components.isPeerOnline import org.monogram.core.ui.components.uiLabel -import org.monogram.core.ui.collectWhenActive -import org.monogram.core.ui.perf.RecompositionProbe -import org.monogram.core.ui.perf.perfSpan import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.core.ui.media.showsMiniPlayer import org.monogram.core.ui.menu.AppMenuGroup import org.monogram.core.ui.menu.AppMenuItem import org.monogram.core.ui.menu.AppMenuPopup import org.monogram.core.ui.menu.AppMenuSurface +import org.monogram.core.ui.perf.RecompositionProbe +import org.monogram.core.ui.perf.perfSpan import org.monogram.core.ui.rememberCacheGeneration import org.monogram.core.ui.rememberEnsuredFile -import org.monogram.core.models.ARCHIVE_FOLDER_ID import org.monogram.feature.chats.ChatsComponent -import org.monogram.feature.chats.archivePreviewTitles import org.monogram.feature.chats.FolderListScroll +import org.monogram.feature.chats.R +import org.monogram.feature.chats.archivePreviewTitles import org.monogram.feature.chats.clampFolderScroll import org.monogram.feature.chats.defaultFolderId -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.outlined.Forum -import androidx.compose.material.icons.outlined.Inventory2 -import androidx.compose.material.icons.outlined.SearchOff -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Surface -import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.style.TextAlign -import org.monogram.core.ui.ExpressiveDefaults -import org.monogram.feature.chats.R import org.monogram.feature.chats.folderChipItems -import org.monogram.feature.chats.showsFolderChipRow import org.monogram.feature.chats.folderScrollKey import org.monogram.feature.chats.folderUnreadBadge +import org.monogram.feature.chats.matchesSearchQuery import org.monogram.feature.chats.onFolderChipClick +import org.monogram.feature.chats.showsFolderChipRow import org.monogram.feature.chats.unreadChatIds -import org.monogram.feature.chats.matchesSearchQuery import org.monogram.feature.chats.visibleChats import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository -import org.monogram.core.ui.components.SearchField @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -141,10 +142,18 @@ fun ChatsContent( selectionBottomBar: @Composable () -> Unit = {}, ) { val state = collectWhenActive(component.state, listActive) + var proxyStatus by remember { mutableStateOf(component.proxyConnectionStatus()) } + LaunchedEffect(component, listActive) { + while (isActive) { + proxyStatus = component.proxyConnectionStatus() + delay(750) + } + } val appearance by AppearanceSettings.state.collectAsState() val cancelSelection by rememberUpdatedState(onCancelSelection) DisposableEffect(component, selectingRecipient, listActive) { - val callback = BackCallback(isEnabled = selectingRecipient && listActive) { cancelSelection() } + val callback = + BackCallback(isEnabled = selectingRecipient && listActive) { cancelSelection() } component.backHandler.register(callback) onDispose { component.backHandler.unregister(callback) } } @@ -157,7 +166,12 @@ fun ChatsContent( var selectedFolderId by rememberSaveable { mutableStateOf(null) } // The list opens on the first folder; a tap or the archive outranks that default. var folderChoiceMade by rememberSaveable { mutableStateOf(false) } - val defaultFolder = remember(folders, appearance.showAllChats) { defaultFolderId(folders, appearance.showAllChats) } + val defaultFolder = remember(folders, appearance.showAllChats) { + defaultFolderId( + folders, + appearance.showAllChats + ) + } var archiveOpen by rememberSaveable { mutableStateOf(false) } var searchOpen by rememberSaveable { mutableStateOf(false) } var folderMenu by remember { mutableStateOf(null) } @@ -253,8 +267,8 @@ fun ChatsContent( if (archiveOpen) return@LaunchedEffect val current = selectedFolderId val missing = (current != null && current != ARCHIVE_FOLDER_ID && - folders.isNotEmpty() && folders.none { it.id == current }) || - (current == null && !appearance.showAllChats && defaultFolder != null) + folders.isNotEmpty() && folders.none { it.id == current }) || + (current == null && !appearance.showAllChats && defaultFolder != null) if (missing) { selectedFolderId = defaultFolder folderChoiceMade = defaultFolder != null @@ -334,7 +348,12 @@ fun ChatsContent( val archivedChats = remember { ChatListSnapshot() } allChats.replace(state.chats) perfSpan("shownChats") { - shownChats.replace(filterChats(visibleChats(state.chats, folders, homeFolderId), state.query)) + shownChats.replace( + filterChats( + visibleChats(state.chats, folders, homeFolderId), + state.query + ) + ) } val archivedSlice = remember(state.chats) { perfSpan("archiveSlice") { @@ -377,7 +396,7 @@ fun ChatsContent( val atTop by remember(paneListState) { derivedStateOf { paneListState.firstVisibleItemIndex == 0 && - paneListState.firstVisibleItemScrollOffset <= AtTopTolerance + paneListState.firstVisibleItemScrollOffset <= AtTopTolerance } } val rawSync = when { @@ -399,8 +418,10 @@ fun ChatsContent( overlayTitle != null -> null state.error?.kind == TelegramError.Kind.Network -> stringResource(R.string.chats_waiting_network) + unreadChats > 0 -> pluralStringResource(R.plurals.chats_unread_chats, unreadChats, unreadChats) + else -> null } val folderTitle = when { @@ -448,35 +469,38 @@ fun ChatsContent( }, ) } else { - ChatsTopBar( - atTop = atTop, - brandTitle = if (archive) archiveLabel else brandLabel, - brandEmojiDocumentId = if (archive) null else state.self?.emojiStatusDocumentId, - folderTitle = folderTitle, - folderSubtitle = subtitle, - overlayTitle = overlayTitle, - archive = archive, - selfTitle = state.self?.title ?: stringResource(R.string.chats_profile), - selfAvatar = rememberSelfAvatar( - self = state.self, + ChatsTopBar( + atTop = atTop, + brandTitle = if (archive) archiveLabel else brandLabel, + brandEmojiDocumentId = if (archive) null else state.self?.emojiStatusDocumentId, + folderTitle = folderTitle, + folderSubtitle = subtitle, + overlayTitle = overlayTitle, + archive = archive, + selfTitle = state.self?.title ?: stringResource(R.string.chats_profile), + selfAvatar = rememberSelfAvatar( + self = state.self, + mediaRepository = component.mediaRepository, + ), + selfOnline = selfOnline, mediaRepository = component.mediaRepository, - ), - selfOnline = selfOnline, - mediaRepository = component.mediaRepository, - searchOpen = searchOpen, - onBack = closeArchive, - onToggleSearch = { searchOpen = !searchOpen }, - onOpenProfile = component::onOpenSelfProfile, - onOpenSettings = component::onOpenSettings, - onMarkAllRead = { component.onMarkRead(markAllIds) }, - searchLabel = stringResource(R.string.chats_search), - searchCloseLabel = stringResource(R.string.chats_search_close), - backLabel = stringResource(R.string.chats_back), - profileLabel = stringResource(R.string.chats_my_profile), - settingsLabel = stringResource(R.string.chats_settings), - markAllReadLabel = markAllReadLabel, - markAllReadEnabled = markAllIds.isNotEmpty(), - ) + searchOpen = searchOpen, + onBack = closeArchive, + onToggleSearch = { searchOpen = !searchOpen }, + onOpenProfile = component::onOpenSelfProfile, + onOpenSettings = component::onOpenSettings, + onOpenProxy = component::onOpenProxy, + onMarkAllRead = { component.onMarkRead(markAllIds) }, + searchLabel = stringResource(R.string.chats_search), + searchCloseLabel = stringResource(R.string.chats_search_close), + backLabel = stringResource(R.string.chats_back), + profileLabel = stringResource(R.string.chats_my_profile), + settingsLabel = stringResource(R.string.chats_settings), + markAllReadLabel = markAllReadLabel, + markAllReadEnabled = markAllIds.isNotEmpty(), + proxyStatus = proxyStatus, + proxyStatusLabel = stringResource(R.string.chats_proxy_status), + ) } }, bottomBar = { @@ -515,7 +539,9 @@ fun ChatsContent( component::onRefresh }, ) - Column(modifier = Modifier.weight(1f).fillMaxSize()) { + Column(modifier = Modifier + .weight(1f) + .fillMaxSize()) { if (!archive && !searchOpen && !foldersAtBottom && showFolderChips) { Box { FolderChipRow( @@ -544,7 +570,9 @@ fun ChatsContent( } } } - Box(modifier = Modifier.weight(1f).fillMaxSize()) { + Box(modifier = Modifier + .weight(1f) + .fillMaxSize()) { androidx.compose.animation.AnimatedVisibility( visible = state.loading && paneEmpty && !folderMoving, modifier = Modifier.fillMaxSize(), @@ -668,7 +696,7 @@ fun ChatsContent( archivedUnmuted = archivedPreview.unmuted, archivedMuted = if (showMutedCounter) archivedPreview.muted else 0, showArchiveRow = folderId == null && !searchOpen && - state.query.isBlank() && archivedCount > 0, + state.query.isBlank() && archivedCount > 0, loading = state.loading && activePage, error = state.error, hasMore = state.hasMore && activePage, @@ -881,7 +909,14 @@ private fun rememberSelfAvatar( ) = rememberEnsuredFile( generation = rememberCacheGeneration( remember(mediaRepository, self?.id, self?.avatarCacheKey) { - self?.id?.let { mediaRepository?.cacheGeneration(peerAvatarCacheKey(it, self.avatarCacheKey)) } + self?.id?.let { + mediaRepository?.cacheGeneration( + peerAvatarCacheKey( + it, + self.avatarCacheKey + ) + ) + } }, ), identity = self?.id?.value to self?.avatarCacheKey, @@ -937,7 +972,15 @@ private fun filterChats( private const val AtTopTolerance = 8 private val FolderScrollSaver = listSaver, Int>( - save = { entries -> entries.flatMap { (id, scroll) -> listOf(id, scroll.index, scroll.offset) } }, + save = { entries -> + entries.flatMap { (id, scroll) -> + listOf( + id, + scroll.index, + scroll.offset + ) + } + }, restore = { values -> values.chunked(3).associate { (id, index, offset) -> id to FolderListScroll(index, offset) } }, diff --git a/feature/chats/src/main/res/values-es/strings.xml b/feature/chats/src/main/res/values-es/strings.xml index bc91e7ac4..e5700040a 100644 --- a/feature/chats/src/main/res/values-es/strings.xml +++ b/feature/chats/src/main/res/values-es/strings.xml @@ -66,4 +66,5 @@ Verificado Menciones no leídas Reacciones no leídas + Proxy connection status diff --git a/feature/chats/src/main/res/values-hy/strings.xml b/feature/chats/src/main/res/values-hy/strings.xml index c94be716c..d0aba12c9 100644 --- a/feature/chats/src/main/res/values-hy/strings.xml +++ b/feature/chats/src/main/res/values-hy/strings.xml @@ -66,4 +66,5 @@ Հաստատված Չկարդացված նշումներ Չկարդացված արձագանքներ + Proxy connection status diff --git a/feature/chats/src/main/res/values-ja/strings.xml b/feature/chats/src/main/res/values-ja/strings.xml index 340c8e3ad..c948cfaf8 100644 --- a/feature/chats/src/main/res/values-ja/strings.xml +++ b/feature/chats/src/main/res/values-ja/strings.xml @@ -64,4 +64,5 @@ 認証済み 未読のメンション 未読のリアクション + Proxy connection status diff --git a/feature/chats/src/main/res/values-pt-rBR/strings.xml b/feature/chats/src/main/res/values-pt-rBR/strings.xml index f95e76cee..09ad864ed 100644 --- a/feature/chats/src/main/res/values-pt-rBR/strings.xml +++ b/feature/chats/src/main/res/values-pt-rBR/strings.xml @@ -66,4 +66,5 @@ Verificado Menções não lidas Reações não lidas + Proxy connection status diff --git a/feature/chats/src/main/res/values-ru/strings.xml b/feature/chats/src/main/res/values-ru/strings.xml index 8680dd4fb..3b5fdacea 100644 --- a/feature/chats/src/main/res/values-ru/strings.xml +++ b/feature/chats/src/main/res/values-ru/strings.xml @@ -70,4 +70,5 @@ Подтверждён Непрочитанные упоминания Непрочитанные реакции + Proxy connection status diff --git a/feature/chats/src/main/res/values-sk/strings.xml b/feature/chats/src/main/res/values-sk/strings.xml index 4f0a31fda..95ae39000 100644 --- a/feature/chats/src/main/res/values-sk/strings.xml +++ b/feature/chats/src/main/res/values-sk/strings.xml @@ -70,4 +70,5 @@ Overené Neprečítané zmienky Neprečítané reakcie + Proxy connection status diff --git a/feature/chats/src/main/res/values-tr/strings.xml b/feature/chats/src/main/res/values-tr/strings.xml index c3dee61d4..b0ab35ecf 100644 --- a/feature/chats/src/main/res/values-tr/strings.xml +++ b/feature/chats/src/main/res/values-tr/strings.xml @@ -66,4 +66,5 @@ Onaylı Okunmamış bahsetmeler Okunmamış tepkiler + Proxy connection status diff --git a/feature/chats/src/main/res/values-uk/strings.xml b/feature/chats/src/main/res/values-uk/strings.xml index 7f995cb97..8f273d227 100644 --- a/feature/chats/src/main/res/values-uk/strings.xml +++ b/feature/chats/src/main/res/values-uk/strings.xml @@ -70,4 +70,5 @@ Підтверджений Непрочитані згадки Непрочитані реакції + Proxy connection status diff --git a/feature/chats/src/main/res/values-zh/strings.xml b/feature/chats/src/main/res/values-zh/strings.xml index a5e1777c9..4e8137e53 100644 --- a/feature/chats/src/main/res/values-zh/strings.xml +++ b/feature/chats/src/main/res/values-zh/strings.xml @@ -64,4 +64,5 @@ 已认证 未读提及 未读表情回应 + Proxy connection status diff --git a/feature/chats/src/main/res/values/strings.xml b/feature/chats/src/main/res/values/strings.xml index ca7951469..ae5cc7a76 100644 --- a/feature/chats/src/main/res/values/strings.xml +++ b/feature/chats/src/main/res/values/strings.xml @@ -66,4 +66,5 @@ Verified Unread mentions Unread reactions + Proxy connection status diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt index b4565c16e..084f071be 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogStore.kt @@ -140,6 +140,7 @@ interface DialogStore : Store { val isSelf: Boolean = false, val isGroup: Boolean = false, val isChannel: Boolean = false, + val isBot: Boolean = false, val isForum: Boolean = false, val emojiStatusDocumentId: Long? = null, val messages: List = emptyList(), diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt index be680fab2..b377f9916 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogExecutor.kt @@ -547,6 +547,7 @@ internal class DialogExecutor( dispatch(Msg.IsSelf(sessionStore?.readAuthorizedUserId() == chatId)) dispatch(Msg.IsGroup(isGroup)) dispatch(Msg.IsChannel(isChannel)) + cachedChat?.let { dispatch(Msg.IsBot(it.isBot)) } dispatch(Msg.IsForum(forum)) cachedChat?.let { dispatch(rightsMsg(it)) } cachedChat?.unreadCount?.let { dispatch(Msg.UnreadCount(it)) } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt index a66d1add9..2facd2c0f 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReducer.kt @@ -26,6 +26,7 @@ internal object DialogReducer : Reducer { is Msg.IsSelf -> copy(isSelf = msg.value) is Msg.IsGroup -> copy(isGroup = msg.value) is Msg.IsChannel -> copy(isChannel = msg.value) + is Msg.IsBot -> copy(isBot = msg.value) is Msg.IsForum -> copy(isForum = msg.value) is Msg.Senders -> copy(senders = senders + msg.value) is Msg.SenderTags -> copy(senderTags = msg.value) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt index 5269b561b..69e8f12c2 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/Msg.kt @@ -27,6 +27,7 @@ internal sealed interface Msg { data class IsSelf(val value: Boolean) : Msg data class IsGroup(val value: Boolean) : Msg data class IsChannel(val value: Boolean) : Msg + data class IsBot(val value: Boolean) : Msg data class Senders(val value: Map) : Msg data class SenderTags(val value: Map) : Msg data class Draft(val value: String) : Msg diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt index 58dde84b3..4403dc7ef 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContent.kt @@ -165,7 +165,12 @@ fun DialogContent(component: DialogComponent, modifier: Modifier = Modifier) { repository = component.mediaRepository, chatCanForward = state.canForward, onForward = component::onForwardMessages, - onDelete = { messages -> messages.forEach { component.onDelete(it.id.id, revoke = true) } }, + onDelete = { messages, revoke -> + messages.forEach { component.onDelete(it.id.id, revoke) } + }, + deleteOffer = { messages -> + deleteOffer(state, messages, System.currentTimeMillis() / 1000) + }, onShowInChat = { message -> component.onJumpToMessage(message.id.id) }, onEnsureReceipts = { message -> component.onLoadReadReceipts(message.id.id) }, onOpenSeenBy = { message -> mediaViewerSeenBy = message }, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContentHelpers.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContentHelpers.kt index bd8fa74d8..c9afdb865 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContentHelpers.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogContentHelpers.kt @@ -175,9 +175,7 @@ internal fun messageMenuActions( !message.pending && !message.text.isNullOrBlank() && state.canSendPlain, - canDelete = !message.pending && - message.id.id > 0 && - (message.outgoing || state.canDeleteOthers), + canDelete = canDeleteMessage(state, message), canForward = canForward, forwardRestricted = sourceShape && state.canForward && message.noforwards, canSelectForForwarding = canForward, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt index ccb9af5ed..6c50ae622 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt @@ -110,7 +110,7 @@ internal fun ColumnScope.DialogHistoryPane( packDocumentId: MutableState, instantViewUrl: MutableState, instantViewHash: MutableIntState, - pendingDeleteId: MutableState, + pendingDeleteIds: MutableState>, taskDraftFor: MutableState?>, peerListId: MutableState, peerListKind: MutableState, @@ -135,7 +135,7 @@ internal fun ColumnScope.DialogHistoryPane( var packDocumentId by packDocumentId var instantViewUrl by instantViewUrl var instantViewHash by instantViewHash - var pendingDeleteId by pendingDeleteId + var pendingDeleteIds by pendingDeleteIds var taskDraftFor by taskDraftFor var peerListId by peerListId var peerListKind by peerListKind @@ -603,7 +603,7 @@ internal fun ColumnScope.DialogHistoryPane( ) }, onEdit = component::onEdit, - onDelete = { pendingDeleteId = it.id.id }, + onDelete = { pendingDeleteIds = listOf(it.id.id) }, onForward = component::onForwardPick, onReact = { emoji, doc -> component.onReact(message.id.id, emoji, doc) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt index 21ae20494..46bd6d681 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreen.kt @@ -176,7 +176,7 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { galleryAccessDenied = !galleryAccess if (galleryAccess) reloadGallery() } - val pendingDeleteId = rememberSaveable { mutableStateOf(null) } + val pendingDeleteIds = remember { mutableStateOf>(emptyList()) } val instantViewUrl = rememberSaveable { mutableStateOf(null) } val instantViewHash = rememberSaveable { mutableIntStateOf(0) } val clipboard = LocalClipboard.current @@ -343,11 +343,17 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { onOpenEmojiStatus = { packDocumentId.value = it }, selectedMessageCount = selectedMessageIds.value.size, canForwardSelected = forwardableSelectedMessages.isNotEmpty() && forwardableSelectedMessages.size == selectedMessageIds.value.size, + canDeleteSelected = deleteOffer( + state, + state.messages.filter { it.id.id in selectedMessageIds.value }, + System.currentTimeMillis() / 1000, + ).visible, onClearSelectedMessages = { selectedMessageIds.value = emptyList() }, onForwardSelectedMessages = { component.onForwardMessages(forwardableSelectedMessages) selectedMessageIds.value = emptyList() }, + onDeleteSelectedMessages = { pendingDeleteIds.value = selectedMessageIds.value }, ) }, ) { inner -> @@ -559,7 +565,7 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { packDocumentId = packDocumentId, instantViewUrl = instantViewUrl, instantViewHash = instantViewHash, - pendingDeleteId = pendingDeleteId, + pendingDeleteIds = pendingDeleteIds, taskDraftFor = taskDraftFor, peerListId = peerListId, peerListKind = peerListKind, @@ -574,7 +580,7 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { packDocumentId = packDocumentId, instantViewUrl = instantViewUrl, instantViewHash = instantViewHash, - pendingDeleteId = pendingDeleteId, + pendingDeleteIds = pendingDeleteIds, taskDraftFor = taskDraftFor, taskDraft = taskDraft, onPhotos = { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreenDialogs.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreenDialogs.kt index 3871538ed..00635ab69 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreenDialogs.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogScreenDialogs.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField @@ -12,12 +13,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.window.Dialog @@ -39,7 +42,7 @@ internal fun DialogScreenDialogs( packDocumentId: MutableState, instantViewUrl: MutableState, instantViewHash: androidx.compose.runtime.MutableIntState, - pendingDeleteId: MutableState, + pendingDeleteIds: MutableState>, taskDraftFor: MutableState?>, taskDraft: MutableState, onPhotos: () -> Unit, @@ -50,14 +53,14 @@ internal fun DialogScreenDialogs( var packDocumentId by packDocumentId var instantViewUrl by instantViewUrl var instantViewHash by instantViewHash - var pendingDeleteId by pendingDeleteId + var pendingDeleteIds by pendingDeleteIds var taskDraftFor by taskDraftFor var taskDraft by taskDraft val playbackContext = LocalContext.current val playbackSession = remember(playbackContext) { MediaPlaybackHolder.session(playbackContext) } val playbackVisible = playbackSession.isMessagePlayback && playbackSession.surface != MediaSurface.VIEWER && playbackSession.surface != MediaSurface.PIP - val pendingDelete = state.messages.firstOrNull { it.id.id == pendingDeleteId } + val pendingDelete = pendingDeleteIds.mapNotNull { id -> state.messages.firstOrNull { it.id.id == id } } AnimatedVisibility( visible = playbackVisible, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -192,24 +195,27 @@ internal fun DialogScreenDialogs( }, ) } - pendingDelete?.let { deleting -> - AlertDialog( - onDismissRequest = { pendingDeleteId = null }, - title = { Text(stringResource(R.string.dialog_delete_confirm)) }, - confirmButton = { - TextButton( - onClick = { - component.onDelete(deleting.id.id, revoke = true) - pendingDeleteId = null - }, - ) { - Text(stringResource(R.string.dialog_delete_for_everyone)) - } - }, - dismissButton = { - TextButton(onClick = { pendingDeleteId = null }) { - Text(stringResource(R.string.dialog_cancel)) - } + val deleteOfferNow = if (pendingDelete.size == pendingDeleteIds.size && pendingDelete.isNotEmpty()) { + deleteOffer(state, pendingDelete, System.currentTimeMillis() / 1000) + } else { + DeleteOffer(forMe = false, forEveryone = false) + } + LaunchedEffect(pendingDeleteIds, pendingDelete.size, deleteOfferNow.visible) { + if (pendingDeleteIds.isNotEmpty() && + (pendingDelete.size != pendingDeleteIds.size || !deleteOfferNow.visible) + ) { + pendingDeleteIds = emptyList() + } + } + if (deleteOfferNow.visible) { + DeleteMessagesDialog( + count = pendingDelete.size, + offer = deleteOfferNow, + onDismiss = { pendingDeleteIds = emptyList() }, + onConfirm = { forEveryone -> + val revoke = deleteRevoke(forEveryone) + pendingDelete.forEach { component.onDelete(it.id.id, revoke) } + pendingDeleteIds = emptyList() }, ) } @@ -230,3 +236,43 @@ internal fun DialogScreenDialogs( } } } + +@Composable +internal fun DeleteMessagesDialog( + count: Int, + offer: DeleteOffer, + onDismiss: () -> Unit, + onConfirm: (forEveryone: Boolean) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (count == 1) { + stringResource(R.string.dialog_delete_confirm) + } else { + pluralStringResource(R.plurals.dialog_delete_confirm_count, count, count) + }, + ) + }, + confirmButton = { + Column { + if (offer.forEveryone) { + TextButton(onClick = { onConfirm(true) }) { + Text(stringResource(R.string.dialog_delete_for_everyone)) + } + } + if (offer.forMe) { + TextButton(onClick = { onConfirm(false) }) { + Text(stringResource(R.string.dialog_delete_for_me)) + } + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt index 66bd3a800..6edda6a21 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogTopBar.kt @@ -38,6 +38,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Forward import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.Search @@ -175,8 +176,10 @@ internal fun DialogTopBar( onOpenEmojiStatus: (Long) -> Unit, selectedMessageCount: Int, canForwardSelected: Boolean, + canDeleteSelected: Boolean, onClearSelectedMessages: () -> Unit, onForwardSelectedMessages: () -> Unit, + onDeleteSelectedMessages: () -> Unit, ) { if (selectedMessageCount > 0) { TopAppBar( @@ -201,6 +204,14 @@ internal fun DialogTopBar( } }, actions = { + if (canDeleteSelected) { + IconButton(onClick = onDeleteSelectedMessages) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.dialog_delete), + ) + } + } IconButton( enabled = canForwardSelected, onClick = onForwardSelectedMessages, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageDelete.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageDelete.kt new file mode 100644 index 000000000..7ed6bec76 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageDelete.kt @@ -0,0 +1,55 @@ +package org.monogram.feature.dialog.ui + +import org.monogram.core.models.ForumIo +import org.monogram.core.models.Message +import org.monogram.feature.dialog.DialogStore + +internal const val DeleteRevokeLimitSeconds = 172_800L + +internal data class DeleteOffer( + val forMe: Boolean, + val forEveryone: Boolean, +) { + val visible: Boolean get() = forMe || forEveryone +} + +internal fun deleteRevoke(forEveryone: Boolean): Boolean = forEveryone + +internal fun deleteOffer( + state: DialogStore.State, + messages: List, + nowSeconds: Long, +): DeleteOffer { + if (messages.isEmpty()) return DeleteOffer(forMe = false, forEveryone = false) + val offers = messages.map { deleteOffer(state, it, nowSeconds) } + if (offers.any { !it.visible }) return DeleteOffer(forMe = false, forEveryone = false) + return DeleteOffer( + forMe = offers.all { it.forMe }, + forEveryone = offers.all { it.forEveryone }, + ) +} + +internal fun deleteOffer( + state: DialogStore.State, + message: Message, + nowSeconds: Long, +): DeleteOffer { + if (!canDeleteMessage(state, message)) return DeleteOffer(forMe = false, forEveryone = false) + // Broadcasts and megagroups both use channels.deleteMessages, which has no revoke flag + if (channelDelete(state)) return DeleteOffer(forMe = false, forEveryone = true) + if (state.isSelf) return DeleteOffer(forMe = true, forEveryone = false) + val fresh = message.date > 0L && nowSeconds - message.date < DeleteRevokeLimitSeconds + val forEveryone = fresh && !state.isBot && (message.outgoing || (state.isGroup && state.canDeleteOthers)) + return DeleteOffer(forMe = true, forEveryone = forEveryone) +} + +internal fun canDeleteMessage(state: DialogStore.State, message: Message): Boolean { + if (message.pending || message.id.id <= 0) return false + if (!channelDelete(state)) return true + if (message.id.id == 1) return false + if (message.mediaKind == "service") return state.canDeleteOthers + return message.outgoing || state.canDeleteOthers +} + +private fun channelDelete(state: DialogStore.State): Boolean = + state.isChannel || ForumIo.isChannelPeer(state.chatId) \ No newline at end of file diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt index 1125a8246..35a1ce611 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMediaViewer.kt @@ -23,10 +23,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.widget.Toast -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.ui.res.stringResource import org.monogram.core.common.Outcome import org.monogram.core.models.Message import org.monogram.core.models.MessageId @@ -158,7 +154,8 @@ internal fun MessageMediaViewerScope( repository: MediaRepository?, chatCanForward: Boolean = true, onForward: (List) -> Unit = {}, - onDelete: (List) -> Unit = {}, + onDelete: (List, Boolean) -> Unit = { _, _ -> }, + deleteOffer: (List) -> DeleteOffer = { DeleteOffer(forMe = true, forEveryone = false) }, onShowInChat: (Message) -> Unit = {}, onEnsureReceipts: (Message) -> Unit = {}, seenByLabel: ((Message) -> String?)? = null, @@ -204,6 +201,7 @@ internal fun MessageMediaViewerScope( chatCanForward = chatCanForward, onForward = onForward, onDelete = onDelete, + deleteOffer = deleteOffer, onShowInChat = onShowInChat, onEnsureReceipts = onEnsureReceipts, seenByLabel = seenByLabel ?: receiptHolder?.label, @@ -222,7 +220,8 @@ private fun MessageMediaViewer( chatTitle: String?, chatCanForward: Boolean, onForward: (List) -> Unit, - onDelete: (List) -> Unit, + onDelete: (List, Boolean) -> Unit, + deleteOffer: (List) -> DeleteOffer, onShowInChat: (Message) -> Unit, onEnsureReceipts: (Message) -> Unit, seenByLabel: ((Message) -> String?)?, @@ -397,7 +396,8 @@ private fun MessageMediaViewer( } }, onDelete = { item, wholeAlbum -> - pendingDelete = if (wholeAlbum) album else album.filter { messageKey(it) == item.id } + val targets = if (wholeAlbum) album else album.filter { messageKey(it) == item.id } + if (deleteOffer(targets).visible) pendingDelete = targets }, onShowInChat = { item -> album.firstOrNull { messageKey(it) == item.id }?.let(onShowInChat) @@ -419,7 +419,7 @@ private fun MessageMediaViewer( { item -> album.firstOrNull { messageKey(it) == item.id }?.let(open) } }, canRetry = true, - canDelete = true, + canDelete = album.any { deleteOffer(listOf(it)).visible }, canForward = chatCanForward, canPictureInPicture = pictureInPicture?.supported == true, onEnterPictureInPicture = { pictureInPicture?.enter() }, @@ -442,33 +442,19 @@ private fun MessageMediaViewer( ) pendingDelete?.let { targets -> - val single = targets.size == 1 - AlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text(stringResource(R.string.media_delete_title)) }, - text = { - Text( - stringResource( - if (single) R.string.media_delete_body_single else R.string.media_delete_body_album, - targets.size, - ), - ) - }, - confirmButton = { - TextButton(onClick = { + val offer = deleteOffer(targets) + if (offer.visible) { + DeleteMessagesDialog( + count = targets.size, + offer = offer, + onDismiss = { pendingDelete = null }, + onConfirm = { forEveryone -> pendingDelete = null - onDelete(targets) + onDelete(targets, deleteRevoke(forEveryone)) onDismiss() - }) { - Text(stringResource(R.string.media_delete_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { - Text(stringResource(R.string.media_delete_cancel)) - } - }, - ) + }, + ) + } } } diff --git a/feature/dialog/src/main/res/values-es/strings.xml b/feature/dialog/src/main/res/values-es/strings.xml index e48802472..522ee8f54 100644 --- a/feature/dialog/src/main/res/values-es/strings.xml +++ b/feature/dialog/src/main/res/values-es/strings.xml @@ -184,6 +184,11 @@ Reenviado a %1$s ¿Eliminar este mensaje? Eliminar para todos + Eliminar para mí + + ¿Eliminar %1$d mensaje? + ¿Eliminar %1$d mensajes? + Cancelar Markdown Vista previa diff --git a/feature/dialog/src/main/res/values-hy/strings.xml b/feature/dialog/src/main/res/values-hy/strings.xml index ca3748e3a..d63329bfd 100644 --- a/feature/dialog/src/main/res/values-hy/strings.xml +++ b/feature/dialog/src/main/res/values-hy/strings.xml @@ -184,6 +184,11 @@ Վերահասցեավորվեց %1$s Ջնջե՞լ այս հաղորդագրությունը։ Ջնջել բոլորի համար + Ջնջել ինձ համար + + Ջնջե՞լ %1$d հաղորդագրություն։ + Ջնջե՞լ %1$d հաղորդագրություն։ + Չեղարկել Markdown Նախադիտում diff --git a/feature/dialog/src/main/res/values-ja/strings.xml b/feature/dialog/src/main/res/values-ja/strings.xml index 7cec48812..b80bcf83b 100644 --- a/feature/dialog/src/main/res/values-ja/strings.xml +++ b/feature/dialog/src/main/res/values-ja/strings.xml @@ -181,6 +181,10 @@ %1$s に転送しました このメッセージを削除しますか? 全員から削除 + 自分だけ削除 + + %1$d件のメッセージを削除しますか? + キャンセル Markdown プレビュー diff --git a/feature/dialog/src/main/res/values-pt-rBR/strings.xml b/feature/dialog/src/main/res/values-pt-rBR/strings.xml index 3d640f5c9..806597e7d 100644 --- a/feature/dialog/src/main/res/values-pt-rBR/strings.xml +++ b/feature/dialog/src/main/res/values-pt-rBR/strings.xml @@ -184,6 +184,11 @@ Encaminhado para %1$s Excluir esta mensagem? Excluir para todos + Excluir para mim + + Excluir %1$d mensagem? + Excluir %1$d mensagens? + Cancelar Markdown Prévia diff --git a/feature/dialog/src/main/res/values-ru/strings.xml b/feature/dialog/src/main/res/values-ru/strings.xml index 485765a76..e07c96c74 100644 --- a/feature/dialog/src/main/res/values-ru/strings.xml +++ b/feature/dialog/src/main/res/values-ru/strings.xml @@ -240,6 +240,13 @@ Переслано в %1$s Удалить это сообщение? Удалить для всех + Удалить у меня + + Удалить %1$d сообщение? + Удалить %1$d сообщения? + Удалить %1$d сообщений? + Удалить %1$d сообщения? + Отмена Вы не можете просматривать сообщения в этом чате Вы не можете отправлять сообщения в этот чат diff --git a/feature/dialog/src/main/res/values-sk/strings.xml b/feature/dialog/src/main/res/values-sk/strings.xml index 3c83c0d00..e475430ec 100644 --- a/feature/dialog/src/main/res/values-sk/strings.xml +++ b/feature/dialog/src/main/res/values-sk/strings.xml @@ -190,6 +190,13 @@ Preposlané do %1$s Odstrániť túto správu? Odstrániť pre všetkých + Odstrániť pre mňa + + Odstrániť %1$d správu? + Odstrániť %1$d správy? + Odstrániť %1$d správ? + Odstrániť %1$d správy? + Zrušiť Markdown Náhľad diff --git a/feature/dialog/src/main/res/values-tr/strings.xml b/feature/dialog/src/main/res/values-tr/strings.xml index 373ac6e46..29fadaf28 100644 --- a/feature/dialog/src/main/res/values-tr/strings.xml +++ b/feature/dialog/src/main/res/values-tr/strings.xml @@ -184,6 +184,11 @@ %1$s sohbetine iletildi Bu mesaj silinsin mi? Herkes için sil + Benim için sil + + %1$d mesaj silinsin mi? + %1$d mesaj silinsin mi? + İptal Markdown Önizleme diff --git a/feature/dialog/src/main/res/values-uk/strings.xml b/feature/dialog/src/main/res/values-uk/strings.xml index 1ba14cf39..3c62720de 100644 --- a/feature/dialog/src/main/res/values-uk/strings.xml +++ b/feature/dialog/src/main/res/values-uk/strings.xml @@ -190,6 +190,13 @@ Переслано в %1$s Видалити це повідомлення? Видалити для всіх + Видалити в мене + + Видалити %1$d повідомлення? + Видалити %1$d повідомлення? + Видалити %1$d повідомлень? + Видалити %1$d повідомлення? + Скасувати Markdown Перегляд diff --git a/feature/dialog/src/main/res/values-zh/strings.xml b/feature/dialog/src/main/res/values-zh/strings.xml index ba68555b2..3f8f37180 100644 --- a/feature/dialog/src/main/res/values-zh/strings.xml +++ b/feature/dialog/src/main/res/values-zh/strings.xml @@ -181,6 +181,10 @@ 已转发到 %1$s 删除这条消息? 为所有人删除 + 仅为我删除 + + 删除 %1$d 条消息? + 取消 Markdown 预览 @@ -269,7 +273,7 @@ 请检查网络连接后重试 折叠 展开 - %1 / %2 + %1$d / %2$d 下载并播放 音频已就绪 下载文件 diff --git a/feature/dialog/src/main/res/values/strings.xml b/feature/dialog/src/main/res/values/strings.xml index ff5d1c77c..8f4d85456 100644 --- a/feature/dialog/src/main/res/values/strings.xml +++ b/feature/dialog/src/main/res/values/strings.xml @@ -201,6 +201,11 @@ Forwarded to %1$s Delete this message? Delete for everyone + Delete for me + + Delete %1$d message? + Delete %1$d messages? + Cancel Markdown Preview diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 679a1ded3..c1f0d0f63 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -16,6 +16,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -26,6 +27,7 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) implementation(project(":core:common")) implementation(project(":core:models")) implementation(project(":core:database")) diff --git a/feature/settings/src/androidTest/java/org/monogram/feature/settings/ui/SettingsLogsLayoutTest.kt b/feature/settings/src/androidTest/java/org/monogram/feature/settings/ui/SettingsLogsLayoutTest.kt new file mode 100644 index 000000000..193e7fa85 --- /dev/null +++ b/feature/settings/src/androidTest/java/org/monogram/feature/settings/ui/SettingsLogsLayoutTest.kt @@ -0,0 +1,125 @@ +package org.monogram.feature.settings.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import kotlin.math.abs +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.DebugLog +import org.monogram.core.ui.theme.MonogramTheme + +@OptIn(ExperimentalMaterial3Api::class) +class SettingsLogsLayoutTest { + @get:Rule + val rule = createComposeRule() + + @After + fun tearDown() { + DebugLog.resetForTests(enabled = false) + } + + @Test + fun filtersSearchAndActionsKeepTheirPlaces() { + DebugLog.resetForTests(enabled = true) + DebugLog.ingestApi("messages.getHistory", "chat=1") + DebugLog.ingestPerf("recomp", "ChatRow", null) + DebugLog.ingestPerf("cache_hit", "photo", null) + DebugLog.ingestPerf("bridge:connect", "dc=2", 8L) + DebugLog.ingestWarn("updates", "gap") + + var query by mutableStateOf("") + var kindName by mutableStateOf("all") + var confirm by mutableStateOf(false) + rule.setContent { + MonogramTheme(dynamicColor = false) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Logs") }, + actions = { + LogsTopActions( + onShare = {}, + onClear = { confirm = true }, + ) + }, + ) + }, + ) { padding -> + SettingsLogs( + query = query, + kind = debugLogKind(kindName), + shareFailed = false, + onQuery = { query = it }, + onKind = { kind -> kindName = kind?.name?.lowercase() ?: "all" }, + modifier = Modifier.padding(padding), + ) + if (confirm) { + AlertDialog( + onDismissRequest = { confirm = false }, + title = { Text("Clear logs") }, + text = { Text("Clear the in-app log? This cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { + confirm = false + DebugLog.clear() + }, + ) { Text("Clear") } + }, + dismissButton = { + TextButton(onClick = { confirm = false }) { + Text("Cancel") + } + }, + ) + } + } + } + } + rule.waitForIdle() + + val search = rule.onNodeWithText("Search logs").fetchSemanticsNode().boundsInRoot + val all = rule.onNodeWithTag("logs-filter-all").fetchSemanticsNode().boundsInRoot + val api = rule.onNodeWithTag("logs-filter-api").fetchSemanticsNode().boundsInRoot + val share = rule.onNodeWithContentDescription("Share logs").fetchSemanticsNode().boundsInRoot + val clear = rule.onNodeWithContentDescription("Clear logs").fetchSemanticsNode().boundsInRoot + assertTrue(search.bottom < all.top) + assertTrue(all.left < api.left) + assertTrue(abs(all.top - api.top) < 8f) + assertTrue(share.right < clear.left) + assertTrue(share.bottom < search.top) + assertTrue(all.bottom < rule.onNodeWithText("messages.getHistory", substring = true).fetchSemanticsNode().boundsInRoot.top) + + rule.onNodeWithTag("logs-filter-recomp").performScrollTo().performClick() + rule.waitForIdle() + rule.onNodeWithText("ChatRow", substring = true).assertIsDisplayed() + rule.onAllNodesWithText("messages.getHistory", substring = true).assertCountEquals(0) + + rule.onNodeWithContentDescription("Clear logs").performClick() + rule.onNodeWithText("Clear the in-app log? This cannot be undone.").assertIsDisplayed() + rule.onNodeWithText("Clear").performClick() + rule.waitForIdle() + rule.onNodeWithText("No events yet", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ProxyCheckQueue.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyCheckQueue.kt new file mode 100644 index 000000000..88c0492ba --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyCheckQueue.kt @@ -0,0 +1,59 @@ +package org.monogram.feature.settings + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Serializes proxy probes. The process has one MTProto proxy, so checks must not overlap, + * but callers can enqueue several and observe [checking] immediately. + */ +class ProxyCheckQueue( + private val scope: CoroutineScope, + private val check: suspend (String) -> Long?, + private val restore: suspend () -> Unit, +) { + private val mutex = Mutex() + private val checkingKeys = MutableStateFlow>(emptySet()) + val checking: StateFlow> = checkingKeys.asStateFlow() + + /** Returns false when this key is already queued or running. */ + fun request(key: String, onResult: (Long?) -> Unit): Boolean { + while (true) { + val current = checkingKeys.value + if (key in current) return false + if (checkingKeys.compareAndSet(current, current + key)) break + } + scope.launch { + var latency: Long? = null + var failed = false + try { + try { + latency = mutex.withLock { + try { + check(key) + } finally { + restore() + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + failed = true + } + onResult(if (failed) null else latency) + } finally { + checkingKeys.update { it - key } + } + } + return true + } + + suspend fun exclusive(block: suspend () -> T): T = mutex.withLock { block() } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ProxyHealthMonitor.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyHealthMonitor.kt new file mode 100644 index 000000000..ea75d1a7d --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyHealthMonitor.kt @@ -0,0 +1,40 @@ +package org.monogram.feature.settings + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.math.min + +class ProxyHealthMonitor( + private val manager: ProxyProfileManager, + private val scope: CoroutineScope, + private val test: suspend (ProxyProfile) -> Long?, + private val intervalMs: Long = 60_000, + private val maxBackoffMs: Long = 15 * 60_000, + private val onResult: (ProxyProfile, Long?) -> Unit = { _, _ -> }, +) { + private var job: Job? = null + + fun start() { + if (job?.isActive == true) return + job = scope.launch { + var backoff = intervalMs + while (isActive) { + val active = manager.active() + if (active == null || !active.enabled || !active.isAllowedOn(manager.context)) break + val latency = runCatching { test(active) }.getOrNull() + manager.record(active, latency) + onResult(active, latency) + backoff = if (latency != null) intervalMs else min(maxBackoffMs, backoff * 2) + delay(backoff) + } + } + } + + fun stop() { + job?.cancel() + job = null + } +} \ No newline at end of file diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ProxyProfile.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyProfile.kt new file mode 100644 index 000000000..1b1ad594f --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ProxyProfile.kt @@ -0,0 +1,139 @@ +package org.monogram.feature.settings + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig +import org.monogram.network.bridge.ProxyType + +/** Metadata for a saved proxy; credentials remain inside the encrypted store. */ +enum class ProxyNetworkScope { ALWAYS, WIFI_ONLY, MOBILE_ONLY, NEVER_ON_VPN } +enum class ProxyHealth { DISABLED, BLOCKED_BY_VPN, TESTING, HEALTHY, UNHEALTHY } + +data class ProxyProfile( + val id: String, + val config: ProxyConfig, + val transportMode: MtprotoTransportMode, + val networkScope: ProxyNetworkScope = ProxyNetworkScope.ALWAYS, + val enabled: Boolean = true, + val favorite: Boolean = false, + val lastLatencyMs: Long? = null, + val consecutiveFailures: Int = 0, + val lastCheckedAt: Long? = null, +) { + val health: ProxyHealth + get() = when { + !enabled -> ProxyHealth.DISABLED + consecutiveFailures > 0 && lastCheckedAt != null -> ProxyHealth.UNHEALTHY + lastLatencyMs != null -> ProxyHealth.HEALTHY + else -> ProxyHealth.UNHEALTHY + } + + fun isAllowedOn(context: Context): Boolean { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return networkScope == ProxyNetworkScope.ALWAYS + val network = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(network) ?: return false + if (networkScope == ProxyNetworkScope.NEVER_ON_VPN && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return false + return when (networkScope) { + ProxyNetworkScope.ALWAYS, ProxyNetworkScope.NEVER_ON_VPN -> true + ProxyNetworkScope.WIFI_ONLY -> capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + ProxyNetworkScope.MOBILE_ONLY -> capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } + } + + fun withTestResult(latencyMs: Long?, now: Long = System.currentTimeMillis()): ProxyProfile = + if (latencyMs != null) copy(lastLatencyMs = latencyMs, consecutiveFailures = 0, lastCheckedAt = now) + else copy(lastLatencyMs = null, consecutiveFailures = consecutiveFailures + 1, lastCheckedAt = now) + + companion object { + fun fromStored(stored: StoredProxy): ProxyProfile = ProxyProfile( + id = stored.id.ifBlank { stored.profileKey }, + config = stored.toConfig(), + transportMode = runCatching { MtprotoTransportMode.valueOf(stored.transportMode.uppercase()) } + .getOrDefault(MtprotoTransportMode.PADDED_INTERMEDIATE), + networkScope = runCatching { ProxyNetworkScope.valueOf(stored.networkScope) } + .getOrDefault(ProxyNetworkScope.ALWAYS), + enabled = stored.enabled, + favorite = stored.favorite, + lastLatencyMs = stored.lastLatencyMs, + consecutiveFailures = stored.consecutiveFailures, + lastCheckedAt = stored.lastCheckedAt, + ) + + } +} + +private fun StoredProxy.toConfig() = ProxyConfig( + type = runCatching { ProxyType.valueOf(kind) }.getOrDefault(ProxyType.SOCKS5), + host = host, + port = port, + username = username, + password = password, + secret = secret, +) + +class ProxyProfileManager(internal val context: Context) { + fun profiles(): List = ProxySettingsStore.loadSnapshot(context).profiles.map(ProxyProfile::fromStored) + + fun save(profile: ProxyProfile, activate: Boolean = false) { + val current = profiles().filterNot { it.id == profile.id } + profile + ProxySettingsStore.saveProfiles(context, current, if (activate) profile.id else activeId()) + } + + fun activate(profile: ProxyProfile) = ProxySettingsStore.saveProfiles(context, profiles(), profile.id) + fun disable() = ProxySettingsStore.clearActive(context) + fun active(): ProxyProfile? = profiles().firstOrNull { it.id == activeId() } + fun activeId(): String? = ProxySettingsStore.loadSnapshot(context).activeProfileKey + + fun remove(profile: ProxyProfile) = ProxySettingsStore.saveProfiles(context, profiles().filterNot { it.id == profile.id }, activeId()?.takeIf { it != profile.id }) + + fun record(profile: ProxyProfile, latencyMs: Long?) = save(profile.withTestResult(latencyMs), activate = activeId() == profile.id) + + suspend fun selectFastest( + candidates: List = profiles(), + maxConcurrent: Int = 3, + timeoutMs: Long = 8_000, + test: suspend (ProxyProfile) -> Long?, + ): ProxyProfile? = coroutineScope { + val semaphore = Semaphore(maxConcurrent.coerceIn(1, candidates.size.coerceAtLeast(1))) + candidates.filter { it.enabled && it.isAllowedOn(context) }.map { profile -> + async { + semaphore.withPermit { + val latency = withTimeoutOrNull(timeoutMs) { test(profile) } + profile to latency + } + } + }.awaitAll().mapNotNull { (profile, latency) -> latency?.let { profile.withTestResult(it) } } + .minWithOrNull(compareBy { it.lastLatencyMs ?: Long.MAX_VALUE }.thenByDescending { it.favorite }) + } +} + + +data class ProxyProfileFilter( + val query: String = "", + val types: Set = emptySet(), + val scopes: Set = emptySet(), + val favoritesOnly: Boolean = false, + val enabledOnly: Boolean = false, + val health: Set = emptySet(), +) + +fun Iterable.filterProfiles(filter: ProxyProfileFilter): List = filter { + val query = filter.query.trim().lowercase() + (query.isEmpty() || it.config.host.lowercase().contains(query) || it.config.type.name.lowercase().contains(query)) && + (filter.types.isEmpty() || it.config.type in filter.types) && + (filter.scopes.isEmpty() || it.networkScope in filter.scopes) && + (!filter.favoritesOnly || it.favorite) && + (!filter.enabledOnly || it.enabled) && + (filter.health.isEmpty() || it.health in filter.health) +} + diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ProxySettingsStore.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ProxySettingsStore.kt new file mode 100644 index 000000000..b51289ecb --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ProxySettingsStore.kt @@ -0,0 +1,253 @@ +package org.monogram.feature.settings + +import android.content.Context +import android.util.Base64 +import java.nio.charset.StandardCharsets +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import org.json.JSONArray +import org.json.JSONObject +import org.monogram.core.common.AppLog +import org.monogram.core.common.Outcome +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig +import org.monogram.network.bridge.ProxyType + +data class StoredProxy( + val kind: String, + val host: String, + val port: Int, + val username: String?, + val password: String?, + val secret: ByteArray, + val transportMode: String = "padded_intermediate", + val id: String = "", + val networkScope: String = "ALWAYS", + val enabled: Boolean = true, + val favorite: Boolean = false, + val lastLatencyMs: Long? = null, + val consecutiveFailures: Int = 0, + val lastCheckedAt: Long? = null, +) { + val profileKey: String + get() = id.ifBlank { listOf(kind, host, port, username.orEmpty(), secret.contentHashCode(), transportMode).joinToString("\u001e") } + + override fun toString(): String = "StoredProxy([REDACTED])" +} +data class ProxyStoreSnapshot( + val profiles: List, + val activeProfileKey: String?, +) + +object ProxySettingsStore { + private const val preferences = "proxy_settings" + private const val valueKey = "encrypted_config" + private const val activeKey = "active_profile" + private const val startupFailureKey = "startup_failure" + private const val keyAlias = "monogram_proxy_config" + private const val separator = "\u001f" + + fun load(context: Context): StoredProxy? = loadSnapshot(context).let { snapshot -> + snapshot.profiles.firstOrNull { it.profileKey == snapshot.activeProfileKey } + } + + fun loadSnapshot(context: Context): ProxyStoreSnapshot { + val profiles = loadAll(context) + val preferences = context.getSharedPreferences(preferences, Context.MODE_PRIVATE) + val active = if (preferences.contains(activeKey)) { + preferences.getString(activeKey, null) + ?.takeIf { key -> profiles.any { it.profileKey == key } } + } else { + profiles.firstOrNull()?.profileKey + } + return ProxyStoreSnapshot(profiles, active) + } + fun loadAll(context: Context): List = runCatching { + val encoded = context.getSharedPreferences(preferences, Context.MODE_PRIVATE) + .getString(valueKey, null) ?: return@runCatching emptyList() + val plaintext = decrypt(encoded) + if (plaintext.trimStart().startsWith("[")) { + val array = JSONArray(plaintext) + (0 until array.length()).map { index -> + val item = array.getJSONObject(index) + parseProxy( + listOf( + item.getString("kind"), + item.getString("host"), + item.getString("port"), + item.optString("username"), + item.optString("password"), + item.getString("secret"), + item.optString("transportMode", "padded_intermediate"), + item.optString("id"), + item.optString("networkScope", "ALWAYS"), + item.optBoolean("enabled", true).toString(), + item.optBoolean("favorite", false).toString(), + item.optString("lastLatencyMs"), + item.optString("consecutiveFailures", "0"), + item.optString("lastCheckedAt"), + ), + ) + } + } else { + listOf(parseProxy(plaintext.split(separator))) + } + }.getOrElse { + AppLog.warn("proxy", "stored proxy settings unavailable") + emptyList() + } + + + fun saveProfiles(context: Context, profiles: List, activeId: String? = null) { + saveAll(context, profiles.map { profile -> + StoredProxy( + kind = profile.config.type.name, + host = profile.config.host, + port = profile.config.port, + username = profile.config.username, + password = profile.config.password, + secret = profile.config.secret, + transportMode = profile.transportMode.name.lowercase(), + id = profile.id, + networkScope = profile.networkScope.name, + enabled = profile.enabled, + favorite = profile.favorite, + lastLatencyMs = profile.lastLatencyMs, + consecutiveFailures = profile.consecutiveFailures, + lastCheckedAt = profile.lastCheckedAt, + ) + }, activeId) + } + fun save(context: Context, proxy: StoredProxy) { + saveAll(context, listOf(proxy), proxy.profileKey) + } + + fun saveAll(context: Context, proxies: List, activeProfileKey: String? = null) { + val payload = JSONArray().apply { + proxies.forEach { proxy -> + put(JSONObject().apply { + put("kind", proxy.kind) + put("host", proxy.host) + put("port", proxy.port) + put("username", proxy.username.orEmpty()) + put("password", proxy.password.orEmpty()) + put("secret", Base64.encodeToString(proxy.secret, Base64.NO_WRAP)) + put("transportMode", proxy.transportMode) + put("id", proxy.id) + put("networkScope", proxy.networkScope) + put("enabled", proxy.enabled) + put("favorite", proxy.favorite) + proxy.lastLatencyMs?.let { put("lastLatencyMs", it) } + put("consecutiveFailures", proxy.consecutiveFailures) + proxy.lastCheckedAt?.let { put("lastCheckedAt", it) } + }) + } + }.toString() + context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit() + .putString(valueKey, encrypt(payload)) + .putString(activeKey, activeProfileKey ?: "") + .apply() + } + + fun remove(context: Context, proxy: StoredProxy) { + val snapshot = loadSnapshot(context) + val remaining = snapshot.profiles.filterNot { it.profileKey == proxy.profileKey } + saveAll( + context, + remaining, + snapshot.activeProfileKey.takeIf { it != proxy.profileKey }, + ) + } + + fun setActive(context: Context, profile: StoredProxy?) { + context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit() + .putString(activeKey, profile?.profileKey ?: "") + .apply() + } + + fun clearActive(context: Context) = setActive(context, null) + + fun markStartupFailure(context: Context) { + context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit() + .putBoolean(startupFailureKey, true) + .apply() + } + + fun consumeStartupFailure(context: Context): Boolean { + val preferences = context.getSharedPreferences(preferences, Context.MODE_PRIVATE) + val pending = preferences.getBoolean(startupFailureKey, false) + if (pending) preferences.edit().remove(startupFailureKey).apply() + return pending + } + + fun clear(context: Context) { + context.getSharedPreferences(preferences, Context.MODE_PRIVATE).edit() + .remove(valueKey) + .remove(activeKey) + .remove(startupFailureKey) + .apply() + } + + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(keyAlias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance("AES", "AndroidKeyStore").apply { + init(android.security.keystore.KeyGenParameterSpec.Builder( + keyAlias, + android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or + android.security.keystore.KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE) + .build()) + }.generateKey() + } + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + val payload = cipher.iv + cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8)) + return Base64.encodeToString(payload, Base64.NO_WRAP) + } + + private fun decrypt(value: String): String { + val payload = Base64.decode(value, Base64.NO_WRAP) + require(payload.size > 12) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, payload.copyOfRange(0, 12))) + return String(cipher.doFinal(payload.copyOfRange(12, payload.size)), StandardCharsets.UTF_8) + } + + private fun parseProxy(payload: List): StoredProxy { + require(payload.size in 6..14) + return StoredProxy( + kind = payload[0], + host = payload[1], + port = payload[2].toInt(), + username = payload[3].ifEmpty { null }, + password = payload[4].ifEmpty { null }, + secret = Base64.decode(payload[5], Base64.NO_WRAP), + transportMode = payload.getOrNull(6).orEmpty().ifEmpty { "padded_intermediate" }, + id = payload.getOrNull(7).orEmpty(), + networkScope = payload.getOrNull(8).orEmpty().ifEmpty { "ALWAYS" }, + enabled = payload.getOrNull(9)?.toBooleanStrictOrNull() ?: true, + favorite = payload.getOrNull(10)?.toBooleanStrictOrNull() ?: false, + lastLatencyMs = payload.getOrNull(11)?.toLongOrNull(), + consecutiveFailures = payload.getOrNull(12)?.toIntOrNull() ?: 0, + lastCheckedAt = payload.getOrNull(13)?.toLongOrNull(), + ).also { proxy -> + val type = ProxyType.valueOf(proxy.kind) + require(type != ProxyType.NONE) + val mode = MtprotoTransportMode.valueOf(proxy.transportMode.uppercase()) + require(type != ProxyType.MTPROTO || mode != MtprotoTransportMode.HTTP) + require(ProxyConfig(type, proxy.host, proxy.port, proxy.username, proxy.password, proxy.secret).validate() is Outcome.Ok) + } + } +} + + + + + diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt index 4ec39a473..9944c3109 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt @@ -20,14 +20,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.monogram.core.common.push.NotificationLocalStore +import org.monogram.core.common.push.PushRegistration import org.monogram.core.database.OfflineWarmup import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.models.AppUpdateState import org.monogram.core.models.PeerId import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig import org.monogram.network.http.MediaRepository -import org.monogram.core.common.push.PushRegistration -import org.monogram.core.common.push.NotificationLocalStore -import org.monogram.core.models.AppUpdateState @OptIn(ExperimentalCoroutinesApi::class) class SettingsComponent( @@ -46,6 +48,7 @@ class SettingsComponent( val debugNotifications: Boolean = false, notificationLocal: NotificationLocalStore? = null, openFolders: Boolean = false, + openProxy: Boolean = false, val appUpdate: AppUpdateController? = null, val updatesEnabled: Boolean = true, ) : ComponentContext by componentContext { @@ -87,7 +90,11 @@ class SettingsComponent( val pages: Value> = childStack( source = pageNavigation, serializer = SettingsPage.serializer(), - initialConfiguration = if (openFolders) SettingsPage.Folders else SettingsPage.Home, + initialConfiguration = when { + openProxy -> SettingsPage.Proxy + openFolders -> SettingsPage.Folders + else -> SettingsPage.Home + }, childFactory = { page, _ -> page }, ) @@ -112,13 +119,23 @@ class SettingsComponent( lifecycle.doOnDestroy { scope.cancel() } } + fun configureProxy(config: ProxyConfig) = client.configureProxy(config) + fun clearProxy() = client.clearProxy() + fun setTransportMode(mode: MtprotoTransportMode) = client.setTransportMode(mode) + suspend fun testProxyConnection() = client.connect() + suspend fun pingProxy(config: ProxyConfig) = client.pingProxy(config) + fun onRefresh() = store.accept(SettingsStore.Intent.Refresh) - fun onOpenWallpapers(cacheDirectory: java.io.File) = wallpaperStore.accept(WallpaperStore.Intent.Open( - java.io.File(cacheDirectory, "wallpapers/${state.value.profile?.id?.value ?: 0L}"), - )) + fun onOpenWallpapers(cacheDirectory: java.io.File) = wallpaperStore.accept( + WallpaperStore.Intent.Open( + java.io.File(cacheDirectory, "wallpapers/${state.value.profile?.id?.value ?: 0L}"), + ) + ) + fun onRetryWallpapers() = wallpaperStore.accept(WallpaperStore.Intent.Retry) fun onPreviewWallpaper(wallpaper: org.monogram.core.models.Wallpaper) = wallpaperStore.accept(WallpaperStore.Intent.Preview(wallpaper)) + fun onCloseWallpapers() = wallpaperStore.accept(WallpaperStore.Intent.Close) fun onClearCache() = store.accept(SettingsStore.Intent.ClearCache) fun onClearChatCache(chatId: Long) = store.accept(SettingsStore.Intent.ClearChatCache(chatId)) @@ -133,8 +150,9 @@ class SettingsComponent( fun openPage(page: SettingsPage) = pageNavigation.pushNew(page) fun popPage() { if (pageBackHandler?.invoke() == true) return - pageNavigation.pop() + if (pages.value.backStack.isEmpty()) onBack() else pageNavigation.pop() } + fun onBack() = onBack.invoke() fun onOpenProfile() = onOpenProfile(PeerId(0L)) fun onNotification(intent: NotificationsStore.Intent) = notificationsStore.accept(intent) diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt index 6751fdb1f..9fe0197a6 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt @@ -8,6 +8,9 @@ sealed interface SettingsPage { @Serializable data object Home : SettingsPage + @Serializable + data object Proxy : SettingsPage + @Serializable data object Data : SettingsPage @@ -39,4 +42,7 @@ sealed interface SettingsPage { @Serializable data object DebugStats : SettingsPage + + @Serializable + data object Logs : SettingsPage } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxyScreen.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxyScreen.kt new file mode 100644 index 000000000..01e4b50e9 --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxyScreen.kt @@ -0,0 +1,900 @@ +package org.monogram.feature.settings.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material.icons.outlined.VpnKey +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.monogram.core.common.Outcome +import org.monogram.core.ui.components.AppModalSheet +import org.monogram.core.ui.components.ItemPosition +import org.monogram.core.ui.components.SettingsCard +import org.monogram.core.ui.loading.MonogramLoading +import org.monogram.core.ui.loading.MonogramLoadingContained +import org.monogram.core.ui.loading.MonogramLoadingInlineSize +import org.monogram.core.ui.media.mediaViewerMotionEnabled +import org.monogram.feature.settings.R +import org.monogram.feature.settings.StoredProxy +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig +import org.monogram.network.bridge.ProxyType +import org.monogram.network.bridge.decodeProxySecret + +internal data class ProxyScreenState( + val type: ProxyType = ProxyType.SOCKS5, + val host: String = "", + val port: String = "1080", + val username: String = "", + val password: String = "", + val secret: String = "", + val transportMode: MtprotoTransportMode = MtprotoTransportMode.PADDED_INTERMEDIATE, + val id: String = "", + val networkScope: String = "ALWAYS", + val enabled: Boolean = true, + val favorite: Boolean = false, + val latencyMs: Long? = null, + val consecutiveFailures: Int = 0, + val lastCheckedAt: Long? = null, +) + +internal fun ProxyScreenState.withPing(latencyMs: Long?, now: Long): ProxyScreenState = + if (latencyMs != null) { + copy(latencyMs = latencyMs, consecutiveFailures = 0, lastCheckedAt = now) + } else { + copy(latencyMs = null, consecutiveFailures = consecutiveFailures + 1, lastCheckedAt = now) + } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ProxyScreen( + profiles: List, + activeKey: String?, + checking: Set, + headerBusy: Boolean, + status: String?, + statusError: Boolean, + onUse: (ProxyScreenState) -> Unit, + onCheckAll: () -> Unit, + onDisable: () -> Unit, + onSave: (ProxyScreenState, String?, (String?) -> Unit) -> Unit, + onDelete: (ProxyScreenState) -> Unit, + onPasteAdd: suspend () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + var editorOpen by rememberSaveable { mutableStateOf(false) } + var editorNonce by rememberSaveable { mutableIntStateOf(0) } + var editingKey by rememberSaveable { mutableStateOf("") } + val active = profiles.firstOrNull { it.profileKey() == activeKey } + val motion = mediaViewerMotionEnabled() + val actionShapes = ButtonDefaults.shapes( + shape = CircleShape, + pressedShape = if (motion) RoundedCornerShape(16.dp) else CircleShape, + ) + val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val openEditor = { profile: ProxyScreenState? -> + editingKey = profile?.profileKey().orEmpty() + editorNonce += 1 + editorOpen = true + } + + Box(modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxSize() + .wrapContentWidth(Alignment.CenterHorizontally) + .widthIn(max = 720.dp) + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = navBottom + 16.dp), + ) { + ProxyStatus( + active = active, + busy = headerBusy || (active != null && active.profileKey() in checking), + onDisable = if (active != null) onDisable else null, + disableEnabled = !headerBusy, + ) + if (status != null) { + Text( + status, + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), + style = MaterialTheme.typography.bodyMedium, + color = if (statusError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Row( + Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.settings_proxy_saved_list), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (profiles.isNotEmpty()) { + TextButton( + onClick = onCheckAll, + enabled = profiles.any { it.profileKey() !in checking }, + ) { + Text( + stringResource(R.string.settings_proxy_check_all), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (profiles.isEmpty()) { + Text( + stringResource(R.string.settings_proxy_empty), + modifier = Modifier.padding(bottom = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Column(Modifier.selectableGroup()) { + profiles.forEachIndexed { index, profile -> + ProxyRow( + profile = profile, + position = proxyPosition(index, profiles.size), + active = profile.profileKey() == activeKey, + checking = profile.profileKey() in checking, + onUse = { onUse(profile) }, + onEdit = { openEditor(profile) }, + ) + } + } + } + Spacer(Modifier.size(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val addMod = Modifier.weight(1f).heightIn(min = 48.dp) + val addClick = { openEditor(null) } + if (profiles.isEmpty()) { + Button(onClick = addClick, shapes = actionShapes, modifier = addMod) { AddProxyLabel() } + } else { + FilledTonalButton(onClick = addClick, shapes = actionShapes, modifier = addMod) { + AddProxyLabel() + } + } + OutlinedButton( + onClick = { scope.launch { onPasteAdd() } }, + shapes = actionShapes, + modifier = Modifier.weight(1f).heightIn(min = 48.dp), + ) { + Text( + stringResource(R.string.settings_proxy_add_clipboard), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + if (editorOpen) { + val seed = profiles.firstOrNull { it.profileKey() == editingKey } ?: ProxyScreenState() + val canDelete = editingKey.isNotEmpty() && profiles.any { it.profileKey() == editingKey } + AppModalSheet(onDismissRequest = { editorOpen = false }) { + key(editorNonce) { + ProxyEditor( + initial = seed, + editing = canDelete, + shapes = actionShapes, + motion = motion, + onDismiss = { editorOpen = false }, + onSave = onSave, + onDelete = { + profiles.firstOrNull { it.profileKey() == editingKey }?.let(onDelete) + editorOpen = false + }, + ) + } + } + } + } +} + +@Composable +private fun AddProxyLabel() { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.settings_proxy_add)) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyStatus( + active: ProxyScreenState?, + busy: Boolean, + onDisable: (() -> Unit)?, + disableEnabled: Boolean, +) { + val title = stringResource( + if (active == null) R.string.settings_proxy_direct_title else R.string.settings_proxy_in_use, + ) + val endpoint = active?.let { "${proxyTypeLabel(it.type)} · ${it.host}:${it.port}" } + val failed = active != null && !busy && active.latencyMs == null && active.consecutiveFailures > 0 + val detail = when { + busy -> stringResource(R.string.settings_proxy_checking) + active == null -> stringResource(R.string.settings_proxy_direct_body) + active.latencyMs != null -> stringResource(R.string.settings_proxy_ping_result, active.latencyMs) + failed -> stringResource(R.string.settings_proxy_unavailable) + else -> stringResource(R.string.settings_proxy_not_checked) + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = MaterialTheme.shapes.extraLarge, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = if (active == null) Icons.Outlined.Public else Icons.Outlined.VpnKey, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleLargeEmphasized) + if (endpoint != null) { + Text( + endpoint, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + detail, + style = MaterialTheme.typography.bodyMedium, + color = if (failed) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + if (busy) { + MonogramLoadingContained( + size = 48.dp, + status = stringResource(R.string.settings_proxy_checking), + ) + } else if (onDisable != null) { + TextButton(onClick = onDisable, enabled = disableEnabled) { + Text(stringResource(R.string.settings_proxy_disable)) + } + } + } + } +} + +@Composable +private fun ProxyRow( + profile: ProxyScreenState, + position: ItemPosition, + active: Boolean, + checking: Boolean, + onUse: () -> Unit, + onEdit: () -> Unit, +) { + val endpoint = "${profile.host}:${profile.port}" + val subtitle = buildString { + append(proxyTypeLabel(profile.type)) + if (profile.transportMode == MtprotoTransportMode.HTTP) { + append(" · ") + append(stringResource(R.string.settings_proxy_transport_http)) + } + append(" · ") + append(proxyPingLabel(profile, checking)) + } + val subtitleColor = if (active) { + MaterialTheme.colorScheme.onSecondaryContainer + } else if (!checking && profile.latencyMs == null && profile.consecutiveFailures > 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + SettingsCard(position = position, selected = active) { + Row( + Modifier.fillMaxWidth().heightIn(min = 64.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + Modifier + .weight(1f) + .heightIn(min = 64.dp) + .selectable(selected = active, role = Role.RadioButton, onClick = onUse) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + modifier = Modifier.size(24.dp).alpha(if (active) 1f else 0f), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + endpoint, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = subtitleColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (checking) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + MonogramLoading( + size = MonogramLoadingInlineSize, + status = stringResource(R.string.settings_proxy_checking), + ) + } + } + IconButton(onClick = onEdit, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.settings_proxy_edit_named, endpoint), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyEditor( + initial: ProxyScreenState, + editing: Boolean, + shapes: androidx.compose.material3.ButtonShapes, + motion: Boolean, + onDismiss: () -> Unit, + onSave: (ProxyScreenState, String?, (String?) -> Unit) -> Unit, + onDelete: () -> Unit, +) { + var typeName by rememberSaveable { mutableStateOf(initial.type.name) } + var host by rememberSaveable { mutableStateOf(initial.host) } + var port by rememberSaveable { mutableStateOf(initial.port) } + var portEdited by rememberSaveable { mutableStateOf(initial.port != initial.type.defaultPort()) } + var username by rememberSaveable { mutableStateOf(initial.username) } + var password by rememberSaveable { mutableStateOf(initial.password) } + var secret by rememberSaveable { mutableStateOf(initial.secret) } + var transportName by rememberSaveable { mutableStateOf(initial.transportMode.name) } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + var secretVisible by rememberSaveable { mutableStateOf(false) } + var hostError by remember { mutableStateOf(null) } + var portError by remember { mutableStateOf(null) } + var authError by remember { mutableStateOf(null) } + var secretError by remember { mutableStateOf(null) } + var formError by remember { mutableStateOf(null) } + var saving by rememberSaveable { mutableStateOf(false) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + val originalKey = rememberSaveable(editing) { + if (editing) initial.profileKey() else "" + }.ifEmpty { null } + val selected = ProxyType.valueOf(typeName) + val selectedTransport = MtprotoTransportMode.valueOf(transportName) + val sheetMax = (LocalConfiguration.current.screenHeightDp * 0.75f).dp + LaunchedEffect(selected) { + if (selected == ProxyType.MTPROTO && selectedTransport == MtprotoTransportMode.HTTP) { + transportName = MtprotoTransportMode.PADDED_INTERMEDIATE.name + } + } + Column( + Modifier + .fillMaxWidth() + .heightIn(max = sheetMax) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 20.dp), + ) { + val reveal = fadeIn(tween(if (motion) 180 else 0)) + + expandVertically(tween(if (motion) 220 else 0)) + val conceal = fadeOut(tween(if (motion) 120 else 0)) + + shrinkVertically(tween(if (motion) 180 else 0)) + Text( + stringResource(if (editing) R.string.settings_proxy_edit else R.string.settings_proxy_add), + style = MaterialTheme.typography.titleLarge, + ) + if (confirmDelete) { + Column(Modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.settings_proxy_remove_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.settings_proxy_remove_body, "${initial.host}:${initial.port}"), + style = MaterialTheme.typography.bodyMedium, + ) + Button( + onClick = onDelete, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Text(stringResource(R.string.settings_proxy_remove)) + } + TextButton( + onClick = { confirmDelete = false }, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Text(stringResource(R.string.settings_proxy_cancel)) + } + } + return@Column + } + Text( + stringResource(R.string.settings_proxy_type), + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleSmall, + ) + ProxyChoiceGroup( + labels = listOf(ProxyType.SOCKS5, ProxyType.HTTP, ProxyType.HTTPS, ProxyType.MTPROTO) + .map { proxyTypeLabel(it) }, + selected = listOf(ProxyType.SOCKS5, ProxyType.HTTP, ProxyType.HTTPS, ProxyType.MTPROTO) + .indexOf(selected), + onSelect = { index -> + val candidate = listOf(ProxyType.SOCKS5, ProxyType.HTTP, ProxyType.HTTPS, ProxyType.MTPROTO)[index] + if (!portEdited) port = candidate.defaultPort() + typeName = candidate.name + }, + ) + AnimatedVisibility(visible = selected != ProxyType.MTPROTO, enter = reveal, exit = conceal) { + Column { + Text( + stringResource(R.string.settings_proxy_transport), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.titleSmall, + ) + ProxyChoiceGroup( + labels = listOf( + stringResource(R.string.settings_proxy_transport_padded), + stringResource(R.string.settings_proxy_transport_http), + ), + selected = if (selectedTransport == MtprotoTransportMode.HTTP) 1 else 0, + onSelect = { index -> + transportName = if (index == 0) { + MtprotoTransportMode.PADDED_INTERMEDIATE.name + } else { + MtprotoTransportMode.HTTP.name + } + }, + ) + } + } + Row( + Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = host, + onValueChange = { host = it; hostError = null; formError = null }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.settings_proxy_host)) }, + supportingText = { Text(hostError ?: stringResource(R.string.settings_proxy_host_hint)) }, + isError = hostError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + singleLine = true, + ) + OutlinedTextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit).take(5); portEdited = true; portError = null }, + modifier = Modifier.width(112.dp), + label = { Text(stringResource(R.string.settings_proxy_port)) }, + supportingText = portError?.let { message -> { Text(message) } }, + isError = portError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + } + AnimatedContent( + targetState = selected == ProxyType.MTPROTO, + modifier = Modifier.padding(top = 4.dp), + transitionSpec = { + fadeIn(tween(if (motion) 180 else 0)) togetherWith fadeOut(tween(if (motion) 120 else 0)) + }, + label = "proxyAuthentication", + ) { mtproto -> + if (mtproto) { + OutlinedTextField( + value = secret, + onValueChange = { secret = it; secretError = null; formError = null }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.settings_proxy_secret)) }, + supportingText = secretError?.let { message -> { Text(message) } }, + isError = secretError != null, + visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { secretVisible = !secretVisible }) { + Icon( + if (secretVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = stringResource( + if (secretVisible) R.string.settings_proxy_hide_secret else R.string.settings_proxy_show_secret, + ), + ) + } + }, + singleLine = true, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = username, + onValueChange = { username = it; authError = null; formError = null }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.settings_proxy_username)) }, + singleLine = true, + ) + OutlinedTextField( + value = password, + onValueChange = { password = it; authError = null; formError = null }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.settings_proxy_password)) }, + supportingText = authError?.let { message -> { Text(message) } }, + isError = authError != null, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + if (passwordVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = stringResource( + if (passwordVisible) { + R.string.settings_proxy_hide_password + } else { + R.string.settings_proxy_show_password + }, + ), + ) + } + }, + singleLine = true, + ) + } + } + } + formError?.let { message -> + Text(message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + val hostRequired = stringResource(R.string.settings_proxy_host_required) + val portInvalid = stringResource(R.string.settings_proxy_port_invalid) + val credentialsIncomplete = stringResource(R.string.settings_proxy_credentials_incomplete) + val secretInvalid = stringResource(R.string.settings_proxy_secret_invalid) + Button( + onClick = { + hostError = null + portError = null + authError = null + secretError = null + formError = null + val draft = initial.copy( + type = selected, + host = host.trim(), + port = port, + username = username, + password = password, + secret = secret.trim(), + transportMode = selectedTransport, + ) + if (draft.host.isBlank()) { + hostError = hostRequired + return@Button + } + when (val valid = draft.toProxyConfig().validate()) { + is Outcome.Ok -> { + saving = true + onSave(draft, originalKey) { failure -> + saving = false + if (failure == null) onDismiss() else formError = failure + } + } + is Outcome.Err -> when (valid.proxyField()) { + ProxyFieldError.HOST -> hostError = hostRequired + ProxyFieldError.PORT -> portError = portInvalid + ProxyFieldError.CREDENTIALS -> authError = credentialsIncomplete + ProxyFieldError.SECRET -> secretError = secretInvalid + ProxyFieldError.OTHER -> formError = valid.message + } + } + }, + enabled = !saving, + shapes = shapes, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).heightIn(min = 56.dp), + ) { + if (saving) { + MonogramLoading( + size = MonogramLoadingInlineSize, + status = stringResource(R.string.settings_proxy_checking), + ) + } else { + Text(stringResource(R.string.settings_proxy_save)) + } + } + if (editing) { + TextButton( + onClick = { confirmDelete = true }, + enabled = !saving, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + ) { + Text( + stringResource(R.string.settings_proxy_remove), + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private enum class ProxyFieldError { HOST, PORT, CREDENTIALS, SECRET, OTHER } + +private fun Outcome.Err.proxyField(): ProxyFieldError = when { + "port" in message -> ProxyFieldError.PORT + "credential" in message -> ProxyFieldError.CREDENTIALS + "secret" in message || "MTProto" in message -> ProxyFieldError.SECRET + "host" in message -> ProxyFieldError.HOST + else -> ProxyFieldError.OTHER +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ProxyChoiceGroup( + labels: List, + selected: Int, + onSelect: (Int) -> Unit, + enabled: (Int) -> Boolean = { true }, +) { + Row( + Modifier.fillMaxWidth().selectableGroup(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + verticalAlignment = Alignment.CenterVertically, + ) { + labels.forEachIndexed { index, label -> + val itemEnabled = enabled(index) + ToggleButton( + checked = selected == index, + onCheckedChange = { if (itemEnabled) onSelect(index) }, + enabled = itemEnabled, + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp) + .semantics { role = Role.RadioButton }, + shapes = when { + labels.size == 1 -> ToggleButtonDefaults.shapesFor(ButtonDefaults.MediumContainerHeight) + index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + index == labels.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.colors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun proxyTypeLabel(type: ProxyType): String = when (type) { + ProxyType.SOCKS5 -> stringResource(R.string.settings_proxy_type_socks5) + ProxyType.HTTP -> stringResource(R.string.settings_proxy_type_http) + ProxyType.HTTPS -> stringResource(R.string.settings_proxy_type_https) + ProxyType.MTPROTO -> stringResource(R.string.settings_proxy_type_mtproto) + ProxyType.NONE -> stringResource(R.string.settings_proxy_type_none) +} + +@Composable +private fun proxyPingLabel(profile: ProxyScreenState, checking: Boolean): String { + val latency = profile.latencyMs + return when { + checking -> stringResource(R.string.settings_proxy_checking) + latency != null -> stringResource(R.string.settings_proxy_latency, latency) + profile.consecutiveFailures > 0 -> stringResource(R.string.settings_proxy_unavailable) + else -> stringResource(R.string.settings_proxy_not_checked) + } +} + +private fun proxyPosition(index: Int, count: Int): ItemPosition = when { + count <= 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == count - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE +} + +private fun ProxyType.defaultPort(): String = when (this) { + ProxyType.SOCKS5 -> "1080" + ProxyType.HTTP -> "8080" + ProxyType.HTTPS, ProxyType.MTPROTO -> "443" + ProxyType.NONE -> "1080" +} + +internal fun ProxyScreenState.toProxyConfig(): ProxyConfig = ProxyConfig( + type = type, + host = host.trim(), + port = port.toIntOrNull() ?: 0, + username = username.takeIf { type != ProxyType.MTPROTO && it.isNotBlank() }, + password = password.takeIf { type != ProxyType.MTPROTO && it.isNotBlank() }, + secret = if (type == ProxyType.MTPROTO) { + decodeProxySecret(secret.trim()) ?: byteArrayOf(0) + } else { + byteArrayOf() + }, +) + +internal fun stateFromStored(proxy: StoredProxy) = ProxyScreenState( + type = runCatching { ProxyType.valueOf(proxy.kind) }.getOrDefault(ProxyType.SOCKS5), + host = proxy.host, + port = proxy.port.toString(), + username = proxy.username.orEmpty(), + password = proxy.password.orEmpty(), + secret = proxy.secret.joinToString("") { byte -> "%02x".format(byte) }, + transportMode = runCatching { MtprotoTransportMode.valueOf(proxy.transportMode.uppercase()) } + .getOrDefault(MtprotoTransportMode.PADDED_INTERMEDIATE), + id = proxy.id, + networkScope = proxy.networkScope, + enabled = proxy.enabled, + favorite = proxy.favorite, + latencyMs = proxy.lastLatencyMs, + consecutiveFailures = proxy.consecutiveFailures, + lastCheckedAt = proxy.lastCheckedAt, +) + +internal fun storedFromState(state: ProxyScreenState) = StoredProxy( + kind = state.type.name, + host = state.host.trim(), + port = state.port.toIntOrNull() ?: 0, + username = state.username.takeIf { state.type != ProxyType.MTPROTO && it.isNotBlank() }, + password = state.password.takeIf { state.type != ProxyType.MTPROTO && it.isNotBlank() }, + secret = if (state.type == ProxyType.MTPROTO) { + decodeProxySecret(state.secret.trim()) ?: byteArrayOf(0) + } else { + byteArrayOf() + }, + transportMode = state.transportMode.name.lowercase(), + id = state.id, + networkScope = state.networkScope, + enabled = state.enabled, + favorite = state.favorite, + lastLatencyMs = state.latencyMs, + consecutiveFailures = state.consecutiveFailures, + lastCheckedAt = state.lastCheckedAt, +) + +internal fun ProxyScreenState.profileKey(): String = storedFromState(this).profileKey + +@Preview(showBackground = true, widthDp = 360, heightDp = 780) +@Composable +private fun ProxyScreenLightPreview() { + MaterialTheme { ProxyScreenPreviewContent() } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 780, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProxyScreenDarkPreview() { + MaterialTheme { ProxyScreenPreviewContent() } +} + +@Composable +private fun ProxyScreenPreviewContent() { + val active = ProxyScreenState(host = "proxy.example", port = "1080", latencyMs = 42, lastCheckedAt = 1) + val other = ProxyScreenState( + type = ProxyType.MTPROTO, + host = "mt.example", + port = "443", + secret = "11".repeat(16), + consecutiveFailures = 2, + lastCheckedAt = 1, + ) + ProxyScreen( + profiles = listOf(active, other), + activeKey = active.profileKey(), + checking = setOf(other.profileKey()), + headerBusy = false, + status = null, + statusError = false, + onUse = {}, + onCheckAll = {}, + onDisable = {}, + onSave = { _, _, done -> done(null) }, + onDelete = {}, + ) +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxySettings.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxySettings.kt new file mode 100644 index 000000000..f13e7287d --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/ProxySettings.kt @@ -0,0 +1,432 @@ +package org.monogram.feature.settings.ui + +import android.content.Context +import android.os.SystemClock +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.monogram.core.common.Outcome +import org.monogram.core.ui.loading.MonogramLoading +import org.monogram.core.ui.loading.MonogramLoadingHeroSize +import org.monogram.feature.settings.ProxyCheckQueue +import org.monogram.feature.settings.ProxySettingsStore +import org.monogram.feature.settings.R +import org.monogram.feature.settings.SettingsComponent +import org.monogram.feature.settings.StoredProxy +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig +import org.monogram.network.bridge.parseProxyImport +import org.monogram.network.bridge.parseTelegramProxyLink + +private const val DIRECT = "" + +private class ProxyRefs( + val component: SettingsComponent, + val context: Context, +) { + var profiles: List = emptyList() + var activeKey: String? = null + var desired: ProxyScreenState? = null + var connectedKey: String? = null + val pending = HashMap() + var report: (String) -> Unit = {} +} + +private data class ProxyConnect(val error: String?, val latencyMs: Long?) + +@Composable +internal fun ProxySettings( + component: SettingsComponent, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val refs = remember(component, context) { ProxyRefs(component, context) } + var ready by remember { mutableStateOf(false) } + var profiles by remember { mutableStateOf>(emptyList()) } + var activeKey by rememberSaveable { mutableStateOf(null) } + var status by rememberSaveable { mutableStateOf(null) } + var statusError by rememberSaveable { mutableStateOf(false) } + var sessionJobs by remember { mutableIntStateOf(0) } + var busy by remember { mutableStateOf>(emptySet()) } + val failedLabel = stringResource(R.string.settings_proxy_test_failed) + val clipboardInvalid = stringResource(R.string.settings_proxy_clipboard_invalid) + val clipboard = LocalClipboard.current + refs.report = { message -> + status = message + statusError = true + } + val pingSlots = remember { Semaphore(3) } + val queue = remember(refs, scope) { + ProxyCheckQueue( + scope = scope, + check = { null }, + restore = { restoreDesired(refs) }, + ) + } + val probes by queue.checking.collectAsStateWithLifecycle() + + fun sync() { + profiles = refs.profiles + activeKey = refs.activeKey + } + + fun enqueue(profile: ProxyScreenState, onLatency: ((Long?) -> Unit)? = null): Boolean { + val key = profile.profileKey() + if (key in busy) return false + busy = busy + key + scope.launch { + val latency = try { + pingSlots.withPermit { probeProxy(refs.component, profile) } + } finally { + busy = busy - key + } + recordPing(refs, key, latency) + sync() + onLatency?.invoke(latency) + } + return true + } + + LaunchedEffect(refs) { + val snapshot = ProxySettingsStore.loadSnapshot(refs.context) + refs.profiles = snapshot.profiles.map(::stateFromStored) + refs.activeKey = snapshot.activeProfileKey?.takeIf { key -> + refs.profiles.any { it.profileKey() == key } + } + refs.desired = refs.profiles.firstOrNull { it.profileKey() == refs.activeKey } + refs.connectedKey = refs.activeKey ?: DIRECT + sync() + if (ProxySettingsStore.consumeStartupFailure(refs.context)) { + status = failedLabel + statusError = true + } + ready = true + } + + if (!ready) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + MonogramLoading(size = MonogramLoadingHeroSize) + } + } else { + ProxyScreen( + profiles = profiles, + activeKey = activeKey, + checking = probes + busy, + headerBusy = sessionJobs > 0, + status = status, + statusError = statusError, + modifier = modifier, + onUse = onUse@{ profile -> + val key = profile.profileKey() + if (key == refs.activeKey || key in busy) return@onUse + busy = busy + key + sessionJobs += 1 + scope.launch { + val result = try { + queue.exclusive { activate(refs, profile) } + } finally { + busy = busy - key + sessionJobs -= 1 + sync() + } + if (result.error == null) { + status = null + statusError = false + enqueue(profile) + } else { + status = result.error + statusError = true + } + } + }, + onCheckAll = { refs.profiles.forEach { enqueue(it) } }, + onDisable = { + sessionJobs += 1 + scope.launch { + val error = try { + queue.exclusive { + val previousDesired = refs.desired + val previousKey = refs.activeKey + refs.desired = null + refs.activeKey = null + refs.connectedKey = null + val failure = restoreDesired(refs, reportFailure = false) + if (failure != null) { + refs.desired = previousDesired + refs.activeKey = previousKey + } else { + persist(refs) + } + failure + } + } finally { + sessionJobs -= 1 + sync() + } + if (error == null) { + status = null + statusError = false + } else { + status = error + statusError = true + } + } + }, + onSave = save@{ draft, originalKey, done -> + if (originalKey == null) { + storeAdded(refs, listOf(draft)) + sync() + status = null + statusError = false + enqueue(draft) + done(null) + return@save + } + val busyKey = originalKey + busy = busy + busyKey + sessionJobs += 1 + scope.launch { + val result = try { + queue.exclusive { saveProfile(refs, draft, originalKey) } + } finally { + busy = busy - busyKey + sessionJobs -= 1 + sync() + } + if (result.error == null) { + status = null + statusError = false + enqueue(draft) + } else { + status = result.error + statusError = true + } + done(result.error) + } + }, + onDelete = { profile -> + val key = profile.profileKey() + val wasActive = refs.activeKey == key + refs.profiles = refs.profiles.filterNot { it.profileKey() == key } + if (wasActive) { + refs.activeKey = null + refs.desired = null + refs.connectedKey = null + } + persist(refs) + sync() + if (wasActive) { + sessionJobs += 1 + scope.launch { + try { + queue.exclusive { restoreDesired(refs) } + } finally { + sessionJobs -= 1 + } + } + } + }, + onPasteAdd = paste@{ + val text = runCatching { + clipboard.getClipEntry()?.clipData + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.text + ?.toString() + }.getOrNull().orEmpty() + val drafts = proxyLinksFromClipboard(text).map { stateFromConfig(it) } + if (drafts.isEmpty()) { + status = clipboardInvalid + statusError = true + return@paste + } + val fresh = drafts.filter { draft -> + refs.profiles.none { it.profileKey() == draft.profileKey() } + } + if (fresh.isEmpty()) return@paste + storeAdded(refs, fresh) + sync() + status = null + statusError = false + fresh.forEach { enqueue(it) } + }, + ) + } +} + +private suspend fun activate(refs: ProxyRefs, profile: ProxyScreenState): ProxyConnect { + val key = profile.profileKey() + var committed = false + try { + val connected = connectProfile(refs.component, profile) + if (connected.error == null) { + refs.connectedKey = key + refs.profiles = refs.profiles.map { if (it.profileKey() == key) profile else it } + refs.activeKey = key + refs.desired = profile + persist(refs) + committed = true + } else { + refs.profiles = refs.profiles.map { + if (it.profileKey() == key) it.withPing(null, System.currentTimeMillis()) else it + } + persist(refs) + } + return connected + } finally { + if (!committed) { + refs.connectedKey = null + restoreDesired(refs, reportFailure = false) + } + } +} + +private fun storeAdded(refs: ProxyRefs, drafts: List) { + val keys = drafts.map { it.profileKey() }.toSet() + refs.profiles = refs.profiles.filterNot { it.profileKey() in keys } + drafts + persist(refs) +} + +private suspend fun saveProfile( + refs: ProxyRefs, + draft: ProxyScreenState, + originalKey: String?, +): ProxyConnect { + var committed = false + try { + val connected = connectProfile(refs.component, draft) + if (connected.error == null) { + val updated = draft + val key = updated.profileKey() + refs.connectedKey = key + refs.profiles = refs.profiles.filterNot { + it.profileKey() == key || (originalKey != null && it.profileKey() == originalKey) + } + updated + refs.activeKey = key + refs.desired = updated + persist(refs) + committed = true + } + return connected + } finally { + if (!committed) { + refs.connectedKey = null + restoreDesired(refs, reportFailure = false) + } + } +} + +private suspend fun probeProxy(component: SettingsComponent, profile: ProxyScreenState): Long? = + when (val result = component.pingProxy(profile.toProxyConfig())) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + +private suspend fun connectProfile(component: SettingsComponent, state: ProxyScreenState): ProxyConnect { + val started = SystemClock.elapsedRealtime() + when (val mode = component.setTransportMode(state.transportMode)) { + is Outcome.Err -> return ProxyConnect(mode.message, null) + is Outcome.Ok -> Unit + } + when (val configured = component.configureProxy(state.toProxyConfig())) { + is Outcome.Err -> return ProxyConnect(configured.message, null) + is Outcome.Ok -> Unit + } + return when (val connected = component.testProxyConnection()) { + is Outcome.Ok -> ProxyConnect(null, (SystemClock.elapsedRealtime() - started).coerceAtLeast(0)) + is Outcome.Err -> ProxyConnect(connected.message, null) + } +} + +private suspend fun restoreDesired(refs: ProxyRefs, reportFailure: Boolean = true): String? { + val desired = refs.desired + val wanted = desired?.profileKey() + if (wanted != null && wanted == refs.connectedKey) return null + if (desired == null) { + if (refs.connectedKey == DIRECT) return null + return when (val cleared = refs.component.clearProxy()) { + is Outcome.Err -> { + refs.connectedKey = null + if (reportFailure) refs.report(cleared.message) + cleared.message + } + is Outcome.Ok -> { + refs.connectedKey = DIRECT + null + } + } + } + val result = connectProfile(refs.component, desired) + return if (result.error == null) { + refs.connectedKey = wanted + null + } else { + refs.connectedKey = null + if (reportFailure) refs.report(result.error) + result.error + } +} + +private fun recordPing(refs: ProxyRefs, key: String, latency: Long?) { + val current = refs.profiles.firstOrNull { it.profileKey() == key } ?: return + val updated = current.withPing(latency, System.currentTimeMillis()) + refs.profiles = refs.profiles.map { if (it.profileKey() == key) updated else it } + if (refs.activeKey == key) refs.desired = updated + persist(refs) +} + +private fun persist(refs: ProxyRefs) { + ProxySettingsStore.saveAll( + refs.context, + refs.profiles.map(::storedFromState), + refs.activeKey, + ) +} + +private fun stateFromConfig(config: ProxyConfig) = stateFromStored( + StoredProxy( + kind = config.type.name, + host = config.host, + port = config.port, + username = config.username, + password = config.password, + secret = config.secret, + transportMode = MtprotoTransportMode.PADDED_INTERMEDIATE.name.lowercase(), + ), +) + +internal fun proxyLinkFromClipboard(text: String): ProxyConfig? { + val trimmed = text.trim() + val parsed = parseProxyImport(trimmed) ?: return null + val scheme = runCatching { java.net.URI(trimmed).scheme }.getOrNull()?.lowercase() ?: return null + return if (scheme == "http" || scheme == "https") parseTelegramProxyLink(trimmed) else parsed +} + +private val clipboardProxySplit = Regex("[\\s;\uFF1B|,\uFF0C]+") + +internal fun proxyLinksFromClipboard(text: String): List { + val seen = HashSet() + return text.split(clipboardProxySplit).mapNotNull { raw -> + val token = raw.trim().trim { it in "\"'<>()[]" } + val config = token.takeIf { it.isNotEmpty() }?.let(::proxyLinkFromClipboard) ?: return@mapNotNull null + config.takeIf { seen.add(stateFromConfig(it).profileKey()) } + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt index 7dda3bab5..0fd0cf8e4 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt @@ -1,83 +1,37 @@ package org.monogram.feature.settings.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars 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.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.Chat -import androidx.compose.material.icons.automirrored.outlined.Help -import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.outlined.Logout -import androidx.compose.material.icons.automirrored.outlined.VolumeOff -import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.BrightnessAuto -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.DarkMode -import androidx.compose.material.icons.outlined.DeleteSweep -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.Image -import androidx.compose.material.icons.outlined.InsertDriveFile -import androidx.compose.material.icons.outlined.Keyboard -import androidx.compose.material.icons.outlined.LightMode -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Storage -import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -94,16 +48,10 @@ import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue 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.graphicsLayer +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -119,38 +67,22 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.essenty.backhandler.BackCallback import com.arkivanov.essenty.backhandler.BackDispatcher import com.arkivanov.essenty.backhandler.BackHandler +import org.monogram.core.common.DebugLog +import org.monogram.core.common.DebugStats import org.monogram.core.common.Outcome +import org.monogram.core.common.SponsorRegistry import org.monogram.core.models.peerAvatarCacheKey -import org.monogram.core.ui.AccentPreset import org.monogram.core.ui.AppearanceSettings -import org.monogram.core.ui.AppearanceState -import org.monogram.core.ui.ComposerStyle import org.monogram.core.ui.DownloadSettings -import org.monogram.core.ui.ThemePreference import org.monogram.core.ui.components.AppBarSyncTitle import org.monogram.core.ui.components.AppStatusBanner import org.monogram.core.ui.components.AppSyncStatus -import org.monogram.core.ui.components.ChatComposerLayout -import org.monogram.core.ui.components.ItemPosition -import org.monogram.core.ui.components.PeerAvatar -import org.monogram.core.ui.components.SectionHeader -import org.monogram.core.ui.components.SettingsCard -import org.monogram.core.ui.components.SettingsCardRow -import org.monogram.core.ui.components.SettingsChoice -import org.monogram.core.ui.components.SettingsChoiceGroup -import org.monogram.core.common.SponsorRegistry -import org.monogram.core.ui.loading.MonogramLoading -import org.monogram.core.ui.loading.MonogramLoadingInlineSize import org.monogram.core.ui.media.mediaViewerMotionEnabled import org.monogram.core.ui.rememberEnsuredFile -import org.monogram.core.ui.theme.accentSwatch import org.monogram.feature.settings.R -import org.monogram.feature.settings.SettingsFolderHost import org.monogram.feature.settings.SettingsComponent +import org.monogram.feature.settings.SettingsFolderHost import org.monogram.feature.settings.SettingsPage -import org.monogram.network.http.FileCache -import java.io.File -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class, ExperimentalDecomposeApi::class) @Composable @@ -167,6 +99,11 @@ fun SettingsContent( val appearance by AppearanceSettings.state.collectAsStateWithLifecycle() val download by DownloadSettings.state.collectAsStateWithLifecycle() var confirmLogout by rememberSaveable { mutableStateOf(false) } + var confirmClearLogs by rememberSaveable { mutableStateOf(false) } + var logQuery by rememberSaveable { mutableStateOf("") } + var logKindName by rememberSaveable { mutableStateOf("all") } + var logShareFailed by rememberSaveable { mutableStateOf(false) } + val logContext = LocalContext.current var confirmClear by rememberSaveable { mutableStateOf(false) } var debugStatsExpanded by rememberSaveable { mutableStateOf("") } val pages by component.pages.subscribeAsState() @@ -175,12 +112,13 @@ fun SettingsContent( val direction = if (layoutDirection == LayoutDirection.Ltr) 1f else -1f val motion = mediaViewerMotionEnabled() val pageBackHandler = remember(component, gestureDispatcher) { - MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) + MultiBackHandler(listOfNotNull(component.backHandler, gestureDispatcher)) } SideEffect { component.setPageBackHandler(folders?.onBack) } DisposableEffect(Unit) { onDispose { component.setPageBackHandler(null) } } val pageAnimation = remember(pageBackHandler, direction, motion) { - val spec = tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) + val spec = + tween(durationMillis = if (motion) 280 else 0, easing = FastOutSlowInEasing) predictiveBackAnimation( backHandler = pageBackHandler, fallbackAnimation = stackAnimation(fade(animationSpec = spec) + slide(animationSpec = spec)), @@ -225,8 +163,10 @@ fun SettingsContent( val accountSubtitle = profileSubtitle(profile?.username) val title = when (page) { SettingsPage.Home -> stringResource(R.string.settings_title) + SettingsPage.Proxy -> stringResource(R.string.settings_proxy) SettingsPage.Data -> stringResource(R.string.settings_data) SettingsPage.DebugStats -> stringResource(R.string.settings_debug_stats) + SettingsPage.Logs -> stringResource(R.string.settings_logs) is SettingsPage.AutoDownload -> stringResource( when (page.network) { "mobile" -> R.string.settings_autodownload_mobile @@ -234,6 +174,7 @@ fun SettingsContent( else -> R.string.settings_autodownload_wifi }, ) + SettingsPage.Appearance -> stringResource(R.string.settings_chat) SettingsPage.Notifications -> stringResource(R.string.settings_notifications) is SettingsPage.NotificationCategory -> stringResource( @@ -243,6 +184,7 @@ fun SettingsContent( else -> R.string.settings_notifications_private }, ) + SettingsPage.NotificationExceptions -> stringResource(R.string.settings_notifications_exceptions) SettingsPage.NotificationDebug -> stringResource(R.string.settings_notifications_debug) SettingsPage.Wallpaper -> stringResource(R.string.settings_wallpaper) @@ -299,6 +241,18 @@ fun SettingsContent( } }, actions = { + if (page == SettingsPage.Logs) { + LogsTopActions( + onShare = { + val kind = debugLogKind(logKindName) + logShareFailed = !shareDebugLog( + logContext, + DebugLog.exportText(kind, logQuery), + ) + }, + onClear = { confirmClearLogs = true }, + ) + } if (page == SettingsPage.Folders && folders != null) { AnimatedContent( targetState = folders.isEditing, @@ -345,131 +299,172 @@ fun SettingsContent( animation = pageAnimation, ) { child -> pageStates.SaveableStateProvider(settingsPageKey(child.instance)) { - Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + Box(Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface)) { when (val visiblePage = child.instance) { - SettingsPage.Home -> SettingsPageList(innerPadding) { - if (state.error != null) { - item { - AppStatusBanner( - sync = AppSyncStatus.Hidden, - error = state.error, - onRetry = component::onRefresh, - ) + SettingsPage.Home -> SettingsPageList(innerPadding) { + if (state.error != null) { + item { + AppStatusBanner( + sync = AppSyncStatus.Hidden, + error = state.error, + onRetry = component::onRefresh, + ) + } } + homeItems( + component = component, + stateTitle = accountTitle, + stateSubtitle = accountSubtitle, + avatarFile = avatarFile, + appVersion = state.appVersion, + buildStamp = state.buildStamp, + onOpen = component::openPage, + onLogout = { confirmLogout = true }, + loading = state.loading || state.loggingOut, + sponsorIds = sponsorIds, + selfPeerId = state.profile?.id?.value, + updateState = updateState, + updatesEnabled = component.updatesEnabled, + ) } - homeItems( - component = component, - stateTitle = accountTitle, - stateSubtitle = accountSubtitle, - avatarFile = avatarFile, - appVersion = state.appVersion, - buildStamp = state.buildStamp, - onOpen = component::openPage, - onLogout = { confirmLogout = true }, - loading = state.loading || state.loggingOut, - sponsorIds = sponsorIds, - selfPeerId = state.profile?.id?.value, - updateState = updateState, - updatesEnabled = component.updatesEnabled, - ) - } - SettingsPage.Data -> SettingsPageList(innerPadding) { - dataItems( - cacheBytes = state.cacheBytes, - cacheByKind = state.cacheByKind, - cacheChats = state.cacheChats, - cacheMessage = state.cacheMessage, - loading = state.loading || state.loggingOut, - download = download, - onSpeedUpUploads = DownloadSettings::setSpeedUpUploads, - onSpeedUpDownloads = DownloadSettings::setSpeedUpDownloads, - onOpenAutoDownload = { network -> - component.openPage( - SettingsPage.AutoDownload(autoDownloadNetworkKey(network)), - ) - }, - onClear = { confirmClear = true }, - onClearChat = component::onClearChatCache, - onClearKind = component::onClearKindCache, - onOpenDebugStats = if (org.monogram.core.common.DebugStats.enabled) { - { component.openPage(SettingsPage.DebugStats) } - } else { - null - }, - mediaRepository = component.mediaRepository, + + SettingsPage.Proxy -> ProxySettings( + component, + Modifier.padding(innerPadding) ) - } - SettingsPage.DebugStats -> SettingsPageList(innerPadding) { - val expanded = debugStatsExpanded.split(',').filter { it.isNotEmpty() }.toSet() - debugStatsItems( - exportMessage = state.debugExportMessage, - expanded = expanded, - onToggleSection = { key -> - debugStatsExpanded = if (key in expanded) { - expanded.minus(key).joinToString(",") + + SettingsPage.Data -> SettingsPageList(innerPadding) { + dataItems( + cacheBytes = state.cacheBytes, + cacheByKind = state.cacheByKind, + cacheChats = state.cacheChats, + cacheMessage = state.cacheMessage, + loading = state.loading || state.loggingOut, + download = download, + onSpeedUpUploads = DownloadSettings::setSpeedUpUploads, + onSpeedUpDownloads = DownloadSettings::setSpeedUpDownloads, + onOpenAutoDownload = { network -> + component.openPage( + SettingsPage.AutoDownload(autoDownloadNetworkKey(network)), + ) + }, + onClear = { confirmClear = true }, + onClearChat = component::onClearChatCache, + onClearKind = component::onClearKindCache, + onOpenDebugStats = if (DebugStats.enabled) { + { component.openPage(SettingsPage.DebugStats) } } else { - expanded.plus(key).joinToString(",") - } + null + }, + onOpenLogs = if (DebugLog.enabled) { + { component.openPage(SettingsPage.Logs) } + } else { + null + }, + mediaRepository = component.mediaRepository, + ) + } + + SettingsPage.Logs -> SettingsLogs( + query = logQuery, + kind = debugLogKind(logKindName), + shareFailed = logShareFailed, + onQuery = { + logQuery = it + logShareFailed = false }, - onExport = component::onExportDebugStats, - onClear = component::onClearDebugStats, - ) - } - is SettingsPage.AutoDownload -> SettingsPageList(innerPadding) { - autoDownloadItems( - network = parseAutoDownloadNetwork(visiblePage.network), - preset = download.presetFor(parseAutoDownloadNetwork(visiblePage.network)), - ) - } - SettingsPage.Appearance -> SettingsPageList(innerPadding) { - appearanceItems( - appearance = appearance, - onOpenWallpaper = { component.openPage(SettingsPage.Wallpaper) }, - ) - } - SettingsPage.Notifications -> SettingsPageList(innerPadding) { - notificationsItems( - component, - notifications, - debug = component.debugNotifications, - onOpenDebug = { component.openPage(SettingsPage.NotificationDebug) }, - onOpenCategory = { kind -> - component.openPage(SettingsPage.NotificationCategory(kind)) + onKind = { kind -> + logKindName = kind?.name?.lowercase() ?: "all" + logShareFailed = false }, - onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, - ) - } - is SettingsPage.NotificationCategory -> SettingsPageList(innerPadding) { - notificationCategoryItems( - component, - notifications, - visiblePage.kind, - onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, + modifier = Modifier.padding(innerPadding), ) - } - SettingsPage.NotificationExceptions -> { - var editingChatId by remember { mutableStateOf(null) } - SettingsPageList(innerPadding) { - notificationExceptionItems( + + SettingsPage.DebugStats -> SettingsPageList(innerPadding) { + val expanded = + debugStatsExpanded.split(',').filter { it.isNotEmpty() }.toSet() + debugStatsItems( + exportMessage = state.debugExportMessage, + expanded = expanded, + onToggleSection = { key -> + debugStatsExpanded = if (key in expanded) { + expanded.minus(key).joinToString(",") + } else { + expanded.plus(key).joinToString(",") + } + }, + onExport = component::onExportDebugStats, + onClear = component::onClearDebugStats, + ) + } + + is SettingsPage.AutoDownload -> SettingsPageList(innerPadding) { + autoDownloadItems( + network = parseAutoDownloadNetwork(visiblePage.network), + preset = download.presetFor(parseAutoDownloadNetwork(visiblePage.network)), + ) + } + + SettingsPage.Appearance -> SettingsPageList(innerPadding) { + appearanceItems( + appearance = appearance, + onOpenWallpaper = { component.openPage(SettingsPage.Wallpaper) }, + ) + } + + SettingsPage.Notifications -> SettingsPageList(innerPadding) { + notificationsItems( component, notifications, - onEditChat = { editingChatId = it }, + debug = component.debugNotifications, + onOpenDebug = { component.openPage(SettingsPage.NotificationDebug) }, + onOpenCategory = { kind -> + component.openPage(SettingsPage.NotificationCategory(kind)) + }, + onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, ) } - editingChatId?.let { chatId -> - NotificationChatModeDialog( - component = component, - state = notifications, - chatId = chatId, - onDismiss = { editingChatId = null }, + + is SettingsPage.NotificationCategory -> SettingsPageList(innerPadding) { + notificationCategoryItems( + component, + notifications, + visiblePage.kind, + onOpenExceptions = { component.openPage(SettingsPage.NotificationExceptions) }, ) } - } - SettingsPage.NotificationDebug -> SettingsPageList(innerPadding) { - notificationDebugItems(component, notifications) - } - SettingsPage.Wallpaper -> WallpaperSettings(component, Modifier.padding(innerPadding)) - SettingsPage.Folders -> folders?.content(innerPadding) + + SettingsPage.NotificationExceptions -> { + var editingChatId by remember { mutableStateOf(null) } + SettingsPageList(innerPadding) { + notificationExceptionItems( + component, + notifications, + onEditChat = { editingChatId = it }, + ) + } + editingChatId?.let { chatId -> + NotificationChatModeDialog( + component = component, + state = notifications, + chatId = chatId, + onDismiss = { editingChatId = null }, + ) + } + } + + SettingsPage.NotificationDebug -> SettingsPageList(innerPadding) { + notificationDebugItems(component, notifications) + } + + SettingsPage.Wallpaper -> WallpaperSettings( + component, + Modifier.padding(innerPadding) + ) + + SettingsPage.Folders -> folders?.content(innerPadding) } } } @@ -555,6 +550,32 @@ fun SettingsContent( }, ) } + + if (confirmClearLogs) { + AlertDialog( + onDismissRequest = { confirmClearLogs = false }, + title = { Text(stringResource(R.string.settings_logs_clear)) }, + text = { Text(stringResource(R.string.settings_logs_clear_confirm)) }, + confirmButton = { + TextButton( + onClick = { + confirmClearLogs = false + DebugLog.clear() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logs_clear_action)) + } + }, + dismissButton = { + TextButton(onClick = { confirmClearLogs = false }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } } @Composable @@ -563,7 +584,9 @@ private fun SettingsPageList( content: LazyListScope.() -> Unit, ) { LazyColumn( - modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally) + modifier = Modifier + .fillMaxSize() + .wrapContentWidth(Alignment.CenterHorizontally) .widthIn(max = 720.dp), contentPadding = PaddingValues( start = 16.dp, @@ -580,7 +603,9 @@ private fun settingsPageKey(page: SettingsPage): String = when (page) { SettingsPage.Home -> "home" SettingsPage.Folders -> "folders" SettingsPage.Data -> "data" + SettingsPage.Proxy -> "proxy" SettingsPage.DebugStats -> "debug-stats" + SettingsPage.Logs -> "logs" is SettingsPage.AutoDownload -> "autodownload:${page.network}" SettingsPage.Appearance -> "appearance" SettingsPage.Wallpaper -> "wallpaper" @@ -591,7 +616,9 @@ private fun settingsPageKey(page: SettingsPage): String = when (page) { } private class MultiBackHandler(private val handlers: List) : BackHandler { - override fun isRegistered(callback: BackCallback): Boolean = handlers.any { it.isRegistered(callback) } + override fun isRegistered(callback: BackCallback): Boolean = + handlers.any { it.isRegistered(callback) } + override fun register(callback: BackCallback) = handlers.forEach { it.register(callback) } override fun unregister(callback: BackCallback) = handlers.forEach { it.unregister(callback) } } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt index 68e4ce34c..8bbf25b74 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.BugReport @@ -119,11 +120,13 @@ private fun ChatStorageRow( topStart = SettingsGroupCorner, topEnd = SettingsGroupCorner, ) + ItemPosition.MIDDLE -> androidx.compose.foundation.shape.RoundedCornerShape(0.dp) ItemPosition.BOTTOM -> RoundedCornerShape( bottomStart = SettingsGroupCorner, bottomEnd = SettingsGroupCorner, ) + ItemPosition.STANDALONE -> androidx.compose.foundation.shape.RoundedCornerShape(SettingsGroupCorner) }, @@ -164,6 +167,7 @@ internal fun LazyListScope.dataItems( onClearChat: (Long) -> Unit, onClearKind: (String) -> Unit, onOpenDebugStats: (() -> Unit)? = null, + onOpenLogs: (() -> Unit)? = null, mediaRepository: MediaRepository? = null, ) { item { Spacer(Modifier.height(8.dp)) } @@ -212,18 +216,33 @@ internal fun LazyListScope.dataItems( onClick = DownloadSettings::resetAutoDownload, ) } - if (onOpenDebugStats != null) { + if (onOpenLogs != null || onOpenDebugStats != null) { item { Spacer(Modifier.height(8.dp)) } - item { - SettingsTile( - icon = Icons.Outlined.BugReport, - title = stringResource(R.string.settings_debug_stats), - subtitle = stringResource(R.string.settings_debug_stats_sub), - iconColor = MaterialTheme.colorScheme.tertiary, - position = ItemPosition.STANDALONE, - onClick = onOpenDebugStats, - trailingContent = { DataChevron() }, - ) + if (onOpenLogs != null) { + item { + SettingsTile( + icon = Icons.AutoMirrored.Outlined.Article, + title = stringResource(R.string.settings_logs), + subtitle = stringResource(R.string.settings_logs_sub), + iconColor = MaterialTheme.colorScheme.primary, + position = if (onOpenDebugStats != null) ItemPosition.TOP else ItemPosition.STANDALONE, + onClick = onOpenLogs, + trailingContent = { DataChevron() }, + ) + } + } + if (onOpenDebugStats != null) { + item { + SettingsTile( + icon = Icons.Outlined.BugReport, + title = stringResource(R.string.settings_debug_stats), + subtitle = stringResource(R.string.settings_debug_stats_sub), + iconColor = MaterialTheme.colorScheme.tertiary, + position = if (onOpenLogs != null) ItemPosition.BOTTOM else ItemPosition.STANDALONE, + onClick = onOpenDebugStats, + trailingContent = { DataChevron() }, + ) + } } item { val network = download.activeNetwork diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt index 2d3557a98..782623472 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Science import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material.icons.outlined.SystemUpdate import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -105,6 +106,17 @@ internal fun LazyListScope.homeItems( trailingContent = { Chevron() }, ) } + item { + SettingsTile( + icon = Icons.Outlined.VpnKey, + title = stringResource(R.string.settings_proxy), + subtitle = stringResource(R.string.settings_proxy_sub), + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.MIDDLE, + onClick = { onOpen(SettingsPage.Proxy) }, + trailingContent = { Chevron() }, + ) + } item { SettingsTile( icon = Icons.Outlined.Storage, diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsLogs.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsLogs.kt new file mode 100644 index 000000000..70cfd4acd --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsLogs.kt @@ -0,0 +1,280 @@ +package org.monogram.feature.settings.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.monogram.core.common.DebugLog +import org.monogram.core.common.DebugLogKind +import org.monogram.core.ui.ExpressiveDefaults +import org.monogram.core.ui.components.SearchField +import org.monogram.feature.settings.R +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +internal fun shareDebugLog(context: Context, text: String): Boolean = runCatching { + val dir = File(context.cacheDir, "open").apply { mkdirs() } + val file = File(dir, "monogram-logs.txt") + file.writeText(text) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file) + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Monogram logs") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(send, null).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(chooser) +}.isSuccess + +@Composable +internal fun LogsTopActions( + onShare: () -> Unit, + onClear: () -> Unit, +) { + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.settings_logs_export), + ) + } + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Outlined.DeleteSweep, + contentDescription = stringResource(R.string.settings_logs_clear), + tint = MaterialTheme.colorScheme.error, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SettingsLogs( + query: String, + kind: DebugLogKind?, + shareFailed: Boolean, + onQuery: (String) -> Unit, + onKind: (DebugLogKind?) -> Unit, + modifier: Modifier = Modifier, +) { + val revision by DebugLog.changes.collectAsStateWithLifecycle() + val events = remember(revision, kind, query) { DebugLog.query(kind, query) } + val bufferEmpty = remember(revision) { DebugLog.query(null, "").isEmpty() } + val listState = rememberLazyListState() + var stickToTop by remember { mutableStateOf(true) } + var anchorId by remember { mutableLongStateOf(-1L) } + var filterKey by remember { mutableStateOf(kind to query) } + var expandedId by remember { mutableStateOf(null) } + val clock = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> stickToTop = index == 0 && offset == 0 } + } + LaunchedEffect(events, kind, query) { + val head = events.firstOrNull()?.id ?: -1L + val filterChanged = filterKey != (kind to query) + if (filterChanged) { + listState.scrollToItem(0) + } else if (!stickToTop && anchorId >= 0L && head != anchorId) { + val shift = events.indexOfFirst { it.id == anchorId } + if (shift > 0) { + listState.scrollToItem( + listState.firstVisibleItemIndex + shift, + listState.firstVisibleItemScrollOffset, + ) + } + } + anchorId = head + filterKey = kind to query + } + + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Column(Modifier.widthIn(max = 720.dp).fillMaxSize()) { + SearchField( + query = query, + onQueryChanged = onQuery, + placeholder = stringResource(R.string.settings_logs_search), + closeLabel = stringResource(R.string.settings_logs_search_clear), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(logFilters, key = { it.name }) { filter -> + FilterChip( + selected = kind == filter.kind, + onClick = { onKind(filter.kind) }, + label = { Text(stringResource(filter.label)) }, + modifier = Modifier.testTag("logs-filter-${filter.name}"), + shapes = FilterChipDefaults.shapes(), + ) + } + } + if (shareFailed) { + Text( + text = stringResource(R.string.settings_logs_export_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + if (events.isEmpty()) { + Box(Modifier.weight(1f).fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + text = stringResource( + if (bufferEmpty) { + R.string.settings_logs_empty + } else { + R.string.settings_logs_no_match + }, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(state = listState, modifier = Modifier.weight(1f).fillMaxWidth()) { + items(events, key = { it.id }) { event -> + val expanded = expandedId == event.id + val error = event.kind == DebugLogKind.ERROR + Column( + Modifier + .fillMaxWidth() + .clickable( + onClickLabel = stringResource( + if (expanded) R.string.settings_logs_collapse else R.string.settings_logs_expand, + ), + ) { + expandedId = if (expanded) null else event.id + } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = clock.format(Date(event.atEpochMs)), + style = ExpressiveDefaults.tabularLabel(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + if (error) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp).size(16.dp), + ) + } + Text( + text = stringResource(labelFor(event.kind)), + style = MaterialTheme.typography.labelLarge, + color = if (error) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.padding(start = 8.dp), + maxLines = 1, + ) + } + Text( + text = event.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + if (expanded && event.detail.isNotBlank() && event.detail != event.summary) { + Text( + text = event.detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + } +} + +private data class LogFilter(val name: String, val kind: DebugLogKind?, val label: Int) + +private val logFilters = listOf( + LogFilter("all", null, R.string.settings_logs_filter_all), + LogFilter("api", DebugLogKind.API, R.string.settings_logs_filter_api), + LogFilter("recomp", DebugLogKind.RECOMPOSITION, R.string.settings_logs_filter_recomposition), + LogFilter("media", DebugLogKind.MEDIA, R.string.settings_logs_filter_media), + LogFilter("connection", DebugLogKind.CONNECTION, R.string.settings_logs_filter_connection), + LogFilter("error", DebugLogKind.ERROR, R.string.settings_logs_filter_error), +) + +internal fun debugLogKind(name: String): DebugLogKind? = when (name) { + "api" -> DebugLogKind.API + "recomposition" -> DebugLogKind.RECOMPOSITION + "media" -> DebugLogKind.MEDIA + "connection" -> DebugLogKind.CONNECTION + "error" -> DebugLogKind.ERROR + else -> null +} + +private fun labelFor(kind: DebugLogKind): Int = when (kind) { + DebugLogKind.API -> R.string.settings_logs_filter_api + DebugLogKind.RECOMPOSITION -> R.string.settings_logs_filter_recomposition + DebugLogKind.MEDIA -> R.string.settings_logs_filter_media + DebugLogKind.CONNECTION -> R.string.settings_logs_filter_connection + DebugLogKind.ERROR -> R.string.settings_logs_filter_error +} diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 451ac5935..fcf457210 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s Mostrar más (%1$d) Mostrar menos + Registros + Llamadas de API, recomposiciones y otros eventos + Buscar registros + Borrar búsqueda + Todos + API + Recomposiciones + Medios + Conexión + Errores + Aún no hay eventos + No hay eventos coincidentes + Borrar registros + ¿Borrar el registro de la app? No se puede deshacer. + Borrar + Compartir registros + No se pudieron compartir los registros + Mostrar detalles + Ocultar detalles Simular red Usando perfil de Wi-Fi Usando perfil de datos móviles @@ -302,4 +321,65 @@ %1$d patrocinadores Convertirse en patrocinador Apoya a Monogram y obtén una insignia + Proxy + Conectarse mediante un proxy + Tipo de proxy + Host + Puerto + Usuario + Contraseña + Secreto MTProto + Guardar proxy + Desactivar proxy + Proxy guardado + Proxy desactivado + Servidor + Autenticación + Elige un protocolo. El puerto usa su valor habitual + Dirección IP o dominio + El host es obligatorio + Predeterminado para %1$s + Opcional para SOCKS5, HTTP y HTTPS + No se usa para MTProto + Mostrar contraseña + Ocultar contraseña + Transporte de Telegram + Intermedio con relleno + Transporte HTTP + Proxies guardados + Activo + Probar conexión + Eliminar + SOCKS5 + HTTP + HTTPS + MTProto + Sin proxy + Conexión fallida + Cancelar + Ping: %1$d ms + Conexión activa + Comprobando… + Aún no se ha comprobado + No disponible + Conexión directa + Telegram se conecta sin proxy + Proxy en uso + Añadir proxy + Añadir desde el portapapeles + El portapapeles no tiene un enlace de proxy + Editar proxy + Comprobar todos + Todavía no hay proxies guardados. Añade uno o pega un enlace de Telegram. + ¿Quitar este proxy? + %1$s se quitará de este dispositivo. + Mostrar secreto + Ocultar secreto + Introduce el usuario y la contraseña, o deja ambos vacíos + Introduce un puerto del 1 al 65535 + Introduce un secreto MTProto válido + El transporte HTTP no está disponible para proxies MTProto + %1$d ms + Probar %1$s + Editar %1$s diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml index 44ba23d73..b9ac06ce2 100644 --- a/feature/settings/src/main/res/values-hy/strings.xml +++ b/feature/settings/src/main/res/values-hy/strings.xml @@ -147,6 +147,25 @@ %1$d մվ · %2$s Ցուցադրել ավելին (%1$d) Ցուցադրել պակաս + Մատյան + API կանչեր, վերակազմավորումներ և այլ իրադարձություններ + Որոնել մատյանում + Մաքրել որոնումը + Բոլորը + API + Վերակազմավորումներ + Մեդիա + Կապ + Սխալներ + Իրադարձություններ դեռ չկան + Համընկնող իրադարձություններ չկան + Մաքրել մատյանը + Մաքրե՞լ հավելվածի մատյանը։ Սա հետարկել չի լինի։ + Մաքրել + Կիսվել մատյանով + Չհաջողվեց կիսվել մատյանով + Ցույց տալ մանրամասները + Թաքցնել մանրամասները Ցանցի նմանակում Wi-Fi պրոֆիլ Բջջային ցանցի պրոֆիլ @@ -302,4 +321,65 @@ %1$d հովանավոր Դառնալ հովանավոր Աջակցեք Monogram-ին և ստացեք նշիչ + Պրոքսի + Միանալ պրոքսիի միջոցով + Տեսակ + Հոսթ + Պորտ + Օգտվող + Գաղտնաբառ + MTProto գաղտնիք + Պահպանել + Անջատել + Պահպանված է + Անջատված է + Server + Authentication + Ընտրեք պրոտոկոլ։ Պորտն օգտագործում է սովորական արժեքը + IP address or domain + Host is required + Default for %1$s + Ըստ ցանկության՝ SOCKS5, HTTP և HTTPS-ի համար + Չի օգտագործվում MTProto-ի համար + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + Պահված պրոքսիներ + Միացված + Ստուգել կապը + Հեռացնել + SOCKS5 + HTTP + HTTPS + MTProto + Առանց պրոքսի + Կապը ձախողվեց + Չեղարկել + Ping՝ %1$d մվ + Կապը ակտիվ է + Ստուգվում է… + Դեռ չի ստուգվել + Անհասանելի + Ուղիղ կապ + Telegram-ը միանում է առանց պրոքսիի + Պրոքսին օգտագործվում է + Ավելացնել պրոքսի + Ավելացնել սեղմատախտակից + Սեղմատախտակում պրոքսիի հղում չկա + Խմբագրել պրոքսին + Ստուգել բոլորը + Պահված պրոքսիներ դեռ չկան։ Ավելացրեք կամ տեղադրեք Telegram-ի հղումը։ + Հեռացնե՞լ այս պրոքսին + %1$s-ը կհեռացվի այս սարքից։ + Ցույց տալ գաղտնիքը + Թաքցնել գաղտնիքը + Մուտքագրեք և՛ անունը, և՛ գաղտնաբառը, կամ թողեք երկուսն էլ դատարկ + Մուտքագրեք պորտ 1-ից 65535 + Մուտքագրեք վավեր MTProto գաղտնիք + HTTP փոխանցումը հասանելի չէ MTProto պրոքսիների համար + %1$d մվ + Ստուգել %1$s + Խմբագրել %1$s diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index c72a19958..8de1a776e 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s さらに表示 (%1$d) 折りたたむ + ログ + API、再コンポーズ、その他のイベント + ログを検索 + 検索をクリア + すべて + API + 再コンポーズ + メディア + 接続 + エラー + イベントはまだありません + 一致するイベントがありません + ログを消去 + アプリ内ログを消去しますか?元に戻せません。 + 消去 + ログを共有 + ログを共有できませんでした + 詳細を表示 + 詳細を隠す ネットワークのシミュレート Wi-Fiプリセットを使用中 モバイルプリセットを使用中 @@ -302,4 +321,65 @@ スポンサー %1$d人 スポンサーになる Monogram を支援してバッジを取得 + プロキシ + プロキシ経由で接続 + プロキシの種類 + ホスト + ポート + ユーザー名 + パスワード + MTProtoシークレット + 保存 + 無効化 + 保存しました + 無効化しました + Server + Authentication + プロトコルを選択。ポートは通常の値に更新されます + IP address or domain + Host is required + Default for %1$s + SOCKS5、HTTP、HTTPSで任意 + MTProtoでは使用しません + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + 保存済みプロキシ + 有効 + 接続をテスト + 削除 + SOCKS5 + HTTP + HTTPS + MTProto + プロキシなし + 接続に失敗しました + キャンセル + Ping: %1$d ms + 接続中 + 確認中… + 未確認 + 利用できません + 直接接続 + プロキシなしで Telegram に接続します + プロキシを使用中 + プロキシを追加 + クリップボードから追加 + クリップボードにプロキシリンクがありません + プロキシを編集 + すべて確認 + 保存したプロキシはまだありません。追加するか、Telegram のリンクを貼り付けてください。 + このプロキシを削除しますか? + %1$s をこの端末から削除します。 + シークレットを表示 + シークレットを隠す + ユーザー名とパスワードの両方を入力するか、両方を空にしてください + 1 から 65535 のポートを入力してください + 有効な MTProto シークレットを入力してください + MTProto プロキシでは HTTP トランスポートを使えません + %1$d ms + %1$s をテスト + %1$s を編集 diff --git a/feature/settings/src/main/res/values-pt-rBR/strings.xml b/feature/settings/src/main/res/values-pt-rBR/strings.xml index 91b03d2d1..7b266ae06 100644 --- a/feature/settings/src/main/res/values-pt-rBR/strings.xml +++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s Mostrar mais (%1$d) Mostrar menos + Registros + Chamadas de API, recomposições e outros eventos + Pesquisar registros + Limpar pesquisa + Todos + API + Recomposições + Mídia + Conexão + Erros + Ainda não há eventos + Nenhum evento correspondente + Limpar registros + Limpar o registro do app? Isso não pode ser desfeito. + Limpar + Compartilhar registros + Não foi possível compartilhar os registros + Mostrar detalhes + Ocultar detalhes Simular rede Usando predefinição de Wi-Fi Usando predefinição de dados móveis @@ -302,4 +321,65 @@ %1$d patrocinadores Torne-se um patrocinador Apoie o Monogram e ganhe um selo + Proxy + Conectar por proxy + Tipo de proxy + Host + Porta + Usuário + Senha + Segredo MTProto + Salvar + Desativar + Proxy salvo + Proxy desativado + Server + Authentication + Escolha um protocolo. A porta usa o valor padrão + IP address or domain + Host is required + Default for %1$s + Opcional para SOCKS5, HTTP e HTTPS + Não usado para MTProto + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + Proxies salvos + Ativo + Testar conexão + Remover + SOCKS5 + HTTP + HTTPS + MTProto + Sem proxy + Falha na conexão + Cancelar + Ping: %1$d ms + Conexão ativa + Verificando… + Ainda não verificado + Indisponível + Conexão direta + O Telegram se conecta sem proxy + Proxy em uso + Adicionar proxy + Adicionar da área de transferência + A área de transferência não tem um link de proxy + Editar proxy + Verificar todos + Nenhum proxy salvo ainda. Adicione um ou cole um link do Telegram. + Remover este proxy? + %1$s será removido deste dispositivo. + Mostrar segredo + Ocultar segredo + Informe usuário e senha, ou deixe os dois vazios + Informe uma porta de 1 a 65535 + Informe um segredo MTProto válido + O transporte HTTP não está disponível para proxies MTProto + %1$d ms + Testar %1$s + Editar %1$s diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index 31080d7d7..01f2092c3 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -147,6 +147,25 @@ %1$d мс · %2$s Показать еще (%1$d) Показать меньше + Журнал + Вызовы API, рекомпозиции и другие события + Поиск в журнале + Очистить поиск + Все + API + Рекомпозиции + Медиа + Соединение + Ошибки + Пока нет событий + Нет подходящих событий + Очистить журнал + Очистить журнал приложения? Это нельзя отменить. + Очистить + Поделиться журналом + Не удалось поделиться журналом + Показать подробности + Скрыть подробности Эмуляция сети Профиль Wi-Fi Профиль мобильной сети @@ -302,4 +321,65 @@ %1$d спонсоров Стать спонсором Поддержите Monogram и получите значок + Прокси + Подключение через прокси + Тип прокси + Адрес + Порт + Имя пользователя + Пароль + Секрет MTProto + Сохранить + Отключить + Прокси сохранён + Прокси отключён + Сервер + Аутентификация + Выберите протокол. Порт будет стандартным + IP-адрес или домен + Укажите хост + По умолчанию для %1$s + Необязательно для SOCKS5, HTTP и HTTPS + Не используется для MTProto + Показать пароль + Скрыть пароль + Транспорт Telegram + Промежуточный с дополнением + HTTP-транспорт + Сохранённые прокси + Включён + Проверить соединение + Удалить + SOCKS5 + HTTP + HTTPS + MTProto + Без прокси + Не удалось подключиться + Отмена + Пинг: %1$d мс + Соединение активно + Проверка… + Ещё не проверялся + Недоступен + Прямое подключение + Telegram подключается без прокси + Прокси используется + Добавить прокси + Добавить из буфера + В буфере нет ссылки на прокси + Изменить прокси + Проверить все + Нет сохранённых прокси. Добавьте один или вставьте ссылку Telegram. + Удалить этот прокси? + %1$s будет удалён с этого устройства. + Показать секрет + Скрыть секрет + Введите имя и пароль или оставьте оба поля пустыми + Введите порт от 1 до 65535 + Введите действительный секрет MTProto + HTTP-транспорт недоступен для прокси MTProto + %1$d мс + Проверить %1$s + Изменить %1$s diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index 105711f26..f4fa6ad19 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s Zobraziť viac (%1$d) Zobraziť menej + Denníky + Volania API, rekompozície a ďalšie udalosti + Hľadať v denníkoch + Vymazať hľadanie + Všetko + API + Rekompozície + Médiá + Pripojenie + Chyby + Zatiaľ žiadne udalosti + Žiadne zodpovedajúce udalosti + Vymazať denníky + Vymazať denník aplikácie? Toto sa nedá vrátiť. + Vymazať + Zdieľať denníky + Denníky sa nepodarilo zdieľať + Zobraziť podrobnosti + Skryť podrobnosti Simulovať sieť Používa sa profil Wi-Fi Používa sa profil mobilných dát @@ -302,4 +321,65 @@ %1$d sponzorov Stať sa sponzorom Podporte Monogram a získajte odznak + Proxy + Pripojiť cez proxy + Typ proxy + Hostiteľ + Port + Používateľ + Heslo + MTProto secret + Uložiť + Vypnúť + Proxy uložená + Proxy vypnutá + Server + Authentication + Vyberte protokol. Port použije obvyklú hodnotu + IP address or domain + Host is required + Default for %1$s + Voliteľné pre SOCKS5, HTTP a HTTPS + Pre MTProto sa nepoužíva + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + Uložené proxy + Aktívne + Testovať pripojenie + Odstrániť + SOCKS5 + HTTP + HTTPS + MTProto + Bez proxy + Pripojenie zlyhalo + Zrušiť + Ping: %1$d ms + Pripojenie je aktívne + Overuje sa… + Zatiaľ neoverené + Nedostupné + Priame pripojenie + Telegram sa pripája bez proxy + Proxy sa používa + Pridať proxy + Pridať zo schránky + Schránka neobsahuje odkaz na proxy + Upraviť proxy + Overiť všetky + Zatiaľ nie sú uložené žiadne proxy. Pridajte jedno alebo vložte odkaz Telegramu. + Odstrániť toto proxy? + %1$s sa odstráni z tohto zariadenia. + Zobraziť tajomstvo + Skryť tajomstvo + Zadajte meno aj heslo, alebo nechajte obe polia prázdne + Zadajte port od 1 do 65535 + Zadajte platné tajomstvo MTProto + HTTP prenos nie je pre proxy MTProto dostupný + %1$d ms + Otestovať %1$s + Upraviť %1$s diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index f99f74718..464806324 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s Daha fazla göster (%1$d) Daha az göster + Günlükler + API çağrıları, yeniden bileşimler ve diğer olaylar + Günlüklerde ara + Aramayı temizle + Tümü + API + Yeniden bileşimler + Medya + Bağlantı + Hatalar + Henüz olay yok + Eşleşen olay yok + Günlükleri temizle + Uygulama günlüğü temizlensin mi? Bu geri alınamaz. + Temizle + Günlükleri paylaş + Günlük paylaşılamadı + Ayrıntıları göster + Ayrıntıları gizle Ağı simüle et Wi-Fi önayarı kullanılıyor Hücresel önayarı kullanılıyor @@ -302,4 +321,65 @@ %1$d sponsor Sponsor ol Monogram\'ı destekleyin ve rozet kazanın + Proxy + Proxy üzerinden bağlan + Proxy türü + Sunucu + Port + Kullanıcı adı + Şifre + MTProto gizli anahtarı + Kaydet + Devre dışı + Proxy kaydedildi + Proxy devre dışı + Server + Authentication + Bir protokol seçin. Bağlantı noktası varsayılan değeri kullanır + IP address or domain + Host is required + Default for %1$s + SOCKS5, HTTP ve HTTPS için isteğe bağlı + MTProto için kullanılmaz + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + Kayıtlı proxyler + Etkin + Bağlantıyı test et + Kaldır + SOCKS5 + HTTP + HTTPS + MTProto + Proxy yok + Bağlantı başarısız + İptal + Ping: %1$d ms + Bağlantı etkin + Denetleniyor… + Henüz denetlenmedi + Kullanılamıyor + Doğrudan bağlantı + Telegram proxy olmadan bağlanır + Proxy kullanılıyor + Proxy ekle + Panodan ekle + Panoda proxy bağlantısı yok + Proxy’yi düzenle + Tümünü denetle + Kayıtlı proxy yok. Bir tane ekleyin veya bir Telegram bağlantısı yapıştırın. + Bu proxy kaldırılsın mı? + %1$s bu cihazdan kaldırılacak. + Gizli anahtarı göster + Gizli anahtarı gizle + Kullanıcı adı ve şifreyi birlikte girin veya ikisini de boş bırakın + 1 ile 65535 arasında bir bağlantı noktası girin + Geçerli bir MTProto gizli anahtarı girin + HTTP aktarımı MTProto proxy’leri için kullanılamaz + %1$d ms + %1$s bağlantısını dene + %1$s proxy’sini düzenle diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index bb5ff4af4..d5f56933a 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -147,6 +147,25 @@ %1$d мс · %2$s Показати більше (%1$d) Показати менше + Журнал + Виклики API, рекомпозиції та інші події + Пошук у журналі + Очистити пошук + Усі + API + Рекомпозиції + Медіа + З’єднання + Помилки + Поки немає подій + Немає відповідних подій + Очистити журнал + Очистити журнал застосунку? Це не можна скасувати. + Очистити + Поділитися журналом + Не вдалося поділитися журналом + Показати подробиці + Сховати подробиці Симуляція мережі Профіль Wi-Fi Профіль мобільної мережі @@ -302,4 +321,65 @@ %1$d спонсорів Стати спонсором Підтримайте Monogram і отримайте значок + Проксі + Підключення через проксі + Тип проксі + Хост + Порт + Ім’я користувача + Пароль + Секрет MTProto + Зберегти + Вимкнути + Проксі збережено + Проксі вимкнено + Сервер + Автентифікація + Виберіть протокол. Порт матиме стандартне значення + IP-адреса або домен + Укажіть хост + Типове значення для %1$s + Необов’язково для SOCKS5, HTTP і HTTPS + Не використовується для MTProto + Показати пароль + Сховати пароль + Транспорт Telegram + Проміжний із доповненням + HTTP-транспорт + Збережені проксі + Увімкнено + Перевірити з’єднання + Видалити + SOCKS5 + HTTP + HTTPS + MTProto + Без проксі + Не вдалося підключитися + Скасувати + Пінг: %1$d мс + З’єднання активне + Перевірка… + Ще не перевірявся + Недоступний + Пряме з’єднання + Telegram підключається без проксі + Проксі використовується + Додати проксі + Додати з буфера + У буфері немає посилання на проксі + Змінити проксі + Перевірити всі + Немає збережених проксі. Додайте один або вставте посилання Telegram. + Видалити цей проксі? + %1$s буде видалено з цього пристрою. + Показати секрет + Сховати секрет + Введіть ім’я й пароль або залиште обидва поля порожніми + Введіть порт від 1 до 65535 + Введіть дійсний секрет MTProto + HTTP-транспорт недоступний для проксі MTProto + %1$d мс + Перевірити %1$s + Змінити %1$s diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml index 5c7073a17..a578fe6d0 100644 --- a/feature/settings/src/main/res/values-zh/strings.xml +++ b/feature/settings/src/main/res/values-zh/strings.xml @@ -147,6 +147,25 @@ %1$d 毫秒 · %2$s 显示更多 (%1$d) 显示较少 + 日志 + API、重组和其他事件 + 搜索日志 + 清除搜索 + 全部 + API + 重组 + 媒体 + 连接 + 错误 + 还没有事件 + 没有匹配的事件 + 清除日志 + 清除应用内日志?此操作无法撤销。 + 清除 + 分享日志 + 无法分享日志 + 显示详情 + 隐藏详情 模拟网络 使用 Wi-Fi 预设 使用移动网络预设 @@ -302,4 +321,65 @@ %1$d 位赞助者 成为赞助者 支持 Monogram 并获得徽章 + 代理 + 通过代理连接 + 代理类型 + 主机 + 端口 + 用户名 + 密码 + MTProto 密钥 + 保存 + 禁用 + 代理已保存 + 代理已禁用 + Server + Authentication + 选择协议,端口将使用常用默认值 + IP address or domain + Host is required + Default for %1$s + SOCKS5、HTTP 和 HTTPS 可选 + MTProto 不使用 + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + 已保存的代理 + 已启用 + 测试连接 + 删除 + SOCKS5 + HTTP + HTTPS + MTProto + 不使用代理 + 连接失败 + 取消 + Ping:%1$d 毫秒 + 连接已启用 + 正在检查… + 尚未检查 + 不可用 + 直接连接 + Telegram 不通过代理连接 + 正在使用代理 + 添加代理 + 从剪贴板添加 + 剪贴板中没有代理链接 + 编辑代理 + 检查全部 + 还没有已保存的代理。添加一个,或粘贴 Telegram 链接。 + 移除这个代理? + %1$s 将从这台设备上移除。 + 显示密钥 + 隐藏密钥 + 请同时填写用户名和密码,或两者都留空 + 请输入 1 到 65535 之间的端口 + 请输入有效的 MTProto 密钥 + MTProto 代理不能使用 HTTP 传输 + %1$d 毫秒 + 测试 %1$s + 编辑 %1$s diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 435a90552..0fa6bf584 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -147,6 +147,25 @@ %1$d ms · %2$s Show more (%1$d) Show less + Logs + API calls, recompositions, and other events + Search logs + Clear search + All + API + Recompositions + Media + Connection + Errors + No events yet + No matching events + Clear logs + Clear the in-app log? This cannot be undone. + Clear + Share logs + Could not share the log + Show details + Hide details Simulate network Using Wi-Fi preset Using mobile preset @@ -302,4 +321,65 @@ %1$d sponsors Become a sponsor Support Monogram and get a badge + Proxy + Connect through a proxy + Proxy type + Host + Port + Username + Password + MTProto secret + Save proxy + Disable proxy + Proxy saved + Proxy disabled + Server + Authentication + Choose a protocol. The port uses its usual default + IP address or domain + Host is required + Default for %1$s + Optional for SOCKS5, HTTP, and HTTPS + Not used for MTProto + Show password + Hide password + Telegram transport + Padded intermediate + HTTP transport + Saved proxies + Active + Test connection + Remove + SOCKS5 + HTTP + HTTPS + MTProto + No proxy + Connection failed + Cancel + Ping: %1$d ms + Connection is active + Checking… + Not checked yet + Unavailable + Direct connection + Telegram connects without a proxy + Proxy in use + Add proxy + Add from clipboard + Clipboard has no proxy link + Edit proxy + Check all + No saved proxies yet. Add one or paste a Telegram link. + Remove this proxy? + %1$s will be removed from this device. + Show secret + Hide secret + Enter both username and password, or leave both empty + Enter a port from 1 to 65535 + Enter a valid MTProto secret + HTTP transport is not available for MTProto proxies + %1$d ms + Test %1$s + Edit %1$s diff --git a/feature/settings/src/test/java/org/monogram/feature/settings/ProxyCheckQueueTest.kt b/feature/settings/src/test/java/org/monogram/feature/settings/ProxyCheckQueueTest.kt new file mode 100644 index 000000000..c9e658303 --- /dev/null +++ b/feature/settings/src/test/java/org/monogram/feature/settings/ProxyCheckQueueTest.kt @@ -0,0 +1,120 @@ +package org.monogram.feature.settings + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.feature.settings.ui.ProxyScreenState +import org.monogram.feature.settings.ui.profileKey +import org.monogram.feature.settings.ui.withPing + +class ProxyCheckQueueTest { + @Test + fun queuedChecksStayVisibleAndDoNotOverlap() = runTest { + val release = CompletableDeferred() + var inFlight = 0 + var maxInFlight = 0 + var restores = 0 + val queue = ProxyCheckQueue( + scope = this, + check = { + inFlight += 1 + maxInFlight = maxOf(maxInFlight, inFlight) + release.await() + inFlight -= 1 + 12L + }, + restore = { restores += 1 }, + ) + val results = mutableListOf() + assertTrue(queue.request("a") { results += "a:${it}" }) + assertTrue(queue.request("b") { results += "b:${it}" }) + testScheduler.advanceUntilIdle() + assertEquals(setOf("a", "b"), queue.checking.value) + assertEquals(1, maxInFlight) + release.complete(Unit) + testScheduler.advanceUntilIdle() + assertEquals(listOf("a:12", "b:12"), results) + assertEquals(2, restores) + assertEquals(emptySet(), queue.checking.value) + } + + @Test + fun duplicateRequestIsIgnored() = runTest { + val gate = Mutex(locked = true) + var calls = 0 + val queue = ProxyCheckQueue( + scope = this, + check = { + calls += 1 + gate.lock() + 1L + }, + restore = {}, + ) + assertTrue(queue.request("a") {}) + assertFalse(queue.request("a") {}) + testScheduler.advanceUntilIdle() + assertEquals(1, calls) + gate.unlock() + testScheduler.advanceUntilIdle() + } + + @Test + fun failedCheckRestoresAndReportsNull() = runTest { + var restores = 0 + val queue = ProxyCheckQueue( + scope = this, + check = { error("offline") }, + restore = { restores += 1 }, + ) + var result: Long? = 7 + assertTrue(queue.request("a") { result = it }) + testScheduler.advanceUntilIdle() + assertEquals(null, result) + assertEquals(1, restores) + assertEquals(emptySet(), queue.checking.value) + } + + @Test + fun exclusiveWaitsForTheRunningCheck() = runTest { + val gate = Mutex(locked = true) + var exclusiveRan = false + val queue = ProxyCheckQueue( + scope = this, + check = { + gate.lock() + 1L + }, + restore = {}, + ) + queue.request("a") {} + launch { + queue.exclusive { exclusiveRan = true } + } + testScheduler.advanceUntilIdle() + assertFalse(exclusiveRan) + gate.unlock() + testScheduler.advanceUntilIdle() + assertTrue(exclusiveRan) + } + + @Test + fun pingRecordKeepsIdentityAndReplacesLatency() { + val saved = ProxyScreenState(host = "proxy.example", port = "1080", password = "secret") + assertEquals(saved.profileKey(), saved.copy(password = "other").profileKey()) + val healthy = saved.withPing(15, 99) + assertEquals(15L, healthy.latencyMs) + assertEquals(0, healthy.consecutiveFailures) + assertEquals(99L, healthy.lastCheckedAt) + assertEquals(saved.profileKey(), healthy.profileKey()) + val failed = healthy.withPing(null, 100) + assertEquals(null, failed.latencyMs) + assertEquals(1, failed.consecutiveFailures) + assertEquals(100L, failed.lastCheckedAt) + } +} diff --git a/feature/settings/src/test/java/org/monogram/feature/settings/ui/ProxyClipboardTest.kt b/feature/settings/src/test/java/org/monogram/feature/settings/ui/ProxyClipboardTest.kt new file mode 100644 index 000000000..e7cfda8fe --- /dev/null +++ b/feature/settings/src/test/java/org/monogram/feature/settings/ui/ProxyClipboardTest.kt @@ -0,0 +1,42 @@ +package org.monogram.feature.settings.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.network.bridge.ProxyType + +class ProxyClipboardTest { + @Test + fun telegramAndSocksLinksAreImported() { + val telegram = proxyLinkFromClipboard( + "https://t.me/proxy?server=example.com&port=443&secret=0123456789abcdef0123456789abcdef", + ) + assertEquals(ProxyType.MTPROTO, telegram?.type) + assertEquals("example.com", telegram?.host) + + val socks = proxyLinkFromClipboard("socks5://example.com:1080") + assertEquals(ProxyType.SOCKS5, socks?.type) + assertEquals(1080, socks?.port) + } + + @Test + fun ordinaryWebLinksAreIgnored() { + assertNull(proxyLinkFromClipboard("https://example.com")) + assertNull(proxyLinkFromClipboard("http://user:pass@example.com:8080")) + assertNull(proxyLinkFromClipboard("")) + } + + @Test + fun severalLinksAreSplitOnCommonSeparators() { + val mtproto = "https://t.me/proxy?server=one.example&port=443&secret=0123456789abcdef0123456789abcdef" + val socks = "socks5://two.example:1080" + val mtprotoUri = "mtproto://three.example:443?secret=0123456789abcdef0123456789abcdef" + val hosts = proxyLinksFromClipboard( + "$mtproto; $socks\n$mtprotoUri | $mtproto,https://example.com", + ).map { it.host } + + assertEquals(listOf("one.example", "two.example", "three.example"), hosts) + assertTrue(proxyLinksFromClipboard("not a proxy").isEmpty()) + } +} diff --git a/native/mtproto-rs/Cargo.lock b/native/mtproto-rs/Cargo.lock index 3d15b7178..8486dfebf 100644 --- a/native/mtproto-rs/Cargo.lock +++ b/native/mtproto-rs/Cargo.lock @@ -114,6 +114,35 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -214,6 +243,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -273,12 +304,31 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -306,6 +356,22 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpubits" version = "0.1.1" @@ -412,6 +478,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -425,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -460,6 +532,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "generic-array" version = "0.14.7" @@ -576,6 +654,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -670,12 +807,15 @@ dependencies = [ "ctr", "flate2", "indexmap", + "jni", "mimalloc", + "monogram-mtproto-transport", "num-bigint", "num-traits", "parking_lot", "pbkdf2", "rapidhash", + "rustls-platform-verifier", "serde", "serde_json", "sha2 0.11.0", @@ -692,6 +832,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "monogram-mtproto-transport" +version = "0.1.0" +dependencies = [ + "base64", + "parking_lot", + "rustls", + "rustls-platform-verifier", + "tellers-mtproto-crypto", + "tellers-mtproto-transport", + "thiserror", +] + [[package]] name = "nom" version = "7.1.3" @@ -763,6 +916,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -832,6 +991,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plain" version = "0.2.3" @@ -935,6 +1100,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -963,6 +1142,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -973,7 +1161,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -988,6 +1251,24 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1014,6 +1295,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1142,6 +1446,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -1291,7 +1611,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1528,18 +1848,43 @@ dependencies = [ "ctutils", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -1549,12 +1894,30 @@ dependencies = [ "nom", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1564,6 +1927,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" diff --git a/native/mtproto-rs/Cargo.toml b/native/mtproto-rs/Cargo.toml index 400fa59fe..9b0dec217 100644 --- a/native/mtproto-rs/Cargo.toml +++ b/native/mtproto-rs/Cargo.toml @@ -38,6 +38,7 @@ cipher = "0.5" pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } flate2 = "1" tlottie = { path = "../../vendor/tlottie", default-features = false, features = ["cpu", "std"] } +monogram-mtproto-transport = { path = "../mtproto-transport" } tellers-mtproto = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto" } tellers-mtproto-codec = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-codec" } @@ -49,6 +50,10 @@ tellers-mtproto-engine = { path = "../../../telers-mtproto-impl/crates/tellers-m [build-dependencies] uniffi = { version = "0.32", features = ["build"] } +[target.'cfg(target_os = "android")'.dependencies] +jni = { version = "0.22", default-features = false } +rustls-platform-verifier = "0.7" + [[bin]] name = "uniffi-bindgen" path = "uniffi-bindgen.rs" diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs index cf3faca24..75b3c5323 100644 --- a/native/mtproto-rs/src/client/media_download.rs +++ b/native/mtproto-rs/src/client/media_download.rs @@ -1051,8 +1051,13 @@ fn follow_cdn_redirect( .ok_or_else(|| MtprotoError::Message("cdn endpoint".into()))?; let mut snap = Snapshot::new(job.dc_id, &mut OsRandom) .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut conn = tcp::connect_obfuscated(&endpoint.addr) - .map_err(|e| MtprotoError::Message(e.to_string()))?; + let mut conn = tcp::connect_obfuscated_timeout_obf( + &endpoint.addr, + 5, + Some(job.dc_id as i16), + endpoint.secret.as_ref().map(|secret| secret.as_slice()), + ) + .map_err(|e| MtprotoError::Message(e.to_string()))?; let mut framing = PaddedIntermediate::default(); create_auth_key_with_pem(&mut conn, &mut framing, &mut snap, &pem)?; let mut slot = Some(crate::rpc::LiveTransport { diff --git a/native/mtproto-rs/src/client/mod.rs b/native/mtproto-rs/src/client/mod.rs index a372c4a74..8cafddac8 100644 --- a/native/mtproto-rs/src/client/mod.rs +++ b/native/mtproto-rs/src/client/mod.rs @@ -170,6 +170,7 @@ pub(crate) fn get_client(handle: u64) -> Result, MtprotoError> { } pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 { + let connections = Arc::new(tcp::ConnectionControl::default()); let path = PathBuf::from(session_path); let store = FileSessionStore::new(&path); let (loaded, load_failed) = match store.load() { @@ -238,7 +239,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 handle, Arc::new(Client { _session_key: crate::session_crypto::key_for(&path), - connections: Arc::new(tcp::ConnectionControl::default()), + connections, data: Mutex::new(ClientData { api_id, api_hash, diff --git a/native/mtproto-rs/src/dialogs/get_dialogs.rs b/native/mtproto-rs/src/dialogs/get_dialogs.rs index 36ff3cef0..ecc1e1a7c 100644 --- a/native/mtproto-rs/src/dialogs/get_dialogs.rs +++ b/native/mtproto-rs/src/dialogs/get_dialogs.rs @@ -6,8 +6,8 @@ use crate::{HashMap, HashMapExt, HashSet, HashSetExt}; use tellers_mtproto::latest::api::{ Chat as TlChat, ChatPhoto, Dialog, InputPeer, InputPeerEmptyConstructor, Message, MessagesDialogs, MessagesGetDialogsRequest, MessagesGetPinnedDialogsRequest, - MessagesPeerDialogs, Peer, PeerNotifySettings, True, TrueConstructor, User, - UserProfilePhoto, Vector, + MessagesPeerDialogs, Peer, PeerNotifySettings, True, TrueConstructor, User, UserProfilePhoto, + Vector, }; use tellers_mtproto_session::Snapshot; @@ -86,7 +86,15 @@ pub fn get_dialogs( } _ => return Err(MtprotoError::Message("unexpected messages.dialogs".into())), }; - let mut out = map_peer_dialogs(peers, media_index, channel_pts, dialogs, messages, chats, users); + let mut out = map_peer_dialogs( + peers, + media_index, + channel_pts, + dialogs, + messages, + chats, + users, + ); if offset_date == 0 && offset_id == 0 && offset_peer_id == 0 { if let Ok(pinned) = fetch_pinned_dialogs( snapshot, diff --git a/native/mtproto-rs/src/dialogs/peers.rs b/native/mtproto-rs/src/dialogs/peers.rs index f1148a377..217897109 100644 --- a/native/mtproto-rs/src/dialogs/peers.rs +++ b/native/mtproto-rs/src/dialogs/peers.rs @@ -4,8 +4,7 @@ use tellers_mtproto::latest::api::{Chat as TlChat, ChatPhoto, Peer, User, UserPr use super::permissions::{ admin_can_delete, admin_can_manage_topics, admin_can_post, banned_media, banned_photos, - banned_plain, banned_send, - banned_view, resolve_permissions, + banned_plain, banned_send, banned_view, resolve_permissions, }; use crate::media::{self, MediaIndex}; use crate::peers::{ diff --git a/native/mtproto-rs/src/dialogs_rpc_tests.rs b/native/mtproto-rs/src/dialogs_rpc_tests.rs index 7999fd921..1b1924d6b 100644 --- a/native/mtproto-rs/src/dialogs_rpc_tests.rs +++ b/native/mtproto-rs/src/dialogs_rpc_tests.rs @@ -717,8 +717,12 @@ mod membership_tests { boxed_vec(vec![group(11, true, false), group(12, false, false)]), boxed_vec(Vec::::new()), ); - let left = out.iter().find(|c| c.id == crate::peers::chat_id_for_chat(11)); - let joined = out.iter().find(|c| c.id == crate::peers::chat_id_for_chat(12)); + let left = out + .iter() + .find(|c| c.id == crate::peers::chat_id_for_chat(11)); + let joined = out + .iter() + .find(|c| c.id == crate::peers::chat_id_for_chat(12)); assert!(left.expect("left dialog").left); assert!(!joined.expect("joined dialog").left); } diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index 99233ebf9..0cdb31578 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -1,4 +1,18 @@ use crate::client_mgr; + +#[cfg(target_os = "android")] +#[allow(unsafe_code)] +#[jni::jni_mangle("org.monogram.mtproto.MtprotoNativeLoader")] +pub extern "system" fn init_platform_verifier<'caller>( + mut unowned_env: jni::EnvUnowned<'caller>, + _this: jni::objects::JObject<'caller>, + context: jni::objects::JObject<'caller>, +) { + unowned_env + .with_env(|env| rustls_platform_verifier::android::init_with_env(env, context)) + .resolve::(); +} + use crate::extras_rpc; use crate::lottie; use crate::perf; @@ -15,7 +29,8 @@ use crate::{ FolderDto, ForumTopicsPageDto, GlobalMessageSearchDto, InlineBotResultsDto, InstantViewDto, LottieSize, MessageDto, MtprotoError, NotifyExceptionDto, NotifySettingsDto, ProfileDto, ReactionChoiceDto, ResolvedPeerDto, SavedGifDto, StickerCatalogDto, StickerListDto, - StickerPackDto, UpdateEventDto, UpdatesStateDto, UploadItemDto, VpxAlphaFrame, VpxFrame, WallpaperCatalogDto, + StickerPackDto, UpdateEventDto, UpdatesStateDto, UploadItemDto, VpxAlphaFrame, VpxFrame, + WallpaperCatalogDto, }; #[uniffi::export] @@ -163,6 +178,91 @@ pub fn create_encrypted_client( Ok(handle) } +#[uniffi::export] +pub fn set_proxy( + kind: String, + host: String, + port: u16, + username: Option, + password: Option, + secret: Vec, +) -> Result<(), MtprotoError> { + let kind = match kind.to_ascii_lowercase().as_str() { + "socks5" => monogram_mtproto_transport::ProxyKind::Socks5, + "http" => monogram_mtproto_transport::ProxyKind::Http, + "https" => monogram_mtproto_transport::ProxyKind::Https, + "mtproto" => monogram_mtproto_transport::ProxyKind::Mtproto, + _ => return Err(MtprotoError::Message("unsupported proxy type".into())), + }; + let parsed = monogram_mtproto_transport::ProxyConfig::decode_mtproto_secret(&secret) + .map_err(|error| MtprotoError::Message(error.to_string()))?; + let (secret, fake_tls_domain) = match parsed { + Some((key, domain)) => (Some(key), domain), + None => (None, None), + }; + crate::tcp::set_proxy(Some(monogram_mtproto_transport::ProxyConfig { + kind, + host, + port, + username, + password, + secret, + fake_tls_domain, + })) + .map_err(|e| MtprotoError::Message(e.to_string())) +} + +#[uniffi::export] +pub fn ping_proxy( + kind: String, + host: String, + port: u16, + username: Option, + password: Option, + secret: Vec, +) -> Result { + let kind = match kind.to_ascii_lowercase().as_str() { + "socks5" => monogram_mtproto_transport::ProxyKind::Socks5, + "http" => monogram_mtproto_transport::ProxyKind::Http, + "https" => monogram_mtproto_transport::ProxyKind::Https, + "mtproto" => monogram_mtproto_transport::ProxyKind::Mtproto, + _ => return Err(MtprotoError::Message("unsupported proxy type".into())), + }; + let parsed = monogram_mtproto_transport::ProxyConfig::decode_mtproto_secret(&secret) + .map_err(|error| MtprotoError::Message(error.to_string()))?; + let (secret, fake_tls_domain) = match parsed { + Some((key, domain)) => (Some(key), domain), + None => (None, None), + }; + crate::tcp::probe_proxy(monogram_mtproto_transport::ProxyConfig { + kind, + host, + port, + username, + password, + secret, + fake_tls_domain, + }) + .map_err(|error| MtprotoError::Message(error.to_string())) +} + +#[uniffi::export] +pub fn clear_proxy() -> Result<(), MtprotoError> { + crate::tcp::set_proxy(None).map_err(|e| MtprotoError::Message(e.to_string())) +} + +#[uniffi::export] +pub fn set_transport_mode(mode: String) -> Result<(), MtprotoError> { + let mode = match mode.to_ascii_lowercase().as_str() { + "padded_intermediate" | "padded-intermediate" | "mtproto" => { + monogram_mtproto_transport::TransportMode::PaddedIntermediate + } + "http" => monogram_mtproto_transport::TransportMode::Http, + _ => return Err(MtprotoError::Message("unsupported transport mode".into())), + }; + crate::tcp::set_transport_mode(mode); + Ok(()) +} #[uniffi::export] pub fn connect(handle: u64) -> Result<(), MtprotoError> { perf::span("connect").with(|| client_mgr::connect(handle)) @@ -1077,7 +1177,6 @@ pub fn get_read_receipt_config( client_mgr::get_read_receipt_config(handle) } - #[uniffi::export] pub fn decode_vpx_alpha_packet( handle: u64, diff --git a/native/mtproto-rs/src/media/index.rs b/native/mtproto-rs/src/media/index.rs index a8f666010..406cf031a 100644 --- a/native/mtproto-rs/src/media/index.rs +++ b/native/mtproto-rs/src/media/index.rs @@ -79,9 +79,9 @@ pub(crate) fn document_kind(doc: &tellers_mtproto::latest::api::DocumentConstruc if is_voice { return "voice".into(); } - let is_round = attrs.iter().any(|a| { - matches!(a, DocumentAttribute::DocumentAttributeVideo(v) if v.round_message.is_some()) - }); + let is_round = attrs.iter().any( + |a| matches!(a, DocumentAttribute::DocumentAttributeVideo(v) if v.round_message.is_some()), + ); if is_round { return "video_note".into(); } diff --git a/native/mtproto-rs/src/messages/send.rs b/native/mtproto-rs/src/messages/send.rs index 268cb6bce..987cf618a 100644 --- a/native/mtproto-rs/src/messages/send.rs +++ b/native/mtproto-rs/src/messages/send.rs @@ -323,13 +323,15 @@ pub fn send_text( allow_paid_floodskip: None, peer, reply_to, - media: Box::new(InputMedia::InputMediaWebPage(InputMediaWebPageConstructor { - flags: InputMediaWebPageConstructor::OPTIONAL_FLAG, - force_large_media: None, - force_small_media: None, - optional: Some(Box::new(True::True(TrueConstructor {}))), - url: url.to_string(), - })), + media: Box::new(InputMedia::InputMediaWebPage( + InputMediaWebPageConstructor { + flags: InputMediaWebPageConstructor::OPTIONAL_FLAG, + force_large_media: None, + force_small_media: None, + optional: Some(Box::new(True::True(TrueConstructor {}))), + url: url.to_string(), + }, + )), message: text.to_string(), random_id: random_id(), reply_markup: None, diff --git a/native/mtproto-rs/src/rpc/framing.rs b/native/mtproto-rs/src/rpc/framing.rs index 27e41b202..84cdd3ba7 100644 --- a/native/mtproto-rs/src/rpc/framing.rs +++ b/native/mtproto-rs/src/rpc/framing.rs @@ -168,7 +168,7 @@ pub(crate) fn open_transport_skip( Some(&secret), ) } else { - tcp::connect_obfuscated_timeout(addr, connect_secs) + tcp::connect_obfuscated_timeout_obf(addr, connect_secs, Some(snapshot.dc_id as i16), None) } .map_err(|e| MtprotoError::Message(format!("{e} via {addr}")))?; Ok((conn, PaddedIntermediate::default(), addr)) diff --git a/native/mtproto-rs/src/search_rpc.rs b/native/mtproto-rs/src/search_rpc.rs index 015824949..4620133ad 100644 --- a/native/mtproto-rs/src/search_rpc.rs +++ b/native/mtproto-rs/src/search_rpc.rs @@ -42,10 +42,7 @@ pub fn contacts_search_query(query: &str) -> Result { /// `folder_id` is always sent: 0 = main list, 1 = archive. /// https://core.telegram.org/api/folders pub fn search_global_folder(folder_id: i32) -> (u32, Option) { - ( - MessagesSearchGlobalRequest::FOLDER_ID_FLAG, - Some(folder_id), - ) + (MessagesSearchGlobalRequest::FOLDER_ID_FLAG, Some(folder_id)) } pub fn contacts_search( diff --git a/native/mtproto-rs/src/tcp.rs b/native/mtproto-rs/src/tcp.rs index c0a6d0f90..bdf7a032f 100644 --- a/native/mtproto-rs/src/tcp.rs +++ b/native/mtproto-rs/src/tcp.rs @@ -1,92 +1,37 @@ -//! Blocking TCP adapter with client-owned shutdown for Tellers connections. - -use parking_lot::{Condvar, Mutex}; use std::cell::RefCell; -use std::io::{Read, Write}; use std::net::TcpStream; -use std::sync::{Arc, Weak}; +use std::sync::Arc; use std::time::Duration; -use tellers_mtproto_crypto::fill_random; -use tellers_mtproto_transport::{ - Connection, Error as TransportError, ObfuscatedProtocol, ObfuscatedStream, -}; +use monogram_mtproto_transport::tcp as transport_tcp; +use tellers_mtproto_transport::{Connection, Error as TransportError}; -pub struct TcpConnection { - stream: Arc, - read_timeout: Duration, +pub(crate) fn set_proxy( + config: Option, +) -> Result<(), TransportError> { + transport_tcp::set_proxy(config) } -#[derive(Default)] -struct ControlState { - closed: bool, - streams: Vec>, +fn proxy() -> Option { + transport_tcp::config().proxy } -#[derive(Default)] -pub(crate) struct ConnectionControl { - state: Mutex, - // Saves serialize with shutdown, but never monopolize socket-state checks. - persistence: Mutex<()>, - wake: Condvar, +pub(crate) fn set_transport_mode(mode: monogram_mtproto_transport::TransportMode) { + transport_tcp::set_transport_mode(mode); } - -impl ConnectionControl { - pub(crate) fn detach_sockets(&self) { - self.state.lock().streams.clear(); - } - pub(crate) fn while_open(&self, operation: impl FnOnce() -> T) -> Result { - let _commit = self.persistence.lock(); - if self.state.lock().closed { - return Err(closed_error()); - } - Ok(operation()) - } - pub(crate) fn close(&self) { - let mut state = self.state.lock(); - state.closed = true; - for stream in state.streams.drain(..).filter_map(|s| s.upgrade()) { - let _ = stream.shutdown(std::net::Shutdown::Both); - } - self.wake.notify_all(); - drop(state); - // Wait for any already-started commit, after closing sockets promptly. - // Never hold state while joining persistence: saves check state under - // the persistence lock, and socket I/O must observe closure immediately. - let _commit = self.persistence.lock(); - } - - pub(crate) fn register(&self, stream: &Arc) -> Result<(), TransportError> { - let mut state = self.state.lock(); - if state.closed { - let _ = stream.shutdown(std::net::Shutdown::Both); - return Err(closed_error()); - } - state.streams.retain(|s| s.strong_count() > 0); - let weak = Arc::downgrade(stream); - if !state.streams.iter().any(|existing| existing.ptr_eq(&weak)) { - state.streams.push(weak); - } - Ok(()) - } - - fn wait(&self, delay: Duration) -> Result<(), TransportError> { - let until = std::time::Instant::now() + delay; - let mut state = self.state.lock(); - while !state.closed { - let remaining = until.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return Ok(()); - } - self.wake.wait_for(&mut state, remaining); - } - Err(closed_error()) - } +fn transport_mode() -> monogram_mtproto_transport::TransportMode { + transport_tcp::config().mode +} +pub struct TcpConnection { + inner: monogram_mtproto_transport::TcpConnection, + stream: Arc, + read_timeout: Duration, } fn closed_error() -> TransportError { TransportError::Connection("client closed".into()) } +pub(crate) type ConnectionControl = monogram_mtproto_transport::tcp::ConnectionControl; thread_local! { static CONTROL: RefCell>> = const { RefCell::new(None) }; @@ -99,28 +44,19 @@ pub(crate) fn with_connection_control( struct Restore(Option>); impl Drop for Restore { fn drop(&mut self) { - // The lane is still locked here. A later cancellation must not - // close this parked socket after another request has acquired it. crate::request_control::detach_sockets(); - CONTROL.with(|cell| { - cell.replace(self.0.take()); - }); + CONTROL.with(|cell| cell.replace(self.0.take())); } } let _restore = Restore(CONTROL.with(|cell| cell.replace(Some(control.clone())))); - body() + transport_tcp::with_connection_control(control, body) } - pub(crate) fn wait_reconnect(delay: Duration) -> Result<(), TransportError> { let deadline = std::time::Instant::now() + delay; loop { crate::request_control::check()?; let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let slice = remaining.min(Duration::from_millis(50)); - match CONTROL.with(|cell| cell.borrow().clone()) { - Some(control) => control.wait(slice)?, - None => std::thread::sleep(slice), - } + transport_tcp::wait_reconnect(remaining.min(Duration::from_millis(50)))?; if remaining.is_zero() { return Ok(()); } @@ -131,13 +67,43 @@ fn check_open() -> Result<(), TransportError> { wait_reconnect(Duration::ZERO) } -/// Serialize persistence commits with client destruction. No snapshot from a -/// completed old call may replace the session after close has returned. pub(crate) fn while_client_open(operation: impl FnOnce() -> T) -> Result { - match CONTROL.with(|cell| cell.borrow().clone()) { - Some(control) => control.while_open(operation), - None => Ok(operation()), - } + transport_tcp::while_client_open(operation) +} + +fn open_registered_connection( + addr: &str, + connect_secs: u64, +) -> Result<(monogram_mtproto_transport::TcpConnection, Arc), TransportError> { + check_open()?; + let mut socket = None; + let inner = monogram_mtproto_transport::TcpConnection::connect_controlled( + addr, + Duration::from_secs(connect_secs.max(1)), + proxy().as_ref(), + |stream| { + let stream = stream.clone(); + if let Some(control) = CONTROL.with(|cell| cell.borrow().clone()) { + control.register(&stream).map_err(|_| { + monogram_mtproto_transport::ProxyError::Handshake("connection cancelled".into()) + })?; + } + crate::request_control::register(&stream).map_err(|_| { + monogram_mtproto_transport::ProxyError::Handshake("request cancelled".into()) + })?; + socket = Some(stream); + Ok(()) + }, + || { + check_open().map_err(|_| { + monogram_mtproto_transport::ProxyError::Handshake("connection cancelled".into()) + }) + }, + ) + .map_err(|error| TransportError::Connection(error.to_string()))?; + let socket = socket.ok_or_else(closed_error)?; + check_open()?; + Ok((inner, socket)) } impl TcpConnection { @@ -155,28 +121,9 @@ impl TcpConnection { } pub fn connect_timeout_secs(addr: &str, connect_secs: u64) -> Result { - check_open()?; - let socket_addr: std::net::SocketAddr = addr - .parse() - .map_err(|e| TransportError::Connection(format!("bad address {addr}: {e}")))?; - let stream = - TcpStream::connect_timeout(&socket_addr, Duration::from_secs(connect_secs.max(1))) - .map_err(|e| TransportError::Connection(e.to_string()))?; - stream - .set_read_timeout(Some(Duration::from_millis(250))) - .map_err(|e| TransportError::Connection(e.to_string()))?; - stream - .set_write_timeout(Some(Duration::from_secs(8))) - .map_err(|e| TransportError::Connection(e.to_string()))?; - stream - .set_nodelay(true) - .map_err(|e| TransportError::Connection(e.to_string()))?; - let stream = Arc::new(stream); - if let Some(control) = CONTROL.with(|cell| cell.borrow().clone()) { - control.register(&stream)?; - } - crate::request_control::register(&stream)?; + let (inner, stream) = open_registered_connection(addr, connect_secs)?; Ok(Self { + inner, stream, read_timeout: Duration::from_secs(8), }) @@ -187,9 +134,8 @@ impl Connection for TcpConnection { fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { check_open()?; crate::request_control::register(&self.stream)?; - self.stream - .as_ref() - .write_all(packet) + self.inner + .send(packet) .map_err(|e| TransportError::Connection(e.to_string())) } @@ -198,18 +144,13 @@ impl Connection for TcpConnection { let deadline = std::time::Instant::now() + self.read_timeout; loop { check_open()?; - match self.stream.as_ref().read(output) { - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::Interrupted - | std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::TimedOut - ) && std::time::Instant::now() < deadline => - { + match self.inner.receive(output) { + Ok(result) => return Ok(result), + Err(error) if std::time::Instant::now() < deadline => { + let _ = error; continue; } - result => return result.map_err(|e| TransportError::Connection(e.to_string())), + Err(error) => return Err(TransportError::Connection(error.to_string())), } } } @@ -220,13 +161,73 @@ impl Connection for TcpConnection { } } -/// AES-CTR obfuscated padded-intermediate transport (not fake-TLS). +enum NativeTransportConnection { + Obfuscated(monogram_mtproto_transport::ObfuscatedConnection), + Http(monogram_mtproto_transport::TransportConnection), +} + pub struct ObfuscatedTcp { - inner: TcpConnection, - obf: ObfuscatedStream, + inner: NativeTransportConnection, + stream: Arc, + read_timeout: Duration, +} + +impl ObfuscatedTcp { + pub fn set_io_timeout(&mut self, secs: u64) { + self.set_io_timeout_ms(secs.saturating_mul(1000)); + } + + pub fn set_io_timeout_ms(&mut self, ms: u64) { + self.read_timeout = Duration::from_millis(ms.max(50)); + let _ = match &mut self.inner { + NativeTransportConnection::Obfuscated(connection) => connection.set_io_timeout_ms(ms), + NativeTransportConnection::Http(connection) => connection.set_io_timeout_ms(ms), + }; + let _ = self + .stream + .set_read_timeout(Some(self.read_timeout.min(Duration::from_millis(250)))); + } +} + +impl Connection for ObfuscatedTcp { + fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { + check_open()?; + crate::request_control::register(&self.stream)?; + match &mut self.inner { + NativeTransportConnection::Obfuscated(connection) => connection.send(packet), + NativeTransportConnection::Http(connection) => connection.send(packet), + } + .map_err(|error| TransportError::Connection(error.to_string())) + } + + fn receive(&mut self, output: &mut [u8]) -> Result { + crate::request_control::register(&self.stream)?; + let deadline = std::time::Instant::now() + self.read_timeout; + loop { + check_open()?; + match match &mut self.inner { + NativeTransportConnection::Obfuscated(connection) => connection.receive(output), + NativeTransportConnection::Http(connection) => connection.receive(output), + } { + Ok(result) => return Ok(result), + Err(error) if std::time::Instant::now() < deadline => { + let _ = error; + continue; + } + Err(error) => return Err(TransportError::Connection(error.to_string())), + } + } + } + + fn close(&mut self) -> Result<(), TransportError> { + match &mut self.inner { + NativeTransportConnection::Obfuscated(connection) => connection.close(), + NativeTransportConnection::Http(connection) => connection.close(), + } + .map_err(|error| TransportError::Connection(error.to_string())) + } } -/// Connect to a DC and send the 64-byte obfuscation header (no raw `eeeeeeee` preamble). pub fn connect_obfuscated(addr: &str) -> Result { connect_obfuscated_timeout(addr, 5) } @@ -238,39 +239,168 @@ pub fn connect_obfuscated_timeout( connect_obfuscated_timeout_obf(addr, connect_secs, None, None) } -/// Direct DC with optional `dcOption.secret` (same AES-CTR wrap as MTProxy). +fn normalize_dc_secret(secret: Option<&[u8]>) -> Result, TransportError> { + match secret { + Some(bytes) if bytes.len() == 16 => { + let mut normalized = [0_u8; 16]; + normalized.copy_from_slice(bytes); + Ok(Some(normalized)) + } + Some(_) => Err(TransportError::Connection( + "invalid MTProto secret length".into(), + )), + None => Ok(None), + } +} + pub fn connect_obfuscated_timeout_obf( addr: &str, connect_secs: u64, dc_id: Option, secret: Option<&[u8]>, ) -> Result { - let inner = TcpConnection::connect_timeout_secs(addr, connect_secs)?; - let mut last_err: Option = None; - for _ in 0..8 { - let mut nonce = [0_u8; 64]; - fill_random(&mut nonce).map_err(|e| TransportError::Connection(e.to_string()))?; - match ObfuscatedStream::new_client( - nonce, - ObfuscatedProtocol::PaddedIntermediate, - dc_id, - secret, - ) { - Ok(obf) => { - let mut conn = ObfuscatedTcp { inner, obf }; - let header = *conn.obf.header(); - conn.inner.send(&header)?; - return Ok(conn); + let secret = if let Some(config) = proxy() { + if config.kind == monogram_mtproto_transport::ProxyKind::Mtproto { + Some(config.secret.ok_or_else(|| { + TransportError::Connection("invalid proxy secret".into()) + })?) + } else { + normalize_dc_secret(secret)? + } + } else { + normalize_dc_secret(secret)? + }; + let (connection, stream) = open_registered_connection(addr, connect_secs)?; + let mode = transport_mode(); + let inner = match mode { + monogram_mtproto_transport::TransportMode::PaddedIntermediate => { + NativeTransportConnection::Obfuscated( + connection + .into_obfuscated(secret, dc_id) + .map_err(|error| TransportError::Connection(error.to_string()))?, + ) + } + monogram_mtproto_transport::TransportMode::Http => { + if proxy() + .is_some_and(|config| config.kind == monogram_mtproto_transport::ProxyKind::Mtproto) + { + return Err(TransportError::Connection( + "Telegram HTTP transport cannot run through an MTProto proxy".into(), + )); + } + NativeTransportConnection::Http( + connection + .into_transport( + addr, + monogram_mtproto_transport::TransportMode::Http, + dc_id, + secret, + ) + .map_err(|error| TransportError::Connection(error.to_string()))?, + ) + } + }; + let mut connection = ObfuscatedTcp { + inner, + stream, + read_timeout: Duration::from_secs(connect_secs.max(1)), + }; + connection.set_io_timeout(connect_secs.max(1)); + Ok(connection) +} + +fn probe_packet() -> Result, TransportError> { + let mut raw = [0_u8; 16]; + tellers_mtproto_crypto::fill_random(&mut raw) + .map_err(|error| TransportError::Connection(error.to_string()))?; + let request = tellers_mtproto::transport::ReqPqMultiRequest { + nonce: bnum::types::I128::from_le_bytes(raw), + }; + let mut encoder = tellers_mtproto::codec::Encoder::new(); + tellers_mtproto::codec::Boxed::encode_boxed(&request, &mut encoder) + .map_err(|error| TransportError::Connection(error.to_string()))?; + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(0); + let plain = tellers_mtproto_engine::encode_plain_message( + &tellers_mtproto_engine::PlainMessage { + message_id: secs << 32, + body: encoder.into_bytes(), + }, + ) + .map_err(|error| TransportError::Connection(error.to_string()))?; + let mut framing = tellers_mtproto_transport::PaddedIntermediate::default(); + tellers_mtproto_transport::Framing::encode(&mut framing, &plain) + .map_err(|error| TransportError::Connection(error.to_string())) +} + +/// Time a throwaway connection through `proxy` to a public DC. +/// Does not read or change the process-wide proxy used by the signed-in session. +/// The clock matches Telegram Desktop: it starts after the proxy/fake-TLS handshake +/// and stops when the datacenter answers `req_pq_multi`. +pub fn probe_proxy(proxy: monogram_mtproto_transport::ProxyConfig) -> Result { + let addr = crate::rpc::dc_endpoints(2) + .first() + .copied() + .ok_or_else(|| TransportError::Connection("no DC endpoints".into()))?; + let timeout = Duration::from_secs(10); + let connection = monogram_mtproto_transport::TcpConnection::connect(addr, timeout, Some(&proxy)) + .map_err(|error| TransportError::Connection(format!("connect: {error}")))?; + let started = std::time::Instant::now(); + let secret = if proxy.kind == monogram_mtproto_transport::ProxyKind::Mtproto { + proxy.secret + } else { + None + }; + let mut obfuscated = connection + .into_obfuscated(secret, Some(2)) + .map_err(|error| TransportError::Connection(format!("obfuscate: {error}")))?; + let _ = obfuscated.set_timeout(timeout); + let packet = probe_packet()?; + obfuscated + .send(&packet) + .map_err(|error| TransportError::Connection(format!("send: {error}")))?; + let mut reply = [0_u8; 64]; + let mut filled = 0; + let deadline = std::time::Instant::now() + timeout; + while filled == 0 { + if std::time::Instant::now() >= deadline { + return Err(TransportError::Connection(format!( + "handshake timed out after {}ms with {filled} bytes", + started.elapsed().as_millis() + ))); + } + match obfuscated.receive(&mut reply[filled..]) { + Ok(0) => { + return Err(TransportError::Connection( + "proxy closed during handshake".into(), + )); + } + Ok(count) => filled += count, + Err(error) => { + let message = error.to_string(); + let retry = message.contains("Try again") + || message.contains("timed out") + || message.contains("WouldBlock") + || message.contains("os error 11"); + if retry && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + continue; + } + return Err(TransportError::Connection(format!( + "read after {}ms: {message}", + started.elapsed().as_millis() + ))); } - Err(err) => last_err = Some(err), } } - Err(last_err.unwrap_or(TransportError::InvalidObfuscationNonce)) + let _ = obfuscated.close(); + Ok(started.elapsed().as_millis().max(1) as i64) } -/// Try DC endpoints in order (5222/80 before 443). pub fn connect_obfuscated_dc(addrs: &[&str]) -> Result { - let mut last_err: Option = None; + let mut last_err = None; for addr in addrs { match connect_obfuscated(addr) { Ok(conn) => return Ok(conn), @@ -279,35 +409,6 @@ pub fn connect_obfuscated_dc(addrs: &[&str]) -> Result Result<(), TransportError> { - let mut buf = packet.to_vec(); - self.obf.encrypt(&mut buf); - self.inner.send(&buf) - } - - fn receive(&mut self, output: &mut [u8]) -> Result { - let n = self.inner.receive(output)?; - self.obf.decrypt(&mut output[..n]); - Ok(n) - } - - fn close(&mut self) -> Result<(), TransportError> { - self.inner.close() - } -} - #[cfg(test)] mod tests { use super::*; @@ -388,6 +489,30 @@ mod tests { worker.join().unwrap(); } + #[test] + fn close_wakes_obfuscated_receive() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let control = Arc::new(ConnectionControl::default()); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let addr = listener.local_addr().unwrap().to_string(); + let worker_control = control.clone(); + let worker = std::thread::spawn(move || { + with_connection_control(&worker_control, || { + let mut connection = connect_obfuscated_timeout(&addr, 30).unwrap(); + ready_tx.send(()).unwrap(); + let result = connection.receive(&mut [0; 4]); + assert!(matches!(result, Ok(0) | Err(_))); + done_tx.send(()).unwrap(); + }); + }); + let (_peer, _) = listener.accept().unwrap(); + ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + control.close(); + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + worker.join().unwrap(); + } + #[test] fn close_cancels_backoff_without_affecting_other_clients() { let control = Arc::new(ConnectionControl::default()); diff --git a/native/mtproto-rs/src/vpx.rs b/native/mtproto-rs/src/vpx.rs index 95326cc21..30988b3af 100644 --- a/native/mtproto-rs/src/vpx.rs +++ b/native/mtproto-rs/src/vpx.rs @@ -176,33 +176,80 @@ pub fn decode_vpx_packet( } } - -pub fn decode_vpx_alpha_packet(handle: u64, data: Vec) -> Result, MtprotoError> { +pub fn decode_vpx_alpha_packet( + handle: u64, + data: Vec, +) -> Result, MtprotoError> { #[cfg(not(has_libvpx))] - { let _ = (handle, data); return Err(MtprotoError::Message("libvpx not linked".into())); } + { + let _ = (handle, data); + return Err(MtprotoError::Message("libvpx not linked".into())); + } #[cfg(has_libvpx)] unsafe { let mut instances = INSTANCES.lock(); let decoder = instances.get_mut(&handle).ok_or_else(missing)?; const MAX_PACKET_SIZE: usize = 4 * 1024 * 1024; - if data.is_empty() || data.len() > MAX_PACKET_SIZE { return Err(MtprotoError::Message("invalid vpx packet size".into())); } - let err = vpx_codec_decode(&mut decoder.ctx, data.as_ptr(), c_uint::try_from(data.len()).map_err(|_| MtprotoError::Message("vpx packet is too large".into()))?, ptr::null_mut(), 0); - if err != 0 { return Err(MtprotoError::Message(format!("vpx decode failed: {err}"))); } + if data.is_empty() || data.len() > MAX_PACKET_SIZE { + return Err(MtprotoError::Message("invalid vpx packet size".into())); + } + let err = vpx_codec_decode( + &mut decoder.ctx, + data.as_ptr(), + c_uint::try_from(data.len()) + .map_err(|_| MtprotoError::Message("vpx packet is too large".into()))?, + ptr::null_mut(), + 0, + ); + if err != 0 { + return Err(MtprotoError::Message(format!("vpx decode failed: {err}"))); + } let mut iter: *const c_void = ptr::null(); let image = vpx_codec_get_frame(&mut decoder.ctx, &mut iter); - if image.is_null() { return Ok(None); } + if image.is_null() { + return Ok(None); + } let image = &*image; - let width = image.d_w; let height = image.d_h; + let width = image.d_w; + let height = image.d_h; const MAX_DIMENSION: u32 = 2048; - if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || image.w < width || image.h < height { return Err(MtprotoError::Message("invalid vpx alpha dimensions".into())); } - let w = width as usize; let h = height as usize; - let stride = usize::try_from(image.stride[0]).map_err(|_| MtprotoError::Message("invalid vpx alpha stride".into()))?; - if image.planes[0].is_null() || stride < w { return Err(MtprotoError::Message("invalid vpx alpha plane".into())); } - let mut alpha = vec![0u8; w.checked_mul(h).ok_or_else(|| MtprotoError::Message("vpx alpha frame is too large".into()))?]; - for row in 0..h { std::ptr::copy_nonoverlapping(image.planes[0].add(row * stride), alpha.as_mut_ptr().add(row * w), w); } - Ok(Some(crate::VpxAlphaFrame { width, height, alpha })) + if width == 0 + || height == 0 + || width > MAX_DIMENSION + || height > MAX_DIMENSION + || image.w < width + || image.h < height + { + return Err(MtprotoError::Message("invalid vpx alpha dimensions".into())); + } + let w = width as usize; + let h = height as usize; + let stride = usize::try_from(image.stride[0]) + .map_err(|_| MtprotoError::Message("invalid vpx alpha stride".into()))?; + if image.planes[0].is_null() || stride < w { + return Err(MtprotoError::Message("invalid vpx alpha plane".into())); + } + let mut alpha = vec![ + 0u8; + w.checked_mul(h).ok_or_else(|| MtprotoError::Message( + "vpx alpha frame is too large".into() + ))? + ]; + for row in 0..h { + std::ptr::copy_nonoverlapping( + image.planes[0].add(row * stride), + alpha.as_mut_ptr().add(row * w), + w, + ); + } + Ok(Some(crate::VpxAlphaFrame { + width, + height, + alpha, + })) } -}fn image_to_rgba(image: &VpxImage) -> Result { +} +fn image_to_rgba(image: &VpxImage) -> Result { const MAX_DIMENSION: u32 = 2048; let width = image.d_w; let height = image.d_h; diff --git a/native/mtproto-rs/src/waveform.rs b/native/mtproto-rs/src/waveform.rs index a5d6288d3..229aa54ba 100644 --- a/native/mtproto-rs/src/waveform.rs +++ b/native/mtproto-rs/src/waveform.rs @@ -1,7 +1,7 @@ use crate::{HashMap, HashMapExt, MtprotoError}; use parking_lot::Mutex; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::LazyLock; +use std::sync::atomic::{AtomicU64, Ordering}; const BINS: usize = 64; const MAX_SAMPLES: u64 = 128 * 1024 * 1024; @@ -21,27 +21,46 @@ pub fn create(duration_us: u64) -> Result { return Err(MtprotoError::Message("invalid waveform duration".into())); } let id = NEXT.fetch_add(1, Ordering::Relaxed); - ACCUMULATORS.lock().insert(id, Accumulator { duration_us, peaks: [0.0; BINS], samples: 0 }); + ACCUMULATORS.lock().insert( + id, + Accumulator { + duration_us, + peaks: [0.0; BINS], + samples: 0, + }, + ); Ok(id) } -pub fn add_pcm(handle: u64, samples: Vec, sample_rate: u32, channels: u32, presentation_time_us: u64) -> Result<(), MtprotoError> { +pub fn add_pcm( + handle: u64, + samples: Vec, + sample_rate: u32, + channels: u32, + presentation_time_us: u64, +) -> Result<(), MtprotoError> { if sample_rate == 0 || sample_rate > 384_000 || channels == 0 || channels > 8 { return Err(MtprotoError::Message("invalid waveform PCM format".into())); } if samples.len() as u64 > MAX_SAMPLES { - return Err(MtprotoError::Message("waveform PCM chunk is too large".into())); + return Err(MtprotoError::Message( + "waveform PCM chunk is too large".into(), + )); } let mut accumulators = ACCUMULATORS.lock(); - let accumulator = accumulators.get_mut(&handle).ok_or_else(|| MtprotoError::Message("unknown waveform handle".into()))?; + let accumulator = accumulators + .get_mut(&handle) + .ok_or_else(|| MtprotoError::Message("unknown waveform handle".into()))?; let frames = samples.len() / channels as usize; for frame in 0..frames { let mut peak = 0.0f32; for channel in 0..channels as usize { peak = peak.max((samples[frame * channels as usize + channel] as f32 / 32768.0).abs()); } - let time_us = presentation_time_us.saturating_add(frame as u64 * 1_000_000 / sample_rate as u64); - let bin = ((time_us as u128 * BINS as u128) / accumulator.duration_us as u128).min((BINS - 1) as u128) as usize; + let time_us = + presentation_time_us.saturating_add(frame as u64 * 1_000_000 / sample_rate as u64); + let bin = ((time_us as u128 * BINS as u128) / accumulator.duration_us as u128) + .min((BINS - 1) as u128) as usize; accumulator.peaks[bin] = accumulator.peaks[bin].max(peak.min(1.0)); } accumulator.samples = accumulator.samples.saturating_add(samples.len() as u64); @@ -52,11 +71,24 @@ pub fn add_pcm(handle: u64, samples: Vec, sample_rate: u32, channels: u32, } pub fn finish(handle: u64) -> Result, MtprotoError> { - let accumulator = ACCUMULATORS.lock().remove(&handle).ok_or_else(|| MtprotoError::Message("unknown waveform handle".into()))?; - if accumulator.samples == 0 { return Ok(Vec::new()); } + let accumulator = ACCUMULATORS + .lock() + .remove(&handle) + .ok_or_else(|| MtprotoError::Message("unknown waveform handle".into()))?; + if accumulator.samples == 0 { + return Ok(Vec::new()); + } let max = accumulator.peaks.iter().copied().fold(0.0f32, f32::max); - if max <= 0.0 { return Ok(accumulator.peaks.to_vec()); } - Ok(accumulator.peaks.iter().map(|value| (value / max).clamp(0.0, 1.0)).collect()) + if max <= 0.0 { + return Ok(accumulator.peaks.to_vec()); + } + Ok(accumulator + .peaks + .iter() + .map(|value| (value / max).clamp(0.0, 1.0)) + .collect()) } -pub fn destroy(handle: u64) { ACCUMULATORS.lock().remove(&handle); } +pub fn destroy(handle: u64) { + ACCUMULATORS.lock().remove(&handle); +} diff --git a/native/mtproto-transport/Cargo.lock b/native/mtproto-transport/Cargo.lock new file mode 100644 index 000000000..d89493769 --- /dev/null +++ b/native/mtproto-transport/Cargo.lock @@ -0,0 +1,1153 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "monogram-mtproto-transport" +version = "0.1.0" +dependencies = [ + "base64", + "parking_lot", + "rustls", + "rustls-platform-verifier", + "tellers-mtproto-crypto", + "tellers-mtproto-transport", + "thiserror", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha1 0.10.7", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tellers-mtproto-crypto" +version = "0.1.0" +dependencies = [ + "aes", + "cipher", + "ctr", + "getrandom 0.4.3", + "num-bigint", + "num-integer", + "num-traits", + "rsa", + "sha1 0.11.0", + "sha2 0.11.0", + "subtle", + "thiserror", +] + +[[package]] +name = "tellers-mtproto-transport" +version = "0.1.0" +dependencies = [ + "crc32fast", + "getrandom 0.4.3", + "tellers-mtproto-crypto", + "thiserror", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/native/mtproto-transport/Cargo.toml b/native/mtproto-transport/Cargo.toml new file mode 100644 index 000000000..7b351541d --- /dev/null +++ b/native/mtproto-transport/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "monogram-mtproto-transport" +version = "0.1.0" +edition = "2024" +rust-version = "1.98" +license = "MPL-2.0" +publish = false + +[dependencies] +base64 = "0.22" +thiserror = "2" +tellers-mtproto-crypto = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-crypto" } +tellers-mtproto-transport = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-transport" } +parking_lot = "0.12" +rustls = "0.23" +rustls-platform-verifier = "0.7" + +[dev-dependencies] \ No newline at end of file diff --git a/native/mtproto-transport/src/config.rs b/native/mtproto-transport/src/config.rs new file mode 100644 index 000000000..754d1cef0 --- /dev/null +++ b/native/mtproto-transport/src/config.rs @@ -0,0 +1,206 @@ +use super::*; + +pub(crate) const MAX_PROXY_HEADERS: usize = 16 * 1024; +pub(crate) const MAX_PROXY_CREDENTIAL: usize = 255; +pub(crate) const MAX_HTTP_BODY: usize = 16 * 1024 * 1024; +pub(crate) const MAX_HTTP_QUEUED_REQUESTS: usize = 64; +pub(crate) const MAX_HTTP_QUEUED_BYTES: usize = MAX_HTTP_BODY * 2; +pub(crate) const DNS_CACHE_TTL: Duration = Duration::from_secs(60); +pub(crate) const DNS_MIN_TTL: Duration = Duration::from_secs(10); +pub(crate) const DNS_MAX_TTL: Duration = Duration::from_secs(300); +pub(crate) const MAX_FAKE_TLS_DOMAIN_LENGTH: usize = 182; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProxyKind { + Socks5, + Http, + Https, + Mtproto, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportMode { + PaddedIntermediate, + Http, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportConfig { + pub proxy: Option, + pub mode: TransportMode, +} + +impl Default for TransportConfig { + fn default() -> Self { + Self { + proxy: None, + mode: TransportMode::PaddedIntermediate, + } + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ProxyConfig { + pub kind: ProxyKind, + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub secret: Option, +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ProxySecret { + pub key: [u8; 16], + pub random_padding: bool, + pub fake_tls_domain: Option, +} + +impl std::fmt::Debug for ProxyConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProxyConfig") + .field("kind", &self.kind) + .field("credentials", &"[REDACTED]") + .finish_non_exhaustive() + } +} + +impl std::fmt::Debug for ProxySecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ProxySecret([REDACTED])") + } +} + +impl ProxySecret { + pub fn parse(input: &str) -> Result { + if input.len() > (17 + MAX_FAKE_TLS_DOMAIN_LENGTH) * 2 || input.trim() != input { + return Err(ProxyError::InvalidConfig("invalid proxy secret length")); + } + let bytes = if input.len() % 2 == 0 && input.bytes().all(|b| b.is_ascii_hexdigit()) { + (0..input.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&input[i..i + 2], 16) + .map_err(|_| ProxyError::InvalidConfig("invalid proxy secret")) + }) + .collect::, _>>()? + } else { + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(input) + .map_err(|_| ProxyError::InvalidConfig("invalid proxy secret"))?; + if base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&decoded) != input { + return Err(ProxyError::InvalidConfig("invalid proxy secret")); + } + decoded + }; + Self::from_binary(&bytes) + } + + pub fn from_binary(bytes: &[u8]) -> Result { + if bytes.len() > 17 + MAX_FAKE_TLS_DOMAIN_LENGTH { + return Err(ProxyError::InvalidConfig("invalid proxy secret length")); + } + let (prefix, start) = match (bytes.len(), bytes.first().copied()) { + (17.., Some(0xdd) | Some(0xee)) => (Some(bytes[0]), 1), + _ => (None, 0), + }; + let valid_length = match prefix { + None => bytes.len() == 16, + Some(0xdd) => bytes.len() == 17, + Some(0xee) => bytes.len() > 17, + _ => false, + }; + if !valid_length { + return Err(ProxyError::InvalidConfig("invalid proxy secret length")); + } + let mut key = [0_u8; 16]; + key.copy_from_slice(&bytes[start..start + 16]); + let fake_tls_domain = if prefix == Some(0xee) { + let domain = std::str::from_utf8(&bytes[start + 16..]) + .map_err(|_| ProxyError::InvalidConfig("Fake-TLS domain is invalid"))?; + if !valid_fake_tls_domain(domain) { + return Err(ProxyError::InvalidConfig("Fake-TLS domain is invalid")); + } + Some(domain.to_owned()) + } else { + None + }; + Ok(Self { + key, + random_padding: prefix.is_some(), + fake_tls_domain, + }) + } +} + +impl ProxyConfig { + pub fn validate(&self) -> Result<(), ProxyError> { + if self.host.is_empty() || self.host.len() > 255 || self.host.bytes().any(|b| b <= 0x20) { + return Err(ProxyError::InvalidConfig("invalid proxy host")); + } + if self.port == 0 { + return Err(ProxyError::InvalidConfig("invalid proxy port")); + } + match self.kind { + ProxyKind::Mtproto + if self.secret.is_none() || self.username.is_some() || self.password.is_some() => + { + Err(ProxyError::InvalidConfig( + "invalid MTProto proxy credentials", + )) + } + ProxyKind::Socks5 | ProxyKind::Http | ProxyKind::Https + if self.username.is_some() != self.password.is_some() || self.secret.is_some() => + { + Err(ProxyError::InvalidConfig("invalid proxy credentials")) + } + ProxyKind::Socks5 | ProxyKind::Http | ProxyKind::Https + if self + .username + .as_deref() + .is_some_and(|value| value.len() > MAX_PROXY_CREDENTIAL) + || self + .password + .as_deref() + .is_some_and(|value| value.len() > MAX_PROXY_CREDENTIAL) => + { + Err(ProxyError::InvalidConfig("proxy credentials are too long")) + } + ProxyKind::Socks5 | ProxyKind::Http | ProxyKind::Https + if self + .username + .as_deref() + .is_some_and(contains_control_character) + || self + .password + .as_deref() + .is_some_and(contains_control_character) => + { + Err(ProxyError::InvalidConfig( + "proxy credentials contain control characters", + )) + } + _ => Ok(()), + } + } +} + +fn contains_control_character(value: &str) -> bool { + value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) +} + +fn valid_fake_tls_domain(domain: &str) -> bool { + if domain.is_empty() || domain.len() > MAX_FAKE_TLS_DOMAIN_LENGTH || !domain.is_ascii() { + return false; + } + domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} diff --git a/native/mtproto-transport/src/connection.rs b/native/mtproto-transport/src/connection.rs new file mode 100644 index 000000000..9af98d0dd --- /dev/null +++ b/native/mtproto-transport/src/connection.rs @@ -0,0 +1,7 @@ +use crate::ProxyError; + +pub trait Connection { + fn send(&mut self, packet: &[u8]) -> Result<(), ProxyError>; + fn receive(&mut self, output: &mut [u8]) -> Result; + fn close(&mut self) -> Result<(), ProxyError>; +} diff --git a/native/mtproto-transport/src/doh.rs b/native/mtproto-transport/src/doh.rs new file mode 100644 index 000000000..7fbb043dd --- /dev/null +++ b/native/mtproto-transport/src/doh.rs @@ -0,0 +1 @@ +pub use crate::tcp::{DohResolution, DohResolver}; diff --git a/native/mtproto-transport/src/error.rs b/native/mtproto-transport/src/error.rs new file mode 100644 index 000000000..81bd281ca --- /dev/null +++ b/native/mtproto-transport/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ProxyError { + #[error("invalid proxy configuration: {0}")] + InvalidConfig(&'static str), + #[error("proxy handshake failed: {0}")] + Handshake(String), + #[error("proxy I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("TLS failed: {0}")] + Tls(String), +} diff --git a/native/mtproto-transport/src/lib.rs b/native/mtproto-transport/src/lib.rs new file mode 100644 index 000000000..a7e4bef4a --- /dev/null +++ b/native/mtproto-transport/src/lib.rs @@ -0,0 +1,636 @@ +#![forbid(unsafe_code)] +pub(crate) mod proxy; +pub mod tcp; + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use crate::proxy::tls::TlsStream; +use tellers_mtproto_transport::{ + Connection, Error as TransportError, ObfuscatedProtocol, ObfuscatedStream, +}; +use thiserror::Error; + +const MAX_PROXY_REPLY: usize = 16 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProxyKind { + Socks5, + Http, + Https, + Mtproto, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportMode { + PaddedIntermediate, + Http, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransportConfig { + pub proxy: Option, + pub mode: TransportMode, +} + +impl Default for TransportConfig { + fn default() -> Self { + Self { + proxy: None, + mode: TransportMode::PaddedIntermediate, + } + } +} +#[derive(Clone, Eq, PartialEq)] +pub struct ProxyConfig { + pub kind: ProxyKind, + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub secret: Option<[u8; 16]>, + pub fake_tls_domain: Option, +} + +impl std::fmt::Debug for ProxyConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProxyConfig") + .field("kind", &self.kind) + .field("host", &self.host) + .field("port", &self.port) + .field("credentials", &"[REDACTED]") + .finish_non_exhaustive() + } +} + +impl ProxyConfig { + /// `None` means no secret. Fake-TLS secrets (`ee` + key + domain) keep the + /// 16-byte key and the SNI domain; `dd` secrets keep only the key. + pub fn decode_mtproto_secret( + bytes: &[u8], + ) -> Result)>, ProxyError> { + if bytes.is_empty() { + return Ok(None); + } + if bytes.len() > 17 + 182 { + return Err(ProxyError::InvalidConfig("invalid proxy secret")); + } + let (prefix, start) = match (bytes.len(), bytes.first().copied()) { + (17.., Some(0xdd) | Some(0xee)) => (Some(bytes[0]), 1), + _ => (None, 0), + }; + let valid = match prefix { + None => bytes.len() == 16, + Some(0xdd) => bytes.len() == 17, + Some(0xee) => bytes.len() > 17, + _ => false, + }; + if !valid { + return Err(ProxyError::InvalidConfig("invalid proxy secret")); + } + let mut key = [0_u8; 16]; + key.copy_from_slice(&bytes[start..start + 16]); + let domain = if prefix == Some(0xee) { + let domain = std::str::from_utf8(&bytes[start + 16..]) + .map_err(|_| ProxyError::InvalidConfig("invalid proxy secret"))?; + if !valid_fake_tls_domain(domain) { + return Err(ProxyError::InvalidConfig("invalid proxy secret")); + } + Some(domain.to_owned()) + } else { + None + }; + Ok(Some((key, domain))) + } +} + +fn valid_fake_tls_domain(domain: &str) -> bool { + !domain.is_empty() + && domain.len() <= 182 + && domain.is_ascii() + && domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +impl ProxyConfig { + pub fn validate(&self) -> Result<(), ProxyError> { + if self.host.is_empty() || self.host.len() > 255 || self.host.bytes().any(|b| b <= 0x20) { + return Err(ProxyError::InvalidConfig("invalid proxy host")); + } + if self.port == 0 { + return Err(ProxyError::InvalidConfig("invalid proxy port")); + } + match self.kind { + ProxyKind::Mtproto => { + if self.secret.is_none() || self.username.is_some() || self.password.is_some() { + return Err(ProxyError::InvalidConfig( + "invalid MTProto proxy credentials", + )); + } + } + ProxyKind::Socks5 | ProxyKind::Http | ProxyKind::Https => { + if self.username.is_some() != self.password.is_some() || self.secret.is_some() { + return Err(ProxyError::InvalidConfig("invalid proxy credentials")); + } + } + } + Ok(()) + } + + pub fn mtproto_secret_hex(&self) -> Option { + self.secret + .map(|secret| secret.iter().map(|byte| format!("{byte:02x}")).collect()) + } +} + +#[derive(Debug, Error)] +pub enum ProxyError { + #[error("invalid proxy configuration: {0}")] + InvalidConfig(&'static str), + #[error("proxy handshake failed: {0}")] + Handshake(String), + #[error("proxy I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("TLS failed: {0}")] + Tls(String), + #[error("transport failed: {0}")] + Transport(String), +} + +impl From for TransportError { + fn from(error: ProxyError) -> Self { + TransportError::Connection(error.to_string()) + } +} + +pub struct TcpConnection { + stream: ProxyStream, + socket: std::sync::Arc, + timeout: Duration, +} +enum ProxyStream { + Plain(TcpStream), + Tls(TlsStream), + FakeTls(crate::proxy::faketls::FakeTlsStream), +} + +impl Read for ProxyStream { + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.read(output), + Self::Tls(stream) => stream.read(output), + Self::FakeTls(stream) => stream.read(output), + } + } +} +impl Write for ProxyStream { + fn write(&mut self, input: &[u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.write(input), + Self::Tls(stream) => stream.write(input), + Self::FakeTls(stream) => stream.write(input), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Plain(stream) => stream.flush(), + Self::Tls(stream) => stream.flush(), + Self::FakeTls(stream) => stream.flush(), + } + } +} + +impl TcpConnection { + pub fn connect_controlled( + target: &str, + timeout: Duration, + proxy: Option<&ProxyConfig>, + mut register: impl FnMut(&std::sync::Arc) -> Result<(), ProxyError>, + mut check: impl FnMut() -> Result<(), ProxyError>, + ) -> Result { + check()?; + let connection = Self::connect(target, timeout, proxy)?; + let stream = connection.socket.clone(); + register(&stream)?; + check()?; + Ok(connection) + } + + pub fn into_http(self, target: &str) -> Result { + match self.into_transport(target, TransportMode::Http, None, None)? { + TransportConnection::Http(connection) => Ok(connection), + TransportConnection::Obfuscated(_) => { + Err(ProxyError::Transport("unexpected transport mode".into())) + } + } + } + + pub fn into_transport( + self, + _target: &str, + _mode: TransportMode, + dc_id: Option, + secret: Option<[u8; 16]>, + ) -> Result { + match _mode { + TransportMode::PaddedIntermediate => Ok(TransportConnection::Obfuscated( + self.into_obfuscated(secret, dc_id)?, + )), + TransportMode::Http => Ok(TransportConnection::Http(HttpTransport { + inner: self, + host: _target + .rsplit_once(':') + .map(|(host, _)| host.to_owned()) + .unwrap_or_else(|| _target.to_owned()), + response: Vec::new(), + })), + } + } +} + +pub type ObfuscatedConnection = ObfuscatedTcp; +pub type HttpConnection = ObfuscatedTcp; +pub type HttpTransportConnection = HttpTransport; + +pub struct HttpTransport { + inner: TcpConnection, + host: String, + response: Vec, +} + +pub enum TransportConnection { + Obfuscated(ObfuscatedConnection), + Http(HttpTransportConnection), +} +impl HttpTransport { + pub fn set_io_timeout_ms(&mut self, timeout: u64) -> Result<(), ProxyError> { + self.inner + .set_timeout(Duration::from_millis(timeout.max(50))) + } +} + +impl Connection for HttpTransport { + fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { + let request = tellers_mtproto_transport::http_request(&self.host, packet); + self.inner.send(&request) + } + + fn receive(&mut self, output: &mut [u8]) -> Result { + const MAX_PACKET: usize = 1024 * 1024; + let mut chunk = [0_u8; 16 * 1024]; + loop { + match tellers_mtproto_transport::http_response(&self.response, MAX_PACKET) { + Ok(packet) => { + if packet.payload.len() > output.len() { + return Err(TransportError::Connection( + "HTTP response exceeds output buffer".into(), + )); + } + let count = packet.payload.len(); + output[..count].copy_from_slice(&packet.payload); + self.response.drain(..packet.consumed); + return Ok(count); + } + Err(tellers_mtproto_transport::Error::Incomplete { .. }) => { + let count = self.inner.receive(&mut chunk)?; + if count == 0 { + return Ok(0); + } + if self.response.len().saturating_add(count) > MAX_PACKET + chunk.len() { + return Err(TransportError::Connection( + "HTTP response is too large".into(), + )); + } + self.response.extend_from_slice(&chunk[..count]); + } + Err(error) => return Err(TransportError::Connection(error.to_string())), + } + } + } + + fn close(&mut self) -> Result<(), TransportError> { + self.inner.close() + } +} + +impl Connection for TransportConnection { + fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { + match self { + Self::Obfuscated(connection) => connection.send(packet), + Self::Http(connection) => connection.send(packet), + } + } + + fn receive(&mut self, output: &mut [u8]) -> Result { + match self { + Self::Obfuscated(connection) => connection.receive(output), + Self::Http(connection) => connection.receive(output), + } + } + + fn close(&mut self) -> Result<(), TransportError> { + match self { + Self::Obfuscated(connection) => connection.close(), + Self::Http(connection) => connection.close(), + } + } +} + +impl TransportConnection { + pub fn set_io_timeout_ms(&mut self, timeout: u64) -> Result<(), ProxyError> { + match self { + Self::Obfuscated(connection) => connection.set_io_timeout_ms(timeout), + Self::Http(connection) => connection.set_io_timeout_ms(timeout), + } + } +} +impl TcpConnection { + pub fn connect( + target: &str, + timeout: Duration, + proxy: Option<&ProxyConfig>, + ) -> Result { + if let Some(proxy) = proxy { + proxy.validate()?; + } + let address = proxy + .map(|proxy| format!("{}:{}", proxy.host, proxy.port)) + .unwrap_or_else(|| target.to_owned()); + let socket = address + .to_socket_addrs()? + .next() + .ok_or_else(|| ProxyError::Handshake("proxy address did not resolve".into()))?; + let mut stream = TcpStream::connect_timeout(&socket, timeout)?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + stream.set_nodelay(true)?; + let socket = std::sync::Arc::new(stream.try_clone()?); + let stream = match proxy { + Some(proxy) => match proxy.kind { + ProxyKind::Socks5 => { + let mut stream = stream; + socks5_connect(&mut stream, target, proxy)?; + ProxyStream::Plain(stream) + } + ProxyKind::Http => { + let mut stream = stream; + http_connect(&mut stream, target, proxy)?; + ProxyStream::Plain(stream) + } + ProxyKind::Https => { + let mut stream = crate::proxy::tls::connect(stream, proxy, timeout)?; + crate::proxy::tls::http_connect(&mut stream, target, proxy)?; + ProxyStream::Tls(stream) + } + ProxyKind::Mtproto => { + if let Some(domain) = proxy.fake_tls_domain.clone() { + let key = proxy + .secret + .ok_or_else(|| ProxyError::InvalidConfig("invalid proxy secret"))?; + crate::proxy::faketls::handshake(&mut stream, &domain, &key)?; + ProxyStream::FakeTls(crate::proxy::faketls::FakeTlsStream::new(stream)) + } else { + ProxyStream::Plain(stream) + } + } + }, + None => ProxyStream::Plain(stream), + }; + Ok(Self { + stream, + socket, + timeout, + }) + } + pub fn set_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { + self.socket.set_read_timeout(Some(timeout))?; + self.socket.set_write_timeout(Some(timeout))?; + self.timeout = timeout; + Ok(()) + } + + pub fn into_obfuscated( + self, + secret: Option<[u8; 16]>, + dc_id: Option, + ) -> Result { + let mut nonce = [0_u8; 64]; + tellers_mtproto_crypto::fill_random(&mut nonce) + .map_err(|error| ProxyError::Transport(error.to_string()))?; + let obf = ObfuscatedStream::new_client( + nonce, + ObfuscatedProtocol::PaddedIntermediate, + dc_id, + secret.as_ref().map(|secret| secret.as_slice()), + ) + .map_err(|error| ProxyError::Transport(error.to_string()))?; + let mut result = ObfuscatedTcp { inner: self, obf }; + let header = *result.obf.header(); + result.inner.stream.write_all(&header)?; + Ok(result) + } +} + +impl Connection for TcpConnection { + fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { + self.stream + .write_all(packet) + .map_err(|error| TransportError::Connection(error.to_string())) + } + fn receive(&mut self, output: &mut [u8]) -> Result { + self.stream + .read(output) + .map_err(|error| TransportError::Connection(error.to_string())) + } + fn close(&mut self) -> Result<(), TransportError> { + self.socket + .shutdown(std::net::Shutdown::Both) + .map_err(|error| TransportError::Connection(error.to_string())) + } +} + +pub struct ObfuscatedTcp { + inner: TcpConnection, + obf: ObfuscatedStream, +} + +impl ObfuscatedTcp { + pub fn set_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { + self.inner.set_timeout(timeout) + } + pub fn set_io_timeout_ms(&mut self, timeout: u64) -> Result<(), ProxyError> { + self.set_timeout(Duration::from_millis(timeout.max(50))) + } +} + +impl Connection for ObfuscatedTcp { + fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { + let mut encrypted = packet.to_vec(); + self.obf.encrypt(&mut encrypted); + self.inner.send(&encrypted) + } + fn receive(&mut self, output: &mut [u8]) -> Result { + let count = self.inner.receive(output)?; + self.obf.decrypt(&mut output[..count]); + Ok(count) + } + fn close(&mut self) -> Result<(), TransportError> { + self.inner.close() + } +} + +fn socks5_connect( + stream: &mut TcpStream, + target: &str, + proxy: &ProxyConfig, +) -> Result<(), ProxyError> { + let authenticated = proxy.username.is_some(); + stream.write_all(if authenticated { + &[5, 2, 0, 2] + } else { + &[5, 1, 0] + })?; + let mut response = [0_u8; 2]; + stream.read_exact(&mut response)?; + if response[0] != 5 || response[1] == 0xff { + return Err(ProxyError::Handshake( + "SOCKS5 authentication rejected".into(), + )); + } + if response[1] == 2 { + let username = proxy.username.as_deref().unwrap_or_default().as_bytes(); + let password = proxy.password.as_deref().unwrap_or_default().as_bytes(); + if username.len() > 255 || password.len() > 255 { + return Err(ProxyError::InvalidConfig("SOCKS5 credentials are too long")); + } + stream.write_all(&[1, username.len() as u8])?; + stream.write_all(username)?; + stream.write_all(&[password.len() as u8])?; + stream.write_all(password)?; + let mut auth = [0_u8; 2]; + stream.read_exact(&mut auth)?; + if auth != [1, 0] { + return Err(ProxyError::Handshake("SOCKS5 credentials rejected".into())); + } + } + let target = target + .rsplit_once(':') + .ok_or(ProxyError::InvalidConfig("target must contain a port"))?; + let host = target.0.as_bytes(); + if host.len() > 255 { + return Err(ProxyError::InvalidConfig("target host is too long")); + } + let port: u16 = target + .1 + .parse() + .map_err(|_| ProxyError::InvalidConfig("target port is invalid"))?; + stream.write_all(&[5, 1, 0, 3, host.len() as u8])?; + stream.write_all(host)?; + stream.write_all(&port.to_be_bytes())?; + let mut head = [0_u8; 4]; + stream.read_exact(&mut head)?; + if head[1] != 0 { + return Err(ProxyError::Handshake("SOCKS5 CONNECT rejected".into())); + } + let length = match head[3] { + 1 => 4, + 4 => 16, + 3 => { + let mut size = [0_u8; 1]; + stream.read_exact(&mut size)?; + size[0] as usize + } + _ => return Err(ProxyError::Handshake("SOCKS5 address type invalid".into())), + }; + let mut discard = vec![0_u8; length + 2]; + stream.read_exact(&mut discard)?; + Ok(()) +} + +fn http_connect( + stream: &mut TcpStream, + target: &str, + proxy: &ProxyConfig, +) -> Result<(), ProxyError> { + let mut request = + format!("CONNECT {target} HTTP/1.1\\r\\nHost: {target}\\r\\nConnection: keep-alive\\r\\n"); + if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) { + use base64::Engine; + request.push_str("Proxy-Authorization: Basic "); + request.push_str( + &base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")), + ); + request.push_str("\\r\\n"); + } + request.push_str("\\r\\n"); + stream.write_all(request.as_bytes())?; + let mut response = Vec::new(); + let mut byte = [0_u8; 1]; + while response.len() < MAX_PROXY_REPLY { + stream.read_exact(&mut byte)?; + response.push(byte[0]); + if response.ends_with(b"\\r\\n\\r\\n") { + break; + } + } + let line = response + .split(|byte| *byte == b'\n') + .next() + .ok_or_else(|| ProxyError::Handshake("HTTP proxy response missing status".into()))?; + let text = std::str::from_utf8(line) + .map_err(|_| ProxyError::Handshake("HTTP proxy response is not UTF-8".into()))?; + let status = text + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| ProxyError::Handshake("HTTP proxy status is invalid".into()))?; + if !(200..300).contains(&status) { + return Err(ProxyError::Handshake(format!( + "HTTP proxy returned status {status}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_proxy_variants_without_exposing_secrets() { + let config = ProxyConfig { + kind: ProxyKind::Mtproto, + host: "proxy.example".into(), + port: 443, + username: None, + password: None, + secret: Some([7; 16]), + fake_tls_domain: None, + }; + assert!(config.validate().is_ok()); + assert!(!format!("{config:?}").contains("070707")); + } + + #[test] + fn rejects_partial_credentials() { + let config = ProxyConfig { + kind: ProxyKind::Http, + host: "proxy.example".into(), + port: 8080, + username: Some("user".into()), + password: None, + secret: None, + fake_tls_domain: None, + }; + assert!(config.validate().is_err()); + } +} diff --git a/native/mtproto-transport/src/proxy/faketls.rs b/native/mtproto-transport/src/proxy/faketls.rs new file mode 100644 index 000000000..c95d8cb88 --- /dev/null +++ b/native/mtproto-transport/src/proxy/faketls.rs @@ -0,0 +1,307 @@ +use crate::ProxyError; +use std::io::{Read, Write}; +use std::net::TcpStream; +use tellers_mtproto_crypto::sha256; + +const RECORD_HANDSHAKE: u8 = 0x16; +const RECORD_CHANGE_CIPHER_SPEC: u8 = 0x14; +const RECORD_APPLICATION_DATA: u8 = 0x17; +const MAX_RECORD: usize = 16 * 1024 + 256; +const MAX_RESPONSE: usize = 64 * 1024; + +pub(crate) struct FakeTlsStream { + inner: TcpStream, + pending: Vec, + pending_at: usize, + sent_change_cipher_spec: bool, +} + +impl FakeTlsStream { + pub(crate) fn new(inner: TcpStream) -> Self { + Self { + inner, + pending: Vec::new(), + pending_at: 0, + sent_change_cipher_spec: false, + } + } +} + +pub(crate) fn handshake( + stream: &mut TcpStream, + domain: &str, + secret: &[u8; 16], +) -> Result<(), ProxyError> { + let mut hello = client_hello(domain)?; + let mut random = [0_u8; 32]; + random.copy_from_slice(&hello[11..43]); + sign_client_hello(&mut hello, secret); + random.copy_from_slice(&hello[11..43]); + stream + .write_all(&hello) + .map_err(|error| ProxyError::Handshake(error.to_string()))?; + let response = read_server_response(stream)?; + if response.len() < 43 || response[0] != RECORD_HANDSHAKE { + return Err(ProxyError::Handshake("proxy authentication failed".into())); + } + let mut server_random = [0_u8; 32]; + server_random.copy_from_slice(&response[11..43]); + let mut signed = response; + signed[11..43].fill(0); + let mut input = Vec::with_capacity(32 + signed.len()); + input.extend_from_slice(&random); + input.extend_from_slice(&signed); + if hmac_sha256(secret, &input) != server_random { + return Err(ProxyError::Handshake("proxy authentication failed".into())); + } + Ok(()) +} + +fn read_server_response(stream: &mut TcpStream) -> Result, ProxyError> { + let mut response = Vec::new(); + for _ in 0..8 { + let mut header = [0_u8; 5]; + stream + .read_exact(&mut header) + .map_err(|error| ProxyError::Handshake(error.to_string()))?; + let length = u16::from_be_bytes([header[3], header[4]]) as usize; + if length > MAX_RECORD || response.len() + 5 + length > MAX_RESPONSE { + return Err(ProxyError::Handshake("proxy authentication failed".into())); + } + let mut payload = vec![0_u8; length]; + stream + .read_exact(&mut payload) + .map_err(|error| ProxyError::Handshake(error.to_string()))?; + let kind = header[0]; + response.extend_from_slice(&header); + response.extend_from_slice(&payload); + match kind { + RECORD_HANDSHAKE | RECORD_CHANGE_CIPHER_SPEC => {} + RECORD_APPLICATION_DATA => return Ok(response), + _ => return Err(ProxyError::Handshake("proxy authentication failed".into())), + } + } + Err(ProxyError::Handshake("proxy authentication failed".into())) +} + +fn client_hello(domain: &str) -> Result, ProxyError> { + if domain.is_empty() || domain.len() > 182 || !domain.is_ascii() { + return Err(ProxyError::InvalidConfig("invalid proxy secret")); + } + #[rustfmt::skip] + const FIXED_EXTENSIONS: &[u8] = &[ + 0x00, 0x17, 0x00, 0x00, + 0xff, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, + 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, 0x19, + 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, + 0x00, 0x23, 0x00, 0x00, + 0x00, 0x0d, 0x00, 0x14, 0x00, 0x12, + 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, + 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, + 0x08, 0x06, 0x06, 0x01, 0x02, 0x01, + ]; + #[rustfmt::skip] + const CIPHER_SUITES: &[u8] = &[ + 0x13, 0x01, 0x13, 0x02, 0x13, 0x03, + 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30, + 0xcc, 0xa9, 0xcc, 0xa8, + 0xc0, 0x13, 0xc0, 0x14, + 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0x00, 0x0a, + ]; + let host = domain.as_bytes(); + let host_len = host.len() as u16; + let sni_list_len = 3 + host_len; + let sni_data_len = 2 + sni_list_len; + let extensions_len = 4 + usize::from(sni_data_len) + FIXED_EXTENSIONS.len(); + let hello_len = 2 + 32 + 1 + 32 + 2 + CIPHER_SUITES.len() + 2 + 2 + extensions_len; + let handshake_len = 4 + hello_len; + let mut session_id = [0_u8; 32]; + tellers_mtproto_crypto::fill_random(&mut session_id) + .map_err(|error| ProxyError::Handshake(error.to_string()))?; + let mut record = Vec::with_capacity(5 + handshake_len); + record.push(RECORD_HANDSHAKE); + record.extend_from_slice(&[0x03, 0x01]); + record.extend_from_slice(&(handshake_len as u16).to_be_bytes()); + record.push(0x01); + record.extend_from_slice(&[ + (hello_len >> 16) as u8, + (hello_len >> 8) as u8, + hello_len as u8, + ]); + record.extend_from_slice(&[0x03, 0x03]); + record.extend_from_slice(&[0_u8; 32]); + record.push(session_id.len() as u8); + record.extend_from_slice(&session_id); + record.extend_from_slice(&(CIPHER_SUITES.len() as u16).to_be_bytes()); + record.extend_from_slice(CIPHER_SUITES); + record.extend_from_slice(&[0x01, 0x00]); + record.extend_from_slice(&(extensions_len as u16).to_be_bytes()); + record.extend_from_slice(&0_u16.to_be_bytes()); + record.extend_from_slice(&sni_data_len.to_be_bytes()); + record.extend_from_slice(&sni_list_len.to_be_bytes()); + record.push(0x00); + record.extend_from_slice(&host_len.to_be_bytes()); + record.extend_from_slice(host); + record.extend_from_slice(FIXED_EXTENSIONS); + if record.len() != 5 + handshake_len { + return Err(ProxyError::Handshake("proxy authentication failed".into())); + } + Ok(record) +} + +fn sign_client_hello(record: &mut [u8], secret: &[u8]) { + record[11..43].fill(0); + let digest = hmac_sha256(secret, record); + record[11..39].copy_from_slice(&digest[..28]); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as u32) + .unwrap_or(0); + let stamped = timestamp.to_le_bytes(); + for index in 0..4 { + record[39 + index] = digest[28 + index] ^ stamped[index]; + } +} + +pub(crate) fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { + const BLOCK: usize = 64; + let mut key_block = [0_u8; BLOCK]; + if key.len() > BLOCK { + key_block[..32].copy_from_slice(&sha256(key)); + } else { + key_block[..key.len()].copy_from_slice(key); + } + let mut inner_key = [0x36_u8; BLOCK]; + let mut outer_key = [0x5c_u8; BLOCK]; + for index in 0..BLOCK { + inner_key[index] ^= key_block[index]; + outer_key[index] ^= key_block[index]; + } + let mut inner = Vec::with_capacity(BLOCK + data.len()); + inner.extend_from_slice(&inner_key); + inner.extend_from_slice(data); + let inner_hash = sha256(&inner); + let mut outer = Vec::with_capacity(BLOCK + 32); + outer.extend_from_slice(&outer_key); + outer.extend_from_slice(&inner_hash); + sha256(&outer) +} + +impl Read for FakeTlsStream { + fn read(&mut self, output: &mut [u8]) -> std::io::Result { + if output.is_empty() { + return Ok(0); + } + if self.pending_at >= self.pending.len() { + self.pending.clear(); + self.pending_at = 0; + self.read_application_data()?; + } + let count = (self.pending.len() - self.pending_at).min(output.len()); + output[..count].copy_from_slice(&self.pending[self.pending_at..self.pending_at + count]); + self.pending_at += count; + Ok(count) + } +} + +impl Write for FakeTlsStream { + fn write(&mut self, input: &[u8]) -> std::io::Result { + if !self.sent_change_cipher_spec { + self.inner + .write_all(&[RECORD_CHANGE_CIPHER_SPEC, 3, 3, 0, 1, 1])?; + self.sent_change_cipher_spec = true; + } + let count = input.len().min(16 * 1024); + let header = [ + RECORD_APPLICATION_DATA, + 3, + 3, + (count >> 8) as u8, + count as u8, + ]; + self.inner.write_all(&header)?; + self.inner.write_all(&input[..count])?; + Ok(count) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +impl FakeTlsStream { + fn read_application_data(&mut self) -> std::io::Result<()> { + for _ in 0..8 { + let mut header = [0_u8; 5]; + self.inner.read_exact(&mut header)?; + let length = u16::from_be_bytes([header[3], header[4]]) as usize; + if length > MAX_RECORD { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "TLS record is too large", + )); + } + let mut payload = vec![0_u8; length]; + self.inner.read_exact(&mut payload)?; + match header[0] { + RECORD_CHANGE_CIPHER_SPEC => {} + RECORD_APPLICATION_DATA => { + self.pending = payload; + return Ok(()); + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "unexpected TLS record", + )); + } + } + } + Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "TLS application data missing", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handshake_accepts_a_signed_server_hello() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut header = [0_u8; 5]; + stream.read_exact(&mut header).unwrap(); + let mut body = vec![0_u8; u16::from_be_bytes([header[3], header[4]]) as usize]; + stream.read_exact(&mut body).unwrap(); + let mut response = vec![0x16, 3, 3, 0, 38, 2, 0, 0, 34, 3, 3]; + response.extend_from_slice(&[0_u8; 32]); + response.extend_from_slice(&[0x14, 3, 3, 0, 1, 1, 0x17, 3, 3, 0, 1, 0]); + let mut authenticated = body[6..38].to_vec(); + authenticated.extend_from_slice(&response); + response[11..43].copy_from_slice(&hmac_sha256(&[7; 16], &authenticated)); + stream.write_all(&response).unwrap(); + let mut prefix = [0_u8; 6]; + stream.read_exact(&mut prefix).unwrap(); + assert_eq!(prefix, [0x14, 3, 3, 0, 1, 1]); + }); + let mut stream = TcpStream::connect(address).unwrap(); + handshake(&mut stream, "example.com", &[7; 16]).unwrap(); + stream.write_all(&[0x14, 3, 3, 0, 1, 1]).unwrap(); + server.join().unwrap(); + } + + fn hex(value: &str) -> Vec { + (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).unwrap()) + .collect() + } +} diff --git a/native/mtproto-transport/src/proxy/http.rs b/native/mtproto-transport/src/proxy/http.rs new file mode 100644 index 000000000..7503f1a1f --- /dev/null +++ b/native/mtproto-transport/src/proxy/http.rs @@ -0,0 +1,102 @@ +use crate::*; + +pub(crate) fn http_connect( + stream: &mut S, + target: &str, + proxy: &ProxyConfig, +) -> Result<(), ProxyError> { + for authenticated in [false, true] { + if authenticated && proxy.username.is_none() { + break; + } + let mut request = + format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\nConnection: keep-alive\r\n"); + if authenticated { + let (Some(user), Some(pass)) = (&proxy.username, &proxy.password) else { + return Err(ProxyError::Handshake( + "HTTP proxy credentials are incomplete".into(), + )); + }; + request.push_str("Proxy-Authorization: Basic "); + request.push_str( + &base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")), + ); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream.write_all(request.as_bytes())?; + + let mut response = Vec::new(); + let mut byte = [0_u8; 1]; + while response.len() < MAX_PROXY_HEADERS { + stream.read_exact(&mut byte)?; + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + break; + } + } + if !response.ends_with(b"\r\n\r\n") { + return Err(ProxyError::Handshake( + "HTTP proxy response headers are too large".into(), + )); + } + let status = parse_http_response_head(&response)?.status; + if (200..300).contains(&status) { + return Ok(()); + } + if status == 407 && !authenticated && proxy.username.is_some() { + let headers = std::str::from_utf8(&response) + .map_err(|_| ProxyError::Handshake("HTTP proxy headers are invalid".into()))?; + let mut content_length = None; + let mut basic = false; + for line in headers + .split("\r\n") + .skip(1) + .filter(|line| !line.is_empty()) + { + let (name, value) = line + .split_once(':') + .ok_or_else(|| ProxyError::Handshake("HTTP proxy header is invalid".into()))?; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + let length = value.parse::().map_err(|_| { + ProxyError::Handshake("HTTP proxy response length is invalid".into()) + })?; + if content_length.replace(length).is_some() || length > MAX_PROXY_HEADERS { + return Err(ProxyError::Handshake( + "HTTP proxy response length is invalid".into(), + )); + } + } + if name.eq_ignore_ascii_case("transfer-encoding") + || (name.eq_ignore_ascii_case("connection") + && value.eq_ignore_ascii_case("close")) + { + return Err(ProxyError::Handshake( + "HTTP proxy challenge requires a new connection".into(), + )); + } + if name.eq_ignore_ascii_case("proxy-authenticate") { + basic |= value + .split_whitespace() + .next() + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("Basic")); + } + } + if !basic || content_length.is_none() { + return Err(ProxyError::Handshake( + "HTTP proxy authentication challenge is unsupported".into(), + )); + } + let mut body = vec![0_u8; content_length.unwrap_or_default()]; + stream.read_exact(&mut body)?; + continue; + } + return Err(ProxyError::Handshake(format!( + "HTTP proxy returned status {status}" + ))); + } + Err(ProxyError::Handshake( + "HTTP proxy authentication failed".into(), + )) +} diff --git a/native/mtproto-transport/src/proxy/mod.rs b/native/mtproto-transport/src/proxy/mod.rs new file mode 100644 index 000000000..203c364c2 --- /dev/null +++ b/native/mtproto-transport/src/proxy/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod faketls; +pub(crate) mod tls; diff --git a/native/mtproto-transport/src/proxy/socks5.rs b/native/mtproto-transport/src/proxy/socks5.rs new file mode 100644 index 000000000..dfdba00ce --- /dev/null +++ b/native/mtproto-transport/src/proxy/socks5.rs @@ -0,0 +1,97 @@ +use crate::*; + +pub(crate) fn split_target(target: &str) -> (&str, u16) { + if let Some(rest) = target.strip_prefix('[') { + if let Some(end) = rest.find(']') { + let host = &rest[..end]; + let port = rest[end + 1..] + .strip_prefix(':') + .and_then(|value| value.parse().ok()) + .unwrap_or(443); + return (host, port); + } + } + target + .rsplit_once(':') + .and_then(|(host, port)| port.parse().ok().map(|port| (host, port))) + .unwrap_or((target, 443)) +} + +pub(crate) fn socks5_connect( + stream: &mut S, + target: &str, + proxy: &ProxyConfig, + resolved_target: Option, +) -> Result<(), ProxyError> { + let auth = proxy.username.is_some(); + stream.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] })?; + let mut method = [0_u8; 2]; + stream.read_exact(&mut method)?; + if method[0] != 5 || !matches!(method[1], 0 | 2) || (method[1] == 2 && !auth) { + return Err(ProxyError::Handshake( + "SOCKS5 authentication rejected".into(), + )); + } + if method[1] == 2 { + let user = proxy.username.as_deref().unwrap_or_default().as_bytes(); + let pass = proxy.password.as_deref().unwrap_or_default().as_bytes(); + if user.len() > 255 || pass.len() > 255 { + return Err(ProxyError::InvalidConfig("SOCKS5 credentials are too long")); + } + stream.write_all(&[1, user.len() as u8])?; + stream.write_all(user)?; + stream.write_all(&[pass.len() as u8])?; + stream.write_all(pass)?; + let mut result = [0_u8; 2]; + stream.read_exact(&mut result)?; + if result != [1, 0] { + return Err(ProxyError::Handshake("SOCKS5 credentials rejected".into())); + } + } + let (host, port) = split_target(target); + let port_bytes = port.to_be_bytes(); + if let Some(address) = resolved_target.or_else(|| { + host.parse::() + .ok() + .map(|address| SocketAddr::new(address, port)) + }) { + match address.ip() { + IpAddr::V4(address) => { + stream.write_all(&[5, 1, 0, 1])?; + stream.write_all(&address.octets())?; + } + IpAddr::V6(address) => { + stream.write_all(&[5, 1, 0, 4])?; + stream.write_all(&address.octets())?; + } + } + } else { + if host.len() > 255 { + return Err(ProxyError::InvalidConfig("target host is too long")); + } + stream.write_all(&[5, 1, 0, 3, host.len() as u8])?; + stream.write_all(host.as_bytes())?; + } + stream.write_all(&port_bytes)?; + let mut head = [0_u8; 4]; + stream.read_exact(&mut head)?; + if head[0] != 5 || head[1] != 0 || head[2] != 0 { + return Err(ProxyError::Handshake("SOCKS5 CONNECT rejected".into())); + } + let length = match head[3] { + 1 => 4, + 4 => 16, + 3 => { + let mut size = [0_u8; 1]; + stream.read_exact(&mut size)?; + if size[0] == 0 { + return Err(ProxyError::Handshake("SOCKS5 address is empty".into())); + } + size[0] as usize + } + _ => return Err(ProxyError::Handshake("SOCKS5 address type invalid".into())), + }; + let mut discard = vec![0_u8; length + 2]; + stream.read_exact(&mut discard)?; + Ok(()) +} diff --git a/native/mtproto-transport/src/proxy/tls.rs b/native/mtproto-transport/src/proxy/tls.rs new file mode 100644 index 000000000..ba3274d10 --- /dev/null +++ b/native/mtproto-transport/src/proxy/tls.rs @@ -0,0 +1,75 @@ +use crate::{ProxyConfig, ProxyError}; +use rustls::pki_types::ServerName; +use rustls::{ClientConfig, ClientConnection, StreamOwned}; +use rustls_platform_verifier::ConfigVerifierExt; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::Arc; + +const MAX_PROXY_HEADERS: usize = 16 * 1024; +pub(crate) type TlsStream = StreamOwned; + +pub(crate) fn connect( + stream: TcpStream, + proxy: &ProxyConfig, + timeout: std::time::Duration, +) -> Result { + let config = ClientConfig::with_platform_verifier() + .map_err(|error| ProxyError::Tls(format!("TLS verifier setup failed: {error}")))?; + let server_name = ServerName::try_from(proxy.host.clone()) + .map_err(|_| ProxyError::Tls("HTTPS proxy host is not a valid TLS name".into()))?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + let connection = ClientConnection::new(Arc::new(config), server_name) + .map_err(|error| ProxyError::Tls(format!("TLS client setup failed: {error}")))?; + Ok(StreamOwned::new(connection, stream)) +} + +pub(crate) fn http_connect( + stream: &mut TlsStream, + target: &str, + proxy: &ProxyConfig, +) -> Result<(), ProxyError> { + let mut request = + format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\nConnection: keep-alive\r\n"); + if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) { + use base64::Engine; + request.push_str("Proxy-Authorization: Basic "); + request.push_str( + &base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")), + ); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream.write_all(request.as_bytes())?; + stream.flush()?; + let mut response = Vec::new(); + let mut byte = [0_u8; 1]; + while response.len() < MAX_PROXY_HEADERS { + stream.read_exact(&mut byte)?; + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + break; + } + } + if !response.ends_with(b"\r\n\r\n") { + return Err(ProxyError::Handshake( + "HTTPS proxy response headers are too large".into(), + )); + } + let line = response + .split(|byte| *byte == b'\n') + .next() + .ok_or_else(|| ProxyError::Handshake("HTTPS proxy status is missing".into()))?; + let status = std::str::from_utf8(line) + .ok() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|status| status.parse::().ok()) + .ok_or_else(|| ProxyError::Handshake("HTTPS proxy status is invalid".into()))?; + if !(200..300).contains(&status) { + return Err(ProxyError::Handshake(format!( + "HTTPS proxy returned status {status}" + ))); + } + Ok(()) +} diff --git a/native/mtproto-transport/src/tcp.rs b/native/mtproto-transport/src/tcp.rs new file mode 100644 index 000000000..49629bcb9 --- /dev/null +++ b/native/mtproto-transport/src/tcp.rs @@ -0,0 +1,139 @@ +use crate::{ProxyConfig, TransportConfig, TransportMode}; +use parking_lot::{Condvar, Mutex}; +use std::cell::RefCell; +use std::net::TcpStream; +use std::sync::{Arc, LazyLock, Weak}; +use std::time::Duration; +use tellers_mtproto_transport::Error as TransportError; + +static CONFIG: LazyLock> = + LazyLock::new(|| Mutex::new(TransportConfig::default())); + +#[derive(Default)] +pub struct ControlState { + pub closed: bool, + pub streams: Vec>, +} + +pub struct ConnectionControl { + pub config: TransportConfig, + pub state: Mutex, + pub persistence: Mutex<()>, + pub wake: Condvar, +} + +impl Default for ConnectionControl { + fn default() -> Self { + Self { + config: CONFIG.lock().clone(), + state: Mutex::default(), + persistence: Mutex::default(), + wake: Condvar::default(), + } + } +} + +impl ConnectionControl { + pub fn detach_sockets(&self) { + self.state.lock().streams.clear(); + } + pub fn while_open(&self, operation: impl FnOnce() -> T) -> Result { + let _commit = self.persistence.lock(); + if self.state.lock().closed { + return Err(closed_error()); + } + Ok(operation()) + } + pub fn close(&self) { + let mut state = self.state.lock(); + state.closed = true; + for stream in state + .streams + .drain(..) + .filter_map(|stream| stream.upgrade()) + { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + self.wake.notify_all(); + drop(state); + let _commit = self.persistence.lock(); + } + pub fn register(&self, stream: &Arc) -> Result<(), TransportError> { + let mut state = self.state.lock(); + if state.closed { + let _ = stream.shutdown(std::net::Shutdown::Both); + return Err(closed_error()); + } + state.streams.retain(|entry| entry.strong_count() > 0); + let weak = Arc::downgrade(stream); + if !state.streams.iter().any(|entry| entry.ptr_eq(&weak)) { + state.streams.push(weak); + } + Ok(()) + } + pub fn wait(&self, delay: Duration) -> Result<(), TransportError> { + let deadline = std::time::Instant::now() + delay; + let mut state = self.state.lock(); + while !state.closed { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + self.wake.wait_for(&mut state, remaining); + } + Err(closed_error()) + } +} + +fn closed_error() -> TransportError { + TransportError::Connection("client closed".into()) +} +thread_local! { static CONTROL: RefCell>> = const { RefCell::new(None) }; } + +pub fn set_proxy(config: Option) -> Result<(), TransportError> { + if let Some(config) = &config { + config + .validate() + .map_err(|error| TransportError::Connection(error.to_string()))?; + } + CONFIG.lock().proxy = config; + Ok(()) +} +pub fn set_transport_mode(mode: TransportMode) { + CONFIG.lock().mode = mode; +} +pub fn config() -> TransportConfig { + CONTROL + .with(|cell| cell.borrow().as_ref().map(|control| control.config.clone())) + .unwrap_or_else(|| CONFIG.lock().clone()) +} +pub fn with_connection_control(control: &Arc, body: impl FnOnce() -> T) -> T { + struct Restore(Option>); + impl Drop for Restore { + fn drop(&mut self) { + CONTROL.with(|cell| cell.replace(self.0.take())); + } + } + let _restore = Restore(CONTROL.with(|cell| cell.replace(Some(control.clone())))); + body() +} +pub fn wait_reconnect(delay: Duration) -> Result<(), TransportError> { + let deadline = std::time::Instant::now() + delay; + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let slice = remaining.min(Duration::from_millis(50)); + match CONTROL.with(|cell| cell.borrow().clone()) { + Some(control) => control.wait(slice)?, + None => std::thread::sleep(slice), + } + if remaining.is_zero() { + return Ok(()); + } + } +} +pub fn while_client_open(operation: impl FnOnce() -> T) -> Result { + match CONTROL.with(|cell| cell.borrow().clone()) { + Some(control) => control.while_open(operation), + None => Ok(operation()), + } +} diff --git a/native/mtproto-transport/src/tests/mod.rs b/native/mtproto-transport/src/tests/mod.rs new file mode 100644 index 000000000..a681a2efb --- /dev/null +++ b/native/mtproto-transport/src/tests/mod.rs @@ -0,0 +1,1067 @@ +use crate::*; + + + #[test] + fn dns_cache_rotates_and_evicts_without_crossing_proxy_scope() { + let host = "cache-scope.invalid"; + let addresses = [ + "127.0.0.1:10001".parse().unwrap(), + "127.0.0.1:10002".parse().unwrap(), + ]; + TcpConnection::cache_addresses_with_ttl(host, 443, false, &addresses, DNS_CACHE_TTL); + TcpConnection::cache_addresses_with_ttl(host, 443, true, &addresses, DNS_CACHE_TTL); + assert_eq!( + TcpConnection::cached_resolution(host, 443, true).unwrap(), + vec![addresses[1], addresses[0]] + ); + assert_eq!( + TcpConnection::cached_resolution(host, 443, true).unwrap(), + addresses + ); + TcpConnection::evict_cached_address(host, 443, true, addresses[0]); + assert_eq!( + TcpConnection::cached_resolution(host, 443, true).unwrap(), + vec![addresses[1]] + ); + assert_eq!( + TcpConnection::cached_resolution(host, 443, false) + .unwrap() + .len(), + 2 + ); + TcpConnection::evict_cached_addresses(host, 443, true); + TcpConnection::evict_cached_addresses(host, 443, false); + } + + #[test] + fn dns_expired_answers_are_removed() { + let host = "cache-expiry.invalid"; + let key = TcpConnection::dns_cache_key(host, 443, true); + DNS_CACHE.lock().unwrap().insert( + key.clone(), + CachedAddresses { + addresses: vec!["127.0.0.1:443".parse().unwrap()], + expires_at: Instant::now() - Duration::from_secs(1), + next: 0, + }, + ); + assert!(TcpConnection::cached_resolution(host, 443, true).is_none()); + assert!(!DNS_CACHE.lock().unwrap().contains_key(&key)); + } + + #[test] + fn proxy_connection_uses_cached_addresses_without_resolving_target() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut header = Vec::new(); + let mut byte = [0_u8; 1]; + while !header.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + header.push(byte[0]); + } + assert!(header.starts_with(b"CONNECT target.invalid:443 HTTP/1.1\r\n")); + stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .unwrap(); + }); + let proxy = ProxyConfig { + kind: ProxyKind::Http, + host: "cached-proxy.invalid".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + TcpConnection::cache_addresses_with_ttl( + &proxy.host, + proxy.port, + true, + &[address], + DNS_CACHE_TTL, + ); + TcpConnection::connect("target.invalid:443", Duration::from_secs(2), Some(&proxy)).unwrap(); + server.join().unwrap(); + TcpConnection::evict_cached_addresses(&proxy.host, proxy.port, true); + } + + #[test] + fn http_transport_serializes_batched_sends_in_fifo_order() { + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + for index in 0..3_u8 { + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut head = Vec::new(); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + } + assert!( + String::from_utf8(head) + .unwrap() + .contains("Content-Length: 72\r\n") + ); + let mut body = [0_u8; 72]; + stream.read_exact(&mut body).unwrap(); + assert_eq!(body, [7 + index; 72]); + stream + .set_read_timeout(Some(Duration::from_millis(50))) + .unwrap(); + let error = stream.peek(&mut byte).unwrap_err(); + assert!(matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + )); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n") + .unwrap(); + stream.write_all(&[index + 1; 4]).unwrap(); + } + }); + let connection = + TcpConnection::connect(&address.to_string(), Duration::from_secs(3), None).unwrap(); + let mut transport = connection + .into_transport("dc.example:80", TransportMode::Http, Some(2), None) + .unwrap(); + for index in 0..3_u8 { + let packet = tellers_mtproto_transport::PaddedIntermediate::default() + .encode_with_padding(&[7 + index; 72], &[42; 15]) + .unwrap(); + transport.send(&packet).unwrap(); + } + for index in 0..3_u8 { + let mut received = Vec::new(); + let mut buffer = [0_u8; 3]; + while received.len() < 8 { + let count = transport.receive(&mut buffer).unwrap(); + assert!(count > 0); + received.extend_from_slice(&buffer[..count]); + } + assert_eq!(&received[..4], &4_u32.to_le_bytes()); + assert_eq!(&received[4..], &[index + 1; 4]); + } + server.join().unwrap(); + } + + #[test] + fn http_transport_bounds_queue_and_drops_it_on_close() { + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut received = Vec::new(); + stream.read_to_end(&mut received).unwrap(); + assert_eq!( + received + .windows(9) + .filter(|window| *window == b"POST /api") + .count(), + 1 + ); + }); + let connection = + TcpConnection::connect(&address.to_string(), Duration::from_secs(3), None).unwrap(); + let mut http = connection.into_http("dc.example:80").unwrap(); + let packet = [3, 0, 0, 0, b'a', b'b', b'c']; + http.send(&packet).unwrap(); + for _ in 0..MAX_HTTP_QUEUED_REQUESTS { + http.send(&packet).unwrap(); + } + assert!(http.send(&packet).is_err()); + http.close().unwrap(); + assert!(http.send(&packet).is_err()); + assert!(http.receive(&mut [0_u8; 8]).is_err()); + server.join().unwrap(); + } + #[test] + fn http_transport_sends_unpadded_envelope_and_decodes_chunked_response() { + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut head = Vec::new(); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + } + assert!( + String::from_utf8(head) + .unwrap() + .contains("Content-Length: 72\r\n") + ); + let mut body = [0_u8; 72]; + stream.read_exact(&mut body).unwrap(); + assert_eq!(body, [7_u8; 72]); + stream.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n").unwrap(); + }); + let connection = + TcpConnection::connect(&address.to_string(), Duration::from_secs(3), None).unwrap(); + let mut transport = connection + .into_transport("dc.example:80", TransportMode::Http, Some(2), None) + .unwrap(); + let packet = tellers_mtproto_transport::PaddedIntermediate::default() + .encode_with_padding(&[7; 72], &[42; 15]) + .unwrap(); + transport.send(&packet).unwrap(); + let mut received = Vec::new(); + let mut buffer = [0_u8; 2]; + while received.len() < 9 { + let count = transport.receive(&mut buffer).unwrap(); + assert!(count > 0); + received.extend_from_slice(&buffer[..count]); + } + assert_eq!(received, b"\x05\0\0\0abcde"); + server.join().unwrap(); + } + #[test] + fn http_payload_strips_transport_padding_but_preserves_encrypted_envelope() { + use tellers_mtproto_transport::PaddedIntermediate; + let framing = PaddedIntermediate::default(); + let mut unencrypted = vec![0_u8; 20]; + unencrypted[8..16].copy_from_slice(&1_u64.to_le_bytes()); + unencrypted[16..20].copy_from_slice(&4_u32.to_le_bytes()); + unencrypted.extend_from_slice(b"body"); + let encrypted = vec![7_u8; 24 + 48]; + for padding_length in 0..16 { + let padding = vec![42_u8; padding_length]; + for envelope in [&unencrypted, &encrypted] { + let packet = framing.encode_with_padding(envelope, &padding).unwrap(); + assert_eq!(telegram_http_payload(&packet).unwrap(), envelope.as_slice()); + } + } + assert!(telegram_http_payload(&[1, 0, 0, 0]).is_err()); + assert!(telegram_http_payload(&[0, 0, 0, 0, 42]).is_err()); + } + + #[test] + fn http_response_headers_reject_ambiguous_or_malformed_framing() { + for header in [ + "NOTHTTP 200 OK\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 0200 OK\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\nContent-Length: 1\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\nTransfer-Encoding: chunked\r\n\r\n", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: +1\r\n\r\n", + "HTTP/1.1 200 OK\r\n Content-Length: 1\r\n\r\n", + "HTTP/1.1 200 OK\r\nInvalid header\r\n\r\n", + ] { + assert!( + parse_http_response_head(header.as_bytes()).is_err(), + "{header:?}" + ); + } + } + + #[test] + fn http_chunked_body_preserves_state_across_every_byte_boundary() { + let wire = b"3;fixture=yes\r\nabc\r\n2\r\nde\r\n0\r\nX-Fixture: done\r\n\r\n"; + let mut decoder = HttpChunkDecoder::default(); + for length in 0..wire.len() { + assert!(decoder.decode(&wire[..length]).unwrap().is_none()); + } + let (body, consumed) = decoder.decode(wire).unwrap().unwrap(); + assert_eq!(body, b"abcde"); + assert_eq!(consumed, wire.len()); + } + + #[test] + fn http_chunked_body_rejects_overflow_and_invalid_delimiters() { + for wire in [ + b"1000001\r\n".as_slice(), + b"+1\r\na\r\n".as_slice(), + b"1\r\naXX".as_slice(), + b"0\r\nContent-Length: 1\r\n\r\n".as_slice(), + ] { + assert!(HttpChunkDecoder::default().decode(wire).is_err()); + } + } + #[test] + fn fake_tls_rejects_unauthenticated_server_response() { + let mut response = vec![0x16, 3, 3, 0, 38, 2, 0, 0, 34, 3, 3]; + response.extend_from_slice(&[0_u8; 32]); + response.extend_from_slice(&[0x17, 3, 3, 0, 1, 0]); + let error = + verify_fake_tls_response(&mut std::io::Cursor::new(response), &[7; 32], &[8; 16]) + .unwrap_err(); + assert!(error.to_string().contains("authentication failed")); + } + + #[test] + fn fake_tls_client_hello_authenticates_random_and_advertises_domain() { + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32; + let first = fake_tls_client_hello("example.com", &[7; 16]).unwrap(); + let second = fake_tls_client_hello("example.com", &[7; 16]).unwrap(); + let after = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32; + let mut unsigned = first.clone(); + unsigned[11..43].fill(0); + let digest = hmac_sha256(&[7; 16], &unsigned); + assert_eq!(&first[11..39], &digest[..28]); + let timestamp = u32::from_le_bytes(first[39..43].try_into().unwrap()) + ^ u32::from_le_bytes(digest[28..32].try_into().unwrap()); + assert!((before..=after).contains(×tamp)); + assert_eq!(&first[..5], &[0x16, 3, 1, first[3], first[4]]); + assert_ne!(&first[11..43], &[0_u8; 32]); + assert_ne!(&first[11..43], &second[11..43]); + assert!( + first + .windows(b"example.com".len()) + .any(|window| window == b"example.com") + ); + } + + #[test] + fn proxy_debug_redacts_credentials_and_secret() { + let config = ProxyConfig { + kind: ProxyKind::Mtproto, + host: "sensitive-host.example".into(), + port: 443, + username: Some("sensitive-user".into()), + password: Some("sensitive-password".into()), + secret: Some(ProxySecret { + key: [9; 16], + random_padding: true, + fake_tls_domain: Some("sensitive-domain.example".into()), + }), + }; + let debug = format!("{config:?} {:?}", config.secret); + assert!(!debug.contains("sensitive")); + assert!(!debug.contains("9, 9")); + } + + #[test] + fn socks5_rejects_unoffered_authentication_method() { + let config = ProxyConfig { + kind: ProxyKind::Socks5, + host: "localhost".into(), + port: 1080, + username: None, + password: None, + secret: None, + }; + let mut stream = + std::io::Cursor::new(vec![0_u8; 3].into_iter().chain([5, 2]).collect::>()); + assert!(socks5_connect(&mut stream, "example.com:443", &config, None).is_err()); + } + + #[test] + fn rejects_partial_credentials() { + let c = ProxyConfig { + kind: ProxyKind::Http, + host: "proxy.example".into(), + port: 8080, + username: Some("user".into()), + password: None, + secret: None, + }; + assert!(c.validate().is_err()); + } + + #[test] + fn rejects_oversized_credentials() { + let c = ProxyConfig { + kind: ProxyKind::Http, + host: "proxy.example".into(), + port: 8080, + username: Some("u".repeat(MAX_PROXY_CREDENTIAL + 1)), + password: Some("p".repeat(MAX_PROXY_CREDENTIAL + 1)), + secret: None, + }; + assert!(c.validate().is_err()); + } + + #[test] + fn rejects_control_characters_in_credentials() { + let c = ProxyConfig { + kind: ProxyKind::Http, + host: "proxy.example".into(), + port: 8080, + username: Some("user\r\nInjected: header".into()), + password: Some("pass".into()), + secret: None, + }; + assert!(c.validate().is_err()); + } + #[test] + fn parses_fake_tls_secret() { + let s = + ProxySecret::parse("ee070707070707070707070707070707076578616d706c652e636f6d").unwrap(); + assert!(s.random_padding); + assert_eq!(s.fake_tls_domain.as_deref(), Some("example.com")); + } + #[test] + fn classic_secrets_preserve_prefix_like_key_bytes() { + for first_byte in [0xdd, 0xee] { + let mut key = [7_u8; 16]; + key[0] = first_byte; + let encoded = key + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let parsed = ProxySecret::parse(&encoded).unwrap(); + assert_eq!(parsed.key, key); + assert!(!parsed.random_padding); + assert_eq!(parsed.fake_tls_domain, None); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key); + assert_eq!(ProxySecret::parse(&encoded).unwrap(), parsed); + } + } + + #[test] + fn prefixed_secrets_require_their_complete_payload() { + let padded = ProxySecret::parse("dd07070707070707070707070707070707").unwrap(); + assert_eq!(padded.key, [7_u8; 16]); + assert!(padded.random_padding); + assert_eq!(padded.fake_tls_domain, None); + for encoded in [ + "070707070707070707070707070707", + "0707070707070707070707070707070707aa", + "dd07070707070707070707070707070707aa", + "ee07070707070707070707070707070707", + ] { + assert!(ProxySecret::parse(encoded).is_err()); + } + } + + #[test] + fn resolves_literals_without_dns() { + let addresses = TcpConnection::resolve("127.0.0.1", 443).unwrap(); + assert_eq!(addresses[0], "127.0.0.1:443".parse().unwrap()); + let addresses = TcpConnection::resolve("::1", 443).unwrap(); + assert_eq!(addresses[0], "[::1]:443".parse().unwrap()); + } + + #[test] + fn fake_tls_domain_has_a_bounded_ascii_payload() { + let mut secret = vec![0xee]; + secret.extend_from_slice(&[7; 16]); + let domain = format!("{}.{}.{}", "a".repeat(63), "b".repeat(63), "c".repeat(54)); + secret.extend_from_slice(domain.as_bytes()); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + assert!(ProxySecret::parse(&encode(&secret)).is_ok()); + secret.push(b'a'); + assert!(ProxySecret::parse(&encode(&secret)).is_err()); + secret.truncate(17); + secret.extend_from_slice("é.example".as_bytes()); + assert!(ProxySecret::parse(&encode(&secret)).is_err()); + } + + #[test] + fn socks5_no_auth_prefers_ip_target() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut greeting = [0_u8; 3]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting, [5, 1, 0]); + stream.write_all(&[5, 0]).unwrap(); + let mut request = [0_u8; 4]; + stream.read_exact(&mut request).unwrap(); + assert_eq!(request, [5, 1, 0, 1]); + let mut address_and_port = [0_u8; 6]; + stream.read_exact(&mut address_and_port).unwrap(); + assert_eq!(&address_and_port[..4], &[127, 0, 0, 2]); + stream + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 1, 187]) + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Socks5, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + TcpConnection::connect("127.0.0.2:443", Duration::from_secs(1), Some(&config)).unwrap(); + server.join().unwrap(); + } + + #[test] + fn socks5_no_auth_preserves_domain_target_for_proxy_resolution() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut greeting = [0_u8; 3]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting, [5, 1, 0]); + stream.write_all(&[5, 0]).unwrap(); + let mut request_head = [0_u8; 5]; + stream.read_exact(&mut request_head).unwrap(); + assert_eq!(&request_head[..4], &[5, 1, 0, 3]); + let domain_length = usize::from(request_head[4]); + let mut domain_and_port = vec![0_u8; domain_length + 2]; + stream.read_exact(&mut domain_and_port).unwrap(); + assert_eq!(&domain_and_port[..domain_length], b"dc.example"); + assert_eq!(&domain_and_port[domain_length..], &443_u16.to_be_bytes()); + stream + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 1, 187]) + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Socks5, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + TcpConnection::connect("dc.example:443", Duration::from_secs(1), Some(&config)).unwrap(); + server.join().unwrap(); + } + + #[test] + fn socks5_username_password_negotiates() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut greeting = [0_u8; 4]; + stream.read_exact(&mut greeting).unwrap(); + assert_eq!(greeting, [5, 2, 0, 2]); + stream.write_all(&[5, 2]).unwrap(); + let mut auth_header = [0_u8; 2]; + stream.read_exact(&mut auth_header).unwrap(); + let mut credentials = vec![0_u8; usize::from(auth_header[1]) + 1]; + stream.read_exact(&mut credentials).unwrap(); + let password_length = usize::from(credentials[usize::from(auth_header[1])]); + let mut password = vec![0_u8; password_length]; + stream.read_exact(&mut password).unwrap(); + assert_eq!(&credentials[..usize::from(auth_header[1])], b"user"); + assert_eq!(password, b"pass"); + stream.write_all(&[1, 0]).unwrap(); + let mut request = [0_u8; 10]; + stream.read_exact(&mut request).unwrap(); + assert_eq!(&request[..4], &[5, 1, 0, 1]); + stream + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 1, 187]) + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Socks5, + host: "127.0.0.1".into(), + port: address.port(), + username: Some("user".into()), + password: Some("pass".into()), + secret: None, + }; + TcpConnection::connect("127.0.0.1:443", Duration::from_secs(1), Some(&config)).unwrap(); + server.join().unwrap(); + } + + #[test] + fn hmac_sha256_matches_standard_vector() { + assert_eq!( + hmac_sha256(&[7; 16], b"abc"), + [ + 0xd0, 0x63, 0xde, 0x16, 0x30, 0xfd, 0x83, 0x1c, 0xc1, 0x0f, 0x47, 0xf5, 0xc6, 0xcd, + 0x41, 0x3a, 0x20, 0x4a, 0x2d, 0xe6, 0x94, 0x38, 0x9d, 0xf5, 0xc1, 0x71, 0xae, 0x7f, + 0x10, 0x73, 0x33, 0xac, + ] + ); + } + + #[test] + fn mtproto_fake_tls_connection_emits_tls_shaped_prefix() { + use std::net::TcpListener; + use std::thread; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut hello_header = [0_u8; 5]; + stream.read_exact(&mut hello_header).unwrap(); + assert_eq!(&hello_header[..3], &[0x16, 3, 1]); + let mut hello_body = + vec![0_u8; u16::from_be_bytes([hello_header[3], hello_header[4]]) as usize]; + stream.read_exact(&mut hello_body).unwrap(); + let mut response = vec![0x16, 3, 3, 0, 38, 2, 0, 0, 34, 3, 3]; + response.extend_from_slice(&[0_u8; 32]); + response.extend_from_slice(&[0x14, 3, 3, 0, 1, 1, 0x17, 3, 3, 0, 1, 0]); + let mut authenticated = hello_body[6..38].to_vec(); + authenticated.extend_from_slice(&response); + response[11..43].copy_from_slice(&hmac_sha256(&[7; 16], &authenticated)); + stream.write_all(&response).unwrap(); + let mut prefix = vec![0_u8; 64 + 6 + 5 + 4]; + stream.read_exact(&mut prefix).unwrap(); + assert_eq!(&prefix[..6], &[0x14, 3, 3, 0, 1, 1]); + assert_eq!(&prefix[6..11], &[0x17, 3, 3, 0, 68]); + assert_ne!(&prefix[11..67], &[0_u8; 56]); + let mut reversed = [0_u8; 64]; + reversed.copy_from_slice(&prefix[11..75]); + reversed.reverse(); + let mut key_material = reversed[8..40].to_vec(); + key_material.extend_from_slice(&[7; 16]); + let key = sha256(&key_material); + let mut cipher = AesCtr::new(&key, &reversed[40..56]).unwrap(); + let mut payload = b"abcdef".to_vec(); + cipher.apply(&mut payload); + stream.write_all(&[0x17, 3, 3, 0, 6]).unwrap(); + stream.write_all(&payload).unwrap(); + }); + let secret = + ProxySecret::parse("ee070707070707070707070707070707076578616d706c652e636f6d").unwrap(); + let config = ProxyConfig { + kind: ProxyKind::Mtproto, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: Some(secret), + }; + let connection = + TcpConnection::connect("dc.example:443", Duration::from_secs(1), Some(&config)) + .unwrap(); + let mut obfuscated = connection.into_obfuscated(None, Some(2)).unwrap(); + obfuscated.send(b"ping").unwrap(); + let mut first = [0_u8; 2]; + assert_eq!(obfuscated.receive(&mut first).unwrap(), 2); + assert_eq!(&first, b"ab"); + let mut remaining = [0_u8; 4]; + assert_eq!(obfuscated.receive(&mut remaining).unwrap(), 4); + assert_eq!(&remaining, b"cdef"); + server.join().unwrap(); + } + + #[test] + fn parses_doh_ipv4_and_ipv6_answers() { + let body = br#"{"Answer":[{"type":1,"data":"203.0.113.7","TTL":2},{"type":28,"data":"2001:db8::7","TTL":600}]}"#; + let addresses = parse_doh_addresses(body, 443).unwrap(); + assert_eq!(addresses.ttl, DNS_MIN_TTL); + assert_eq!( + addresses.addresses, + vec![ + "203.0.113.7:443".parse().unwrap(), + "[2001:db8::7]:443".parse().unwrap(), + ] + ); + } + + #[test] + fn telegram_http_transport_round_trips_a_response() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + assert!(String::from_utf8_lossy(&request).contains("POST /api HTTP/1.1")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: keep-alive\r\n\r\nxyz", + ) + .unwrap(); + }); + let connection = TcpConnection::connect( + &format!("127.0.0.1:{}", address.port()), + Duration::from_secs(1), + None, + ) + .unwrap(); + let mut http = connection.into_http("dc.example:443").unwrap(); + http.send(&[3, 0, 0, 0, b'a', b'b', b'c']).unwrap(); + let mut output = [0_u8; 7]; + assert_eq!(http.receive(&mut output).unwrap(), 7); + assert_eq!(&output[..4], &[3, 0, 0, 0]); + assert_eq!(&output[4..], b"xyz"); + server.join().unwrap(); + } + + #[test] + fn socket_registration_can_cancel_proxy_negotiation() { + use std::net::{Shutdown, TcpListener}; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (socket_tx, socket_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let worker = thread::spawn(move || { + let config = ProxyConfig { + kind: ProxyKind::Socks5, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + let result = TcpConnection::connect_controlled( + "example.com:443", + Duration::from_secs(30), + Some(&config), + |socket| { + socket_tx.send(socket.clone()).unwrap(); + Ok(()) + }, + move || { + if worker_cancelled.load(std::sync::atomic::Ordering::Acquire) { + Err(ProxyError::Handshake("connection cancelled".into())) + } else { + Ok(()) + } + }, + ); + done_tx.send(result.is_err()).unwrap(); + }); + let (mut peer, _) = listener.accept().unwrap(); + peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut greeting = [0_u8; 3]; + peer.read_exact(&mut greeting).unwrap(); + cancelled.store(true, std::sync::atomic::Ordering::Release); + socket_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .shutdown(Shutdown::Both) + .unwrap(); + assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap()); + worker.join().unwrap(); + } + + #[test] + fn telegram_http_large_response_survives_small_reads() { + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + let mut body = [0_u8; 3]; + stream.read_exact(&mut body).unwrap(); + assert_eq!(&body, b"abc"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 131072\r\n\r\n") + .unwrap(); + stream.write_all(&vec![42_u8; 131072]).unwrap(); + }); + let connection = + TcpConnection::connect(&address.to_string(), Duration::from_secs(3), None).unwrap(); + let mut http = connection.into_http("dc.example:80").unwrap(); + let packet = [3, 0, 0, 0, b'a', b'b', b'c']; + http.send(&packet).unwrap(); + let mut received = Vec::new(); + let mut buffer = [0_u8; 37]; + while received.len() < 131076 { + let count = http.receive(&mut buffer).unwrap(); + assert!(count > 0); + received.extend_from_slice(&buffer[..count]); + } + assert_eq!(&received[..4], &131072_u32.to_le_bytes()); + assert!(received[4..].iter().all(|byte| *byte == 42)); + assert!(http.receive(&mut buffer).is_err()); + server.join().unwrap(); + } + + #[test] + fn http_connect_rejects_non_success_status() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 256]; + while !bytes.windows(4).any(|w| w == b"\r\n\r\n") { + let n = stream.read(&mut chunk).unwrap(); + bytes.extend_from_slice(&chunk[..n]); + } + assert!(bytes.starts_with(b"CONNECT example.com:443 HTTP/1.1\r\n")); + stream + .write_all( + b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Http, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + assert!( + TcpConnection::connect("example.com:443", Duration::from_secs(1), Some(&config)) + .is_err() + ); + server.join().unwrap(); + } + + #[test] + fn http_connect_rejects_malformed_status_line() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + stream.write_all(b"NOT-HTTP 200\r\n\r\n").unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Http, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + assert!( + TcpConnection::connect("example.com:443", Duration::from_secs(1), Some(&config)) + .is_err() + ); + server.join().unwrap(); + } + + #[test] + fn http_connect_auth_succeeds() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 256]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut chunk).unwrap(); + bytes.extend_from_slice(&chunk[..count]); + } + let request = String::from_utf8(bytes).unwrap(); + assert!(request.starts_with("CONNECT example.com:443 HTTP/1.1\r\n")); + assert!(!request.contains("Proxy-Authorization:")); + stream.write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\nContent-Length: 0\r\n\r\n").unwrap(); + let mut bytes = Vec::new(); + let mut byte = [0_u8; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + let request = String::from_utf8(bytes).unwrap(); + assert!(request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n")); + stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Http, + host: "127.0.0.1".into(), + port: address.port(), + username: Some("user".into()), + password: Some("pass".into()), + secret: None, + }; + TcpConnection::connect("example.com:443", Duration::from_secs(1), Some(&config)).unwrap(); + server.join().unwrap(); + } + + #[test] + fn https_proxy_rejects_plaintext_endpoint() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n"); + }); + let config = ProxyConfig { + kind: ProxyKind::Https, + host: "localhost".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + assert!( + TcpConnection::connect("example.com:443", Duration::from_secs(1), Some(&config)) + .is_err() + ); + server.join().unwrap(); + } + + fn exercise_https_certificate(subject: &str, trusted: bool) -> (bool, bool) { + use rustls::pki_types::PrivatePkcs8KeyDer; + use rustls::{RootCertStore, ServerConfig, ServerConnection}; + use std::net::TcpListener; + + let certificate = rcgen::generate_simple_self_signed(vec![subject.to_owned()]).unwrap(); + let der = certificate.cert.der().clone(); + let key = PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()); + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![der.clone()], key.into()) + .unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut stream = + StreamOwned::new(ServerConnection::new(Arc::new(config)).unwrap(), socket); + let mut header = Vec::new(); + let mut byte = [0_u8; 1]; + while !header.ends_with(b"\r\n\r\n") { + if stream.read_exact(&mut byte).is_err() { + return false; + } + header.push(byte[0]); + assert!(header.len() <= MAX_PROXY_HEADERS); + } + assert!(header.starts_with(b"CONNECT example.com:443 HTTP/1.1\r\n")); + stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nping") + .unwrap(); + stream.flush().unwrap(); + let mut reply = [0_u8; 4]; + stream.read_exact(&mut reply).unwrap(); + assert_eq!(&reply, b"pong"); + true + }); + let proxy = ProxyConfig { + kind: ProxyKind::Https, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + let result = TcpConnection::connect_with_tls_factory( + "example.com:443", + Duration::from_secs(2), + Some(&proxy), + |_| Ok(()), + || Ok(()), + || { + let mut roots = RootCertStore::empty(); + if trusted { + roots.add(der).unwrap(); + } + Ok(ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth()) + }, + ); + let connected = result.is_ok(); + if let Ok(mut connection) = result { + let mut payload = [0_u8; 4]; + connection.stream.read_exact(&mut payload).unwrap(); + assert_eq!(&payload, b"ping"); + connection.send(b"pong").unwrap(); + connection.stream.flush().unwrap(); + } + (connected, server.join().unwrap()) + } + + #[test] + fn https_proxy_trusted_certificate_tunnels_bidirectionally() { + assert_eq!(exercise_https_certificate("127.0.0.1", true), (true, true)); + } + + #[test] + fn https_proxy_untrusted_certificate_never_sends_connect() { + assert_eq!( + exercise_https_certificate("127.0.0.1", false), + (false, false) + ); + } + + #[test] + fn https_proxy_hostname_mismatch_never_sends_connect() { + assert_eq!( + exercise_https_certificate("wrong.example", true), + (false, false) + ); + } + + #[test] + fn http_connect_rejects_unterminated_oversized_headers() { + use std::net::TcpListener; + use std::thread; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + stream + .write_all(&vec![b'X'; MAX_PROXY_HEADERS + 1]) + .unwrap(); + }); + let config = ProxyConfig { + kind: ProxyKind::Http, + host: "127.0.0.1".into(), + port: address.port(), + username: None, + password: None, + secret: None, + }; + let result = + TcpConnection::connect("example.com:443", Duration::from_secs(1), Some(&config)); + assert!( + matches!(result, Err(ProxyError::Handshake(message)) if message.contains("too large")) + ); + server.join().unwrap(); + } diff --git a/native/mtproto/build.gradle.kts b/native/mtproto/build.gradle.kts index fae594293..1078488ed 100644 --- a/native/mtproto/build.gradle.kts +++ b/native/mtproto/build.gradle.kts @@ -35,8 +35,16 @@ android { } } +val platformVerifierVersion = providers.fileContents( + rootProject.layout.projectDirectory.file("native/mtproto-rs/Cargo.lock"), +).asText.map { lock -> + Regex("""name = "rustls-platform-verifier-android"\s+version = "([^"]+)"""").find(lock)?.groupValues?.get(1) + ?: error("rustls-platform-verifier-android not found in Cargo.lock") +} + dependencies { implementation(libs.androidx.core.ktx) + implementation(platformVerifierVersion.map { "org.rustls:rustls-platform-verifier:$it" }) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) // Android needs the AAR (ships libjnidispatch.so); plain JAR is desktop-only. @@ -44,6 +52,7 @@ dependencies { } val rustCrate = rootProject.layout.projectDirectory.dir("native/mtproto-rs") +val transportCrate = rootProject.layout.projectDirectory.dir("native/mtproto-transport") val jniLibs = layout.projectDirectory.dir("src/main/jniLibs") val skipNativeBuild = providers.gradleProperty("skipNativeBuild").map { it.toBoolean() }.orElse(false) @@ -113,6 +122,8 @@ val buildNativeMtproto = rustCrate.file("build.rs"), ) inputs.dir(rustCrate.dir("src")) + inputs.file(transportCrate.file("Cargo.toml")) + inputs.dir(transportCrate.dir("src")) inputs.property("abis", abis) outputs.files( abis.map { jniLibs.file("$it/libmonogram_mtproto.so") }, @@ -169,6 +180,8 @@ val buildHostUniffiMtproto = rustCrate.file("build.rs"), ) inputs.dir(rustCrate.dir("src")) + inputs.file(transportCrate.file("Cargo.toml")) + inputs.dir(transportCrate.dir("src")) outputs.file(hostUniffiLib) enabled = !skipNativeBuild.get() } diff --git a/native/mtproto/consumer-rules.pro b/native/mtproto/consumer-rules.pro index 43c2032da..5ea1afd4c 100644 --- a/native/mtproto/consumer-rules.pro +++ b/native/mtproto/consumer-rules.pro @@ -3,4 +3,5 @@ -keep class uniffi.monogram_mtproto.** { *; } -keepclassmembers class uniffi.monogram_mtproto.** { *; } -keep class org.monogram.mtproto.** { *; } +-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; } -dontwarn java.awt.** diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt index 9baf53562..1c4ae8fa2 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt @@ -35,6 +35,11 @@ import uniffi.monogram_mtproto.PollVotersDto * Low-level UniFFI surface backed by the Tellers MTProto runtime. */ interface MtprotoNative { + fun setTransportMode(mode: String) = Unit + fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = Unit + fun pingProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray): Long = + throw UnsupportedOperationException("proxy ping") + fun clearProxy() = Unit fun createRequestControl(): Long = 0L fun bindRequestControl(id: Long): Long = 0L fun cancelRequestControl(id: Long) = Unit diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt index c495a448a..c3aab0973 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeLoader.kt @@ -1,11 +1,24 @@ package org.monogram.mtproto +import android.content.Context import android.util.Log object MtprotoNativeLoader { @Volatile private var cached: MtprotoNative? = null + private var platformVerifierInitialized = false + + @Synchronized + fun initializePlatformVerifier(context: Context) { + if (platformVerifierInitialized || loadOrStub() === MtprotoNative.Stub) return + System.loadLibrary("monogram_mtproto") + initPlatformVerifier(context.applicationContext) + platformVerifierInitialized = true + } + + private external fun initPlatformVerifier(context: Context) + /** * Loads the real UniFFI backend. Falls back to [MtprotoNative.Stub] only if the * native library cannot initialize; Stub then fails all network calls explicitly. diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeStub.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeStub.kt index 58e87f75d..e37b8c8e9 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeStub.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeStub.kt @@ -18,6 +18,10 @@ import uniffi.monogram_mtproto.UploadItemDto object MtprotoNativeStub : MtprotoNative { override fun libraryVersion(): String = "stub-0.0.1" + override fun setTransportMode(mode: String) = Unit + override fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = Unit + override fun clearProxy() = Unit + override fun createClient(apiId: Int, apiHash: String, sessionPath: String): Long = 1L override fun isAuthorized(handle: Long): Boolean = false diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt index ef6d233f3..7a83177f3 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt @@ -36,6 +36,7 @@ import uniffi.monogram_mtproto.clearActiveDialog as nativeClearActiveDialog import uniffi.monogram_mtproto.clientApiId as nativeClientApiId import uniffi.monogram_mtproto.clientExists as nativeClientExists import uniffi.monogram_mtproto.connect as nativeConnect +import uniffi.monogram_mtproto.clearProxy as nativeClearProxy import uniffi.monogram_mtproto.contactsSearch as nativeContactsSearch import uniffi.monogram_mtproto.createEncryptedClient as nativeCreateEncryptedClient import uniffi.monogram_mtproto.customEmojiIsFree as nativeCustomEmojiIsFree @@ -52,6 +53,9 @@ import uniffi.monogram_mtproto.forwardMessages as nativeForwardMessages import uniffi.monogram_mtproto.getAllStickers as nativeGetAllStickers import uniffi.monogram_mtproto.getBotCallbackAnswer as nativeGetBotCallbackAnswer import uniffi.monogram_mtproto.getChats as nativeGetChats +import uniffi.monogram_mtproto.pingProxy as nativePingProxy +import uniffi.monogram_mtproto.setProxy as nativeSetProxy +import uniffi.monogram_mtproto.setTransportMode as nativeSetTransportMode import uniffi.monogram_mtproto.getCommonChats as nativeGetCommonChats import uniffi.monogram_mtproto.getDiscussionMessage as nativeGetDiscussionMessage import uniffi.monogram_mtproto.getEmojiStickers as nativeGetEmojiStickers @@ -167,6 +171,20 @@ object MtprotoNativeUniFfi : MtprotoNative { uniffiEnsureInitialized() } + override fun setTransportMode(mode: String) = nativeSetTransportMode(mode) + override fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = nativeSetProxy(kind, host, port.toUShort(), username, password, secret) + + override fun pingProxy( + kind: String, + host: String, + port: Int, + username: String?, + password: String?, + secret: ByteArray, + ): Long = nativePingProxy(kind, host, port.toUShort(), username, password, secret) + + override fun clearProxy() = nativeClearProxy() + override fun libraryVersion(): String = nativeLibraryVersion() override fun perfSetEnabled(enabled: Boolean) = diff --git a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt index e05d64dab..b3a78df55 100644 --- a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt +++ b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt @@ -708,6 +708,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_clear_active_dialog( ): Int + external fun uniffi_monogram_mtproto_checksum_func_clear_proxy( + ): Int external fun uniffi_monogram_mtproto_checksum_func_client_api_id( ): Int external fun uniffi_monogram_mtproto_checksum_func_client_exists( @@ -868,6 +870,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_perf_snapshot( ): Int + external fun uniffi_monogram_mtproto_checksum_func_ping_proxy( + ): Int external fun uniffi_monogram_mtproto_checksum_func_read_discussion( ): Int external fun uniffi_monogram_mtproto_checksum_func_read_history( @@ -930,6 +934,10 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_set_file_part_kib( ): Int + external fun uniffi_monogram_mtproto_checksum_func_set_proxy( + ): Int + external fun uniffi_monogram_mtproto_checksum_func_set_transport_mode( + ): Int external fun uniffi_monogram_mtproto_checksum_func_set_typing( ): Int external fun uniffi_monogram_mtproto_checksum_func_sign_in( @@ -980,6 +988,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_clear_active_dialog(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_monogram_mtproto_fn_func_clear_proxy(uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_monogram_mtproto_fn_func_client_api_id(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_client_exists(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1140,6 +1150,8 @@ internal object UniffiLib { ): Unit external fun uniffi_monogram_mtproto_fn_func_perf_snapshot(`reset`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_ping_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Long external fun uniffi_monogram_mtproto_fn_func_read_discussion(`handle`: Long,`chatId`: Long,`msgId`: Int,`readMaxId`: Int,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_monogram_mtproto_fn_func_read_history(`handle`: Long,`chatId`: Long,`maxId`: Int,uniffi_out_err: UniffiRustCallStatus, @@ -1202,6 +1214,10 @@ internal object UniffiLib { ): Unit external fun uniffi_monogram_mtproto_fn_func_set_file_part_kib(`kib`: Int,uniffi_out_err: UniffiRustCallStatus, ): Unit + external fun uniffi_monogram_mtproto_fn_func_set_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + external fun uniffi_monogram_mtproto_fn_func_set_transport_mode(`mode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_monogram_mtproto_fn_func_set_typing(`handle`: Long,`chatId`: Long,`typing`: Byte,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_monogram_mtproto_fn_func_sign_in(`handle`: Long,`phone`: RustBuffer.ByValue,`phoneCodeHash`: RustBuffer.ByValue,`phoneCode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1360,6 +1376,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_clear_active_dialog() and 0xFFFF) != 35684) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_clear_proxy() and 0xFFFF) != 9892) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_client_api_id() and 0xFFFF) != 53318) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1600,6 +1619,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_perf_snapshot() and 0xFFFF) != 54140) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_ping_proxy() and 0xFFFF) != 46980) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_read_discussion() and 0xFFFF) != 59067) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1693,6 +1715,12 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_set_file_part_kib() and 0xFFFF) != 34374) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_set_proxy() and 0xFFFF) != 39093) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if ((lib.uniffi_monogram_mtproto_checksum_func_set_transport_mode() and 0xFFFF) != 17640) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_set_typing() and 0xFFFF) != 45608) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1847,6 +1875,33 @@ public abstract class FfiConverterCallbackInterface: Ffi } } +/** + * @suppress + */ +public object FfiConverterUShort: FfiConverter { + override fun lift(value: Short): UShort { + return value.toUShort() + } + + fun lift(value: Int): UShort { + return value.toUShort() + } + + override fun read(buf: ByteBuffer): UShort { + return lift(buf.getShort()) + } + + override fun lower(value: UShort): Short { + return value.toShort() + } + + override fun allocationSize(value: UShort) = 2UL + + override fun write(value: UShort, buf: ByteBuffer) { + buf.putShort(value.toShort()) + } +} + /** * @suppress */ @@ -5974,6 +6029,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_clear_proxy( + + _status) +} + + fun `clientApiId`(`handle`: kotlin.ULong): kotlin.Int { return FfiConverterInt.lift( uniffiRustCall() { _status -> @@ -7055,6 +7120,23 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_ping_proxy( + + + FfiConverterString.lower(`kind`), + FfiConverterString.lower(`host`), + FfiConverterUShort.lower(`port`), + FfiConverterOptionalString.lower(`username`), + FfiConverterOptionalString.lower(`password`), + FfiConverterByteArray.lower(`secret`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int, `readMaxId`: kotlin.Int) = uniffiRustCallWithError(MtprotoException) { _status -> @@ -7505,6 +7587,33 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_set_proxy( + + + FfiConverterString.lower(`kind`), + FfiConverterString.lower(`host`), + FfiConverterUShort.lower(`port`), + FfiConverterOptionalString.lower(`username`), + FfiConverterOptionalString.lower(`password`), + FfiConverterByteArray.lower(`secret`),_status) +} + + + + @Throws(MtprotoException::class) fun `setTransportMode`(`mode`: kotlin.String) + = + uniffiRustCallWithError(MtprotoException) { _status -> + UniffiLib.uniffi_monogram_mtproto_fn_func_set_transport_mode( + + + FfiConverterString.lower(`mode`),_status) +} + + + @Throws(MtprotoException::class) fun `setTyping`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `typing`: kotlin.Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/ProxyConfig.kt b/network/bridge/src/main/java/org/monogram/network/bridge/ProxyConfig.kt new file mode 100644 index 000000000..4453919b1 --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/ProxyConfig.kt @@ -0,0 +1,123 @@ +package org.monogram.network.bridge + +import android.util.Base64 +import org.monogram.core.common.Outcome + +enum class ProxyType { NONE, SOCKS5, HTTP, HTTPS, MTPROTO } + +enum class MtprotoTransportMode { PADDED_INTERMEDIATE, HTTP } + +data class ProxyConfig( + val type: ProxyType, + val host: String, + val port: Int, + val username: String? = null, + val password: String? = null, + val secret: ByteArray = byteArrayOf(), +) { + override fun toString(): String = "ProxyConfig(type=$type, [REDACTED])" + + fun validate(): Outcome { + if (type == ProxyType.NONE) return Outcome.Ok(Unit) + if (host.isEmpty() || host.length > 255 || host.any { it <= ' ' }) return Outcome.Err("proxy host is invalid", IllegalArgumentException("proxy host is invalid")) + if (port !in 1..65535) return Outcome.Err("proxy port is invalid", IllegalArgumentException("proxy port is invalid")) + if ((username == null) != (password == null)) return Outcome.Err("proxy credentials are incomplete", IllegalArgumentException("proxy credentials are incomplete")) + if (username != null && (username.length > 255 || password!!.length > 255)) return Outcome.Err("proxy credentials are too long", IllegalArgumentException("proxy credentials are too long")) + if (username != null && (username.any { it.code < 0x20 || it.code == 0x7f } || password!!.any { it.code < 0x20 || it.code == 0x7f })) return Outcome.Err("proxy credentials contain control characters", IllegalArgumentException("proxy credentials contain control characters")) + if (type == ProxyType.MTPROTO) { + if (username != null || password != null) { + return Outcome.Err("MTProto proxy credentials are not supported", IllegalArgumentException("MTProto proxy credentials are not supported")) + } + val classic = secret.size == 16 + val randomPadding = secret.size == 17 && secret[0] == 0xdd.toByte() + val fakeTls = secret.size in 18..199 && secret[0] == 0xee.toByte() && + secret.copyOfRange(17, secret.size).toString(Charsets.US_ASCII).let { domain -> + domain.toByteArray(Charsets.US_ASCII).contentEquals(secret.copyOfRange(17, secret.size)) && + domain.length <= 182 && domain.split('.').all { label -> + label.isNotEmpty() && label.length <= 63 && + label.first().isLetterOrDigit() && label.last().isLetterOrDigit() && + label.all { it.isLetterOrDigit() || it == '-' } + } + } + if (!classic && !randomPadding && !fakeTls) { + return Outcome.Err("MTProto secret encoding is invalid", IllegalArgumentException("MTProto secret encoding is invalid")) + } + } + if (type != ProxyType.MTPROTO && secret.isNotEmpty()) return Outcome.Err("secret is only valid for MTProto proxy", IllegalArgumentException("secret is only valid for MTProto proxy")) + return Outcome.Ok(Unit) + } +} + +fun decodeProxySecret(value: String): ByteArray? { + if (value.isEmpty()) return null + if (value.length % 2 == 0 && value.all { it.digitToIntOrNull(16) != null }) { + return ByteArray(value.length / 2) { index -> + value.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + if (value.any { it.isWhitespace() || it == '+' || it == '/' || it == '=' }) return null + return runCatching { + val decoded = Base64.decode(value, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val canonical = Base64.encodeToString(decoded, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + decoded.takeIf { canonical == value } + }.getOrNull() +} + +fun decodeProxySecretHex(value: String): ByteArray? = + decodeProxySecret(value)?.takeIf { value.length % 2 == 0 && value.all { it.digitToIntOrNull(16) != null } } + + + +/** Parses Telegram MTProto proxy links without retaining the original URL. */ +fun parseTelegramProxyLink(value: String): ProxyConfig? { + val input = value.trim() + if (input.isEmpty() || input.length > 4096) return null + val uri = runCatching { java.net.URI(input) }.getOrNull() ?: return null + val path = uri.path.orEmpty().trimEnd('/') + val isTelegramProxy = (uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) && + (uri.host.equals("t.me", true) || uri.host.equals("telegram.me", true)) && + path.equals("/proxy", true) + val isTelegramScheme = uri.scheme.equals("tg", true) && (path.equals("/proxy", true) || uri.host.equals("proxy", true)) + if (!isTelegramProxy && !isTelegramScheme) return null + val query = uri.rawQuery ?: return null + val values = query.split('&').mapNotNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@mapNotNull null + val key = runCatching { java.net.URLDecoder.decode(pair.substring(0, separator), Charsets.UTF_8.name()) }.getOrNull() + val valuePart = runCatching { java.net.URLDecoder.decode(pair.substring(separator + 1), Charsets.UTF_8.name()) }.getOrNull() + if (key == null || valuePart == null) null else key to valuePart + }.toMap() + val host = values["server"]?.trim()?.takeIf { it.isNotEmpty() && it.length <= 255 } ?: return null + val port = values["port"]?.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null + val secretText = values["secret"]?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val secret = decodeProxySecret(secretText)?.takeIf { it.size in 16..199 } ?: return null + val config = ProxyConfig(ProxyType.MTPROTO, host, port, secret = secret) + return config.takeIf { it.validate() is Outcome.Ok } +} +/** Parses supported proxy URIs and Telegram share links into a canonical contract. */ +fun parseProxyImport(value: String): ProxyConfig? { + parseTelegramProxyLink(value)?.let { return it } + val input = value.trim().takeIf { it.length in 1..4096 } ?: return null + val uri = runCatching { java.net.URI(input) }.getOrNull() ?: return null + val type = when (uri.scheme?.lowercase()) { + "mtproto" -> ProxyType.MTPROTO + "socks5", "socks" -> ProxyType.SOCKS5 + "http" -> ProxyType.HTTP + "https" -> ProxyType.HTTPS + else -> return null + } + val host = uri.host?.takeIf { it.isNotBlank() } ?: return null + val port = uri.port.takeIf { it in 1..65535 } ?: when (type) { + ProxyType.SOCKS5 -> 1080 + ProxyType.HTTP -> 8080 + ProxyType.HTTPS, ProxyType.MTPROTO -> 443 + ProxyType.NONE -> return null + } + val user = uri.userInfo?.substringBefore(':')?.takeIf { it.isNotEmpty() } + val password = uri.userInfo?.substringAfter(':', "")?.takeIf { uri.userInfo.contains(':') } + val secretText = uri.rawQuery.orEmpty().split('&').firstOrNull { it.substringBefore('=') == "secret" } + ?.substringAfter('=') ?: uri.userInfo?.takeIf { type == ProxyType.MTPROTO } + val secret = if (type == ProxyType.MTPROTO) secretText?.let(::decodeProxySecret) else null + val config = ProxyConfig(type, host, port, user, password, secret ?: byteArrayOf()) + return config.takeIf { it.validate() is Outcome.Ok } +} diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt index b49fd38f3..b2fa986a1 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt @@ -27,6 +27,8 @@ import org.monogram.core.models.AuthState import org.monogram.core.models.PeerId import org.monogram.mtproto.MtprotoNative import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig import uniffi.monogram_mtproto.MtprotoException internal class SessionCore( @@ -45,6 +47,8 @@ internal class SessionCore( @Volatile internal var connectedHandle: Long = 0L @Volatile + private var proxyState: ProxyConnectionStatus = ProxyConnectionStatus.Disabled + @Volatile private var updatesStartedHandle: Long = 0L private val updatesStartMutex = Mutex() internal val connectMutex = Mutex() @@ -317,6 +321,7 @@ internal class SessionCore( return Outcome.Ok(Unit) } AppLog.api("connect", "start handle=$locked") + if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Connecting val connected = rpc("connect failed") { activeHandle -> refreshDcSidecar(sessionPath) try { @@ -324,13 +329,17 @@ internal class SessionCore( if (isCurrentHandle(activeHandle)) connectedHandle = activeHandle } catch (e: Exception) { connectedHandle = 0L + if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Failed throw e } finally { DcTxtBootstrap.logNativeStatus(sessionPath) } activeHandle } - if (connected is Outcome.Ok) maybeStartUpdates(connected.value) + if (connected is Outcome.Ok) { + if (nativeProxyConfigured()) proxyState = ProxyConnectionStatus.Connected + maybeStartUpdates(connected.value) + } return when (connected) { is Outcome.Ok -> Outcome.Ok(Unit) is Outcome.Err -> connected @@ -550,6 +559,76 @@ internal class SessionCore( resetHandle() } + override fun configureProxy(config: ProxyConfig): Outcome { + when (val valid = config.validate()) { + is Outcome.Err -> return valid + is Outcome.Ok -> Unit + } + return try { + if (config.type == org.monogram.network.bridge.ProxyType.NONE) { + native.clearProxy() + proxyState = ProxyConnectionStatus.Disabled + } else { + native.setProxy(config.type.name.lowercase(), config.host, config.port, config.username, config.password, config.secret.copyOf()) + proxyState = ProxyConnectionStatus.Disconnected + } + resetHandle() + Outcome.Ok(Unit) + } catch (error: Throwable) { + Outcome.Err(error.message ?: "proxy configuration failed", error) + } + } + + override fun setTransportMode(mode: MtprotoTransportMode): Outcome = try { + native.setTransportMode( + when (mode) { + MtprotoTransportMode.PADDED_INTERMEDIATE -> "padded_intermediate" + MtprotoTransportMode.HTTP -> "http" + }, + ) + resetHandle() + Outcome.Ok(Unit) + } catch (error: Throwable) { + Outcome.Err(error.message ?: "transport mode configuration failed", error) + } + + override suspend fun pingProxy(config: ProxyConfig): Outcome { + when (val valid = config.validate()) { + is Outcome.Err -> return valid + is Outcome.Ok -> Unit + } + if (config.type == org.monogram.network.bridge.ProxyType.NONE) { + return Outcome.Err("proxy is disabled") + } + return withContext(nativeDispatcher) { + try { + Outcome.Ok( + native.pingProxy( + config.type.name.lowercase(), + config.host, + config.port, + config.username, + config.password, + config.secret.copyOf(), + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Outcome.Err(error.message ?: "proxy ping failed", error) + } + } + } + + override fun clearProxy(): Outcome = try { + native.clearProxy() + proxyState = ProxyConnectionStatus.Disabled + resetHandle() + Outcome.Ok(Unit) + } catch (error: Throwable) { + Outcome.Err(error.message ?: "proxy configuration failed", error) + } + override fun close() { synchronized(handleLock) { if (closed) return @@ -565,8 +644,13 @@ internal class SessionCore( handle.also { handle = 0L } } connectedHandle = 0L + if (proxyState == ProxyConnectionStatus.Connected) proxyState = ProxyConnectionStatus.Disconnected if (updatesStartedHandle == oldHandle) updatesStartedHandle = 0L if (oldHandle != 0L) native.destroyClient(oldHandle) updatesWake.trySend(Unit) } + + override fun proxyConnectionStatus(): ProxyConnectionStatus = proxyState + + private fun nativeProxyConfigured(): Boolean = proxyState != ProxyConnectionStatus.Disabled } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt index a9d160a39..0891de955 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt @@ -2,8 +2,18 @@ package org.monogram.network.bridge.session import org.monogram.core.common.Outcome import org.monogram.core.models.AuthState +import org.monogram.network.bridge.MtprotoTransportMode +import org.monogram.network.bridge.ProxyConfig + +enum class ProxyConnectionStatus { Disabled, Disconnected, Connecting, Connected, Failed } interface SessionOps { + fun proxyConnectionStatus(): ProxyConnectionStatus = ProxyConnectionStatus.Disabled + fun setTransportMode(mode: MtprotoTransportMode): Outcome = Outcome.Ok(Unit) + fun configureProxy(config: ProxyConfig): Outcome = Outcome.Ok(Unit) + fun clearProxy(): Outcome = Outcome.Ok(Unit) + suspend fun pingProxy(config: ProxyConfig): Outcome = + Outcome.Err("proxy ping unavailable") suspend fun isLocallyAuthorized(): Outcome = Outcome.Ok(false) suspend fun isAuthorized(): Outcome = Outcome.Ok(false) diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/ProxyConfigImportTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/ProxyConfigImportTest.kt new file mode 100644 index 000000000..ec02ca456 --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/ProxyConfigImportTest.kt @@ -0,0 +1,31 @@ +package org.monogram.network.bridge + +import org.junit.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull + +class ProxyConfigImportTest { + @Test + fun parsesTelegramLinkWithoutRetainingUrl() { + val config = parseProxyImport("https://t.me/proxy?server=example.com&port=443&secret=0123456789abcdef0123456789abcdef") + assertNotNull(config) + assertEquals(ProxyType.MTPROTO, config!!.type) + assertEquals("example.com", config!!.host) + assertEquals(16, config!!.secret.size) + } + + @Test + fun parsesHttpAndSocksUris() { + assertEquals(ProxyType.HTTP, parseProxyImport("http://user:pass@example.com:8080")?.type) + assertEquals(ProxyType.SOCKS5, parseProxyImport("socks5://example.com:1080")?.type) + } + + @Test + fun rejectsUnsupportedOrInvalidSecret() { + assertNull(parseProxyImport("ftp://example.com:21")) + assertNull(parseProxyImport("mtproto://example.com:443?secret=bad")) + } +} + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 1c0d8222e..9eec1a726 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,14 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + exclusiveContent { + forRepository { + maven { + url = uri("https://raw.githubusercontent.com/rustls/rustls-platform-verifier/maven-archive/android-release-support/maven/") + } + } + filter { includeGroup("org.rustls") } + } } }