diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a41528c91..9dd93b14d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -26,18 +26,6 @@ jobs: with: submodules: recursive - - name: Checkout Tellers MTProto - uses: actions/checkout@v5 - with: - repository: gdlbo/telers-mtproto-impl - path: .ci/telers-mtproto-impl - persist-credentials: false - - - name: Link Tellers MTProto crate - run: | - ln -sfn "$GITHUB_WORKSPACE/.ci/telers-mtproto-impl" "$GITHUB_WORKSPACE/../telers-mtproto-impl" - test -f "$GITHUB_WORKSPACE/../telers-mtproto-impl/crates/tellers-mtproto/Cargo.toml" - - name: JDK 17 uses: actions/setup-java@v5 with: diff --git a/.gitmodules b/.gitmodules index 480aff13e..a8c7305b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ -[submodule "tellers-tl"] - path = vendor/tellers-tl - url = https://github.com/gdlbo/tellers-tl.git -[submodule "telers-mtproto-impl"] - path = vendor/telers-mtproto-impl - url = https://github.com/gdlbo/telers-mtproto-impl [submodule "tlottie"] path = vendor/tlottie url = https://github.com/dkaraush/tlottie.git +[submodule "mono-rs"] + path = vendor/mono-rs + url = https://github.com/monogram-android/mono-rs.git diff --git a/AGENTS.md b/AGENTS.md index d8af950fa..0baf8f309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ untracked. Initialize submodules when needed: git submodule update --init --recursive ``` -The MTProto crate may also require `../telers-mtproto-impl`. +The MTProto crate uses the pinned `vendor/mono-rs` submodule through local path dependencies. Initialize submodules with `git submodule update --init --recursive`. ## References @@ -148,8 +148,8 @@ The MTProto crate may also require `../telers-mtproto-impl`. | Files and references | https://core.telegram.org/api/files and https://core.telegram.org/api/file-references | | Folders and offsets | https://core.telegram.org/api/folders and https://core.telegram.org/api/offsets | | API id and terms | https://core.telegram.org/api/obtaining_api_id and https://core.telegram.org/api/terms | -| Tellers TL | https://github.com/gdlbo/tellers-tl | -| Tellers MTProto runtime | https://github.com/gdlbo/telers-mtproto-impl | +| Telegram TL schemas | https://github.com/monogram-android/mono-rs/blob/main/docs/THIRD_PARTY_NOTICES.md | +| MTProto runtime | https://github.com/monogram-android/mono-rs | | Telegram Android | https://github.com/DrKLO/Telegram | | Grammers | https://github.com/LonamiWebs/grammers | | Telegram Desktop | https://github.com/telegramdesktop/tdesktop | diff --git a/README.md b/README.md index 992c53999..8601f78e4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram is a blazing-fast, fully native Telegram client for Android 7.0 and newer. Built with Kotlin, Jetpack Compose, and Material 3, it pairs a modern Android interface with our own MTProto implementation in Rust

+

Monogram is a blazing-fast, fully native Telegram client for Android 7.0 and newer. Built with Kotlin, Jetpack Compose, and Material 3, it pairs a modern Android interface with the independent mono-rs MTProto library.

> [!IMPORTANT] > Monogram is in **active development**. Features and architecture are still evolving; bugs and incomplete behavior are possible. @@ -35,7 +35,7 @@ Support the project on [Boosty](https://boosty.to/monogram) - **Fully native** — Built for Android with Kotlin and Jetpack Compose - **Made for your screen** — Material 3 layouts that adapt to phones, tablets, and larger displays - **Your chats, with media** — Photos, videos, and animated stickers in your conversations -- **Blazingly fast, powered by Rust** — Our own native implementation of Telegram's MTProto protocol 🚀 +- **Blazingly fast, powered by Rust** — Uses the independent [mono-rs](https://github.com/monogram-android/mono-rs) implementation of Telegram's MTProto protocol - **No NFT or crypto** — Monogram will not include NFT promotions, gifts, or other Telegram features that we consider outside the scope of a messaging app ## Build from source @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -Run both clone commands from the same parent directory. The current Cargo manifests require `../telers-mtproto-impl` next to the Monogram checkout; the submodule under `vendor` does not replace this sibling checkout. CI uses the same layout. +The `vendor/mono-rs` submodule provides the pinned MTProto workspace locally. Initialize all submodules with `git submodule update --init --recursive`. ### 3. Configure local settings diff --git a/README_ES.md b/README_ES.md index c1f77d99a..db06897b5 100644 --- a/README_ES.md +++ b/README_ES.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram es un cliente de Telegram rapidísimo y completamente nativo para Android 7.0 y versiones posteriores. Desarrollado con Kotlin, Jetpack Compose y Material 3, combina una interfaz Android moderna con nuestra propia implementación de MTProto en Rust

+

Monogram es un cliente de Telegram rapidísimo y completamente nativo para Android 7.0 y versiones posteriores. Desarrollado con Kotlin, Jetpack Compose y Material 3, combina una interfaz Android moderna con la biblioteca independiente mono-rs para MTProto.

> [!IMPORTANT] > Monogram está en **desarrollo activo**. Las funciones y la arquitectura siguen evolucionando; puede haber errores y funciones incompletas. @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -Ejecuta ambos comandos de clonación desde el mismo directorio padre. Los manifiestos actuales de Cargo requieren `../telers-mtproto-impl` junto al directorio de Monogram; el submódulo de `vendor` no sustituye esta copia adyacente. CI utiliza la misma estructura. +El submódulo `vendor/mono-rs` proporciona localmente el espacio de trabajo MTProto fijado. Inicializa todos los submódulos con `git submodule update --init --recursive`. ### 3. Configurar los ajustes locales diff --git a/README_KOR.md b/README_KOR.md index 21039c49e..70bfa0780 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram은 Android 7.0 이상을 위한 매우 빠른 완전 네이티브 Telegram 클라이언트입니다. Kotlin, Jetpack Compose, Material 3로 만든 현대적인 Android 인터페이스와 Rust로 직접 구현한 MTProto를 결합했습니다

+

Monogram은 Android 7.0 이상을 위한 매우 빠른 완전 네이티브 Telegram 클라이언트입니다. Kotlin, Jetpack Compose, Material 3로 만든 현대적인 Android 인터페이스에서 독립 mono-rs MTProto 라이브러리를 사용합니다.

> [!IMPORTANT] > Monogram은 **활발히 개발 중**입니다. 기능과 아키텍처가 계속 바뀌고 있으며, 버그나 미완성 기능이 있을 수 있습니다. @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -같은 상위 디렉터리에서 두 clone 명령을 실행하세요. 현재 Cargo 매니페스트는 Monogram 디렉터리 옆의 `../telers-mtproto-impl`을 요구합니다. `vendor`의 서브모듈은 이 별도 체크아웃을 대체하지 않습니다. CI도 같은 디렉터리 구성을 사용합니다. +`vendor/mono-rs` 서브모듈이 고정된 MTProto 작업 공간을 로컬로 제공합니다. 모든 서브모듈은 `git submodule update --init --recursive`로 초기화하세요. ### 3. 로컬 설정 diff --git a/README_RU.md b/README_RU.md index a60a368bc..19559c796 100644 --- a/README_RU.md +++ b/README_RU.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram — молниеносный, полностью нативный клиент Telegram для Android 7.0 и новее. Kotlin, Jetpack Compose и Material 3 сочетают современный Android-интерфейс с нашей собственной реализацией MTProto на Rust

+

Monogram — молниеносный, полностью нативный клиент Telegram для Android 7.0 и новее. Kotlin, Jetpack Compose и Material 3 сочетают современный Android-интерфейс с независимой библиотекой mono-rs для MTProto.

> [!IMPORTANT] > Monogram находится в **активной разработке**. Возможности и архитектура продолжают меняться; возможны ошибки и неполная реализация отдельных функций. @@ -35,7 +35,7 @@ - **Полностью нативный** — Создан для Android на Kotlin и Jetpack Compose - **Под ваш экран** — Интерфейс Material 3 адаптируется к телефонам, планшетам и большим экранам - **Общение с медиа** — Фотографии, видео и анимированные стикеры в переписке -- **Молниеносный, благодаря Rust** — Собственная нативная реализация протокола Telegram MTProto 🚀 +- **Молниеносный, благодаря Rust** — Использует независимую библиотеку [mono-rs](https://github.com/monogram-android/mono-rs) для протокола Telegram MTProto - **Без NFT и криптовалют** — Monogram не будет включать продвижение NFT, подарки и другие функции Telegram, которые мы считаем выходящими за рамки приложения для обмена сообщениями ## Сборка из исходников @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -Выполните обе команды клонирования из одного родительского каталога. Текущие манифесты Cargo ожидают `../telers-mtproto-impl` рядом с каталогом Monogram; подмодуль в `vendor` не заменяет эту соседнюю копию. В CI используется такое же расположение. +Подмодуль `vendor/mono-rs` локально предоставляет закреплённое рабочее пространство MTProto. Инициализируйте все подмодули командой `git submodule update --init --recursive`. ### 3. Локальные настройки diff --git a/README_TR.md b/README_TR.md index 25d26139d..8bbfdd4e7 100644 --- a/README_TR.md +++ b/README_TR.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram, Android 7.0 ve üzeri için yıldırım hızında, tamamen yerel bir Telegram istemcisidir. Kotlin, Jetpack Compose ve Material 3 ile geliştirilen modern Android arayüzünü Rust ile yazdığımız kendi MTProto uygulamamızla bir araya getirir

+

Monogram, Android 7.0 ve üzeri için yıldırım hızında, tamamen yerel bir Telegram istemcisidir. Kotlin, Jetpack Compose ve Material 3 ile geliştirilen modern Android arayüzü, bağımsız mono-rs MTProto kütüphanesini kullanır.

> [!IMPORTANT] > Monogram **aktif geliştirme** aşamasındadır. Özellikler ve mimari gelişmeye devam etmektedir; hatalar ve eksik işlevler olabilir. @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -Her iki klonlama komutunu aynı üst dizinden çalıştırın. Mevcut Cargo bildirimleri, Monogram dizininin yanında `../telers-mtproto-impl` bulunmasını gerektirir; `vendor` altındaki alt modül bu komşu kopyanın yerini tutmaz. CI da aynı dizin düzenini kullanır. +`vendor/mono-rs` alt modülü, sabitlenen MTProto çalışma alanını yerel olarak sağlar. Tüm alt modülleri `git submodule update --init --recursive` komutuyla başlatın. ### 3. Yerel ayarları yapılandırma diff --git a/README_UR.md b/README_UR.md index af5248616..6f354a210 100644 --- a/README_UR.md +++ b/README_UR.md @@ -13,7 +13,7 @@

English · Русский · Türkçe · 한국어 · اُردو · Español

-

Monogram، Android 7.0 اور اس سے نئے ورژنز کے لیے بجلی کی طرح تیز، مکمل طور پر نیٹو Telegram کلائنٹ ہے۔ Kotlin، Jetpack Compose اور Material 3 سے بنایا گیا جدید Android انٹرفیس، Rust میں ہماری اپنی MTProto implementation کے ساتھ کام کرتا ہے

+

Monogram، Android 7.0 اور اس سے نئے ورژنز کے لیے بجلی کی طرح تیز، مکمل طور پر نیٹو Telegram کلائنٹ ہے۔ Kotlin، Jetpack Compose اور Material 3 سے بنایا گیا جدید Android انٹرفیس آزاد mono-rs MTProto لائبریری استعمال کرتا ہے۔

> [!IMPORTANT] > Monogram کی **فعال ترقی جاری ہے**۔ خصوصیات اور ساخت میں تبدیلیاں ہو رہی ہیں؛ خرابیاں اور نامکمل خصوصیات موجود ہو سکتی ہیں۔ @@ -56,12 +56,11 @@ cargo install cargo-ndk --locked ```console git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git cd monogram git submodule update --init --recursive ``` -دونوں clone کمانڈز ایک ہی بنیادی ڈائریکٹری سے چلائیں۔ موجودہ Cargo manifests کو Monogram کی ڈائریکٹری کے ساتھ `../telers-mtproto-impl` درکار ہے؛ `vendor` میں موجود submodule اس الگ، ساتھ والی کاپی کا متبادل نہیں ہے۔ CI بھی یہی ترتیب استعمال کرتا ہے۔ +`vendor/mono-rs` submodule مقامی طور پر مقررہ MTProto workspace فراہم کرتا ہے۔ تمام submodules کو `git submodule update --init --recursive` سے شروع کریں۔ ### 3. مقامی ترتیبات diff --git a/app/src/androidTest/java/org/monogram/root/ChecklistOutboxPersistenceTest.kt b/app/src/androidTest/java/org/monogram/root/ChecklistOutboxPersistenceTest.kt new file mode 100644 index 000000000..0bbc6d048 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/ChecklistOutboxPersistenceTest.kt @@ -0,0 +1,143 @@ +package org.monogram.root + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.lang.reflect.Proxy +import java.util.UUID +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import org.monogram.core.models.* +import org.monogram.network.bridge.message.MessageOps + +@RunWith(AndroidJUnit4::class) +class ChecklistOutboxPersistenceTest { + @Test + fun checklistPermissionsAndItemsSurviveReopenUncertaintyAndExplicitRetry() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "checklist-outbox-${UUID.randomUUID()}.db" + fun open() = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + var db = open() + val spec = ChecklistSendSpec( + "Synthetic list", + listOf("First", "Second"), + othersCanAppend = true, + othersCanComplete = false + ) + val pending = spec.pendingChecklist() + val row = OutgoingTextSendEntity( + 123, MessageEntity( + chatId = 7, + id = -37, + senderId = null, + text = spec.title, + date = 100, + outgoing = true, + pending = true, + randomId = 123, + mediaCacheKey = null, + mediaKind = "todo", + checklistJson = Checklists.serialize(pending), + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true + ), silent = true, noWebpage = false, scheduleDate = 0x7ffffffe, webpageUrl = null + ) + try { + val cache = OfflineWarmup(db) + cache.saveLocalCloudDraft(CloudDraft(PeerId(7), 99, text = "Keep draft")) + cache.enqueueOutgoingTextSend(row, consumeDraft = false) + assertEquals( + "Keep draft", + cache.captureOutgoingTextDraft( + PeerId(7), + 99, + cache.outgoingAccountEpoch() + ).draft.text + ) + assertEquals( + spec, + ChecklistSendSpec.fromPending( + db.outgoingTextSendDao().get(123)!!.message.toModel().checklist!! + ) + ) + try { + ChecklistSendSpec.fromPending( + pending.copy( + items = listOf( + pending.items.first().copy(done = true) + ) + ) + ); fail("completed pending item accepted") + } catch (_: IllegalArgumentException) { + } + try { + ChecklistSendSpec.fromPending( + pending.copy( + items = listOf( + pending.items.first().copy(id = 9) + ) + ) + ); fail("changed pending id accepted") + } catch (_: IllegalArgumentException) { + } + assertEquals(1, db.outgoingTextSendDao().claim(123)) + db.close(); db = open() + val restored = OfflineWarmup(db) + restored.recoverInterruptedTextSends() + assertEquals( + row.copy(phase = OutgoingSendPhase.UNCERTAIN), + db.outgoingTextSendDao().get(123) + ) + assertEquals(0, db.outgoingTextSendDao().claim(123)) + var attempts = 0 + val client = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, method, args -> + check(method.name == "sendChecklistWithOptions") { "unexpected ${method.name}" } + attempts++ + assertEquals(PeerId(7), args!![0]); assertEquals(spec, args[1]); assertEquals( + 123L, + args[2] + ) + assertEquals(MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe), args[3]) + assertEquals(42, args[4]); assertEquals(99, args[5]) + if (attempts == 1) Outcome.Err("RPC 500: INTERNAL") + else { + val message = Message( + MessageId(PeerId(7), 37), + null, + null, + 200, + outgoing = true, + mediaKind = "todo", + checklist = pending + ) + val result = OutgoingMessageResult.Scheduled(ScheduledMessage(message)) + runBlocking { restored.applyOutgoingResult(123, result) } + Outcome.Ok(result) + } + } as MessageOps + val worker = OutgoingTextSync(restored, { sendRetainedMessage(client, it) }) + worker.syncOnce(); assertEquals(0, attempts) + restored.retryOutgoingTextSend(123) + worker.syncOnce(); assertEquals(1, attempts) + worker.syncOnce(); assertEquals(1, attempts) + assertEquals(OutgoingSendPhase.UNCERTAIN, db.outgoingTextSendDao().get(123)?.phase) + restored.retryOutgoingTextSend(123); worker.requestResume() + worker.syncOnce(); assertEquals(2, attempts) + assertNull(db.outgoingTextSendDao().get(123)) + val queued = db.scheduledMessageDao().forChat(7).single().message.toModel() + assertEquals(pending, queued.checklist) + assertEquals(123L, queued.randomId) + } finally { + db.close(); context.deleteDatabase(name) + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/ChecklistTitleInteractionTest.kt b/app/src/androidTest/java/org/monogram/root/ChecklistTitleInteractionTest.kt new file mode 100644 index 000000000..3e152bcbe --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/ChecklistTitleInteractionTest.kt @@ -0,0 +1,262 @@ +package org.monogram.root + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.UiStateTestActivity +import org.monogram.core.common.Outcome +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.network.bridge.MtprotoClient +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger + +class ChecklistTitleInteractionTest { + @get:Rule + val compose = createEmptyComposeRule() + + @Test + fun editExistingTitlePreservesItemsAndRequiresExplicitRetryAfterFailure() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var message = Message( + MessageId(PeerId(7), 37), null, null, 100, outgoing = true, mediaKind = "todo", + checklist = Checklist( + "Synthetic checklist", entities = listOf(TextEntity("bold", 0, 9)), items = listOf( + ChecklistItem( + 0, + "Fixture task", + done = true, + completedBy = 42, + completedAt = 9, + entities = listOf(TextEntity("italic", 0, 7)) + ), + ChecklistItem(-7, "Keep task", done = true, completedBy = 43, completedAt = 10) + ) + ) + ) + val mutations = AtomicInteger() + val removals = AtomicInteger() + val limitFailures = AtomicInteger() + val client = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticChecklistClient" + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled + "updates", "sessionLost" -> emptyFlow() + "getChats" -> Outcome.Ok(listOf(Chat(PeerId(7), "Fixture chat"))) + "getProfile" -> Outcome.Ok(Profile(PeerId(7), "fixture", "Fixture chat")) + "getHistory", "getHistoryPage", "getHistoryPageBackground" -> Outcome.Ok( + listOf( + message + ) + ) + + "getMessage" -> Outcome.Ok(message) + "getPinnedMessages", "getSavedGifs" -> Outcome.Ok(emptyList()) + "getGroupAdminTags" -> Outcome.Ok(emptyMap()) + "editChecklistText" -> { + assertEquals(37, args!![1]) + val spec = args[2] as ChecklistTextEdit + val list = message.checklist!! + val item = spec.itemId?.let { id -> list.items.single { it.id == id } } + assertEquals(item?.text ?: list.title, spec.expectedText) + assertEquals(item?.entities ?: list.entities, spec.expectedEntities) + assertEquals(spec.expectedEntities, spec.entities) + if (spec.itemId == null && spec.text.length > 32) { + limitFailures.incrementAndGet(); Outcome.Err("checklist text too long") + } else if (mutations.incrementAndGet() == 1) Outcome.Err("RPC 500: INTERNAL") + else { + message = message.copy( + checklist = if (spec.itemId == null) list.copy( + title = spec.text, + entities = spec.entities + ) + else list.copy(items = list.items.map { + if (it.id == spec.itemId) it.copy( + text = spec.text, + entities = spec.entities + ) else it + }) + ) + Outcome.Ok(Unit) + } + } + + "removeChecklistItem" -> { + val spec = args!![2] as ChecklistTextEdit + assertEquals(0, spec.itemId) + assertEquals("Fixture task edited", spec.expectedText) + if (removals.incrementAndGet() == 1) Outcome.Err("RPC 500: INTERNAL") + else { + message = + message.copy(checklist = message.checklist!!.let { it.copy(items = it.items.filterNot { row -> row.id == spec.itemId }) }); Outcome.Ok( + Unit + ) + } + } + + "editText" -> error("checklist must use media edit") + "setDialogForeground", "close", "setForeground" -> Unit + "updateStatus", "setTyping", "readHistory" -> Outcome.Ok(Unit) + else -> Outcome.Err("unsupported synthetic checklist method") + } + } as MtprotoClient + var scenario: ActivityScenario? = null + try { + UiStateTestActivity.content = { componentContext -> + val root = remember(componentContext) { + RootComponent( + componentContext, + DefaultStoreFactory(), + client, + null, + null, + null, + startOnHome = true, + bubbleChatId = 7 + ) + } + MonogramTheme(dynamicColor = false) { RootContent(root, Modifier.fillMaxSize()) } + } + scenario = ActivityScenario.launch(UiStateTestActivity::class.java) + compose.waitUntil(5_000) { + compose.onAllNodesWithText("Synthetic checklist").fetchSemanticsNodes().isNotEmpty() + } + compose.onNode( + hasClickAction() and hasAnyDescendant(hasText("Synthetic checklist")), + useUnmergedTree = true + ) + .performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + val edit = context.getString(org.monogram.feature.dialog.R.string.dialog_edit) + try { + compose.waitUntil(5_000) { + compose.onAllNodesWithText(edit).fetchSemanticsNodes().isNotEmpty() + } + } catch (error: ComposeTimeoutException) { + throw AssertionError( + compose.onAllNodes(isRoot())[0].printToString(maxDepth = 100), + error + ) + } + compose.onNodeWithText(edit).performClick() + compose.onNode(hasSetTextAction()).assertTextContains("Synthetic checklist") + compose.onNode(hasSetTextAction()) + .performTextInputSelection(androidx.compose.ui.text.TextRange(19)) + compose.onNode(hasSetTextAction()).performTextInput("x".repeat(33)) + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_editing)) + .performClick() + compose.waitUntil(5_000) { limitFailures.get() == 1 } + compose.onNode(hasSetTextAction()) + .assertTextContains("Synthetic checklist" + "x".repeat(33)) + compose.onNode(hasSetTextAction()) + .performTextInputSelection(androidx.compose.ui.text.TextRange(19, 52)) + compose.onNode(hasSetTextAction()) + .performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Backspace) } + compose.onNode(hasSetTextAction()).assertTextContains("Synthetic checklist") + compose.onNode(hasSetTextAction()) + .performTextInputSelection(androidx.compose.ui.text.TextRange(19)) + compose.onNode(hasSetTextAction()).performTextInput(" edited") + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_editing)) + .performClick() + compose.waitUntil(5_000) { mutations.get() == 1 } + compose.onNode(hasSetTextAction()).assertTextContains("Synthetic checklist edited") + scenario.recreate() + compose.onNode(hasSetTextAction()).assertTextContains("Synthetic checklist edited") + assertEquals(1, mutations.get()) + assertTrue( + compose.onAllNodesWithText("Synthetic checklist").fetchSemanticsNodes().isNotEmpty() + ) + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_editing)) + .performClick() + compose.waitUntil(5_000) { + compose.onAllNodes(hasSetTextAction() and hasText("")).fetchSemanticsNodes() + .isNotEmpty() + } + compose.onNodeWithText("Synthetic checklist edited").assertIsDisplayed() + compose.onNodeWithText("Fixture task").assertIsDisplayed() + compose.onNode(hasSetTextAction()).assertTextEquals("") + assertEquals(2, mutations.get()) + val originalItem = ChecklistItem( + 0, + "Fixture task", + done = true, + completedBy = 42, + completedAt = 9, + entities = listOf(TextEntity("italic", 0, 7)) + ) + val keptItem = + ChecklistItem(-7, "Keep task", done = true, completedBy = 43, completedAt = 10) + assertEquals(listOf(originalItem, keptItem), message.checklist!!.items) + compose.onNodeWithContentDescription( + context.getString( + org.monogram.feature.dialog.R.string.dialog_checklist_edit_item, + "Fixture task" + ) + ).performClick() + compose.onNode(hasSetTextAction()).assertTextContains("Fixture task") + compose.onNode(hasSetTextAction()) + .performTextInputSelection(androidx.compose.ui.text.TextRange(12)) + compose.onNode(hasSetTextAction()).performTextInput(" edited") + scenario.recreate() + compose.onNode(hasSetTextAction()).assertTextContains("Fixture task edited") + assertEquals(2, mutations.get()) + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_editing)) + .performClick() + compose.waitUntil(5_000) { + mutations.get() == 3 && compose.onAllNodes( + hasSetTextAction() and hasText( + "" + ) + ).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("Fixture task edited").assertIsDisplayed() + assertEquals("Synthetic checklist edited", message.checklist!!.title) + assertEquals(listOf(TextEntity("bold", 0, 9)), message.checklist!!.entities) + assertEquals( + listOf(originalItem.copy(text = "Fixture task edited"), keptItem), + message.checklist!!.items + ) + val removeLabel = context.getString( + org.monogram.feature.dialog.R.string.dialog_checklist_remove_item, + "Fixture task edited" + ) + val delete = context.getString(org.monogram.feature.dialog.R.string.dialog_delete) + compose.onNodeWithContentDescription(removeLabel).performClick() + compose.onNodeWithText(context.getString(org.monogram.feature.dialog.R.string.dialog_cancel)) + .performClick() + assertEquals(0, removals.get()) + compose.onNodeWithContentDescription(removeLabel).performClick() + compose.onNodeWithText(delete).performClick() + compose.waitUntil(5_000) { removals.get() == 1 } + compose.onNodeWithText("Fixture task edited").assertIsDisplayed() + compose.onNodeWithContentDescription(removeLabel).performClick() + compose.onNodeWithText(delete).performClick() + compose.waitUntil(5_000) { + compose.onAllNodesWithText("Fixture task edited").fetchSemanticsNodes().isEmpty() + } + assertEquals(2, removals.get()) + assertEquals(listOf(keptItem), message.checklist!!.items) + compose.onNodeWithContentDescription( + context.getString( + org.monogram.feature.dialog.R.string.dialog_checklist_remove_item, + "Keep task" + ) + ).assertDoesNotExist() + } finally { + scenario?.close(); UiStateTestActivity.content = {} + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/ComposerLiveInteropTest.kt b/app/src/androidTest/java/org/monogram/root/ComposerLiveInteropTest.kt new file mode 100644 index 000000000..00de6b75d --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/ComposerLiveInteropTest.kt @@ -0,0 +1,90 @@ +package org.monogram.root + +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.security.MessageDigest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.monogram.MonogramApp +import org.monogram.core.common.Outcome +import org.monogram.core.models.* + +class ComposerLiveInteropTest { + private fun accepted(result: Outcome, operation: String): T = when (result) { + is Outcome.Ok -> result.value + is Outcome.Err -> throw AssertionError("Live fixture failed: $operation") + } + + @Test + fun silentTextRoundTripToVerifiedLocalTestingBot(): Unit = runBlocking { + val args = InstrumentationRegistry.getArguments() + assumeTrue(args.getString("composerLive") == "true") + val peerId = requireNotNull(args.getString("composerBotId")).toLong() + val username = requireNotNull(args.getString("composerBotUsername")) + val expectedSelf = requireNotNull(args.getString("composerSelfSha256")) + val nonce = requireNotNull(args.getString("composerFixtureNonce")) + val randomId = requireNotNull(args.getString("composerRandomId")).toLong() + require(peerId > 0 && randomId != 0L && nonce.matches(Regex("[a-f0-9-]{36}"))) + val app = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as MonogramApp + withTimeout(60_000) { + app.awaitReady() + val client = app.client + assertTrue( + "Expected an already authorized installation", + accepted(client.isAuthorized(), "authorization") + ) + val self = accepted(client.getProfile(PeerId(0)), "self identity") + val actualSelf = + MessageDigest.getInstance("SHA-256").digest(self.id.value.toString().toByteArray()) + .joinToString("") { "%02x".format(it) } + assertTrue( + "Configured bot receiver does not match this account", + actualSelf == expectedSelf + ) + val peer = accepted(client.resolveUsername(username), "testing bot resolution") + assertTrue( + "Testing bot identity mismatch", peer.isBot && peer.peerId.value == peerId && + peer.username?.equals(username, ignoreCase = true) == true + ) + val body = "Monogram parity silent text fixture $nonce" + val history = + accepted(client.getHistory(peer.peerId, limit = 100), "testing chat history") + val existing = history.firstOrNull { it.outgoing && it.text == body } + val attempt = File(app.filesDir, "composer-live-attempt-$nonce") + val message = if (existing != null) existing else { + assertFalse( + "Prior attempt is uncertain; inspect before any resend", + attempt.exists() + ) + attempt.writeText(randomId.toString()) + val result = accepted( + client.sendTextWithOptions( + peer.peerId, body, + MessageSendOptions(silent = true), randomId + ), "silent send" + ) + assertTrue( + "Immediate fixture returned scheduled mode", + result is OutgoingMessageResult.Sent + ) + (result as OutgoingMessageResult.Sent).message + } + assertTrue( + "Unexpected returned message identity", + message.id.chatId == peer.peerId && message.id.id > 0 && message.outgoing + ) + val confirmed = + accepted(client.getHistory(peer.peerId, limit = 100), "sent history confirmation") + .firstOrNull { it.id == message.id } + assertTrue("Sent fixture was not confirmed losslessly", confirmed?.text == body) + File( + app.filesDir, + "composer-live-proof-$nonce" + ).writeText("silent=true mode=sent historyConfirmed=true id=${message.id.id}") + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/ContactOutboxPersistenceTest.kt b/app/src/androidTest/java/org/monogram/root/ContactOutboxPersistenceTest.kt new file mode 100644 index 000000000..c89ae9055 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/ContactOutboxPersistenceTest.kt @@ -0,0 +1,271 @@ +package org.monogram.root + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.lang.reflect.Proxy +import java.util.UUID +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import org.monogram.core.models.* +import org.monogram.network.bridge.message.MessageOps + +@RunWith(AndroidJUnit4::class) +class ContactOutboxPersistenceTest { + @Test + fun syntheticContactCardSurvivesReopenAndExplicitRetryWithoutImportOrDraftLoss() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + for (location in listOf(false, true)) { + val name = "contact-outbox-${UUID.randomUUID()}.db" + fun open() = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + var db = open() + val spec = ContactSendSpec( + "+15555550123", "Synthetic", "Contact", + "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Synthetic Contact\r\nNOTE:Fixture ü\r\nEND:VCARD" + ) + val place = GeoPlace(1.0, 2.0, live = true, periodSeconds = 900, heading = 360) + val payload = if (location) encodeLocationSend(place) else encodeContactSend(spec) + val kind = if (location) "geo" else "contact" + val row = OutgoingTextSendEntity( + 123, MessageEntity( + chatId = 7, + id = -37, + senderId = null, + text = if (location) null else spec.displayName, + date = 100, + outgoing = true, + pending = true, + randomId = 123, + mediaCacheKey = null, + mediaKind = kind, + fileName = payload, + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true + ), silent = true, noWebpage = false, scheduleDate = null, webpageUrl = null + ) + try { + val cache = OfflineWarmup(db) + cache.saveLocalCloudDraft(CloudDraft(PeerId(7), 99, text = "Keep draft")) + if (location) { + assertTrue(runCatching { + cache.enqueueOutgoingTextSend( + row.copy(scheduleDate = 0x7fff_fffe), + consumeDraft = false + ) + }.isFailure) + assertTrue(runCatching { + cache.enqueueOutgoingTextSend( + row.copy( + message = row.message.copy( + fileName = "{\"lat\":1.0,\"long\":2.0}" + ) + ), consumeDraft = false + ) + }.isFailure) + } + cache.enqueueOutgoingTextSend(row, consumeDraft = false) + assertEquals( + "Keep draft", + cache.captureOutgoingTextDraft( + PeerId(7), + 99, + cache.outgoingAccountEpoch() + ).draft.text + ) + if (location) { + assertEquals( + place, + decodeLocationSend(db.outgoingTextSendDao().get(123)!!.message.fileName) + ) + assertEquals(place, ServiceMedia.geo(payload)) + } else { + assertEquals( + spec, + decodeContactSend(db.outgoingTextSendDao().get(123)!!.message.fileName) + ) + val preview = ServiceMedia.contact(payload)!! + assertEquals(0L, preview.userId); assertEquals( + spec.vcard, + preview.vcard + ); assertEquals(spec.phone, preview.phone) + } + assertEquals(1, db.outgoingTextSendDao().claim(123)) + db.close(); db = open() + val restored = OfflineWarmup(db) + restored.recoverInterruptedTextSends() + assertEquals( + row.copy(phase = OutgoingSendPhase.UNCERTAIN), + db.outgoingTextSendDao().get(123) + ) + assertEquals(0, db.outgoingTextSendDao().claim(123)) + var attempts = 0 + val client = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, method, args -> + check(method.name == if (location) "sendLocationWithOptions" else "sendContactWithOptions") { "unexpected ${method.name}; send must not import" } + attempts++ + assertEquals(PeerId(7), args!![0]); assertEquals( + if (location) place else spec, + args[1] + ); assertEquals(123L, args[2]) + assertEquals(MessageSendOptions(silent = true), args[3]); assertEquals( + 42, + args[4] + ); assertEquals(99, args[5]) + if (attempts == 1) Outcome.Err("RPC 500: INTERNAL") + else { + val response = org.json.JSONObject(payload) + .apply { remove("send_version"); if (!location) put("u", 42) } + .toString() + val message = Message( + MessageId(PeerId(7), 37), + null, + null, + if (location) System.currentTimeMillis() / 1000 else 200, + outgoing = true, + mediaKind = kind, + fileName = response + ) + val result = OutgoingMessageResult.Sent(message) + runBlocking { + if (location) { + assertTrue(runCatching { + restored.applyOutgoingResult( + 123, + OutgoingMessageResult.Sent( + message.copy( + id = MessageId( + PeerId(8), + 37 + ) + ) + ) + ) + }.isFailure) + assertNull( + restored.liveLocation( + MessageId(PeerId(8), 37), + restored.outgoingAccountEpoch() + ) + ) + assertNotNull(db.outgoingTextSendDao().get(123)) + } + restored.applyOutgoingResult(123, result) + } + Outcome.Ok(result) + } + } as MessageOps + val worker = OutgoingTextSync(restored, { sendRetainedMessage(client, it) }) + worker.syncOnce(); assertEquals(0, attempts) + restored.retryOutgoingTextSend(123) + worker.syncOnce(); assertEquals(1, attempts) + worker.syncOnce(); assertEquals(1, attempts) + assertEquals(OutgoingSendPhase.UNCERTAIN, db.outgoingTextSendDao().get(123)?.phase) + restored.retryOutgoingTextSend(123); worker.requestResume() + worker.syncOnce(); assertEquals(2, attempts) + assertNull(db.outgoingTextSendDao().get(123)) + val delivered = db.messageDao().byIds(7, listOf(37)).single().toModel() + if (location) assertEquals(place, delivered.geoPlace) + else { + assertEquals(42L, delivered.contactCard?.userId) + assertEquals(spec.vcard, delivered.contactCard?.vcard) + } + assertEquals(123L, delivered.randomId) + if (location) { + val id = delivered.id + val epoch = restored.outgoingAccountEpoch() + val active = restored.liveLocation(id, epoch)!! + assertEquals(123L, active.randomId) + assertEquals(delivered.date + 900, active.expiresAt) + assertEquals(LiveLocationPhase.ACTIVE, active.phase) + assertFalse(active.expired(active.expiresAt!! - 1)); assertTrue( + active.expired( + active.expiresAt!! + ) + ) + restored.recoverLiveLocations(epoch) + val paused = restored.liveLocation(id, epoch)!! + assertEquals(LiveLocationPhase.PAUSED, paused.phase) + assertFalse( + restored.transitionLiveLocation( + id, + active.revision, + LiveLocationPhase.ACTIVE, + epoch + ) + ) + assertTrue( + restored.transitionLiveLocation( + id, + paused.revision, + LiveLocationPhase.STOPPING, + epoch + ) + ) + db.close(); db = open() + val restarted = OfflineWarmup(db) + val newEpoch = restarted.outgoingAccountEpoch() + restarted.recoverLiveLocations(newEpoch) + val uncertain = restarted.liveLocation(id, newEpoch)!! + assertEquals(LiveLocationPhase.UNCERTAIN_STOP, uncertain.phase) + assertFalse( + restarted.transitionLiveLocation( + id, + uncertain.revision, + LiveLocationPhase.ACTIVE, + newEpoch + ) + ) + assertTrue( + restarted.transitionLiveLocation( + id, + uncertain.revision, + LiveLocationPhase.STOPPING, + newEpoch + ) + ) + val stopping = restarted.liveLocation(id, newEpoch)!! + assertTrue( + restarted.transitionLiveLocation( + id, + stopping.revision, + LiveLocationPhase.STOPPED, + newEpoch + ) + ) + val stopped = restarted.liveLocation(id, newEpoch)!! + restarted.applyOutgoingResult(123, OutgoingMessageResult.Sent(delivered)) + assertEquals(stopped, restarted.liveLocation(id, newEpoch)) + assertFalse( + restarted.transitionLiveLocation( + id, + stopped.revision, + LiveLocationPhase.ACTIVE, + newEpoch + ) + ) + restarted.clearAccountCache() + assertTrue(runCatching { + restarted.transitionLiveLocation( + id, + stopped.revision, + LiveLocationPhase.STOPPING, + newEpoch + ) + }.isFailure) + restarted.activateOutgoingAccount() + assertNull(restarted.liveLocation(id, restarted.outgoingAccountEpoch())) + } + } finally { + db.close(); context.deleteDatabase(name) + } + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/DiceSendInteractionTest.kt b/app/src/androidTest/java/org/monogram/root/DiceSendInteractionTest.kt new file mode 100644 index 000000000..811857aa5 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/DiceSendInteractionTest.kt @@ -0,0 +1,183 @@ +package org.monogram.root + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.room.Room +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.UiStateTestActivity +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.network.bridge.MtprotoClient +import java.lang.reflect.Proxy +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +class DiceSendInteractionTest { + @get:Rule + val compose = createEmptyComposeRule() + + @Test + fun composerDiceRetainsIdentityThroughUncertaintyAndExplicitRetryThenRendersServerResult() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "dice-ui-${UUID.randomUUID()}.db" + val db = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + val cache = OfflineWarmup(db) + runBlocking { + cache.upsertChats( + listOf( + Chat( + PeerId(7), + "Synthetic dice chat", + lastMessageDate = 1000L + ) + ) + ) + } + val events = + kotlinx.coroutines.flow.MutableSharedFlow( + replay = 4 + ) + val attempts = AtomicInteger() + val identities = java.util.concurrent.CopyOnWriteArrayList() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val client = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticDiceClient" + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled + "updates" -> events + "sessionLost" -> emptyFlow() + "getChats" -> Outcome.Ok( + listOf( + Chat( + PeerId(7), + "Synthetic dice chat", + lastMessageDate = 1000L + ) + ) + ) + + "getFolders" -> Outcome.Ok(emptyList()) + "getHistory", "getHistoryPage", "getHistoryPageBackground", "getPinnedMessages", "getSavedGifs" -> Outcome.Ok( + emptyList() + ) + + "getProfile" -> Outcome.Ok(Profile(PeerId(7), "fixture", "Synthetic dice chat")) + "getGroupAdminTags" -> Outcome.Ok(emptyMap()) + "sendDiceWithOptions" -> { + val id = args!![2] as Long + identities += id + assertEquals("🎲", args[1]) + if (attempts.incrementAndGet() == 1) Outcome.Err("RPC 500: INTERNAL") + else { + val message = Message( + MessageId(PeerId(7), 37), + null, + null, + System.currentTimeMillis() / 1000, + outgoing = true, + randomId = id, + mediaKind = "dice", + fileName = """{"e":"🎲","v":4}""" + ) + val result = OutgoingMessageResult.Sent(message) + runBlocking { + cache.applyOutgoingResult(id, result) + events.emit(org.monogram.network.bridge.MtprotoUpdate.NewMessage(message)) + } + Outcome.Ok(result) + } + } + + "sendTextWithOptions", "sendText" -> error("dice must use typed media send") + "setDialogForeground", "close", "setForeground" -> Unit + "updateStatus", "setTyping", "readHistory" -> Outcome.Ok(Unit) + else -> Outcome.Err("unsupported synthetic dice method") + } + } as MtprotoClient + val worker = OutgoingTextSync(cache, { sendRetainedMessage(client, it) }) + var scenario: ActivityScenario? = null + try { + UiStateTestActivity.content = { componentContext -> + val root = remember(componentContext) { + RootComponent( + componentContext, + DefaultStoreFactory(), + client, + cache, + null, + null, + startOnHome = true, + bubbleChatId = 7 + ) + } + MonogramTheme(dynamicColor = false) { RootContent(root, Modifier.fillMaxSize()) } + } + scenario = ActivityScenario.launch(UiStateTestActivity::class.java) + try { + compose.waitUntil(5_000) { + compose.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() + } + } catch (error: ComposeTimeoutException) { + throw AssertionError(compose.onRoot().printToString(), error) + } + compose.onNode(hasSetTextAction()).performTextInput("🎲") + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_send)) + .performClick() + val waiting = + context.getString(org.monogram.feature.dialog.R.string.dialog_dice_pending) + compose.waitUntil(5_000) { + compose.onAllNodesWithText(waiting).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText(waiting).assertIsDisplayed() + compose.onNodeWithText( + context.getString( + org.monogram.feature.dialog.R.string.dialog_dice_result, + 0 + ) + ).assertDoesNotExist() + worker.start(scope) + compose.waitUntil(5_000) { + runBlocking { + db.outgoingTextSendDao().all() + .singleOrNull()?.phase == OutgoingSendPhase.UNCERTAIN + } + } + assertEquals(1, attempts.get()) + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + val result = + context.getString(org.monogram.feature.dialog.R.string.dialog_dice_result, 4) + compose.waitUntil(5_000) { + compose.onAllNodesWithText(result).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText(result).assertIsDisplayed() + compose.onNodeWithText(waiting).assertDoesNotExist() + assertEquals(2, attempts.get()) + assertEquals(identities.first(), identities.last()) + assertTrue(identities.first() != 0L) + runBlocking { assertTrue(db.outgoingTextSendDao().all().isEmpty()) } + } finally { + runBlocking { worker.stop() }; scope.cancel(); scenario?.close(); UiStateTestActivity.content = + {} + db.close(); context.deleteDatabase(name) + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/OutgoingMediaProcessDeathTest.kt b/app/src/androidTest/java/org/monogram/root/OutgoingMediaProcessDeathTest.kt new file mode 100644 index 000000000..833acf402 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/OutgoingMediaProcessDeathTest.kt @@ -0,0 +1,183 @@ +package org.monogram.root + +import android.content.Context +import android.os.Process +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import org.monogram.core.models.* + +/** Host invokes prepare, force-stops the test target, then invokes resume without reinstalling. */ +class OutgoingMediaProcessDeathTest { + private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val directory: File get() = File(context.noBackupFilesDir, "media-process-proof") + private fun database() = + Room.databaseBuilder(context, MonogramDatabase::class.java, "media-process-proof.db") + .build() + + private fun phase(expected: String) { + assumeTrue( + InstrumentationRegistry.getArguments().getString("mediaProcessPhase") == expected + ) + } + + private fun attempt() { + val file = File(directory, "attempts") + val previous = if (file.exists()) file.readText().trim().toInt() else 0 + file.writeText((previous + 1).toString()) + } + + @Test + fun prepareInFlightAlbum(): Unit = runBlocking { + phase("prepare") + context.deleteDatabase("media-process-proof.db") + directory.deleteRecursively(); directory.mkdirs() + val db = database() + val files = OutgoingMediaFiles(File(directory, "owned")) + val cache = OfflineWarmup(db, outgoingMediaFiles = files) + val original = + File(directory, "synthetic-source").apply { writeText("synthetic-upload-bytes") } + val uploads = (0..1).map { index -> + val id = 123L + index + OutgoingMediaUpload( + original.absolutePath, "application/octet-stream", + MessageEntity( + chatId = 7, + id = -37 - index, + senderId = 7, + text = "synthetic caption", + date = 100, + outgoing = true, + pending = true, + randomId = id, + mediaCacheKey = null, + mediaKind = "document", + fileName = "fixture-$index.bin", + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true, + entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""" + ) + ) + } + db.metaDao().upsert(MetaEntity("fixture.identity", "synthetic-account")) + cache.retainAndEnqueueOutgoingMedia( + OutgoingMediaBatchEntity(123, 7, 100, true, 0x7ffffffe, true), + uploads, 128, cache.outgoingAccountEpoch() + ) + original.delete() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val worker = + OutgoingTextSync(cache, { error("unexpected text send") }, sendMedia = { send -> + send.items.forEach { + assertEquals( + "synthetic-upload-bytes", + files.verifiedFile(it.source).readText() + ) + } + assertTrue(send.batch.silent && send.batch.album) + assertEquals(0x7ffffffe, send.batch.scheduleDate) + attempt() + File(directory, "ready").writeText(Process.myPid().toString()) + awaitCancellation() + }) + worker.start(scope) + try { + // Host must terminate this process before cancellation can mark the claim uncertain. + withTimeout(90_000) { awaitCancellation() } + } finally { + worker.stop(); scope.cancel(); db.close() + } + } + + @Test + fun resumeWithoutAutomaticReplayThenExplicitlyRetryAndSettle() = runBlocking { + phase("resume") + val oldPid = File(directory, "ready").readText().trim().toInt() + assertNotEquals(oldPid, Process.myPid()) + val db = database() + val files = OutgoingMediaFiles(File(directory, "owned")) + val cache = OfflineWarmup(db, outgoingMediaFiles = files) + val persisted = db.outgoingMediaSendDao().get(123)!! + assertEquals(OutgoingSendPhase.IN_FLIGHT, persisted.batch.phase) + assertEquals( + listOf(123L, 124L), + persisted.items.sortedBy { it.position }.map { it.randomId }) + assertEquals("synthetic-account", db.metaDao().get("fixture.identity")?.value) + assertFalse(File(directory, "synthetic-source").exists()) + persisted.items.forEach { + assertEquals( + "synthetic-upload-bytes", + files.verifiedFile(it.source).readText() + ) + } + val attempted = CompletableDeferred() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val worker = + OutgoingTextSync(cache, { error("unexpected text send") }, sendMedia = { send -> + assertEquals( + persisted.items.sortedBy { it.position }, + send.items.sortedBy { it.position }) + attempt(); attempted.complete(Unit); Outcome.Ok(Unit) + }) + try { + worker.start(scope) + withTimeout(5_000) { + cache.observeOutgoingMediaSends() + .first { it.single().batch.phase == OutgoingSendPhase.UNCERTAIN } + } + worker.requestResume() + worker.syncOnce() + assertEquals("1", File(directory, "attempts").readText()) + assertFalse(attempted.isCompleted) + cache.retryOutgoingMediaSend(123) + withTimeout(5_000) { attempted.await() } + assertEquals("2", File(directory, "attempts").readText()) + val results = persisted.items.sortedBy { it.position }.mapIndexed { index, item -> + item.randomId to + OutgoingMessageResult.Scheduled( + ScheduledMessage( + item.message.copy( + id = 37 + index, + pending = false, + mediaCacheKey = "synthetic-remote-${37 + index}", + thumbCacheKey = null + ).toModel(), false + ) + ) + } + assertTrue(cache.applyDurableBatch(5, 1, null, emptyMap()) { + cache.applyOutgoingMediaResults(123, results, cache.outgoingAccountEpoch(), 5, 1) + }) + files.release(persisted.items.map { it.source }) + assertNull(db.outgoingMediaSendDao().get(123)) + assertEquals(listOf(37, 38), db.scheduledMessageDao().forChat(7).map { it.message.id }) + assertTrue( + db.scheduledMessageDao().forChat(7) + .all { it.message.mediaCacheKey?.startsWith("synthetic-remote-") == true }) + assertTrue(db.messageDao().byIds(7, listOf(37, 38)).isEmpty()) + assertTrue( + cache.applyDurableBatch( + 5, + 1, + null, + emptyMap() + ) { error("duplicate receipt replayed") }) + assertTrue(File(directory, "owned").listFiles().orEmpty().isEmpty()) + File( + directory, + "verified" + ).writeText("oldPid=$oldPid newPid=${Process.myPid()} attempts=2 queue=2") + } finally { + worker.stop(); scope.cancel(); db.close() + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/PollOutboxPersistenceTest.kt b/app/src/androidTest/java/org/monogram/root/PollOutboxPersistenceTest.kt new file mode 100644 index 000000000..23fcaf0ed --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/PollOutboxPersistenceTest.kt @@ -0,0 +1,124 @@ +package org.monogram.root + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.lang.reflect.Proxy +import java.util.UUID +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import org.monogram.core.models.* +import org.monogram.network.bridge.message.MessageOps + +@RunWith(AndroidJUnit4::class) +class PollOutboxPersistenceTest { + @Test + fun quizPayloadSurvivesReopenAndUncertainRetryWithSameIdentityAndOptions() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "poll-outbox-${UUID.randomUUID()}.db" + fun open() = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + var db = open() + val spec = PollSendSpec( + "Synthetic quiz", + listOf("First", "Second"), + anonymous = false, + quiz = true, + correctAnswer = 1, + solution = "Synthetic explanation" + ) + val payload = encodePollSend(spec) + val row = OutgoingTextSendEntity( + 123, MessageEntity( + chatId = 7, + id = -37, + senderId = null, + text = spec.question, + date = 100, + outgoing = true, + pending = true, + randomId = 123, + mediaCacheKey = null, + mediaKind = "poll", + fileName = payload, + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true + ), silent = true, noWebpage = false, scheduleDate = 0x7ffffffe, webpageUrl = null + ) + try { + val cache = OfflineWarmup(db) + cache.saveLocalCloudDraft(CloudDraft(PeerId(7), 99, text = "Keep draft")) + cache.enqueueOutgoingTextSend(row, consumeDraft = false) + assertEquals( + "Keep draft", + cache.captureOutgoingTextDraft( + PeerId(7), + 99, + cache.outgoingAccountEpoch() + ).draft.text + ) + assertEquals(spec, decodePollSend(db.outgoingTextSendDao().get(123)!!.message.fileName)) + val preview = ServiceMedia.poll(payload)!! + assertFalse(preview.answers.any { it.correct || it.chosen }) + assertEquals("", preview.solution) + assertEquals(listOf("First", "Second"), preview.answers.map { it.text }) + assertEquals(1, db.outgoingTextSendDao().claim(123)) + db.close(); db = open() + val restored = OfflineWarmup(db) + restored.recoverInterruptedTextSends() + assertEquals( + row.copy(phase = OutgoingSendPhase.UNCERTAIN), + db.outgoingTextSendDao().get(123) + ) + assertEquals(0, db.outgoingTextSendDao().claim(123)) + var attempts = 0 + val client = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, method, args -> + check(method.name == "sendPollWithOptions") { "unexpected ${method.name}" } + attempts++ + assertEquals(PeerId(7), args!![0]); assertEquals(spec, args[1]); assertEquals( + 123L, + args[2] + ) + assertEquals(MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe), args[3]) + assertEquals(42, args[4]); assertEquals(99, args[5]) + if (attempts == 1) Outcome.Err("RPC 500: INTERNAL") + else { + val message = Message( + MessageId(PeerId(7), 37), + null, + null, + 200, + outgoing = true, + mediaKind = "poll", + fileName = """{"id":"987","q":"Synthetic quiz","z":true,"a":[{"t":"First","o":"30"},{"t":"Second","o":"31"}]}""" + ) + val result = OutgoingMessageResult.Scheduled(ScheduledMessage(message)) + runBlocking { restored.applyOutgoingResult(123, result) } + Outcome.Ok(result) + } + } as MessageOps + val worker = OutgoingTextSync(restored, { sendRetainedMessage(client, it) }) + worker.syncOnce(); assertEquals(0, attempts) + restored.retryOutgoingTextSend(123) + worker.syncOnce(); assertEquals(1, attempts) + worker.syncOnce(); assertEquals(1, attempts) + assertEquals(OutgoingSendPhase.UNCERTAIN, db.outgoingTextSendDao().get(123)?.phase) + restored.retryOutgoingTextSend(123); worker.requestResume() + worker.syncOnce(); assertEquals(2, attempts) + assertNull(db.outgoingTextSendDao().get(123)) + val queued = db.scheduledMessageDao().forChat(7).single().message + assertEquals(987L, ServiceMedia.poll(queued.fileName)?.id) + assertEquals(123L, queued.randomId) + } finally { + db.close(); context.deleteDatabase(name) + } + } +} diff --git a/app/src/androidTest/java/org/monogram/root/RecipientPickerTest.kt b/app/src/androidTest/java/org/monogram/root/RecipientPickerTest.kt index 798e8a835..3f373ccea 100644 --- a/app/src/androidTest/java/org/monogram/root/RecipientPickerTest.kt +++ b/app/src/androidTest/java/org/monogram/root/RecipientPickerTest.kt @@ -30,7 +30,7 @@ class RecipientPickerTest { lifecycle.resume() RecipientPickerComponent(DefaultComponentContext(lifecycle), RecipientRequest(fromChatId = 9, messageIds = listOf(5, 3, 5)), - fake(calls), null, null, {}) + fake(calls), null, {}) } try { withContext(Dispatchers.Main) { @@ -52,7 +52,8 @@ class RecipientPickerTest { RecipientPickerComponent(DefaultComponentContext(lifecycle), RecipientRequest(fromChatId = 9, messageIds = listOf(3)), fake(calls) { peer -> if (peer == 2L && failOnce) { failOnce = false; true } else false }, - null, null, {}) + null, + {}) } try { withContext(Dispatchers.Main) { @@ -76,7 +77,9 @@ class RecipientPickerTest { lifecycle.resume() RecipientPickerComponent(DefaultComponentContext(lifecycle), RecipientRequest(share = IncomingShare("Caption", listOf(IncomingShareAttachment("/test/photo.jpg", "photo", "image/jpeg", "photo.jpg")))), - fake(calls), null, null, {}) + fake(calls), + null, + {}) } try { withContext(Dispatchers.Main) { picker.onToggle(1); picker.onToggle(2); picker.onSend() } diff --git a/app/src/androidTest/java/org/monogram/root/ScheduledQueueRootRestoreTest.kt b/app/src/androidTest/java/org/monogram/root/ScheduledQueueRootRestoreTest.kt new file mode 100644 index 000000000..d1fc68b84 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/ScheduledQueueRootRestoreTest.kt @@ -0,0 +1,530 @@ +package org.monogram.root + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.flow.emptyFlow +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.UiStateTestActivity +import org.monogram.core.common.Outcome +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import kotlinx.coroutines.runBlocking +import androidx.room.Room +import java.io.File +import org.monogram.network.bridge.MtprotoClient + +class ScheduledQueueRootRestoreTest { + @get:Rule + val compose = createEmptyComposeRule() + private var scenario: ActivityScenario? = null + private lateinit var root: RootComponent + private val mutations = AtomicInteger() + private val reads = AtomicInteger() + private val calls = java.util.concurrent.CopyOnWriteArrayList() + private val scheduled = ScheduledMessage( + Message( + MessageId(PeerId(7), 37), null, + "Synthetic queued caption", System.currentTimeMillis() / 1000 + 3600, outgoing = true + ), false + ) + + @After + fun close() { + scenario?.close(); UiStateTestActivity.content = {} + } + + @Test + fun rootQueueAndUnsubmittedEditorSurviveActivityRecreationWithoutMutation() = + checkRestoration(false) + + @Test + fun russianDarkLargeTextQueueAndEditorSurviveActivityRecreationWithoutMutation() = + checkRestoration(true) + + private fun launch( + client: MtprotoClient, + warmup: OfflineWarmup? = null, + russianDarkLargeText: Boolean = false + ): android.content.Context { + val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + val context = if (russianDarkLargeText) targetContext.createConfigurationContext( + android.content.res.Configuration(targetContext.resources.configuration).apply { + setLocale(java.util.Locale.forLanguageTag("ru")); fontScale = 1.5f + }) else targetContext + UiStateTestActivity.content = { componentContext -> + val component = remember(componentContext) { + RootComponent( + componentContext, + DefaultStoreFactory(), + client, + warmup, + null, + null, + startOnHome = true + ) + } + root = component + val density = LocalDensity.current + CompositionLocalProvider( + LocalResources provides context.resources, + LocalConfiguration provides context.resources.configuration, + LocalDensity provides Density( + density.density, + if (russianDarkLargeText) 1.5f else density.fontScale + ) + ) { + MonogramTheme(darkTheme = russianDarkLargeText, dynamicColor = false) { + RootContent(component, Modifier.fillMaxSize()) + } + } + } + scenario = ActivityScenario.launch(UiStateTestActivity::class.java) + try { + compose.waitUntil(5_000) { + compose.onAllNodesWithText("Synthetic queue chat").fetchSemanticsNodes() + .isNotEmpty() + } + } catch (error: ComposeTimeoutException) { + throw AssertionError( + "Root=${root.stack.value.active.configuration}; calls=$calls\n${ + compose.onRoot().printToString() + }", error + ) + } + return context + } + + private fun openQueue(): RootComponent.Config.Dialog { + compose.onNodeWithText("Synthetic queue chat").performClick() + lateinit var dialog: RootComponent.Config.Dialog + compose.runOnIdle { + dialog = root.stack.value.active.configuration as RootComponent.Config.Dialog + assertEquals(7L, dialog.chatId) + (root.stack.value.active.instance as RootComponent.Child.Dialog).component.onOpenScheduledQueue() + } + return dialog + } + + private fun checkRestoration(russianDarkLargeText: Boolean) { + val context = launch(client(), russianDarkLargeText = russianDarkLargeText) + val dialog = openQueue() + compose.waitUntil(5_000) { + compose.onAllNodesWithText(scheduled.message.text!!).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(org.monogram.feature.dialog.R.string.dialog_edit)) + .performClick() + val editor = hasSetTextAction() and hasText(scheduled.message.text!!) + compose.onNode(editor).performTextReplacement("Local unsent caption after recreation") + val readsBefore = reads.get() + scenario!!.recreate() + compose.onNode(hasSetTextAction() and hasText("Local unsent caption after recreation")) + .assertTextContains("Local unsent caption after recreation") + compose.runOnIdle { + assertEquals( + RootComponent.Config.ScheduledQueue(7), + root.stack.value.active.configuration + ) + assertEquals(dialog, root.stack.value.backStack.last().configuration) + assertEquals(0, mutations.get()) + assertTrue(reads.get() >= readsBefore) + } + if (russianDarkLargeText) { + compose.waitForIdle() + val image = compose.onRoot().captureToImage().asAndroidBitmap() + try { + java.io.File( + context.getExternalFilesDir(null), + "scheduled-queue-root-russian-large-proof.png" + ) + .outputStream() + .use { image.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + } finally { + image.recycle() + } + } + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_markdown_close)) + .performClick() + compose.onNodeWithText(scheduled.message.text!!).assertIsDisplayed() + compose.runOnIdle { + (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.onBack() + assertEquals(dialog, root.stack.value.active.configuration) + assertEquals(0, mutations.get()) + } + } + + @Test + fun queueEditorKeepsDraftAndControlsAboveKeyboardAcrossRotation() { + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + fun shell(command: String) = android.os.ParcelFileDescriptor.AutoCloseInputStream( + automation.executeShellCommand(command) + ).bufferedReader().use { it.readText().trim() } + org.junit.Assume.assumeTrue( + "An installed input method is required", + shell("ime list -a -s").isNotBlank() + ) + val oldKeyboard = shell("settings get secure show_ime_with_hard_keyboard") + val oldRotation = shell("settings get system user_rotation") + val oldAccelerometer = shell("settings get system accelerometer_rotation") + try { + shell("settings put secure show_ime_with_hard_keyboard 1") + assertTrue(automation.setRotation(android.app.UiAutomation.ROTATION_FREEZE_0)) + val context = launch(client()) + openQueue() + compose.waitUntil(5_000) { + compose.onAllNodesWithText(scheduled.message.text!!).fetchSemanticsNodes() + .isNotEmpty() + } + compose.onNodeWithContentDescription(context.getString(org.monogram.feature.dialog.R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(org.monogram.feature.dialog.R.string.dialog_edit)) + .performClick() + val draft = "Keyboard and rotation keep this unsent draft" + compose.onNode(hasSetTextAction() and hasText(scheduled.message.text!!)) + .performTextReplacement(draft) + fun verifyKeyboard() { + compose.onNode(hasSetTextAction() and hasText(draft)).performClick() + .assertIsFocused() + scenario!!.onActivity { activity -> + val manager = + activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager + manager.showSoftInput( + activity.currentFocus, + android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT + ) + } + try { + compose.waitUntil(5_000) { + var visible = false + scenario!!.onActivity { activity -> + visible = + androidx.core.view.ViewCompat.getRootWindowInsets(activity.window.decorView) + ?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true + } + visible + } + } catch (error: ComposeTimeoutException) { + var window = "" + scenario!!.onActivity { + window = + "focus=${it.hasWindowFocus()} display=${it.window.decorView.display?.displayId} current=${it.currentFocus?.javaClass?.simpleName}" + } + val state = shell("dumpsys input_method").lineSequence().filter { + it.contains("mCurMethodId=") || it.contains("mShowRequested=") || it.contains( + "mInputShown=" + ) || + it.contains("mCurFocusedWindow=") || it.contains("mInteractive=") + }.joinToString("\n") + throw AssertionError("$window\n$state", error) + } + var keyboardTop = 0f + var density = 1f + scenario!!.onActivity { activity -> + val view = activity.window.decorView + val insets = + requireNotNull(androidx.core.view.ViewCompat.getRootWindowInsets(view)) + keyboardTop = + (view.height - insets.getInsets(androidx.core.view.WindowInsetsCompat.Type.ime()).bottom).toFloat() + density = activity.resources.displayMetrics.density + } + val apply = + compose.onNode(hasText(context.getString(org.monogram.feature.dialog.R.string.dialog_markdown_apply)) and hasClickAction()) + apply.assertIsDisplayed() + assertTrue( + "Apply must remain above the keyboard", + apply.getBoundsInRoot().bottom.value * density <= keyboardTop + ) + val field = compose.onNode(hasSetTextAction() and hasText(draft)) + field.assertTextContains(draft) + assertTrue( + "Editor must remain above the keyboard", + field.getBoundsInRoot().bottom.value * density <= keyboardTop + ) + compose.runOnIdle { + assertEquals( + RootComponent.Config.ScheduledQueue(7), + root.stack.value.active.configuration + ) + assertEquals(0, mutations.get()) + } + } + verifyKeyboard() + assertTrue(automation.setRotation(android.app.UiAutomation.ROTATION_FREEZE_90)) + compose.waitUntil(10_000) { + var landscape = false + scenario!!.onActivity { + landscape = + it.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + } + landscape + } + verifyKeyboard() + } finally { + automation.setRotation(android.app.UiAutomation.ROTATION_UNFREEZE) + for ((table, key, value) in listOf( + Triple("secure", "show_ime_with_hard_keyboard", oldKeyboard), + Triple("system", "user_rotation", oldRotation), + Triple("system", "accelerometer_rotation", oldAccelerometer) + )) { + shell(if (value == "null") "settings delete $table $key" else "settings put $table $key $value") + } + } + } + + @Test + fun retainedAlbumSettlesIntoPersistentQueueAndLostCancelCallbackRefreshesWithoutReplay() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "scheduled-album-lifecycle.db" + val directory = File(context.noBackupFilesDir, "scheduled-album-lifecycle") + context.deleteDatabase(name) + directory.deleteRecursively(); directory.mkdirs() + fun database() = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + var db = database() + val files = OutgoingMediaFiles(File(directory, "owned")) + var cache = OfflineWarmup(db, outgoingMediaFiles = files) + try { + val send = runBlocking { + val original = + File(directory, "synthetic-source").apply { writeText("synthetic-album-bytes") } + val uploads = (0..1).map { index -> + OutgoingMediaUpload( + original.absolutePath, "application/octet-stream", MessageEntity( + chatId = 7, + id = -37 - index, + senderId = 99, + text = "Album caption $index", + date = 100, + outgoing = true, + pending = true, + randomId = 123L + index, + groupedId = 777, + mediaKind = "document", + mediaCacheKey = null, + fileName = "fixture-$index.bin", + entitiesJson = "[]" + ) + ) + } + val admitted = cache.retainAndEnqueueOutgoingMedia( + OutgoingMediaBatchEntity( + 123, + 7, + 100, + true, + scheduled.message.date.toInt(), + true + ), + uploads, 128, cache.outgoingAccountEpoch() + ).send + assertTrue(original.delete()) + var attempts = 0 + val worker = OutgoingTextSync( + cache, + { error("unexpected text send") }, + sendMedia = { batch -> + attempts++ + assertTrue(batch.batch.silent && batch.batch.album) + assertEquals(scheduled.message.date.toInt(), batch.batch.scheduleDate) + assertEquals(listOf(123L, 124L), batch.items.map { it.randomId }) + batch.items.forEach { + assertEquals( + "synthetic-album-bytes", + files.verifiedFile(it.source).readText() + ) + } + Outcome.Ok(Unit) + }) + worker.syncOnce(); worker.stop() + assertEquals(1, attempts) + assertEquals( + OutgoingSendPhase.IN_FLIGHT, + db.outgoingMediaSendDao().get(123)!!.batch.phase + ) + admitted + } + db.close(); db = database(); cache = OfflineWarmup(db, outgoingMediaFiles = files) + val remote = java.util.concurrent.CopyOnWriteArrayList() + runBlocking { + assertEquals( + OutgoingSendPhase.IN_FLIGHT, + db.outgoingMediaSendDao().get(123)!!.batch.phase + ) + send.items.forEach { files.verifiedFile(it.source) } + val results = send.items.mapIndexed { index, item -> + val message = ScheduledMessage( + item.message.copy( + id = 37 + index, + pending = false, + date = scheduled.message.date, + mediaCacheKey = "synthetic-remote-${37 + index}" + ).toModel(), false + ) + remote += message + item.randomId to OutgoingMessageResult.Scheduled(message) + } + assertTrue(cache.applyDurableBatch(5, 1, null, emptyMap()) { + cache.applyOutgoingMediaResults( + 123, + results, + cache.outgoingAccountEpoch(), + 5, + 1 + ) + }) + files.release(send.items.map { it.source }) + assertNull(db.outgoingMediaSendDao().get(123)) + assertEquals( + listOf(37, 38), + db.scheduledMessageDao().forChat(7).map { it.message.id }) + assertTrue(db.messageDao().byIds(7, listOf(37, 38)).isEmpty()) + assertTrue(File(directory, "owned").listFiles().orEmpty().isEmpty()) + } + db.close(); db = database(); cache = OfflineWarmup(db, outgoingMediaFiles = files) + runBlocking { assertEquals(2, db.scheduledMessageDao().forChat(7).size) } + val uiContext = launch(client(history = { remote.toList() }, delete = { ids -> + assertEquals(listOf(37), ids) + remote.removeAll { it.message.id.id in ids } + Outcome.Err("RPC 500: INTERNAL") + }), cache) + val queueIcon = + uiContext.getString(org.monogram.feature.dialog.R.string.dialog_scheduled_messages) + compose.onNodeWithText("Synthetic queue chat").performClick() + compose.waitUntil(5_000) { + compose.onAllNodesWithContentDescription(queueIcon).fetchSemanticsNodes() + .isNotEmpty() + } + compose.onNodeWithContentDescription(queueIcon).performClick() + compose.waitUntil(5_000) { + val state = + (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.state.value + !state.loading && !state.fromCache && !state.needsRefresh && state.messages.size == 2 + } + val cancel = + uiContext.getString(org.monogram.feature.dialog.R.string.dialog_scheduled_cancel) + compose.onAllNodesWithContentDescription(uiContext.getString(org.monogram.feature.dialog.R.string.dialog_scheduled_actions)) + .onFirst().performClick() + compose.onNodeWithText(cancel).performClick() + compose.runOnIdle { assertEquals(0, mutations.get()) } + compose.onNode(hasText(cancel) and hasClickAction() and hasAnyAncestor(isDialog())) + .performClick() + compose.waitUntil(5_000) { + val state = + (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.state.value + state.needsRefresh && state.mutatingId == null + } + compose.runOnIdle { assertEquals(1, mutations.get()) } + compose.onNodeWithContentDescription(uiContext.getString(org.monogram.feature.dialog.R.string.dialog_refresh)) + .performClick() + compose.waitUntil(5_000) { + val state = + (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.state.value + !state.loading && !state.needsRefresh && state.messages.map { it.message.id.id } == listOf( + 38 + ) + } + compose.onNodeWithText("Album caption 1").assertIsDisplayed() + compose.onNodeWithText("Album caption 0").assertDoesNotExist() + runBlocking { + assertEquals( + listOf(38), + db.scheduledMessageDao().forChat(7).map { it.message.id }) + } + compose.runOnIdle { assertEquals(1, mutations.get()) } + remote.clear() + compose.runOnIdle { (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.onRefresh() } + compose.waitUntil(5_000) { + val state = + (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.state.value + !state.loading && state.messages.isEmpty() + } + compose.runOnIdle { (root.stack.value.active.instance as RootComponent.Child.ScheduledQueue).component.onBack() } + compose.waitForIdle() + compose.onNodeWithContentDescription(queueIcon).assertDoesNotExist() + } finally { + scenario?.close(); scenario = null + db.close(); context.deleteDatabase(name); directory.deleteRecursively() + } + } + + private fun client( + history: () -> List = { listOf(scheduled) }, + delete: ((List) -> Outcome)? = null + ) = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + calls += method.name + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticQueueRestoreClient" + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled + "updates", "sessionLost" -> emptyFlow() + "getChats" -> Outcome.Ok( + listOf( + Chat( + PeerId(7), + "Synthetic queue chat", + lastMessageDate = 1000L + ) + ) + ) + + "getFolders" -> Outcome.Ok(listOf(Folder(2, "Work", chatIds = listOf(PeerId(7))))) + "getHistory", "getHistoryPage", "getHistoryPageBackground", "getPinnedMessages", "getSavedGifs" -> Outcome.Ok( + emptyList() + ) + + "getProfile" -> { + val peer = args?.firstOrNull() as? PeerId ?: PeerId(99) + Outcome.Ok( + Profile( + peer, + "fixture", + if (peer == PeerId(7)) "Synthetic queue chat" else "Synthetic self" + ) + ) + } + + "getGroupAdminTags" -> Outcome.Ok(emptyMap()) + "getScheduledHistory", "getScheduledMessages" -> { + reads.incrementAndGet(); Outcome.Ok(history()) + } + + "deleteScheduledMessages" -> { + mutations.incrementAndGet() + @Suppress("UNCHECKED_CAST") + val ids = args!![1] as List + delete?.invoke(ids) ?: Outcome.Err("unexpected mutation during recreation") + } + + "sendScheduledMessages", "editScheduledMessage" -> { + mutations.incrementAndGet(); Outcome.Err("unexpected mutation during recreation") + } + + "setDialogForeground", "close", "setForeground" -> Unit + "updateStatus", "setTyping", "readHistory", "logout" -> Outcome.Ok(Unit) + else -> Outcome.Err("unsupported synthetic queue method") + } + } as MtprotoClient +} diff --git a/app/src/androidTest/java/org/monogram/root/StructuredCreationRootTest.kt b/app/src/androidTest/java/org/monogram/root/StructuredCreationRootTest.kt new file mode 100644 index 000000000..5dd5eac6c --- /dev/null +++ b/app/src/androidTest/java/org/monogram/root/StructuredCreationRootTest.kt @@ -0,0 +1,268 @@ +package org.monogram.root + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.room.Room +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.UiStateTestActivity +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.MtprotoUpdate +import java.lang.reflect.Proxy +import java.util.UUID + +class StructuredCreationRootTest { + @get:Rule + val compose = createEmptyComposeRule() + + @Test + fun attachmentFormsAdmitRealRowsAndTypedSenderSettlesAuthoritativePollAndChecklistWithoutErasingDraft() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "structured-root-${UUID.randomUUID()}.db" + val db = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + val cache = OfflineWarmup(db) + val events = MutableSharedFlow(replay = 8) + val delivered = java.util.concurrent.CopyOnWriteArrayList() + val identities = java.util.concurrent.CopyOnWriteArrayList() + val client = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticStructuredClient" + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled + "updates" -> events + "sessionLost" -> emptyFlow() + "getChats" -> Outcome.Ok( + listOf( + Chat( + PeerId(7), + "Fixture chat", + isBot = true, + canSendPolls = true + ) + ) + ) + + "getFolders" -> Outcome.Ok(emptyList()) + "getHistory", "getHistoryPage", "getHistoryPageBackground" -> Outcome.Ok(delivered.toList()) + "getPinnedMessages", "getSavedGifs" -> Outcome.Ok(emptyList()) + "getProfile" -> Outcome.Ok(Profile(PeerId(7), "fixture", "Fixture chat")) + "getGroupAdminTags" -> Outcome.Ok(emptyMap()) + "sendPollWithOptions", "sendChecklistWithOptions" -> { + val id = args!![2] as Long + identities += id + val row = runBlocking { requireNotNull(db.outgoingTextSendDao().get(id)) } + assertEquals( + org.monogram.core.database.entity.OutgoingSendPhase.IN_FLIGHT, + row.phase + ) + assertTrue(id != 0L) + val options = args[3] as MessageSendOptions + assertTrue(options.silent) + val pending = row.message.toModel() + val message = if (method.name == "sendPollWithOptions") { + val spec = args[1] as PollSendSpec + assertEquals("Synthetic poll", spec.question) + assertEquals(listOf("First", "Second"), spec.answers) + pending.copy( + id = MessageId(PeerId(7), 37), pending = false, text = null, + fileName = """{"id":"987","q":"Synthetic poll","a":[{"t":"First","o":"30"},{"t":"Second","o":"31"}]}""" + ) + } else { + val spec = args[1] as ChecklistSendSpec + assertEquals( + ChecklistSendSpec( + "Synthetic list", + listOf("Fixture task"), + othersCanAppend = true + ), spec + ) + pending.copy(id = MessageId(PeerId(7), 38), pending = false, text = null) + } + val result = OutgoingMessageResult.Sent(message) + delivered += message + runBlocking { + cache.applyOutgoingResult(id, result) + events.emit(MtprotoUpdate.OutgoingMessageAcknowledged(id, result)) + } + Outcome.Ok(result) + } + + "sendTextWithOptions", "sendText" -> throw AssertionError("creation must use typed media send") + "setDialogForeground", "close", "setForeground" -> Unit + "updateStatus", "setTyping", "readHistory" -> Outcome.Ok(Unit) + else -> Outcome.Err("unsupported synthetic structured method") + } + } as MtprotoClient + var rootComponent: RootComponent? = null + var scenario: ActivityScenario? = null + try { + UiStateTestActivity.content = { componentContext -> + val root = remember(componentContext) { + RootComponent( + componentContext, + DefaultStoreFactory(), + client, + cache, + null, + null, + startOnHome = true, + bubbleChatId = 7 + ) + } + rootComponent = root + MonogramTheme(dynamicColor = false) { RootContent(root, Modifier.fillMaxSize()) } + } + scenario = ActivityScenario.launch(UiStateTestActivity::class.java) + compose.waitUntil(5_000) { + compose.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty() + } + compose.onNode(hasSetTextAction()).performTextInput("Keep draft") + fun text(id: Int) = context.getString(id) + fun fill(label: String, value: String) { + compose.onNode(hasSetTextAction() and hasText(label)).performScrollTo() + .performTextInput(value) + } + + fun rights(plain: Boolean, photos: Boolean, polls: Boolean) = runBlocking { + val chat = Chat( + PeerId(7), + "Fixture chat", + isBot = polls, + canSendPlain = plain, + canSendPhotos = photos, + canSendPolls = polls + ) + cache.upsertChats(listOf(chat)); events.emit(MtprotoUpdate.ChatsChanged(listOf(chat))) + } + + val worker = OutgoingTextSync(cache, { sendRetainedMessage(client, it) }) + for (kind in 0..1) { + val checklist = kind == 1 + if (kind == 0) { + rights(false, false, true) + compose.waitUntil(5_000) { + compose.onAllNodes(hasText("Keep draft") and !isEnabled()) + .fetchSemanticsNodes().isNotEmpty() + } + } + compose.onNodeWithContentDescription(text(org.monogram.feature.dialog.R.string.dialog_attach)) + .performClick() + val create = + text(if (checklist) org.monogram.feature.dialog.R.string.dialog_checklist_create else org.monogram.feature.dialog.R.string.dialog_poll_create) + compose.waitUntil(5_000) { + compose.onAllNodesWithContentDescription(create).fetchSemanticsNodes() + .isNotEmpty() + } + compose.onNodeWithContentDescription(create).performScrollTo().performClick() + if (checklist) { + fill( + text(org.monogram.feature.dialog.R.string.dialog_checklist_title), + "Synthetic list" + ) + fill( + context.getString( + org.monogram.feature.dialog.R.string.dialog_checklist_item, + 1 + ), "Fixture task" + ) + compose.onNodeWithText(text(org.monogram.feature.dialog.R.string.dialog_checklist_allow_append)) + .performScrollTo().performSemanticsAction(SemanticsActions.OnClick) { it() } + } else { + fill( + text(org.monogram.feature.dialog.R.string.dialog_poll_question), + "Synthetic poll" + ) + fill( + context.getString( + org.monogram.feature.dialog.R.string.dialog_poll_option, + 1 + ), "First" + ) + fill( + context.getString( + org.monogram.feature.dialog.R.string.dialog_poll_option, + 2 + ), "Second" + ) + } + if (kind == 0) { + val send = text(org.monogram.feature.dialog.R.string.dialog_send) + rights(false, false, false) + compose.waitUntil(5_000) { + compose.onAllNodes(hasText(send) and !isEnabled()).fetchSemanticsNodes() + .isNotEmpty() + } + compose.runOnIdle { + (rootComponent!!.stack.value.active.instance as RootComponent.Child.Dialog).component + .onSendPoll(PollSendSpec("Rejected poll", listOf("First", "Second"))) + } + runBlocking { assertTrue(db.outgoingTextSendDao().all().isEmpty()) } + rights(false, false, true) + compose.waitUntil(5_000) { + compose.onAllNodes(hasText(send) and isEnabled()).fetchSemanticsNodes() + .isNotEmpty() + } + compose.onNode(hasSetTextAction() and hasText("Synthetic poll")) + .assertTextContains("Synthetic poll") + } + compose.onNodeWithText(text(org.monogram.feature.dialog.R.string.dialog_send_silent)) + .performScrollTo().performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.onNodeWithText(text(org.monogram.feature.dialog.R.string.dialog_send)) + .performScrollTo().performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.waitUntil(5_000) { + runBlocking { + db.outgoingTextSendDao().all().size == 1 + } + } + compose.waitUntil(5_000) { + compose.onAllNodesWithText(create).fetchSemanticsNodes().isEmpty() + } + compose.onNodeWithText("Keep draft").assertTextContains("Keep draft") + if (kind == 0) rights(true, true, false) + runBlocking { worker.syncOnce() } + val expected = kind + 1 + compose.waitUntil(5_000) { + delivered.size == expected && compose.onAllNodesWithText( + if (checklist) "Fixture task" else "First" + ).fetchSemanticsNodes().isNotEmpty() + } + runBlocking { + assertTrue(db.outgoingTextSendDao().all().isEmpty()) + assertEquals( + "Keep draft", + cache.captureOutgoingTextDraft( + PeerId(7), + 0, + cache.outgoingAccountEpoch() + ).draft.text + ) + } + } + assertEquals(2, identities.size); assertEquals(2, identities.toSet().size) + runBlocking { assertEquals(2, db.messageDao().byIds(7, listOf(37, 38)).size) } + } finally { + scenario?.close(); UiStateTestActivity.content = {}; db.close(); context.deleteDatabase( + name + ) + } + } +} diff --git a/app/src/main/java/org/monogram/MainActivity.kt b/app/src/main/java/org/monogram/MainActivity.kt index 8307d6c54..2988bc0b5 100644 --- a/app/src/main/java/org/monogram/MainActivity.kt +++ b/app/src/main/java/org/monogram/MainActivity.kt @@ -154,6 +154,7 @@ open class MainActivity : ComponentActivity() { bubbleChatId = if (this@MainActivity is BubbleActivity) { intent.getLongExtra(NotificationPresenter.EXTRA_CHAT_ID, 0L).takeIf { it != 0L } } else null, + liveLocations = app.liveLocations, onRootBack = { finish() }, ) if (pendingIncomingShare != null) { diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index d0750e269..5fba7a656 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -4,6 +4,7 @@ import android.app.Application import android.os.Build import androidx.compose.material3.ComposeMaterial3Flags import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.core.content.ContextCompat import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -36,6 +37,7 @@ import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.DownloadState import org.monogram.core.ui.FileLeases import org.monogram.core.ui.ImageCache +import org.monogram.core.ui.currentLocation import org.monogram.core.ui.perf.perfSpan import org.monogram.feature.settings.ProxySettingsStore import org.monogram.mtproto.MtprotoNativeLoader @@ -52,6 +54,8 @@ import org.monogram.network.http.TelegramInlineThumbPeek import org.monogram.network.http.TelegramMediaFetcher import org.monogram.network.http.TelegramPeerInlineThumbPeek import org.monogram.push.PushCoordinator +import org.monogram.root.LiveLocationRuntime +import org.monogram.root.editRetainedLiveLocation import org.monogram.sponsor.SponsorSyncManager import org.monogram.update.AndroidAppUpdateInstaller import org.monogram.update.AppUpdateManager @@ -78,6 +82,24 @@ class MonogramApp : Application() { private val settingsScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val ready = CompletableDeferred() + internal val liveLocations by lazy { + LiveLocationRuntime(activityScope, warmup, sample = { + currentLocation( + this, allowCached = false + )?.let { org.monogram.core.models.GeoPlace(it.first, it.second) } + }, permitted = { + ContextCompat.checkSelfPermission( + this, android.Manifest.permission.ACCESS_COARSE_LOCATION + ) == android.content.pm.PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + this, android.Manifest.permission.ACCESS_FINE_LOCATION + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + }, edit = { id, point -> + val epoch = warmup.outgoingAccountEpoch() + editRetainedLiveLocation( + client, id, point + ) { warmup.outgoingAccountIsCurrent(epoch) } + }) + } private val activityScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val startedActivities = mutableSetOf() private var activityIdleJob: Job? = null @@ -109,21 +131,19 @@ class MonogramApp : Application() { suspend fun awaitReady() = ready.await() - fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = - runBlocking { - try { - withTimeout(timeoutMs) { ready.await() } - true - } catch (_: Exception) { - false - } + fun awaitReadyBlocking(timeoutMs: Long = 20_000L): Boolean = runBlocking { + try { + withTimeout(timeoutMs) { ready.await() } + true + } catch (_: Exception) { + false } + } - fun launchWhenReady(block: suspend () -> Unit): Job = - settingsScope.launch { - ready.await() - block() - } + fun launchWhenReady(block: suspend () -> Unit): Job = settingsScope.launch { + ready.await() + block() + } private fun applyDownloadSettings(state: DownloadState) { client.applyDownloadConcurrency(state.effectiveLanes, state.parts) @@ -190,7 +210,11 @@ class MonogramApp : Application() { return StartupSteps( openDatabase = { db = perfSpan("app:database") { DatabaseProvider.get(this) } - warmup = OfflineWarmup(db) + warmup = OfflineWarmup( + db, outgoingMediaFiles = org.monogram.core.database.OutgoingMediaFiles( + File(noBackupFilesDir, "outgoing-media") + ) + ) sessionStore = SessionMetadataStore(db) }, installAppearance = { @@ -203,8 +227,13 @@ class MonogramApp : Application() { MtprotoNativeLoader.initializePlatformVerifier(this) val sessionFile = File(filesDir, "mtproto.session.json") perfSpan("app:tdlibImport") { - runCatching { TdlibSessionImport.maybeImport(filesDir, sessionFile, cacheDir) } - .onFailure { AppLog.warn("session", "tdlib import failed") } + runCatching { + TdlibSessionImport.maybeImport( + filesDir, + sessionFile, + cacheDir + ) + }.onFailure { AppLog.warn("session", "tdlib import failed") } } client = perfSpan("app:client") { BridgedMtprotoClient( @@ -218,18 +247,18 @@ class MonogramApp : Application() { 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 + 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() } @@ -244,29 +273,46 @@ class MonogramApp : Application() { perfSpan("app:imageCache") { ImageCache.install(this) } }, cleanup = { - runCatching { warmup.prepareStartupSnapshot(sessionStore) } - .onFailure { AppLog.warn("warmup", "startup cleanup failed") } + runCatching { warmup.prepareStartupSnapshot(sessionStore) }.onFailure { + AppLog.warn( + "warmup", + "startup cleanup failed" + ) + } }, prewarm = { if (BuildConfig.STARTUP_PREWARM) { val started = PerfLog.nowMs() when (val result = runCatching { client.connect() }.getOrNull()) { - is Outcome.Ok -> - PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=ok") + is Outcome.Ok -> PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=ok" + ) - is Outcome.Err -> - PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=err") + is Outcome.Err -> PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=err" + ) - null -> - PerfLog.mark("app:prewarm", PerfLog.nowMs() - started, "result=throw") + null -> PerfLog.mark( + "app:prewarm", + PerfLog.nowMs() - started, + "result=throw" + ) } } }, mediaMigration = { TdlibSessionImport.wipeTdlib(filesDir, cacheDir) if (!durableMedia.exists() && cacheMedia.exists()) { - runCatching { cacheMedia.copyRecursively(durableMedia, overwrite = false) } - .onFailure { AppLog.warn("media", "cache migration failed") } + runCatching { + cacheMedia.copyRecursively( + durableMedia, + overwrite = false + ) + }.onFailure { AppLog.warn("media", "cache migration failed") } } }, installDownloadSettings = { @@ -293,32 +339,19 @@ class MonogramApp : Application() { destPath: String, kind: MediaFetchKind, priority: Int, - ): Outcome = - when (kind) { - MediaFetchKind.Thumb -> - client.downloadMessageThumb( - chatId, - messageId, - destPath, - priority - ) + ): Outcome = when (kind) { + MediaFetchKind.Thumb -> client.downloadMessageThumb( + chatId, messageId, destPath, priority + ) - MediaFetchKind.Display -> - client.downloadMessageDisplay( - chatId, - messageId, - destPath, - priority - ) + MediaFetchKind.Display -> client.downloadMessageDisplay( + chatId, messageId, destPath, priority + ) - MediaFetchKind.Full -> - client.downloadMessageMedia( - chatId, - messageId, - destPath, - priority - ) - } + MediaFetchKind.Full -> client.downloadMessageMedia( + chatId, messageId, destPath, priority + ) + } }, customEmojiFetcher = { documentId, destPath, priority -> @@ -408,16 +441,28 @@ class MonogramApp : Application() { ) }, startPush = { - runCatching { push.start() } - .onFailure { AppLog.warn("startup", "push start failed") } + runCatching { push.start() }.onFailure { + AppLog.warn( + "startup", + "push start failed" + ) + } }, startSponsor = { - runCatching { sponsorSync.start() } - .onFailure { AppLog.warn("startup", "sponsor start failed") } + runCatching { sponsorSync.start() }.onFailure { + AppLog.warn( + "startup", + "sponsor start failed" + ) + } }, startAppUpdate = { - runCatching { appUpdate.start() } - .onFailure { AppLog.warn("startup", "app update start failed") } + runCatching { appUpdate.start() }.onFailure { + AppLog.warn( + "startup", + "app update start failed" + ) + } }, ) } @@ -453,12 +498,17 @@ private fun isEmulatorDevice(model: String): Boolean { val fingerprint = Build.FINGERPRINT.orEmpty() val product = Build.PRODUCT.orEmpty() val hardware = Build.HARDWARE.orEmpty() - return fingerprint.startsWith("generic") || - fingerprint.contains("emulator", ignoreCase = true) || - product.contains("sdk", ignoreCase = true) || - hardware.contains("goldfish", ignoreCase = true) || - hardware.contains("ranchu", ignoreCase = true) || - model.contains("sdk_gphone", ignoreCase = true) || - model.contains("emulator", ignoreCase = true) || - model.contains("google_sdk", ignoreCase = true) + return fingerprint.startsWith("generic") || fingerprint.contains( + "emulator", + ignoreCase = true + ) || product.contains("sdk", ignoreCase = true) || hardware.contains( + "goldfish", + ignoreCase = true + ) || hardware.contains("ranchu", ignoreCase = true) || model.contains( + "sdk_gphone", + ignoreCase = true + ) || model.contains("emulator", ignoreCase = true) || model.contains( + "google_sdk", + ignoreCase = true + ) } diff --git a/app/src/main/java/org/monogram/root/CloudDraftSync.kt b/app/src/main/java/org/monogram/root/CloudDraftSync.kt new file mode 100644 index 000000000..1616d0838 --- /dev/null +++ b/app/src/main/java/org/monogram/root/CloudDraftSync.kt @@ -0,0 +1,188 @@ +package org.monogram.root + +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.retryWhen +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.database.OfflineWarmup +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.PeerId + +internal class CloudDraftSync( + private val cache: OfflineWarmup, + private val fetch: suspend () -> Outcome>, + private val save: suspend (CloudDraft) -> Outcome, +) { + private val refreshPending = AtomicBoolean(true) + private val retryRejected = AtomicBoolean(false) + private val rejected = mutableMapOf, Long>() + private val wake = Channel(Channel.CONFLATED) + private var job: Job? = null + + fun start(scope: CoroutineScope) { + if (job?.isActive == true) return + job = scope.launch { + coroutineScope { + launch { cache.draftSync.retries.collect { requestRefresh() } } + launch { + cache.observeCloudDraftStates().retryWhen { cause, _ -> + if (cause is CancellationException) throw cause + currentCoroutineContext().ensureActive() + AppLog.warn("draft-sync", "observation failed") + cache.draftSync.fail(TelegramError.parse("INTERNAL")) + delay(60_000) + true + }.collect { wake.trySend(Unit) } + } + requestRefresh() + var backoff = 2_000L + while (isActive) { + wake.receive() + delay(400) + val retry = try { + syncOnce() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + currentCoroutineContext().ensureActive() + AppLog.warn("draft-sync", "synchronization failed") + cache.draftSync.fail(TelegramError.parse("INTERNAL")) + refreshPending.set(true) + backoff + } + if (retry > 0) { + delay(maxOf(retry, backoff)) + backoff = minOf(backoff * 2, 60_000L) + wake.trySend(Unit) + } else { + backoff = 2_000L + } + } + } + } + } + + suspend fun stop() { + val running = job + job = null + running?.cancelAndJoin() + cache.draftSync.reset() + } + + fun requestRefresh() { + refreshPending.set(true) + retryRejected.set(true) + wake.trySend(Unit) + } + + internal suspend fun syncOnce(): Long { + if (retryRejected.getAndSet(false)) rejected.clear() + if (refreshPending.getAndSet(false)) { + val before = + cache.cloudDraftStates().associateBy { it.draft.chatId to it.draft.threadId } + when (val result = monitored(null) { fetch() }) { + is Outcome.Err -> { + refreshPending.set(true) + return retryDelay(result) + } + + is Outcome.Ok -> { + val returned = result.value.associateBy { it.chatId to it.threadId } + for ((key, draft) in returned) cache.applyCloudDraft( + draft, + before[key]?.revision ?: 0 + ) + for ((key, state) in before) { + if (key !in returned) cache.applyCloudDraft( + CloudDraft( + key.first, + key.second + ), state.revision + ) + } + } + } + } + val states = cache.cloudDraftStates() + rejected.keys.retainAll(states.map { it.draft.chatId to it.draft.threadId }.toSet()) + for (snapshot in states) { + val key = snapshot.draft.chatId to snapshot.draft.threadId + val state = cache.cloudDraftState(key.first, key.second) + if (!state.dirty || state.draft.hasUnsupportedContent) { + rejected.remove(key) + cache.draftSync.clearError(key) + continue + } + if (rejected[key] == state.revision) continue + when (val result = monitored(key) { + save(state.draft).also { + if (it is Outcome.Ok) cache.acknowledgeCloudDraft( + state.draft.chatId, + state.draft.threadId, + state.revision + ) + } + }) { + is Outcome.Ok -> rejected.remove(key) + is Outcome.Err -> { + val error = result.telegramError + if (error.kind in setOf( + TelegramError.Kind.Flood, + TelegramError.Kind.Network, + TelegramError.Kind.Auth, + TelegramError.Kind.Session, + TelegramError.Kind.Internal + ) || + error.httpCode in 500..599 || (error.retryAfterSeconds ?: 0) > 0 + ) { + cache.draftSync.fail(error) + refreshPending.set(true) + return retryDelay(result) + } + rejected[key] = state.revision + } + } + } + return 0 + } + + private suspend fun monitored( + context: Pair?, + block: suspend () -> Outcome + ): Outcome { + val token = cache.draftSync.begin(context) + var error: TelegramError? = null + val operation = if (context == null) "fetch" else "save" + AppLog.api("draft-sync", "$operation started") + try { + return block().also { + error = (it as? Outcome.Err)?.telegramError + if (error == null) AppLog.api("draft-sync", "$operation completed") + else AppLog.warn("draft-sync", "$operation failed kind=${error?.kind}") + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + AppLog.warn("draft-sync", "$operation failed unexpectedly") + throw failure + } finally { + cache.draftSync.finish(context, token, error) + } + } + + private fun retryDelay(error: Outcome.Err): Long = + maxOf(2_000L, (error.telegramError.retryAfterSeconds?.toLong() ?: 0) * 1_000L) +} diff --git a/app/src/main/java/org/monogram/root/LiveLocationRuntime.kt b/app/src/main/java/org/monogram/root/LiveLocationRuntime.kt new file mode 100644 index 000000000..243f79f52 --- /dev/null +++ b/app/src/main/java/org/monogram/root/LiveLocationRuntime.kt @@ -0,0 +1,87 @@ +package org.monogram.root + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.GeoPlace +import org.monogram.core.models.MessageId +import java.util.concurrent.ConcurrentHashMap + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class LiveLocationRuntime internal constructor( + private val scope: CoroutineScope, + private val cache: OfflineWarmup, + private val sample: suspend () -> GeoPlace?, + private val permitted: () -> Boolean, + private val edit: suspend (MessageId, GeoPlace?) -> Outcome, +) { + private val roots = ConcurrentHashMap.newKeySet() + private val mutex = Mutex() + private val controllers = MutableStateFlow(null) + private var owner: LiveLocationSync? + get() = controllers.value + set(value) { + controllers.value = value + } + val errors = controllers.flatMapLatest { + it?.errors + ?: flowOf(emptyMap()) + } + val failure = controllers.flatMapLatest { it?.failure ?: flowOf(null) } + private var epoch: Long? = null + + internal fun setForeground(root: Any, visible: Boolean) { + if (visible) roots.add(root) else roots.remove(root) + scope.launch { + mutex.withLock { + owner?.setForeground(roots.isNotEmpty()) + if (roots.isEmpty()) owner?.stop() else owner?.start(scope) + } + } + } + + internal suspend fun startAuthorized() = mutex.withLock { + val current = cache.outgoingAccountEpoch() + if (!cache.outgoingAccountIsCurrent(current)) return@withLock + if (epoch != current) { + owner?.stop() + owner = LiveLocationSync(cache, sample, permitted, edit) + epoch = current + } + owner?.setForeground(roots.isNotEmpty()) + if (roots.isNotEmpty()) owner?.start(scope) + } + + private suspend fun authorizedOwner(expectedEpoch: Long?): LiveLocationSync? = mutex.withLock { + val current = cache.outgoingAccountEpoch() + owner?.takeIf { + epoch == current && (expectedEpoch == null || expectedEpoch == current) && cache.outgoingAccountIsCurrent( + current + ) + } + } + + internal suspend fun resume(id: MessageId, expectedEpoch: Long? = null): Boolean { + val current = authorizedOwner(expectedEpoch) ?: return false + return mutex.withLock { + if (owner !== current || roots.isEmpty() || !current.start(scope)) false else current.resume( + id + ) + } + } + + internal suspend fun requestStop(id: MessageId, expectedEpoch: Long? = null): Boolean = + authorizedOwner(expectedEpoch)?.requestStop(id) == true + + internal suspend fun stopAuthorized() = mutex.withLock { + owner?.stop() + owner = null + epoch = null + } +} diff --git a/app/src/main/java/org/monogram/root/LiveLocationSync.kt b/app/src/main/java/org/monogram/root/LiveLocationSync.kt new file mode 100644 index 000000000..e84f2e1ec --- /dev/null +++ b/app/src/main/java/org/monogram/root/LiveLocationSync.kt @@ -0,0 +1,324 @@ +package org.monogram.root + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.GeoPlace +import org.monogram.core.models.LiveLocationPhase +import org.monogram.core.models.LiveLocationSession +import org.monogram.core.models.MessageId +import org.monogram.core.models.ServiceMedia +import org.monogram.network.bridge.MtprotoClient +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +internal suspend fun editRetainedLiveLocation( + client: MtprotoClient, + id: MessageId, + point: GeoPlace?, + isCurrent: () -> Boolean = { true }, +): Outcome { + if (!isCurrent()) return Outcome.Err("RPC 500: INTERNAL") + + val result = client.editLiveLocation( + id.chatId, id.id, point?.latitude, point?.longitude, point?.heading, stopped = point == null + ) + + val error = (result as? Outcome.Err)?.telegramError + + if (point != null || result is Outcome.Ok || !isCurrent() || error?.kind == TelegramError.Kind.Flood || error?.requiresReauth == true) return result + + val read = client.getMessage(id.chatId, id.id) + val message = (read as? Outcome.Ok)?.value ?: return result + val place = ServiceMedia.geo(message.fileName) + + return if (isCurrent() && message.id == id && message.outgoing && !message.pending && !message.failed && message.mediaKind == "geo" && place != null && (!place.live || place.periodSeconds <= 0)) Outcome.Ok( + Unit + + ) else result +} + +internal class LiveLocationSync( + private val cache: OfflineWarmup, + private val sample: suspend () -> GeoPlace?, + private val permitted: () -> Boolean, + private val edit: suspend (MessageId, GeoPlace?) -> Outcome, + private val wallMillis: () -> Long = System::currentTimeMillis, + private val elapsedMillis: () -> Long = { System.nanoTime() / 1_000_000 }, +) { + private val rpc = Mutex() + private val cycle = Mutex() + private val lastAttempt = ConcurrentHashMap() + private val expiryElapsed = ConcurrentHashMap() + private val mutableErrors = MutableStateFlow>(emptyMap()) + private val mutableFailure = MutableStateFlow(null) + val failure: StateFlow = mutableFailure + val errors: StateFlow> = mutableErrors + + @Volatile + private var foreground = false + private val epoch = cache.outgoingAccountEpoch() + private val acquisition = + AtomicReference>?>() + private var job: Job? = null + + @Volatile + private var blockedUntilElapsed = 0L + + suspend fun start(scope: CoroutineScope): Boolean { + if (!cache.outgoingAccountIsCurrent(epoch)) return false + if (job?.isActive == true) return mutableFailure.value == null + mutableFailure.value = null + try { + cache.recoverLiveLocations(epoch) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + mutableFailure.value = TelegramError.parse("RPC 500: INTERNAL"); return false + } + job = scope.launch { + try { + while (isActive && cache.outgoingAccountIsCurrent(epoch)) { + syncOnce() + delay(15_000) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + mutableFailure.value = TelegramError.parse("RPC 500: INTERNAL") + } finally { + withContext(NonCancellable) { safePause() } + } + } + return job?.isActive == true + } + + suspend fun stop() { + foreground = false + acquisition.get()?.second?.cancelAndJoin() + job?.cancelAndJoin() + job = null + safePause() + } + + suspend fun setForeground(value: Boolean) { + foreground = value + if (!value) { + acquisition.get()?.second?.cancelAndJoin() + safePause() + } + } + + suspend fun resume(id: MessageId): Boolean { + if (!foreground || !permitted() || !cache.outgoingAccountIsCurrent(epoch) || blocked()) return false + val current = cache.liveLocation(id, epoch) ?: return false + if (current.phase != LiveLocationPhase.PAUSED || expired(current)) return false + val changed = + cache.transitionLiveLocation(id, current.revision, LiveLocationPhase.ACTIVE, epoch) + if (changed) mutableErrors.update { it - id } + return changed + } + + suspend fun requestStop(id: MessageId): Boolean { + if (!cache.outgoingAccountIsCurrent(epoch)) return false + val current = cache.liveLocation(id, epoch) ?: return false + if (current.phase !in setOf( + LiveLocationPhase.ACTIVE, LiveLocationPhase.PAUSED, LiveLocationPhase.UNCERTAIN_STOP + ) + ) return false + if (!cache.transitionLiveLocation( + id, current.revision, LiveLocationPhase.STOPPING, epoch + ) + ) return false + val claimedRevision = current.revision + 1 + try { + acquisition.get()?.takeIf { it.first == id }?.second?.cancelAndJoin() + return rpc.withLock { + if (!cache.outgoingAccountIsCurrent(epoch)) return@withLock false + val stopping = cache.liveLocation(id, epoch) ?: return@withLock false + if (stopping.phase != LiveLocationPhase.STOPPING || stopping.revision != claimedRevision) return@withLock false + if (blocked()) { + cache.transitionLiveLocation( + id, stopping.revision, LiveLocationPhase.UNCERTAIN_STOP, epoch + ) + return@withLock false + } + val result = try { + edit(id, null) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Outcome.Err("RPC 500: INTERNAL") + } + when (result) { + is Outcome.Ok -> { + cache.transitionLiveLocation( + id, stopping.revision, LiveLocationPhase.STOPPED, epoch + ) + mutableErrors.update { it - id } + true + } + + is Outcome.Err -> { + failed(id, result.telegramError) + cache.transitionLiveLocation( + id, stopping.revision, LiveLocationPhase.UNCERTAIN_STOP, epoch + ) + false + } + } + } + } finally { + withContext(NonCancellable) { + if (cache.outgoingAccountIsCurrent(epoch)) cache.liveLocation(id, epoch)?.let { + if (it.phase == LiveLocationPhase.STOPPING && it.revision == claimedRevision) cache.transitionLiveLocation( + id, it.revision, LiveLocationPhase.UNCERTAIN_STOP, epoch + ) + } + } + } + } + + internal suspend fun syncOnce() = cycle.withLock { + if (!cache.outgoingAccountIsCurrent(epoch)) return@withLock + val sessions = cache.observeLiveLocations().first() + for (session in sessions) { + if (!cache.outgoingAccountIsCurrent(epoch)) return@withLock + if (expired(session)) { + cache.transitionLiveLocation( + session.messageId, session.revision, LiveLocationPhase.EXPIRED, epoch + ) + continue + } + if (session.phase != LiveLocationPhase.ACTIVE) continue + if (!foreground || !permitted()) { + pauseActive(); return@withLock + } + val previous = lastAttempt[session.messageId] + if (blocked() || (previous != null && elapsedMillis() - previous < 15_000)) continue + val point = acquire(session.messageId) + rpc.withLock send@{ + if (!cache.outgoingAccountIsCurrent(epoch)) return@send + val current = cache.liveLocation(session.messageId, epoch) ?: return@send + if (current.revision != session.revision || current.phase != LiveLocationPhase.ACTIVE || blocked()) return@send + if (!foreground || !permitted()) { + pauseActive(); return@send + } + if (expired(current)) { + cache.transitionLiveLocation( + current.messageId, current.revision, LiveLocationPhase.EXPIRED, epoch + ) + return@send + } + if (point == null || !point.latitude.isFinite() || !point.longitude.isFinite() || point.latitude !in -90.0..90.0 || point.longitude !in -180.0..180.0 || (point.heading != null && point.heading !in 1..360)) { + cache.transitionLiveLocation( + current.messageId, current.revision, LiveLocationPhase.PAUSED, epoch + ) + return@send + } + lastAttempt[current.messageId] = elapsedMillis() + val result = try { + edit(current.messageId, point) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Outcome.Err("RPC 500: INTERNAL") + } + if (result is Outcome.Err && cache.outgoingAccountIsCurrent(epoch)) { + failed(current.messageId, result.telegramError) + cache.transitionLiveLocation( + current.messageId, current.revision, LiveLocationPhase.PAUSED, epoch + ) + } + } + } + } + + private fun expired(session: LiveLocationSession): Boolean { + val deadline = session.expiresAt ?: return false + val elapsedDeadline = expiryElapsed.computeIfAbsent(session.messageId) { + elapsedMillis() + maxOf(0, Math.multiplyExact(deadline, 1000) - wallMillis()) + } + return session.expired(wallMillis() / 1000) || elapsedMillis() >= elapsedDeadline + } + + private suspend fun acquire(id: MessageId): GeoPlace? = coroutineScope { + val child = async(start = CoroutineStart.LAZY) { + try { + withTimeoutOrNull(8_000) { sample() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } + } + val token = id to child + acquisition.set(token) + try { + if (!foreground || !permitted() || !cache.outgoingAccountIsCurrent(epoch) || cache.liveLocation( + id, + epoch + )?.phase != LiveLocationPhase.ACTIVE + ) child.cancel() + child.await() + } catch (cancelled: CancellationException) { + currentCoroutineContext().ensureActive() + null + } finally { + acquisition.compareAndSet(token, null); child.cancel() + } + } + + private suspend fun blocked() = + elapsedMillis() < blockedUntilElapsed || wallMillis() < cache.outgoingSendBlockedUntil() + + private suspend fun failed(id: MessageId, error: TelegramError) { + mutableErrors.update { it + (id to error) } + error.retryAfterSeconds?.takeIf { it > 0 }?.let { seconds -> + val wait = seconds.toLong() * 1000 + blockedUntilElapsed = maxOf(blockedUntilElapsed, elapsedMillis() + wait) + cache.blockScheduledQueueUntil(wallMillis() + wait, epoch) + } + } + + private suspend fun safePause() { + try { + pauseActive() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + mutableFailure.value = TelegramError.parse("RPC 500: INTERNAL") + } + } + + private suspend fun pauseActive() { + if (!cache.outgoingAccountIsCurrent(epoch)) return + for (session in cache.observeLiveLocations().first()) { + if (session.phase == LiveLocationPhase.ACTIVE && cache.outgoingAccountIsCurrent(epoch)) cache.transitionLiveLocation( + session.messageId, session.revision, LiveLocationPhase.PAUSED, epoch + ) + } + } +} diff --git a/app/src/main/java/org/monogram/root/OutgoingMediaSend.kt b/app/src/main/java/org/monogram/root/OutgoingMediaSend.kt new file mode 100644 index 000000000..86a35b77c --- /dev/null +++ b/app/src/main/java/org/monogram/root/OutgoingMediaSend.kt @@ -0,0 +1,97 @@ +package org.monogram.root + +import org.monogram.core.common.Outcome +import org.monogram.core.database.StoredPayloadException +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.models.MessageSendOptions +import org.monogram.core.models.PeerId +import org.monogram.core.models.TextEntities +import org.monogram.core.models.UploadItem +import org.monogram.network.bridge.message.MessageOps + +private val supportedCaptionEntities = setOf( + "bold", + "heading", + "italic", + "underline", + "strike", + "code", + "pre", + "spoiler", + "blockquote", + "url", + "mention", + "hashtag", + "bot_command", + "email", + "phone", + "cashtag", + "bank_card", + "text_url", + "custom_emoji", + "mention_name" +) + +internal suspend fun sendRetainedMedia( + cache: OfflineWarmup, client: MessageOps, row: OutgoingMediaSend +): Outcome { + val epoch = cache.outgoingAccountEpoch() + val items = prepareOutgoing { + row.copy(batch = row.batch.copy(phase = OutgoingSendPhase.READY)).requireValid() + check(cache.outgoingAccountIsCurrent(epoch)) + val files = checkNotNull(cache.outgoingMediaFiles) + row.items.sortedBy { it.position }.map { item -> + val message = item.message + val entities = TextEntities.parse(message.entitiesJson) + if (!message.entitiesJson.isNullOrBlank() && message.entitiesJson != "[]" && + TextEntities.serialize(entities) != message.entitiesJson + ) throw StoredPayloadException() + if (entities.any { + it.kind !in supportedCaptionEntities || it.offset < 0 || it.length <= 0 || + it.offset.toLong() + it.length > message.text.orEmpty().length + }) throw StoredPayloadException() + UploadItem( + path = files.verifiedFile(item.source).absolutePath, + kind = message.mediaKind ?: throw StoredPayloadException(), + mimeType = item.mimeType, + fileName = message.fileName.orEmpty(), + caption = message.text.orEmpty(), + duration = message.mediaDuration ?: 0, + width = message.mediaWidth ?: 0, + height = message.mediaHeight ?: 0, + randomId = item.randomId, + captionEntities = entities + ) + } + } + prepareOutgoing { check(cache.outgoingAccountIsCurrent(epoch)) } + val first = row.items.minBy { it.position }.message + val options = + MessageSendOptions(silent = row.batch.silent, scheduleDate = row.batch.scheduleDate) + return if (row.batch.album) { + when (val result = client.sendUploadedAlbumWithOptions( + PeerId(row.batch.chatId), + items, + options, + replyToMsgId = first.replyToMsgId ?: 0, + topMsgId = first.replyToTopId ?: 0 + )) { + is Outcome.Ok -> Outcome.Ok(Unit) + is Outcome.Err -> result + } + } else { + when (val result = client.sendUploadedMediaWithOptions( + PeerId(row.batch.chatId), + items.single(), + options, + replyToMsgId = first.replyToMsgId ?: 0, + topMsgId = first.replyToTopId ?: 0, + entitiesJson = first.entitiesJson + )) { + is Outcome.Ok -> Outcome.Ok(Unit) + is Outcome.Err -> result + } + } +} diff --git a/app/src/main/java/org/monogram/root/OutgoingPreparation.kt b/app/src/main/java/org/monogram/root/OutgoingPreparation.kt new file mode 100644 index 000000000..f2ffe2c8f --- /dev/null +++ b/app/src/main/java/org/monogram/root/OutgoingPreparation.kt @@ -0,0 +1,19 @@ +package org.monogram.root + +import kotlinx.coroutines.CancellationException +import java.io.IOException + +internal class OutgoingPreparationException(cause: Exception) : + IOException("Outgoing message could not be prepared", cause) + +internal suspend fun prepareOutgoing(block: suspend () -> T): T = try { + block() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (error: IOException) { + throw OutgoingPreparationException(error) +} catch (error: IllegalArgumentException) { + throw OutgoingPreparationException(error) +} catch (error: IllegalStateException) { + throw OutgoingPreparationException(error) +} diff --git a/app/src/main/java/org/monogram/root/OutgoingTextSync.kt b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt new file mode 100644 index 000000000..a564160b5 --- /dev/null +++ b/app/src/main/java/org/monogram/root/OutgoingTextSync.kt @@ -0,0 +1,300 @@ +package org.monogram.root + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.StoredPayloadException +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.decodeContactSend +import org.monogram.core.database.decodeLocationSend +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.models.ChecklistSendSpec +import org.monogram.core.models.Checklists +import org.monogram.core.models.Dice +import org.monogram.core.models.OutgoingMessageResult +import org.monogram.core.models.ServiceMedia +import org.monogram.network.bridge.message.MessageOps + +internal suspend fun sendRetainedMessage( + client: MessageOps, row: OutgoingTextSendEntity, +): Outcome { + val message = row.message + val options = org.monogram.core.models.MessageSendOptions( + silent = row.silent, scheduleDate = row.scheduleDate, noWebpage = row.noWebpage + ) + return if (message.mediaKind == "geo") { + val place = prepareOutgoing { + decodeLocationSend(message.fileName).also { + if (!message.text.isNullOrBlank() || row.webpageUrl != null || row.noWebpage || (it.live && row.scheduleDate != null)) throw StoredPayloadException() + } + } + client.sendLocationWithOptions( + org.monogram.core.models.PeerId(message.chatId), + place, + row.randomId, + options, + replyToMsgId = message.replyToMsgId ?: 0, + topMsgId = message.replyToTopId ?: 0 + ) + } else if (message.mediaKind == "contact") { + val spec = prepareOutgoing { + decodeContactSend(message.fileName).also { + if (message.text != it.displayName || row.webpageUrl != null || row.noWebpage) throw StoredPayloadException() + } + } + client.sendContactWithOptions( + org.monogram.core.models.PeerId(message.chatId), + spec, + row.randomId, + options, + replyToMsgId = message.replyToMsgId ?: 0, + topMsgId = message.replyToTopId ?: 0 + ) + } else if (message.mediaKind == "todo") { + val spec = prepareOutgoing { + ChecklistSendSpec.fromPending( + Checklists.parse(message.checklistJson) ?: throw StoredPayloadException() + ).also { + if (message.text != it.title || row.webpageUrl != null || row.noWebpage) throw StoredPayloadException() + } + } + client.sendChecklistWithOptions( + org.monogram.core.models.PeerId(message.chatId), + spec, + row.randomId, + options, + replyToMsgId = message.replyToMsgId ?: 0, + topMsgId = message.replyToTopId ?: 0 + ) + } else if (message.mediaKind == "poll") { + val spec = prepareOutgoing { + org.monogram.core.database.decodePollSend(message.fileName).also { + if (message.text != it.question || row.webpageUrl != null || row.noWebpage) throw StoredPayloadException() + } + } + client.sendPollWithOptions( + org.monogram.core.models.PeerId(message.chatId), + spec, + row.randomId, + options, + replyToMsgId = message.replyToMsgId ?: 0, + topMsgId = message.replyToTopId ?: 0 + ) + } else if (message.mediaKind == "dice") { + val dice = prepareOutgoing { + (ServiceMedia.dice(message.fileName) ?: throw StoredPayloadException()).also { + if (it.value != 0 || it.emoticon !in Dice.supportedEmoticons) throw StoredPayloadException() + } + } + client.sendDiceWithOptions( + org.monogram.core.models.PeerId(message.chatId), + dice.emoticon, + row.randomId, + options, + replyToMsgId = message.replyToMsgId ?: 0, + topMsgId = message.replyToTopId ?: 0 + ) + } else client.sendTextWithOptions( + org.monogram.core.models.PeerId(message.chatId), + message.text.orEmpty(), + options, + row.randomId, + replyToMsgId = message.replyToMsgId ?: 0, + entitiesJson = message.entitiesJson, + topMsgId = message.replyToTopId ?: 0, + webpageUrl = row.webpageUrl + ) +} + +internal class OutgoingTextSync( + private val cache: OfflineWarmup, + private val send: suspend (OutgoingTextSendEntity) -> Outcome, + private val wallMillis: () -> Long = System::currentTimeMillis, + private val elapsedMillis: () -> Long = { System.nanoTime() / 1_000_000 }, + private val sendMedia: suspend (OutgoingMediaSend) -> Outcome = { Outcome.Err("unsupported") }, +) { + private val wake = Channel(Channel.CONFLATED) + private var job: Job? = null + private var paused = false + private var blockedUntil = 0L + + fun start(scope: CoroutineScope) { + if (job?.isActive == true) return + paused = false + job = scope.launch { + while (isActive) { + try { + cache.recoverInterruptedTextSends() + cache.recoverInterruptedMediaSends() + cache.recoverOutgoingMediaFiles(cache.outgoingAccountEpoch()) + break + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + cache.outgoingSendSync.fail(TelegramError.parse("INTERNAL")) + delay(60_000) + } + } + coroutineScope { + launch { cache.outgoingSendSync.retries.collect { requestResume() } } + launch { + combine( + cache.observeOutgoingTextSends(), cache.observeOutgoingMediaSends() + ) { text, media -> + text.map { it.randomId }.toSet() + media.map { it.batch.batchId } + }.retryWhen { error, _ -> + if (error is CancellationException) throw error + cache.outgoingSendSync.fail(TelegramError.parse("INTERNAL")) + delay(60_000) + true + }.collect { ids -> + cache.outgoingSendSync.retain(ids) + wake.trySend(Unit) + } + } + requestResume() + while (isActive) { + wake.receive() + if (paused) continue + try { + val wait = syncOnce() + if (wait > 0) { + delay(wait); wake.trySend(Unit) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + currentCoroutineContext().ensureActive() + paused = true + cache.outgoingSendSync.fail(TelegramError.parse("INTERNAL")) + } + } + } + } + } + + fun requestResume() { + paused = false; wake.trySend(Unit) + } + + suspend fun stop() { + val running = job + job = null + running?.cancelAndJoin() + cache.outgoingSendSync.reset() + } + + internal suspend fun syncOnce(): Long { + val persistedWait = (cache.outgoingSendBlockedUntil() - wallMillis()).coerceAtLeast(0) + val wait = maxOf(persistedWait, (blockedUntil - elapsedMillis()).coerceAtLeast(0)) + if (wait > 0) return wait + if (paused) return 0 + cache.outgoingSendSync.fail(null) + for (row in pendingSends()) { + currentCoroutineContext().ensureActive() + if (row.phase != OutgoingSendPhase.READY || !row.claim()) continue + cache.outgoingSendSync.begin(row.id) + var error: TelegramError? = null + try { + when (val result = row.send()) { + is Outcome.Ok -> Unit // Native durable receipts settle both kinds of outbox row. + is Outcome.Err -> { + if (!row.exists()) continue + error = result.telegramError + row.markUncertain() + val retry = + (error.retryAfterSeconds?.toLong() ?: 0).coerceAtLeast(0) * 1_000L + if (retry > 0) { + blockedUntil = maxOf(blockedUntil, elapsedMillis() + retry) + cache.blockOutgoingSendsUntil(wallMillis() + retry) + cache.outgoingSendSync.fail(error) + return retry + } + if (error.kind in setOf( + TelegramError.Kind.Network, + TelegramError.Kind.Auth, + TelegramError.Kind.Session, + TelegramError.Kind.Internal + ) || error.httpCode in 500..599 + ) { + paused = true + cache.outgoingSendSync.fail(error) + return 0 + } + } + } + } catch (_: OutgoingPreparationException) { + row.markFailed() + } catch (cancelled: CancellationException) { + withContext(NonCancellable) { row.markUncertain() } + throw cancelled + } catch (_: Exception) { + currentCoroutineContext().ensureActive() + row.markUncertain() + error = TelegramError.parse("INTERNAL") + paused = true + cache.outgoingSendSync.fail(error) + return 0 + } finally { + cache.outgoingSendSync.finish(row.id, error) + } + } + return 0 + } + + private data class PendingSend( + val id: Long, val phase: OutgoingSendPhase, val date: Long, + val claim: suspend () -> Boolean, val markUncertain: suspend () -> Unit, + val markFailed: suspend () -> Unit, + val exists: suspend () -> Boolean, val send: suspend () -> Outcome, + ) + + private suspend fun pendingSends(): List { + val text = cache.outgoingTextSends().map { row -> + PendingSend( + row.randomId, + row.phase, + row.message.date, + claim = { cache.claimOutgoingTextSend(row.randomId) }, + markUncertain = { cache.markOutgoingTextSendUncertain(row.randomId) }, + markFailed = { cache.markOutgoingTextSendFailed(row.randomId) }, + exists = { cache.outgoingTextSends().any { it.randomId == row.randomId } }, + send = { + when (val result = send(row)) { + is Outcome.Ok -> Outcome.Ok(Unit) + is Outcome.Err -> result + } + }) + } + val media = cache.outgoingMediaSends().map { row -> + PendingSend( + row.batch.batchId, + row.batch.phase, + row.items.minOfOrNull { it.message.date } ?: row.batch.createdAt, + claim = { cache.claimOutgoingMediaSend(row.batch.batchId) }, + markUncertain = { cache.markOutgoingMediaSendUncertain(row.batch.batchId) }, + markFailed = { cache.markOutgoingMediaSendFailed(row.batch.batchId) }, + exists = { + cache.outgoingMediaSends().any { it.batch.batchId == row.batch.batchId } + }, + send = { sendMedia(row) }) + } + return (text + media).sortedWith(compareBy({ it.date }, { it.id })) + } +} diff --git a/app/src/main/java/org/monogram/root/RecipientPickerComponent.kt b/app/src/main/java/org/monogram/root/RecipientPickerComponent.kt index de6f4e677..d836df55d 100644 --- a/app/src/main/java/org/monogram/root/RecipientPickerComponent.kt +++ b/app/src/main/java/org/monogram/root/RecipientPickerComponent.kt @@ -19,7 +19,6 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.UploadItem import org.monogram.core.models.userFacingShareText import org.monogram.network.bridge.MtprotoClient -import org.monogram.network.http.MediaRepository @Serializable data class RecipientRequest( @@ -30,7 +29,8 @@ data class RecipientRequest( ) { val forwarding: Boolean get() = fromChatId != null && messageIds.isNotEmpty() val needsPhotoSendRight: Boolean - get() = requiresPhotos || share?.attachments.orEmpty().any { it.kind == "photo" || it.kind == "video" } + get() = requiresPhotos || share?.attachments.orEmpty() + .any { it.kind == "photo" || it.kind == "video" } } data class RecipientPickerState( @@ -63,23 +63,32 @@ class RecipientPickerComponent( val request: RecipientRequest, client: MtprotoClient, warmup: OfflineWarmup?, - val mediaRepository: MediaRepository?, private val close: () -> Unit, ) : ComponentContext by context { private val restored = stateKeeper.consume("recipient-progress", RecipientProgress.serializer()) - private val sender = instanceKeeper.getOrCreate { RecipientSender(request, client, warmup, restored) } + private val sender = + instanceKeeper.getOrCreate { RecipientSender(request, client, warmup, restored) } val state: StateFlow = sender.state fun onComment(value: String) = sender.update { if (started) this else copy(comment = value) } - fun onDropAuthor(value: Boolean) = sender.update { if (started) this else copy(dropAuthor = value) } + fun onDropAuthor(value: Boolean) = + sender.update { if (started) this else copy(dropAuthor = value) } + fun onToggle(id: Long) = sender.update { if (started) this else copy(selected = if (id in selected) selected - id else selected + id) } + fun onSend() = sender.send() - fun onClose() { if (!state.value.sending) close() } + fun onClose() { + if (!state.value.sending) close() + } init { - stateKeeper.register("recipient-progress", RecipientProgress.serializer()) { sender.progress() } - backHandler.register(object : com.arkivanov.essenty.backhandler.BackCallback(isEnabled = true) { + stateKeeper.register( + "recipient-progress", + RecipientProgress.serializer() + ) { sender.progress() } + backHandler.register(object : + com.arkivanov.essenty.backhandler.BackCallback(isEnabled = true) { override fun onBack() = onClose() }) } @@ -92,20 +101,26 @@ private class RecipientSender( restored: RecipientProgress?, ) : InstanceKeeper.Instance { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - val state = MutableStateFlow(RecipientPickerState( - comment = restored?.comment ?: userFacingShareText(request.share?.text.orEmpty()), - selected = restored?.selected.orEmpty(), completed = restored?.completed.orEmpty(), - dropAuthor = restored?.dropAuthor ?: false, started = restored?.started ?: false, - interrupted = restored?.inFlight ?: false, done = restored?.done ?: false, - )) + val state = MutableStateFlow( + RecipientPickerState( + comment = restored?.comment ?: userFacingShareText(request.share?.text.orEmpty()), + selected = restored?.selected.orEmpty(), completed = restored?.completed.orEmpty(), + dropAuthor = restored?.dropAuthor ?: false, started = restored?.started ?: false, + interrupted = restored?.inFlight ?: false, done = restored?.done ?: false, + ) + ) private val nextStep = restored?.nextStep.orEmpty().toMutableMap() fun progress() = state.value.let { - RecipientProgress(it.selected, it.completed, it.comment, it.dropAuthor, nextStep.toMap(), it.started, - it.sending || it.interrupted, it.done) + RecipientProgress( + it.selected, it.completed, it.comment, it.dropAuthor, nextStep.toMap(), it.started, + it.sending || it.interrupted, it.done + ) } override fun onDestroy() = scope.cancel() - fun update(block: RecipientPickerState.() -> RecipientPickerState) { state.value = state.value.block() } + fun update(block: RecipientPickerState.() -> RecipientPickerState) { + state.value = state.value.block() + } fun send() { val snapshot = state.value @@ -120,9 +135,16 @@ private class RecipientSender( when (val result = operations[step]()) { is Outcome.Err -> { val mapped = mappedRecipientError(result) - update { copy(sending = false, error = true, errorMessage = mapped) } + update { + copy( + sending = false, + error = true, + errorMessage = mapped + ) + } return@launch } + is Outcome.Ok -> { step++ nextStep[id] = step @@ -133,22 +155,35 @@ private class RecipientSender( update { copy(completed = completed + id) } } update { copy(sending = false, done = true) } - } catch (e: CancellationException) { throw e + } catch (e: CancellationException) { + throw e } catch (_: Exception) { update { copy(sending = false, error = true, errorMessage = null) } } } } - private fun operations(peer: PeerId, snapshot: RecipientPickerState): List Outcome>> = + private fun operations( + peer: PeerId, + snapshot: RecipientPickerState + ): List Outcome>> = recipientSendSteps(request, snapshot.comment, snapshot.dropAuthor).map { step -> when (step) { is RecipientSendStep.Forward -> { - { client.forwardMessages(PeerId(step.fromChatId), step.messageIds, peer, step.dropAuthor) } + { + client.forwardMessages( + PeerId(step.fromChatId), + step.messageIds, + peer, + step.dropAuthor + ) + } } + is RecipientSendStep.Text -> { { client.sendText(peer, step.text).asMessages() } } + is RecipientSendStep.Upload -> { { client.sendUploadedMedia(peer, step.item).asMessages() } } @@ -165,6 +200,7 @@ internal sealed interface RecipientSendStep { val messageIds: List, val dropAuthor: Boolean, ) : RecipientSendStep + data class Text(val text: String) : RecipientSendStep data class Upload(val item: UploadItem) : RecipientSendStep } diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index 348994228..1f41f4343 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -9,6 +9,7 @@ import com.arkivanov.decompose.router.stack.navigate import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.replaceAll import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.Lifecycle import com.arkivanov.essenty.lifecycle.doOnDestroy import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.essenty.lifecycle.doOnStop @@ -33,6 +34,7 @@ import org.monogram.core.common.push.PushRegistration import org.monogram.core.common.telegram.TelegramLink import org.monogram.core.common.telegram.parseTelegramLink import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.RetainedMediaSource import org.monogram.core.database.SessionMetadataStore import org.monogram.core.database.entity.UpdateCursorEntity import org.monogram.core.markup.NativeMarkupParser @@ -48,6 +50,7 @@ import org.monogram.feature.auth.AuthComponent import org.monogram.feature.chats.ChatsComponent import org.monogram.feature.dialog.DialogComponent import org.monogram.feature.dialog.SelectedTargetWarmup +import org.monogram.feature.dialog.scheduled.ScheduledQueueComponent import org.monogram.feature.folders.FoldersComponent import org.monogram.feature.profile.ProfileComponent import org.monogram.feature.settings.AppUpdateController @@ -74,6 +77,7 @@ class RootComponent( private val onIncomingShareConsumed: () -> Unit = {}, val appUpdate: AppUpdateController? = null, private val bubbleChatId: Long? = null, + private val liveLocations: LiveLocationRuntime? = null, private val onRootBack: () -> Unit = {}, ) : ComponentContext by componentContext { @@ -87,8 +91,7 @@ class RootComponent( val storagePressure = (mediaRepository?.inflightBytes() ?: 0L) > 32L * 1024 * 1024 val runtime = Runtime.getRuntime() val free = runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory() - storagePressure || free < 32L * 1024 * 1024 || - RuntimePressure.blocked() + storagePressure || free < 32L * 1024 * 1024 || RuntimePressure.blocked() }, load = { peer, anchor, limit, priority -> val usedBefore = mediaRepository?.usedByteCount() ?: 0L @@ -100,8 +103,7 @@ class RootComponent( if (page is Outcome.Ok) warmup?.cacheHistoryPage(PeerId(peer), page.value) val used = (mediaRepository?.usedByteCount() ?: 0L) - usedBefore PerfLog.event( - "target_warmup", - "priority=$priority used_bytes=$used" + "target_warmup", "priority=$priority used_bytes=$used" ) }, ) @@ -112,6 +114,16 @@ class RootComponent( private val navigation = StackNavigation() private var expiringSession = false + private val draftSync = + warmup?.let { CloudDraftSync(it, client::getCloudDrafts, client::saveCloudDraft) } + private val outgoingTextSync = warmup?.let { cache -> + OutgoingTextSync(cache, send = { row -> + sendRetainedMessage(client, row) + }, sendMedia = { row -> sendRetainedMedia(cache, client, row) }) + } + private var rootForeground = + lifecycle.state == Lifecycle.State.STARTED || lifecycle.state == Lifecycle.State.RESUMED + private var draftSyncAuthorized = false private var accountFlagsVersion = 0L private var listDetailVisible = false private val incomingShareState = MutableStateFlow(null) @@ -173,11 +185,10 @@ class RootComponent( } } - fun hasIncomingShare(): Boolean = pendingIncomingShare != null || - stack.value.items.any { item -> - (item.configuration as? Config.Dialog)?.incomingShare != null || - (item.configuration as? Config.Recipients)?.request?.share != null - } + fun hasIncomingShare(): Boolean = + pendingIncomingShare != null || stack.value.items.any { item -> + (item.configuration as? Config.Dialog)?.incomingShare != null || (item.configuration as? Config.Recipients)?.request?.share != null + } fun cancelIncomingShare() { pendingIncomingShare = null @@ -282,11 +293,9 @@ class RootComponent( } } - is TelegramLink.PrivateChannel -> - openFromNotification(link.chatId, link.messageId) + is TelegramLink.PrivateChannel -> openFromNotification(link.chatId, link.messageId) - is TelegramLink.Invite, is TelegramLink.Share -> - AppLog.api("t.me", "link type ignored") + is TelegramLink.Invite, is TelegramLink.Share -> AppLog.api("t.me", "link type ignored") } } @@ -295,28 +304,46 @@ class RootComponent( if (expiringSession) return true val cursor = batch.cursor?.let { UpdateCursorEntity( - SessionMetadataStore.KEY_PRIMARY_CURSOR, - it.pts, - it.qts, - it.date, - it.seq + SessionMetadataStore.KEY_PRIMARY_CURSOR, it.pts, it.qts, it.date, it.seq ) } - return cache.applyDurableBatch( - batch.accountGeneration, - batch.batchId, - cursor, - batch.channelCursors + val releasedSources = mutableListOf() + val committed = cache.applyDurableBatch( + batch.accountGeneration, batch.batchId, cursor, batch.channelCursors ) { + val receipts = + batch.updates.filterIsInstance() + val media = cache.outgoingMediaSends() + .filter { send -> send.items.any { item -> receipts.any { it.randomId == item.randomId } } } + val mediaIds = media.flatMap { send -> send.items.map { it.randomId } }.toSet() batch.updates.forEach { + if (it is MtprotoUpdate.OutgoingMessageAcknowledged && it.randomId in mediaIds) return@forEach applyDurableUpdate( - cache, - it, + cache, it, batch.accountGeneration, batch.batchId + ) + } + media.forEach { send -> + val ids = send.items.map { it.randomId }.toSet() + cache.applyOutgoingMediaResults( + send.batch.batchId, + receipts.filter { it.randomId in ids }.map { it.randomId to it.result }, + cache.outgoingAccountEpoch(), batch.accountGeneration, batch.batchId ) + releasedSources += send.items.map { it.source } } } + if (committed && releasedSources.isNotEmpty()) { + try { + cache.outgoingMediaFiles?.release(releasedSources) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("outgoing", "retained media cleanup deferred") + } + } + return committed } private suspend fun applyDurableUpdate( @@ -326,37 +353,51 @@ class RootComponent( acceptedOrder: Long, ) { when (update) { - is MtprotoUpdate.NewMessage -> - cache.applyIncomingMessage(update.message, accountGeneration, acceptedOrder) + is MtprotoUpdate.NewMessage -> cache.applyIncomingMessage( + update.message, + accountGeneration, + acceptedOrder + ) + is MtprotoUpdate.OutgoingMessageAcknowledged -> cache.applyOutgoingResult( + update.randomId, update.result, accountGeneration, acceptedOrder, + ) + + is MtprotoUpdate.ScheduledMessageChanged -> cache.applyScheduledMessage(update.message) + is MtprotoUpdate.ScheduledMessagesDeleted -> cache.deleteScheduledMessages( + update.chatId, update.messageIds + ) + + is MtprotoUpdate.DraftChanged -> cache.applyCloudDraft(update.draft) is MtprotoUpdate.MessageEdited -> cache.applyMessageEdit(update.message) is MtprotoUpdate.MessagesDeleted -> cache.deleteMessages( - update.chatId, - update.messageIds + update.chatId, update.messageIds ) - is MtprotoUpdate.MessageReactions -> - cache.applyReactions(update.chatId, update.messageId, update.reactionsJson) + is MtprotoUpdate.MessageReactions -> cache.applyReactions( + update.chatId, + update.messageId, + update.reactionsJson + ) is MtprotoUpdate.ReadOutbox -> cache.applyOutboxRead(update.chatId, update.maxId) - is MtprotoUpdate.DiscussionInbox -> - cache.applyDiscussionRead(update.channelId, update.topMessageId, update.readMaxId) + is MtprotoUpdate.DiscussionInbox -> cache.applyDiscussionRead( + update.channelId, + update.topMessageId, + update.readMaxId + ) is MtprotoUpdate.ReadInbox -> cache.applyInboxRead( - update.chatId, - update.maxId, - update.stillUnread + update.chatId, update.maxId, update.stillUnread ) is MtprotoUpdate.ChatsChanged -> cache.upsertChats(update.chats) is MtprotoUpdate.UnreadMentionsDelta -> cache.addUnreadMentions( - update.chatId, - update.delta + update.chatId, update.delta ) is MtprotoUpdate.UnreadReactionsDelta -> cache.addUnreadReactions( - update.chatId, - update.delta + update.chatId, update.delta ) else -> Unit @@ -370,10 +411,13 @@ class RootComponent( stack.subscribe { childStack -> val dialog = childStack.active.configuration as? Config.Dialog pushRegistration?.onVisibleChat( - dialog?.chatId, - dialog?.threadTopMsgId?.takeIf { it > 0 }) + dialog?.chatId, dialog?.threadTopMsgId?.takeIf { it > 0 }) } lifecycle.doOnStart { + if (draftSyncAuthorized && !expiringSession) { + draftSync?.requestRefresh() + outgoingTextSync?.requestResume() + } if (!stack.value.active.configuration.isAuthArea()) { scope.launch { sessionStore?.readAuthorizedUserId()?.value?.let { @@ -385,7 +429,10 @@ class RootComponent( } } } - lifecycle.doOnDestroy { scope.cancel() } + lifecycle.doOnDestroy { + liveLocations?.setForeground(this@RootComponent, false) + scope.cancel() + } scope.launch(start = CoroutineStart.UNDISPATCHED) { client.sessionLost().collect { expireSession() } } @@ -397,6 +444,13 @@ class RootComponent( if (expiringSession) return@collect try { when (update) { + is MtprotoUpdate.SyncState -> { + if (!update.isSyncing && draftSyncAuthorized) { + draftSync?.requestRefresh() + outgoingTextSync?.requestResume() + } + } + is MtprotoUpdate.FoldersChanged -> warmup?.replaceFolders(update.folders) is MtprotoUpdate.ChatsChanged -> { warmup?.upsertChats(update.chats) @@ -404,14 +458,11 @@ class RootComponent( } is MtprotoUpdate.PeerStatus -> sessionStore?.updatePeerStatus( - update.userId.value, - update.status, - update.statusAt + update.userId.value, update.status, update.statusAt ) is MtprotoUpdate.PeerEmojiStatus -> sessionStore?.updatePeerEmojiStatus( - update.userId.value, - update.documentId + update.userId.value, update.documentId ) is MtprotoUpdate.ReadInbox -> { @@ -456,6 +507,16 @@ class RootComponent( val bridged = client as? BridgedMtprotoClient ?: return@launch val result = withContext(Dispatchers.IO) { bridged.isAuthorized() } if (result is Outcome.Ok && result.value) { + if (!expiringSession) { + warmup?.activateOutgoingAccount() + if (expiringSession) { + warmup?.deactivateOutgoingAccount(); return@launch + } + draftSyncAuthorized = true + draftSync?.start(scope) + outgoingTextSync?.start(scope) + startLiveLocations() + } if (!expiringSession && stack.value.active.configuration.isAuthArea()) { navigation.replaceAll(Config.Home) pendingIncomingShare?.let(::openIncomingShare) @@ -466,13 +527,26 @@ class RootComponent( } } lifecycle.doOnStart { + rootForeground = true + liveLocations?.setForeground(this@RootComponent, true) scope.launch(Dispatchers.IO) { client.updateStatus(offline = false) } } lifecycle.doOnStop { + rootForeground = false + liveLocations?.setForeground(this@RootComponent, false) scope.launch(Dispatchers.IO) { client.updateStatus(offline = true) } } } + private suspend fun startLiveLocations() { + liveLocations?.setForeground(this, rootForeground) + liveLocations?.startAuthorized() + } + + private suspend fun stopLiveLocations() { + liveLocations?.stopAuthorized() + } + private suspend fun refreshAccountFlags() { if (expiringSession) return val version = accountFlagsVersion @@ -499,6 +573,11 @@ class RootComponent( private suspend fun expireSession() { if (expiringSession) return expiringSession = true + draftSyncAuthorized = false + warmup?.deactivateOutgoingAccount() + draftSync?.stop() + outgoingTextSync?.stop() + stopLiveLocations() accountFlagsVersion++ accountState.isPremium = false // Destroy account screens and their collectors before clearing their cache. @@ -514,7 +593,7 @@ class RootComponent( private fun child(config: Config, context: ComponentContext): Child = when (config) { is Config.Recipients -> Child.Recipients( - RecipientPickerComponent(context, config.request, client, warmup, mediaRepository) { + RecipientPickerComponent(context, config.request, client, warmup) { navigation.pop() }, ) @@ -528,6 +607,15 @@ class RootComponent( expiringSession = false scope.launch { sessionStore?.saveAuthorized(session) + if (expiringSession) return@launch + warmup?.activateOutgoingAccount() + if (expiringSession) { + warmup?.deactivateOutgoingAccount(); return@launch + } + draftSyncAuthorized = true + draftSync?.start(scope) + outgoingTextSync?.start(scope) + startLiveLocations() pushRegistration?.setAccountUserId(session.userId.value) refreshAccountFlags() runCatching { pushRegistration?.reregister() } @@ -559,6 +647,7 @@ class RootComponent( is Config.Dialog -> { targetWarmup.promote(config.chatId) + val liveDialogEpoch = warmup?.outgoingAccountEpoch() val component = DialogComponent( componentContext = context, storeFactory = storeFactory, @@ -573,6 +662,18 @@ class RootComponent( markup = NativeMarkupParser(), isPremium = { accountState.isPremium }, onBack = ::onBack, + onOpenScheduledQueue = { + navigation.navigate { uniqueStack(it, Config.ScheduledQueue(config.chatId)) } + }, + onResumeLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } + ?.let { runtime -> { id -> runtime.resume(id, liveDialogEpoch) } }, + onStopLiveLocation = liveLocations?.takeIf { liveDialogEpoch != null } + ?.let { runtime -> { id -> runtime.requestStop(id, liveDialogEpoch) } }, + liveLocationErrors = liveLocations?.errors ?: kotlinx.coroutines.flow.flowOf( + emptyMap() + ), + liveLocationFailure = liveLocations?.failure + ?: kotlinx.coroutines.flow.flowOf(null), showBackButton = config.chatId != bubbleChatId || config.threadTopMsgId != 0, onOpenProfile = ::openProfile, onOpenChat = { peer, jump, threadTop -> @@ -606,6 +707,18 @@ class RootComponent( Child.Dialog(component) } + is Config.ScheduledQueue -> Child.ScheduledQueue( + ScheduledQueueComponent( + context, + storeFactory, + client, + warmup, + PeerId(config.chatId), + ::onBack, + mediaRepository, + ), + ) + is Config.Profile -> Child.Profile( ProfileComponent( componentContext = context, @@ -632,9 +745,24 @@ class RootComponent( buildStamp = "${BuildConfig.BUILD_TYPE} · ${BuildConfig.GIT_COMMIT}", onBack = { navigation.pop() }, onOpenProfile = ::openProfile, + onBeforeLogout = { + expiringSession = true + draftSyncAuthorized = false + warmup?.deactivateOutgoingAccount() + draftSync?.stop() + outgoingTextSync?.stop() + stopLiveLocations() + }, onLoggedOut = { - MediaPlaybackHolder.peek()?.stop() - navigation.replaceAll(Config.Auth) + expiringSession = true + draftSyncAuthorized = false + scope.launch { + draftSync?.stop() + outgoingTextSync?.stop() + stopLiveLocations() + MediaPlaybackHolder.peek()?.stop() + navigation.replaceAll(Config.Auth) + } }, pushRegistration = pushRegistration, debugNotifications = BuildConfig.DEBUG, @@ -659,6 +787,9 @@ class RootComponent( class Auth(val component: AuthComponent) : Child() object Proxy : Child() class Home(val component: HomeComponent) : Child() + class ScheduledQueue(val component: ScheduledQueueComponent) : + Child() + class Dialog(val component: DialogComponent) : Child() class Profile(val component: ProfileComponent) : Child() class Settings( @@ -691,6 +822,9 @@ class RootComponent( val incomingShare: IncomingShare? = null, ) : Config + @Serializable + data class ScheduledQueue(val chatId: Long) : Config + @Serializable data class Profile(val peerId: Long) : Config diff --git a/app/src/main/java/org/monogram/root/RootContent.kt b/app/src/main/java/org/monogram/root/RootContent.kt index 2167518c8..e5acf65bc 100644 --- a/app/src/main/java/org/monogram/root/RootContent.kt +++ b/app/src/main/java/org/monogram/root/RootContent.kt @@ -104,6 +104,7 @@ import org.monogram.core.ui.perf.RecompositionProbe import org.monogram.feature.auth.ui.AuthContent import org.monogram.feature.chats.ui.ChatsContent import org.monogram.feature.dialog.DialogComponent +import org.monogram.feature.dialog.scheduled.ScheduledQueueContent import org.monogram.feature.dialog.ui.AudioMessagePlaybackScope import org.monogram.feature.dialog.ui.DialogContent import org.monogram.feature.profile.ui.ProfileContent @@ -123,9 +124,7 @@ fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { @Composable @OptIn(ExperimentalDecomposeApi::class, FaultyDecomposeApi::class) private fun RootPlaybackContent( - component: RootComponent, - modifier: Modifier, - togglePlayback: () -> Unit + component: RootComponent, modifier: Modifier, togglePlayback: () -> Unit ) { RecompositionProbe("AppRoot") SideEffect { component.noteFirstFrame() } @@ -134,12 +133,8 @@ private fun RootPlaybackContent( val playbackSession = remember(playbackContext) { MediaPlaybackHolder.session(playbackContext) } val authenticated = !currentStack.active.instance.isSignedOut() val rootLifecycleState = LocalLifecycleOwner.current.lifecycle.currentStateAsState().value - val showMessagePlayback = authenticated && playbackSession.isMessagePlayback && - rootLifecycleState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) && - !LocalPictureInPictureActive.current && - playbackSession.surface != MediaSurface.VIEWER && - playbackSession.surface != MediaSurface.PIP && - currentStack.active.instance !is RootComponent.Child.Dialog + val showMessagePlayback = + authenticated && playbackSession.isMessagePlayback && rootLifecycleState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) && !LocalPictureInPictureActive.current && playbackSession.surface != MediaSurface.VIEWER && playbackSession.surface != MediaSurface.PIP && currentStack.active.instance !is RootComponent.Child.Dialog val openPlaybackMessage: () -> Unit = { val item = playbackSession.current val chatId = item?.sourceChatId @@ -150,19 +145,17 @@ private fun RootPlaybackContent( } val activeDialog by rememberUpdatedState((currentStack.active.instance as? RootComponent.Child.Dialog)?.component) MessagePlaybackLifecycle(playbackSession) - val home = (currentStack.items.firstOrNull { it.instance is RootComponent.Child.Home } - ?.instance as? RootComponent.Child.Home)?.component + val home = + (currentStack.items.firstOrNull { it.instance is RootComponent.Child.Home }?.instance as? RootComponent.Child.Home)?.component // Derived once per stack change instead of two list scans on every recomposition. val dialog by remember { derivedStateOf { - (currentStack.items.lastOrNull { it.instance is RootComponent.Child.Dialog } - ?.instance as? RootComponent.Child.Dialog)?.component + (currentStack.items.lastOrNull { it.instance is RootComponent.Child.Dialog }?.instance as? RootComponent.Child.Dialog)?.component } } val selectedChatId by remember { derivedStateOf { - (currentStack.items.lastOrNull { it.configuration is RootComponent.Config.Dialog } - ?.configuration as? RootComponent.Config.Dialog)?.chatId + (currentStack.items.lastOrNull { it.configuration is RootComponent.Config.Dialog }?.configuration as? RootComponent.Config.Dialog)?.chatId } } val homeState = rememberSaveableStateHolder() @@ -208,18 +201,15 @@ private fun RootPlaybackContent( Direction.EXIT_BACK -> width * factor } alpha = alphaProgress - } - ) + }) } predictiveBackAnimation( backHandler = backHandler, fallbackAnimation = stackAnimation { child, other, _ -> val settingsNav = - child.configuration is RootComponent.Config.Settings || - other.configuration is RootComponent.Config.Settings + child.configuration is RootComponent.Config.Settings || other.configuration is RootComponent.Config.Settings val proxyNav = - child.configuration is RootComponent.Config.Proxy || - other.configuration is RootComponent.Config.Proxy + child.configuration is RootComponent.Config.Proxy || other.configuration is RootComponent.Config.Proxy if (settingsNav || proxyNav) settingsSlide else screenSlide }, selector = { event, _, _ -> @@ -257,38 +247,33 @@ private fun RootPlaybackContent( ) { val density = LocalDensity.current val fold = rememberFoldLayout() - val foldListMaxWidthDp = fold.hinge - ?.takeIf { fold.mode == FoldLayoutMode.VERTICAL_BOOK } - ?.let { (it.left / density.density).roundToInt() - 8 } - ?.takeIf { it >= AppearanceSettings.MIN_LIST_PANE_WIDTH } - val foldHingeGapDp = fold.hinge - ?.takeIf { fold.mode == FoldLayoutMode.VERTICAL_BOOK } + val foldListMaxWidthDp = + fold.hinge?.takeIf { fold.mode == FoldLayoutMode.VERTICAL_BOOK } + ?.let { (it.left / density.density).roundToInt() - 8 } + ?.takeIf { it >= AppearanceSettings.MIN_LIST_PANE_WIDTH } + val foldHingeGapDp = fold.hinge?.takeIf { fold.mode == FoldLayoutMode.VERTICAL_BOOK } ?.let { (it.width / density.density).roundToInt().coerceAtLeast(14) } val tabletop = fold.mode == FoldLayoutMode.HORIZONTAL_TABLETOP && fold.hinge != null - val tabletopListHeightDp = fold.hinge - ?.takeIf { tabletop } - ?.let { (it.top / density.density).roundToInt() - 8 } - ?.takeIf { it >= 240 } + val tabletopListHeightDp = + fold.hinge?.takeIf { tabletop }?.let { (it.top / density.density).roundToInt() - 8 } + ?.takeIf { it >= 240 } val expanded = home != null && (maxWidth >= 840.dp || foldListMaxWidthDp != null || tabletopListHeightDp != null) val appearance by AppearanceSettings.state.collectAsState() val defaultListWidthDp = (maxWidth * 0.35f).value.roundToInt() .coerceIn(AppearanceSettings.MIN_LIST_PANE_WIDTH, 420) - val maxListWidthDp = (foldListMaxWidthDp ?: AppearanceSettings.MAX_LIST_PANE_WIDTH) - .coerceAtMost((maxWidth.value * 0.5f).roundToInt()) + val maxListWidthDp = (foldListMaxWidthDp + ?: AppearanceSettings.MAX_LIST_PANE_WIDTH).coerceAtMost((maxWidth.value * 0.5f).roundToInt()) .coerceAtLeast(AppearanceSettings.MIN_LIST_PANE_WIDTH) var dragWidthDp by remember { mutableIntStateOf(0) } val listWidthDp = when { dragWidthDp > 0 -> dragWidthDp.coerceIn( - AppearanceSettings.MIN_LIST_PANE_WIDTH, - maxListWidthDp + AppearanceSettings.MIN_LIST_PANE_WIDTH, maxListWidthDp ) - appearance.listPaneWidth > 0 -> - appearance.listPaneWidth.coerceIn( - AppearanceSettings.MIN_LIST_PANE_WIDTH, - maxListWidthDp - ) + appearance.listPaneWidth > 0 -> appearance.listPaneWidth.coerceIn( + AppearanceSettings.MIN_LIST_PANE_WIDTH, maxListWidthDp + ) else -> defaultListWidthDp.coerceAtMost(maxListWidthDp) } @@ -349,11 +334,10 @@ private fun RootPlaybackContent( onHorizontalDrag = { change, delta -> change.consume() dragWidthDp = - (dragWidthDp + (delta / density.density).roundToInt()) - .coerceIn( - AppearanceSettings.MIN_LIST_PANE_WIDTH, - maxListWidthDp - ) + (dragWidthDp + (delta / density.density).roundToInt()).coerceIn( + AppearanceSettings.MIN_LIST_PANE_WIDTH, + maxListWidthDp + ) }, ) }, @@ -384,15 +368,14 @@ private fun RootPlaybackContent( .fillMaxSize() .then( if (expanded) { - Modifier - .clip( - RoundedCornerShape( - topStart = 32.dp, - topEnd = 32.dp, - bottomStart = 32.dp, - bottomEnd = 32.dp, - ) + Modifier.clip( + RoundedCornerShape( + topStart = 32.dp, + topEnd = 32.dp, + bottomStart = 32.dp, + bottomEnd = 32.dp, ) + ) } else { Modifier }, @@ -420,8 +403,7 @@ private fun RootPlaybackContent( prioritizeChildren = { true }, ) { !expanded && when (component.stack.value.active.instance) { - is RootComponent.Child.Dialog, is RootComponent.Child.Profile, - is RootComponent.Child.Settings, is RootComponent.Child.Proxy -> true + is RootComponent.Child.Dialog, is RootComponent.Child.Profile, is RootComponent.Child.ScheduledQueue, is RootComponent.Child.Settings, is RootComponent.Child.Proxy -> true else -> false } @@ -478,6 +460,10 @@ private fun RootPlaybackContent( key(instance.component) { dialogContent(instance.component) } } + is RootComponent.Child.ScheduledQueue -> ScheduledQueueContent( + instance.component + ) + is RootComponent.Child.Profile -> ProfileContent(instance.component) is RootComponent.Child.Settings -> SettingsContent( component = instance.component, @@ -514,8 +500,7 @@ private fun RootPlaybackContent( ) } PlaybackBar( - visible = recipient == null && currentStack.active.instance !is RootComponent.Child.Dialog && - !restoreMiniPlayer, + visible = recipient == null && currentStack.active.instance !is RootComponent.Child.Dialog && !restoreMiniPlayer, onExpand = { restoreMiniPlayer = true }, modifier = Modifier.align(Alignment.BottomCenter), ) @@ -610,8 +595,7 @@ private fun RootPlaybackContent( @Composable private fun EmptyDetailContent() { Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - modifier = Modifier.fillMaxSize() + color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxSize() ) { Column( modifier = Modifier diff --git a/app/src/test/java/org/monogram/root/CloudDraftSyncTest.kt b/app/src/test/java/org/monogram/root/CloudDraftSyncTest.kt new file mode 100644 index 000000000..890d0194c --- /dev/null +++ b/app/src/test/java/org/monogram/root/CloudDraftSyncTest.kt @@ -0,0 +1,274 @@ +package org.monogram.root + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState +import org.monogram.core.models.PeerId + +class CloudDraftSyncTest { + private val peer = PeerId(7) + + private class Cache(initial: CloudDraftState) : OfflineWarmup() { + val states = MutableStateFlow(listOf(initial)) + override suspend fun cloudDraftStates() = states.value + override suspend fun cloudDraftState(chatId: PeerId, threadId: Int) = + states.value.firstOrNull { it.draft.chatId == chatId && it.draft.threadId == threadId } + ?: CloudDraftState(CloudDraft(chatId, threadId)) + + override fun observeCloudDraftStates() = states + fun edit(text: String) { + states.value = states.value.map { it.localEdit(it.draft.copy(text = text)) } + } + + override suspend fun applyCloudDraft(draft: CloudDraft, expectedRevision: Long?) { + states.value = states.value.map { + if (it.draft.chatId == draft.chatId && it.draft.threadId == draft.threadId && + (expectedRevision == null || expectedRevision == it.revision) + ) it.mergeRemote(draft) else it + } + } + + override suspend fun acknowledgeCloudDraft(chatId: PeerId, threadId: Int, revision: Long) { + states.value = states.value.map { + if (it.draft.chatId == chatId && it.draft.threadId == threadId) it.acknowledge( + revision + ) else it + } + } + } + + @Test + fun anEditDuringFetchIsSavedInsteadOfOverwritten() = runBlocking { + val cache = Cache(CloudDraftState(CloudDraft(peer, text = "old", date = 10))) + val saved = mutableListOf() + val sync = CloudDraftSync(cache, fetch = { + cache.edit("new local") + Outcome.Ok(listOf(CloudDraft(peer, text = "stale snapshot", date = 20))) + }, save = { saved += it.text; Outcome.Ok(Unit) }) + assertEquals(0L, sync.syncOnce()) + assertEquals(listOf("new local"), saved) + assertEquals("new local", cache.states.value.single().draft.text) + assertFalse(cache.states.value.single().dirty) + } + + @Test + fun anEditDuringSaveRemainsPendingUntilItsOwnAcknowledgment() = runBlocking { + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "first"), revision = 1, dirty = true)) + val saved = mutableListOf() + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + saved += it.text + if (saved.size == 1) cache.edit("second") + Outcome.Ok(Unit) + }) + sync.syncOnce() + assertTrue(cache.states.value.single().dirty) + sync.syncOnce() + assertEquals(listOf("first", "second"), saved) + assertFalse(cache.states.value.single().dirty) + } + + @Test + fun aRestartRetriesPersistedEditsAndHonorsFloodWait() = runBlocking { + val logging = org.monogram.core.common.DebugLog.enabled + org.monogram.core.common.DebugLog.resetForTests() + try { + val cache = Cache( + CloudDraftState( + CloudDraft(peer, text = "pending"), + revision = 1, + dirty = true + ) + ) + val first = + CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { Outcome.Err("FLOOD_WAIT_72") }) + assertEquals(72_000L, first.syncOnce()) + assertTrue(cache.states.value.single().dirty) + var count = 0 + val restored = + CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { count++; Outcome.Ok(Unit) }) + assertEquals(0L, restored.syncOnce()) + assertEquals(1, count) + assertFalse(cache.states.value.single().dirty) + val logs = org.monogram.core.common.DebugLog.exportText(text = "draft-sync") + assertTrue(logs.contains("save failed kind=Flood")) + assertTrue(logs.contains("save completed")) + assertFalse(logs.contains("pending")) + } finally { + org.monogram.core.common.DebugLog.resetForTests(logging) + } + } + + @Test + fun missingServerDraftsClearCleanCachedDraftsWithoutSending() = runBlocking { + val cache = Cache(CloudDraftState(CloudDraft(peer, text = "old remote", date = 10))) + val sync = CloudDraftSync( + cache, + { Outcome.Ok(emptyList()) }, + { error("clean drafts must not send") }) + sync.syncOnce() + assertEquals("", cache.states.value.single().draft.text) + assertFalse(cache.states.value.single().dirty) + } + + @Test + fun oneRejectedPeerDoesNotBlockOthersOrRetryUntilEditedOrRequested() = runBlocking { + val other = PeerId(8) + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "first"), revision = 1, dirty = true)) + cache.states.value += CloudDraftState( + CloudDraft(other, text = "second"), + revision = 1, + dirty = true + ) + val attempted = mutableListOf() + var allow = false + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + attempted += it.chatId + if (it.chatId == peer && !allow) Outcome.Err("CHAT_WRITE_FORBIDDEN") else Outcome.Ok( + Unit + ) + }) + assertEquals(0L, sync.syncOnce()) + assertEquals(listOf(peer, other), attempted) + assertNotNull(cache.draftSync.observe(peer).first().error) + assertNull(cache.draftSync.observe(other).first().error) + assertFalse(cache.states.value.first { it.draft.chatId == other }.dirty) + sync.syncOnce() + assertEquals(2, attempted.size) + cache.states.value = cache.states.value.map { + if (it.draft.chatId == peer) it.localEdit(it.draft.copy(text = "edited")) else it + } + sync.syncOnce() + assertEquals(listOf(peer, other, peer), attempted) + allow = true + sync.requestRefresh() + sync.syncOnce() + assertEquals(listOf(peer, other, peer, peer), attempted) + assertNull(cache.draftSync.observe(peer).first().error) + assertFalse(cache.states.value.first { it.draft.chatId == peer }.dirty) + } + + @Test + fun accountFloodWaitStopsTheBatchAndIsReportedToOtherDrafts() = runBlocking { + val other = PeerId(8) + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "first"), revision = 1, dirty = true)) + cache.states.value += CloudDraftState( + CloudDraft(other, text = "second"), + revision = 1, + dirty = true + ) + val attempted = mutableListOf() + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + attempted += it.chatId + Outcome.Err("FLOOD_WAIT_72") + }) + assertEquals(72_000L, sync.syncOnce()) + assertEquals(listOf(peer), attempted) + assertEquals( + org.monogram.core.common.telegram.TelegramError.Kind.Flood, + cache.draftSync.observe(other).first().error?.kind + ) + assertTrue(cache.states.value.all { it.dirty }) + } + + @Test + fun anotherDraftEditedDuringASaveUsesItsLatestRevision() = runBlocking { + val other = PeerId(8) + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "first"), revision = 1, dirty = true)) + cache.states.value += CloudDraftState( + CloudDraft(other, text = "old"), + revision = 1, + dirty = true + ) + val saved = mutableListOf() + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + saved += it.text + if (it.chatId == peer) cache.states.value = cache.states.value.map { state -> + if (state.draft.chatId == other) state.localEdit(state.draft.copy(text = "new")) else state + } + Outcome.Ok(Unit) + }) + sync.syncOnce() + assertEquals(listOf("first", "new"), saved) + } + + @Test + fun unsupportedContentDetectedDuringAnotherSaveIsNotOverwritten() = runBlocking { + val other = PeerId(8) + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "first"), revision = 1, dirty = true)) + cache.states.value += CloudDraftState( + CloudDraft(other, text = "old"), + revision = 1, + dirty = true + ) + val saved = mutableListOf() + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + saved += it.chatId + cache.states.value = cache.states.value.map { state -> + if (state.draft.chatId == other) state.mergeRemote( + state.draft.copy( + date = 20, + hasUnsupportedContent = true + ) + ) else state + } + Outcome.Ok(Unit) + }) + sync.syncOnce() + assertEquals(listOf(peer), saved) + assertTrue(cache.states.value.first { it.draft.chatId == other }.draft.hasUnsupportedContent) + } + + @Test + fun unknownServerFailuresRemainGlobalAndRetryable() = runBlocking { + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "pending"), revision = 1, dirty = true)) + val error = org.monogram.core.common.telegram.TelegramError.parse("FUTURE_ERROR").copy( + httpCode = 500, kind = org.monogram.core.common.telegram.TelegramError.Kind.Generic + ) + val sync = CloudDraftSync( + cache, + { Outcome.Ok(emptyList()) }, + { Outcome.Err("fixture", telegram = error) }) + assertEquals(2_000L, sync.syncOnce()) + assertEquals(error, cache.draftSync.observe(PeerId(8)).first().error) + assertTrue(cache.states.value.single().dirty) + } + + @Test + fun stoppingAnInFlightSaveDoesNotAcknowledgeOrLeakItsJob() = runBlocking { + val cache = + Cache(CloudDraftState(CloudDraft(peer, text = "pending"), revision = 1, dirty = true)) + val entered = CompletableDeferred() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val sync = CloudDraftSync(cache, { Outcome.Ok(emptyList()) }, { + entered.complete(Unit) + awaitCancellation() + }) + try { + sync.start(scope) + withTimeout(5_000) { entered.await() } + sync.stop() + assertTrue(cache.states.value.single().dirty) + } finally { + scope.cancel() + } + } +} diff --git a/app/src/test/java/org/monogram/root/OutgoingMediaSyncTest.kt b/app/src/test/java/org/monogram/root/OutgoingMediaSyncTest.kt new file mode 100644 index 000000000..3295eaa3f --- /dev/null +++ b/app/src/test/java/org/monogram/root/OutgoingMediaSyncTest.kt @@ -0,0 +1,228 @@ +package org.monogram.root + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.monogram.core.common.Outcome +import org.monogram.core.database.* +import org.monogram.core.database.entity.* +import org.monogram.core.models.* +import org.monogram.network.bridge.message.MessageOps +import java.io.File +import java.lang.reflect.Proxy + +class OutgoingMediaSyncTest { + @get:Rule + val temporary = TemporaryFolder() + private fun media( + source: RetainedMediaSource = RetainedMediaSource( + "00000000-0000-0000-0000-000000000001.bin", + 3, + "0".repeat(64) + ) + ) = OutgoingMediaSend( + OutgoingMediaBatchEntity(123, 7, 100, true, 0x7ffffffe, false), + listOf( + OutgoingMediaItemEntity( + 123, 123, 0, "image/png", source, + MessageEntity( + chatId = 7, + id = -37, + senderId = 7, + text = "fixture", + date = 100, + outgoing = true, + mediaCacheKey = "local:retained", + mediaKind = "photo", + fileName = "fixture.png", + pending = true, + randomId = 123, + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true, + entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""" + ) + ) + ) + ) + + private class Cache(send: OutgoingMediaSend) : OfflineWarmup() { + val rows = MutableStateFlow(listOf(send)) + val textRows = MutableStateFlow(emptyList()) + override fun observeOutgoingTextSends() = textRows + override suspend fun outgoingTextSends() = textRows.value + var deadline = 0L + override fun observeOutgoingMediaSends() = rows + override suspend fun outgoingMediaSends() = rows.value + override suspend fun outgoingSendBlockedUntil() = deadline + override suspend fun blockOutgoingSendsUntil(deadline: Long) { + this.deadline = maxOf(this.deadline, deadline) + } + + override suspend fun claimOutgoingMediaSend(batchId: Long): Boolean { + val row = rows.value.singleOrNull() ?: return false + if (row.batch.phase != OutgoingSendPhase.READY) return false + rows.value = + listOf(row.copy(batch = row.batch.copy(phase = OutgoingSendPhase.IN_FLIGHT))) + return true + } + + override suspend fun markOutgoingMediaSendUncertain(batchId: Long) { + rows.value = + rows.value.map { it.copy(batch = it.batch.copy(phase = OutgoingSendPhase.UNCERTAIN)) } + } + + override suspend fun markOutgoingMediaSendFailed(batchId: Long) { + rows.value = + rows.value.map { it.copy(batch = it.batch.copy(phase = OutgoingSendPhase.FAILED)) } + } + + override suspend fun recoverInterruptedMediaSends() { + rows.value = rows.value.map { + if (it.batch.phase == OutgoingSendPhase.IN_FLIGHT) it.copy( + batch = it.batch.copy(phase = OutgoingSendPhase.UNCERTAIN) + ) else it + } + } + } + + @Test + fun missingRetainedMediaFailsLocallyWithoutCallingTelegram() = runTest { + val cache = Cache(media()) + val files = OutgoingMediaFiles(File(temporary.root, "owned")) + val senderCache = OfflineWarmup(outgoingMediaFiles = files) + val client = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, _, _ -> + error("missing attachment must not reach Telegram") + } as MessageOps + val worker = OutgoingTextSync(cache, { error("text unexpected") }, sendMedia = { + sendRetainedMedia(senderCache, client, it) + }) + worker.syncOnce() + assertEquals(OutgoingSendPhase.FAILED, cache.rows.value.single().batch.phase) + worker.syncOnce() + assertEquals(OutgoingSendPhase.FAILED, cache.rows.value.single().batch.phase) + } + + @Test + fun mediaSuccessIsClaimedOnceAndWaitsForDurableReceipts() = runTest { + val cache = Cache(media()) + var calls = 0 + val worker = OutgoingTextSync( + cache, + { error("text unexpected") }, + sendMedia = { calls++; Outcome.Ok(Unit) }) + worker.syncOnce(); worker.requestResume(); worker.syncOnce() + assertEquals(1, calls) + assertEquals(OutgoingSendPhase.IN_FLIGHT, cache.rows.value.single().batch.phase) + } + + @Test + fun floodWaitAppliesToMediaAcrossWorkerRecreationWithoutReplay() = runTest { + val cache = Cache(media()) + cache.textRows.value = listOf( + OutgoingTextSendEntity( + 999, + media().items.single().message.copy( + id = -99, + randomId = 999, + date = 200, + mediaKind = null + ), + silent = false, noWebpage = true, scheduleDate = null, webpageUrl = null + ) + ) + val worker = OutgoingTextSync( + cache, + { error("text unexpected") }, + wallMillis = { 1000 }, + elapsedMillis = { 0 }, + sendMedia = { Outcome.Err("FLOOD_WAIT_10") }) + assertEquals(10_000L, worker.syncOnce()) + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.single().batch.phase) + val recreated = OutgoingTextSync( + cache, + { error("text unexpected") }, + wallMillis = { 1000 }, + elapsedMillis = { 0 }, + sendMedia = { error("must not replay") }) + assertEquals(10_000L, recreated.syncOnce()) + } + + @Test + fun stopAwaitsMediaCancellationAndPreservesUncertainty() = runTest { + val cache = Cache(media()) + val entered = CompletableDeferred() + val worker = OutgoingTextSync( + cache, + { error("text unexpected") }, + sendMedia = { entered.complete(Unit); awaitCancellation() }) + worker.start(this) + entered.await() + worker.stop() + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.single().batch.phase) + } + + @Test + fun interruptedMediaRecoveryDoesNotAutomaticallyReplay() = runTest { + val row = media() + val cache = Cache(row.copy(batch = row.batch.copy(phase = OutgoingSendPhase.IN_FLIGHT))) + val worker = OutgoingTextSync( + cache, + { error("text unexpected") }, + sendMedia = { error("must not replay") }) + worker.start(this) + testScheduler.runCurrent() + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.single().batch.phase) + worker.stop() + } + + @Test + fun senderUsesVerifiedRetainedBytesAndImmutableOptions() = runBlocking { + val files = OutgoingMediaFiles(File(temporary.root, "owned")) + val original = temporary.newFile().apply { writeText("abc") } + val source = files.retain(listOf(original), 3).single() + original.delete() + var calls = 0 + val client = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, method, args -> + check(method.name == "sendUploadedMediaWithOptions") + calls++ + val item = args[1] as UploadItem + assertEquals("abc", File(item.path).readText()) + assertEquals(123L, item.randomId) + assertEquals("fixture", item.caption) + assertEquals(1, item.captionEntities.size) + assertEquals(MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe), args[2]) + assertEquals(42, args[3]); assertEquals(99, args[4]) + Outcome.Ok( + OutgoingMessageResult.Sent( + media(source).items.single().message.copy(id = 37).toModel() + ) + ) + } as MessageOps + val cache = OfflineWarmup(outgoingMediaFiles = files) + assertTrue(sendRetainedMedia(cache, client, media(source)) is Outcome.Ok) + assertEquals(1, calls) + File(temporary.root, "owned/${source.name}").writeText("bad") + try { + sendRetainedMedia(cache, client, media(source)); fail("changed payload accepted") + } catch (_: java.io.IOException) { + } + assertEquals(1, calls) + cache.deactivateOutgoingAccount() + try { + sendRetainedMedia(cache, client, media(source)); fail("inactive account accepted") + } catch (_: OutgoingPreparationException) { + } + assertEquals(1, calls) + } +} diff --git a/app/src/test/java/org/monogram/root/OutgoingTextSyncTest.kt b/app/src/test/java/org/monogram/root/OutgoingTextSyncTest.kt new file mode 100644 index 000000000..a04da241d --- /dev/null +++ b/app/src/test/java/org/monogram/root/OutgoingTextSyncTest.kt @@ -0,0 +1,543 @@ +package org.monogram.root + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.toModel +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.update +import org.monogram.core.models.* + +class OutgoingTextSyncTest { + private fun row(id: Long, phase: OutgoingSendPhase = OutgoingSendPhase.READY) = + OutgoingTextSendEntity( + id, + MessageEntity( + chatId = id, + id = -id.toInt(), + senderId = 7, + text = "fixture", + date = 100, + outgoing = true, + mediaCacheKey = null, + mediaKind = null, + pending = true, + randomId = id + ), + silent = true, + noWebpage = true, + scheduleDate = 0x7ffffffe, + webpageUrl = null, + phase = phase, + ) + + private fun sent(row: OutgoingTextSendEntity) = Outcome.Ok( + OutgoingMessageResult.Sent(row.message.copy(id = 37, pending = false).toModel()), + ) + + private class Cache(rows: List) : OfflineWarmup() { + val rows = MutableStateFlow(rows) + var blockedUntil = 0L + override fun observeOutgoingTextSends() = rows + override suspend fun outgoingTextSends() = rows.value + override suspend fun outgoingSendBlockedUntil() = blockedUntil + override suspend fun blockOutgoingSendsUntil(deadline: Long) { + blockedUntil = maxOf(blockedUntil, deadline) + } + + override suspend fun claimOutgoingTextSend(randomId: Long): Boolean { + while (true) { + val before = rows.value + if (before.none { it.randomId == randomId && it.phase == OutgoingSendPhase.READY }) return false + if (rows.compareAndSet( + before, + before.map { if (it.randomId == randomId) it.copy(phase = OutgoingSendPhase.IN_FLIGHT) else it }) + ) return true + } + } + + override suspend fun recoverInterruptedTextSends() { + rows.value = + rows.value.map { if (it.phase == OutgoingSendPhase.IN_FLIGHT) it.copy(phase = OutgoingSendPhase.UNCERTAIN) else it } + } + + override suspend fun markOutgoingTextSendUncertain(randomId: Long) { + rows.value = rows.value.map { + if (it.randomId == randomId && it.phase == OutgoingSendPhase.IN_FLIGHT) it.copy( + phase = OutgoingSendPhase.UNCERTAIN + ) else it + } + } + + override suspend fun markOutgoingTextSendFailed(randomId: Long) { + rows.value = rows.value.map { + if (it.randomId == randomId && it.phase == OutgoingSendPhase.IN_FLIGHT) it.copy( + phase = OutgoingSendPhase.FAILED + ) else it + } + } + + override suspend fun retryOutgoingTextSend(randomId: Long) { + rows.value = rows.value.map { + if (it.randomId == randomId && it.phase in setOf( + OutgoingSendPhase.UNCERTAIN, + OutgoingSendPhase.FAILED + ) + ) it.copy(phase = OutgoingSendPhase.READY) else it + } + outgoingSendSync.requestResume() + } + } + + @Test + fun liveLocationPacingStopAndLifecycleFences() = runBlocking { + withTimeout(10_000) { + var wall = 100_000L + var elapsed = 0L + var permission = true + var failStop = true + var failMovement = false + var blockedUntil = 0L + val id = MessageId(PeerId(7), 37) + val sessions = MutableStateFlow(listOf(LiveLocationSession(id, 123, 10000))) + var recoveries = 0 + var recoveryFails = false + val cache = object : OfflineWarmup() { + override fun observeLiveLocations() = sessions + override suspend fun liveLocation(id: MessageId, epoch: Long) = + sessions.value.firstOrNull { it.messageId == id } + + override suspend fun transitionLiveLocation( + id: MessageId, + revision: Long, + phase: LiveLocationPhase, + epoch: Long + ): Boolean { + check(outgoingAccountIsCurrent(epoch)) + val current = liveLocation(id, epoch) ?: return false + if (current.revision != revision || !current.canTransition(phase)) return false + sessions.update { rows -> + rows.map { + if (it == current) it.copy( + phase = phase, + revision = revision + 1 + ) else it + } + } + return true + } + + override suspend fun recoverLiveLocations(epoch: Long) { + recoveries++ + if (recoveryFails) error("synthetic recovery failure") + sessions.update { rows -> + rows.map { + when (it.phase) { + LiveLocationPhase.ACTIVE -> it.copy( + phase = LiveLocationPhase.PAUSED, + revision = it.revision + 1 + ) + + LiveLocationPhase.STOPPING -> it.copy( + phase = LiveLocationPhase.UNCERTAIN_STOP, + revision = it.revision + 1 + ) + + else -> it + } + } + } + } + + override suspend fun outgoingSendBlockedUntil() = blockedUntil + override suspend fun blockOutgoingSendsUntil(deadline: Long) { + blockedUntil = maxOf(blockedUntil, deadline) + } + } + val point = GeoPlace(1.0, 2.0) + val calls = mutableListOf() + var pendingFix: CompletableDeferred? = null + var sampling: CompletableDeferred? = null + val worker = LiveLocationSync( + cache, + sample = { sampling?.complete(Unit); pendingFix?.await() ?: point }, + permitted = { permission }, + edit = { _, fix -> + calls += if (fix == null) "stop" else "move" + if (fix == null && failStop) Outcome.Err("rpc timeout") + else if (fix != null && failMovement) Outcome.Err("RPC 420: FLOOD_WAIT_30") else Outcome.Ok( + Unit + ) + }, + wallMillis = { wall }, + elapsedMillis = { elapsed }) + worker.setForeground(true) + worker.syncOnce(); worker.syncOnce() + assertEquals(listOf("move"), calls) + elapsed = 14_999; worker.syncOnce(); assertEquals(1, calls.size) + elapsed = 15_000; worker.syncOnce(); assertEquals(2, calls.size) + elapsed = 30_000 + pendingFix = CompletableDeferred(); sampling = CompletableDeferred() + val late = async { worker.syncOnce() } + sampling.await() + assertFalse(worker.requestStop(id)) + assertEquals(LiveLocationPhase.UNCERTAIN_STOP, sessions.value.single().phase) + late.await() + pendingFix.complete(point) + assertEquals(listOf("move", "move", "stop"), calls) + assertFalse(worker.resume(id)) + failStop = false + assertTrue(worker.requestStop(id)) + worker.syncOnce(); assertEquals(4, calls.size) + assertEquals(LiveLocationPhase.STOPPED, sessions.value.single().phase) + assertFalse(worker.resume(id)) + sessions.value = listOf(LiveLocationSession(id, 123, 101)) + wall = 101_000; worker.syncOnce() + assertEquals(LiveLocationPhase.EXPIRED, sessions.value.single().phase) + sessions.value = listOf(LiveLocationSession(id, 123, 10000)) + permission = false; worker.syncOnce() + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + pendingFix = null; sampling = null; permission = true + worker.syncOnce(); assertEquals(4, calls.size) + assertTrue(worker.resume(id)); worker.syncOnce(); assertEquals(5, calls.size) + worker.setForeground(false); worker.setForeground(true); worker.syncOnce() + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + assertEquals(5, calls.size) + assertTrue(worker.resume(id)) + elapsed += 15_000; failMovement = true; worker.syncOnce() + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + assertNotNull(worker.errors.value[id]) + wall += 1_000_000 + assertFalse(worker.resume(id)) + elapsed += 30_000 + assertTrue(worker.resume(id)) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + worker.start(scope) + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + worker.stop(); scope.cancel() + cache.deactivateOutgoingAccount() + val count = calls.size + worker.syncOnce(); assertFalse(worker.requestStop(id)); assertEquals(count, calls.size) + cache.activateOutgoingAccount() + sessions.value = listOf(LiveLocationSession(id, 123, 10000)) + val entered = CompletableDeferred() + val release = CompletableDeferred() + val ordered = mutableListOf() + val serial = + LiveLocationSync(cache, sample = { point }, permitted = { true }, edit = { _, fix -> + ordered += if (fix == null) "stop" else "move" + if (fix != null) { + entered.complete(Unit); release.await() + } + Outcome.Ok(Unit) + }, wallMillis = { wall }, elapsedMillis = { elapsed }) + serial.setForeground(true) + val moving = async { serial.syncOnce() } + entered.await() + val stopping = async { serial.requestStop(id) } + while (sessions.value.single().phase != LiveLocationPhase.STOPPING) kotlinx.coroutines.yield() + assertFalse(stopping.isCompleted) + assertEquals(listOf("move"), ordered) + release.complete(Unit); moving.await() + assertTrue(stopping.await()) + serial.syncOnce() + assertEquals(listOf("move", "stop"), ordered) + val bounded = MessageId(id.chatId, 38) + sessions.value = listOf(LiveLocationSession(bounded, 124, wall / 1000 + 10)) + serial.syncOnce() + val beforeExpiry = ordered.size + wall -= 1_000_000; elapsed += 10_000 + serial.syncOnce() + assertEquals(LiveLocationPhase.EXPIRED, sessions.value.single().phase) + assertEquals(beforeExpiry, ordered.size) + var lookup: Message? = Message( + id, + null, + null, + 100, + outgoing = true, + mediaKind = "geo", + fileName = "{\"lat\":1.0,\"long\":2.0}" + ) + var reads = 0 + var stopError = "rpc timeout" + val client = java.lang.reflect.Proxy.newProxyInstance( + org.monogram.network.bridge.MtprotoClient::class.java.classLoader, + arrayOf(org.monogram.network.bridge.MtprotoClient::class.java) + ) { _, method, args -> + assertEquals(id.chatId, args!![0]); assertEquals(id.id, args[1]) + when (method.name) { + "editLiveLocation" -> Outcome.Err(stopError) + "getMessage" -> { + reads++; Outcome.Ok(lookup) + } + + else -> error("unexpected ${method.name}") + } + } as org.monogram.network.bridge.MtprotoClient + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Ok) + lookup = lookup!!.copy(id = MessageId(PeerId(8), 37)) + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + lookup = lookup!!.copy(id = id, outgoing = false) + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + lookup = lookup!!.copy( + outgoing = true, + fileName = "{\"lat\":1.0,\"long\":2.0,\"live\":true,\"p\":900}" + ) + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + lookup = null + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + val beforeMove = reads + assertTrue(editRetainedLiveLocation(client, id, point) is Outcome.Err) + assertEquals(beforeMove, reads) + stopError = "RPC 420: FLOOD_WAIT_30" + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + assertEquals(beforeMove, reads) + stopError = "RPC 401: SESSION_REVOKED" + assertTrue(editRetainedLiveLocation(client, id, null) is Outcome.Err) + assertEquals(beforeMove, reads) + assertTrue( + editRetainedLiveLocation( + client, + id, + null, + isCurrent = { false }) is Outcome.Err + ) + assertEquals(beforeMove, reads) + val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + var runtimeStopGate: CompletableDeferred? = null + var runtimeStopFails = false + val runtime = LiveLocationRuntime( + runtimeScope, + cache, + sample = { error("paused sessions must not acquire") }, + permitted = { true }, + edit = { _, _ -> + runtimeStopGate?.await() + if (runtimeStopFails) Outcome.Err("RPC 500: INTERNAL") else Outcome.Ok(Unit) + }) + sessions.value = listOf(LiveLocationSession(id, 123, null)) + val first = Any(); + val second = Any() + runtime.setForeground(first, true) + val beforeStart = recoveries + runtime.startAuthorized() + assertEquals(beforeStart + 1, recoveries) + sessions.value = listOf(LiveLocationSession(id, 123, null)) + runtime.setForeground(second, true); runtime.startAuthorized() + runtime.setForeground(first, false) + assertEquals(beforeStart + 1, recoveries) + assertEquals(LiveLocationPhase.ACTIVE, sessions.value.single().phase) + runtime.setForeground(second, false) + kotlinx.coroutines.yield() + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + runtime.setForeground(first, true) + kotlinx.coroutines.yield() + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + assertTrue(runtime.resume(id)) + assertEquals(LiveLocationPhase.ACTIVE, sessions.value.single().phase) + runtimeStopGate = CompletableDeferred() + runtimeStopFails = true + val runtimeStopping = async { runtime.requestStop(id) } + while (sessions.value.single().phase != LiveLocationPhase.STOPPING) kotlinx.coroutines.yield() + runtime.setForeground(first, false) + kotlinx.coroutines.yield() + assertFalse(runtimeStopping.isCompleted) + assertFalse(runtime.resume(id)) + runtimeStopGate!!.complete(Unit) + assertFalse(runtimeStopping.await()) + assertEquals(LiveLocationPhase.UNCERTAIN_STOP, sessions.value.single().phase) + assertNotNull(runtime.errors.first()[id]) + runtimeStopGate = null; runtimeStopFails = false + assertTrue(runtime.requestStop(id)) + assertEquals(LiveLocationPhase.STOPPED, sessions.value.single().phase) + assertTrue(runtime.errors.first().isEmpty()) + val previousEpoch = cache.outgoingAccountEpoch() + cache.deactivateOutgoingAccount(); cache.activateOutgoingAccount() + sessions.value = listOf(LiveLocationSession(id, 123, null, LiveLocationPhase.PAUSED)) + runtime.startAuthorized() + assertFalse(runtime.resume(id, previousEpoch)); assertFalse( + runtime.requestStop( + id, + previousEpoch + ) + ) + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + recoveryFails = true + runtime.setForeground(first, true) + kotlinx.coroutines.yield() + assertNotNull(runtime.failure.first()) + assertFalse(runtime.resume(id, cache.outgoingAccountEpoch())) + assertEquals(LiveLocationPhase.PAUSED, sessions.value.single().phase) + recoveryFails = false + assertTrue(runtime.resume(id, cache.outgoingAccountEpoch())) + assertEquals(LiveLocationPhase.ACTIVE, sessions.value.single().phase) + runtime.setForeground(first, false) + runtime.stopAuthorized() + assertFalse(runtime.resume(id)); assertFalse(runtime.requestStop(id)) + assertTrue(runtime.errors.first().isEmpty()) + runtimeScope.cancel() + } + } + + @Test + fun preparationFailureIsNotUncertainAndDoesNotBlockOtherMessages() = runBlocking { + val cache = Cache(listOf(row(1), row(2))) + val attempted = mutableListOf() + val worker = OutgoingTextSync(cache, { + attempted += it.randomId + if (it.randomId == 1L) throw OutgoingPreparationException(java.io.IOException("missing source")) + sent(it) + }) + worker.syncOnce() + assertEquals(listOf(1L, 2L), attempted) + assertEquals(OutgoingSendPhase.FAILED, cache.rows.value.first().phase) + assertEquals(OutgoingSendPhase.IN_FLIGHT, cache.rows.value.last().phase) + OutgoingTextSync(cache, { error("failed row must not automatically replay") }).syncOnce() + cache.retryOutgoingTextSend(1) + worker.syncOnce() + assertEquals(listOf(1L, 2L, 1L), attempted) + } + + @Test + fun exceptionAfterSubmissionStillLeavesDeliveryUncertain() = runBlocking { + val cache = Cache(listOf(row(1), row(2))) + var calls = 0 + val worker = OutgoingTextSync(cache, { + calls++ + throw java.io.IOException("connection lost after submission") + }) + worker.syncOnce() + assertEquals(1, calls) + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.first().phase) + assertEquals(OutgoingSendPhase.READY, cache.rows.value.last().phase) + worker.syncOnce() + assertEquals(1, calls) + } + + @Test + fun readyIsClaimedOnceAndSuccessWaitsForTheDurableReceipt() = runBlocking { + val cache = Cache(listOf(row(1), row(2, OutgoingSendPhase.UNCERTAIN))) + val attempted = mutableListOf() + val worker = OutgoingTextSync(cache, { attempted += it; sent(it) }) + worker.syncOnce(); worker.syncOnce(); worker.requestResume(); worker.syncOnce() + assertEquals(listOf(1L), attempted.map { it.randomId }) + assertEquals(row(1), attempted.single()) + assertEquals(OutgoingSendPhase.IN_FLIGHT, cache.rows.value.first().phase) + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.last().phase) + } + + @Test + fun peerFailureIsIsolatedAndOnlyExplicitRetryMakesItReadyAgain() = runBlocking { + val cache = Cache(listOf(row(1), row(2))) + val attempted = mutableListOf() + val worker = OutgoingTextSync( + cache, + { + attempted += it.randomId; if (it.randomId == 1L) Outcome.Err("CHAT_WRITE_FORBIDDEN") else sent( + it + ) + }) + worker.syncOnce(); worker.syncOnce() + assertEquals(listOf(1L, 2L), attempted) + assertNotNull(cache.outgoingSendSync.state.value.errors[1]) + cache.retryOutgoingTextSend(1); worker.requestResume(); worker.syncOnce() + assertEquals(listOf(1L, 2L, 1L), attempted) + } + + @Test + fun globalFailureStopsRemainingReadyRowsUntilResumeWithoutReplayingTheFailedRow() = + runBlocking { + val cache = Cache(listOf(row(1), row(2))) + val attempted = mutableListOf() + val worker = OutgoingTextSync( + cache, + { + attempted += it.randomId; if (it.randomId == 1L) Outcome.Err("RPC 500: INTERNAL") else sent( + it + ) + }) + worker.syncOnce(); worker.syncOnce() + assertEquals(listOf(1L), attempted) + worker.requestResume(); worker.syncOnce() + assertEquals(listOf(1L, 2L), attempted) + } + + @Test + fun floodWaitSurvivesRestartAndCannotBeShortenedByResumeOrAWallClockJump() = runBlocking { + val cache = Cache(listOf(row(1), row(2))) + var wall = 100_000L + var elapsed = 1_000L + val worker = + OutgoingTextSync(cache, { Outcome.Err("FLOOD_WAIT_30") }, { wall }, { elapsed }) + assertEquals(30_000L, worker.syncOnce()) + worker.requestResume(); wall += 60_000 + assertEquals(30_000L, worker.syncOnce()) + wall = 110_000 + var count = 0 + val restored = OutgoingTextSync(cache, { count++; sent(it) }, { wall }, { elapsed }) + assertEquals(20_000L, restored.syncOnce()) + assertEquals(0, count) + wall = 131_000; elapsed = 31_001 + assertEquals(0L, restored.syncOnce()) + assertEquals(1, count) + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.first().phase) + } + + @Test + fun stoppingJoinsTheNetworkAndLeavesAnUncertainAttemptWithoutAutomaticRestartReplay() = + runBlocking { + val cache = Cache(listOf(row(1))) + val entered = CompletableDeferred() + val cancelled = CompletableDeferred() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val worker = OutgoingTextSync(cache, { + entered.complete(Unit) + try { + awaitCancellation() + } finally { + cancelled.complete(Unit) + } + }) + try { + worker.start(scope) + withTimeout(3_000) { entered.await() } + worker.stop() + assertTrue(cancelled.isCompleted) + assertEquals(OutgoingSendPhase.UNCERTAIN, cache.rows.value.single().phase) + assertNull(cache.outgoingSendSync.state.value.activeRandomId) + val restored = + OutgoingTextSync(cache, { error("uncertain attempt must not replay") }) + assertEquals(0L, restored.syncOnce()) + } finally { + worker.stop(); scope.cancel() + } + } + + @Test + fun aReceiptBeforeTheCallbackPreventsAnErrorFromRecreatingTheAttempt() = runBlocking { + val cache = Cache(listOf(row(1))) + val worker = OutgoingTextSync(cache, { + cache.rows.value = emptyList() + Outcome.Err("INTERNAL") + }) + worker.syncOnce() + assertTrue(cache.rows.value.isEmpty()) + assertTrue(cache.outgoingSendSync.state.value.errors.isEmpty()) + assertNull(cache.outgoingSendSync.state.value.error) + } +} diff --git a/app/src/test/java/org/monogram/root/ScheduledQueueNavigationTest.kt b/app/src/test/java/org/monogram/root/ScheduledQueueNavigationTest.kt new file mode 100644 index 000000000..3f3b4f6d2 --- /dev/null +++ b/app/src/test/java/org/monogram/root/ScheduledQueueNavigationTest.kt @@ -0,0 +1,20 @@ +package org.monogram.root + +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test + +class ScheduledQueueNavigationTest { + @Test + fun queueDestinationRetainsItsPeerAndDialogBackStackAcrossSerialization() { + val dialog = RootComponent.Config.Dialog(7, threadTopMsgId = 99, isForum = true) + val queue = RootComponent.Config.ScheduledQueue(7) + val stack = listOf(RootComponent.Config.Home, dialog, queue) + val serializer = ListSerializer(RootComponent.Config.serializer()) + val restored = Json.decodeFromString(serializer, Json.encodeToString(serializer, stack)) + assertEquals(stack, restored) + assertEquals(dialog, restored.dropLast(1).last()) + assertEquals(stack, uniqueStack(restored, queue)) + } +} diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 9893b109e..306876885 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -11,21 +12,33 @@ android { defaultConfig { minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } +androidComponents { + onVariants { variant -> + variant.androidTest?.sources?.assets?.addStaticSourceDirectory("$projectDir/schemas") + } +} + dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) implementation(project(":core:common")) api(project(":core:models")) api(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.core.ktx) ksp(libs.androidx.room.compiler) diff --git a/core/database/schemas/org.monogram.core.database.MonogramDatabase/10.json b/core/database/schemas/org.monogram.core.database.MonogramDatabase/10.json new file mode 100644 index 000000000..a7e30f0fc --- /dev/null +++ b/core/database/schemas/org.monogram.core.database.MonogramDatabase/10.json @@ -0,0 +1,1915 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "c869a3b046e7354e3bb6495e54c81ba1", + "entities": [ + { + "tableName": "meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `isForum` INTEGER NOT NULL, `left` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `lastMessagePreview` TEXT, `lastMessageOutgoing` INTEGER NOT NULL, `lastMessageSenderName` TEXT, `lastMessageMediaKind` TEXT, `lastMessageDate` INTEGER, `photoCacheKey` TEXT, `archived` INTEGER NOT NULL, `muted` INTEGER NOT NULL, `muteOverride` INTEGER NOT NULL, `unreadMark` INTEGER NOT NULL, `unreadMentionsCount` INTEGER NOT NULL, `unreadReactionsCount` INTEGER NOT NULL, `isContact` INTEGER NOT NULL, `isBot` INTEGER NOT NULL, `isVerified` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `pinnedOrder` INTEGER NOT NULL, `readInboxMaxId` INTEGER NOT NULL, `readOutboxMaxId` INTEGER NOT NULL, `peerStatus` TEXT, `peerStatusAt` INTEGER, `lastMediaThumbCacheKey` TEXT, `lastMessageId` INTEGER NOT NULL, `dialogScrollMessageId` INTEGER, `canView` INTEGER NOT NULL, `canSendPlain` INTEGER NOT NULL, `canSendPhotos` INTEGER NOT NULL, `canSendPolls` INTEGER NOT NULL DEFAULT 0, `canForward` INTEGER NOT NULL, `canDeleteOthers` INTEGER NOT NULL, `canManageTopics` INTEGER NOT NULL, `emojiStatusDocumentId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForum", + "columnName": "isForum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "left", + "columnName": "left", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessagePreview", + "columnName": "lastMessagePreview", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageOutgoing", + "columnName": "lastMessageOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageMediaKind", + "columnName": "lastMessageMediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageDate", + "columnName": "lastMessageDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "photoCacheKey", + "columnName": "photoCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muteOverride", + "columnName": "muteOverride", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMark", + "columnName": "unreadMark", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionsCount", + "columnName": "unreadMentionsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionsCount", + "columnName": "unreadReactionsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isContact", + "columnName": "isContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBot", + "columnName": "isBot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedOrder", + "columnName": "pinnedOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readInboxMaxId", + "columnName": "readInboxMaxId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readOutboxMaxId", + "columnName": "readOutboxMaxId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peerStatus", + "columnName": "peerStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "peerStatusAt", + "columnName": "peerStatusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastMediaThumbCacheKey", + "columnName": "lastMediaThumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dialogScrollMessageId", + "columnName": "dialogScrollMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "canView", + "columnName": "canView", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPlain", + "columnName": "canSendPlain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPhotos", + "columnName": "canSendPhotos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSendPolls", + "columnName": "canSendPolls", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canForward", + "columnName": "canForward", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteOthers", + "columnName": "canDeleteOthers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManageTopics", + "columnName": "canManageTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiStatusDocumentId", + "columnName": "emojiStatusDocumentId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_archived_left_pinned_pinnedOrder_lastMessageDate_id", + "unique": false, + "columnNames": [ + "archived", + "left", + "pinned", + "pinnedOrder", + "lastMessageDate", + "id" + ], + "orders": [ + "ASC", + "ASC", + "DESC", + "ASC", + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_archived_left_pinned_pinnedOrder_lastMessageDate_id` ON `${TABLE_NAME}` (`archived` ASC, `left` ASC, `pinned` DESC, `pinnedOrder` ASC, `lastMessageDate` DESC, `id` DESC)" + } + ] + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `emoticon` TEXT NOT NULL, `chatIdsCsv` TEXT NOT NULL, `pinnedChatIdsCsv` TEXT NOT NULL, `includeContacts` INTEGER NOT NULL, `includeNonContacts` INTEGER NOT NULL, `includeGroups` INTEGER NOT NULL, `includeChannels` INTEGER NOT NULL, `includeBots` INTEGER NOT NULL, `excludeChatIdsCsv` TEXT NOT NULL, `excludeMuted` INTEGER NOT NULL, `excludeRead` INTEGER NOT NULL, `excludeArchived` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoticon", + "columnName": "emoticon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdsCsv", + "columnName": "chatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pinnedChatIdsCsv", + "columnName": "pinnedChatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "includeContacts", + "columnName": "includeContacts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeNonContacts", + "columnName": "includeNonContacts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeGroups", + "columnName": "includeGroups", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeChannels", + "columnName": "includeChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "includeBots", + "columnName": "includeBots", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeChatIdsCsv", + "columnName": "excludeChatIdsCsv", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "excludeMuted", + "columnName": "excludeMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeRead", + "columnName": "excludeRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "excludeArchived", + "columnName": "excludeArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "outgoing_media_batches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`batchId` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `scheduleDate` INTEGER, `album` INTEGER NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`batchId`))", + "fields": [ + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleDate", + "columnName": "scheduleDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "batchId" + ] + } + }, + { + "tableName": "outgoing_media_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`randomId` INTEGER NOT NULL, `batchId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `source_name` TEXT NOT NULL, `source_size` INTEGER NOT NULL, `source_sha256` TEXT NOT NULL, `message_chatId` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `message_senderId` INTEGER, `message_text` TEXT, `message_date` INTEGER NOT NULL, `message_editDate` INTEGER, `message_outgoing` INTEGER NOT NULL, `message_mediaCacheKey` TEXT, `message_mediaKind` TEXT, `message_thumbCacheKey` TEXT, `message_mediaDuration` INTEGER, `message_mediaWidth` INTEGER, `message_mediaHeight` INTEGER, `message_groupedId` INTEGER, `message_fileName` TEXT, `message_fileSize` INTEGER, `message_supportsStreaming` INTEGER NOT NULL, `message_reactionsJson` TEXT, `message_repliesCount` INTEGER NOT NULL, `message_discussionPeerId` INTEGER, `message_replyQuote` TEXT, `message_replyQuoteEntitiesJson` TEXT, `message_entitiesJson` TEXT, `message_replyToMsgId` INTEGER, `message_replyToTopId` INTEGER, `message_forumTopic` INTEGER NOT NULL, `message_fwdFrom` TEXT, `message_fwdFromId` INTEGER, `message_fwdDate` INTEGER, `message_viaBot` TEXT, `message_senderName` TEXT, `message_replyMarkupJson` TEXT, `message_pending` INTEGER NOT NULL, `message_failed` INTEGER NOT NULL DEFAULT 0, `message_randomId` INTEGER, `message_checklistJson` TEXT, PRIMARY KEY(`randomId`), FOREIGN KEY(`batchId`) REFERENCES `outgoing_media_batches`(`batchId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source.name", + "columnName": "source_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source.size", + "columnName": "source_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source.sha256", + "columnName": "source_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message.chatId", + "columnName": "message_chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "message_senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "message_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "message_editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "message_outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "message_mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "message_mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "message_thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "message_mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "message_mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "message_mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "message_groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "message_fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "message_fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "message_supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "message_reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "message_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "message_discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "message_replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "message_replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "message_entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "message_replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "message_replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "message_forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "message_fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "message_fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "message_fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "message_viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "message_senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "message_replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "message_pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "message_failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "message_randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "message_checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "randomId" + ] + }, + "indices": [ + { + "name": "index_outgoing_media_items_batchId_position", + "unique": true, + "columnNames": [ + "batchId", + "position" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_outgoing_media_items_batchId_position` ON `${TABLE_NAME}` (`batchId`, `position`)" + }, + { + "name": "index_outgoing_media_items_source_name", + "unique": true, + "columnNames": [ + "source_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_outgoing_media_items_source_name` ON `${TABLE_NAME}` (`source_name`)" + } + ], + "foreignKeys": [ + { + "table": "outgoing_media_batches", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "batchId" + ], + "referencedColumns": [ + "batchId" + ] + } + ] + }, + { + "tableName": "outgoing_text_sends", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`randomId` INTEGER NOT NULL, `silent` INTEGER NOT NULL, `noWebpage` INTEGER NOT NULL, `scheduleDate` INTEGER, `webpageUrl` TEXT, `phase` TEXT NOT NULL, `message_chatId` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `message_senderId` INTEGER, `message_text` TEXT, `message_date` INTEGER NOT NULL, `message_editDate` INTEGER, `message_outgoing` INTEGER NOT NULL, `message_mediaCacheKey` TEXT, `message_mediaKind` TEXT, `message_thumbCacheKey` TEXT, `message_mediaDuration` INTEGER, `message_mediaWidth` INTEGER, `message_mediaHeight` INTEGER, `message_groupedId` INTEGER, `message_fileName` TEXT, `message_fileSize` INTEGER, `message_supportsStreaming` INTEGER NOT NULL, `message_reactionsJson` TEXT, `message_repliesCount` INTEGER NOT NULL, `message_discussionPeerId` INTEGER, `message_replyQuote` TEXT, `message_replyQuoteEntitiesJson` TEXT, `message_entitiesJson` TEXT, `message_replyToMsgId` INTEGER, `message_replyToTopId` INTEGER, `message_forumTopic` INTEGER NOT NULL, `message_fwdFrom` TEXT, `message_fwdFromId` INTEGER, `message_fwdDate` INTEGER, `message_viaBot` TEXT, `message_senderName` TEXT, `message_replyMarkupJson` TEXT, `message_pending` INTEGER NOT NULL, `message_failed` INTEGER NOT NULL DEFAULT 0, `message_randomId` INTEGER, `message_checklistJson` TEXT, PRIMARY KEY(`randomId`))", + "fields": [ + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noWebpage", + "columnName": "noWebpage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleDate", + "columnName": "scheduleDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "webpageUrl", + "columnName": "webpageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message.chatId", + "columnName": "message_chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "message_senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "message_text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "message_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "message_editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "message_outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "message_mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "message_mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "message_thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "message_mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "message_mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "message_mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "message_groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "message_fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "message_fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "message_supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "message_reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "message_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "message_discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "message_replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "message_replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "message_entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "message_replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "message_replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "message_forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "message_fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "message_fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "message_fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "message_viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "message_senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "message_replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "message_pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "message_failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "message_randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "message_checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "randomId" + ] + } + }, + { + "tableName": "scheduled_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hasUnsupportedContent` INTEGER NOT NULL DEFAULT 1, `chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `senderId` INTEGER, `text` TEXT, `date` INTEGER NOT NULL, `editDate` INTEGER, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `mediaDuration` INTEGER, `mediaWidth` INTEGER, `mediaHeight` INTEGER, `groupedId` INTEGER, `fileName` TEXT, `fileSize` INTEGER, `supportsStreaming` INTEGER NOT NULL, `reactionsJson` TEXT, `repliesCount` INTEGER NOT NULL, `discussionPeerId` INTEGER, `replyQuote` TEXT, `replyQuoteEntitiesJson` TEXT, `entitiesJson` TEXT, `replyToMsgId` INTEGER, `replyToTopId` INTEGER, `forumTopic` INTEGER NOT NULL, `fwdFrom` TEXT, `fwdFromId` INTEGER, `fwdDate` INTEGER, `viaBot` TEXT, `senderName` TEXT, `replyMarkupJson` TEXT, `pending` INTEGER NOT NULL, `failed` INTEGER NOT NULL DEFAULT 0, `randomId` INTEGER, `checklistJson` TEXT, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "hasUnsupportedContent", + "columnName": "hasUnsupportedContent", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "message.chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.senderId", + "columnName": "senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "message.date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.editDate", + "columnName": "editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "message.thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "message.mediaDuration", + "columnName": "mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaWidth", + "columnName": "mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.mediaHeight", + "columnName": "mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.groupedId", + "columnName": "groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.supportsStreaming", + "columnName": "supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.reactionsJson", + "columnName": "reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.discussionPeerId", + "columnName": "discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyQuote", + "columnName": "replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyQuoteEntitiesJson", + "columnName": "replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.entitiesJson", + "columnName": "entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyToMsgId", + "columnName": "replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.replyToTopId", + "columnName": "replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.forumTopic", + "columnName": "forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.fwdFrom", + "columnName": "fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "message.fwdFromId", + "columnName": "fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.fwdDate", + "columnName": "fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.viaBot", + "columnName": "viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "message.senderName", + "columnName": "senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "message.replyMarkupJson", + "columnName": "replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "message.pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message.failed", + "columnName": "failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "message.randomId", + "columnName": "randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "message.checklistJson", + "columnName": "checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + }, + "indices": [ + { + "name": "index_scheduled_messages_chatId_date", + "unique": false, + "columnNames": [ + "chatId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scheduled_messages_chatId_date` ON `${TABLE_NAME}` (`chatId`, `date`)" + } + ] + }, + { + "tableName": "scheduled_queue_revisions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `revision` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `senderId` INTEGER, `text` TEXT, `date` INTEGER NOT NULL, `editDate` INTEGER, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `mediaDuration` INTEGER, `mediaWidth` INTEGER, `mediaHeight` INTEGER, `groupedId` INTEGER, `fileName` TEXT, `fileSize` INTEGER, `supportsStreaming` INTEGER NOT NULL, `reactionsJson` TEXT, `repliesCount` INTEGER NOT NULL, `discussionPeerId` INTEGER, `replyQuote` TEXT, `replyQuoteEntitiesJson` TEXT, `entitiesJson` TEXT, `replyToMsgId` INTEGER, `replyToTopId` INTEGER, `forumTopic` INTEGER NOT NULL, `fwdFrom` TEXT, `fwdFromId` INTEGER, `fwdDate` INTEGER, `viaBot` TEXT, `senderName` TEXT, `replyMarkupJson` TEXT, `pending` INTEGER NOT NULL, `failed` INTEGER NOT NULL DEFAULT 0, `randomId` INTEGER, `checklistJson` TEXT, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editDate", + "columnName": "editDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaDuration", + "columnName": "mediaDuration", + "affinity": "INTEGER" + }, + { + "fieldPath": "mediaWidth", + "columnName": "mediaWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "mediaHeight", + "columnName": "mediaHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "groupedId", + "columnName": "groupedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportsStreaming", + "columnName": "supportsStreaming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactionsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "discussionPeerId", + "columnName": "discussionPeerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "replyQuote", + "columnName": "replyQuote", + "affinity": "TEXT" + }, + { + "fieldPath": "replyQuoteEntitiesJson", + "columnName": "replyQuoteEntitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "entitiesJson", + "columnName": "entitiesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMsgId", + "columnName": "replyToMsgId", + "affinity": "INTEGER" + }, + { + "fieldPath": "replyToTopId", + "columnName": "replyToTopId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forumTopic", + "columnName": "forumTopic", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fwdFrom", + "columnName": "fwdFrom", + "affinity": "TEXT" + }, + { + "fieldPath": "fwdFromId", + "columnName": "fwdFromId", + "affinity": "INTEGER" + }, + { + "fieldPath": "fwdDate", + "columnName": "fwdDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "viaBot", + "columnName": "viaBot", + "affinity": "TEXT" + }, + { + "fieldPath": "senderName", + "columnName": "senderName", + "affinity": "TEXT" + }, + { + "fieldPath": "replyMarkupJson", + "columnName": "replyMarkupJson", + "affinity": "TEXT" + }, + { + "fieldPath": "pending", + "columnName": "pending", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failed", + "columnName": "failed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "randomId", + "columnName": "randomId", + "affinity": "INTEGER" + }, + { + "fieldPath": "checklistJson", + "columnName": "checklistJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + }, + "indices": [ + { + "name": "index_messages_chatId_date", + "unique": false, + "columnNames": [ + "chatId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_date` ON `${TABLE_NAME}` (`chatId`, `date`)" + } + ] + }, + { + "tableName": "message_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + } + }, + { + "tableName": "message_holes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `startId` INTEGER NOT NULL, `endId` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`chatId`, `startId`, `endId`, `source`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startId", + "columnName": "startId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endId", + "columnName": "endId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "startId", + "endId", + "source" + ] + } + }, + { + "tableName": "update_cursors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `pts` INTEGER NOT NULL, `qts` INTEGER NOT NULL, `date` INTEGER NOT NULL, `seq` INTEGER NOT NULL, `channelPts` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pts", + "columnName": "pts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qts", + "columnName": "qts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seq", + "columnName": "seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelPts", + "columnName": "channelPts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "update_batch_receipts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountGeneration` INTEGER NOT NULL, `batchId` INTEGER NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`accountGeneration`, `batchId`))", + "fields": [ + { + "fieldPath": "accountGeneration", + "columnName": "accountGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appliedAtMs", + "columnName": "appliedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountGeneration", + "batchId" + ] + } + }, + { + "tableName": "peers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `about` TEXT, `avatarCacheKey` TEXT, `status` TEXT, `statusAt` INTEGER, `emojiStatusDocumentId` INTEGER, `extraJson` TEXT, `isSelf` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCacheKey", + "columnName": "avatarCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusAt", + "columnName": "statusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "emojiStatusDocumentId", + "columnName": "emojiStatusDocumentId", + "affinity": "INTEGER" + }, + { + "fieldPath": "extraJson", + "columnName": "extraJson", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelf", + "columnName": "isSelf", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "profile_tabs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `tabsJson` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`peerId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tabsJson", + "columnName": "tabsJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId" + ] + } + }, + { + "tableName": "profile_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `avatarCacheKey` TEXT, `status` TEXT, `statusAt` INTEGER, `role` TEXT NOT NULL, `rank` TEXT, `totalCount` INTEGER NOT NULL, PRIMARY KEY(`peerId`, `userId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarCacheKey", + "columnName": "avatarCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusAt", + "columnName": "statusAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rank", + "columnName": "rank", + "affinity": "TEXT" + }, + { + "fieldPath": "totalCount", + "columnName": "totalCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "userId" + ] + } + }, + { + "tableName": "profile_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `tab` TEXT NOT NULL, `messageId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `text` TEXT, `date` INTEGER NOT NULL, `outgoing` INTEGER NOT NULL, `mediaCacheKey` TEXT, `mediaKind` TEXT, `thumbCacheKey` TEXT, `fileName` TEXT, `fileSize` INTEGER, PRIMARY KEY(`peerId`, `tab`, `messageId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tab", + "columnName": "tab", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoing", + "columnName": "outgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaCacheKey", + "columnName": "mediaCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaKind", + "columnName": "mediaKind", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbCacheKey", + "columnName": "thumbCacheKey", + "affinity": "TEXT" + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "tab", + "messageId" + ] + } + }, + { + "tableName": "profile_common", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`peerId` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `photoCacheKey` TEXT, PRIMARY KEY(`peerId`, `chatId`))", + "fields": [ + { + "fieldPath": "peerId", + "columnName": "peerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoCacheKey", + "columnName": "photoCacheKey", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "peerId", + "chatId" + ] + } + }, + { + "tableName": "sponsors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `sourceChannelId` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceChannelId", + "columnName": "sourceChannelId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c869a3b046e7354e3bb6495e54c81ba1')" + ] + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/CloudDraftPersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/CloudDraftPersistenceTest.kt new file mode 100644 index 000000000..d89e759b5 --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/CloudDraftPersistenceTest.kt @@ -0,0 +1,168 @@ +package org.monogram.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.MetaEntity +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.PeerId +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class CloudDraftPersistenceTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var database: MonogramDatabase + private lateinit var warmup: OfflineWarmup + private val peer = PeerId(7) + + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "draft-test-${UUID.randomUUID()}.db" + reopen() + } + + private fun reopen() { + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + warmup = OfflineWarmup(database) + } + + @After + fun close() { + database.close() + context.deleteDatabase(name) + } + + @Test + fun legacyTextAndStructuredPendingEditsSurviveReopenWithoutChangingIdentity() = runBlocking { + database.metaDao().upsert(MetaEntity("fixture.identity", "7")) + database.metaDao().upsert(MetaEntity(draftMetaKey(peer.value, 99), "legacy text")) + assertEquals("legacy text", warmup.cloudDraftState(peer, 99).draft.text) + assertTrue(warmup.cloudDraftStates().single().dirty) + val payload = CloudDraft( + peer, + 99, + "local", + """[{"kind":"bold","offset":0,"length":5}]""", + 42, + noWebpage = true + ) + val saved = warmup.saveLocalCloudDraft(payload, markdown = true) + database.close() + reopen() + assertEquals(saved, warmup.cloudDraftState(peer, 99)) + assertTrue(warmup.cloudDraftState(peer, 99).markdown) + assertTrue(warmup.cloudDraftState(peer, 99).dirty) + assertFalse(warmup.cloudDraftState(peer, 0).dirty) + assertEquals("7", database.metaDao().get("fixture.identity")?.value) + assertNull(database.metaDao().get(draftMetaKey(peer.value, 99))) + } + + @Test + fun localEditsAndNewEventsWinAgainstStaleAcknowledgmentsAndFetches() = runBlocking { + val first = warmup.saveLocalCloudDraft(CloudDraft(peer, text = "first")) + val second = warmup.saveLocalCloudDraft(CloudDraft(peer, text = "second")) + warmup.acknowledgeCloudDraft(peer, 0, first.revision) + assertEquals(second, warmup.cloudDraftState(peer)) + warmup.applyCloudDraft(CloudDraft(peer, text = "remote", date = 30)) + assertEquals("second", warmup.cloudDraftState(peer).draft.text) + assertTrue(warmup.cloudDraftState(peer).dirty) + warmup.acknowledgeCloudDraft(peer, 0, second.revision) + assertFalse(warmup.cloudDraftState(peer).dirty) + val beforeFetch = warmup.cloudDraftState(peer).revision + warmup.applyCloudDraft(CloudDraft(peer, text = "new event", date = 40)) + warmup.applyCloudDraft(CloudDraft(peer, text = "stale fetch", date = 40), beforeFetch) + assertEquals("new event", warmup.cloudDraftState(peer).draft.text) + } + + @Test + fun orderedWritesSurviveDisposalAndFinishBeforeAccountCacheClearing() = runBlocking { + database.metaDao().upsert(MetaEntity("fixture.identity", "7")) + val writes = (1..20).map { number -> + launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { warmup.setDraft(peer, text = number.toString()) } + } + } + writes.joinAll() + assertEquals("20", warmup.draft(peer)) + val disposed = launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { warmup.setDraft(peer, text = "after disposal") } + } + disposed.cancel() + disposed.join() + assertEquals("after disposal", warmup.draft(peer)) + val pending = launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { warmup.setDraft(peer, text = "pending before logout") } + } + val clear = launch(start = CoroutineStart.UNDISPATCHED) { warmup.clearAccountCache() } + pending.cancel() + pending.join() + clear.join() + assertTrue(warmup.cloudDraftStates().isEmpty()) + assertEquals("7", database.metaDao().get("fixture.identity")?.value) + } + + @Test + fun explicitUnsupportedDraftDiscardSurvivesReopenAndRemoteRefresh() = runBlocking { + val remote = + CloudDraft(peer, text = "unsupported content", date = 20, hasUnsupportedContent = true) + warmup.applyCloudDraft(remote) + val guarded = warmup.saveLocalCloudDraft(CloudDraft(peer, text = "local typing")) + assertTrue(guarded.draft.hasUnsupportedContent) + val discarded = warmup.discardLocalCloudDraft(peer) + assertFalse(discarded.draft.hasUnsupportedContent) + database.close() + reopen() + assertEquals(discarded, warmup.cloudDraftState(peer)) + warmup.applyCloudDraft(remote) + assertFalse(warmup.cloudDraftState(peer).draft.hasUnsupportedContent) + assertTrue(warmup.cloudDraftState(peer).discardingUnsupported) + warmup.acknowledgeCloudDraft(peer, 0, discarded.revision) + assertFalse(warmup.cloudDraftState(peer).discardingUnsupported) + assertFalse(warmup.cloudDraftState(peer).dirty) + } + + @Test + fun remoteDraftAndReceiptCommitOrRollBackTogether() = runBlocking { + val remote = CloudDraft(peer, text = "remote", date = 30) + try { + warmup.applyDurableBatch(5, 8, null, emptyMap()) { + warmup.applyCloudDraft(remote) + error("fixture rollback") + } + fail("transaction must fail") + } catch (_: IllegalStateException) { + assertFalse(database.updateCursorDao().isBatchApplied(5, 8)) + assertEquals("", warmup.cloudDraftState(peer).draft.text) + } + assertTrue( + warmup.applyDurableBatch( + 5, + 8, + null, + emptyMap() + ) { warmup.applyCloudDraft(remote) }) + assertTrue(database.updateCursorDao().isBatchApplied(5, 8)) + assertEquals(remote, warmup.cloudDraftState(peer).draft) + assertTrue( + warmup.applyDurableBatch( + 5, + 8, + null, + emptyMap() + ) { error("replay must not apply twice") }) + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/ContactImportPersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/ContactImportPersistenceTest.kt new file mode 100644 index 000000000..349c531f1 --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/ContactImportPersistenceTest.kt @@ -0,0 +1,153 @@ +package org.monogram.core.database + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.models.* +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class ContactImportPersistenceTest { + @Test + fun importKeyRetainsLegacyJsonEscapingForExistingRecords() { + val contact = ContactSendSpec("+15555550123", "A/B", "Quoted \"name\"\nКонтакт") + val legacy = org.json.JSONObject().put("send_version", 1).put("p", contact.phone) + .put("f", contact.firstName).put("l", contact.lastName).put("c", "").put("u", 0) + .toString() + val digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(legacy.toByteArray(Charsets.UTF_8)) + val key = "contact_import:" + digest.joinToString("") { "%02x".format(it.toInt() and 255) } + assertEquals(key, contactImportMetaKey(contact)) + assertEquals(key, contactImportMetaKey(contact.copy(vcard = "Ignored"))) + } + + @Test + fun syntheticImportRecoversUncertaintyAndRequiresExplicitRetryWithStableIdentityAndEpoch() = + runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "import-attempt-${UUID.randomUUID()}.db" + fun open() = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + var db = open() + val contact = + ContactSendSpec("+15555550123", "Synthetic", "Contact", "Excluded from import") + try { + val first = OfflineWarmup(db) + val epoch = first.outgoingAccountEpoch() + val started = first.beginContactImport(contact, epoch, retry = false) + assertTrue(started.spec.clientId != 0L) + assertEquals( + ContactImportPhase.IN_FLIGHT, + first.contactImportAttempt(contact, epoch)?.phase + ) + try { + first.beginContactImport( + contact, + epoch, + retry = true + ); fail("concurrent import admitted") + } catch (_: IllegalStateException) { + } + db.close(); db = open() + val restored = OfflineWarmup(db) + val current = restored.outgoingAccountEpoch() + val uncertain = restored.contactImportAttempt(contact, current)!! + assertEquals(started.spec, uncertain.spec) + assertEquals(ContactImportPhase.UNCERTAIN, uncertain.phase) + assertEquals( + uncertain, + restored.contactImportAttempt(contact.copy(vcard = "Different card"), current) + ) + try { + restored.beginContactImport( + contact, + current, + retry = false + ); fail("interrupted import replayed") + } catch (_: IllegalStateException) { + } + val retry = restored.beginContactImport(contact, current, retry = true) + assertEquals(started.spec.clientId, retry.spec.clientId) + assertTrue( + restored.finishContactImport( + contact, + current, + retry.copy(phase = ContactImportPhase.RETRY_REQUIRED) + ) + ) + assertEquals( + ContactImportPhase.RETRY_REQUIRED, + restored.contactImportAttempt(contact, current)?.phase + ) + try { + restored.beginContactImport( + contact, + current, + retry = false + ); fail("server retry replayed implicitly") + } catch (_: IllegalStateException) { + } + val last = restored.beginContactImport(contact, current, retry = true) + assertEquals(started.spec, last.spec) + val imported = last.copy(phase = ContactImportPhase.IMPORTED, userId = PeerId(42)) + assertTrue(restored.finishContactImport(contact, current, imported)) + assertEquals(imported, restored.contactImportAttempt(contact, current)) + try { + restored.finishContactImport( + contact, + current, + last.copy(phase = ContactImportPhase.UNCERTAIN) + ); fail("late settlement overwrote imported result") + } catch (_: IllegalStateException) { + } + try { + restored.beginContactImport( + contact, + current, + retry = true + ); fail("terminal import repeated") + } catch (_: IllegalStateException) { + } + restored.deactivateOutgoingAccount(); restored.activateOutgoingAccount() + assertFalse(restored.finishContactImport(contact, current, imported)) + try { + restored.contactImportAttempt( + contact, + current + ); fail("stale account read accepted") + } catch (_: IllegalStateException) { + } + restored.clearAccountCache() + restored.activateOutgoingAccount() + val clearedEpoch = restored.outgoingAccountEpoch() + assertNull(restored.contactImportAttempt(contact, clearedEpoch)) + val unknown = restored.beginContactImport(contact, clearedEpoch, retry = false) + restored.finishContactImport( + contact, + clearedEpoch, + unknown.copy(phase = ContactImportPhase.NOT_ADDED) + ) + try { + restored.beginContactImport( + contact, + clearedEpoch, + retry = false + ); fail("unregistered result retried implicitly") + } catch (_: IllegalStateException) { + } + val later = restored.beginContactImport(contact, clearedEpoch, retry = true) + assertEquals(unknown.spec, later.spec) + restored.finishContactImport( + contact, + clearedEpoch, + later.copy(phase = ContactImportPhase.NOT_ADDED) + ) + Unit + } finally { + db.close(); context.deleteDatabase(name) + } + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/OutgoingMediaPersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/OutgoingMediaPersistenceTest.kt new file mode 100644 index 000000000..d0f531d8f --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/OutgoingMediaPersistenceTest.kt @@ -0,0 +1,344 @@ +package org.monogram.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.* +import org.monogram.core.models.* +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class OutgoingMediaPersistenceTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var db: MonogramDatabase + private lateinit var filesDirectory: java.io.File + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "media-outbox-${UUID.randomUUID()}.db" + filesDirectory = + java.io.File(context.noBackupFilesDir, "media-fixture-${UUID.randomUUID()}") + .apply { mkdirs() } + reopen() + } + + private fun reopen() { + db = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + } + + @After + fun close() { + db.close(); context.deleteDatabase(name); filesDirectory.deleteRecursively() + } + + private fun send() = OutgoingMediaSend( + OutgoingMediaBatchEntity(123, 7, 100, true, 0x7ffffffe, true), + (0..1).map { index -> + val random = 123L + index + OutgoingMediaItemEntity( + random, 123, index, "image/png", + RetainedMediaSource(UUID.randomUUID().toString() + ".bin", 3, "0".repeat(64)), + MessageEntity( + chatId = 7, + id = -37 - index, + senderId = 7, + text = "fixture", + date = 100, + outgoing = true, + mediaCacheKey = "local:retained", + mediaKind = "photo", + pending = true, + randomId = random, + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true, + entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""" + ) + ) + }) + + private suspend inline fun rejects(block: suspend () -> Unit) { + try { + block(); fail("expected rejection") + } catch (error: Throwable) { + if (error !is T) throw error + } + } + + @Test + fun immutablePayloadAndClaimSurviveReopenAndNeedExplicitRetry() = runBlocking { + val send = send() + db.outgoingMediaSendDao().enqueue(send) + db.outgoingMediaSendDao().enqueue(send.copy(items = send.items.reversed())) + assertEquals(1, db.outgoingMediaSendDao().claim(123)) + assertEquals(0, db.outgoingMediaSendDao().claim(123)) + db.close(); reopen() + val dao = db.outgoingMediaSendDao() + val restored = dao.get(123)!! + assertEquals(send.items, restored.items.sortedBy { it.position }) + assertTrue(restored.batch.silent) + assertEquals(0x7ffffffe, restored.batch.scheduleDate) + assertEquals(1, dao.recoverInterrupted()) + assertEquals(0, dao.claim(123)) + rejects { dao.enqueue(send.copy(batch = send.batch.copy(silent = false))) } + assertEquals(1, dao.retry(123)) + assertEquals(1, dao.claim(123)) + assertEquals(1, dao.markFailed(123)) + db.close(); reopen() + val failed = db.outgoingMediaSendDao() + assertEquals(0, failed.recoverInterrupted()) + assertEquals(OutgoingSendPhase.FAILED, failed.get(123)?.batch?.phase) + assertEquals(send.items, failed.get(123)!!.items.sortedBy { it.position }) + assertEquals(0, failed.claim(123)) + assertEquals(1, failed.retry(123)) + assertEquals(1, failed.claim(123)) + } + + @Test + fun collidingItemRollsBackWholeNewBatch() = runBlocking { + val first = send() + val dao = db.outgoingMediaSendDao() + dao.enqueue(first) + val other = first.copy( + batch = first.batch.copy(batchId = 999), + items = first.items.mapIndexed { index, item -> + val randomId = if (index == 0) 999L else item.randomId + item.copy( + batchId = 999, + randomId = randomId, + message = item.message.copy(randomId = randomId) + ) + }) + rejects { dao.enqueue(other) } + assertNull(dao.get(999)) + assertEquals(1, dao.all().size) + val duplicateSources = other.copy( + batch = other.batch.copy(batchId = 1999), + items = other.items.mapIndexed { index, item -> + val randomId = 1999L + index + item.copy( + batchId = 1999, + randomId = randomId, + message = item.message.copy(randomId = randomId) + ) + }) + rejects { dao.enqueue(duplicateSources) } + assertNull(dao.get(1999)) + rejects { dao.acknowledge(123, 7, setOf(123)) } + rejects { dao.acknowledge(123, 8, setOf(123, 124)) } + assertEquals(2, dao.get(123)!!.items.size) + } + + @Test + fun invalidAlbumShapesLeaveNoPartialAdmission() = runBlocking { + val send = send() + val dao = db.outgoingMediaSendDao() + val malformed = listOf( + send.copy(items = send.items.map { it.copy(position = 0) }), + send.copy(items = send.items.map { it.copy(source = send.items.first().source) }), + send.copy( + items = listOf( + send.items.first(), + send.items.last() + .copy(message = send.items.last().message.copy(mediaKind = "document")) + ) + ), + send.copy(batch = send.batch.copy(scheduleDate = Int.MAX_VALUE)), + ) + malformed.forEach { rejects { dao.enqueue(it) } } + assertTrue(dao.all().isEmpty()) + } + + @Test + fun admissionAndDraftConsumptionRespectAccountEpochAndNewerEdits() = runBlocking { + val cache = OfflineWarmup(db) + val draft = CloudDraft(PeerId(7), 99, text = "fixture") + cache.saveLocalCloudDraft(draft) + val epoch = cache.outgoingAccountEpoch() + val captured = cache.captureOutgoingTextDraft(PeerId(7), 99, epoch) + val send = send() + assertTrue(cache.enqueueOutgoingMediaSend(send, epoch, captured)) + assertEquals("", cache.cloudDraftState(PeerId(7), 99).draft.text) + cache.clearAccountCache(); cache.activateOutgoingAccount() + rejects { cache.enqueueOutgoingMediaSend(send, epoch) } + rejects { cache.applyOutgoingMediaResults(123, emptyList(), epoch) } + assertTrue(db.outgoingMediaSendDao().all().isEmpty()) + cache.saveLocalCloudDraft(draft) + val currentEpoch = cache.outgoingAccountEpoch() + val older = cache.captureOutgoingTextDraft(PeerId(7), 99, currentEpoch) + cache.saveLocalCloudDraft(draft) + assertFalse(cache.enqueueOutgoingMediaSend(send, currentEpoch, older)) + assertEquals("fixture", cache.cloudDraftState(PeerId(7), 99).draft.text) + } + + @Test + fun retainedAdmissionSurvivesOriginalDeletionDatabaseReopenAndRecovery() = runBlocking { + val original = send() + val sources = original.items.mapIndexed { index, _ -> + java.io.File(filesDirectory, "original-$index").apply { writeText("abc") } + } + val owned = java.io.File(filesDirectory, "owned") + val cache = OfflineWarmup(db, outgoingMediaFiles = OutgoingMediaFiles(owned)) + val draft = CloudDraft(PeerId(7), 99, text = "fixture") + cache.saveLocalCloudDraft(draft) + val epoch = cache.outgoingAccountEpoch() + val captured = cache.captureOutgoingTextDraft(PeerId(7), 99, epoch) + val admitted = cache.retainAndEnqueueOutgoingMedia( + original.batch, + original.items.mapIndexed { index, item -> + OutgoingMediaUpload(sources[index].absolutePath, item.mimeType, item.message) + }, + 6, + epoch, + captured + ) + assertTrue(admitted.draftConsumed) + sources.forEach { it.delete() } + db.close(); reopen() + val files = OutgoingMediaFiles(owned) + val restored = OfflineWarmup(db, outgoingMediaFiles = files) + restored.recoverOutgoingMediaFiles(restored.outgoingAccountEpoch()) + val persisted = db.outgoingMediaSendDao().get(123)!! + persisted.items.forEach { item -> + assertEquals("abc", files.verifiedFile(item.source).readText()) + assertEquals( + "local:" + files.verifiedFile(item.source).absolutePath, + item.message.mediaCacheKey + ) + } + assertEquals("", restored.cloudDraftState(PeerId(7), 99).draft.text) + restored.clearAccountCache() + assertTrue(owned.listFiles().orEmpty().isEmpty()) + } + + @Test + fun recoveryWaitsForInProgressCopyAndAdmission() = runBlocking { + val original = send() + val source = java.io.File(filesDirectory, "original").apply { writeText("abc") } + val owned = java.io.File(filesDirectory, "owned") + val files = OutgoingMediaFiles(owned) + val cache = OfflineWarmup(db, outgoingMediaFiles = files) + val epoch = cache.outgoingAccountEpoch() + val admission = async(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + cache.retainAndEnqueueOutgoingMedia( + original.batch, + original.items.map { + OutgoingMediaUpload( + source.absolutePath, + it.mimeType, + it.message + ) + }, 6, epoch + ) + } + val recovery = async(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + cache.recoverOutgoingMediaFiles(epoch) + } + assertFalse(recovery.isCompleted) + val result = admission.await() + recovery.await() + result.send.items.forEach { assertEquals("abc", files.verifiedFile(it.source).readText()) } + } + + @Test + fun rejectedRetainedAdmissionRemovesOnlyUnreferencedCopies() = runBlocking { + val original = send() + val source = java.io.File(filesDirectory, "original").apply { writeText("abc") } + val owned = java.io.File(filesDirectory, "owned") + val cache = OfflineWarmup(db, outgoingMediaFiles = OutgoingMediaFiles(owned)) + val uploads = + original.items.map { OutgoingMediaUpload(source.absolutePath, it.mimeType, it.message) } + cache.retainAndEnqueueOutgoingMedia( + original.batch, + uploads, + 6, + cache.outgoingAccountEpoch() + ) + val existing = owned.listFiles()!!.map { it.name }.toSet() + rejects { + cache.retainAndEnqueueOutgoingMedia( + original.batch.copy(batchId = 999), + uploads, + 6, + cache.outgoingAccountEpoch() + ) + } + assertEquals(existing, owned.listFiles()!!.map { it.name }.toSet()) + assertEquals("abc", source.readText()) + assertEquals(1, db.outgoingMediaSendDao().all().size) + } + + @Test + fun settlementRequiresCompletePeerMatchedResultsAndCommitsWithQueue() = runBlocking { + val cache = OfflineWarmup(db) + val send = send() + cache.enqueueOutgoingMediaSend(send, cache.outgoingAccountEpoch()) + val results = send.items.mapIndexed { index, item -> + item.randomId to + OutgoingMessageResult.Scheduled( + ScheduledMessage( + item.message.copy( + id = 37 + index, + pending = false + ).toModel(), false + ) + ) + } + rejects { + cache.applyOutgoingMediaResults( + 123, + results.take(1), + cache.outgoingAccountEpoch() + ) + } + assertTrue(db.scheduledMessageDao().forChat(7).isEmpty()) + val foreign = results.map { (randomId, result) -> + randomId to result.copy( + message = result.message.copy( + message = result.message.message.copy( + id = result.message.message.id.copy( + chatId = PeerId( + 8 + ) + ) + ) + ) + ) + } + rejects { + cache.applyOutgoingMediaResults( + 123, + foreign, + cache.outgoingAccountEpoch() + ) + } + assertTrue(db.scheduledMessageDao().forChat(7).isEmpty()) + rejects { + cache.applyOutgoingMediaResults( + 123, + listOf(results[0], results[0]), + cache.outgoingAccountEpoch() + ) + } + assertNotNull(db.outgoingMediaSendDao().get(123)) + assertTrue(kotlinx.coroutines.withTimeout(5_000) { + cache.applyDurableBatch(5, 1, null, emptyMap()) { + cache.applyOutgoingMediaResults(123, results, cache.outgoingAccountEpoch(), 5, 1) + } + }) + assertNull(db.outgoingMediaSendDao().get(123)) + assertNull(db.outgoingMediaSendDao().item(123)) + assertEquals(listOf(37, 38), db.scheduledMessageDao().forChat(7).map { it.message.id }) + cache.applyOutgoingMediaResults(123, results, cache.outgoingAccountEpoch()) + assertEquals(2, db.scheduledMessageDao().forChat(7).size) + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/OutgoingTextPersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/OutgoingTextPersistenceTest.kt new file mode 100644 index 000000000..103f07b5b --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/OutgoingTextPersistenceTest.kt @@ -0,0 +1,308 @@ +package org.monogram.core.database + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.models.OutgoingMessageResult +import org.monogram.core.models.ScheduledMessage +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class OutgoingTextPersistenceTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var database: MonogramDatabase + + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "outbox-test-${UUID.randomUUID()}.db" + reopen() + } + + private fun reopen() { + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + } + + @After + fun close() { + database.close(); context.deleteDatabase(name) + } + + private fun row(date: Int? = 0x7ffffffe) = OutgoingTextSendEntity( + randomId = 123, + message = MessageEntity( + chatId = 7, + id = -37, + senderId = 7, + text = "fixture", + date = 100, + outgoing = true, + mediaCacheKey = null, + mediaKind = null, + pending = true, + randomId = 123, + entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""", + replyToMsgId = 42, + replyToTopId = 99, + forumTopic = true + ), + silent = true, noWebpage = true, scheduleDate = date, webpageUrl = "https://example.com", + ) + + @Test + fun localFailureSurvivesReopenAndRequiresExplicitRetry() = runBlocking { + val dao = database.outgoingTextSendDao() + dao.enqueue(row()) + assertEquals(1, dao.claim(123)) + assertEquals(1, dao.markFailed(123)) + database.close(); reopen() + val restored = database.outgoingTextSendDao() + restored.recoverInterrupted() + assertEquals(OutgoingSendPhase.FAILED, restored.get(123)?.phase) + assertEquals(0, restored.claim(123)) + assertEquals(1, restored.retry(123)) + assertEquals(1, restored.claim(123)) + assertEquals(123L, restored.get(123)?.message?.randomId) + } + + @Test + fun diceOutboxRetainsIdentityAcrossReopenAndSettlesOnlyFromAuthoritativeReceipt() = + runBlocking { + val base = row(null) + val dice = base.copy( + message = base.message.copy( + text = null, + entitiesJson = null, + mediaKind = "dice", + fileName = """{"e":"🎲","v":0}""" + ), + noWebpage = false, webpageUrl = null + ) + val warmup = OfflineWarmup(database) + warmup.enqueueOutgoingTextSend(dice, consumeDraft = false) + assertEquals(1, database.outgoingTextSendDao().claim(123)) + database.close(); reopen() + assertEquals( + dice.copy(phase = OutgoingSendPhase.IN_FLIGHT), + database.outgoingTextSendDao().get(123) + ) + database.outgoingTextSendDao().recoverInterrupted() + assertEquals( + OutgoingSendPhase.UNCERTAIN, + database.outgoingTextSendDao().get(123)?.phase + ) + assertEquals(0, database.outgoingTextSendDao().claim(123)) + assertEquals(1, database.outgoingTextSendDao().retry(123)) + assertEquals(1, database.outgoingTextSendDao().claim(123)) + val result = + dice.message.copy(id = 37, pending = false, fileName = """{"e":"🎲","v":4}""") + .toModel() + OfflineWarmup(database).applyOutgoingResult(123, OutgoingMessageResult.Sent(result)) + assertNull(database.outgoingTextSendDao().get(123)) + val delivered = database.messageDao().byIds(7, listOf(37)).single().toModel() + assertEquals(4, org.monogram.core.models.ServiceMedia.dice(delivered.fileName)?.value) + } + + @Test + fun scheduledSnapshotApiRejectsPushRacesAndOldAccountEpochs() = runBlocking { + val warmup = OfflineWarmup(database) + val peer = org.monogram.core.models.PeerId(7) + val first = ScheduledMessage(row().message.copy(id = 37, pending = false).toModel()) + val second = ScheduledMessage(first.message.copy(id = first.message.id.copy(id = 38))) + warmup.applyScheduledMessage(first) + val epoch = warmup.outgoingAccountEpoch() + val revision = warmup.scheduledQueueRevision(peer, epoch) + warmup.applyScheduledMessage(second) + assertFalse(warmup.replaceScheduledMessagesIfCurrent(peer, revision, listOf(first), epoch)) + assertEquals( + listOf(37, 38), + database.scheduledMessageDao().forChat(7).map { it.message.id }) + val fresh = warmup.scheduledQueueRevision(peer, epoch) + assertTrue(warmup.replaceScheduledMessagesIfCurrent(peer, fresh, listOf(second), epoch)) + warmup.clearAccountCache() + warmup.activateOutgoingAccount() + try { + warmup.replaceScheduledMessagesIfCurrent(peer, fresh, listOf(first), epoch) + fail("old account snapshot accepted") + } catch (_: IllegalStateException) { + } + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + try { + warmup.blockScheduledQueueUntil(1000, epoch) + fail("old account flood deadline accepted") + } catch (_: IllegalStateException) { + } + assertEquals(0L, warmup.outgoingSendBlockedUntil()) + warmup.blockScheduledQueueUntil(1000, warmup.outgoingAccountEpoch()) + assertEquals(1000L, warmup.outgoingSendBlockedUntil()) + } + + @Test + fun enqueueAndDraftConsumptionCommitTogetherWithoutErasingNewerEdits() = runBlocking { + val warmup = OfflineWarmup(database) + val draft = org.monogram.core.models.CloudDraft( + org.monogram.core.models.PeerId(7), + 99, + text = "fixture" + ) + val epoch = warmup.outgoingAccountEpoch() + warmup.saveLocalCloudDraft(draft) + val captured = warmup.captureOutgoingTextDraft(draft.chatId, 99, epoch) + assertTrue(warmup.enqueueOutgoingTextSend(row(), epoch, captured)) + database.close(); reopen() + val restored = OfflineWarmup(database) + assertNotNull(database.outgoingTextSendDao().get(123)) + assertEquals("", restored.cloudDraftState(draft.chatId, 99).draft.text) + assertTrue(restored.cloudDraftState(draft.chatId, 99).dirty) + database.outgoingTextSendDao().acknowledge(123, 7) + restored.saveLocalCloudDraft(draft) + val older = + restored.captureOutgoingTextDraft(draft.chatId, 99, restored.outgoingAccountEpoch()) + restored.saveLocalCloudDraft(draft) + assertFalse(restored.enqueueOutgoingTextSend(row(), restored.outgoingAccountEpoch(), older)) + assertEquals("fixture", restored.cloudDraftState(draft.chatId, 99).draft.text) + database.outgoingTextSendDao().acknowledge(123, 7) + val supported = + restored.captureOutgoingTextDraft(draft.chatId, 99, restored.outgoingAccountEpoch()) + restored.applyCloudDraft(draft.copy(hasUnsupportedContent = true)) + try { + restored.enqueueOutgoingTextSend(row(), restored.outgoingAccountEpoch(), supported) + fail("new unsupported content was sent") + } catch (_: IllegalStateException) { + } + assertNull(database.outgoingTextSendDao().get(123)) + assertTrue(restored.cloudDraftState(draft.chatId, 99).draft.hasUnsupportedContent) + } + + @Test + fun delayedComposerWritesCannotCrossLogoutOrAccountReplacement() = runBlocking { + val warmup = OfflineWarmup(database) + val epoch = warmup.outgoingAccountEpoch() + warmup.deactivateOutgoingAccount() + suspend fun rejected(stale: Long) { + try { + warmup.enqueueOutgoingTextSend(row(), stale); fail("stale account write accepted") + } catch (_: IllegalStateException) { + } + } + rejected(epoch) + warmup.activateOutgoingAccount() + rejected(epoch) + val active = warmup.outgoingAccountEpoch() + warmup.enqueueOutgoingTextSend(row(), active) + assertNotNull(database.outgoingTextSendDao().get(123)) + warmup.clearAccountCache() + rejected(active) + warmup.activateOutgoingAccount() + rejected(active) + assertNull(database.outgoingTextSendDao().get(123)) + warmup.enqueueOutgoingTextSend(row(), warmup.outgoingAccountEpoch()) + assertNotNull(database.outgoingTextSendDao().get(123)) + } + + @Test + fun payloadOptionsAndRandomIdsSurviveRestartWithoutAutomaticUncertainReplay() = runBlocking { + for (date in listOf(null, 0x7ffffffe)) { + val expected = row(date) + database.outgoingTextSendDao().enqueue(expected) + assertEquals(1, database.outgoingTextSendDao().claim(123)) + assertEquals(0, database.outgoingTextSendDao().claim(123)) + database.close(); reopen() + val dao = database.outgoingTextSendDao() + assertEquals(expected.copy(phase = OutgoingSendPhase.IN_FLIGHT), dao.get(123)) + assertEquals(1, dao.recoverInterrupted()) + assertEquals(0, dao.claim(123)) + assertEquals(expected.copy(phase = OutgoingSendPhase.UNCERTAIN), dao.get(123)) + assertEquals(1, dao.retry(123)) + assertEquals(expected, dao.get(123)) + dao.acknowledge(123, 7) + } + } + + @Test + fun duplicateEnqueueDoesNotResetFlightOrPermitPayloadMutation() = runBlocking { + val dao = database.outgoingTextSendDao() + val original = row() + dao.enqueue(original); dao.claim(123); dao.enqueue(original) + assertEquals(0, dao.claim(123)) + try { + dao.enqueue(original.copy(message = original.message.copy(text = "changed"))) + fail("random ID payload changed") + } catch (_: IllegalArgumentException) { + } + assertEquals(original.copy(phase = OutgoingSendPhase.IN_FLIGHT), dao.get(123)) + } + + @Test + fun authoritativeReceiptAndOutboxRemovalRollbackAndCommitTogether() = runBlocking { + val dao = database.outgoingTextSendDao() + val original = row() + dao.enqueue(original) + val result = OutgoingMessageResult.Scheduled( + ScheduledMessage( + original.message.copy( + id = 37, + pending = false + ).toModel() + ) + ) + val warmup = OfflineWarmup(database) + try { + warmup.applyDurableBatch(5, 12, null, emptyMap()) { + warmup.applyOutgoingResult(123, result) + error("synthetic receipt rollback") + } + } catch (_: IllegalStateException) { + } + assertEquals(original, dao.get(123)) + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + warmup.applyDurableBatch(5, 12, null, emptyMap()) { + warmup.applyOutgoingResult( + 123, + result + ) + } + assertNull(dao.get(123)) + assertEquals(37, database.scheduledMessageDao().forChat(7).single().message.id) + assertTrue(database.messageDao().byIds(7, listOf(37)).isEmpty()) + } + + @Test + fun floodDeadlinePersistsAndCannotBeShortenedAndClearsWithTheAccount() = runBlocking { + val cache = OfflineWarmup(database) + cache.blockOutgoingSendsUntil(130_000) + cache.blockOutgoingSendsUntil(120_000) + database.close(); reopen() + val restored = OfflineWarmup(database) + assertEquals(130_000L, restored.outgoingSendBlockedUntil()) + restored.clearAccountCache() + assertEquals(0L, restored.outgoingSendBlockedUntil()) + } + + @Test + fun foreignPeerReceiptCannotRemoveOutboxOrInsertHistory() = runBlocking { + val original = row(null) + database.outgoingTextSendDao().enqueue(original) + val result = OutgoingMessageResult.Sent( + original.message.copy(chatId = 8, id = 37, pending = false).toModel() + ) + try { + OfflineWarmup(database).applyOutgoingResult(123, result) + fail("foreign peer receipt accepted") + } catch (_: IllegalArgumentException) { + } + assertEquals(original, database.outgoingTextSendDao().get(123)) + assertTrue(database.messageDao().byIds(8, listOf(37)).isEmpty()) + } +} diff --git a/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt b/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt new file mode 100644 index 000000000..056b1b80f --- /dev/null +++ b/core/database/src/androidTest/java/org/monogram/core/database/ScheduledQueuePersistenceTest.kt @@ -0,0 +1,299 @@ +package org.monogram.core.database + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.withTransaction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.flow.first +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.MetaEntity +import org.monogram.core.database.entity.ScheduledMessageEntity +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class ScheduledQueuePersistenceTest { + private lateinit var context: Context + private lateinit var name: String + private lateinit var database: MonogramDatabase + + @Before + fun open() { + context = InstrumentationRegistry.getInstrumentation().targetContext + name = "queue-test-${UUID.randomUUID()}.db" + reopen() + } + + private fun reopen() { + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name) + .addMigrations(DatabaseProvider.MIGRATION_9_10).build() + } + + @After + fun close() { + database.close() + context.deleteDatabase(name) + } + + private fun message(id: Int = 37, text: String = "scheduled", chatId: Long = 7) = MessageEntity( + chatId = chatId, id = id, senderId = 7, text = text, date = 100, + outgoing = true, mediaCacheKey = "attachment", mediaKind = "photo", + entitiesJson = """[{"kind":"bold","offset":0,"length":3}]""", + replyToMsgId = 42, replyToTopId = 99, forumTopic = true, + ) + + @Test + fun overlappingIdsRemainSeparateAndSurviveRestart() = runBlocking { + val scheduled = ScheduledMessageEntity(message()) + database.metaDao().upsert(MetaEntity("fixture.identity", "7")) + database.messageDao().upsertAll(listOf(message(text = "history"))) + val dao = database.scheduledMessageDao() + assertTrue(dao.replaceIfCurrent(7, dao.revision(7), listOf(scheduled))) + database.close() + reopen() + assertEquals(scheduled, database.scheduledMessageDao().forChat(7).single()) + assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) + assertEquals("7", database.metaDao().get("fixture.identity")?.value) + database.scheduledMessageDao().remove(7, listOf(37)) + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) + } + + @Test + fun newerEventsDeletionsAndAccountClearingInvalidateInFlightSnapshots() = runBlocking { + val dao = database.scheduledMessageDao() + val beforeEvent = dao.revision(7) + dao.applyMessages(7, listOf(ScheduledMessageEntity(message(text = "new event")))) + assertFalse(dao.replaceIfCurrent(7, beforeEvent, listOf(ScheduledMessageEntity(message())))) + val beforeDelete = dao.revision(7) + dao.remove(7, listOf(37)) + assertFalse( + dao.replaceIfCurrent( + 7, + beforeDelete, + listOf(ScheduledMessageEntity(message())) + ) + ) + val beforeClear = dao.revision(8) + OfflineWarmup(database).clearAccountCache() + assertFalse( + dao.replaceIfCurrent( + 8, + beforeClear, + listOf(ScheduledMessageEntity(message(chatId = 8))) + ) + ) + assertTrue(dao.forChat(7).isEmpty()) + assertTrue(dao.forChat(8).isEmpty()) + } + + @Test + fun eventRowsAndRevisionRollbackTogetherAndRejectForeignOrPendingIds() = runBlocking { + val dao = database.scheduledMessageDao() + val revision = dao.revision(7) + try { + database.withTransaction { + dao.applyMessages(7, listOf(ScheduledMessageEntity(message()))) + error("synthetic rollback") + } + } catch (_: IllegalStateException) { + } + assertEquals(revision, dao.revision(7)) + assertTrue(dao.forChat(7).isEmpty()) + for (invalid in listOf( + message(id = -1), + message(chatId = 8), + message().copy(pending = true) + )) { + try { + dao.applyMessages(7, listOf(ScheduledMessageEntity(invalid))) + fail("invalid queue identity accepted") + } catch (_: IllegalArgumentException) { + } + } + assertEquals(revision, dao.revision(7)) + } + + @Test + fun durableQueueEventsAndReceiptsCommitTogetherWithoutTouchingHistory() = runBlocking { + val warmup = OfflineWarmup(database) + val dao = database.scheduledMessageDao() + database.messageDao().upsertAll(listOf(message(text = "history"))) + val scheduled = org.monogram.core.models.ScheduledMessage(message().toModel()) + assertTrue(warmup.applyDurableBatch(5, 8, null, emptyMap()) { + warmup.applyScheduledMessage(scheduled) + }) + assertTrue( + warmup.applyDurableBatch( + 5, + 8, + null, + emptyMap() + ) { error("replay must not apply twice") }) + val revision = dao.revision(7) + try { + warmup.applyDurableBatch(5, 9, null, emptyMap()) { + warmup.deleteScheduledMessages(org.monogram.core.models.PeerId(7), listOf(37)) + error("synthetic receipt rollback") + } + } catch (_: IllegalStateException) { + } + assertEquals(revision, dao.revision(7)) + assertEquals(37, dao.forChat(7).single().message.id) + assertTrue(warmup.applyDurableBatch(5, 9, null, emptyMap()) { + warmup.deleteScheduledMessages(org.monogram.core.models.PeerId(7), listOf(37)) + }) + assertTrue(dao.forChat(7).isEmpty()) + assertEquals("history", database.messageDao().byIds(7, listOf(37)).single().text) + } + + @Test + fun authoritativeOutgoingModesReconcileByRandomIdAcrossOverlappingNamespaces() = runBlocking { + val warmup = OfflineWarmup(database) + database.messageDao().upsertAll( + listOf( + message(text = "old history"), + message(id = -37, text = "pending").copy(pending = true, randomId = 123), + ) + ) + val queued = org.monogram.core.models.OutgoingMessageResult.Scheduled( + org.monogram.core.models.ScheduledMessage(message(text = "queued").toModel()), + ) + warmup.applyDurableBatch(5, 10, null, emptyMap()) { + warmup.applyOutgoingResult( + 456, + queued + ) + } + assertEquals(456L, database.scheduledMessageDao().forChat(7).single().message.randomId) + assertEquals("old history", database.messageDao().byIds(7, listOf(37)).single().text) + assertEquals(-37, database.messageDao().pendingByRandomId(7, 123).single().id) + val sent = + org.monogram.core.models.OutgoingMessageResult.Sent(message(text = "delivered").toModel()) + warmup.applyDurableBatch(5, 11, null, emptyMap()) { warmup.applyOutgoingResult(123, sent) } + database.close() + reopen() + assertTrue(database.messageDao().pendingByRandomId(7, 123).isEmpty()) + assertEquals("delivered", database.messageDao().byIds(7, listOf(37)).single().text) + assertEquals("queued", database.scheduledMessageDao().forChat(7).single().message.text) + assertTrue( + OfflineWarmup(database).applyDurableBatch( + 5, + 11, + null, + emptyMap() + ) { error("receipt replay") }) + } + + private fun legacyDatabase(version: Int, populate: (SQLiteDatabase) -> Unit) { + database.close() + val schema = InstrumentationRegistry.getInstrumentation().context.assets + .open("org.monogram.core.database.MonogramDatabase/$version.json").bufferedReader() + .use { JSONObject(it.readText()) } + .getJSONObject("database") + val file = context.getDatabasePath(name) + file.parentFile!!.mkdirs() + SQLiteDatabase.openOrCreateDatabase(file, null).use { old -> + val entities = schema.getJSONArray("entities") + for (i in 0 until entities.length()) { + val entity = entities.getJSONObject(i) + val table = entity.getString("tableName") + old.execSQL(entity.getString("createSql").replace("\${TABLE_NAME}", table)) + val indices = entity.optJSONArray("indices") + if (indices != null) for (j in 0 until indices.length()) old.execSQL( + indices.getJSONObject( + j + ).getString("createSql").replace("\${TABLE_NAME}", table) + ) + } + val setup = schema.getJSONArray("setupQueries") + for (i in 0 until setup.length()) old.execSQL(setup.getString(i)) + populate(old) + old.version = version + } + reopen() + } + + @Test + fun unsupportedFlagSurvivesCacheReopenAndSnapshotReplacement() = runBlocking { + val peer = org.monogram.core.models.PeerId(7) + val known = org.monogram.core.models.ScheduledMessage( + message().toModel(), + hasUnsupportedContent = false + ) + OfflineWarmup(database).applyScheduledMessage(known) + database.close(); reopen() + val warmup = OfflineWarmup(database) + assertFalse(warmup.observeScheduledMessages(peer).first().single().hasUnsupportedContent) + val epoch = warmup.outgoingAccountEpoch() + val revision = warmup.scheduledQueueRevision(peer, epoch) + assertTrue( + warmup.replaceScheduledMessagesIfCurrent( + peer, revision, + listOf(known.copy(hasUnsupportedContent = true)), epoch + ) + ) + assertTrue(warmup.observeScheduledMessages(peer).first().single().hasUnsupportedContent) + } + + @Test + fun versionNineMigrationPreservesIdentityHistoryAndPendingRandomIds() = runBlocking { + legacyDatabase(9) { old -> + old.execSQL( + "INSERT INTO meta (`key`, value) VALUES (?, ?)", + arrayOf(SessionMetadataStore.KEY_USER_ID, "7") + ) + old.execSQL( + "INSERT INTO meta (`key`, value) VALUES (?, ?)", + arrayOf(SessionMetadataStore.KEY_AUTHORIZED, "1") + ) + val schema = InstrumentationRegistry.getInstrumentation().context.assets + .open("org.monogram.core.database.MonogramDatabase/9.json").bufferedReader() + .use { JSONObject(it.readText()) }.getJSONObject("database") + .getJSONArray("entities") + val chat = (0 until schema.length()).map { schema.getJSONObject(it) } + .first { it.getString("tableName") == "chats" } + val fields = chat.getJSONArray("fields") + val values = ContentValues() + for (i in 0 until fields.length()) { + val field = fields.getJSONObject(i) + if (field.optBoolean("notNull", false)) { + val column = field.getString("columnName") + if (field.getString("affinity") == "INTEGER") values.put(column, 0) + else values.put(column, "") + } + } + values.put("id", 7L) + values.put("title", "Fixture") + values.put("canSendPlain", 1) + old.insertOrThrow("chats", null, values) + old.insertOrThrow("messages", null, ContentValues().apply { + put("chatId", 7L); put("id", -37); put("text", "pending fixture"); put("date", 100L) + put("outgoing", 1); put("supportsStreaming", 0); put("repliesCount", 0) + put("forumTopic", 0); put("pending", 1); put("randomId", 123456789L) + }) + } + assertEquals("7", database.metaDao().get(SessionMetadataStore.KEY_USER_ID)?.value) + assertEquals("1", database.metaDao().get(SessionMetadataStore.KEY_AUTHORIZED)?.value) + assertFalse(database.chatDao().get(7)!!.canSendPolls) + assertTrue(database.chatDao().get(7)!!.canSendPlain) + assertTrue(database.outgoingTextSendDao().all().isEmpty()) + assertTrue(database.outgoingMediaSendDao().all().isEmpty()) + val pending = database.messageDao().byIds(7, listOf(-37)).single() + assertTrue(pending.pending) + assertEquals(123456789L, pending.randomId) + assertTrue(database.scheduledMessageDao().forChat(7).isEmpty()) + val migratedQueue = database.scheduledMessageDao() + assertTrue(migratedQueue.replaceIfCurrent(7, 0, listOf(ScheduledMessageEntity(message())))) + assertEquals(DatabaseProvider.SCHEMA_VERSION, database.openHelper.readableDatabase.version) + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/CloudDraftSyncMonitor.kt b/core/database/src/main/java/org/monogram/core/database/CloudDraftSyncMonitor.kt new file mode 100644 index 000000000..8b3bc41e5 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/CloudDraftSyncMonitor.kt @@ -0,0 +1,75 @@ +package org.monogram.core.database + +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.PeerId + +data class CloudDraftSyncStatus(val syncing: Boolean = false, val error: TelegramError? = null) + +class CloudDraftSyncMonitor(private val elapsedMillis: () -> Long = { System.nanoTime() / 1_000_000 }) { + private data class Failure(val error: TelegramError, val retryUntil: Long) + private data class Snapshot( + val busy: Map?, Long> = emptyMap(), + val errors: Map?, Failure> = emptyMap(), + ) + + private val state = MutableStateFlow(Snapshot()) + private val sequence = AtomicLong() + private val retryRequests = MutableSharedFlow(extraBufferCapacity = 1) + val retries = retryRequests.asSharedFlow() + + fun observe(chatId: PeerId, threadId: Int = 0) = state.map { snapshot -> + val key = chatId to threadId + val failure = snapshot.errors[null] ?: snapshot.errors[key] + val error = failure?.let { + if (it.error.retryAfterSeconds == null) it.error else it.error.copy( + retryAfterSeconds = ((it.retryUntil - elapsedMillis()).coerceAtLeast(0) + 999) + .div(1_000).coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + ) + } + CloudDraftSyncStatus(null in snapshot.busy || key in snapshot.busy, error) + }.distinctUntilChanged() + + fun requestRetry() { + retryRequests.tryEmit(Unit) + } + + fun begin(context: Pair? = null): Long { + val token = sequence.incrementAndGet() + state.update { it.copy(busy = it.busy + (context to token), errors = it.errors - context) } + return token + } + + fun finish(context: Pair?, token: Long, error: TelegramError? = null) { + val failure = error?.let { + Failure( + it, + elapsedMillis() + (it.retryAfterSeconds?.toLong() ?: 0) * 1_000 + ) + } + state.update { + if (it.busy[context] != token) it else it.copy( + busy = it.busy - context, + errors = if (failure == null) it.errors - context else it.errors + (context to failure), + ) + } + } + + fun fail(error: TelegramError) { + finish(null, begin(), error) + } + + fun clearError(context: Pair) { + state.update { it.copy(errors = it.errors - context) } + } + + fun reset() { + state.value = Snapshot() + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/ContactImportCodec.kt b/core/database/src/main/java/org/monogram/core/database/ContactImportCodec.kt new file mode 100644 index 000000000..dbd0bbc60 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/ContactImportCodec.kt @@ -0,0 +1,50 @@ +package org.monogram.core.database + +import kotlinx.serialization.Required +import kotlinx.serialization.Serializable +import org.json.JSONObject +import org.monogram.core.models.ContactImportAttempt +import org.monogram.core.models.ContactImportPhase +import org.monogram.core.models.ContactImportSpec +import org.monogram.core.models.ContactSendSpec +import org.monogram.core.models.PeerId +import java.security.MessageDigest + +internal fun contactImportMetaKey(spec: ContactSendSpec): String { + spec.copy(vcard = "").requireValid() + // Existing metadata keys depend on JSONObject's escaping and field order. + val identity = JSONObject().put("send_version", 1).put("p", spec.phone) + .put("f", spec.firstName).put("l", spec.lastName).put("c", "").put("u", 0).toString() + val bytes = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)) + return "contact_import:" + bytes.joinToString("") { "%02x".format(it.toInt() and 255) } +} + +internal fun encodeContactImport(attempt: ContactImportAttempt): String = + storedPayloadJson.encodeToString( + StoredContactImport( + id = attempt.spec.clientId, phone = attempt.spec.phone, first = attempt.spec.firstName, + last = attempt.spec.lastName, phase = attempt.phase, user = attempt.userId?.value, + ) + ) + +internal fun decodeContactImport(raw: String): ContactImportAttempt = readStoredPayload { + val value = storedPayloadJson.decodeFromString(raw) + require(value.version == 1 && value.id != 0L) + ContactSendSpec(value.phone, value.first, value.last).requireValid() + val user = value.user?.let(::PeerId) + require(if (value.phase == ContactImportPhase.IMPORTED) user != null && user.value > 0 else user == null) + ContactImportAttempt( + ContactImportSpec(value.id, value.phone, value.first, value.last), value.phase, user, + ) +} + +@Serializable +private data class StoredContactImport( + @Required val version: Int = 1, + val id: Long, + val phone: String, + val first: String, + val last: String, + val phase: ContactImportPhase, + val user: Long? = null, +) diff --git a/core/database/src/main/java/org/monogram/core/database/ContactSendCodec.kt b/core/database/src/main/java/org/monogram/core/database/ContactSendCodec.kt new file mode 100644 index 000000000..23b365661 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/ContactSendCodec.kt @@ -0,0 +1,35 @@ +package org.monogram.core.database + +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.monogram.core.models.ContactSendSpec + +fun encodeContactSend(spec: ContactSendSpec): String { + spec.requireValid() + return storedPayloadJson.encodeToString( + StoredContactSend( + phone = spec.phone, + firstName = spec.firstName, + lastName = spec.lastName, + vcard = spec.vcard, + ) + ) +} + +fun decodeContactSend(raw: String?): ContactSendSpec = readStoredPayload { + val value = storedPayloadJson.decodeFromString(requireNotNull(raw)) + require(value.version == 1 && value.userId == 0L) + ContactSendSpec(value.phone, value.firstName, value.lastName, value.vcard) + .also { it.requireValid() } +} + +@Serializable +private data class StoredContactSend( + @Required @SerialName("send_version") val version: Int = 1, + @SerialName("p") val phone: String, + @SerialName("f") val firstName: String, + @SerialName("l") val lastName: String, + @SerialName("c") val vcard: String, + @Required @SerialName("u") val userId: Long = 0, +) diff --git a/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt index 9eba3c5f8..466281b42 100644 --- a/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt +++ b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt @@ -22,7 +22,7 @@ object DatabaseProvider { @Volatile private var instance: MonogramDatabase? = null - const val SCHEMA_VERSION = 9 + const val SCHEMA_VERSION = 10 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -83,6 +83,84 @@ object DatabaseProvider { } } + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS scheduled_messages ( + chatId INTEGER NOT NULL, id INTEGER NOT NULL, senderId INTEGER, text TEXT, + date INTEGER NOT NULL, editDate INTEGER, outgoing INTEGER NOT NULL, + mediaCacheKey TEXT, mediaKind TEXT, thumbCacheKey TEXT, mediaDuration INTEGER, + mediaWidth INTEGER, mediaHeight INTEGER, groupedId INTEGER, fileName TEXT, + fileSize INTEGER, supportsStreaming INTEGER NOT NULL, reactionsJson TEXT, + repliesCount INTEGER NOT NULL, discussionPeerId INTEGER, replyQuote TEXT, + replyQuoteEntitiesJson TEXT, entitiesJson TEXT, replyToMsgId INTEGER, + replyToTopId INTEGER, forumTopic INTEGER NOT NULL, fwdFrom TEXT, fwdFromId INTEGER, + fwdDate INTEGER, viaBot TEXT, senderName TEXT, replyMarkupJson TEXT, + pending INTEGER NOT NULL, failed INTEGER NOT NULL DEFAULT 0, randomId INTEGER, + checklistJson TEXT, hasUnsupportedContent INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(chatId, id) + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_scheduled_messages_chatId_date ON scheduled_messages (chatId, date)") + db.execSQL("CREATE TABLE IF NOT EXISTS scheduled_queue_revisions (chatId INTEGER NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(chatId))") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS outgoing_text_sends ( + randomId INTEGER NOT NULL, + message_chatId INTEGER NOT NULL, message_id INTEGER NOT NULL, message_senderId INTEGER, + message_text TEXT, message_date INTEGER NOT NULL, message_editDate INTEGER, + message_outgoing INTEGER NOT NULL, message_mediaCacheKey TEXT, message_mediaKind TEXT, + message_thumbCacheKey TEXT, message_mediaDuration INTEGER, message_mediaWidth INTEGER, + message_mediaHeight INTEGER, message_groupedId INTEGER, message_fileName TEXT, + message_fileSize INTEGER, message_supportsStreaming INTEGER NOT NULL, message_reactionsJson TEXT, + message_repliesCount INTEGER NOT NULL, message_discussionPeerId INTEGER, message_replyQuote TEXT, + message_replyQuoteEntitiesJson TEXT, message_entitiesJson TEXT, message_replyToMsgId INTEGER, + message_replyToTopId INTEGER, message_forumTopic INTEGER NOT NULL, message_fwdFrom TEXT, + message_fwdFromId INTEGER, message_fwdDate INTEGER, message_viaBot TEXT, message_senderName TEXT, + message_replyMarkupJson TEXT, message_pending INTEGER NOT NULL, message_failed INTEGER NOT NULL DEFAULT 0, + message_randomId INTEGER, message_checklistJson TEXT, silent INTEGER NOT NULL, + noWebpage INTEGER NOT NULL, scheduleDate INTEGER, webpageUrl TEXT, phase TEXT NOT NULL, + PRIMARY KEY(randomId) + ) + """.trimIndent() + ) + db.execSQL("ALTER TABLE chats ADD COLUMN canSendPolls INTEGER NOT NULL DEFAULT 0") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS outgoing_media_batches ( + batchId INTEGER NOT NULL, chatId INTEGER NOT NULL, createdAt INTEGER NOT NULL, + silent INTEGER NOT NULL, scheduleDate INTEGER, album INTEGER NOT NULL, phase TEXT NOT NULL, + PRIMARY KEY(batchId) + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS outgoing_media_items ( + randomId INTEGER NOT NULL, batchId INTEGER NOT NULL, position INTEGER NOT NULL, mimeType TEXT NOT NULL, + source_name TEXT NOT NULL, source_size INTEGER NOT NULL, source_sha256 TEXT NOT NULL, + message_chatId INTEGER NOT NULL, message_id INTEGER NOT NULL, message_senderId INTEGER, + message_text TEXT, message_date INTEGER NOT NULL, message_editDate INTEGER, + message_outgoing INTEGER NOT NULL, message_mediaCacheKey TEXT, message_mediaKind TEXT, + message_thumbCacheKey TEXT, message_mediaDuration INTEGER, message_mediaWidth INTEGER, + message_mediaHeight INTEGER, message_groupedId INTEGER, message_fileName TEXT, + message_fileSize INTEGER, message_supportsStreaming INTEGER NOT NULL, message_reactionsJson TEXT, + message_repliesCount INTEGER NOT NULL, message_discussionPeerId INTEGER, message_replyQuote TEXT, + message_replyQuoteEntitiesJson TEXT, message_entitiesJson TEXT, message_replyToMsgId INTEGER, + message_replyToTopId INTEGER, message_forumTopic INTEGER NOT NULL, message_fwdFrom TEXT, + message_fwdFromId INTEGER, message_fwdDate INTEGER, message_viaBot TEXT, message_senderName TEXT, + message_replyMarkupJson TEXT, message_pending INTEGER NOT NULL, message_failed INTEGER NOT NULL DEFAULT 0, + message_randomId INTEGER, message_checklistJson TEXT, PRIMARY KEY(randomId), + FOREIGN KEY(batchId) REFERENCES outgoing_media_batches(batchId) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_outgoing_media_items_batchId_position ON outgoing_media_items(batchId, position)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_outgoing_media_items_source_name ON outgoing_media_items(source_name)") + } + } + /** SQL stays off Dispatchers.IO, which media downloads also use. */ private val queueLock = Object() private val onRoomThread = ThreadLocal() @@ -219,6 +297,7 @@ object DatabaseProvider { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ) .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setQueryExecutor(roomQueryExecutor) diff --git a/core/database/src/main/java/org/monogram/core/database/DraftStateCodec.kt b/core/database/src/main/java/org/monogram/core/database/DraftStateCodec.kt new file mode 100644 index 000000000..8d354f2c4 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/DraftStateCodec.kt @@ -0,0 +1,78 @@ +package org.monogram.core.database + +import kotlinx.serialization.Required +import kotlinx.serialization.Serializable +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState +import org.monogram.core.models.PeerId + +internal fun encodeDraftState(state: CloudDraftState): String = + storedPayloadJson.encodeToString( + StoredDraftState( + chatId = state.draft.chatId.value, + threadId = state.draft.threadId, + text = state.draft.text, + entities = state.draft.entitiesJson, + replyTo = state.draft.replyToMessageId, + date = state.draft.date, + noWebpage = state.draft.noWebpage, + unsupported = state.draft.hasUnsupportedContent, + revision = state.revision, + dirty = state.dirty, + discardingUnsupported = state.discardingUnsupported, + markdown = state.markdown, + remoteDate = state.lastRemoteDate, + ) + ) + +internal fun decodeDraftState(raw: String): CloudDraftState = readStoredPayload { + val value = storedPayloadJson.decodeFromString(raw) + check(value.version == 1) { "Unsupported stored draft version" } + CloudDraftState( + draft = CloudDraft( + chatId = PeerId(value.chatId), + threadId = value.threadId, + text = value.text, + entitiesJson = value.entities, + replyToMessageId = value.replyTo, + date = value.date, + noWebpage = value.noWebpage, + hasUnsupportedContent = value.unsupported, + ), + revision = value.revision, + dirty = value.dirty, + discardingUnsupported = value.discardingUnsupported, + markdown = value.markdown, + lastRemoteDate = value.remoteDate, + ) +} + +@Serializable +private data class StoredDraftState( + @Required val version: Int = 1, + val chatId: Long, + val threadId: Int, + val text: String, + val entities: String? = null, + val replyTo: Int, + val date: Int, + val noWebpage: Boolean, + val unsupported: Boolean, + val revision: Long, + val dirty: Boolean, + val discardingUnsupported: Boolean = false, + val markdown: Boolean = false, + val remoteDate: Int, +) + +internal fun draftContextFromMetaKey(key: String): Pair? { + val parts = key.split(':') + if (parts.firstOrNull() !in setOf("draft", "cloud_draft") || parts.size !in 2..3) return null + val chatId = parts[1].toLongOrNull()?.takeIf { it != 0L } ?: return null + val threadId = + if (parts.size == 2) 0 else parts[2].toIntOrNull()?.takeIf { it >= 0 } ?: return null + return PeerId(chatId) to threadId +} + +internal fun cloudDraftMetaKey(chatId: Long, threadId: Int): String = + "cloud_draft:$chatId:$threadId" diff --git a/core/database/src/main/java/org/monogram/core/database/LiveLocationCodec.kt b/core/database/src/main/java/org/monogram/core/database/LiveLocationCodec.kt new file mode 100644 index 000000000..c96b8469d --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/LiveLocationCodec.kt @@ -0,0 +1,53 @@ +package org.monogram.core.database + +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.monogram.core.models.LiveLocationPhase +import org.monogram.core.models.LiveLocationSession +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +internal fun liveLocationKey(id: MessageId) = "live_location:${id.chatId.value}:${id.id}" + +internal fun encodeLiveLocation(session: LiveLocationSession): String { + session.requireValidStoredSession() + return storedPayloadJson.encodeToString( + StoredLiveLocation( + chat = session.messageId.chatId.value, + id = session.messageId.id, + random = session.randomId, + expires = session.expiresAt, + phase = session.phase, + revision = session.revision, + ) + ) +} + +internal fun decodeLiveLocation(raw: String): LiveLocationSession = readStoredPayload { + val value = storedPayloadJson.decodeFromString(raw) + require(value.version == 1) + LiveLocationSession( + MessageId(PeerId(value.chat), value.id), + value.random, + value.expires, + value.phase, + value.revision, + ).also { it.requireValidStoredSession() } +} + +private fun LiveLocationSession.requireValidStoredSession() { + require(messageId.chatId.value != 0L && messageId.id > 0 && randomId != 0L && revision >= 0) + require(expiresAt?.let { it > 0 } != false) +} + +@Serializable +private data class StoredLiveLocation( + @Required @SerialName("v") val version: Int = 1, + val chat: Long, + val id: Int, + val random: Long, + val expires: Long? = null, + val phase: LiveLocationPhase, + val revision: Long, +) diff --git a/core/database/src/main/java/org/monogram/core/database/LocationSendCodec.kt b/core/database/src/main/java/org/monogram/core/database/LocationSendCodec.kt new file mode 100644 index 000000000..0e9866393 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/LocationSendCodec.kt @@ -0,0 +1,43 @@ +package org.monogram.core.database + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.monogram.core.models.GeoPlace + +fun encodeLocationSend(place: GeoPlace): String { + place.requireValidSend() + return storedPayloadJson.encodeToString( + StoredLocationSend( + latitude = place.latitude, longitude = place.longitude, live = place.live, + period = place.periodSeconds, heading = place.heading, + ) + ) +} + +fun decodeLocationSend(raw: String?): GeoPlace = readStoredPayload { + val value = storedPayloadJson.decodeFromString(requireNotNull(raw)) + require(value.version == 1) + GeoPlace(value.latitude, value.longitude, value.live, value.period, value.heading) + .also { it.requireValidSend() } +} + +private fun GeoPlace.requireValidSend() { + require(latitude.isFinite() && latitude in -90.0..90.0 && longitude.isFinite() && longitude in -180.0..180.0) + require(if (live) periodSeconds == Int.MAX_VALUE || periodSeconds in 60..86400 else periodSeconds == 0) + require(if (live) heading == null || heading in 1..360 else heading == null) +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +private data class StoredLocationSend( + @Required @SerialName("send_version") val version: Int = 1, + @SerialName("lat") val latitude: Double, + @SerialName("long") val longitude: Double, + val live: Boolean, + @SerialName("p") val period: Int, + @EncodeDefault(EncodeDefault.Mode.NEVER) + @SerialName("h") val heading: Int? = null, +) diff --git a/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt b/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt index 16f40ac70..7adc57fae 100644 --- a/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt +++ b/core/database/src/main/java/org/monogram/core/database/ModelMappers.kt @@ -60,6 +60,7 @@ fun ChatEntity.toModel(): Chat = Chat( canDeleteOthers = canDeleteOthers, canManageTopics = canManageTopics, emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, ) fun Chat.toEntity(): ChatEntity = ChatEntity( @@ -101,6 +102,7 @@ fun Chat.toEntity(): ChatEntity = ChatEntity( canDeleteOthers = canDeleteOthers, canManageTopics = canManageTopics, emojiStatusDocumentId = emojiStatusDocumentId, + canSendPolls = canSendPolls, ) fun FolderEntity.toModel(): Folder = Folder( diff --git a/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt b/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt index b7d8589bf..4c1f18cd9 100644 --- a/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt +++ b/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt @@ -8,23 +8,32 @@ import org.monogram.core.database.dao.MessageDao import org.monogram.core.database.dao.MessageHoleDao import org.monogram.core.database.dao.MessageTombstoneDao import org.monogram.core.database.dao.MetaDao +import org.monogram.core.database.dao.OutgoingMediaSendDao +import org.monogram.core.database.dao.OutgoingTextSendDao import org.monogram.core.database.dao.PeerDao import org.monogram.core.database.dao.ProfileCommonDao import org.monogram.core.database.dao.ProfileMediaDao import org.monogram.core.database.dao.ProfileMembersDao import org.monogram.core.database.dao.ProfileTabsDao +import org.monogram.core.database.dao.ScheduledMessageDao import org.monogram.core.database.dao.SponsorDao import org.monogram.core.database.dao.UpdateCursorDao import org.monogram.core.database.entity.ChatEntity import org.monogram.core.database.entity.FolderEntity import org.monogram.core.database.entity.MessageEntity import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.MessageTombstoneEntity import org.monogram.core.database.entity.MetaEntity +import org.monogram.core.database.entity.OutgoingMediaBatchEntity +import org.monogram.core.database.entity.OutgoingMediaItemEntity +import org.monogram.core.database.entity.OutgoingTextSendEntity import org.monogram.core.database.entity.PeerEntity import org.monogram.core.database.entity.ProfileCommonEntity import org.monogram.core.database.entity.ProfileMediaEntity import org.monogram.core.database.entity.ProfileMemberEntity import org.monogram.core.database.entity.ProfileTabsEntity +import org.monogram.core.database.entity.ScheduledMessageEntity +import org.monogram.core.database.entity.ScheduledQueueRevisionEntity import org.monogram.core.database.entity.SponsorEntity import org.monogram.core.database.entity.UpdateBatchReceiptEntity import org.monogram.core.database.entity.UpdateCursorEntity @@ -34,8 +43,13 @@ import org.monogram.core.database.entity.UpdateCursorEntity MetaEntity::class, ChatEntity::class, FolderEntity::class, + OutgoingMediaBatchEntity::class, + OutgoingMediaItemEntity::class, + OutgoingTextSendEntity::class, + ScheduledMessageEntity::class, + ScheduledQueueRevisionEntity::class, MessageEntity::class, - org.monogram.core.database.entity.MessageTombstoneEntity::class, + MessageTombstoneEntity::class, MessageHoleEntity::class, UpdateCursorEntity::class, UpdateBatchReceiptEntity::class, @@ -46,13 +60,16 @@ import org.monogram.core.database.entity.UpdateCursorEntity ProfileCommonEntity::class, SponsorEntity::class, ], - version = 9, + version = 10, exportSchema = true, ) abstract class MonogramDatabase : RoomDatabase() { abstract fun metaDao(): MetaDao abstract fun chatDao(): ChatDao abstract fun folderDao(): FolderDao + abstract fun outgoingMediaSendDao(): OutgoingMediaSendDao + abstract fun outgoingTextSendDao(): OutgoingTextSendDao + abstract fun scheduledMessageDao(): ScheduledMessageDao abstract fun messageDao(): MessageDao abstract fun messageTombstoneDao(): MessageTombstoneDao abstract fun messageHoleDao(): MessageHoleDao diff --git a/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt index 8371ecb29..c0b10feb2 100644 --- a/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt +++ b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt @@ -3,10 +3,14 @@ package org.monogram.core.database import androidx.room.withTransaction import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -15,17 +19,35 @@ import org.monogram.core.common.AppLog import org.monogram.core.common.PerfLog import org.monogram.core.database.dao.ChatReadState import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.MetaEntity +import org.monogram.core.database.entity.OutgoingMediaBatchEntity +import org.monogram.core.database.entity.OutgoingMediaItemEntity +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.database.entity.ScheduledMessageEntity import org.monogram.core.database.entity.UpdateBatchReceiptEntity import org.monogram.core.database.entity.UpdateCursorEntity import org.monogram.core.models.Chat +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState +import org.monogram.core.models.ContactImportAttempt +import org.monogram.core.models.ContactImportPhase +import org.monogram.core.models.ContactImportSpec +import org.monogram.core.models.ContactSendSpec import org.monogram.core.models.Folder +import org.monogram.core.models.LiveLocationPhase +import org.monogram.core.models.LiveLocationSession import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.OutgoingMessageResult import org.monogram.core.models.PeerId import org.monogram.core.models.Profile +import org.monogram.core.models.ScheduledMessage import org.monogram.core.models.chatListPreviewSource import org.monogram.core.models.isPlaceholderPeerTitle import org.monogram.core.models.mergeIncomingMessage import org.monogram.core.models.mergeLocalCache +import java.util.UUID.randomUUID /** * Reads cached dialogs/folders/messages for UI hydration before network sync. @@ -36,8 +58,32 @@ import org.monogram.core.models.mergeLocalCache open class OfflineWarmup( private val db: MonogramDatabase? = null, val recentHistory: RecentHistoryMemory = RecentHistoryMemory(), + val outgoingMediaFiles: OutgoingMediaFiles? = null, ) { private val cleanupMutex = Mutex() + val draftSync = CloudDraftSyncMonitor() + val outgoingSendSync = OutgoingSendSyncMonitor() + private val activeContactImports = mutableMapOf, Long>() + private val draftWriteMutex = Mutex() + private val outgoingAccountEpoch = java.util.concurrent.atomic.AtomicLong() + + @Volatile + private var outgoingAccountActive = true + + fun outgoingAccountEpoch(): Long = outgoingAccountEpoch.get() + fun outgoingAccountIsCurrent(epoch: Long): Boolean = + outgoingAccountActive && outgoingAccountEpoch.get() == epoch + + suspend fun deactivateOutgoingAccount() = draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + Unit + } + + suspend fun activateOutgoingAccount() = draftWriteMutex.withLock { + if (!outgoingAccountActive) outgoingAccountEpoch.incrementAndGet() + outgoingAccountActive = true + } @Volatile private var firstPaintSnapshot: OfflineWarmupSnapshot? = null @@ -99,8 +145,12 @@ open class OfflineWarmup( open suspend fun chatsAfter(cursor: ChatCacheCursor, limit: Int): List = roomIo { db?.chatDao()?.afterCursor( - cursor.archived, cursor.pinned, cursor.pinnedOrder, - cursor.lastMessageDate, cursor.id, limit + cursor.archived, + cursor.pinned, + cursor.pinnedOrder, + cursor.lastMessageDate, + cursor.id, + limit )?.map { it.toModel() }.orEmpty() } @@ -159,14 +209,13 @@ open class OfflineWarmup( pruneOffset = if (chats.size < CACHE_PRUNE_CHATS) 0 else pruneOffset + chats.size for (chatId in chatsToPrune(chats, openChatId)) { currentCoroutineContext().ensureActive() - val newest = database.messageDao() - .newestConfirmedIds(chatId, HISTORY_CACHE_WINDOW) - .toSet() + val newest = + database.messageDao().newestConfirmedIds(chatId, HISTORY_CACHE_WINDOW).toSet() val old = database.messageDao() .oldConfirmedIds(chatId, cutoffSeconds, CACHE_PRUNE_ROWS + newest.size) val drop = old.filter { it !in newest }.take(CACHE_PRUNE_ROWS) - val kept = (newest + database.messageDao() - .stickyIds(chatId, cutoffSeconds, 200)).distinct() + val kept = + (newest + database.messageDao().stickyIds(chatId, cutoffSeconds, 200)).distinct() kept.minOrNull()?.let { oldestKept -> database.messageTombstoneDao().deleteBelow(chatId, oldestKept) } @@ -228,22 +277,126 @@ open class OfflineWarmup( open fun observeReadStates(): Flow> = db?.chatDao()?.observeReadStates() ?: emptyFlow() - open suspend fun draft(chatId: PeerId, threadId: Int = 0): String = roomIo { - db?.metaDao()?.get(draftMetaKey(chatId.value, threadId))?.value.orEmpty() + open suspend fun draft(chatId: PeerId, threadId: Int = 0): String = + cloudDraftState(chatId, threadId).draft.text + + open suspend fun setDraft(chatId: PeerId, threadId: Int = 0, text: String) { + editLocalCloudDraft(chatId, threadId) { current -> + current.copy( + text = text, entitiesJson = null, + replyToMessageId = if (text.isEmpty()) 0 else current.replyToMessageId, + hasUnsupportedContent = if (text.isEmpty()) false else current.hasUnsupportedContent, + ) + } } - open suspend fun setDraft(chatId: PeerId, threadId: Int = 0, text: String) = roomIo { - val database = db ?: return@roomIo - val key = draftMetaKey(chatId.value, threadId) - if (text.isEmpty()) { - database.metaDao().delete(key) - } else { - database.metaDao().upsert( - org.monogram.core.database.entity.MetaEntity(key, text), + open suspend fun cloudDraftState(chatId: PeerId, threadId: Int = 0): CloudDraftState = roomIo { + db?.let { readCloudDraftState(it, chatId, threadId) } ?: CloudDraftState( + CloudDraft( + chatId, threadId ) + ) + } + + open fun observeCloudDraft(chatId: PeerId, threadId: Int = 0): Flow = + db?.metaDao()?.observe(cloudDraftMetaKey(chatId.value, threadId)) + ?.map { cloudDraftState(chatId, threadId) }?.distinctUntilChanged() ?: emptyFlow() + + open fun observeCloudDraftStates(): Flow> = + db?.metaDao()?.observeLike("cloud_draft:%")?.map { cloudDraftStates() } + ?.distinctUntilChanged() ?: emptyFlow() + + open suspend fun cloudDraftStates(): List = roomIo { + val database = db ?: return@roomIo emptyList() + database.withTransaction { + val keys = database.metaDao().keysLike("cloud_draft:%") + database.metaDao() + .keysLike("draft:%") + val contexts = keys.mapNotNull(::draftContextFromMetaKey).distinct() + contexts.map { (chatId, threadId) -> readCloudDraftState(database, chatId, threadId) } } } + open suspend fun saveLocalCloudDraft( + draft: CloudDraft, markdown: Boolean? = null + ): CloudDraftState = + editLocalCloudDraft(draft.chatId, draft.threadId, markdown = markdown) { draft } + + open suspend fun discardLocalCloudDraft(chatId: PeerId, threadId: Int = 0): CloudDraftState = + editLocalCloudDraft(chatId, threadId, discardUnsupported = true) { + CloudDraft( + chatId, threadId + ) + } + + private suspend fun editLocalCloudDraft( + chatId: PeerId, threadId: Int, discardUnsupported: Boolean = false, + markdown: Boolean? = null, edit: (CloudDraft) -> CloudDraft, + ): CloudDraftState = draftWriteMutex.withLock { + roomIo { + val database = db + if (database == null) { + val initial = CloudDraft(chatId, threadId) + return@roomIo CloudDraftState(initial).localEdit( + edit(initial), discardUnsupported, markdown + ) + } + database.withTransaction { + val current = readCloudDraftState(database, chatId, threadId) + val state = current.localEdit(edit(current.draft), discardUnsupported, markdown) + writeCloudDraftState(database, state) + state + } + } + } + + open suspend fun applyCloudDraft(draft: CloudDraft, expectedRevision: Long? = null) = roomIo { + val database = db ?: return@roomIo + database.withTransaction { + val current = readCloudDraftState(database, draft.chatId, draft.threadId) + if (expectedRevision == null || expectedRevision == current.revision) { + writeCloudDraftState(database, current.mergeRemote(draft)) + } + } + } + + open suspend fun acknowledgeCloudDraft(chatId: PeerId, threadId: Int, revision: Long) = roomIo { + val database = db ?: return@roomIo + database.withTransaction { + val current = readCloudDraftState(database, chatId, threadId) + val acknowledged = current.acknowledge(revision) + if (acknowledged != current) writeCloudDraftState(database, acknowledged) + } + } + + private suspend fun readCloudDraftState( + database: MonogramDatabase, chatId: PeerId, threadId: Int + ): CloudDraftState { + val stored = database.metaDao().get(cloudDraftMetaKey(chatId.value, threadId)) + if (stored != null) { + val state = decodeDraftState(stored.value) + readStoredPayload { + check(state.draft.chatId == chatId && state.draft.threadId == threadId) { "Stored draft context does not match" } + } + return state + } + val legacy = database.metaDao().get(draftMetaKey(chatId.value, threadId)) + return CloudDraftState( + CloudDraft(chatId, threadId, text = legacy?.value.orEmpty()), + revision = if (legacy == null) 0 else 1, + dirty = legacy != null + ) + } + + private suspend fun writeCloudDraftState(database: MonogramDatabase, state: CloudDraftState) { + database.metaDao().upsert( + org.monogram.core.database.entity.MetaEntity( + cloudDraftMetaKey(state.draft.chatId.value, state.draft.threadId), + encodeDraftState(state) + ) + ) + database.metaDao().delete(draftMetaKey(state.draft.chatId.value, state.draft.threadId)) + } + open suspend fun discussionReadMax(chatId: PeerId, topId: Int): Int = roomIo { db?.metaDao()?.get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0 } @@ -253,9 +406,7 @@ open class OfflineWarmup( database.withTransaction { val confirmed = maxOf( database.metaDao() - .get("discussion_read:${chatId.value}:$topId") - ?.value - ?.toIntOrNull() ?: 0, + .get("discussion_read:${chatId.value}:$topId")?.value?.toIntOrNull() ?: 0, maxId, ) database.metaDao().upsert( @@ -456,9 +607,7 @@ open class OfflineWarmup( } open suspend fun olderMessages( - chatId: PeerId, - beforeId: Int, - limit: Int = HISTORY_CACHE_WINDOW + chatId: PeerId, beforeId: Int, limit: Int = HISTORY_CACHE_WINDOW ): List { val database = db ?: run { ensureStartupCleanup() @@ -472,7 +621,9 @@ open class OfflineWarmup( ) return@onRoom emptyList() if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( - chatId.value, rows.minOf { it.id }, beforeId - 1, + chatId.value, + rows.minOf { it.id }, + beforeId - 1, MessageHoleEntity.SOURCE_HISTORY ) ) return@onRoom emptyList() @@ -481,9 +632,7 @@ open class OfflineWarmup( } open suspend fun newerMessages( - chatId: PeerId, - afterId: Int, - limit: Int = HISTORY_CACHE_WINDOW + chatId: PeerId, afterId: Int, limit: Int = HISTORY_CACHE_WINDOW ): List { val database = db ?: run { ensureStartupCleanup() @@ -497,7 +646,9 @@ open class OfflineWarmup( ) return@onRoom emptyList() if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( - chatId.value, afterId + 1, rows.maxOf { it.id }, + chatId.value, + afterId + 1, + rows.maxOf { it.id }, MessageHoleEntity.SOURCE_HISTORY ) ) return@onRoom emptyList() @@ -559,8 +710,9 @@ open class OfflineWarmup( val holeDao = database.messageHoleDao() if (database.metaDao().get(metaKey) == null) { val holes = mergeHistoryHoles( - holeDao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + - MessageHoleEntity(chatId.value, 1, Int.MAX_VALUE) + holeDao.forChat( + chatId.value, MessageHoleEntity.SOURCE_HISTORY + ) + MessageHoleEntity(chatId.value, 1, Int.MAX_VALUE) ) holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) holes.forEach { holeDao.insert(it) } @@ -588,8 +740,9 @@ open class OfflineWarmup( database.withTransaction { val dao = database.messageHoleDao() val holes = mergeHistoryHoles( - dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + - MessageHoleEntity(chatId.value, startId, endId) + dao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + MessageHoleEntity( + chatId.value, startId, endId + ) ) dao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) holes.forEach { dao.insert(it) } @@ -619,8 +772,7 @@ open class OfflineWarmup( } return DatabaseProvider.onRoom { ensureStartupCleanup() - database.messageDao() - .atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) + database.messageDao().atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) .map { it.toModel() } } } @@ -629,8 +781,8 @@ open class OfflineWarmup( open suspend fun applyProfileToChat(profile: Profile) { val stored = roomIo { db?.chatDao()?.get(profile.id.value)?.toModel() } ?: return - val title = profile.title.takeIf { !isPlaceholderPeerTitle(it, profile.id.value) } - ?: stored.title + val title = + profile.title.takeIf { !isPlaceholderPeerTitle(it, profile.id.value) } ?: stored.title val updated = stored.copy( title = title, isChannel = profile.kind == "channel", @@ -678,6 +830,20 @@ open class OfflineWarmup( } } + suspend fun applyMessageRead( + expectedEpoch: Long, chatId: PeerId, messageId: Int, message: Message? + ): Boolean = draftWriteMutex.withLock { + require(messageId > 0) + require(message == null || message.id.chatId == chatId && message.id.id == messageId) + if (!outgoingAccountIsCurrent(expectedEpoch)) return@withLock false + if (message == null) deleteMessages(chatId, setOf(messageId)) else upsertMessages( + listOf( + message + ) + ) + true + } + open suspend fun upsertMessages(messages: List) = withContext(RoomLane.maintenance()) { upsertMessagesNow(messages) } @@ -793,13 +959,12 @@ open class OfflineWarmup( .isNotEmpty() if (mergeMessages(listOf(message)).isEmpty()) return@withTransaction val stored = database.chatDao().get(message.id.chatId.value)?.toModel() - val chat = (stored ?: placeholderChat(message.id.chatId)) - .mergeIncomingMessage( - message, - alreadyStored = existed, - accountGeneration = accountGeneration, - acceptedOrder = acceptedOrder, - ) + val chat = (stored ?: placeholderChat(message.id.chatId)).mergeIncomingMessage( + message, + alreadyStored = existed, + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) database.chatDao().upsertAll(listOf(chat.toEntity())) } } @@ -839,27 +1004,529 @@ open class OfflineWarmup( } /** Drops cached chats/history/peers/folders. Auth flags are [SessionMetadataStore.clearSession]. */ - open suspend fun clearAccountCache() = roomIo { - recentWarmupJob?.cancel() - recentHistory.clear() - firstPaintSnapshot = null - recentWarmupChats = emptyList() + open suspend fun outgoingSendBlockedUntil(): Long = roomIo { + db?.metaDao()?.get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() ?: 0L + } + + open suspend fun blockOutgoingSendsUntil(deadline: Long) = roomIo { val database = db ?: return@roomIo - database.messageDao().clearAll() - database.messageTombstoneDao().clear() - database.chatDao().clear() - database.folderDao().clear() - database.peerDao().clear() - database.updateCursorDao().clear() - database.updateCursorDao().clearReceipts() - database.profileTabsDao().clear() - database.profileMembersDao().clear() - database.profileMediaDao().clear() - database.profileCommonDao().clear() - database.messageHoleDao().clearAll() - database.metaDao().deleteLike("history_coverage:%") - database.metaDao().deleteLike("discussion_read:%") - database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") + database.withTransaction { + val old = + database.metaDao().get("outgoing_send.blocked_until_ms")?.value?.toLongOrNull() + ?: 0L + database.metaDao().upsert( + MetaEntity( + "outgoing_send.blocked_until_ms", maxOf(old, deadline).toString() + ) + ) + } + } + + open fun observeOutgoingTextSends(): Flow> = + db?.outgoingTextSendDao()?.observeAll() ?: flowOf(emptyList()) + + open suspend fun outgoingTextSends() = roomIo { db?.outgoingTextSendDao()?.all().orEmpty() } + open suspend fun captureOutgoingTextDraft( + chatId: PeerId, threadId: Int, expectedEpoch: Long + ): CloudDraftState = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + cloudDraftState(chatId, threadId) + } + + /** Persists the send; returns whether its matching draft was consumed in the same transaction. */ + open suspend fun contactImportAttempt( + spec: ContactSendSpec, epoch: Long + ): ContactImportAttempt? = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + val key = contactImportMetaKey(spec) + val value = roomIo { db?.metaDao()?.get(key)?.value?.let(::decodeContactImport) } + if (value?.phase == ContactImportPhase.IN_FLIGHT && (epoch to key) !in activeContactImports) value.copy( + phase = ContactImportPhase.UNCERTAIN + ) else value + } + + open suspend fun beginContactImport( + spec: ContactSendSpec, epoch: Long, retry: Boolean + ): ContactImportAttempt = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + spec.requireValid() + val key = contactImportMetaKey(spec) + check((epoch to key) !in activeContactImports) + val attempt = roomIo { + val database = checkNotNull(db) + database.withTransaction { + val previous = database.metaDao().get(key)?.value?.let(::decodeContactImport) + check( + previous == null || retry && previous.phase in setOf( + ContactImportPhase.UNCERTAIN, + ContactImportPhase.RETRY_REQUIRED, + ContactImportPhase.NOT_ADDED, + ContactImportPhase.IN_FLIGHT + ) + ) + val identity = previous?.spec + ?: ContactImportSpec(randomUUID().mostSignificantBits.takeIf { it != 0L } ?: 1L, + spec.phone, + spec.firstName, + spec.lastName) + check(identity.phone == spec.phone && identity.firstName == spec.firstName && identity.lastName == spec.lastName) + ContactImportAttempt( + identity, ContactImportPhase.IN_FLIGHT + ).also { + database.metaDao().upsert(MetaEntity(key, encodeContactImport(it))) + } + } + } + activeContactImports[epoch to key] = attempt.spec.clientId + attempt + } + + open suspend fun finishContactImport( + spec: ContactSendSpec, epoch: Long, attempt: ContactImportAttempt + ): Boolean = draftWriteMutex.withLock { + val key = contactImportMetaKey(spec) + try { + if (!outgoingAccountIsCurrent(epoch)) return@withLock false + require(attempt.phase != ContactImportPhase.IN_FLIGHT) + val encoded = encodeContactImport(attempt) + decodeContactImport(encoded) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = database.metaDao().get(key)?.value?.let(::decodeContactImport) + check(current != null && current.spec == attempt.spec && current.phase == ContactImportPhase.IN_FLIGHT) + database.metaDao().upsert(MetaEntity(key, encoded)) + } + } + true + } finally { + activeContactImports.remove(epoch to key, attempt.spec.clientId) + } + } + + open suspend fun enqueueOutgoingTextSend( + row: OutgoingTextSendEntity, + expectedEpoch: Long = outgoingAccountEpoch(), + draftSnapshot: CloudDraftState? = null, + consumeDraft: Boolean = true, + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = draftSnapshot?.let { + require(it.draft.chatId.value == row.message.chatId) + readCloudDraftState( + database, it.draft.chatId, it.draft.threadId + ).also { state -> + check(!state.draft.hasUnsupportedContent) + } + } + require(database.outgoingMediaSendDao().item(row.randomId) == null) + database.outgoingTextSendDao().enqueue(row) + if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) return@withTransaction false + writeCloudDraftState( + database, current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false + ) + ) + true + } + } + } + + open fun observeOutgoingMediaSends() = + db?.outgoingMediaSendDao()?.observeAll() ?: flowOf(emptyList()) + + open suspend fun outgoingMediaSends() = roomIo { db?.outgoingMediaSendDao()?.all().orEmpty() } + open suspend fun claimOutgoingMediaSend(batchId: Long): Boolean = + roomIo { db?.outgoingMediaSendDao()?.claim(batchId) == 1 } + + open suspend fun recoverInterruptedMediaSends() = + roomIo { db?.outgoingMediaSendDao()?.recoverInterrupted(); Unit } + + open suspend fun markOutgoingMediaSendUncertain(batchId: Long) = + roomIo { db?.outgoingMediaSendDao()?.markUncertain(batchId); Unit } + + open suspend fun markOutgoingMediaSendFailed(batchId: Long) = + roomIo { db?.outgoingMediaSendDao()?.markFailed(batchId); Unit } + + open suspend fun retryOutgoingMediaSend(batchId: Long) = roomIo { + if (db?.outgoingMediaSendDao()?.retry(batchId) == 1) outgoingSendSync.requestResume() + } + + open suspend fun enqueueOutgoingMediaSend( + send: OutgoingMediaSend, expectedEpoch: Long, + draftSnapshot: CloudDraftState? = null, consumeDraft: Boolean = true, + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + enqueueMediaLocked(send, draftSnapshot, consumeDraft) + } + + private suspend fun enqueueMediaLocked( + send: OutgoingMediaSend, + draftSnapshot: CloudDraftState?, + consumeDraft: Boolean, + ): Boolean = roomIo { + val database = checkNotNull(db) + database.withTransaction { + val current = draftSnapshot?.let { + require(it.draft.chatId.value == send.batch.chatId) + require(it.draft.threadId == (send.items.firstOrNull()?.message?.replyToTopId ?: 0)) + readCloudDraftState( + database, it.draft.chatId, it.draft.threadId + ).also { state -> check(!state.draft.hasUnsupportedContent) } + } + send.items.forEach { require(database.outgoingTextSendDao().get(it.randomId) == null) } + database.outgoingMediaSendDao().enqueue(send) + if (!consumeDraft || draftSnapshot == null || current == null || current.revision != draftSnapshot.revision || current.draft != draftSnapshot.draft || current.markdown != draftSnapshot.markdown) return@withTransaction false + writeCloudDraftState( + database, current.localEdit( + CloudDraft(current.draft.chatId, current.draft.threadId), markdown = false + ) + ) + true + } + } + + open suspend fun recoverOutgoingMediaFiles(expectedEpoch: Long) = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + cleanupOutgoingMediaFilesLocked() + } + + private suspend fun cleanupOutgoingMediaFilesLocked() { + if (outgoingMediaFiles == null) return + val referenced = roomIo { + checkNotNull(db).outgoingMediaSendDao().all().flatMap { it.items } + .map { it.source.name }.toSet() + } + outgoingMediaFiles.removeUnreferenced(referenced) + } + + open suspend fun retainAndEnqueueOutgoingMedia( + batch: OutgoingMediaBatchEntity, + uploads: List, + maxTotalBytes: Long, + expectedEpoch: Long, + draftSnapshot: CloudDraftState? = null, + consumeDraft: Boolean = true, + ): OutgoingMediaAdmission = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(expectedEpoch)) + val files = checkNotNull(outgoingMediaFiles) + try { + val retained = files.retain(uploads.map { java.io.File(it.path) }, maxTotalBytes) + val items = uploads.mapIndexed { index, upload -> + val path = "local:" + files.retainedPath(retained[index]) + OutgoingMediaItemEntity( + checkNotNull(upload.message.randomId), + batch.batchId, + index, + upload.mimeType, + retained[index], + upload.message.copy( + mediaCacheKey = path, thumbCacheKey = path, fileSize = retained[index].size + ) + ) + } + val send = OutgoingMediaSend(batch, items) + val consumed = withContext(NonCancellable) { + enqueueMediaLocked( + send, draftSnapshot, consumeDraft + ) + } + OutgoingMediaAdmission(send, consumed) + } catch (error: Throwable) { + withContext(NonCancellable) { + try { + cleanupOutgoingMediaFilesLocked() + } catch (_: Exception) { + AppLog.warn("outgoing", "retained media recovery deferred") + } + } + throw error + } + } + + open suspend fun claimOutgoingTextSend(randomId: Long): Boolean = + roomIo { db?.outgoingTextSendDao()?.claim(randomId) == 1 } + + open suspend fun recoverInterruptedTextSends() = + roomIo { db?.outgoingTextSendDao()?.recoverInterrupted(); Unit } + + open suspend fun markOutgoingTextSendUncertain(randomId: Long) = + roomIo { db?.outgoingTextSendDao()?.markUncertain(randomId); Unit } + + open suspend fun markOutgoingTextSendFailed(randomId: Long) = + roomIo { db?.outgoingTextSendDao()?.markFailed(randomId); Unit } + + open suspend fun retryOutgoingTextSend(randomId: Long) = roomIo { + if (db?.outgoingTextSendDao()?.retry(randomId) == 1) outgoingSendSync.requestResume() + } + + open suspend fun applyOutgoingMediaResults( + batchId: Long, + results: List>, + expectedEpoch: Long, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, + ) = roomIo { + // Durable batch application already owns Room; never acquire the admission mutex here. + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + val database = db ?: return@roomIo + database.withTransaction { + val send = database.outgoingMediaSendDao().get(batchId) ?: return@withTransaction + val ids = results.map { it.first } + require(ids.size == ids.toSet().size && ids.toSet() == send.items.map { it.randomId } + .toSet()) + val messageIds = results.map { (_, result) -> + val message = when (result) { + is OutgoingMessageResult.Sent -> result.message + is OutgoingMessageResult.Scheduled -> result.message.message + } + require(message.id.chatId.value == send.batch.chatId && message.id.id > 0) + (result is OutgoingMessageResult.Scheduled) to message.id.id + } + require(messageIds.toSet().size == messageIds.size) + results.forEach { (randomId, result) -> + applyOutgoingResult( + randomId, result, accountGeneration, acceptedOrder + ) + } + database.outgoingMediaSendDao().acknowledge(batchId, send.batch.chatId, ids.toSet()) + } + } + + open suspend fun applyOutgoingResult( + randomId: Long, result: OutgoingMessageResult, + accountGeneration: Long = 0, acceptedOrder: Long = 0, + ) = roomIo { + require(randomId != 0L) + val database = db ?: return@roomIo + database.withTransaction { + val retained = database.outgoingTextSendDao().get(randomId) + when (result) { + is OutgoingMessageResult.Scheduled -> applyScheduledMessage( + result.message.copy(message = result.message.message.copy(randomId = randomId)), + ) + + is OutgoingMessageResult.Sent -> { + val message = + result.message.copy(randomId = randomId, pending = false, failed = false) + val pending = + database.messageDao().pendingByRandomId(message.id.chatId.value, randomId) + require(pending.size <= 1) + if (pending.isNotEmpty()) reconcileOutgoing( + pending.single().id, message, randomId + ) + else applyIncomingMessage(message, accountGeneration, acceptedOrder) + if (retained?.message?.mediaKind == "geo" && decodeLocationSend(retained.message.fileName).live) { + val place = org.monogram.core.models.ServiceMedia.geo(message.fileName) + if (place?.live == true && place.periodSeconds > 0) { + val key = liveLocationKey(message.id) + val existing = database.metaDao().get(key) + if (existing == null) { + val deadline = + if (place.periodSeconds == Int.MAX_VALUE) null else Math.addExact( + message.date, place.periodSeconds.toLong() + ) + val phase = + if (deadline != null && deadline <= System.currentTimeMillis() / 1000) LiveLocationPhase.EXPIRED + else LiveLocationPhase.ACTIVE + val session = LiveLocationSession( + message.id, randomId, deadline, phase + ) + database.metaDao() + .upsert(MetaEntity(key, encodeLiveLocation(session))) + } else require(decodeLiveLocation(existing.value).randomId == randomId) + } + } + } + } + val chatId = when (result) { + is OutgoingMessageResult.Sent -> result.message.id.chatId.value + is OutgoingMessageResult.Scheduled -> result.message.message.id.chatId.value + } + database.outgoingTextSendDao().acknowledge(randomId, chatId) + } + } + + open fun observeLiveLocations(): Flow> = + db?.metaDao()?.observeLike("live_location:%")?.map { rows -> + rows.map { row -> + decodeLiveLocation(row.value).also { + readStoredPayload { require(row.key == liveLocationKey(it.messageId)) } + } + } + } ?: flowOf(emptyList()) + + open suspend fun liveLocation( + id: MessageId, epoch: Long + ): LiveLocationSession? = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + db?.metaDao()?.get(liveLocationKey(id))?.let { + decodeLiveLocation(it.value).also { session -> + readStoredPayload { + require(session.messageId == id) + } + } + } + } + } + + open suspend fun transitionLiveLocation( + id: MessageId, revision: Long, phase: LiveLocationPhase, epoch: Long + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + val key = liveLocationKey(id) + val current = database.metaDao().get(key)?.let { decodeLiveLocation(it.value) } + ?: return@withTransaction false + require(current.messageId == id) + if (current.revision != revision || current.revision == Long.MAX_VALUE || !current.canTransition( + phase + ) || (phase == LiveLocationPhase.ACTIVE && current.expired( + System.currentTimeMillis() / 1000 + )) + ) return@withTransaction false + database.metaDao().upsert( + MetaEntity( + key, + encodeLiveLocation(current.copy(phase = phase, revision = revision + 1)) + ) + ) + true + } + } + } + + open suspend fun recoverLiveLocations(epoch: Long) = draftWriteMutex.withLock { + check(outgoingAccountIsCurrent(epoch)) + roomIo { + val database = checkNotNull(db) + database.withTransaction { + for (key in database.metaDao().keysLike("live_location:%")) { + val current = + decodeLiveLocation(checkNotNull(database.metaDao().get(key)).value) + require(key == liveLocationKey(current.messageId)) + val phase = when (current.phase) { + LiveLocationPhase.ACTIVE -> LiveLocationPhase.PAUSED + LiveLocationPhase.STOPPING -> LiveLocationPhase.UNCERTAIN_STOP + else -> continue + } + check(current.revision < Long.MAX_VALUE) + database.metaDao().upsert( + MetaEntity( + key, encodeLiveLocation( + current.copy( + phase = phase, revision = current.revision + 1 + ) + ) + ) + ) + } + } + } + } + + open suspend fun blockScheduledQueueUntil(deadline: Long, expectedEpoch: Long) = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + blockOutgoingSendsUntil(deadline) + } + + open fun observeScheduledMessages(chatId: PeerId): Flow> = + db?.scheduledMessageDao()?.observe(chatId.value)?.map { rows -> + rows.map { + ScheduledMessage( + it.message.toModel(), it.hasUnsupportedContent + ) + } + } ?: flowOf(emptyList()) + + open suspend fun scheduledQueueRevision(chatId: PeerId, expectedEpoch: Long): Long = + draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { checkNotNull(db).scheduledMessageDao().revision(chatId.value) } + } + + open suspend fun replaceScheduledMessagesIfCurrent( + chatId: PeerId, + expectedRevision: Long, + messages: List, + expectedEpoch: Long, + ): Boolean = draftWriteMutex.withLock { + check(outgoingAccountActive && outgoingAccountEpoch.get() == expectedEpoch) + roomIo { + checkNotNull(db).scheduledMessageDao().replaceIfCurrent( + chatId.value, expectedRevision, messages.map { + ScheduledMessageEntity( + it.message.toEntity(), it.hasUnsupportedContent + ) + }) + } + } + + open suspend fun applyScheduledMessage(message: ScheduledMessage) = roomIo { + db?.scheduledMessageDao()?.applyMessages( + message.message.id.chatId.value, listOf( + ScheduledMessageEntity( + message.message.toEntity(), message.hasUnsupportedContent + ) + ) + ) + Unit + } + + open suspend fun deleteScheduledMessages(chatId: PeerId, ids: List) = roomIo { + db?.scheduledMessageDao()?.remove(chatId.value, ids) + Unit + } + + open suspend fun clearAccountCache() = draftWriteMutex.withLock { + outgoingAccountActive = false + outgoingAccountEpoch.incrementAndGet() + draftSync.reset() + outgoingSendSync.reset() + roomIo { + recentWarmupJob?.cancel() + recentHistory.clear() + firstPaintSnapshot = null + recentWarmupChats = emptyList() + val database = db ?: return@roomIo + database.messageDao().clearAll() + database.scheduledMessageDao().clear() + database.outgoingTextSendDao().clear() + database.outgoingMediaSendDao().clear() + database.messageTombstoneDao().clear() + database.chatDao().clear() + database.folderDao().clear() + database.peerDao().clear() + database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() + database.profileTabsDao().clear() + database.profileMembersDao().clear() + database.profileMediaDao().clear() + database.profileCommonDao().clear() + database.messageHoleDao().clearAll() + database.metaDao().deleteLike("history_coverage:%") + database.metaDao().deleteLike("discussion_read:%") + database.metaDao().deleteLike("${DRAFT_META_PREFIX}%") + database.metaDao().deleteLike("cloud_draft:%") + database.metaDao().deleteLike("outgoing_send.%") + database.metaDao().deleteLike("contact_import:%") + database.metaDao().deleteLike("live_location:%") + activeContactImports.clear() + } + try { + outgoingMediaFiles?.removeUnreferenced(emptySet()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + AppLog.warn("outgoing", "account media cleanup deferred") + } } open suspend fun setDialogScroll(chatId: PeerId, messageId: Int) = roomIo { diff --git a/core/database/src/main/java/org/monogram/core/database/OutgoingMediaAdmission.kt b/core/database/src/main/java/org/monogram/core/database/OutgoingMediaAdmission.kt new file mode 100644 index 000000000..97f6c9a13 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/OutgoingMediaAdmission.kt @@ -0,0 +1,7 @@ +package org.monogram.core.database + +import org.monogram.core.database.entity.MessageEntity +import org.monogram.core.database.entity.OutgoingMediaSend + +data class OutgoingMediaUpload(val path: String, val mimeType: String, val message: MessageEntity) +data class OutgoingMediaAdmission(val send: OutgoingMediaSend, val draftConsumed: Boolean) diff --git a/core/database/src/main/java/org/monogram/core/database/OutgoingMediaFiles.kt b/core/database/src/main/java/org/monogram/core/database/OutgoingMediaFiles.kt new file mode 100644 index 000000000..a9b2e9f50 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/OutgoingMediaFiles.kt @@ -0,0 +1,147 @@ +package org.monogram.core.database + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.security.MessageDigest +import java.util.UUID + +data class RetainedMediaSource(val name: String, val size: Long, val sha256: String) + +/** The caller supplies an account-owned directory beneath Android's no-backup files directory. */ +class OutgoingMediaFiles(directory: File) { + private val root = directory.canonicalFile + private val mutex = Mutex() + + suspend fun retain(sources: List, maxTotalBytes: Long): List = + withContext(Dispatchers.IO) { + require(sources.size in 1..10 && maxTotalBytes > 0) + mutex.withLock { + if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create retained media directory") + val created = mutableListOf() + try { + var remaining = maxTotalBytes + sources.map { source -> + currentCoroutineContext().ensureActive() + val expectedSize = source.length() + if (!source.isFile) throw IOException("source media is missing") + if (expectedSize !in 1..remaining) throw IOException("source media is empty or exceeds the batch limit") + val name = UUID.randomUUID().toString() + ".bin" + val temporary = File(root, "$name.part") + val destination = File(root, name) + created += temporary + created += destination + val digest = MessageDigest.getInstance("SHA-256") + var copied = 0L + source.inputStream().use { input -> + FileOutputStream(temporary).use { output -> + val buffer = ByteArray(64 * 1024) + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count < 0) break + copied += count + if (copied > remaining || copied > expectedSize) throw IOException( + "source media grew while copying" + ) + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + } + if (copied != expectedSize || source.length() != expectedSize) throw IOException( + "source media changed while copying" + ) + output.fd.sync() + } + } + currentCoroutineContext().ensureActive() + if (!temporary.renameTo(destination)) throw IOException("cannot finalize retained media") + remaining -= copied + RetainedMediaSource(name, copied, digest.digest().hex()) + } + } catch (error: Throwable) { + created.forEach { it.delete() } + throw error + } + } + } + + suspend fun verifiedFile(source: RetainedMediaSource): File = withContext(Dispatchers.IO) { + mutex.withLock { + val file = ownedFile(source) + if (!file.isFile || file.length() != source.size) throw IOException("retained media is missing or changed") + val digest = MessageDigest.getInstance("SHA-256") + var verifiedBytes = 0L + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count < 0) break + verifiedBytes += count + if (verifiedBytes > source.size) throw IOException("retained media is changed") + digest.update(buffer, 0, count) + } + } + if (verifiedBytes != source.size || digest.digest() + .hex() != source.sha256 + ) throw IOException("retained media is changed") + file + } + } + + // Release only after durable acknowledgement, rejected admission, or an account-owned clear. + suspend fun release(sources: List) = withContext(Dispatchers.IO) { + mutex.withLock { + val files = sources.map(::ownedFile) + files.forEach { if (it.exists() && !it.delete()) throw IOException("retained media release failed") } + } + } + + internal suspend fun retainedPath(source: RetainedMediaSource): String = + withContext(Dispatchers.IO) { + ownedFile(source).absolutePath + } + + // Caller serializes this Room-reference snapshot with copy/admission under the account mutex. + internal suspend fun removeUnreferenced(referenced: Set) = withContext(Dispatchers.IO) { + mutex.withLock { + if (!root.exists()) return@withLock + val files = root.listFiles() + ?: throw IOException("retained media recovery cannot read directory") + files.forEach { file -> + currentCoroutineContext().ensureActive() + val name = file.name.removeSuffix(".part") + if (!name.endsWith(".bin")) return@forEach + val id = name.removeSuffix(".bin") + if (runCatching { + UUID.fromString(id).toString() == id + }.getOrDefault(false) && file.name !in referenced && file.isFile && file.canonicalFile == file.absoluteFile) { + if (!file.delete()) throw IOException("retained media recovery cleanup failed") + } + } + } + } + + private fun ownedFile(source: RetainedMediaSource): File { + if (source.size <= 0 || !source.sha256.matches(Regex("[0-9a-f]{64}"))) { + throw IOException("invalid retained media descriptor") + } + if (!source.name.endsWith(".bin")) throw IOException("invalid retained media name") + val id = source.name.removeSuffix(".bin") + val validId = runCatching { UUID.fromString(id).toString() == id }.getOrDefault(false) + if (!validId) throw IOException("invalid retained media name") + val file = File(root, source.name) + if (file.canonicalFile != file.absoluteFile || file.parentFile != root) { + throw IOException("retained media is outside its owned directory") + } + return file + } +} + +private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it.toInt() and 255) } diff --git a/core/database/src/main/java/org/monogram/core/database/OutgoingSendSyncMonitor.kt b/core/database/src/main/java/org/monogram/core/database/OutgoingSendSyncMonitor.kt new file mode 100644 index 000000000..6891c585a --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/OutgoingSendSyncMonitor.kt @@ -0,0 +1,56 @@ +package org.monogram.core.database + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.monogram.core.common.telegram.TelegramError + +data class OutgoingSendSyncState( + val activeRandomId: Long? = null, + val errors: Map = emptyMap(), + val error: TelegramError? = null, +) + +class OutgoingSendSyncMonitor { + private val mutable = MutableStateFlow(OutgoingSendSyncState()) + private val requests = MutableSharedFlow( + extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val retries = requests + fun requestResume() { + requests.tryEmit(Unit) + } + + val state = mutable.asStateFlow() + fun begin(randomId: Long) { + mutable.update { it.copy(activeRandomId = randomId, errors = it.errors - randomId) } + } + + fun finish(randomId: Long, error: TelegramError?) { + mutable.update { + if (it.activeRandomId != randomId) it else it.copy( + activeRandomId = null, + errors = if (error == null) it.errors - randomId else it.errors + (randomId to error) + ) + } + } + + fun fail(error: TelegramError?) { + mutable.update { it.copy(error = error) } + } + + fun retain(ids: Set) { + mutable.update { + it.copy( + activeRandomId = it.activeRandomId?.takeIf(ids::contains), + errors = it.errors.filterKeys(ids::contains) + ) + } + } + + fun reset() { + mutable.value = OutgoingSendSyncState() + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/PollSendCodec.kt b/core/database/src/main/java/org/monogram/core/database/PollSendCodec.kt new file mode 100644 index 000000000..6e7ea5dd5 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/PollSendCodec.kt @@ -0,0 +1,70 @@ +package org.monogram.core.database + +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.monogram.core.models.PollSendSpec + +fun encodePollSend(spec: PollSendSpec): String { + spec.requireValid() + return storedPayloadJson.encodeToString( + StoredPollSend( + question = spec.question, + answers = spec.answers.mapIndexed { index, title -> + StoredPollAnswer( + title, + (48 + index).toString(16) + ) + }, + quiz = spec.quiz, + multipleChoice = spec.multipleChoice, + public = !spec.anonymous, + send = StoredPollOptions(correct = spec.correctAnswer, solution = spec.solution), + ) + ) +} + +fun decodePollSend(raw: String?): PollSendSpec = readStoredPayload { + val value = storedPayloadJson.decodeFromString(requireNotNull(raw)) + require(value.send.version == 1) + require(value.answers.size in 2..10) + value.answers.forEachIndexed { index, answer -> + require( + answer.option == (48 + index).toString( + 16 + ) + ) + } + PollSendSpec( + question = value.question, + answers = value.answers.map { it.title }, + anonymous = !value.public, + multipleChoice = value.multipleChoice, + quiz = value.quiz, + correctAnswer = value.send.correct, + solution = value.send.solution, + ).also { it.requireValid() } +} + +@Serializable +private data class StoredPollSend( + @SerialName("q") val question: String, + @SerialName("a") val answers: List, + @SerialName("z") val quiz: Boolean, + @SerialName("m") val multipleChoice: Boolean, + @SerialName("o") val public: Boolean, + val send: StoredPollOptions, +) + +@Serializable +private data class StoredPollAnswer( + @SerialName("t") val title: String, + @SerialName("o") val option: String, +) + +@Serializable +private data class StoredPollOptions( + @Required val version: Int = 1, + val correct: Int? = null, + val solution: String? = null, +) diff --git a/core/database/src/main/java/org/monogram/core/database/StoredPayloadJson.kt b/core/database/src/main/java/org/monogram/core/database/StoredPayloadJson.kt new file mode 100644 index 000000000..09388e7ee --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/StoredPayloadJson.kt @@ -0,0 +1,22 @@ +package org.monogram.core.database + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json + +class StoredPayloadException : + IllegalStateException("Stored payload is invalid or unsupported") + +internal inline fun readStoredPayload(block: () -> T): T = try { + block() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: IllegalArgumentException) { + throw StoredPayloadException() +} catch (_: IllegalStateException) { + throw StoredPayloadException() +} + +internal val storedPayloadJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} diff --git a/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt index 6df7e1fdf..0193b1345 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/MessageDao.kt @@ -29,6 +29,9 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE chatId = :chatId AND id IN (:ids)") suspend fun byIds(chatId: Long, ids: List): List + @Query("SELECT * FROM messages WHERE chatId = :chatId AND randomId = :randomId AND (id < 0 OR pending = 1)") + abstract suspend fun pendingByRandomId(chatId: Long, randomId: Long): List + @Query("SELECT DISTINCT chatId FROM messages WHERE failed = 1 OR ((pending = 1 OR id < 0) AND (randomId IS NULL OR randomId = 0))") suspend fun startupTransientChatIds(): List diff --git a/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt index b15ed4511..a7fd96674 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/MetaDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow import org.monogram.core.database.entity.MetaEntity @Dao @@ -11,6 +12,12 @@ interface MetaDao { @Query("SELECT * FROM meta WHERE key = :key LIMIT 1") suspend fun get(key: String): MetaEntity? + @Query("SELECT * FROM meta WHERE `key` = :key LIMIT 1") + fun observe(key: String): Flow + + @Query("SELECT * FROM meta WHERE `key` LIKE :pattern") + fun observeLike(pattern: String): Flow> + @Query("SELECT `key` FROM meta WHERE `key` LIKE :pattern") suspend fun keysLike(pattern: String): List diff --git a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt new file mode 100644 index 000000000..dcc63228f --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingMediaSendDao.kt @@ -0,0 +1,78 @@ +package org.monogram.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.monogram.core.database.entity.OutgoingMediaBatchEntity +import org.monogram.core.database.entity.OutgoingMediaItemEntity +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingSendPhase + +@Dao +abstract class OutgoingMediaSendDao { + @Transaction + @Query("SELECT * FROM outgoing_media_batches ORDER BY createdAt, batchId") + abstract fun observeAll(): Flow> + + @Transaction + @Query("SELECT * FROM outgoing_media_batches ORDER BY createdAt, batchId") + abstract suspend fun all(): List + + @Transaction + @Query("SELECT * FROM outgoing_media_batches WHERE batchId = :batchId") + abstract suspend fun get(batchId: Long): OutgoingMediaSend? + + @Query("SELECT * FROM outgoing_media_items WHERE randomId = :randomId") + abstract suspend fun item(randomId: Long): OutgoingMediaItemEntity? + + @Insert + protected abstract suspend fun insertBatch(batch: OutgoingMediaBatchEntity) + + @Insert + protected abstract suspend fun insertItems(items: List) + + @Transaction + open suspend fun enqueue(send: OutgoingMediaSend) { + send.requireValid() + val previous = get(send.batch.batchId) + if (previous != null) { + require( + previous.batch.copy(phase = OutgoingSendPhase.READY) == send.batch && + previous.items.sortedBy { it.position } == send.items.sortedBy { it.position }) + return + } + insertBatch(send.batch) + insertItems(send.items) + } + + @Query("UPDATE outgoing_media_batches SET phase = 'IN_FLIGHT' WHERE batchId = :batchId AND phase = 'READY'") + abstract suspend fun claim(batchId: Long): Int + + @Query("UPDATE outgoing_media_batches SET phase = 'UNCERTAIN' WHERE phase = 'IN_FLIGHT'") + abstract suspend fun recoverInterrupted(): Int + + @Query("UPDATE outgoing_media_batches SET phase = 'UNCERTAIN' WHERE batchId = :batchId AND phase = 'IN_FLIGHT'") + abstract suspend fun markUncertain(batchId: Long): Int + + @Query("UPDATE outgoing_media_batches SET phase = 'FAILED' WHERE batchId = :batchId AND phase = 'IN_FLIGHT'") + abstract suspend fun markFailed(batchId: Long): Int + + @Query("UPDATE outgoing_media_batches SET phase = 'READY' WHERE batchId = :batchId AND phase IN ('UNCERTAIN', 'FAILED')") + abstract suspend fun retry(batchId: Long): Int + + // Keep every source until the complete album has authoritative receipts. + @Query("DELETE FROM outgoing_media_batches WHERE batchId = :batchId") + protected abstract suspend fun deleteBatch(batchId: Long) + + @Transaction + open suspend fun acknowledge(batchId: Long, chatId: Long, randomIds: Set) { + val send = get(batchId) ?: return + require(send.batch.chatId == chatId && send.items.map { it.randomId }.toSet() == randomIds) + deleteBatch(batchId) + } + + @Query("DELETE FROM outgoing_media_batches") + abstract suspend fun clear() +} diff --git a/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt new file mode 100644 index 000000000..15e5ba77b --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/dao/OutgoingTextSendDao.kt @@ -0,0 +1,108 @@ +package org.monogram.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity + +@Dao +abstract class OutgoingTextSendDao { + @Query("SELECT * FROM outgoing_text_sends ORDER BY message_date, randomId") + abstract fun observeAll(): Flow> + + @Query("SELECT * FROM outgoing_text_sends ORDER BY message_date, randomId") + abstract suspend fun all(): List + + @Query("SELECT * FROM outgoing_text_sends WHERE message_chatId = :chatId ORDER BY message_date, randomId") + abstract fun observe(chatId: Long): Flow> + + @Query("SELECT * FROM outgoing_text_sends WHERE randomId = :randomId") + abstract suspend fun get(randomId: Long): OutgoingTextSendEntity? + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun insert(row: OutgoingTextSendEntity) + + @Transaction + open suspend fun enqueue(row: OutgoingTextSendEntity) { + require(row.randomId != 0L && row.message.randomId == row.randomId) + require(row.message.chatId != 0L && row.message.id < 0 && row.message.pending && row.message.outgoing) + require(row.phase == OutgoingSendPhase.READY) + if (row.message.mediaKind == "geo") { + val place = org.monogram.core.database.decodeLocationSend(row.message.fileName) + require( + row.message.text.isNullOrBlank() && row.message.entitiesJson.isNullOrBlank() && + row.webpageUrl == null && !row.noWebpage && row.message.mediaCacheKey == null && (!place.live || row.scheduleDate == null) + ) + } else if (row.message.mediaKind == "contact") { + val spec = org.monogram.core.database.decodeContactSend(row.message.fileName) + require( + row.message.text == spec.displayName && row.message.entitiesJson.isNullOrBlank() && + row.webpageUrl == null && !row.noWebpage && row.message.mediaCacheKey == null + ) + } else if (row.message.mediaKind == "todo") { + val list = + requireNotNull(org.monogram.core.models.Checklists.parse(row.message.checklistJson)) + val spec = org.monogram.core.models.ChecklistSendSpec.fromPending(list) + require( + row.message.text == spec.title && row.message.entitiesJson.isNullOrBlank() && + row.message.fileName == null && row.webpageUrl == null && !row.noWebpage && row.message.mediaCacheKey == null + ) + } else if (row.message.mediaKind == "poll") { + val spec = org.monogram.core.database.decodePollSend(row.message.fileName) + require( + row.message.text == spec.question && row.message.entitiesJson.isNullOrBlank() && + row.webpageUrl == null && !row.noWebpage && row.message.mediaCacheKey == null + ) + } else if (row.message.mediaKind == "dice") { + val dice = org.monogram.core.models.ServiceMedia.dice(row.message.fileName) + require(dice != null && dice.value == 0 && dice.emoticon in org.monogram.core.models.Dice.supportedEmoticons) + require( + row.message.text.isNullOrBlank() && row.message.entitiesJson.isNullOrBlank() && + row.webpageUrl == null && !row.noWebpage && row.message.mediaCacheKey == null + ) + } else { + require(row.message.mediaKind == null || (row.message.mediaKind == "webpage" && !row.webpageUrl.isNullOrBlank())) + } + require((row.message.mediaKind == "todo" || row.message.checklistJson == null) && row.message.replyMarkupJson == null && row.message.groupedId == null) + require(row.message.fwdFrom == null && row.message.fwdFromId == null) + require(row.scheduleDate == null || (row.scheduleDate > 0 && row.scheduleDate != Int.MAX_VALUE)) + val previous = get(row.randomId) + if (previous != null) { + require(previous.copy(phase = row.phase) == row) + return + } + insert(row) + } + + @Query("UPDATE outgoing_text_sends SET phase = 'IN_FLIGHT' WHERE randomId = :randomId AND phase = 'READY'") + abstract suspend fun claim(randomId: Long): Int + + @Query("UPDATE outgoing_text_sends SET phase = 'UNCERTAIN' WHERE phase = 'IN_FLIGHT'") + abstract suspend fun recoverInterrupted(): Int + + @Query("UPDATE outgoing_text_sends SET phase = 'UNCERTAIN' WHERE randomId = :randomId AND phase = 'IN_FLIGHT'") + abstract suspend fun markUncertain(randomId: Long): Int + + @Query("UPDATE outgoing_text_sends SET phase = 'FAILED' WHERE randomId = :randomId AND phase = 'IN_FLIGHT'") + abstract suspend fun markFailed(randomId: Long): Int + + @Query("UPDATE outgoing_text_sends SET phase = 'READY' WHERE randomId = :randomId AND phase IN ('UNCERTAIN', 'FAILED')") + abstract suspend fun retry(randomId: Long): Int + + @Query("DELETE FROM outgoing_text_sends WHERE randomId = :randomId AND message_chatId = :chatId") + protected abstract suspend fun deleteMatched(randomId: Long, chatId: Long) + + @Transaction + open suspend fun acknowledge(randomId: Long, chatId: Long) { + val row = get(randomId) ?: return + require(row.message.chatId == chatId) + deleteMatched(randomId, chatId) + } + + @Query("DELETE FROM outgoing_text_sends") + abstract suspend fun clear() +} diff --git a/core/database/src/main/java/org/monogram/core/database/dao/ScheduledMessageDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/ScheduledMessageDao.kt new file mode 100644 index 000000000..0bc3de244 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/dao/ScheduledMessageDao.kt @@ -0,0 +1,92 @@ +package org.monogram.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.monogram.core.database.entity.ScheduledMessageEntity +import org.monogram.core.database.entity.ScheduledQueueRevisionEntity + +@Dao +abstract class ScheduledMessageDao { + @Query("SELECT * FROM scheduled_messages WHERE chatId = :chatId ORDER BY date, id") + abstract fun observe(chatId: Long): Flow> + + @Query("SELECT * FROM scheduled_messages WHERE chatId = :chatId ORDER BY date, id") + abstract suspend fun forChat(chatId: Long): List + + @Query("SELECT revision FROM scheduled_queue_revisions WHERE chatId = :chatId") + protected abstract suspend fun storedRevision(chatId: Long): Long? + + @Transaction + open suspend fun revision(chatId: Long): Long { + val stored = storedRevision(chatId) + if (stored != null) return stored + insertRevision(ScheduledQueueRevisionEntity(chatId, 0)) + return 0L + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertRows(rows: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertRevision(row: ScheduledQueueRevisionEntity) + + @Query("DELETE FROM scheduled_messages WHERE chatId = :chatId") + protected abstract suspend fun deleteChat(chatId: Long) + + @Query("DELETE FROM scheduled_messages WHERE chatId = :chatId AND id IN (:ids)") + protected abstract suspend fun deleteIds(chatId: Long, ids: List) + + @Query("DELETE FROM scheduled_messages") + protected abstract suspend fun deleteAllRows() + + private fun validate(chatId: Long, rows: List) { + require(rows.all { it.message.chatId == chatId && it.message.id > 0 && !it.message.pending }) + require(rows.map { it.message.id }.distinct().size == rows.size) + } + + private suspend fun advance(chatId: Long) { + insertRevision(ScheduledQueueRevisionEntity(chatId, Math.addExact(revision(chatId), 1L))) + } + + @Transaction + open suspend fun replaceIfCurrent( + chatId: Long, + expectedRevision: Long, + rows: List + ): Boolean { + validate(chatId, rows) + if (revision(chatId) != expectedRevision) return false + deleteChat(chatId) + insertRows(rows) + advance(chatId) + return true + } + + @Transaction + open suspend fun applyMessages(chatId: Long, rows: List) { + validate(chatId, rows) + insertRows(rows) + advance(chatId) + } + + @Transaction + open suspend fun remove(chatId: Long, ids: List) { + require(ids.all { it > 0 }) + deleteIds(chatId, ids) + advance(chatId) + } + + @Transaction + open suspend fun clear() { + // Retain and advance revisions so in-flight snapshots cannot repopulate a cleared account. + for (chatId in trackedChats()) advance(chatId) + deleteAllRows() + } + + @Query("SELECT chatId FROM scheduled_queue_revisions") + protected abstract suspend fun trackedChats(): List +} diff --git a/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt index 23432f433..0605310af 100644 --- a/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt +++ b/core/database/src/main/java/org/monogram/core/database/entity/ChatEntity.kt @@ -48,6 +48,7 @@ data class ChatEntity( val canView: Boolean = true, val canSendPlain: Boolean = true, val canSendPhotos: Boolean = true, + @androidx.room.ColumnInfo(defaultValue = "0") val canSendPolls: Boolean = false, val canForward: Boolean = true, val canDeleteOthers: Boolean = false, val canManageTopics: Boolean = false, diff --git a/core/database/src/main/java/org/monogram/core/database/entity/OutgoingMediaSend.kt b/core/database/src/main/java/org/monogram/core/database/entity/OutgoingMediaSend.kt new file mode 100644 index 000000000..7de46833b --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/entity/OutgoingMediaSend.kt @@ -0,0 +1,75 @@ +package org.monogram.core.database.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Relation +import org.monogram.core.database.RetainedMediaSource + +@Entity(tableName = "outgoing_media_batches", primaryKeys = ["batchId"]) +data class OutgoingMediaBatchEntity( + val batchId: Long, + val chatId: Long, + val createdAt: Long, + val silent: Boolean, + val scheduleDate: Int?, + val album: Boolean, + val phase: OutgoingSendPhase = OutgoingSendPhase.READY, +) + +// MessageEntity schema changes must also migrate the embedded media outbox columns. +@Entity( + tableName = "outgoing_media_items", primaryKeys = ["randomId"], + foreignKeys = [ForeignKey( + entity = OutgoingMediaBatchEntity::class, parentColumns = ["batchId"], + childColumns = ["batchId"], onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["batchId", "position"], unique = true), Index( + value = ["source_name"], + unique = true + )] +) +data class OutgoingMediaItemEntity( + val randomId: Long, val batchId: Long, val position: Int, val mimeType: String, + @Embedded(prefix = "source_") val source: RetainedMediaSource, + @Embedded(prefix = "message_") val message: MessageEntity, +) + +data class OutgoingMediaSend( + @Embedded val batch: OutgoingMediaBatchEntity, + @Relation( + parentColumn = "batchId", + entityColumn = "batchId" + ) val items: List, +) { + fun requireValid() { + require(batch.batchId != 0L && batch.chatId != 0L && batch.phase == OutgoingSendPhase.READY) + require(batch.scheduleDate == null || (batch.scheduleDate > 0 && batch.scheduleDate != Int.MAX_VALUE)) + require(items.size in 1..10 && (batch.album || items.size == 1)) + require(items.singleOrNull { it.position == 0 }?.randomId == batch.batchId) + require(items.map { it.randomId } + .toSet().size == items.size && items.none { it.randomId == 0L }) + require(items.map { it.position }.sorted() == items.indices.toList()) + require(items.map { it.source.name }.toSet().size == items.size) + require(items.map { it.message.id }.toSet().size == items.size) + val first = items.first().message + items.forEach { item -> + val message = item.message + require(item.batchId == batch.batchId && message.chatId == batch.chatId && message.randomId == item.randomId) + require(message.id < 0 && message.pending && message.outgoing) + require(message.mediaKind in setOf("photo", "video", "document")) + require(message.checklistJson == null && message.replyMarkupJson == null && message.fwdFrom == null && message.fwdFromId == null) + require(message.replyToMsgId == first.replyToMsgId && message.replyToTopId == first.replyToTopId && message.forumTopic == first.forumTopic) + require(item.source.size > 0 && item.source.sha256.matches(Regex("[0-9a-f]{64}"))) + val id = item.source.name.removeSuffix(".bin") + require( + item.source.name.endsWith(".bin") && java.util.UUID.fromString(id).toString() == id + ) + } + if (batch.album) { + val kinds = items.map { it.message.mediaKind }.toSet() + require(kinds.all { it == "photo" || it == "video" } || kinds == setOf("document")) + } + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/entity/OutgoingTextSendEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/OutgoingTextSendEntity.kt new file mode 100644 index 000000000..c4abfd881 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/entity/OutgoingTextSendEntity.kt @@ -0,0 +1,18 @@ +package org.monogram.core.database.entity + +import androidx.room.Embedded +import androidx.room.Entity + +// MessageEntity schema changes must also migrate the embedded outbox columns. +@Entity(tableName = "outgoing_text_sends", primaryKeys = ["randomId"]) +data class OutgoingTextSendEntity( + val randomId: Long, + @Embedded(prefix = "message_") val message: MessageEntity, + val silent: Boolean, + val noWebpage: Boolean, + val scheduleDate: Int?, + val webpageUrl: String?, + val phase: OutgoingSendPhase = OutgoingSendPhase.READY, +) + +enum class OutgoingSendPhase { READY, IN_FLIGHT, UNCERTAIN, FAILED } diff --git a/core/database/src/main/java/org/monogram/core/database/entity/ScheduledMessageEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/ScheduledMessageEntity.kt new file mode 100644 index 000000000..aa2b9a6b3 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/entity/ScheduledMessageEntity.kt @@ -0,0 +1,19 @@ +package org.monogram.core.database.entity + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Index + +// MessageEntity schema changes must migrate this table as well as messages. +@Entity( + tableName = "scheduled_messages", + primaryKeys = ["chatId", "id"], + indices = [Index(value = ["chatId", "date"])] +) +data class ScheduledMessageEntity( + @Embedded val message: MessageEntity, + @androidx.room.ColumnInfo(defaultValue = "1") val hasUnsupportedContent: Boolean = true, +) + +@Entity(tableName = "scheduled_queue_revisions", primaryKeys = ["chatId"]) +data class ScheduledQueueRevisionEntity(val chatId: Long, val revision: Long) diff --git a/core/database/src/test/java/org/monogram/core/database/CloudDraftSyncMonitorTest.kt b/core/database/src/test/java/org/monogram/core/database/CloudDraftSyncMonitorTest.kt new file mode 100644 index 000000000..c489b71a0 --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/CloudDraftSyncMonitorTest.kt @@ -0,0 +1,64 @@ +package org.monogram.core.database + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.PeerId + +class CloudDraftSyncMonitorTest { + private val peer = PeerId(7) + + @Test + fun peerFailuresAreScopedButAccountFailuresReachAllDrafts() = runBlocking { + val monitor = CloudDraftSyncMonitor() + val token = monitor.begin(peer to 9) + assertTrue(monitor.observe(peer, 9).first().syncing) + assertFalse(monitor.observe(peer, 10).first().syncing) + val error = TelegramError.parse("CHAT_WRITE_FORBIDDEN") + monitor.finish(peer to 9, token, error) + assertEquals(error, monitor.observe(peer, 9).first().error) + assertNull(monitor.observe(peer, 10).first().error) + monitor.fail(TelegramError.parse("FLOOD_WAIT_5")) + assertEquals(TelegramError.Kind.Flood, monitor.observe(PeerId(8)).first().error?.kind) + } + + @Test + fun lateCollectorsReceiveRemainingFloodWaitInsteadOfTheOriginalDuration() = runBlocking { + var now = 0L + val monitor = CloudDraftSyncMonitor { now } + monitor.fail(TelegramError.parse("FLOOD_WAIT_5")) + now = 2_001 + assertEquals(3, monitor.observe(peer).first().error?.retryAfterSeconds) + now = 6_000 + assertEquals(0, monitor.observe(peer).first().error?.retryAfterSeconds) + } + + @Test + fun staleCompletionCannotClearANewerOperationOrCrossAReset() = runBlocking { + val monitor = CloudDraftSyncMonitor() + val old = monitor.begin(peer to 0) + monitor.reset() + val current = monitor.begin(peer to 0) + monitor.finish(peer to 0, old) + assertTrue(monitor.observe(peer).first().syncing) + val error = TelegramError.parse("CHAT_WRITE_FORBIDDEN") + monitor.finish(peer to 0, current, error) + monitor.finish(peer to 0, old) + assertEquals(error, monitor.observe(peer).first().error) + monitor.reset() + assertEquals(CloudDraftSyncStatus(), monitor.observe(peer).first()) + } + + @Test + fun retryIsAnExplicitSignalWithoutPerformingTransportWork() = runBlocking { + val monitor = CloudDraftSyncMonitor() + val received = async(start = CoroutineStart.UNDISPATCHED) { monitor.retries.first() } + monitor.requestRetry() + received.await() + assertEquals(CloudDraftSyncStatus(), monitor.observe(peer).first()) + } +} diff --git a/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt b/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt index 2a8c62531..9aa6478a4 100644 --- a/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt +++ b/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt @@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicReference class MessageMediaFieldsTest { @Test fun schemaVersionIsCurrent() { - assertEquals(9, DatabaseProvider.SCHEMA_VERSION) + assertEquals(10, DatabaseProvider.SCHEMA_VERSION) } @Test @@ -31,6 +31,10 @@ class MessageMediaFieldsTest { assertTrue(DatabaseProvider.hasMigrationPath(2, 3)) assertTrue(DatabaseProvider.hasMigrationPath(3, 4)) assertTrue(DatabaseProvider.hasMigrationPath(8, 9)) + assertTrue(DatabaseProvider.hasMigrationPath(9, 10)) + assertTrue(DatabaseProvider.hasMigrationPath(10, 11)) + assertTrue(DatabaseProvider.hasMigrationPath(11, 12)) + assertTrue(DatabaseProvider.hasMigrationPath(13, 14)) assertFalse(DatabaseProvider.hasMigrationPath(32)) assertFalse(DatabaseProvider.hasMigrationPath(3, 2)) } @@ -110,6 +114,29 @@ class MessageMediaFieldsTest { assertEquals(55L, back.randomId) assertEquals(true, back.supportsStreaming) assertEquals(8_000_000L, back.fileSize) + val richChecklist = org.monogram.core.models.Checklist( + "😀 task", othersCanComplete = true, + entities = listOf(TextEntity("bold", 3, 4)), items = listOf( + org.monogram.core.models.ChecklistItem( + -7, + "😀 task", + done = true, + completedBy = 42, + completedAt = 9, + entities = listOf(TextEntity("italic", 3, 4)) + ) + ) + ) + val todo = Message( + MessageId(PeerId(42), 8), + null, + null, + 2, + outgoing = false, + mediaKind = "todo", + checklist = richChecklist + ) + assertEquals(richChecklist, todo.toEntity().toModel().checklist) } @Test diff --git a/core/database/src/test/java/org/monogram/core/database/MessageReadOwnershipTest.kt b/core/database/src/test/java/org/monogram/core/database/MessageReadOwnershipTest.kt new file mode 100644 index 000000000..f459664de --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/MessageReadOwnershipTest.kt @@ -0,0 +1,69 @@ +package org.monogram.core.database + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +class MessageReadOwnershipTest { + private val message = Message(MessageId(PeerId(7), 37), null, null, 100, outgoing = false) + + @Test + fun accountDeactivationJoinsReadPersistenceAndRejectsOldOwnerAfterReactivation() = runBlocking { + val entered = CompletableDeferred() + val release = CompletableDeferred() + var writes = 0 + val warmup = object : OfflineWarmup() { + override suspend fun upsertMessages(messages: List) { + entered.complete(Unit) + release.await() + writes++ + } + } + val epoch = warmup.outgoingAccountEpoch() + val read = async(start = CoroutineStart.UNDISPATCHED) { + warmup.applyMessageRead( + epoch, + PeerId(7), + 37, + message + ) + } + entered.await() + val deactivate = + async(start = CoroutineStart.UNDISPATCHED) { warmup.deactivateOutgoingAccount() } + assertFalse(deactivate.isCompleted) + release.complete(Unit) + assertTrue(read.await()) + deactivate.await() + warmup.activateOutgoingAccount() + assertFalse(warmup.applyMessageRead(epoch, PeerId(7), 37, message)) + assertEquals(1, writes) + } + + @Test + fun deletedReadPersistsOnlyTheRequestedIdentity() = runBlocking { + val deletions = mutableListOf>>() + val warmup = object : OfflineWarmup() { + override suspend fun deleteMessages(chatId: PeerId?, messageIds: Collection) { + deletions += chatId to messageIds.toList() + } + } + assertTrue(warmup.applyMessageRead(warmup.outgoingAccountEpoch(), PeerId(7), 37, null)) + assertEquals(listOf(PeerId(7) to listOf(37)), deletions) + assertTrue(runCatching { + warmup.applyMessageRead( + warmup.outgoingAccountEpoch(), + PeerId(8), + 37, + message + ) + }.exceptionOrNull() is IllegalArgumentException) + assertEquals(1, deletions.size) + } +} diff --git a/core/database/src/test/java/org/monogram/core/database/OutgoingMediaFilesTest.kt b/core/database/src/test/java/org/monogram/core/database/OutgoingMediaFilesTest.kt new file mode 100644 index 000000000..02f8dbc7b --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/OutgoingMediaFilesTest.kt @@ -0,0 +1,140 @@ +package org.monogram.core.database + +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class OutgoingMediaFilesTest { + @get:Rule + val temporary = TemporaryFolder() + + @Test + fun retainedCopiesSurviveSourceCleanupAndStoreRecreation() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile("attach-photo").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val descriptor = OutgoingMediaFiles(root).retain(listOf(original), 3).single() + assertFalse(descriptor.name.startsWith("attach-")) + original.delete() + val restored = OutgoingMediaFiles(root) + assertArrayEquals(byteArrayOf(1, 2, 3), restored.verifiedFile(descriptor).readBytes()) + restored.release(listOf(descriptor)) + restored.release(listOf(descriptor)) + assertTrue(root.listFiles().orEmpty().isEmpty()) + } + + @Test + fun unavailableOwnedDirectoryIsAnIoFailure() = runBlocking { + val blocked = temporary.newFile("blocked") + val original = temporary.newFile().apply { writeText("one") } + expectFailure { OutgoingMediaFiles(blocked).retain(listOf(original), 10) } + assertEquals("one", original.readText()) + } + + @Test + fun changedSourceDoesNotChangeTheRetainedPayload() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + val store = OutgoingMediaFiles(root) + val descriptor = store.retain(listOf(original), 10).single() + original.writeText("two") + assertEquals("one", store.verifiedFile(descriptor).readText()) + Unit + } + + @Test + fun failedBatchRemovesAlreadyCopiedFilesAndTemporaryFiles() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + expectFailure { + OutgoingMediaFiles(root).retain(listOf(original, File(temporary.root, "missing")), 10) + } + assertTrue(root.listFiles().orEmpty().isEmpty()) + assertEquals("one", original.readText()) + } + + @Test + fun aggregateBudgetIsEnforcedAcrossTheWholeBatch() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + expectFailure { + OutgoingMediaFiles(root).retain( + listOf(original, original), + 5 + ) + } + assertTrue(root.listFiles().orEmpty().isEmpty()) + } + + @Test + fun hashAndLengthChangesAreRejectedBeforeUpload() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + val store = OutgoingMediaFiles(root) + val descriptor = store.retain(listOf(original), 10).single() + File(root, descriptor.name).writeText("two") + expectFailure { store.verifiedFile(descriptor) } + File(root, descriptor.name).writeText("longer") + expectFailure { store.verifiedFile(descriptor) } + } + + @Test + fun malformedOwnershipCannotDeleteOrReadAnExternalFile() = runBlocking { + val root = temporary.newFolder("owned") + val outside = temporary.newFile("outside.bin").apply { writeText("private") } + val store = OutgoingMediaFiles(root) + val descriptor = RetainedMediaSource("../outside.bin", 7, "0".repeat(64)) + val valid = store.retain(listOf(outside), 7).single() + expectFailure { store.release(listOf(valid, descriptor)) } + assertEquals("private", store.verifiedFile(valid).readText()) + expectFailure { store.verifiedFile(descriptor) } + assertEquals("private", outside.readText()) + } + + @Test + fun cancelledAdmissionCreatesNoFiles() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + expectFailure { + withContext(Job().apply { cancel() }) { + OutgoingMediaFiles(root).retain( + listOf(original), + 10 + ) + } + } + assertTrue(root.listFiles().orEmpty().isEmpty()) + } + + @Test + fun recoveryKeepsReferencedCopiesAndOnlyRemovesOwnedOrphans() = runBlocking { + val root = File(temporary.root, "owned") + val original = temporary.newFile().apply { writeText("one") } + val store = OutgoingMediaFiles(root) + val sources = store.retain(listOf(original, original), 6) + val partial = File( + root, + java.util.UUID.randomUUID().toString() + ".bin.part" + ).apply { writeText("partial") } + val unrelated = File(root, "unrelated.bin").apply { writeText("keep") } + store.removeUnreferenced(setOf(sources.first().name)) + assertEquals("one", store.verifiedFile(sources.first()).readText()) + assertFalse(File(root, sources.last().name).exists()) + assertFalse(partial.exists()) + assertEquals("keep", unrelated.readText()) + } + + private suspend inline fun expectFailure(block: suspend () -> Unit) { + try { + block(); fail("expected ${T::class.java.simpleName}") + } catch (error: Throwable) { + if (error !is T) throw error + } + } +} diff --git a/core/database/src/test/java/org/monogram/core/database/StoredPayloadCompatibilityTest.kt b/core/database/src/test/java/org/monogram/core/database/StoredPayloadCompatibilityTest.kt new file mode 100644 index 000000000..bc7e6909a --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/StoredPayloadCompatibilityTest.kt @@ -0,0 +1,76 @@ +package org.monogram.core.database + +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.ContactImportPhase +import org.monogram.core.models.ServiceMedia + +class StoredPayloadCompatibilityTest { + @Test + fun legacyPollRemainsSendableAndRenderableWithOpaqueAnswerOptions() { + val raw = + """{"q":"Pick \\\"one\\\"","a":[{"t":"First","o":"30"},{"t":"Second","o":"31"}],"z":true,"m":false,"o":false,"send":{"version":1,"correct":1,"solution":"Why"},"future":true}""" + val spec = decodePollSend(raw) + assertEquals(1, spec.correctAnswer) + val encoded = encodePollSend(spec) + assertEquals(spec, decodePollSend(encoded)) + val rendered = ServiceMedia.poll(encoded)!! + assertEquals(spec.question, rendered.question) + assertArrayEquals(byteArrayOf(49), rendered.answers[1].option) + assertTrue(rendered.isQuiz) + assertTrue(runCatching { decodePollSend(raw.replace("\"31\"", "\"32\"")) }.isFailure) + } + + @Test + fun legacyContactKeepsEscapedTextVcardAndRenderingShape() { + val spec = + decodeContactSend("""{"send_version":1,"p":"+15555550123","f":"A/B","l":"Contact","c":"line1\nline2","u":0}""") + val encoded = encodeContactSend(spec) + assertEquals(spec, decodeContactSend(encoded)) + assertNotNull(ServiceMedia.contact(encoded)) + assertEquals("line1\nline2", spec.vcard) + } + + @Test + fun locationsRetainOptionalHeadingAndLongLivedSessionIdentity() { + val place = + decodeLocationSend("""{"send_version":1,"lat":55.5,"long":37.5,"live":false,"p":0}""") + assertFalse( + storedPayloadJson.parseToJsonElement(encodeLocationSend(place)).jsonObject.containsKey( + "h" + ) + ) + assertEquals(place, ServiceMedia.geo(encodeLocationSend(place))) + val session = + decodeLiveLocation("""{"v":1,"chat":7,"id":37,"random":9223372036854775807,"expires":null,"phase":"PAUSED","revision":2}""") + assertEquals(Long.MAX_VALUE, session.randomId) + assertEquals(session, decodeLiveLocation(encodeLiveLocation(session))) + assertTrue(encodeLiveLocation(session).contains("\"expires\":null")) + } + + @Test + fun legacyDraftDefaultsAndImportPhaseSurviveTypedDecoding() { + val draft = + decodeDraftState("""{"version":1,"chatId":7,"threadId":0,"text":"Draft","entities":null,"replyTo":0,"date":10,"noWebpage":false,"unsupported":false,"revision":2,"dirty":true,"remoteDate":9}""") + assertFalse(draft.markdown) + assertFalse(draft.discardingUnsupported) + assertEquals(draft, decodeDraftState(encodeDraftState(draft))) + val attempt = + decodeContactImport("""{"version":1,"id":9223372036854775807,"phone":"+15555550123","first":"Fixture","last":"","phase":"IMPORTED","user":42}""") + assertEquals(ContactImportPhase.IMPORTED, attempt.phase) + assertEquals(Long.MAX_VALUE, attempt.spec.clientId) + assertEquals(attempt, decodeContactImport(encodeContactImport(attempt))) + } + + @Test + fun missingVersionsAndInvalidStoredStateAreRejected() { + assertTrue(runCatching { decodeContactSend("""{"p":"+15555550123","f":"Fixture","l":"","c":"","u":0}""") }.isFailure) + assertTrue(runCatching { decodeLocationSend("""{"send_version":2,"lat":0,"long":0,"live":false,"p":0}""") }.isFailure) + assertTrue(runCatching { decodeLocationSend("""{"send_version":1,"lat":91,"long":0,"live":false,"p":0}""") }.isFailure) + val invalid = runCatching { decodeDraftState("{}") }.exceptionOrNull() + assertTrue(invalid is StoredPayloadException) + assertNull(invalid?.cause) + assertTrue(runCatching { decodeContactImport("""{"version":1,"id":1,"phone":"+15555550123","first":"Fixture","last":"","phase":"IMPORTED","user":null}""") }.isFailure) + } +} diff --git a/core/models/src/main/java/org/monogram/core/models/ChatListPreview.kt b/core/models/src/main/java/org/monogram/core/models/ChatListPreview.kt index f8e9c38ce..4f9b050d3 100644 --- a/core/models/src/main/java/org/monogram/core/models/ChatListPreview.kt +++ b/core/models/src/main/java/org/monogram/core/models/ChatListPreview.kt @@ -65,6 +65,13 @@ private val generatedFileNameKinds = setOf( "venue", "contact", "dice", + "giveaway", + "giveaway_results", + "paid_media", + "game", + "invoice", + "story", + "video_stream", ) private val stickerFileSuffixes = listOf(".webp", ".webm", ".tgs") @@ -128,6 +135,13 @@ fun chatListMediaLabel(kind: String): String? = when (kind) { "venue" -> "Venue" "contact" -> "Contact" "dice" -> "Dice" + "giveaway" -> "Giveaway" + "giveaway_results" -> "Giveaway results" + "paid_media" -> "Paid media" + "game" -> "Game" + "invoice" -> "Invoice" + "story" -> "Story" + "video_stream" -> "Live stream" else -> null } diff --git a/core/models/src/main/java/org/monogram/core/models/Checklist.kt b/core/models/src/main/java/org/monogram/core/models/Checklist.kt index ab558aec2..25d17aba6 100644 --- a/core/models/src/main/java/org/monogram/core/models/Checklist.kt +++ b/core/models/src/main/java/org/monogram/core/models/Checklist.kt @@ -6,6 +6,7 @@ data class ChecklistItem( val done: Boolean = false, val completedBy: Long? = null, val completedAt: Long? = null, + val entities: List = emptyList(), ) data class Checklist( @@ -13,6 +14,7 @@ data class Checklist( val othersCanAppend: Boolean = false, val othersCanComplete: Boolean = false, val items: List = emptyList(), + val entities: List = emptyList(), ) object Checklists { @@ -30,6 +32,7 @@ object Checklists { done = (obj["d"] as? Number)?.toInt() == 1 || obj["d"] == true, completedBy = (obj["by"] as? Number)?.toLong()?.takeIf { it != 0L }, completedAt = (obj["at"] as? Number)?.toLong()?.takeIf { it != 0L }, + entities = TextEntities.parse(obj["e"] as? String), ) }.orEmpty() return Checklist( @@ -37,6 +40,7 @@ object Checklists { othersCanAppend = root["a"] == true, othersCanComplete = root["c"] == true, items = items, + entities = TextEntities.parse(root["e"] as? String), ) } @@ -44,6 +48,11 @@ object Checklists { if (checklist == null) return null return buildString { append("{\"t\":\"").append(CompactJson.escape(checklist.title)).append('"') + if (checklist.entities.isNotEmpty()) append(",\"e\":\"").append( + CompactJson.escape( + TextEntities.serialize(checklist.entities)!! + ) + ).append('"') if (checklist.othersCanAppend) append(",\"a\":true") if (checklist.othersCanComplete) append(",\"c\":true") append(",\"i\":[") @@ -51,6 +60,11 @@ object Checklists { if (index > 0) append(',') append("{\"id\":").append(item.id) append(",\"t\":\"").append(CompactJson.escape(item.text)).append('"') + if (item.entities.isNotEmpty()) append(",\"e\":\"").append( + CompactJson.escape( + TextEntities.serialize(item.entities)!! + ) + ).append('"') if (item.done) append(",\"d\":1") item.completedBy?.let { append(",\"by\":").append(it) } item.completedAt?.let { append(",\"at\":").append(it) } diff --git a/core/models/src/main/java/org/monogram/core/models/ChecklistSendSpec.kt b/core/models/src/main/java/org/monogram/core/models/ChecklistSendSpec.kt new file mode 100644 index 000000000..e227cb039 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ChecklistSendSpec.kt @@ -0,0 +1,38 @@ +package org.monogram.core.models + +data class ChecklistSendSpec( + val title: String, + val items: List, + val othersCanAppend: Boolean = false, + val othersCanComplete: Boolean = false, +) { + fun pendingChecklist(): Checklist { + requireValid() + return Checklist( + title, + items = items.mapIndexed { index, text -> ChecklistItem(index + 1, text) }, + othersCanAppend = othersCanAppend, + othersCanComplete = othersCanComplete + ) + } + + companion object { + fun fromPending(checklist: Checklist): ChecklistSendSpec { + require(checklist.entities.isEmpty()) + require(checklist.items.withIndex().all { (index, item) -> + item.entities.isEmpty() && item.id == index + 1 && !item.done && item.completedBy == null && item.completedAt == null + }) + return ChecklistSendSpec( + checklist.title, + checklist.items.map { it.text }, + checklist.othersCanAppend, + checklist.othersCanComplete + ).also { it.requireValid() } + } + } + + fun requireValid() { + require(title.isNotBlank() && title.length <= 32) + require(items.size in 1..30 && items.all { it.isNotBlank() && it.length <= 64 }) + } +} diff --git a/core/models/src/main/java/org/monogram/core/models/ChecklistTextEdit.kt b/core/models/src/main/java/org/monogram/core/models/ChecklistTextEdit.kt new file mode 100644 index 000000000..ea37b75c8 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ChecklistTextEdit.kt @@ -0,0 +1,9 @@ +package org.monogram.core.models + +data class ChecklistTextEdit( + val itemId: Int?, + val text: String, + val entities: List, + val expectedText: String, + val expectedEntities: List, +) diff --git a/core/models/src/main/java/org/monogram/core/models/CloudDraft.kt b/core/models/src/main/java/org/monogram/core/models/CloudDraft.kt new file mode 100644 index 000000000..5bbf455e9 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/CloudDraft.kt @@ -0,0 +1,12 @@ +package org.monogram.core.models + +data class CloudDraft( + val chatId: PeerId, + val threadId: Int = 0, + val text: String = "", + val entitiesJson: String? = null, + val replyToMessageId: Int = 0, + val date: Int = 0, + val noWebpage: Boolean = false, + val hasUnsupportedContent: Boolean = false, +) diff --git a/core/models/src/main/java/org/monogram/core/models/CloudDraftState.kt b/core/models/src/main/java/org/monogram/core/models/CloudDraftState.kt new file mode 100644 index 000000000..951dd426d --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/CloudDraftState.kt @@ -0,0 +1,51 @@ +package org.monogram.core.models + +data class CloudDraftState( + val draft: CloudDraft, + val revision: Long = 0, + val dirty: Boolean = false, + val lastRemoteDate: Int = draft.date, + val discardingUnsupported: Boolean = false, + val markdown: Boolean = false, +) { + fun localEdit( + value: CloudDraft, discardUnsupported: Boolean = false, markdown: Boolean? = null + ): CloudDraftState { + require(value.chatId == draft.chatId && value.threadId == draft.threadId) + val guarded = value.copy( + hasUnsupportedContent = value.hasUnsupportedContent || (!discardUnsupported && draft.hasUnsupportedContent) + ) + return copy( + draft = guarded, + revision = revision + 1, + dirty = true, + discardingUnsupported = discardingUnsupported || discardUnsupported, + markdown = if (discardUnsupported) false else markdown ?: this.markdown + ) + } + + fun mergeRemote(value: CloudDraft): CloudDraftState { + require(value.chatId == draft.chatId && value.threadId == draft.threadId) + if (value.date != 0 && value.date < lastRemoteDate) return this + val remoteDate = maxOf(lastRemoteDate, value.date) + if (dirty) { + val guarded = + draft.copy(hasUnsupportedContent = draft.hasUnsupportedContent || (!discardingUnsupported && value.hasUnsupportedContent)) + return copy( + draft = guarded, + revision = if (guarded != draft) revision + 1 else revision, + lastRemoteDate = remoteDate + ) + } + if (value == draft) return copy(lastRemoteDate = remoteDate) + return copy( + draft = value, + revision = revision + 1, + lastRemoteDate = remoteDate, + markdown = markdown && value.text == draft.text && value.entitiesJson == draft.entitiesJson + ) + } + + fun acknowledge(savedRevision: Long): CloudDraftState = + if (savedRevision == revision) copy(dirty = false, discardingUnsupported = false) else this +} diff --git a/core/models/src/main/java/org/monogram/core/models/ContactImportAttempt.kt b/core/models/src/main/java/org/monogram/core/models/ContactImportAttempt.kt new file mode 100644 index 000000000..f55797629 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ContactImportAttempt.kt @@ -0,0 +1,9 @@ +package org.monogram.core.models + +enum class ContactImportPhase { IN_FLIGHT, UNCERTAIN, RETRY_REQUIRED, IMPORTED, NOT_ADDED } + +data class ContactImportAttempt( + val spec: ContactImportSpec, + val phase: ContactImportPhase, + val userId: PeerId? = null, +) diff --git a/core/models/src/main/java/org/monogram/core/models/ContactSendSpec.kt b/core/models/src/main/java/org/monogram/core/models/ContactSendSpec.kt new file mode 100644 index 000000000..e5ed2c1d7 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ContactSendSpec.kt @@ -0,0 +1,29 @@ +package org.monogram.core.models + +data class ContactSendSpec( + val phone: String, + val firstName: String, + val lastName: String = "", + val vcard: String = "", +) { + val displayName: String get() = ContactCard(phone, firstName, lastName).displayName + + fun requireValid() { + val digits = phone.removePrefix("+") + require(digits.length in 3..15 && digits.all { it in '0'..'9' }) + require(firstName.isNotBlank() && firstName.length <= 256 && lastName.length <= 256) + require(vcard.toByteArray(Charsets.UTF_8).size <= 65536) + } +} + +data class ContactImportSpec( + val clientId: Long, val phone: String, val firstName: String, val lastName: String = "" +) + +data class ContactImportMatch(val clientId: Long, val userId: PeerId) +data class ContactImportPopularity(val clientId: Long, val importers: Int) +data class ContactImportResult( + val imported: List, + val retryClientIds: List, + val popular: List, +) diff --git a/core/models/src/main/java/org/monogram/core/models/LiveLocationSession.kt b/core/models/src/main/java/org/monogram/core/models/LiveLocationSession.kt new file mode 100644 index 000000000..a9465ed0e --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/LiveLocationSession.kt @@ -0,0 +1,33 @@ +package org.monogram.core.models + +enum class LiveLocationPhase { ACTIVE, PAUSED, STOPPING, UNCERTAIN_STOP, STOPPED, EXPIRED } + +data class LiveLocationSession( + val messageId: MessageId, + val randomId: Long, + val expiresAt: Long?, + val phase: LiveLocationPhase = LiveLocationPhase.ACTIVE, + val revision: Long = 0, +) { + fun expired(nowSeconds: Long): Boolean = expiresAt?.let { nowSeconds >= it } ?: false + + fun canTransition(next: LiveLocationPhase): Boolean = when (phase) { + LiveLocationPhase.ACTIVE -> next in setOf( + LiveLocationPhase.PAUSED, LiveLocationPhase.STOPPING, LiveLocationPhase.EXPIRED + ) + + LiveLocationPhase.PAUSED -> next in setOf( + LiveLocationPhase.ACTIVE, LiveLocationPhase.STOPPING, LiveLocationPhase.EXPIRED + ) + + LiveLocationPhase.STOPPING -> next in setOf( + LiveLocationPhase.STOPPED, LiveLocationPhase.UNCERTAIN_STOP, LiveLocationPhase.EXPIRED + ) + + LiveLocationPhase.UNCERTAIN_STOP -> next in setOf( + LiveLocationPhase.STOPPING, LiveLocationPhase.STOPPED, LiveLocationPhase.EXPIRED + ) + + LiveLocationPhase.STOPPED, LiveLocationPhase.EXPIRED -> false + } +} diff --git a/core/models/src/main/java/org/monogram/core/models/MessageSendOptions.kt b/core/models/src/main/java/org/monogram/core/models/MessageSendOptions.kt new file mode 100644 index 000000000..b665c8a7a --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/MessageSendOptions.kt @@ -0,0 +1,12 @@ +package org.monogram.core.models + +data class MessageSendOptions( + val silent: Boolean = false, + val scheduleDate: Int? = null, + val noWebpage: Boolean = false, +) + +sealed interface OutgoingMessageResult { + data class Sent(val message: Message) : OutgoingMessageResult + data class Scheduled(val message: ScheduledMessage) : OutgoingMessageResult +} diff --git a/core/models/src/main/java/org/monogram/core/models/Models.kt b/core/models/src/main/java/org/monogram/core/models/Models.kt index 75962ffdd..320a73cad 100644 --- a/core/models/src/main/java/org/monogram/core/models/Models.kt +++ b/core/models/src/main/java/org/monogram/core/models/Models.kt @@ -63,6 +63,7 @@ data class Chat( val canView: Boolean = true, val canSendPlain: Boolean = true, val canSendPhotos: Boolean = true, + val canSendPolls: Boolean = false, val canForward: Boolean = true, val canDeleteOthers: Boolean = false, /** Creator or `manage_topics` admin right. */ diff --git a/core/models/src/main/java/org/monogram/core/models/PollSendSpec.kt b/core/models/src/main/java/org/monogram/core/models/PollSendSpec.kt new file mode 100644 index 000000000..f8755490c --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/PollSendSpec.kt @@ -0,0 +1,20 @@ +package org.monogram.core.models + +data class PollSendSpec( + val question: String, + val answers: List, + val anonymous: Boolean = true, + val multipleChoice: Boolean = false, + val quiz: Boolean = false, + val correctAnswer: Int? = null, + val solution: String? = null, +) { + fun requireValid() { + require(question.isNotBlank() && question.length <= 255) + require(answers.size in 2..10 && answers.all { it.isNotBlank() && it.length <= 100 }) + require(!quiz || !multipleChoice && correctAnswer != null) + require(quiz || correctAnswer == null && solution == null) + require(correctAnswer == null || correctAnswer in answers.indices) + require(solution == null || solution.isNotBlank() && solution.length <= 200) + } +} diff --git a/core/models/src/main/java/org/monogram/core/models/ProfileTabs.kt b/core/models/src/main/java/org/monogram/core/models/ProfileTabs.kt index 38f1717a9..8b405ff7f 100644 --- a/core/models/src/main/java/org/monogram/core/models/ProfileTabs.kt +++ b/core/models/src/main/java/org/monogram/core/models/ProfileTabs.kt @@ -4,7 +4,7 @@ package org.monogram.core.models * Shared-media tabs shown on a peer profile. * * [wire] matches the `messages.search` filter names mapped in - * `native/mtproto-rs/src/messages_rpc.rs` and Telegram's profile tab order. + * and the Android MTProto DTO/FFI boundary. */ enum class ProfileTab(val wire: String) { MEDIA("photo_video"), diff --git a/core/models/src/main/java/org/monogram/core/models/ReadReceipts.kt b/core/models/src/main/java/org/monogram/core/models/ReadReceipts.kt index 5177c6055..108fe06cf 100644 --- a/core/models/src/main/java/org/monogram/core/models/ReadReceipts.kt +++ b/core/models/src/main/java/org/monogram/core/models/ReadReceipts.kt @@ -15,6 +15,12 @@ data class ReadReceiptConfig( } } +data class MessageViewerPage( + val viewers: List, + val totalCount: Int, + val nextOffset: String? +) + data class MessageViewer( val peerId: PeerId, val date: Long, diff --git a/core/models/src/main/java/org/monogram/core/models/ScheduledMessage.kt b/core/models/src/main/java/org/monogram/core/models/ScheduledMessage.kt new file mode 100644 index 000000000..9d2e3ada7 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ScheduledMessage.kt @@ -0,0 +1,20 @@ +package org.monogram.core.models + +/** Scheduled message IDs use a separate namespace from normal history. */ +data class ScheduledMessage(val message: Message, val hasUnsupportedContent: Boolean = true) { + val canPreviewMedia: Boolean + get() = message.mediaCacheKey != null && message.mediaKind in setOf( + "photo", "video", "gif", "video_note" + ) + + val canEditText: Boolean + get() = !hasUnsupportedContent && message.mediaKind in setOf( + null, "photo", "video", "gif", "document", "audio" + ) + + val mediaMessageId: MessageId + get() { + require(message.id.id > 0) + return MessageId(message.id.chatId, -1 - message.id.id) + } +} \ No newline at end of file diff --git a/core/models/src/main/java/org/monogram/core/models/ServiceMedia.kt b/core/models/src/main/java/org/monogram/core/models/ServiceMedia.kt index e05e06018..4971167c6 100644 --- a/core/models/src/main/java/org/monogram/core/models/ServiceMedia.kt +++ b/core/models/src/main/java/org/monogram/core/models/ServiceMedia.kt @@ -9,7 +9,7 @@ package org.monogram.core.models * no schema change is needed. [Message] exposes typed accessors below; the payload is parsed on * demand, so a cached message renders after a restart. * - * JSON keys, as written by `native/mtproto-rs/src/media_rpc.rs`: + * JSON keys emitted by the native MTProto DTO/FFI boundary: * - poll: `q` question, `a` answers (`t` text, `c` chosen, `k` correct, `v` voters), `n` total * voters, `z` quiz, `m` multiple choice, `x` closed, `o` public voters, `s` solution * - geo: `lat`, `long`, plus `live`, `p` period seconds and `h` heading for live locations @@ -25,12 +25,13 @@ data class PollAnswer( /** Raw option bytes from the server, sent back verbatim when voting. */ val option: ByteArray = ByteArray(0), ) { - override fun equals(other: Any?): Boolean = other is PollAnswer && - text == other.text && chosen == other.chosen && correct == other.correct && - voters == other.voters && option.contentEquals(other.option) + override fun equals(other: Any?): Boolean = + other is PollAnswer && text == other.text && chosen == other.chosen && correct == other.correct && voters == other.voters && option.contentEquals( + other.option + ) + + override fun hashCode(): Int = ((text.hashCode() * 31 + voters) * 31 + option.contentHashCode()) - override fun hashCode(): Int = - ((text.hashCode() * 31 + voters) * 31 + option.contentHashCode()) val hasVotes: Boolean get() = voters > 0 } @@ -43,6 +44,9 @@ data class Poll( val closed: Boolean = false, val publicVoters: Boolean = false, val solution: String = "", + val id: Long? = null, + val creator: Boolean = false, + val revotingDisabled: Boolean = false, ) { private val maxVoters: Int get() = answers.maxOfOrNull { it.voters } ?: 0 @@ -96,6 +100,31 @@ data class ContactCard( data class Dice( val value: Int, val emoticon: String, +) { + companion object { + val supportedEmoticons = setOf("🎲", "🎯", "🏀", "⚽", "🎳", "🎰") + fun emoticonForSend(text: String): String? = + text.trim().replace("\uFE0F", "").takeIf { it in supportedEmoticons } + } +} + +/** + * Card payload for media without a downloadable file: giveaways, paid media, games, + * invoices, stories and live streams. Numeric fields are zero when absent. + */ +data class MediaAnnouncement( + val title: String = "", + val description: String = "", + val detail: String = "", + val quantity: Int = 0, + val months: Int = 0, + val stars: Long = 0, + val winners: Int = 0, + val unclaimed: Int = 0, + val items: Int = 0, + val storyId: Int = 0, + val rtmp: Boolean = false, + val untilDate: Int = 0, ) object ServiceMedia { @@ -120,6 +149,9 @@ object ServiceMedia { multipleChoice = flag(root["m"]), closed = flag(root["x"]), publicVoters = flag(root["o"]), + id = (root["id"] as? String)?.toLongOrNull(), + creator = flag(root["creator"]), + revotingDisabled = flag(root["r"]), solution = root["s"] as? String ?: "", ) } @@ -154,6 +186,23 @@ object ServiceMedia { Dice(value = value, emoticon = root["e"] as? String ?: "") } + fun announcement(raw: String?): MediaAnnouncement? = parse(raw) { root -> + MediaAnnouncement( + title = root["t"] as? String ?: "", + description = root["s"] as? String ?: "", + detail = root["d"] as? String ?: "", + quantity = (root["q"] as? Number)?.toInt() ?: 0, + months = (root["m"] as? Number)?.toInt() ?: 0, + stars = (root["x"] as? Number)?.toLong() ?: 0, + winners = (root["w"] as? Number)?.toInt() ?: 0, + unclaimed = (root["u"] as? Number)?.toInt() ?: 0, + items = (root["i"] as? Number)?.toInt() ?: 0, + storyId = (root["n"] as? Number)?.toInt() ?: 0, + rtmp = flag(root["r"]), + untilDate = (root["e"] as? Number)?.toInt() ?: 0, + ) + } + private fun place(root: Map<*, *>): GeoPlace? { val lat = (root["lat"] as? Number)?.toDouble() ?: return null val long = (root["long"] as? Number)?.toDouble() ?: return null @@ -215,3 +264,16 @@ val Message.contactCard: ContactCard? val Message.dice: Dice? get() = if (mediaKind == "dice") ServiceMedia.dice(fileName) else null + +val Message.announcementCard: MediaAnnouncement? + get() = if (mediaKind in announcementKinds) ServiceMedia.announcement(fileName) else null + +val announcementKinds = setOf( + "giveaway", + "giveaway_results", + "paid_media", + "game", + "invoice", + "story", + "video_stream", +) diff --git a/core/models/src/test/java/org/monogram/core/models/ChecklistTest.kt b/core/models/src/test/java/org/monogram/core/models/ChecklistTest.kt index c4eb66738..602e29fc0 100644 --- a/core/models/src/test/java/org/monogram/core/models/ChecklistTest.kt +++ b/core/models/src/test/java/org/monogram/core/models/ChecklistTest.kt @@ -26,7 +26,17 @@ class ChecklistTest { val source = Checklist( title = "Tasks", othersCanComplete = true, - items = listOf(ChecklistItem(1, "One", done = true), ChecklistItem(2, "Two")), + entities = listOf(TextEntity("italic", 0, 5)), + items = listOf( + ChecklistItem( + 1, + "One", + done = true, + completedBy = 7L, + completedAt = 9L, + entities = listOf(TextEntity("bold", 0, 3)) + ), ChecklistItem(2, "Two") + ), ) val parsed = Checklists.parse(Checklists.serialize(source)) requireNotNull(parsed) @@ -35,5 +45,17 @@ class ChecklistTest { assertEquals(source.items.map { it.text }, parsed.items.map { it.text }) assertTrue(parsed.items[0].done) assertFalse(parsed.items[1].done) + assertEquals(source, parsed) + val pending = ChecklistSendSpec("Tasks", listOf("One")).pendingChecklist() + assertTrue(runCatching { ChecklistSendSpec.fromPending(pending.copy(entities = source.entities)) }.isFailure) + assertTrue(runCatching { + ChecklistSendSpec.fromPending( + pending.copy( + items = listOf( + pending.items.single().copy(entities = source.items[0].entities) + ) + ) + ) + }.isFailure) } } diff --git a/core/models/src/test/java/org/monogram/core/models/CloudDraftStateTest.kt b/core/models/src/test/java/org/monogram/core/models/CloudDraftStateTest.kt new file mode 100644 index 000000000..c74bb0d8d --- /dev/null +++ b/core/models/src/test/java/org/monogram/core/models/CloudDraftStateTest.kt @@ -0,0 +1,75 @@ +package org.monogram.core.models + +import org.junit.Assert.* +import org.junit.Test + +class CloudDraftStateTest { + private val initial = CloudDraft(PeerId(7), text = "server", date = 100) + + @Test + fun remoteUpdatesDoNotOverwriteUnsentLocalEdits() { + val local = CloudDraftState(initial).localEdit(initial.copy(text = "local")) + val merged = local.mergeRemote(initial.copy(text = "other device", date = 200)) + assertEquals("local", merged.draft.text) + assertTrue(merged.dirty) + assertEquals(local.revision, merged.revision) + assertEquals(200, merged.lastRemoteDate) + } + + @Test + fun staleAcknowledgmentDoesNotClearANewerEdit() { + val first = CloudDraftState(initial).localEdit(initial.copy(text = "first")) + val second = first.localEdit(initial.copy(text = "second")) + assertEquals(second, second.acknowledge(first.revision)) + val saved = second.acknowledge(second.revision) + assertFalse(saved.dirty) + assertEquals("second", saved.draft.text) + } + + @Test + fun oldSnapshotsAreIgnoredAndAnUndatedClearIsApplied() { + val state = CloudDraftState(initial) + assertEquals(state, state.mergeRemote(initial.copy(text = "old", date = 99))) + val cleared = state.mergeRemote(initial.copy(text = "", date = 0)) + assertEquals("", cleared.draft.text) + assertEquals(100, cleared.lastRemoteDate) + assertEquals(1L, cleared.revision) + } + + @Test + fun unsupportedRemoteContentBlocksTextAutosavesUntilExplicitDiscard() { + val local = CloudDraftState(initial).localEdit(initial.copy(text = "local")) + val rich = initial.copy(text = "rich remote", date = 200, hasUnsupportedContent = true) + val conflict = local.mergeRemote(rich) + assertTrue(conflict.draft.hasUnsupportedContent) + assertEquals("local", conflict.draft.text) + assertTrue(conflict.acknowledge(local.revision).dirty) + assertTrue(conflict.localEdit(initial.copy(text = "more typing")).draft.hasUnsupportedContent) + val discarded = conflict.localEdit(CloudDraft(initial.chatId), discardUnsupported = true) + assertFalse(discarded.draft.hasUnsupportedContent) + assertTrue(discarded.discardingUnsupported) + assertFalse(discarded.mergeRemote(rich).draft.hasUnsupportedContent) + assertFalse(discarded.acknowledge(discarded.revision).discardingUnsupported) + } + + @Test + fun localMarkdownModeSurvivesAcknowledgmentAndMatchingRemoteUpdates() { + val local = CloudDraftState(initial).localEdit(initial, markdown = true) + val saved = local.acknowledge(local.revision) + assertTrue(saved.markdown) + assertTrue(saved.mergeRemote(initial.copy(date = 200)).markdown) + assertFalse(saved.mergeRemote(initial.copy(text = "different", date = 200)).markdown) + assertFalse(local.localEdit(CloudDraft(initial.chatId), discardUnsupported = true).markdown) + } + + @Test + fun contextsCannotBeMixed() { + val state = CloudDraftState(initial) + assertThrows(IllegalArgumentException::class.java) { + state.localEdit(initial.copy(threadId = 99)) + } + assertThrows(IllegalArgumentException::class.java) { + state.mergeRemote(initial.copy(chatId = PeerId(8))) + } + } +} diff --git a/core/models/src/test/java/org/monogram/core/models/PollRevotingTest.kt b/core/models/src/test/java/org/monogram/core/models/PollRevotingTest.kt new file mode 100644 index 000000000..5f6d28be0 --- /dev/null +++ b/core/models/src/test/java/org/monogram/core/models/PollRevotingTest.kt @@ -0,0 +1,33 @@ +package org.monogram.core.models + +import org.junit.Assert.* +import org.junit.Test + +class PollRevotingTest { + private fun parse(field: String): Poll = ServiceMedia.poll("""{"q":"Fixture","a":[]$field}""")!! + + @Test + fun pollIdentityUsesLosslessDecimalStringAndLegacyPayloadHasNoIdentity() { + assertNull(parse("").id) + assertEquals(Long.MAX_VALUE, parse(",\"id\":\"9223372036854775807\"").id) + assertEquals(Long.MIN_VALUE, parse(",\"id\":\"-9223372036854775808\"").id) + assertNull(parse(",\"id\":\"invalid\"").id) + } + + @Test + fun creatorPermissionIsExplicitAndAbsentForLegacyPayloads() { + assertFalse(parse("").creator) + assertTrue(parse(",\"creator\":1").creator) + assertTrue(parse(",\"creator\":true").creator) + assertFalse(parse(",\"creator\":0").creator) + } + + @Test + fun legacyPollsKeepRevotingAndCurrentPayloadAcceptsBooleanOrIntegerFlag() { + assertFalse(parse("").revotingDisabled) + assertFalse(parse(",\"r\":0").revotingDisabled) + assertFalse(parse(",\"r\":false").revotingDisabled) + assertTrue(parse(",\"r\":1").revotingDisabled) + assertTrue(parse(",\"r\":true").revotingDisabled) + } +} diff --git a/core/models/src/test/java/org/monogram/core/models/ScheduledMessageTest.kt b/core/models/src/test/java/org/monogram/core/models/ScheduledMessageTest.kt new file mode 100644 index 000000000..771a5dfc3 --- /dev/null +++ b/core/models/src/test/java/org/monogram/core/models/ScheduledMessageTest.kt @@ -0,0 +1,46 @@ +package org.monogram.core.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ScheduledMessageTest { + private fun queued(id: Int) = ScheduledMessage( + Message( + id = MessageId(PeerId(7), id), senderId = null, text = "caption", + date = 0, outgoing = true, + ) + ) + + @Test + fun editingRequiresKnownContentAndACaptionCapableType() { + assertEquals(false, queued(1).canEditText) + for (kind in listOf(null, "photo", "video", "gif", "document", "audio")) { + val known = queued(1).copy( + message = queued(1).message.copy(mediaKind = kind), + hasUnsupportedContent = false + ) + assertEquals(true, known.canEditText) + assertEquals(false, known.copy(hasUnsupportedContent = true).canEditText) + } + for (kind in listOf("poll", "sticker", "voice", "video_note", "service", "unsupported")) { + assertEquals( + false, + queued(1).copy( + message = queued(1).message.copy(mediaKind = kind), + hasUnsupportedContent = false + ).canEditText + ) + } + } + + @Test + fun mediaIdsMatchNativeNamespaceWithoutChangingQueueIdentity() { + for ((id, mediaId) in listOf(1 to -2, 37 to -38, Int.MAX_VALUE to Int.MIN_VALUE)) { + val item = queued(id) + assertEquals(MessageId(PeerId(7), mediaId), item.mediaMessageId) + assertEquals(id, item.message.id.id) + } + assertThrows(IllegalArgumentException::class.java) { queued(0).mediaMessageId } + } +} diff --git a/core/models/src/test/java/org/monogram/core/models/ServiceMediaTest.kt b/core/models/src/test/java/org/monogram/core/models/ServiceMediaTest.kt index 42a8ad1d8..716916e5d 100644 --- a/core/models/src/test/java/org/monogram/core/models/ServiceMediaTest.kt +++ b/core/models/src/test/java/org/monogram/core/models/ServiceMediaTest.kt @@ -135,8 +135,44 @@ class ServiceMediaPreviewTest { @Test fun allServiceKindsHaveALabel() { - listOf("poll", "geo", "venue", "contact", "dice").forEach { kind -> + listOf( + "poll", "geo", "venue", "contact", "dice", + "giveaway", "giveaway_results", "paid_media", "game", "invoice", + "story", "video_stream", + ).forEach { kind -> assertNotNull("missing label for $kind", chatListMediaLabel(kind)) } } + + @Test + fun giveawayAnnouncementCarriesStats() { + val card = ServiceMedia.announcement( + """{"d":"Telegram Premium","q":5,"m":12,"x":250,"e":1700000000}""", + )!! + assertEquals("Telegram Premium", card.detail) + assertEquals(5, card.quantity) + assertEquals(12, card.months) + assertEquals(250L, card.stars) + assertEquals(1700000000, card.untilDate) + } + + @Test + fun giveawayResultsSeparateWinnersFromUnclaimed() { + val card = ServiceMedia.announcement( + """{"d":"Stars","w":10,"u":3,"x":100}""", + )!! + assertEquals(10, card.winners) + assertEquals(3, card.unclaimed) + assertEquals(100L, card.stars) + } + + @Test + fun storyAnnouncementKeepsIdAndLiveStreamFlag() { + val story = ServiceMedia.announcement("""{"n":42,"d":"caption"}""")!! + assertEquals(42, story.storyId) + assertEquals("caption", story.detail) + + val stream = ServiceMedia.announcement("""{"r":true}""")!! + assertTrue(stream.rtmp) + } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 99c5e1eb4..9cc20d5bd 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -11,9 +11,11 @@ android { defaultConfig { minSdk = 24 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -24,6 +26,7 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) api(libs.androidx.credentials) implementation(libs.androidx.credentials.play.services.auth) testImplementation(libs.junit) diff --git a/core/ui/src/androidTest/java/org/monogram/core/ui/OwnershipServiceMessageTest.kt b/core/ui/src/androidTest/java/org/monogram/core/ui/OwnershipServiceMessageTest.kt new file mode 100644 index 000000000..09014ae5e --- /dev/null +++ b/core/ui/src/androidTest/java/org/monogram/core/ui/OwnershipServiceMessageTest.kt @@ -0,0 +1,74 @@ +package org.monogram.core.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.platform.app.InstrumentationRegistry +import java.util.Locale +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.monogram.core.models.formatServiceMessage +import org.monogram.core.ui.theme.MonogramTheme + +class OwnershipServiceMessageTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun everyLocaleNamesNewOwnerForIncomingAndOutgoingTransfers() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + for (tag in listOf("en", "es", "hy", "ja", "pt-BR", "ru", "sk", "tr", "uk", "zh")) { + val configuration = Configuration(context.resources.configuration).apply { + setLocale(Locale.forLanguageTag(tag)) + } + val resources = context.createConfigurationContext(configuration).resources + for (kind in listOf("creator_pending", "creator_change")) { + for (outgoing in listOf(false, true)) { + val text = formatServiceMessage( + raw = "$kind\u001fAlice\u001fBob", + youLabel = "You", + outgoing = outgoing, + someoneLabel = "Someone", + templateFor = serviceMessageCatalog(resources), + ) + assertTrue("$tag $kind outgoing=$outgoing: $text", text.contains("Bob")) + assertFalse(text.contains("Alice")) + assertFalse(text.contains("You")) + } + } + } + } + + @Test + fun renderedOwnershipMessagesNameNewOwner() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val configuration = Configuration(context.resources.configuration).apply { + setLocale(Locale.ENGLISH) + } + val resources = context.createConfigurationContext(configuration).resources + rule.setContent { + MonogramTheme { + Column { + for (kind in listOf("creator_pending", "creator_change")) { + Text( + formatServiceMessage( + raw = "$kind\u001fAlice\u001fBob", + youLabel = "You", + outgoing = true, + someoneLabel = "Someone", + templateFor = serviceMessageCatalog(resources), + ), + ) + } + } + } + } + rule.onNodeWithText("Bob will become the new owner").assertIsDisplayed() + rule.onNodeWithText("Bob became the new owner").assertIsDisplayed() + } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/AppearanceSettings.kt b/core/ui/src/main/java/org/monogram/core/ui/AppearanceSettings.kt index dcfb1dd8e..47a39e2c0 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/AppearanceSettings.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/AppearanceSettings.kt @@ -35,13 +35,17 @@ internal fun restoreRightComposerSlot(raw: String?, legacyShowEmoji: Boolean): C if (raw != null) restoreComposerSlot(raw, ComposerSlotAction.Emoji) else if (legacyShowEmoji) ComposerSlotAction.Emoji else ComposerSlotAction.None -fun ComposerSlotAction.canSend(canSendPlain: Boolean, canSendPhotos: Boolean): Boolean = when (this) { +fun ComposerSlotAction.canSend( + canSendPlain: Boolean, + canSendPhotos: Boolean, + canSendStructured: Boolean = false +): Boolean = when (this) { ComposerSlotAction.None -> false ComposerSlotAction.Emoji, ComposerSlotAction.Location -> canSendPlain ComposerSlotAction.Photos -> canSendPhotos + ComposerSlotAction.Attach -> canSendPlain || canSendPhotos || canSendStructured ComposerSlotAction.Stickers, ComposerSlotAction.Gifs, - ComposerSlotAction.Attach, ComposerSlotAction.File, -> canSendPlain || canSendPhotos } diff --git a/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt b/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt new file mode 100644 index 000000000..5ae135348 --- /dev/null +++ b/core/ui/src/main/java/org/monogram/core/ui/CurrentLocation.kt @@ -0,0 +1,127 @@ +package org.monogram.core.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.os.Looper +import android.os.SystemClock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean + +private fun locationPermitted(context: Context) = + context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED || context.checkSelfPermission( + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + +private fun locationManager(context: Context) = + context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager + +private fun locationProviders(manager: LocationManager): List = + linkedSetOf().apply { + addAll(manager.getProviders(true)) + add(LocationManager.GPS_PROVIDER); add(LocationManager.NETWORK_PROVIDER); add( + LocationManager.PASSIVE_PROVIDER + ) + if (Build.VERSION.SDK_INT >= 31) add(LocationManager.FUSED_PROVIDER) + }.filter { + it == LocationManager.PASSIVE_PROVIDER || (Build.VERSION.SDK_INT >= 31 && it == LocationManager.FUSED_PROVIDER) || manager.isProviderEnabled( + it + ) + } + +@Suppress("MissingPermission") +fun lastKnownLocation(context: Context): Pair? { + if (!locationPermitted(context)) return null + val manager = locationManager(context) ?: return null + val providers = try { + locationProviders(manager) + } catch (_: SecurityException) { + return null + } + return providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time }?.let { it.latitude to it.longitude } +} + +@Suppress("MissingPermission") +suspend fun currentLocation( + context: Context, timeoutMs: Long = 8_000, allowCached: Boolean = true +): Pair? = withContext(Dispatchers.IO) { + if (!locationPermitted(context)) return@withContext null + val manager = locationManager(context) ?: return@withContext null + val providers = try { + locationProviders(manager) + } catch (_: SecurityException) { + return@withContext null + } + val cached = + if (allowCached) providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time } else null + val fresh = coroutineScope { + providers.map { provider -> + async { + withTimeoutOrNull(timeoutMs) { + awaitSingleFix( + manager, provider + ) + } + } + }.mapNotNull { it.await() } + .filter { allowCached || SystemClock.elapsedRealtimeNanos() - it.elapsedRealtimeNanos in 0..30_000_000_000L } + .maxByOrNull { it.time } + } + listOfNotNull(fresh, cached).maxByOrNull { it.time }?.let { it.latitude to it.longitude } +} + +@Suppress("MissingPermission") +private suspend fun awaitSingleFix(manager: LocationManager, provider: String): Location? = + suspendCancellableCoroutine { continuation -> + val settled = AtomicBoolean() + val signal = if (Build.VERSION.SDK_INT >= 30) CancellationSignal() else null + lateinit var listener: android.location.LocationListener + fun release() { + runCatching { manager.removeUpdates(listener) }; signal?.cancel() + } + + fun finish(location: Location?) { + if (settled.compareAndSet(false, true)) { + release(); continuation.resumeWith(Result.success(location)) + } + } + listener = object : android.location.LocationListener { + override fun onLocationChanged(location: Location) { + finish(location) + } + + override fun onProviderDisabled(provider: String) = Unit + override fun onProviderEnabled(provider: String) = Unit + override fun onStatusChanged( + provider: String?, status: Int, extras: android.os.Bundle? + ) = Unit + } + continuation.invokeOnCancellation { settled.set(true); release() } + try { + if (continuation.isActive) { + manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper()) + if (Build.VERSION.SDK_INT >= 30 && continuation.isActive && !settled.get()) manager.getCurrentLocation( + provider, + signal, + { it.run() }) { location -> + finish( + location + ) + } + if (!continuation.isActive || settled.get()) release() + } + } catch (_: Exception) { + finish(null) + } + } diff --git a/core/ui/src/main/java/org/monogram/core/ui/ServiceMessages.kt b/core/ui/src/main/java/org/monogram/core/ui/ServiceMessages.kt index 851d3e10c..e535b8433 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/ServiceMessages.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/ServiceMessages.kt @@ -63,6 +63,20 @@ fun serviceMessageTemplateId(kind: String): Int = when (kind) { "suggested_post_refund" -> R.string.service_suggested_post_refund "gift_ton" -> R.string.service_gift_ton "suggest_birthday" -> R.string.service_suggest_birthday + "star_gift_offer" -> R.string.service_star_gift_offer + "star_gift_offer_declined" -> R.string.service_star_gift_offer_declined + "creator_pending" -> R.string.service_creator_pending + "creator_change" -> R.string.service_creator_change + "noforwards_toggle" -> R.string.service_noforwards_toggle + "noforwards_request" -> R.string.service_noforwards_request + "poll_answer_added" -> R.string.service_poll_answer_added + "poll_answer_removed" -> R.string.service_poll_answer_removed + "managed_bot" -> R.string.service_managed_bot + "community_change" -> R.string.service_community_change + "join_community" -> R.string.service_join_community + "gram_transfer" -> R.string.service_gram_transfer + "ton_connect" -> R.string.service_ton_connect + "paid_token_used" -> R.string.service_paid_token_used else -> R.string.service_unknown } diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt b/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt index bf62d6406..4766385f9 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/AppModalSheet.kt @@ -25,12 +25,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -40,7 +41,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -60,7 +60,6 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp @@ -113,15 +112,17 @@ fun AppModalSheet( content: @Composable ColumnScope.() -> Unit, ) { val density = LocalDensity.current - val configuration = LocalConfiguration.current val view = LocalView.current + val windowBounds = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + (view.context.getSystemService(android.content.Context.WINDOW_SERVICE) as WindowManager) + .currentWindowMetrics.bounds + } else null val overlayWidth = with(density) { - view.rootView.width.takeIf { it > 0 }?.toDp() ?: configuration.screenWidthDp.dp + (windowBounds?.width() ?: view.resources.displayMetrics.widthPixels).toDp() } val overlayHeight = with(density) { - view.rootView.height.takeIf { it > 0 }?.toDp() ?: configuration.screenHeightDp.dp + (windowBounds?.height() ?: view.resources.displayMetrics.heightPixels).toDp() } - val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val scope = rememberCoroutineScope() val dismissRequest = rememberUpdatedState(onDismissRequest) val exit = rememberUpdatedState(exitSpec) @@ -297,7 +298,7 @@ fun AppModalSheet( 0f } alpha = scrimColor.alpha * progress.value.coerceIn(0f, 1f) * - (1f - dragged) * (1f - backProgress.coerceIn(0f, 1f)) + (1f - dragged) * (1f - backProgress.coerceIn(0f, 1f)) } .background(scrimColor.copy(alpha = 1f)) .clickable( @@ -312,7 +313,9 @@ fun AppModalSheet( .align(if (capped) Alignment.BottomEnd else Alignment.BottomCenter) .then( if (capped) { - Modifier.padding(end = 12.dp, bottom = 8.dp).widthIn(max = maxWidth) + Modifier + .padding(end = 12.dp, bottom = 8.dp) + .widthIn(max = maxWidth) } else { Modifier }, @@ -321,10 +324,10 @@ fun AppModalSheet( .onSizeChanged { sheetHeightPx = it.height } .graphicsLayer { translationY = ( - (1f - progress.value) * size.height + - dragOffsetY + - backProgress * size.height - ).coerceAtLeast(0f) + (1f - progress.value) * size.height + + dragOffsetY + + backProgress * size.height + ).coerceAtLeast(0f) if (fadeSheet) alpha = progress.value.coerceIn(0f, 1f) } .clickable( @@ -332,6 +335,7 @@ fun AppModalSheet( interactionSource = remember { MutableInteractionSource() }, onClick = {}, ) + .windowInsetsPadding(WindowInsets.statusBars) .then(modifier), color = containerColor, contentColor = contentColor, @@ -348,7 +352,7 @@ fun AppModalSheet( .fillMaxWidth() .nestedScroll(nestedScroll) .then( - if (padNavigationBars) Modifier.padding(bottom = navBottom) else Modifier, + if (padNavigationBars) Modifier.navigationBarsPadding() else Modifier, ) .then(if (padIme) Modifier.imePadding() else Modifier) .clipToBounds(), @@ -448,10 +452,12 @@ fun SheetPanelHost( private fun TransparentPopupSystemBars() { val view = LocalView.current val lightNav = !isSystemInDarkTheme() - SideEffect { + DisposableEffect(view, lightNav) { val root = view.rootView val params = root.layoutParams as? WindowManager.LayoutParams if (params != null) { + // Compose handles IME insets; Android's default popup panning resets them on focus changes. + params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE params.flags = params.flags or WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { params.setFitInsetsTypes(0) @@ -476,6 +482,7 @@ private fun TransparentPopupSystemBars() { } ctx = ctx.baseContext } + onDispose { } } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/ChatComposerLayout.kt b/core/ui/src/main/java/org/monogram/core/ui/components/ChatComposerLayout.kt index 6b64ae6aa..372d10e94 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/ChatComposerLayout.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/ChatComposerLayout.kt @@ -1,5 +1,9 @@ package org.monogram.core.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -8,15 +12,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.material.icons.outlined.AttachFile @@ -41,17 +41,18 @@ import org.monogram.core.ui.ComposerSlotAction import org.monogram.core.ui.ComposerStyle import org.monogram.core.ui.loading.MonogramBusyIconButton -fun composerSlotIcon(action: ComposerSlotAction, style: ComposerStyle): ImageVector = when (action) { - ComposerSlotAction.None -> Icons.Outlined.Block - ComposerSlotAction.Emoji -> Icons.Outlined.EmojiEmotions - ComposerSlotAction.Stickers -> Icons.Outlined.Interests - ComposerSlotAction.Gifs -> Icons.Outlined.GifBox - ComposerSlotAction.Attach -> - if (style == ComposerStyle.IOS) Icons.Outlined.Add else Icons.Outlined.AttachFile - ComposerSlotAction.Photos -> Icons.Outlined.PhotoLibrary - ComposerSlotAction.File -> Icons.AutoMirrored.Outlined.InsertDriveFile - ComposerSlotAction.Location -> Icons.Outlined.LocationOn -} +fun composerSlotIcon(action: ComposerSlotAction, style: ComposerStyle): ImageVector = + when (action) { + ComposerSlotAction.None -> Icons.Outlined.Block + ComposerSlotAction.Emoji -> Icons.Outlined.EmojiEmotions + ComposerSlotAction.Stickers -> Icons.Outlined.Interests + ComposerSlotAction.Gifs -> Icons.Outlined.GifBox + ComposerSlotAction.Attach -> if (style == ComposerStyle.IOS) Icons.Outlined.Add else Icons.Outlined.AttachFile + + ComposerSlotAction.Photos -> Icons.Outlined.PhotoLibrary + ComposerSlotAction.File -> Icons.AutoMirrored.Outlined.InsertDriveFile + ComposerSlotAction.Location -> Icons.Outlined.LocationOn + } /** Shared geometry for the real composer and its settings preview. The caller owns text input. */ @Composable @@ -70,6 +71,9 @@ fun ChatComposerLayout( onLeft: () -> Unit, onRight: () -> Unit, onSend: () -> Unit, + onSendLongClick: (() -> Unit)? = null, + sendMenu: (@Composable () -> Unit)? = null, + sendOptionsDescription: String? = null, topTrailing: (@Composable BoxScope.() -> Unit)? = null, modifier: Modifier = Modifier, field: @Composable () -> Unit, @@ -95,13 +99,17 @@ fun ChatComposerLayout( } } Row( - modifier = modifier.fillMaxWidth().animateContentSize(tween(220)), + modifier = modifier + .fillMaxWidth() + .animateContentSize(tween(220)), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { if (ios && showLeft) leftButton() Surface( - modifier = Modifier.weight(1f).heightIn(min = 48.dp), + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp), shape = shape, color = animateColorAsState( if (ios) scheme.surface.copy(alpha = 0.58f) else scheme.surfaceContainerHighest, @@ -112,7 +120,9 @@ fun ChatComposerLayout( Row(verticalAlignment = Alignment.Bottom) { if (!ios && showLeft) leftButton() Box( - modifier = Modifier.weight(1f).heightIn(min = 48.dp) + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp) .padding( start = if (ios || !showLeft) 14.dp else 0.dp, end = if (showRight) 0.dp else 12.dp, @@ -126,29 +136,34 @@ fun ChatComposerLayout( } } } - MonogramBusyIconButton( - onClick = onSend, - busy = sending, - enabled = sendEnabled, - modifier = Modifier.size(48.dp), - shape = if (style == ComposerStyle.Material) RoundedCornerShape(16.dp) else CircleShape, - colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors( - containerColor = sendColor, - contentColor = if (sendEnabled) scheme.onPrimary else scheme.onSurfaceVariant, - disabledContainerColor = sendColor, - disabledContentColor = if (sending) scheme.onPrimary else scheme.onSurfaceVariant, - ), - status = sendDescription, - icon = { - Icon( - when { - editing -> Icons.Outlined.Check - ios -> Icons.Outlined.ArrowUpward - else -> Icons.AutoMirrored.Outlined.Send - }, - sendDescription, - ) - }, - ) + Box { + MonogramBusyIconButton( + onClick = onSend, + onLongClick = onSendLongClick, + onLongClickLabel = sendOptionsDescription, + busy = sending, + enabled = sendEnabled, + modifier = Modifier.size(48.dp), + shape = if (style == ComposerStyle.Material) RoundedCornerShape(16.dp) else CircleShape, + colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors( + containerColor = sendColor, + contentColor = if (sendEnabled) scheme.onPrimary else scheme.onSurfaceVariant, + disabledContainerColor = sendColor, + disabledContentColor = if (sending) scheme.onPrimary else scheme.onSurfaceVariant, + ), + status = sendDescription, + icon = { + Icon( + when { + editing -> Icons.Outlined.Check + ios -> Icons.Outlined.ArrowUpward + else -> Icons.AutoMirrored.Outlined.Send + }, + sendDescription, + ) + }, + ) + sendMenu?.invoke() + } } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/MediaPreview.kt b/core/ui/src/main/java/org/monogram/core/ui/components/MediaPreview.kt index dd07427ab..c05dfee25 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/MediaPreview.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/MediaPreview.kt @@ -74,7 +74,9 @@ fun MediaPreviewWindow( isAppearanceLightNavigationBars = false } } - Box(Modifier.fillMaxSize().background(background), content = content) + Box(Modifier + .fillMaxSize() + .background(background), content = content) } } @@ -104,7 +106,8 @@ fun MediaPreviewViewer( val context = LocalContext.current val ready = file?.takeIf { it.exists() && it.length() > 0L } val video = forceVideo || ready?.let(::videoFile) == true - val presentable = video || (ready != null && !gzipFile(ready)) || (uri != null && forceVideo) + val presentable = + loading || failed || video || (ready != null && !gzipFile(ready)) || (uri != null && forceVideo) if (!presentable) { if (asDialog) { @@ -112,7 +115,9 @@ fun MediaPreviewViewer( UnsupportedMediaBody(fallbackTitle, failed, onRetry) } } else { - Box(Modifier.fillMaxSize().background(Color.Black)) { + Box(Modifier + .fillMaxSize() + .background(Color.Black)) { UnsupportedMediaBody(fallbackTitle, failed, onRetry) } } diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/OutgoingStatusMark.kt b/core/ui/src/main/java/org/monogram/core/ui/components/OutgoingStatusMark.kt index f5a671f0a..1c155e760 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/OutgoingStatusMark.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/OutgoingStatusMark.kt @@ -1,5 +1,7 @@ package org.monogram.core.ui.components +import androidx.compose.animation.core.tween +import androidx.compose.ui.Alignment import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -31,11 +33,12 @@ fun OutgoingStatusMark( failed: Boolean = false, ) { AnimatedContent( - targetState = Triple(failed, pending, read), - modifier = modifier, + targetState = Triple(failed, !failed && pending, !failed && !pending && read), + modifier = modifier.size(16.dp), + contentAlignment = Alignment.Center, transitionSpec = { - (fadeIn() + scaleIn(initialScale = 0.72f)) togetherWith - (fadeOut() + scaleOut(targetScale = 0.72f)) + (fadeIn(tween(160)) + scaleIn(tween(220), initialScale = 0.85f)) togetherWith + (fadeOut(tween(100)) + scaleOut(tween(140), targetScale = 0.85f)) }, label = "outgoing-status", ) { (isFailed, isPending, isRead) -> diff --git a/core/ui/src/main/java/org/monogram/core/ui/loading/MonogramBusyControls.kt b/core/ui/src/main/java/org/monogram/core/ui/loading/MonogramBusyControls.kt index 250d75e9b..e080a5e9d 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/loading/MonogramBusyControls.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/loading/MonogramBusyControls.kt @@ -1,41 +1,49 @@ package org.monogram.core.ui.loading import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonShapes -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.ui.semantics.onLongClick as semanticsLongClick @Composable private fun MonogramBusySwap( @@ -48,8 +56,11 @@ private fun MonogramBusySwap( targetState = busy, modifier = modifier, transitionSpec = { - (fadeIn(MonogramMotion.appear) + scaleIn(MonogramMotion.appear, initialScale = MonogramMotion.AppearScale)) togetherWith - (fadeOut(MonogramMotion.disappear) + scaleOut(MonogramMotion.disappear, targetScale = MonogramMotion.DisappearScale)) + (fadeIn(MonogramMotion.appear) + scaleIn( + MonogramMotion.appear, initialScale = MonogramMotion.AppearScale + )) togetherWith (fadeOut(MonogramMotion.disappear) + scaleOut( + MonogramMotion.disappear, targetScale = MonogramMotion.DisappearScale + )) }, contentAlignment = Alignment.Center, label = "monoBusySwap", @@ -144,11 +155,42 @@ fun MonogramBusyIconButton( shape: Shape = IconButtonDefaults.filledShape, colors: IconButtonColors = IconButtonDefaults.filledIconButtonColors(), status: String? = null, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, icon: @Composable () -> Unit, ) { + val interactions = remember { MutableInteractionSource() } + val latestLongClick by rememberUpdatedState(onLongClick) + var held by remember { mutableStateOf(false) } + val timeout = LocalViewConfiguration.current.longPressTimeoutMillis + LaunchedEffect(interactions, enabled, busy, onLongClick != null, timeout) { + if (!enabled || busy || latestLongClick == null) return@LaunchedEffect + var hold: Job? = null + try { + interactions.interactions.collect { event -> + when (event) { + is PressInteraction.Press -> { + held = false + hold?.cancel() + hold = launch { delay(timeout); held = true; latestLongClick?.invoke() } + } + + is PressInteraction.Release -> hold?.cancel() + is PressInteraction.Cancel -> { + hold?.cancel(); held = false + } + } + } + } finally { + hold?.cancel() + } + } FilledIconButton( - onClick = onClick, - modifier = modifier, + onClick = { if (held) held = false else onClick() }, + modifier = if (onLongClick != null && enabled && !busy) modifier.semantics { + semanticsLongClick(onLongClickLabel) { latestLongClick?.invoke(); true } + } else modifier, + interactionSource = interactions, enabled = enabled && !busy, shape = shape, colors = colors, @@ -182,7 +224,7 @@ fun MonogramBusyField( singleLine: Boolean = true, shape: Shape = OutlinedTextFieldDefaults.shape, keyboardType: KeyboardType = KeyboardType.Text, - imeAction: androidx.compose.ui.text.input.ImeAction = androidx.compose.ui.text.input.ImeAction.Default, + imeAction: ImeAction = ImeAction.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, password: Boolean = false, leadingIcon: @Composable (() -> Unit)? = null, diff --git a/core/ui/src/main/res/values-es/strings.xml b/core/ui/src/main/res/values-es/strings.xml index ca3ca50fb..73b3cc001 100644 --- a/core/ui/src/main/res/values-es/strings.xml +++ b/core/ui/src/main/res/values-es/strings.xml @@ -276,4 +276,18 @@ Imagen copiada. Algunas apps pueden no ser compatibles con HDR Por seguridad, esta acción no está disponible justo después de iniciar sesión. Inténtalo tras 24 horas o usa una sesión anterior El proveedor firmó otros datos de acceso. Revisa sus ajustes de aplicaciones privilegiadas para Monogram + %1$s recibió una oferta de regalo + Oferta de regalo rechazada + %2$s será el nuevo propietario + %2$s se convirtió en el nuevo propietario + Se cambió la protección del contenido + Se solicitó cambiar la protección del contenido + Se añadió una respuesta a la encuesta + Se eliminó una respuesta de la encuesta + Se creó el bot %2$s + Se cambió la comunidad + %1$s se unió mediante una comunidad + %1$s transfirió Grams + Solicitud de TON Connect de %2$s + Se usó el token de mensaje de pago diff --git a/core/ui/src/main/res/values-hy/strings.xml b/core/ui/src/main/res/values-hy/strings.xml index 2b80b84c6..7e3651002 100644 --- a/core/ui/src/main/res/values-hy/strings.xml +++ b/core/ui/src/main/res/values-hy/strings.xml @@ -276,4 +276,18 @@ Պատկերը պատճենվեց։ Որոշ հավելվածներ կարող են չաջակցել HDR-ին Անվտանգության նկատառումներով այս գործողությունն անհասանելի է մուտքից անմիջապես հետո։ Փորձեք 24 ժամ անց կամ օգտագործեք ավելի հին նստաշրջան Հավատարմագրերի մատակարարը ստորագրել է մուտքի այլ տվյալներ։ Ստուգեք Monogram-ի արտոնյալ հավելվածների կարգավորումները + %1$s-ը ստացել է նվերի առաջարկ + Նվերի առաջարկը մերժվել է + %2$s-ը կդառնա նոր սեփականատերը + %2$s-ը դարձել է նոր սեփականատերը + Բովանդակության պաշտպանությունը փոխվել է + Բովանդակության պաշտպանության փոփոխման հարցում + Հարցմանը պատասխան ավելացվեց + Հարցումից պատասխան հեռացվեց + Ստեղծվել է %2$s բոտը + Համայնքը փոխվել է + %1$s-ը միացել է համայնքի միջոցով + %1$s-ը փոխանցել է Grams + TON Connect հարցում %2$s-ից + Օգտագործվել է վճարովի հաղորդագրության թոքենը diff --git a/core/ui/src/main/res/values-ja/strings.xml b/core/ui/src/main/res/values-ja/strings.xml index 21f7eabb9..2ce5bca96 100644 --- a/core/ui/src/main/res/values-ja/strings.xml +++ b/core/ui/src/main/res/values-ja/strings.xml @@ -259,4 +259,18 @@ 画像をコピーしました。一部のアプリではHDRに対応していない場合があります 安全のため、ログイン直後はこの操作を行えません。24時間後に再試行するか、以前からログインしている端末を使用してください 認証情報プロバイダが異なるログインデータに署名しました。Monogramの特権アプリ設定を確認してください + %1$sさんがギフトのオファーを受け取りました + ギフトのオファーが辞退されました + %2$sさんが新しい所有者になります + %2$sさんが新しい所有者になりました + コンテンツ保護が変更されました + コンテンツ保護の変更がリクエストされました + 投票に回答が追加されました + 投票から回答が削除されました + ボット「%2$s」が作成されました + コミュニティが変更されました + %1$sさんがコミュニティ経由で参加しました + %1$sさんがGramを送金しました + %2$sからのTON Connectリクエスト + 有料メッセージのトークンが使用されました diff --git a/core/ui/src/main/res/values-pt-rBR/strings.xml b/core/ui/src/main/res/values-pt-rBR/strings.xml index 4df062324..4b3aee8e4 100644 --- a/core/ui/src/main/res/values-pt-rBR/strings.xml +++ b/core/ui/src/main/res/values-pt-rBR/strings.xml @@ -276,4 +276,18 @@ Imagem copiada. Alguns apps podem não ser compatíveis com HDR Por segurança, esta ação não está disponível logo após entrar. Tente após 24 horas ou use uma sessão mais antiga O provedor assinou dados de acesso diferentes. Verifique as configurações de aplicativos privilegiados para o Monogram + %1$s recebeu uma oferta de presente + Oferta de presente recusada + %2$s será o novo proprietário + %2$s se tornou o novo proprietário + A proteção de conteúdo mudou + Foi solicitada uma mudança na proteção de conteúdo + Resposta adicionada à enquete + Resposta removida da enquete + O bot %2$s foi criado + A comunidade mudou + %1$s entrou por uma comunidade + %1$s transferiu Grams + Solicitação TON Connect de %2$s + Token de mensagem paga usado diff --git a/core/ui/src/main/res/values-ru/strings.xml b/core/ui/src/main/res/values-ru/strings.xml index 5d71c304b..34d64c5d3 100644 --- a/core/ui/src/main/res/values-ru/strings.xml +++ b/core/ui/src/main/res/values-ru/strings.xml @@ -310,4 +310,18 @@ Изображение скопировано. Некоторые приложения могут не поддерживать HDR В целях безопасности это действие недоступно сразу после входа. Попробуйте через 24 часа или используйте более старую сессию Провайдер ключей подписал другие данные входа. Проверьте настройки привилегированных приложений для Monogram + %1$s получил(а) предложение подарка + Предложение подарка отклонено + %2$s станет новым владельцем + %2$s стал(а) новым владельцем + Защита содержимого изменена + Запрошено изменение защиты содержимого + В опрос добавлен ответ + Из опроса удалён ответ + Создан бот %2$s + Сообщество изменено + %1$s присоединился(ась) через сообщество + %1$s перевёл(ла) Grams + Запрос TON Connect от %2$s + Использован токен платного сообщения diff --git a/core/ui/src/main/res/values-sk/strings.xml b/core/ui/src/main/res/values-sk/strings.xml index 9b08a2470..0fbade29c 100644 --- a/core/ui/src/main/res/values-sk/strings.xml +++ b/core/ui/src/main/res/values-sk/strings.xml @@ -310,4 +310,18 @@ Obrázok skopírovaný. Niektoré aplikácie nemusia podporovať HDR Z bezpečnostných dôvodov táto akcia nie je dostupná hneď po prihlásení. Skúste to po 24 hodinách alebo použite staršiu reláciu Poskytovateľ podpísal iné prihlasovacie údaje. Skontrolujte jeho nastavenia privilegovaných aplikácií pre Monogram + %1$s dostal(a) ponuku darčeka + Ponuka darčeka bola odmietnutá + %2$s sa stane novým vlastníkom + %2$s sa stal(a) novým vlastníkom + Ochrana obsahu sa zmenila + Požiadaná zmena ochrany obsahu + Do ankety bola pridaná odpoveď + Z ankety bola odstránená odpoveď + Bol vytvorený bot %2$s + Komunita sa zmenila + %1$s sa pridal(a) cez komunitu + %1$s previedol(a) Grams + Žiadosť TON Connect od %2$s + Použitý token platenej správy diff --git a/core/ui/src/main/res/values-tr/strings.xml b/core/ui/src/main/res/values-tr/strings.xml index 29cef4f17..9ea7f8173 100644 --- a/core/ui/src/main/res/values-tr/strings.xml +++ b/core/ui/src/main/res/values-tr/strings.xml @@ -276,4 +276,18 @@ Görüntü kopyalandı. Bazı uygulamalar HDR desteklemeyebilir Güvenliğiniz için giriş yaptıktan hemen sonra bu işlem kullanılamaz. 24 saat sonra yeniden deneyin veya eski bir oturum kullanın Kimlik bilgisi sağlayıcısı farklı giriş verilerini imzaladı. Monogram için ayrıcalıklı uygulama ayarlarını kontrol edin + %1$s bir hediye teklifi aldı + Hediye teklifi reddedildi + %2$s yeni sahip olacak + %2$s yeni sahip oldu + İçerik koruması değişti + İçerik koruması değişikliği istendi + Ankete yanıt eklendi + Anketten yanıt kaldırıldı + %2$s botu oluşturuldu + Topluluk değişti + %1$s bir topluluk aracılığıyla katıldı + %1$s Gram gönderdi + %2$s tarafından TON Connect isteği + Ücretli mesaj belirteci kullanıldı diff --git a/core/ui/src/main/res/values-uk/strings.xml b/core/ui/src/main/res/values-uk/strings.xml index 74bcecbe5..cdd135e5f 100644 --- a/core/ui/src/main/res/values-uk/strings.xml +++ b/core/ui/src/main/res/values-uk/strings.xml @@ -310,4 +310,18 @@ Зображення скопійовано. Деякі застосунки можуть не підтримувати HDR З міркувань безпеки ця дія недоступна відразу після входу. Спробуйте через 24 години або використайте старішу сесію Провайдер ключів підписав інші дані входу. Перевірте налаштування привілейованих застосунків для Monogram + %1$s отримав(ла) пропозицію подарунка + Пропозицію подарунка відхилено + %2$s стане новим власником + %2$s став(ла) новим власником + Захист вмісту змінено + Запитано зміну захисту вмісту + До опитування додано відповідь + З опитування видалено відповідь + Створено бота %2$s + Спільноту змінено + %1$s приєднався(лася) через спільноту + %1$s переказав(ла) Grams + Запит TON Connect від %2$s + Використано токен платного повідомлення diff --git a/core/ui/src/main/res/values-zh/strings.xml b/core/ui/src/main/res/values-zh/strings.xml index 54d51afe7..306950de5 100644 --- a/core/ui/src/main/res/values-zh/strings.xml +++ b/core/ui/src/main/res/values-zh/strings.xml @@ -259,4 +259,18 @@ 已复制图片。部分应用可能不支持 HDR 为确保安全,登录后暂时无法执行此操作。请在24小时后重试,或使用更早登录的会话 凭据提供方签署了不同的登录数据。请检查其 Monogram 特权应用设置 + %1$s 收到了礼物报价 + 礼物报价已被拒绝 + %2$s 将成为新所有者 + %2$s 已成为新所有者 + 内容保护已更改 + 已请求更改内容保护 + 投票中已添加答案 + 投票中已移除答案 + 已创建机器人 %2$s + 社区已更改 + %1$s 通过社区加入 + %1$s 转账了 Grams + 来自 %2$s 的 TON Connect 请求 + 已使用付费消息令牌 diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index ff3fdecf7..7c5fff1ff 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -276,4 +276,18 @@ Image copied. Some apps may not support HDR For your security, this action is unavailable just after logging in. Try again after 24 hours or use an older session The credential provider signed different login data. Check its privileged app settings for Monogram + %1$s received a gift offer + Gift offer declined + %2$s will become the new owner + %2$s became the new owner + Content protection changed + Content protection change requested + Answer added to the poll + Answer removed from the poll + Bot %2$s was created + Community changed + %1$s joined via a community + %1$s transferred Grams + TON Connect request from %2$s + Paid message token used diff --git a/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt b/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt index 78c4826be..9161a796e 100644 --- a/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt +++ b/feature/chats/src/androidTest/java/org/monogram/feature/chats/BridgeCatchupDeviceTest.kt @@ -98,6 +98,7 @@ class BridgeCatchupDeviceTest { canView = true, canSendPlain = true, canSendPhotos = true, + canSendPolls = false, canForward = true, canDeleteOthers = false, emojiStatusDocumentId = null, diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt index 5cee63b6a..8dc841330 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt @@ -146,6 +146,13 @@ internal data class ChatListTexts( val voice: String, val link: String, val checklist: String, + val giveaway: String, + val giveawayResults: String, + val paidMedia: String, + val game: String, + val invoice: String, + val story: String, + val liveStream: String, ) { fun mediaLabel(kind: String): String? = when (kind) { "photo" -> photo @@ -158,6 +165,13 @@ internal data class ChatListTexts( "video_note" -> video "webpage" -> link "todo" -> checklist + "giveaway" -> giveaway + "giveaway_results" -> giveawayResults + "paid_media" -> paidMedia + "game" -> game + "invoice" -> invoice + "story" -> story + "video_stream" -> liveStream else -> chatListMediaLabel(kind) } } @@ -180,6 +194,13 @@ internal fun chatListTexts(): ChatListTexts = ChatListTexts( voice = stringResource(R.string.chats_preview_voice), link = stringResource(R.string.chats_preview_link), checklist = stringResource(R.string.chats_preview_checklist), + giveaway = stringResource(R.string.chats_preview_giveaway), + giveawayResults = stringResource(R.string.chats_preview_giveaway_results), + paidMedia = stringResource(R.string.chats_preview_paid_media), + game = stringResource(R.string.chats_preview_game), + invoice = stringResource(R.string.chats_preview_invoice), + story = stringResource(R.string.chats_preview_story), + liveStream = stringResource(R.string.chats_preview_live_stream), ) /** @@ -469,8 +490,9 @@ internal fun ChatRow( }, ) } - if (chat.unreadMark && unread == 0 && - !showsMentionBadge(chat) && !showsReactionBadge(chat) + if (chat.unreadMark && unread == 0 && !showsMentionBadge(chat) && !showsReactionBadge( + chat + ) ) { UnreadMarkDot( muted = chat.muted, @@ -830,13 +852,9 @@ private fun LastMessageThumb( val context = LocalContext.current val sizePx = with(LocalDensity.current) { ChatRowThumbSize.roundToPx() } AsyncImage( - model = ImageRequest.Builder(context) - .data(local) - .size(sizePx) + model = ImageRequest.Builder(context).data(local).size(sizePx) .memoryCacheKey("${local.absolutePath}:${local.length()}:$sizePx") - .diskCacheKey("${local.absolutePath}:${local.length()}") - .crossfade(false) - .build(), + .diskCacheKey("${local.absolutePath}:${local.length()}").crossfade(false).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -847,11 +865,7 @@ private fun LastMessageThumb( /** Thumbnails only exist for visual media with a cached small preview. */ private fun thumbAvailable(chat: Chat, media: ChatPreviewMedia?): Boolean = - (media == ChatPreviewMedia.Photo || - media == ChatPreviewMedia.Video || - media == ChatPreviewMedia.Gif) && - chat.lastMessageId != 0 && - !chat.lastMediaThumbCacheKey.isNullOrBlank() + (media == ChatPreviewMedia.Photo || media == ChatPreviewMedia.Video || media == ChatPreviewMedia.Gif) && chat.lastMessageId != 0 && !chat.lastMediaThumbCacheKey.isNullOrBlank() private val ChatPreviewMedia.icon: ImageVector get() = when (this) { diff --git a/feature/chats/src/main/res/values-es/strings.xml b/feature/chats/src/main/res/values-es/strings.xml index 58b918ed3..637116ea9 100644 --- a/feature/chats/src/main/res/values-es/strings.xml +++ b/feature/chats/src/main/res/values-es/strings.xml @@ -67,4 +67,11 @@ Menciones no leídas Reacciones no leídas Proxy connection status + Sorteo + Resultados del sorteo + Contenido de pago + Juego + Factura + Historia + Transmisión en vivo diff --git a/feature/chats/src/main/res/values-hy/strings.xml b/feature/chats/src/main/res/values-hy/strings.xml index 8086e2721..439afdc77 100644 --- a/feature/chats/src/main/res/values-hy/strings.xml +++ b/feature/chats/src/main/res/values-hy/strings.xml @@ -67,4 +67,11 @@ Չկարդացված նշումներ Չկարդացված արձագանքներ 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 915a583fc..75e401a0b 100644 --- a/feature/chats/src/main/res/values-ja/strings.xml +++ b/feature/chats/src/main/res/values-ja/strings.xml @@ -65,4 +65,11 @@ 未読のメンション 未読のリアクション 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 131488a85..f9b582c42 100644 --- a/feature/chats/src/main/res/values-pt-rBR/strings.xml +++ b/feature/chats/src/main/res/values-pt-rBR/strings.xml @@ -67,4 +67,11 @@ Menções não lidas Reações não lidas Proxy connection status + Sorteio + Resultados do sorteio + Mídia paga + Jogo + Fatura + Story + Transmissão ao vivo diff --git a/feature/chats/src/main/res/values-ru/strings.xml b/feature/chats/src/main/res/values-ru/strings.xml index 9b9e03e46..299d8891d 100644 --- a/feature/chats/src/main/res/values-ru/strings.xml +++ b/feature/chats/src/main/res/values-ru/strings.xml @@ -71,4 +71,11 @@ Непрочитанные упоминания Непрочитанные реакции 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 17daf7dcf..3f7aaabdc 100644 --- a/feature/chats/src/main/res/values-sk/strings.xml +++ b/feature/chats/src/main/res/values-sk/strings.xml @@ -71,4 +71,11 @@ Neprečítané zmienky Neprečítané reakcie Proxy connection status + Žrebovanie + Výsledky žrebovania + Platený obsah + Hra + Faktúra + Príbeh + Živé vysielanie diff --git a/feature/chats/src/main/res/values-tr/strings.xml b/feature/chats/src/main/res/values-tr/strings.xml index ecac12186..7de7abdbf 100644 --- a/feature/chats/src/main/res/values-tr/strings.xml +++ b/feature/chats/src/main/res/values-tr/strings.xml @@ -67,4 +67,11 @@ Okunmamış bahsetmeler Okunmamış tepkiler Proxy connection status + Çekiliş + Çekiliş sonuçları + Ücretli medya + Oyun + Fatura + Hikâye + Canlı yayın diff --git a/feature/chats/src/main/res/values-uk/strings.xml b/feature/chats/src/main/res/values-uk/strings.xml index 35d084b8d..1a5427d31 100644 --- a/feature/chats/src/main/res/values-uk/strings.xml +++ b/feature/chats/src/main/res/values-uk/strings.xml @@ -71,4 +71,11 @@ Непрочитані згадки Непрочитані реакції 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 ea7004104..c1252cd6d 100644 --- a/feature/chats/src/main/res/values-zh/strings.xml +++ b/feature/chats/src/main/res/values-zh/strings.xml @@ -65,4 +65,11 @@ 未读提及 未读表情回应 Proxy connection status + 抽奖 + 抽奖结果 + 付费内容 + 游戏 + 账单 + 故事 + 直播 diff --git a/feature/chats/src/main/res/values/strings.xml b/feature/chats/src/main/res/values/strings.xml index fdbef3ede..9ebc73697 100644 --- a/feature/chats/src/main/res/values/strings.xml +++ b/feature/chats/src/main/res/values/strings.xml @@ -67,4 +67,11 @@ Unread mentions Unread reactions Proxy connection status + Giveaway + Giveaway results + Paid media + Game + Invoice + Story + Live stream diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt index 5e9ed8d87..c1ac723f7 100644 --- a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/HistoryLoadingDeviceTest.kt @@ -3,6 +3,7 @@ package org.monogram.feature.dialog import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createAndroidComposeRule import com.arkivanov.decompose.DefaultComponentContext @@ -11,6 +12,8 @@ import com.arkivanov.essenty.lifecycle.destroy import com.arkivanov.essenty.lifecycle.resume import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow import org.junit.Assert.* @@ -90,14 +93,93 @@ class HistoryLoadingDeviceTest { } } as MtprotoClient - private fun scenario(cached: Boolean, unread: Boolean, body: (DialogComponent) -> Unit) { + @Test + fun attachmentOnlyComposerOffersSilentSendAndRetainsOutboxStatus() { + val context = + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + val directory = java.io.File( + context.noBackupFilesDir, + "media-composer-fixture-${java.util.UUID.randomUUID()}" + ).apply { mkdirs() } + val photo = java.io.File(directory, "fixture-photo.png") + android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888) + .let { bitmap -> + photo.outputStream() + .use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + } + val cache = OfflineWarmup( + org.monogram.core.database.DatabaseProvider.get(context), + outgoingMediaFiles = org.monogram.core.database.OutgoingMediaFiles( + java.io.File( + directory, + "owned" + ) + ) + ) + try { + scenario(cached = false, unread = false, draftCache = cache) { component -> + compose.runOnUiThread { + component.onAttachMedia( + listOf( + UploadItem( + photo.absolutePath, + "photo", + fileName = photo.name + ) + ) + ) + } + compose.waitUntil(5_000) { component.state.value.pendingAttach.size == 1 } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_send)) + .performSemanticsAction( + androidx.compose.ui.semantics.SemanticsActions.OnLongClick + ) { it() } + compose.onNodeWithText(context.getString(R.string.dialog_send_silent)) + .assertIsDisplayed().performClick() + compose.waitUntil(5_000) { component.state.value.outgoingMediaSends.size == 1 && component.state.value.pendingAttach.isEmpty() } + compose.runOnIdle { + assertTrue(component.state.value.outgoingMediaSends.single().batch.silent) + assertFalse(component.state.value.outgoingMediaSends.single().batch.album) + assertTrue(component.state.value.messages.none { it.pending }) + } + compose.onNodeWithText( + context.getString(R.string.dialog_outbox_ready), + substring = true + ).assertIsDisplayed() + val proof = java.io.File( + context.getExternalFilesDir(null), + "media-composer-silent-proof.png" + ) + proof.outputStream().use { + compose.onRoot().captureToImage().asAndroidBitmap() + .compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + val output = androidx.test.platform.app.InstrumentationRegistry.getArguments() + .getString("additionalTestOutputDir") + if (output != null) { + java.io.File(output).mkdirs() + proof.copyTo(java.io.File(output, proof.name), overwrite = true) + } + } + } finally { + runBlocking { cache.clearAccountCache() }; directory.deleteRecursively() + } + } + + private fun scenario( + cached: Boolean, + unread: Boolean, + draftCache: OfflineWarmup? = null, + body: (DialogComponent) -> Unit, + ) { val lifecycle = LifecycleRegistry() val frames = mutableListOf() val frameListener = android.view.Window.OnFrameMetricsAvailableListener { _, metrics, _ -> frames += metrics.getMetric(android.view.FrameMetrics.TOTAL_DURATION) } lateinit var component: DialogComponent - val cache = object : OfflineWarmup() { + val cache = draftCache ?: object : OfflineWarmup() { override suspend fun chat(chatId: PeerId) = Chat( peer, "Synthetic conversation", lastMessageId = 240, unreadCount = if (unread) 20 else 0, readInboxMaxId = if (unread) 220 else 240, @@ -147,6 +229,114 @@ class HistoryLoadingDeviceTest { } } + @Test + fun restoredDraftTypingReplyAndSilentSyncRecoveryRemainConsistent() { + val drafts = MutableStateFlow( + CloudDraftState( + CloudDraft( + peer, + text = "Draft fixture", + entitiesJson = TextEntities.serialize(listOf(TextEntity("bold", 0, 5))), + replyToMessageId = 17, + date = 100, + noWebpage = true, + ) + ) + ) + var writeGate: CompletableDeferred? = null + val recovered = CompletableDeferred() + val retried = java.util.concurrent.atomic.AtomicInteger() + val cache = object : OfflineWarmup() { + override suspend fun chat(chatId: PeerId) = + Chat(peer, "Synthetic draft", lastMessageId = 240, readInboxMaxId = 240) + + override fun observeCloudDraft(chatId: PeerId, threadId: Int) = drafts + override suspend fun cloudDraftState(chatId: PeerId, threadId: Int) = drafts.value + override suspend fun saveLocalCloudDraft( + draft: CloudDraft, + markdown: Boolean? + ): CloudDraftState { + writeGate?.await() + return drafts.value.localEdit(draft, markdown = markdown).also { drafts.value = it } + } + } + try { + scenario(false, false, cache) { component -> + compose.waitUntil(15_000) { component.state.value.draft == "Draft fixture" && !component.state.value.loading } + compose.onNode(hasSetTextAction() and hasText("Draft fixture")).assertIsDisplayed() + assertEquals(listOf(TextEntity("bold", 0, 5)), component.state.value.draftEntities) + assertEquals(17, component.state.value.draftReplyId) + val pending = CompletableDeferred() + compose.runOnUiThread { writeGate = pending } + compose.onNode(hasSetTextAction()).performTextReplacement("Edited fixture") + compose.waitUntil { component.state.value.draft == "Edited fixture" } + compose.runOnUiThread { + drafts.value = drafts.value.mergeRemote( + CloudDraft( + peer, + text = "Concurrent remote", + date = 200 + ) + ) + } + compose.onNode(hasSetTextAction() and hasText("Edited fixture")).assertIsDisplayed() + pending.complete(Unit) + compose.waitUntil { drafts.value.draft.text == "Edited fixture" } + assertEquals(17, drafts.value.draft.replyToMessageId) + assertTrue(drafts.value.draft.noWebpage) + val cancel = compose.activity.getString(R.string.dialog_cancel) + compose.onNodeWithContentDescription(cancel).performClick() + compose.waitUntil { drafts.value.draft.replyToMessageId == 0 } + compose.runOnUiThread { + scope.launch { + cache.draftSync.retries.collect { + retried.incrementAndGet() + val token = cache.draftSync.begin(peer to 0) + recovered.await() + drafts.value = drafts.value.acknowledge(drafts.value.revision) + cache.draftSync.finish(peer to 0, token) + } + } + val token = cache.draftSync.begin(peer to 0) + cache.draftSync.finish( + peer to 0, token, + org.monogram.core.common.telegram.TelegramError.parse("NETWORK_ERROR") + ) + } + val retry = compose.activity.getString(org.monogram.core.ui.R.string.status_retry) + compose.waitUntil { component.state.value.draftSync.error != null } + compose.onNodeWithTag("draft-sync").assertDoesNotExist() + compose.onNodeWithText(retry).assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(org.monogram.core.ui.R.string.status_network)) + .assertDoesNotExist() + compose.runOnUiThread { cache.draftSync.requestRetry() } + compose.waitUntil { retried.get() == 1 && component.state.value.draftSync.syncing } + compose.onNodeWithTag("draft-sync").assertDoesNotExist() + compose.onNodeWithText(compose.activity.getString(org.monogram.core.ui.R.string.status_syncing)) + .assertDoesNotExist() + compose.onNodeWithText(retry).assertDoesNotExist() + assertEquals("Edited fixture", component.state.value.draft) + recovered.complete(Unit) + compose.waitUntil { !component.state.value.draftSync.syncing && component.state.value.draftSync.error == null } + compose.runOnUiThread { + drafts.value = drafts.value.mergeRemote( + CloudDraft( + peer, + text = "Remote after retry", + date = 300 + ) + ) + } + compose.waitUntil { component.state.value.draft == "Remote after retry" } + compose.onNode(hasSetTextAction() and hasText("Remote after retry")) + .assertIsDisplayed() + } + } finally { + writeGate?.complete(Unit) + recovered.complete(Unit) + } + } + @Test fun uncachedOpeningPagingAndIncomingMessagesRender() = scenario(false, false) { component -> compose.waitUntil(15000) { !component.state.value.loading && component.state.value.messages.isNotEmpty() } diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreenTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreenTest.kt new file mode 100644 index 000000000..1ebc1a1d6 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreenTest.kt @@ -0,0 +1,492 @@ +package org.monogram.feature.dialog.scheduled + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class ScheduledQueueScreenTest { + @get:Rule + val compose = createAndroidComposeRule() + @Test + fun textEditorDismissesWithoutMutationAppliesOnceAndPreservesDraftOnConcurrentChange() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val original = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), + senderId = null, + text = "original editable caption", + date = 2_000_000_000L, + outgoing = true, + mediaKind = "photo" + ), false + ) + var state by mutableStateOf( + ScheduledQueueStore.State( + messages = listOf(original), + fromCache = false + ) + ) + val applied = mutableListOf() + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen(state, {}, {}, {}, {}, onEdit = { expected, text, _ -> + assertEquals(original, expected); applied += text + state = state.copy( + messages = listOf( + original.copy( + message = original.message.copy(text = text) + ) + ) + ) + }) + } + } + fun open() { + compose.onNodeWithContentDescription(context.getString(R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(R.string.dialog_edit)).performClick() + } + open() + compose.onNode(hasSetTextAction()).performTextReplacement("local caption") + compose.onNodeWithContentDescription(context.getString(R.string.dialog_markdown_close)) + .performClick() + compose.runOnIdle { assertTrue(applied.isEmpty()) } + open() + compose.onNode(hasSetTextAction()).assertTextContains("original editable caption") + compose.onNode(hasSetTextAction()).performTextReplacement("new caption") + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)).performClick() + compose.runOnIdle { assertEquals(listOf("new caption"), applied) } + open() + compose.onNode(hasSetTextAction()).performTextReplacement("keep local draft") + compose.runOnIdle { + state = + state.copy(messages = listOf(original.copy(message = original.message.copy(text = "remote caption")))) + } + compose.onNodeWithText(context.getString(R.string.dialog_scheduled_edit_changed)) + .assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)) + .assertIsNotEnabled() + compose.onNode(hasSetTextAction()).assertTextContains("keep local draft") + compose.runOnIdle { assertEquals(1, applied.size) } + } + + @Test + fun unknownQueueContentDoesNotExposeEditing() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val queued = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), senderId = null, + text = "unknown formatting fixture", date = 2_000_000_000L, outgoing = true + ) + ) + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen( + ScheduledQueueStore.State( + messages = listOf(queued), + fromCache = false + ), + {}, + {}, + {}, + {}, + onEdit = { _, _, _ -> error("unknown content must not be edited") }) + } + } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(R.string.dialog_edit)).assertDoesNotExist() + } + + @Test + fun editorRestoresLocalTextAndFormattingAndWaitsForMatchingQueueSnapshot() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val original = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), + senderId = null, + text = "original caption", + date = 2_000_000_000L, + outgoing = true, + mediaKind = "photo", + entities = listOf(TextEntity("bold", 0, 8)) + ), false + ) + var state by mutableStateOf( + ScheduledQueueStore.State( + messages = listOf(original), + fromCache = false + ) + ) + var saved: List? = null + val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(compose) + restoration.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen(state, {}, {}, {}, {}, onEdit = { _, text, entities -> + assertEquals("original caption saved", text); saved = entities + }) + } + } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(R.string.dialog_edit)).performClick() + compose.onNode(hasSetTextAction()) + .performTextInputSelection(androidx.compose.ui.text.TextRange(16)) + compose.onNode(hasSetTextAction()).performTextInput(" saved") + compose.runOnIdle { state = state.copy(messages = emptyList(), fromCache = true) } + restoration.emulateSavedInstanceStateRestore() + compose.onNode(hasSetTextAction()).assertTextContains("original caption saved") + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)) + .assertIsNotEnabled() + compose.runOnIdle { state = state.copy(messages = listOf(original), fromCache = false) } + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)).assertIsEnabled() + .performClick() + compose.runOnIdle { assertEquals(listOf(TextEntity("bold", 0, 8)), saved) } + } + + @Test + fun failedEditsKeepLocalTextAndLateAcknowledgementsDoNotConsumeNewerEdits() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val original = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), senderId = null, + text = "original", date = 2_000_000_000L, outgoing = true + ), false + ) + var state by mutableStateOf( + ScheduledQueueStore.State( + messages = listOf(original), + fromCache = false + ) + ) + var attempts = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen( + state, + {}, + { + state = state.copy( + error = null, + needsRefresh = false, + uncertainIds = emptySet() + ) + }, + {}, + {}, + onEdit = { _, _, _ -> attempts++; state = state.copy(mutatingId = 8) }) + } + } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(context.getString(R.string.dialog_edit)).performClick() + compose.onNode(hasSetTextAction()).performTextReplacement("attempted edit") + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)).performClick() + compose.runOnIdle { + state = state.copy( + mutatingId = null, + needsRefresh = true, + uncertainIds = setOf(8), + error = TelegramError.parse("RPC 500: INTERNAL") + ) + } + compose.onNode(hasSetTextAction()).assertTextContains("attempted edit") + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)) + .assertIsNotEnabled() + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.runOnIdle { assertEquals(1, attempts) } + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)).performClick() + compose.onNode(hasSetTextAction()).performTextReplacement("newer local draft") + compose.runOnIdle { + assertEquals(2, attempts) + state = state.copy( + mutatingId = null, + messages = listOf(original.copy(message = original.message.copy(text = "attempted edit"))) + ) + } + compose.onNode(hasSetTextAction()).assertTextContains("newer local draft") + compose.onNodeWithText(context.getString(R.string.dialog_scheduled_edit_changed)) + .assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.dialog_markdown_apply)) + .assertIsNotEnabled() + } + + @Test + fun photoPreviewOpensFromCacheShowsLoadingAndRetryAndClosesWithoutMutations() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val queued = ScheduledMessage( + Message( + MessageId(PeerId(7), 37), senderId = null, + text = "synthetic preview caption", date = 2_000_000_000L, outgoing = true, + mediaKind = "photo", mediaCacheKey = "scheduled-preview-fixture" + ) + ) + val file = java.io.File(context.cacheDir, "scheduled-preview-fixture.png") + val bitmap = + android.graphics.Bitmap.createBitmap(16, 16, android.graphics.Bitmap.Config.ARGB_8888) + bitmap.eraseColor(android.graphics.Color.BLUE) + file.outputStream() + .use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + var state by mutableStateOf( + ScheduledQueueStore.State( + messages = listOf(queued), + fromCache = true + ) + ) + var retries = 0 + var closes = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen( + state, + {}, + {}, + onPreview = { id -> + assertEquals(37, id) + state = state.copy( + preview = ScheduledQueueStore.PreviewState( + queued, + loading = true + ) + ) + }, + onClosePreview = { closes++; state = state.copy(preview = null) }, + onRetryPreview = { + retries++; state = state.copy( + preview = ScheduledQueueStore.PreviewState( + queued, + loading = true + ) + ) + }) + } + } + try { + compose.onNode(hasContentDescription(context.getString(R.string.dialog_media_photo)) and hasClickAction()) + .performClick() + val close = context.getString(org.monogram.core.ui.R.string.media_preview_close) + compose.onNodeWithContentDescription(close).assertIsDisplayed() + compose.runOnIdle { + state = state.copy( + preview = ScheduledQueueStore.PreviewState( + queued, + error = TelegramError.parse("RPC 500: INTERNAL") + ) + ) + } + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.media_video_retry)) + .performClick() + compose.runOnIdle { + assertEquals(1, retries); state = + state.copy(preview = ScheduledQueueStore.PreviewState(queued, file = file)) + } + compose.onNode(hasText("synthetic preview caption") and hasAnyAncestor(isDialog())) + .assertIsDisplayed() + compose.onNodeWithContentDescription(close).performClick() + compose.waitForIdle() + compose.runOnIdle { assertEquals(1, closes); assertNull(state.preview) } + } finally { + file.delete() + } + } + + @Test + fun cachedRowsSurviveLoadingAndErrorsAndRetryAndBackAreExplicit() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val queued = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), senderId = null, + text = "synthetic queued text", date = 2_000_000_000L, outgoing = true + ) + ) + var state by mutableStateOf(ScheduledQueueStore.State(messages = listOf(queued))) + var refreshes = 0 + var backs = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen(state, { backs++ }, { refreshes++ }) + } + } + compose.onNodeWithText("synthetic queued text").assertIsDisplayed() + compose.onNodeWithContentDescription(context.getString(R.string.dialog_refresh)) + .performClick() + compose.runOnIdle { assertEquals(1, refreshes); state = state.copy(loading = true) } + compose.onNodeWithText("synthetic queued text").assertIsDisplayed() + compose.runOnIdle { + state = state.copy(loading = false, error = TelegramError.parse("RPC 500: INTERNAL")) + } + compose.onNodeWithText("synthetic queued text").assertIsDisplayed() + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.onNodeWithContentDescription(context.getString(R.string.dialog_back)).performClick() + compose.runOnIdle { assertEquals(2, refreshes); assertEquals(1, backs) } + } + + @Test + fun sendNowAndCancellationRequireConfirmationAndUncertainRowsDisableActions() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val queued = ScheduledMessage( + Message( + MessageId(PeerId(7), 8), senderId = null, + text = "synthetic action fixture", date = 2_000_000_000L, outgoing = true + ) + ) + var state by mutableStateOf( + ScheduledQueueStore.State( + messages = listOf(queued), + fromCache = false + ) + ) + val sent = mutableListOf() + val cancelled = mutableListOf() + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen(state, {}, {}, { sent += it }, { cancelled += it }) + } + } + val actions = context.getString(R.string.dialog_scheduled_actions) + val send = context.getString(R.string.dialog_scheduled_send_now) + val cancel = context.getString(R.string.dialog_scheduled_cancel) + fun choose(label: String) { + compose.onNodeWithContentDescription(actions).performClick() + compose.onNodeWithText(label).performClick() + compose.onNode(isDialog()).assertExists() + } + choose(send) + compose.onNodeWithText(context.getString(R.string.dialog_cancel)).performClick() + compose.runOnIdle { assertTrue(sent.isEmpty()); assertTrue(cancelled.isEmpty()) } + choose(send) + compose.onNode(hasText(send) and hasClickAction() and hasAnyAncestor(isDialog())) + .performClick() + compose.runOnIdle { assertEquals(listOf(8), sent); assertTrue(cancelled.isEmpty()) } + choose(cancel) + compose.onNodeWithText(context.getString(R.string.dialog_cancel)).performClick() + compose.runOnIdle { assertTrue(cancelled.isEmpty()) } + choose(cancel) + compose.onNode(hasText(cancel) and hasClickAction() and hasAnyAncestor(isDialog())) + .performClick() + compose.runOnIdle { + assertEquals(listOf(8), cancelled); state = state.copy(uncertainIds = setOf(8)) + } + compose.onNodeWithContentDescription(actions).assertIsNotEnabled() + compose.onNodeWithText(context.getString(R.string.dialog_scheduled_check_result)) + .assertIsDisplayed() + } + + @Test + fun reschedulePickerRestoresAndDismissalNeverChangesTheQueue() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val original = System.currentTimeMillis() / 1000 + 3600 + val state = ScheduledQueueStore.State( + fromCache = false, messages = listOf( + ScheduledMessage( + Message( + MessageId(PeerId(7), 8), + senderId = null, + text = "synthetic reschedule", + date = original, + outgoing = true + ) + ) + ) + ) + val changes = mutableListOf>() + val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(compose) + restoration.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen(state, {}, {}, {}, {}, { id, date -> changes += id to date }) + } + } + val label = context.getString(R.string.dialog_scheduled_reschedule) + fun openPicker() { + compose.onNodeWithContentDescription(context.getString(R.string.dialog_scheduled_actions)) + .performClick() + compose.onNodeWithText(label).performClick() + } + openPicker() + restoration.emulateSavedInstanceStateRestore() + val button = SemanticsMatcher.expectValue( + androidx.compose.ui.semantics.SemanticsProperties.Role, + androidx.compose.ui.semantics.Role.Button + ) + compose.onNode(hasText(label) and button).performScrollTo().assertIsEnabled() + compose.onNode(hasText(context.getString(R.string.dialog_cancel)) and button) + .performScrollTo().performClick() + compose.runOnIdle { assertTrue(changes.isEmpty()) } + openPicker() + compose.onNode(hasText(label) and button).performScrollTo().performClick() + compose.runOnIdle { assertEquals(listOf(8 to ((original / 60) * 60).toInt()), changes) } + } + + @Test + fun queuedListScrollRestoresAcrossSavedState() { + val restoration = androidx.compose.ui.test.junit4.StateRestorationTester(compose) + val state = ScheduledQueueStore.State(fromCache = false, messages = (1..60).map { id -> + ScheduledMessage( + Message( + MessageId(PeerId(7), id), senderId = null, + text = "synthetic queued item $id", date = 2_000_000_000L + id, outgoing = true + ) + ) + }) + restoration.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen( + state, + {}, + {}) + } + } + compose.onNode(hasScrollToIndexAction()).performScrollToIndex(49) + compose.onNodeWithText("synthetic queued item 50").assertIsDisplayed() + restoration.emulateSavedInstanceStateRestore() + compose.onNodeWithText("synthetic queued item 50").assertIsDisplayed() + } + + @Test + fun emptyStateAndOnlineSentinelUseLocalizedLabels() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var state by mutableStateOf(ScheduledQueueStore.State(fromCache = false)) + compose.setContent { + MonogramTheme(dynamicColor = false) { + ScheduledQueueScreen( + state, + {}, + {}) + } + } + compose.onNodeWithText(context.getString(R.string.dialog_scheduled_empty)) + .assertIsDisplayed() + compose.runOnIdle { + state = state.copy( + messages = listOf( + ScheduledMessage( + Message( + MessageId(PeerId(7), 8), + senderId = null, + text = "online fixture", + date = 0x7ffffffeL, + outgoing = true + ) + ) + ) + ) + } + compose.onNodeWithText(context.getString(R.string.dialog_scheduled_empty)) + .assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.dialog_schedule_when_online)) + .assertIsDisplayed() + compose.onNodeWithText("online fixture").assertIsDisplayed() + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/AttachDockInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/AttachDockInteractionTest.kt new file mode 100644 index 000000000..8ef133f02 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/AttachDockInteractionTest.kt @@ -0,0 +1,78 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.Density +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class AttachDockInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun allActionsRemainVisibleWithLargeLocalizedLabels() { + val configuration = + android.content.res.Configuration(compose.activity.resources.configuration).apply { + setLocale(java.util.Locale("ru")) + fontScale = 1.5f + } + val context = compose.activity.createConfigurationContext(configuration) + val calls = mutableListOf() + compose.setContent { + val density = LocalDensity.current + CompositionLocalProvider( + LocalConfiguration provides configuration, + LocalResources provides context.resources, + LocalDensity provides Density(density.density, 1.5f) + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + Surface { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + AttachDock( + AttachBody.Gallery, + true, + true, + { calls += "gallery" }, + { calls += "file" }, + { calls += "location" }, + onCreatePoll = { calls += "poll" }, + onCreateChecklist = { calls += "checklist" }) + } + } + } + } + } + listOf( + R.string.dialog_attach_gallery, + R.string.dialog_attach_file, + R.string.dialog_location_action, + R.string.dialog_poll_create, + R.string.dialog_checklist_create + ).forEach { + compose.onNodeWithContentDescription(context.getString(it)).assertIsDisplayed() + .performClick() + } + compose.runOnIdle { + assertEquals( + listOf("gallery", "file", "location", "poll", "checklist"), + calls + ) + } + compose.waitForIdle() + composerUiProof("attach-actions.png") + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistCreationInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistCreationInteractionTest.kt new file mode 100644 index 000000000..ad0ba79b2 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistCreationInteractionTest.kt @@ -0,0 +1,87 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.StateRestorationTester +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class ChecklistCreationInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun checklistBoundsPermissionsAndRestorationPreserveFormUntilAdmission() { + val restore = StateRestorationTester(compose) + val context = compose.activity + var admission by mutableStateOf(null) + var failure by mutableStateOf(null) + val calls = mutableListOf>() + var dismissed = 0 + restore.setContent { + MonogramTheme(dynamicColor = false) { + MessageCreationSheet( + MessageCreationKind.Checklist, admission, false, failure, + { _, _ -> throw AssertionError("unexpected poll send") }, + { spec, options -> + calls += spec to options; failure = TelegramError.parse("RPC 500: INTERNAL") + }, { dismissed++ }) + } + } + val send = context.getString(R.string.dialog_send) + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + fun field(label: String) = compose.onNode(hasSetTextAction() and hasText(label)) + val title = context.getString(R.string.dialog_checklist_title) + val first = context.getString(R.string.dialog_checklist_item, 1) + field(title).performScrollTo().performTextInput("x".repeat(33)) + field(first).performScrollTo().performTextInput("First task") + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + field(title).performScrollTo().performTextReplacement("Synthetic list") + field(first).performScrollTo().performTextReplacement("x".repeat(65)) + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + field(first).performScrollTo().performTextReplacement("First task") + compose.onNodeWithText(context.getString(R.string.dialog_checklist_add)).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + field(context.getString(R.string.dialog_checklist_item, 2)).performScrollTo() + .performTextInput("Second task") + compose.onNodeWithText(context.getString(R.string.dialog_checklist_allow_append)) + .performScrollTo().performSemanticsAction(SemanticsActions.OnClick) { it() } + restore.emulateSavedInstanceStateRestore() + field("Synthetic list").performScrollTo().assertTextContains("Synthetic list") + compose.onNodeWithText(context.getString(R.string.dialog_checklist_allow_append)) + .performScrollTo().assertIsOn() + compose.onNodeWithText(context.getString(R.string.dialog_checklist_allow_complete)) + .assertIsOff() + compose.waitForIdle() + composerUiProof("checklist-sheet.png") + compose.onNodeWithText(send).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.runOnIdle { + assertEquals(1, calls.size); assertEquals(0, dismissed) + assertEquals( + ChecklistSendSpec( + "Synthetic list", + listOf("First task", "Second task"), + othersCanAppend = true + ), calls.single().first + ) + } + compose.onNodeWithText(send).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.runOnIdle { + assertEquals(2, calls.size); assertEquals( + calls.first(), + calls.last() + ); admission = 123L + } + compose.waitUntil { dismissed == 1 } + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistMutationInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistMutationInteractionTest.kt new file mode 100644 index 000000000..b55a13728 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ChecklistMutationInteractionTest.kt @@ -0,0 +1,171 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class ChecklistMutationInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + private fun message( + outgoing: Boolean = false, + complete: Boolean = true, + append: Boolean = true + ) = Message( + MessageId(PeerId(7), 37), null, null, 100, outgoing = outgoing, mediaKind = "todo", + checklist = Checklist( + "Synthetic checklist", othersCanComplete = complete, othersCanAppend = append, + entities = listOf(TextEntity("italic", 0, 19)), + items = listOf( + ChecklistItem( + 1, + "Fixture item", + entities = listOf(TextEntity("bold", 0, 7)) + ) + ) + ), + ) + + @Test + fun pendingToggleRetainsValueAndDisablesToggleAndAppendUntilServerResult() { + var model by mutableStateOf(message()) + var busy by mutableStateOf(false) + var toggles = 0; + var appends = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + model, + null, + showSender = false, + joinsMessageAbove = false, + joinsMessageBelow = false, + mediaRepository = null, + checklistBusy = busy, + onToggleChecklist = { toggles++; busy = true }, + onAddChecklistItem = { _, _ -> appends++ }) + } + } + val add = + InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_checklist_add) + + fun assertRichItem() { + val title = compose.onNodeWithText("Synthetic checklist") + .fetchSemanticsNode().config[androidx.compose.ui.semantics.SemanticsProperties.Text].single() + assertTrue(title.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic }) + val text = compose.onNodeWithText("Fixture item") + .fetchSemanticsNode().config[androidx.compose.ui.semantics.SemanticsProperties.Text].single() + assertTrue(text.spanStyles.any { it.start == 0 && it.end == 7 && it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold }) + } + assertRichItem() + compose.onNodeWithText("Fixture item").assertIsOff().performClick() + compose.onNodeWithText("Fixture item").assertIsOff().assertIsNotEnabled() + compose.onNodeWithText(add).assertIsDisplayed().assertIsNotEnabled() + compose.runOnIdle { + assertEquals(1, toggles); assertEquals(0, appends) + model = model.copy(checklist = model.checklist!!.let { + it.copy(items = it.items.map { item -> + item.copy(done = true) + }) + }) + busy = false + } + assertRichItem() + compose.onNodeWithText("Fixture item").assertIsOn().assertIsEnabled() + compose.onNodeWithText( + InstrumentationRegistry.getInstrumentation().targetContext.getString( + R.string.dialog_checklist_progress, 1, 1 + ) + ).assertIsDisplayed() + composerUiProof("checklist-message-completed.png") + compose.onNodeWithText(add).assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, appends) } + } + + @Test + fun taskOverflowKeepsEditingAndConfirmedDeletionAvailable() { + val model = message(outgoing = true).let { + it.copy( + checklist = it.checklist!!.copy( + items = listOf(ChecklistItem(1, "First task"), ChecklistItem(2, "Second task")) + ) + ) + } + var edited: Int? = null + var removed: Int? = null + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + model, + null, + showSender = false, + joinsMessageAbove = false, + joinsMessageBelow = false, + mediaRepository = null, + onToggleChecklist = {}, + onEditChecklistItem = { edited = it }, + onRemoveChecklistItem = { removed = it }) + } + } + val context = compose.activity + val more = context.getString(org.monogram.core.ui.R.string.media_more_actions) + compose.onAllNodesWithContentDescription(more)[0].performClick() + compose.onNodeWithText(context.getString(R.string.dialog_checklist_edit_item, "First task")) + .performClick() + compose.runOnIdle { assertEquals(1, edited); assertNull(removed) } + compose.onAllNodesWithContentDescription(more)[0].performClick() + compose.onNodeWithText( + context.getString( + R.string.dialog_checklist_remove_item, + "First task" + ) + ).performClick() + compose.runOnIdle { assertNull(removed) } + compose.onNodeWithText(context.getString(R.string.dialog_delete)).performClick() + compose.runOnIdle { assertEquals(1, removed) } + composerUiProof("checklist-message-actions.png") + } + + @Test + fun accessPendingAndExhaustedIdsBlockChecklistActions() { + var model by mutableStateOf(message(complete = false, append = false)) + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + model, + null, + showSender = false, + joinsMessageAbove = false, + joinsMessageBelow = false, + mediaRepository = null, + onToggleChecklist = { error("forbidden toggle") }, + onAddChecklistItem = { _, _ -> error("forbidden append") }) + } + } + val add = + InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dialog_checklist_add) + compose.onNodeWithText("Fixture item").assertIsNotEnabled() + compose.onNodeWithText(add).assertDoesNotExist() + compose.runOnIdle { model = message(outgoing = true).copy(pending = true) } + compose.onNodeWithText("Fixture item").assertIsNotEnabled() + compose.onNodeWithText(add).assertDoesNotExist() + compose.runOnIdle { + model = message(outgoing = true).let { + it.copy( + checklist = it.checklist!!.copy( + items = listOf(ChecklistItem(Int.MAX_VALUE, "Last item")) + ) + ) + } + } + compose.onNodeWithText(add).assertDoesNotExist() + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/LocationPickerSheetTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/LocationPickerSheetTest.kt index f6f00d196..8d760c7d5 100644 --- a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/LocationPickerSheetTest.kt +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/LocationPickerSheetTest.kt @@ -1,11 +1,15 @@ package org.monogram.feature.dialog.ui +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.fillMaxSize import android.graphics.Bitmap import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.test.platform.app.InstrumentationRegistry import java.io.File import org.junit.Assert.assertTrue @@ -28,16 +32,38 @@ class LocationPickerSheetTest { private var sent = 0 private var dismissed = 0 - private fun render(locating: Boolean = false, place: GeoPlace? = this.place) { + private var sharedPeriod = 0 + private fun render( + locating: Boolean = false, + place: GeoPlace? = this.place, + live: Boolean = false + ) { + val config = + android.content.res.Configuration(compose.activity.resources.configuration).apply { + if (live) { + setLocale(java.util.Locale("ru")); fontScale = 1.5f + } + } + val localized = compose.activity.createConfigurationContext(config) compose.setContent { - MonogramTheme(dynamicColor = false) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalResources provides localized.resources, + androidx.compose.ui.platform.LocalConfiguration provides config, + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + localized.resources.displayMetrics.density, + config.fontScale + ), + ) { + MonogramTheme(darkTheme = live, dynamicColor = false) { LocationPickerSheet( place = place, locating = locating, mapTiles = null, onSend = { sent++ }, onDismiss = { dismissed++ }, + onShareLive = if (live) ({ sharedPeriod = it }) else null, ) + } } } compose.waitForIdle() @@ -45,17 +71,39 @@ class LocationPickerSheetTest { @Test fun showsTitleCoordinatesAndBothActions() { - render() - compose.onNodeWithText("Location").assertIsDisplayed() + render(live = true) + evidence("location-picker-initial.png") + val context = compose.activity.createConfigurationContext( + android.content.res.Configuration(compose.activity.resources.configuration) + .apply { setLocale(java.util.Locale("ru")) }) + val title = + context.getString(org.monogram.feature.dialog.R.string.dialog_location_pick_title) + val send = context.getString(org.monogram.feature.dialog.R.string.dialog_location_send_this) + val cancel = context.getString(org.monogram.feature.dialog.R.string.dialog_cancel) + val share = + context.getString(org.monogram.feature.dialog.R.string.dialog_location_share_live) + compose.onAllNodesWithText(title)[0].assertIsDisplayed() compose.onNodeWithText("55.7512, 37.6184").assertIsDisplayed() - compose.onNodeWithText("Send this location").assertIsDisplayed() - compose.onNodeWithText("Cancel").assertIsDisplayed() + compose.onNodeWithText(send).assertIsDisplayed() + compose.onNodeWithText(context.getString(org.monogram.feature.dialog.R.string.dialog_location_live)) + .performClick() + compose.onNodeWithText("8 часов").performScrollTo().performClick() + evidence("location-picker-live.png") + compose.onNodeWithText(share).performScrollTo().performClick() + compose.runOnIdle { + org.junit.Assert.assertEquals( + 28800, + sharedPeriod + ); org.junit.Assert.assertEquals(0, sent) + } + compose.onNodeWithText(cancel).performScrollTo().assertIsDisplayed() - compose.onNodeWithText("Send this location").performClick() + compose.onAllNodesWithText(title)[1].performScrollTo().performClick() + compose.onNodeWithText(send).performScrollTo().performClick() compose.waitForIdle() assertTrue("Send did not confirm", sent == 1) - compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText(cancel).performScrollTo().performClick() compose.waitForIdle() assertTrue("Cancel did not dismiss", dismissed == 1) @@ -65,10 +113,151 @@ class LocationPickerSheetTest { @Test fun readingLocationKeepsTheActionDisabled() { render(locating = true, place = null) - compose.onNodeWithText("Finding your location…").assertIsDisplayed() - compose.onNodeWithText("Send this location").performClick() + compose.onNodeWithText(compose.activity.getString(org.monogram.feature.dialog.R.string.dialog_location_locating)) + .assertIsDisplayed() + compose.onNodeWithText(compose.activity.getString(org.monogram.feature.dialog.R.string.dialog_location_send_this)) + .performClick() compose.waitForIdle() assertTrue("a location with no fix must not be sent", sent == 0) + val context = compose.activity + org.junit.Assert.assertEquals( + android.content.pm.PackageManager.PERMISSION_DENIED, + context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) + ) + org.junit.Assert.assertEquals( + android.content.pm.PackageManager.PERMISSION_DENIED, + context.checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) + ) + kotlinx.coroutines.runBlocking { + org.junit.Assert.assertNull(kotlinx.coroutines.withTimeout(2_000) { + org.monogram.core.ui.currentLocation(context, allowCached = false) + }) + } + org.junit.Assert.assertNull(org.monogram.core.ui.lastKnownLocation(context)) + } + + @Test + fun liveSessionNoticesKeepStopUncertaintyAndRequireExplicitResume() { + val config = + android.content.res.Configuration(compose.activity.resources.configuration).apply { + setLocale(java.util.Locale("ru")); fontScale = 1.5f + } + val context = compose.activity.createConfigurationContext(config) + val id = org.monogram.core.models.MessageId(org.monogram.core.models.PeerId(7), 37) + val expiry = System.currentTimeMillis() / 1000 + 900 + var phase by androidx.compose.runtime.mutableStateOf(org.monogram.core.models.LiveLocationPhase.PAUSED) + var busy by androidx.compose.runtime.mutableStateOf(false) + var failure by androidx.compose.runtime.mutableStateOf( + null + ) + var resumed = 0; + var stopped = 0; + var opened = 0 + compose.setContent { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalResources provides context.resources, + androidx.compose.ui.platform.LocalConfiguration provides config, + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + context.resources.displayMetrics.density, + 1.5f + ), + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + androidx.compose.material3.Surface { + androidx.compose.foundation.layout.Box( + androidx.compose.ui.Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = androidx.compose.ui.Alignment.BottomCenter + ) { + LiveLocationNotices( + listOf( + org.monogram.core.models.LiveLocationSession( + id, + 123, + expiry, + phase + ) + ), + emptyMap(), + failure, + if (busy) setOf(37) else emptySet(), + emptySet(), + { org.junit.Assert.assertEquals(37, it); resumed++ }, + { org.junit.Assert.assertEquals(37, it); stopped++ }, + { org.junit.Assert.assertEquals(37, it); opened++ }) + } + } + } + } + } + val resume = context.getString(org.monogram.feature.dialog.R.string.dialog_location_resume) + val stop = context.getString(org.monogram.feature.dialog.R.string.dialog_location_stop) + val paused = context.getString(org.monogram.feature.dialog.R.string.dialog_location_paused) + compose.onNodeWithText(paused, substring = true).assertIsDisplayed() + assertTrue( + "Live controls must stay compact at large font scale", + compose.onNodeWithTag("live-location-37") + .fetchSemanticsNode().boundsInRoot.height <= 96 * context.resources.displayMetrics.density + ) + compose.onNodeWithText( + context.getString( + org.monogram.feature.dialog.R.string.dialog_location_until, + "" + ), substring = true + ).performClick() + compose.onNodeWithContentDescription(resume).performClick() + compose.runOnIdle { + org.junit.Assert.assertEquals( + 1, + resumed + ); org.junit.Assert.assertEquals(1, opened) + } + compose.onNodeWithText(paused, substring = true).assertIsDisplayed() + compose.onNodeWithContentDescription(stop).assertIsDisplayed() + evidence("live-location-paused.png") + compose.onNodeWithContentDescription(stop).performClick() + compose.runOnIdle { + org.junit.Assert.assertEquals(1, stopped); phase = + org.monogram.core.models.LiveLocationPhase.STOPPING; busy = true + } + compose.onNodeWithTag("live-location-stop-37").assertIsNotEnabled() + compose.onNodeWithContentDescription(resume).assertDoesNotExist() + compose.runOnIdle { + phase = org.monogram.core.models.LiveLocationPhase.UNCERTAIN_STOP; busy = false + } + compose.onNodeWithText( + context.getString(org.monogram.feature.dialog.R.string.dialog_location_stop_uncertain), + substring = true + ).assertIsDisplayed() + compose.onNodeWithContentDescription(stop).assertIsDisplayed() + evidence("live-location-uncertain.png") + compose.onNodeWithContentDescription(stop).performClick() + compose.runOnIdle { + org.junit.Assert.assertEquals( + 2, + stopped + ); org.junit.Assert.assertEquals(1, resumed) + } + compose.onNodeWithText( + context.getString(org.monogram.feature.dialog.R.string.dialog_location_stop_uncertain), + substring = true + ).assertIsDisplayed() + compose.runOnIdle { + phase = org.monogram.core.models.LiveLocationPhase.ACTIVE; failure = + org.monogram.core.common.telegram.TelegramError.parse("RPC 500: INTERNAL") + } + compose.onNodeWithContentDescription(resume).performClick() + compose.runOnIdle { + org.junit.Assert.assertEquals( + 2, + resumed + ); org.junit.Assert.assertEquals(2, stopped); phase = + org.monogram.core.models.LiveLocationPhase.STOPPED + } + compose.onNodeWithContentDescription(stop).assertDoesNotExist() + compose.runOnIdle { phase = org.monogram.core.models.LiveLocationPhase.EXPIRED } + compose.onNodeWithContentDescription(stop).assertDoesNotExist() } /** Visual evidence for review; the file lands in the test app's cache dir. */ diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/OutgoingBubbleStatusTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/OutgoingBubbleStatusTest.kt new file mode 100644 index 000000000..810c0881c --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/OutgoingBubbleStatusTest.kt @@ -0,0 +1,164 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import org.monogram.core.ui.components.OutgoingStatusMark +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.activity.ComponentActivity +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class OutgoingBubbleStatusTest { + @get:Rule + val compose = createAndroidComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val message = Message( + MessageId(PeerId(5), -1), + null, + "Pending message", + 10, + outgoing = true, + pending = true + ) + + @Test + fun pendingMessageUsesBubbleClockWithoutComposerNotice() { + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + message, null, showSender = false, joinsMessageAbove = false, + joinsMessageBelow = false, mediaRepository = null + ) + } + } + compose.onNodeWithText("Pending message").assertIsDisplayed() + compose.onNodeWithContentDescription(context.getString(org.monogram.core.ui.R.string.status_outgoing_sending)) + .assertExists() + compose.onNodeWithText(context.getString(R.string.dialog_outbox_ready)).assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.dialog_outbox_waiting)) + .assertDoesNotExist() + composerUiProof("outbox-pending-bubble.png") + } + + @Test + fun acknowledgementDoesNotReplayTheBubbleEntrance() { + var model by mutableStateOf( + message.copy( + randomId = 42, + date = System.currentTimeMillis() / 1000 + ) + ) + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + model, null, showSender = false, joinsMessageAbove = false, + joinsMessageBelow = false, mediaRepository = null + ) + } + } + compose.waitForIdle() + compose.mainClock.autoAdvance = false + val top = compose.onNodeWithText("Pending message").fetchSemanticsNode().boundsInRoot.top + compose.runOnIdle { model = model.copy(id = MessageId(PeerId(5), 100), pending = false) } + repeat(8) { + compose.mainClock.advanceTimeBy(32) + assertEquals( + top, + compose.onNodeWithText("Pending message").fetchSemanticsNode().boundsInRoot.top, + 0.5f + ) + } + compose.mainClock.autoAdvance = true + } + + @Test + fun statusTransitionKeepsItsWidthAcrossSendingFailureAndRead() { + var phase by mutableStateOf(0) + compose.setContent { + MonogramTheme(dynamicColor = false) { + OutgoingStatusMark( + pending = phase == 0, + failed = phase == 1, + read = phase == 3, + color = androidx.compose.ui.graphics.Color.Black, + modifier = Modifier.testTag("status") + ) + } + } + compose.waitForIdle() + compose.mainClock.autoAdvance = false + val width = compose.onNodeWithTag("status").fetchSemanticsNode().boundsInRoot.width + for (target in 1..3) { + compose.runOnIdle { phase = target } + repeat(10) { + compose.mainClock.advanceTimeBy(32) + assertEquals( + width, + compose.onNodeWithTag("status").fetchSemanticsNode().boundsInRoot.width, + 0.5f + ) + } + } + compose.mainClock.autoAdvance = true + compose.onNodeWithContentDescription(context.getString(org.monogram.core.ui.R.string.status_outgoing_read)) + .assertExists() + } + + @Test + fun retryDetailsExpandAndCollapseThroughIntermediateFrames() { + var failed by mutableStateOf(false) + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + message.copy(pending = !failed, failed = failed), null, + showSender = false, joinsMessageAbove = false, joinsMessageBelow = false, + mediaRepository = null, onRetrySend = if (failed) ({}) else null, + modifier = Modifier.testTag("bubble") + ) + } + } + compose.waitForIdle() + compose.mainClock.autoAdvance = false + fun height() = compose.onNodeWithTag("bubble").fetchSemanticsNode().boundsInRoot.height + val initial = height() + compose.runOnIdle { failed = true } + val expanding = List(10) { compose.mainClock.advanceTimeBy(32); height() } + val expanded = expanding.last() + org.junit.Assert.assertTrue(expanded > initial) + org.junit.Assert.assertTrue(expanding.any { it > initial + 1 && it < expanded - 1 }) + compose.runOnIdle { failed = false } + val collapsing = List(10) { compose.mainClock.advanceTimeBy(32); height() } + org.junit.Assert.assertTrue(collapsing.any { it > initial + 1 && it < expanded - 1 }) + assertEquals(initial, collapsing.last(), 0.5f) + compose.mainClock.autoAdvance = true + } + + @Test + fun uncertainDeliveryShowsExplicitRetryOnBubble() { + var retries = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + message.copy(pending = false, failed = true), null, showSender = false, + joinsMessageAbove = false, joinsMessageBelow = false, mediaRepository = null, + sendUncertain = true, onRetrySend = { retries++ }) + } + } + compose.onNodeWithText(context.getString(R.string.dialog_outbox_uncertain)) + .assertIsDisplayed() + compose.runOnIdle { assertEquals(0, retries) } + composerUiProof("outbox-uncertain-bubble.png") + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.runOnIdle { assertEquals(1, retries) } + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollControlsTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollControlsTest.kt new file mode 100644 index 000000000..b90d2c84e --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollControlsTest.kt @@ -0,0 +1,258 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.models.Poll +import org.monogram.core.models.PollAnswer +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class PollControlsTest { + @get:Rule + val compose = createAndroidComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private fun openActions(description: String = context.getString(org.monogram.core.ui.R.string.media_more_actions)) { + compose.onNodeWithContentDescription(description).performClick() + compose.mainClock.advanceTimeBy(400) + compose.waitForIdle() + } + + private fun poll( + multiple: Boolean = true, + answered: Boolean = false, + quiz: Boolean = false, + closed: Boolean = false + ) = Poll( + question = "Synthetic poll", multipleChoice = multiple, isQuiz = quiz, closed = closed, + answers = listOf( + PollAnswer("First", chosen = answered, option = byteArrayOf(0, -1)), + PollAnswer("Second", option = byteArrayOf(-128, 0)) + ), + ) + + @Test + fun multipleChoiceWaitsForSubmitPreservesOpaqueBytesAndRestoresSelection() { + val restoration = StateRestorationTester(compose) + val votes = mutableListOf>() + var busy by mutableStateOf(false) + val model = poll() + restoration.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard(model, { votes += it; busy = true }, null, voting = busy) + } + } + val submit = context.getString(R.string.dialog_poll_submit) + compose.onNodeWithText(submit).assertIsNotEnabled() + compose.onNodeWithText("First").performClick() + compose.onNodeWithText("Second").performClick() + compose.runOnIdle { assertTrue(votes.isEmpty()) } + restoration.emulateSavedInstanceStateRestore() + compose.onNodeWithText("First").assertIsOn() + compose.onNodeWithText("Second").assertIsOn() + compose.onNodeWithText(submit).performClick() + compose.runOnIdle { + assertEquals(1, votes.size) + assertArrayEquals(byteArrayOf(0, -1), votes.single()[0]) + assertArrayEquals(byteArrayOf(-128, 0), votes.single()[1]) + } + compose.onNodeWithText("First").assertIsNotEnabled() + compose.onNodeWithText(context.getString(R.string.dialog_poll_updating)).assertIsDisplayed() + } + + @Test + fun retractSendsEmptyVectorAndAnsweredQuizAndClosedPollAreReadOnly() { + var model by mutableStateOf(poll(answered = true).copy(creator = true)) + val votes = mutableListOf>() + compose.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard( + model, + { votes += it }, + null, + onClose = {}) + } + } + val retract = context.getString(R.string.dialog_poll_retract) + openActions() + compose.runOnIdle { assertTrue(votes.isEmpty()) } + composerUiProof("poll-message-action-menu.png") + compose.onNodeWithText(context.getString(R.string.dialog_poll_close)).assertIsDisplayed() + compose.onNodeWithText(retract).performClick() + compose.runOnIdle { + assertEquals(1, votes.size); assertTrue( + votes.single().isEmpty() + ); model = poll(answered = true, quiz = true) + } + compose.onNodeWithText(retract).assertDoesNotExist() + compose.onNodeWithText("Second").assertIsNotEnabled() + compose.runOnIdle { model = poll(closed = true) } + compose.onNodeWithText(retract).assertDoesNotExist() + compose.onNodeWithText("First").assertIsNotEnabled() + } + + @Test + fun closePollRequiresConfirmationSurvivesRestorationAndDisappearsAfterAuthoritativeClose() { + val restoration = StateRestorationTester(compose) + var model by mutableStateOf(poll().copy(creator = true)) + var closes = 0 + restoration.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard(model, {}, null, onClose = { closes++ }) + } + } + val close = context.getString(R.string.dialog_poll_close) + val cancel = context.getString(R.string.dialog_cancel) + openActions() + compose.onNodeWithText(close).performClick() + compose.runOnIdle { assertEquals(0, closes) } + compose.onNodeWithText(cancel).performClick() + compose.runOnIdle { assertEquals(0, closes) } + openActions() + compose.onNodeWithText(close).performClick() + restoration.emulateSavedInstanceStateRestore() + compose.onNodeWithText(context.getString(R.string.dialog_poll_close_confirm)) + .assertIsDisplayed() + compose.onNode(hasText(close) and hasClickAction() and hasAnyAncestor(isDialog())) + .performClick() + compose.runOnIdle { assertEquals(1, closes); model = model.copy(closed = true) } + compose.onNodeWithText(close).assertDoesNotExist() + } + + @Test + fun closePollIsHiddenForNonCreatorAndDisabledWhileVoting() { + var model by mutableStateOf(poll()) + compose.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard(model, {}, null, voting = true, onClose = { error("busy close") }) + } + } + val close = context.getString(R.string.dialog_poll_close) + compose.onNodeWithText(close).assertDoesNotExist() + compose.runOnIdle { model = model.copy(creator = true) } + compose.onNodeWithContentDescription(context.getString(org.monogram.core.ui.R.string.media_more_actions)) + .assertIsNotEnabled() + } + + @Test + fun answeredPollWithRevotingDisabledHasNoRetractionControl() { + compose.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard( + poll(answered = true).copy(revotingDisabled = true), + { error("forbidden vote") }, + null + ) + } + } + compose.onNodeWithText(context.getString(R.string.dialog_poll_retract)).assertDoesNotExist() + compose.onNodeWithText("First").assertIsNotEnabled() + compose.onNodeWithText("Second").assertIsNotEnabled() + } + + @Test + fun russianDarkLargeTextKeepsSelectionAndSubmitVisible() { + val localized = context.createConfigurationContext( + android.content.res.Configuration(context.resources.configuration).apply { + setLocale(java.util.Locale.forLanguageTag("ru")); fontScale = 1.5f + }) + var submitted = false + compose.setContent { + val density = androidx.compose.ui.platform.LocalDensity.current + CompositionLocalProvider( + androidx.compose.ui.platform.LocalResources provides localized.resources, + androidx.compose.ui.platform.LocalConfiguration provides localized.resources.configuration, + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density.density, + 1.5f + ), + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + PollBubbleCard( + poll().copy( + question = "Синтетический опрос с длинным вопросом", + creator = true + ), { submitted = true }, null, onClose = {}) + } + } + } + compose.onNodeWithText("First").assertIsDisplayed().performClick() + compose.onNodeWithText(localized.getString(R.string.dialog_poll_submit)).assertIsDisplayed() + .performClick() + compose.runOnIdle { assertTrue(submitted) } + val image = compose.onRoot().captureToImage() + val bitmap = image.asAndroidBitmap() + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let { java.io.File(it) } + ?: context.getExternalFilesDir(null)!! + directory.mkdirs() + val output = java.io.File(directory, "poll-russian-dark-large.png") + output.outputStream() + .use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + openActions(localized.getString(org.monogram.core.ui.R.string.media_more_actions)) + compose.onNodeWithText(localized.getString(R.string.dialog_poll_close)).performClick() + compose.onNodeWithText(localized.getString(R.string.dialog_poll_close_confirm)) + .assertIsDisplayed() + compose.onNodeWithText(localized.getString(R.string.dialog_cancel)).assertIsDisplayed() + val confirmation = compose.onNode(isDialog()).captureToImage().asAndroidBitmap() + java.io.File(directory, "poll-close-russian-dark-large.png").outputStream().use { + confirmation.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + } + + @Test + fun resultPercentagesUseTotalVotersAndLongAnswersRemainReadable() { + val longAnswer = + "A longer answer that must stay readable across multiple lines in the message card" + val model = poll(answered = true).copy( + totalVoters = 4, answers = listOf( + PollAnswer(longAnswer, chosen = true, voters = 3, option = byteArrayOf(1)), + PollAnswer("Second", voters = 1, option = byteArrayOf(2)), + ) + ) + compose.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard( + model, + {}, + null + ) + } + } + val formatter = java.text.NumberFormat.getPercentInstance() + compose.onNodeWithText(formatter.format(0.75)).assertIsDisplayed() + compose.onNodeWithText(formatter.format(0.25)).assertIsDisplayed() + compose.onNodeWithText(longAnswer).assertIsDisplayed() + composerUiProof("poll-message-results.png") + } + + @Test + fun singleChoiceVotesImmediatelyAndMissingPermissionDisablesRows() { + var allowed by mutableStateOf(true) + val votes = mutableListOf>() + compose.setContent { + MonogramTheme(dynamicColor = false) { + PollBubbleCard( + poll(multiple = false), if (allowed) { + { votes += it } + } else null, null) + } + } + compose.onNodeWithText("Second").performClick() + compose.runOnIdle { + assertEquals(1, votes.size); assertArrayEquals( + byteArrayOf(-128, 0), + votes.single().single() + ); allowed = false + } + compose.onNodeWithText("First").assertIsNotEnabled() + compose.onNodeWithText(context.getString(R.string.dialog_poll_submit)).assertDoesNotExist() + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollCreationInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollCreationInteractionTest.kt new file mode 100644 index 000000000..5c42566d3 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollCreationInteractionTest.kt @@ -0,0 +1,240 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.StateRestorationTester +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class PollCreationInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun closeStaysReachableWhenFocusMovesBetweenPollAndTaskFields() { + org.junit.Assume.assumeTrue(android.os.Build.VERSION.SDK_INT >= 29) + var kind by mutableStateOf(MessageCreationKind.Poll) + var visible by mutableStateOf(true) + var dismissed = 0 + val context = compose.activity + compose.setContent { + MonogramTheme(darkTheme = false, dynamicColor = false) { + if (visible) MessageCreationSheet( + kind, null, false, null, + { _, _ -> error("unexpected send") }, { _, _ -> error("unexpected send") }, + { visible = false; dismissed++ }) + } + } + MessageCreationKind.entries.forEach { current -> + compose.runOnIdle { kind = current; visible = true } + val checklist = current == MessageCreationKind.Checklist + val title = + context.getString(if (checklist) R.string.dialog_checklist_title else R.string.dialog_poll_question) + val item = context.getString( + if (checklist) R.string.dialog_checklist_item else R.string.dialog_poll_option, + 1 + ) + val close = + compose.onNodeWithContentDescription(context.getString(R.string.dialog_cancel)) + compose.onNode(hasSetTextAction() and hasText(title)).performScrollTo().performClick() + .performTextInput("Synthetic title") + compose.waitForIdle() + close.assertIsDisplayed() + val top = close.fetchSemanticsNode().boundsInWindow.top + val imeHeights = mutableListOf() + val popup = android.view.inspector.WindowInspector.getGlobalWindowViews().last { + (it.layoutParams as? android.view.WindowManager.LayoutParams)?.title == "Pop-Up Window" + } + compose.waitUntil(timeoutMillis = 5_000) { + (androidx.core.view.ViewCompat.getRootWindowInsets(popup) + ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.ime())?.bottom ?: 0) > 0 + } + val listener = android.view.ViewTreeObserver.OnPreDrawListener { + imeHeights += androidx.core.view.ViewCompat.getRootWindowInsets(popup) + ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.ime())?.bottom ?: 0 + true + } + compose.runOnIdle { popup.viewTreeObserver.addOnPreDrawListener(listener) } + compose.onNode(hasSetTextAction() and hasText(item)).performScrollTo().performClick() + .performTextInput("Synthetic item") + compose.waitForIdle() + compose.runOnIdle { popup.viewTreeObserver.removeOnPreDrawListener(listener) } + assertTrue( + "IME disappeared during field switch: $imeHeights", + imeHeights.isNotEmpty() && imeHeights.all { it > 0 }) + close.assertIsDisplayed() + assertEquals(top, close.fetchSemanticsNode().boundsInWindow.top, 2f) + composerUiProof(if (checklist) "checklist-keyboard.png" else "poll-keyboard.png") + close.performClick() + compose.waitUntil { !visible } + } + compose.runOnIdle { assertEquals(2, dismissed) } + } + + @Test + fun addingAndRemovingOptionsKeepsRemainingTextAndOrder() { + compose.setContent { + MonogramTheme(darkTheme = false, dynamicColor = false) { + MessageCreationSheet( + MessageCreationKind.Poll, null, false, null, + { _, _ -> }, { _, _ -> }, {}) + } + } + val context = compose.activity + fun option(index: Int) = compose.onNode( + hasSetTextAction() and hasText( + context.getString( + R.string.dialog_poll_option, + index + ) + ) + ) + option(1).performTextInput("First") + option(2).performTextInput("Second") + compose.onNodeWithText(context.getString(R.string.dialog_poll_add_option)).performScrollTo() + .performClick() + option(3).performScrollTo().performTextInput("Third") + compose.onNodeWithContentDescription( + context.getString(R.string.dialog_delete) + ": " + + context.getString(R.string.dialog_poll_option, 2) + ).performScrollTo().performClick() + compose.waitForIdle() + option(1).performScrollTo().assertTextContains("First") + option(2).performScrollTo().assertTextContains("Third") + compose.onNodeWithText("Second").assertDoesNotExist() + } + + @Test + fun quizValidationRestorationAndFailedAdmissionPreserveFormUntilConfirmation() { + val restore = StateRestorationTester(compose) + val context = compose.activity.createConfigurationContext( + android.content.res.Configuration(compose.activity.resources.configuration).apply { + setLocale(java.util.Locale.forLanguageTag("ru")); fontScale = 1.5f + }) + var admission by mutableStateOf(null) + var error by mutableStateOf(null) + val calls = mutableListOf>() + var dismissed = 0 + var allowed by mutableStateOf(true) + restore.setContent { + val density = androidx.compose.ui.platform.LocalDensity.current + CompositionLocalProvider( + androidx.compose.ui.platform.LocalResources provides context.resources, + androidx.compose.ui.platform.LocalConfiguration provides context.resources.configuration, + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density.density, + 1.5f + ), + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + MessageCreationSheet( + MessageCreationKind.Poll, + admission, + false, + error, + { spec, options -> + calls += spec to options; error = + TelegramError.parse("RPC 500: INTERNAL") + }, + { _, _ -> error("unexpected checklist send") }, + { dismissed++ }, + allowed = allowed + ) + } + } + } + val send = context.getString(R.string.dialog_send) + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + fun fill(label: String, value: String) { + compose.onNode(hasSetTextAction() and hasText(label)).performScrollTo() + .performTextInput(value) + } + fill(context.getString(R.string.dialog_poll_question), "Synthetic quiz") + fill(context.getString(R.string.dialog_poll_option, 1), "First") + fill(context.getString(R.string.dialog_poll_option, 2), "Second") + compose.onNodeWithText(context.getString(R.string.dialog_poll_quiz)).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.onNodeWithText(context.getString(R.string.dialog_poll_quiz)).assertIsOn() + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + compose.onNodeWithContentDescription( + context.getString( + R.string.dialog_poll_correct_option, + 2 + ) + ).performScrollTo().performClick() + fill(context.getString(R.string.dialog_poll_explanation), "Synthetic explanation") + compose.onNodeWithText(context.getString(R.string.dialog_send_silent)).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.onNodeWithText(context.getString(R.string.dialog_send_silent)).assertIsOn() + restore.emulateSavedInstanceStateRestore() + compose.onNodeWithText(context.getString(R.string.dialog_send_silent)).assertIsOn() + compose.onNode(hasSetTextAction() and hasText("Synthetic quiz")).performScrollTo() + .assertTextContains("Synthetic quiz") + compose.onNodeWithContentDescription( + context.getString( + R.string.dialog_poll_correct_option, + 2 + ) + ).performScrollTo().assertIsSelected() + compose.waitForIdle() + composerUiProof("poll-sheet.png") + compose.onNodeWithText(send).performScrollTo().performClick() + compose.runOnIdle { + assertEquals(1, calls.size); assertEquals(0, dismissed) + assertEquals( + PollSendSpec( + "Synthetic quiz", + listOf("First", "Second"), + quiz = true, + correctAnswer = 1, + solution = "Synthetic explanation" + ), calls.single().first + ) + assertTrue(calls.single().second.silent) + } + compose.runOnIdle { allowed = false } + compose.onNodeWithText(send).performScrollTo().assertIsNotEnabled() + compose.onNodeWithContentDescription(context.getString(R.string.dialog_send_options)) + .assertIsNotEnabled() + compose.runOnIdle { allowed = true } + compose.onNodeWithText(send).performScrollTo().performClick() + compose.runOnIdle { assertEquals(calls.first(), calls.last()); assertEquals(2, calls.size) } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_send_options)) + .performScrollTo().performClick() + val schedule = context.getString(R.string.dialog_send_schedule) + compose.waitUntil { + compose.onAllNodesWithText(schedule).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText(schedule).performClick() + compose.onNode( + hasText(schedule) and hasClickAction() and SemanticsMatcher.expectValue( + androidx.compose.ui.semantics.SemanticsProperties.Role, + androidx.compose.ui.semantics.Role.Button + ) + ).performScrollTo().performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.waitUntil { calls.size == 3 } + compose.runOnIdle { + assertEquals(3, calls.size) + assertEquals(calls.first().first, calls.last().first) + assertTrue(calls.last().second.silent) + assertTrue(requireNotNull(calls.last().second.scheduleDate).toLong() > System.currentTimeMillis() / 1000 + 59) + } + compose.onNode(hasText(schedule) and hasClickAction()).performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) { it() } + compose.runOnIdle { + assertEquals(4, calls.size); assertEquals( + calls[2], + calls[3] + ); admission = 123L + } + compose.waitUntil { dismissed == 1 } + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollPushInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollPushInteractionTest.kt new file mode 100644 index 000000000..6427678a3 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/PollPushInteractionTest.kt @@ -0,0 +1,106 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.DialogStore +import org.monogram.feature.dialog.DialogStoreFactory +import org.monogram.network.bridge.MtprotoClient +import org.monogram.network.bridge.MtprotoUpdate +import java.lang.reflect.Proxy +import java.util.concurrent.atomic.AtomicInteger + +class PollPushInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun externalPollUpdateRefreshesRealStoreAndRenderedSelectionWithoutSending() { + val events = MutableSharedFlow(replay = 16) + val reads = AtomicInteger() + val mutations = AtomicInteger() + val before = Message( + MessageId(PeerId(7), 37), + null, + null, + 100, + outgoing = false, + mediaKind = "poll", + fileName = """{"q":"Synthetic external poll","id":"123","n":0,"a":[{"t":"Fixture answer","o":"00ff","c":false}]}""" + ) + val after = + before.copy(fileName = """{"q":"Synthetic external poll","id":"123","n":12,"a":[{"t":"Fixture answer","o":"00ff","c":true,"v":12}]}""") + val client = Proxy.newProxyInstance( + MtprotoClient::class.java.classLoader, + arrayOf(MtprotoClient::class.java) + ) { proxy, method, args -> + when (method.name) { + "equals" -> proxy === args?.firstOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString", "libraryVersion" -> "SyntheticPollClient" + "updates" -> events + "sessionLost" -> emptyFlow() + "getHistory", "getHistoryPage", "getHistoryPageBackground" -> Outcome.Ok( + listOf( + before + ) + ) + + "getMessage" -> { + reads.incrementAndGet(); Outcome.Ok(after) + } + + "getChats" -> Outcome.Ok(listOf(Chat(PeerId(7), "Fixture chat"))) + "getProfile" -> Outcome.Ok(Profile(PeerId(7), "fixture", "Fixture chat")) + "getPinnedMessages", "getSavedGifs" -> Outcome.Ok(emptyList()) + "getGroupAdminTags" -> Outcome.Ok(emptyMap()) + "sendPollVote", "closePoll" -> { + mutations.incrementAndGet(); Outcome.Err("unexpected fixture mutation") + } + + "setDialogForeground", "close" -> Unit + "readHistory", "setTyping" -> Outcome.Ok(Unit) + else -> Outcome.Err("unsupported poll fixture method") + } + } as MtprotoClient + lateinit var store: DialogStore + compose.setContent { + val currentStore = remember { + DialogStoreFactory( + DefaultStoreFactory(), + client, + null, + null, + PeerId(7), + seedIsForum = false + ).create().also { store = it } + } + val flow = remember(currentStore) { currentStore.stateFlow } + val state by flow.collectAsState() + DisposableEffect(currentStore) { onDispose { currentStore.dispose() } } + MonogramTheme(dynamicColor = false) { + state.messages.firstOrNull()?.poll?.let { PollBubbleCard(it, {}, null) } + } + } + compose.waitUntil(5_000) { + compose.onAllNodesWithText("Synthetic external poll").fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("Synthetic external poll").assertIsDisplayed() + compose.onNodeWithText("Fixture answer").assertIsOff() + compose.runOnIdle { assertTrue(events.tryEmit(MtprotoUpdate.PollChanged(123))) } + compose.waitUntil(5_000) { store.state.messages.firstOrNull()?.fileName == after.fileName } + compose.onNodeWithText("Fixture answer").assertIsOn() + compose.runOnIdle { assertEquals(1, reads.get()); assertEquals(0, mutations.get()) } + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/SendOptionsInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/SendOptionsInteractionTest.kt new file mode 100644 index 000000000..6a3ce2da6 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/SendOptionsInteractionTest.kt @@ -0,0 +1,357 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.material3.Button +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.database.toModel +import org.monogram.core.ui.loading.MonogramBusyIconButton +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R +import org.monogram.feature.dialog.store.validSendSchedule + +class SendOptionsInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun localFailureShowsNotSentAndAllowsExplicitRetry() { + val context = compose.activity + var retried: Long? = null + val message = org.monogram.core.database.entity.MessageEntity( + chatId = 7, id = -37, senderId = 7, text = "Fixture", date = 100, + outgoing = true, pending = true, randomId = 123, mediaCacheKey = null, mediaKind = null, + ) + val row = org.monogram.core.database.entity.OutgoingTextSendEntity( + 123, message, false, false, null, null, + org.monogram.core.database.entity.OutgoingSendPhase.FAILED, + ) + compose.setContent { + MonogramTheme(dynamicColor = false) { + MessageBubble( + message.toModel().copy(pending = false, failed = true), null, + showSender = false, joinsMessageAbove = false, joinsMessageBelow = false, + mediaRepository = null, onRetrySend = { retried = row.randomId }, + ) + } + } + compose.onNodeWithText(context.getString(R.string.dialog_outbox_failed), substring = true) + .assertIsDisplayed() + compose.onNodeWithText( + context.getString(R.string.dialog_outbox_uncertain), + substring = true + ).assertDoesNotExist() + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.runOnIdle { assertEquals(123L, retried) } + } + + @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + @Test + fun editingAttachmentActionWorksWithBlankCaptionAndDisablesDuringUpload() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val label = context.getString(R.string.dialog_replace_attachment) + var busy by mutableStateOf(false) + var editing by mutableStateOf(true) + var replacements = 0 + var sends = 0 + var pending by mutableStateOf(emptyList()) + compose.setContent { + MonogramTheme(dynamicColor = false) { + DialogWriteBar( + composer = androidx.compose.ui.text.input.TextFieldValue(""), + onComposerChange = {}, + sending = busy, + canSendPlain = true, + canSendPhotos = true, + isChannel = false, + editing = editing, + editingBody = "synthetic attachment", + replyBody = null, + pendingAttach = pending, + hasFailed = false, + onSend = { sends++ }, + onRetryFailed = {}, + onCancelEdit = { editing = false }, + onClearReply = {}, + onClearAttach = {}, + onReplaceAttachment = { + replacements++ + pending = listOf( + org.monogram.core.models.UploadItem( + "synthetic-replacement", + "photo" + ) + ) + }) + } + } + compose.onNodeWithContentDescription(label).assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(1, replacements); assertEquals(0, sends) } + compose.onNodeWithContentDescription(context.getString(R.string.dialog_editing)) + .assertIsEnabled() + compose.waitForIdle() + composerUiProof("replace-attachment.png") + compose.onNodeWithContentDescription(context.getString(R.string.dialog_editing)) + .performClick() + compose.runOnIdle { assertEquals(1, sends); busy = true } + compose.onNodeWithContentDescription(label).assertIsNotEnabled() + compose.runOnIdle { busy = false; editing = false } + compose.onNodeWithContentDescription(label).assertDoesNotExist() + } + + @Test + fun longPressOpensOptionsWithoutSendingAndSilentChoiceRunsOnlyOnce() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val silentLabel = context.getString(R.string.dialog_send_silent) + var plain = 0 + var silent = 0 + compose.setContent { + var menu by remember { mutableStateOf(false) } + MonogramTheme(dynamicColor = false) { + androidx.compose.foundation.layout.Column( + androidx.compose.ui.Modifier.fillMaxSize(), + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Bottom + ) { + Text("synthetic photo attachment") + org.monogram.core.ui.components.ChatComposerLayout( + style = org.monogram.core.ui.ComposerStyle.Material, + leftAction = org.monogram.core.ui.ComposerSlotAction.None, + rightAction = org.monogram.core.ui.ComposerSlotAction.None, + leftEnabled = false, + rightEnabled = false, + sendEnabled = true, + sending = false, + editing = false, + leftDescription = "", + rightDescription = "", + sendDescription = "synthetic send", + onLeft = {}, + onRight = {}, + onSend = { plain++ }, + onSendLongClick = { menu = true }, + sendMenu = { + SendOptionsMenu(menu, + { menu = false }, + { silent++; menu = false }, + { menu = false }) + }) { Text("synthetic caption") } + } + } + } + compose.mainClock.autoAdvance = false + compose.onNodeWithContentDescription("synthetic send").performTouchInput { down(center) } + compose.mainClock.advanceTimeBy(1_100) + compose.waitForIdle() + compose.onNodeWithContentDescription("synthetic send").performTouchInput { up() } + compose.mainClock.autoAdvance = true + compose.onNodeWithText(silentLabel).assertIsDisplayed() + composerUiProof("send-menu.png") + compose.onNodeWithText(silentLabel).performClick() + compose.runOnIdle { assertEquals(0, plain); assertEquals(1, silent) } + compose.onNodeWithContentDescription("synthetic send").performClick() + compose.runOnIdle { assertEquals(1, plain); assertEquals(1, silent) } + } + + @Test + fun uncertainSendOffersAnExplicitRetryAndReadySendDoesNot() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var phase by mutableStateOf(org.monogram.core.database.entity.OutgoingSendPhase.READY) + var retried: Long? = null + val row = org.monogram.core.database.entity.OutgoingTextSendEntity( + randomId = 321, + message = org.monogram.core.database.entity.MessageEntity( + chatId = 7, + id = -1, + senderId = null, + text = "synthetic retry", + date = 100, + outgoing = true, + pending = true, + randomId = 321, + mediaCacheKey = null, + mediaKind = null + ), + silent = true, noWebpage = false, scheduleDate = null, webpageUrl = null, + ) + compose.setContent { + MonogramTheme(dynamicColor = false) { + val failed = phase == org.monogram.core.database.entity.OutgoingSendPhase.UNCERTAIN + MessageBubble( + row.message.toModel().copy(pending = !failed, failed = failed), null, + showSender = false, joinsMessageAbove = false, joinsMessageBelow = false, + mediaRepository = null, sendUncertain = failed, + onRetrySend = if (failed) ({ + retried = row.randomId + phase = org.monogram.core.database.entity.OutgoingSendPhase.READY + }) else null, + ) + } + } + val retry = context.getString(org.monogram.core.ui.R.string.status_retry) + compose.onNodeWithText(retry).assertDoesNotExist() + compose.runOnIdle { phase = org.monogram.core.database.entity.OutgoingSendPhase.UNCERTAIN } + compose.onNodeWithText( + context.getString(R.string.dialog_outbox_uncertain), + substring = true + ).assertIsDisplayed() + compose.onNodeWithText(retry).performClick() + compose.runOnIdle { assertEquals(321L, retried) } + compose.onNodeWithText(retry).assertDoesNotExist() + } + + @Test + fun mediaNoticeKeepsCaptionAndExplicitRetryAtLargeFontInDarkTheme() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var phase by mutableStateOf(org.monogram.core.database.entity.OutgoingSendPhase.UNCERTAIN) + var retried: Long? = null + val message = org.monogram.core.database.entity.MessageEntity( + chatId = 7, + id = -1, + senderId = null, + text = "synthetic media caption with longer text", + date = 100, + outgoing = true, + pending = true, + randomId = 123, + mediaCacheKey = "local:fixture", + mediaKind = "photo" + ) + val item = org.monogram.core.database.entity.OutgoingMediaItemEntity( + 123, 123, 0, "image/png", + org.monogram.core.database.RetainedMediaSource( + "00000000-0000-0000-0000-000000000001.bin", + 3, + "0".repeat(64) + ), message + ) + compose.setContent { + val density = androidx.compose.ui.platform.LocalDensity.current + CompositionLocalProvider( + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density.density, + 1.5f + ) + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + val send = outgoingBubbleSend( + message.toModel(), emptyList(), listOf( + org.monogram.core.database.entity.OutgoingMediaSend( + org.monogram.core.database.entity.OutgoingMediaBatchEntity( + 123, + 7, + 100, + true, + null, + false, + phase + ), listOf(item) + ) + ) + )!! + val failed = + send.phase == org.monogram.core.database.entity.OutgoingSendPhase.UNCERTAIN + MessageBubble( + message.toModel().copy(pending = !failed, failed = failed), null, + showSender = false, joinsMessageAbove = false, joinsMessageBelow = false, + mediaRepository = null, sendUncertain = failed, + onRetrySend = if (failed) ({ + retried = send.id + phase = org.monogram.core.database.entity.OutgoingSendPhase.READY + }) else null, + ) + } + } + } + compose.onNodeWithText("synthetic media caption", substring = true).assertIsDisplayed() + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.runOnIdle { assertEquals(123L, retried) } + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .assertDoesNotExist() + } + + @Test + fun scheduleCloseAndActionStayVisibleWhenSwitchingTimeFields() { + var visible by mutableStateOf(true) + var dismissed = 0 + compose.setContent { + MonogramTheme(darkTheme = false, dynamicColor = false) { + if (visible) ScheduleMessageSheet( + onDismiss = { visible = false; dismissed++ }, + onSchedule = { error("unexpected schedule") }) + } + } + val close = + compose.onNodeWithContentDescription(compose.activity.getString(R.string.dialog_cancel)) + val fields = compose.onAllNodes(hasSetTextAction()) + fields[0].performClick() + compose.waitForIdle() + close.assertIsDisplayed() + val top = close.fetchSemanticsNode().boundsInWindow.top + fields[1].performClick() + close.assertIsDisplayed() + assertEquals(top, close.fetchSemanticsNode().boundsInWindow.top, 2f) + val button = SemanticsMatcher.expectValue( + androidx.compose.ui.semantics.SemanticsProperties.Role, + androidx.compose.ui.semantics.Role.Button + ) + compose.onNode(hasText(compose.activity.getString(R.string.dialog_send_schedule)) and button) + .assertIsDisplayed() + composerUiProof("schedule-keyboard.png") + close.performClick() + compose.runOnIdle { assertEquals(1, dismissed) } + } + + @Test + fun scheduleSheetCancelDoesNotSendAndConfirmEmitsAValidatedTime() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + var scheduled: Int? = null + var dismissed = 0 + compose.setContent { + var visible by remember { mutableStateOf(true) } + MonogramTheme(dynamicColor = false) { + Button(onClick = { visible = true }) { Text("reopen synthetic sheet") } + if (visible) ScheduleMessageSheet( + onDismiss = { dismissed++; visible = false }, + onSchedule = { scheduled = it; visible = false }, + ) + } + } + composerUiProof("schedule-sheet.png") + val button = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button) + compose.onNodeWithContentDescription(context.getString(R.string.dialog_cancel)) + .assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(1, dismissed); assertNull(scheduled) } + compose.onNodeWithText("reopen synthetic sheet").performClick() + val schedule = context.getString(R.string.dialog_send_schedule) + compose.onNode(hasText(schedule) and button).performScrollTo().performClick() + compose.runOnIdle { + assertNotNull(scheduled); assertTrue( + validSendSchedule( + scheduled, + System.currentTimeMillis() / 1000 + ) + ) + } + } +} + +internal fun composerUiProof(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val bitmap = instrumentation.uiAutomation.takeScreenshot() + java.io.File(instrumentation.targetContext.cacheDir, name).outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/UnsupportedDraftNoticeTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/UnsupportedDraftNoticeTest.kt new file mode 100644 index 000000000..28dbd359a --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/UnsupportedDraftNoticeTest.kt @@ -0,0 +1,47 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R + +class UnsupportedDraftNoticeTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun discardRequiresConfirmationAndCancelPreservesTheDraft() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val discard = context.getString(R.string.dialog_discard_draft) + var discarded = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + UnsupportedDraftNotice("Synthetic draft") { discarded++ } + } + } + compose.onNodeWithText("Synthetic draft").assertIsDisplayed() + compose.onNode(hasText(discard) and hasClickAction()).performClick() + compose.onNode(isDialog()).assertExists() + compose.runOnIdle { assertEquals(0, discarded) } + compose.onNodeWithText(context.getString(R.string.dialog_cancel)).performClick() + compose.onNode(isDialog()).assertDoesNotExist() + compose.runOnIdle { assertEquals(0, discarded) } + compose.onNodeWithText("Synthetic draft").assertIsDisplayed() + compose.onNode(hasText(discard) and hasClickAction()).performClick() + compose.onNode(hasText(discard) and hasClickAction() and hasAnyAncestor(isDialog())) + .performClick() + compose.runOnIdle { assertEquals(1, discarded) } + compose.onNode(isDialog()).assertDoesNotExist() + } +} diff --git a/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ViewerPagesInteractionTest.kt b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ViewerPagesInteractionTest.kt new file mode 100644 index 000000000..9a066a789 --- /dev/null +++ b/feature/dialog/src/androidTest/java/org/monogram/feature/dialog/ui/ViewerPagesInteractionTest.kt @@ -0,0 +1,146 @@ +package org.monogram.feature.dialog.ui + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.monogram.core.models.* +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.dialog.R +import org.monogram.feature.dialog.ViewerPageState + +class ViewerPagesInteractionTest { + @get:Rule + val compose = createAndroidComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private fun viewer(id: Long, reaction: String) = + MessageViewer(PeerId(id), 100, title = "Fixture $id", emoticon = reaction) + + @Test + fun fiftyToSixtyEntriesRemainReachableInRussianDarkLargeText() { + org.junit.Assume.assumeTrue(android.os.Build.VERSION.SDK_INT >= 29) + val localized = context.createConfigurationContext( + android.content.res.Configuration(context.resources.configuration).apply { + setLocale(java.util.Locale.forLanguageTag("ru")); fontScale = 1.5f + }) + var users by mutableStateOf((1L..50L).map { viewer(it, "👍") }) + var page by mutableStateOf(ViewerPageState(loaded = true, nextOffset = "second")) + var opened: Long? = null + compose.setContent { + val density = androidx.compose.ui.platform.LocalDensity.current + CompositionLocalProvider( + androidx.compose.ui.platform.LocalResources provides localized.resources, + androidx.compose.ui.platform.LocalConfiguration provides localized.resources.configuration, + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density.density, + 1.5f + ) + ) { + MonogramTheme(darkTheme = true, dynamicColor = false) { + PeerListSheet( + "reaction", + users, + { null }, + { opened = it }, + {}, + page = page, + onLoadMore = { + users = users + (51L..60L).map { viewer(it, "👍") }; page = + page.copy(nextOffset = null) + }) + } + } + } + compose.onNodeWithText(localized.getString(R.string.dialog_peer_list_load_more)) + .assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals(60, users.size) } + compose.onNode( + hasScrollAction() and SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange), + useUnmergedTree = true + ).performScrollToIndex(59) + compose.onNodeWithText("Fixture 60", useUnmergedTree = true).assertIsDisplayed() + .performClick() + compose.runOnIdle { assertEquals(60L, opened) } + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let { java.io.File(it) } ?: context.getExternalFilesDir(null)!! + directory.mkdirs() + compose.runOnUiThread { + val view = android.view.inspector.WindowInspector.getGlobalWindowViews().last() + val image = android.graphics.Bitmap.createBitmap( + view.width, + view.height, + android.graphics.Bitmap.Config.ARGB_8888 + ) + view.draw(android.graphics.Canvas(image)) + java.io.File(directory, "viewer-pages-russian-dark-large.png").outputStream().use { + image.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + } + } + + @Test + fun filterSurvivesLoadingFailureRetryAndSecondPageArrival() { + var users by mutableStateOf(listOf(viewer(1, "👍"), viewer(2, "🔥"))) + var page by mutableStateOf(ViewerPageState(loaded = true, nextOffset = "next")) + var requests = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + PeerListSheet( + "reaction", + users, + { null }, + {}, + {}, + initialFilter = reactionChoiceKey("👍", null), + page = page, + onLoadMore = { requests++; page = page.copy(loading = true) }) + } + } + val more = context.getString(R.string.dialog_peer_list_load_more) + val retry = context.getString(org.monogram.core.ui.R.string.status_retry) + compose.onNodeWithText("Fixture 1").assertIsDisplayed() + compose.onNodeWithText("Fixture 2").assertDoesNotExist() + compose.onNodeWithText(more).performClick() + compose.runOnIdle { assertEquals(1, requests) } + compose.runOnIdle { page = page.copy(loading = false, failed = true) } + compose.onNodeWithText(context.getString(R.string.dialog_peer_list_load_failed)) + .assertIsDisplayed() + compose.onNodeWithText("Fixture 1").assertIsDisplayed() + compose.onNodeWithText(retry).performClick() + compose.runOnIdle { + assertEquals(2, requests) + users = users + viewer(3, "👍") + page = page.copy(loading = false, failed = false, nextOffset = null) + } + compose.onNodeWithText("Fixture 3").assertIsDisplayed() + compose.onNodeWithText("Fixture 2").assertDoesNotExist() + compose.onNodeWithText(more).assertDoesNotExist() + compose.onNodeWithText(retry).assertDoesNotExist() + } + + @Test + fun initialFailureOffersRetryInsteadOfEndlessLoading() { + var requests = 0 + compose.setContent { + MonogramTheme(dynamicColor = false) { + PeerListSheet( + "poll", + null, + { null }, + {}, + {}, + page = ViewerPageState(failed = true), + onLoadMore = { requests++ }) + } + } + compose.onNodeWithText(context.getString(R.string.dialog_peer_list_load_failed)) + .assertIsDisplayed() + compose.onNodeWithText(context.getString(org.monogram.core.ui.R.string.status_retry)) + .performClick() + compose.runOnIdle { assertEquals(1, requests) } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ChecklistEditState.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ChecklistEditState.kt new file mode 100644 index 000000000..ff75cdd5d --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ChecklistEditState.kt @@ -0,0 +1,91 @@ +package org.monogram.feature.dialog + +import org.monogram.core.models.Checklists +import org.monogram.core.models.CompactJson +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId +import org.monogram.core.models.TextEntities +import org.monogram.core.models.TextEntity + +internal data class ChecklistEditState( + val chatId: Long, + val threadId: Int, + val messageId: Int, + val date: Long, + val checklistJson: String, + val itemId: Int?, + val draft: String, + val entitiesJson: String?, + val markdown: Boolean, +) { + fun message(chat: PeerId, thread: Int): Message? { + if (chat.value != chatId || thread != threadId || messageId <= 0) return null + val checklist = Checklists.parse(checklistJson) ?: return null + if (itemId != null && checklist.items.count { it.id == itemId } != 1) return null + return Message( + MessageId(chat, messageId), + null, + null, + date, + outgoing = true, + mediaKind = "todo", + checklist = checklist + ) + } + + fun encode(): String { + fun quote(value: String?) = value?.let { "\"${CompactJson.escape(it)}\"" } ?: "null" + return "{\"v\":1,\"chat\":$chatId,\"thread\":$threadId,\"id\":$messageId,\"date\":$date," + "\"list\":${ + quote( + checklistJson + ) + },\"item\":$itemId,\"text\":${quote(draft)},\"entities\":${ + quote( + entitiesJson + ) + },\"markdown\":$markdown}" + } + + companion object { + fun decode(raw: String?): ChecklistEditState? { + val row = CompactJson.parse(raw.orEmpty()) as? Map<*, *> ?: return null + if ((row["v"] as? Number)?.toInt() != 1) return null + return ChecklistEditState( + (row["chat"] as? Number)?.toLong() ?: return null, + (row["thread"] as? Number)?.toInt() ?: return null, + (row["id"] as? Number)?.toInt() ?: return null, + (row["date"] as? Number)?.toLong() ?: return null, + row["list"] as? String ?: return null, + (row["item"] as? Number)?.toInt(), + row["text"] as? String ?: return null, + row["entities"] as? String, + row["markdown"] == true, + ) + } + + fun from(state: DialogStore.State): ChecklistEditState? { + val message = state.editing ?: return null + if (!message.outgoing || message.pending || message.id.chatId != state.chatId || message.id.id <= 0) return null + val checklist = Checklists.serialize(message.checklist) ?: return null + return ChecklistEditState( + state.chatId.value, + state.threadTopId, + message.id.id, + message.date, + checklist, + state.editingChecklistItemId, + state.draft, + TextEntities.serialize(state.checklistEditEntities()), + state.draftFormatting + ) + } + } +} + +internal fun DialogStore.State.checklistEditEntities(): List = + (draftEntities + draftMentions.map { + TextEntity( + "mention_name", it.start, it.length, it.peerId.value.toString() + ) + }).distinct().sortedWith(compareBy { it.offset }.thenByDescending { it.length }) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt index f8ad57cad..1cfe73151 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt @@ -20,11 +20,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.serializer +import org.monogram.core.common.telegram.TelegramError import org.monogram.core.database.OfflineWarmup import org.monogram.core.database.SessionMetadataStore import org.monogram.core.markup.KotlinMarkupParser @@ -32,6 +38,8 @@ import org.monogram.core.markup.MarkupParser import org.monogram.core.models.ForumIo import org.monogram.core.models.ForumTopic import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.PeerId import org.monogram.core.models.TextEntity import org.monogram.core.models.UploadItem @@ -41,6 +49,8 @@ import org.monogram.core.models.remapTextEntities import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.gzipFile import org.monogram.core.ui.webmFile +import org.monogram.feature.dialog.DialogStore.Intent.ImportContact +import org.monogram.feature.dialog.DialogStore.Intent.LoadContactImport import org.monogram.feature.dialog.store.filterComposerPayload import org.monogram.feature.dialog.ui.InstantViewController import org.monogram.network.bridge.MtprotoClient @@ -51,7 +61,7 @@ class DialogComponent( componentContext: ComponentContext, storeFactory: StoreFactory, val client: MtprotoClient, - warmup: OfflineWarmup?, + private val warmup: OfflineWarmup?, sessionStore: SessionMetadataStore?, val mediaRepository: MediaRepository?, val chatId: PeerId, @@ -64,9 +74,83 @@ class DialogComponent( private val onOpenProfile: (PeerId) -> Unit = {}, private val onOpenChat: (PeerId, Int, Int) -> Unit = { _, _, _ -> }, private val onRequestForward: ((List) -> Unit)? = null, + private val onOpenScheduledQueue: () -> Unit = {}, val showBackButton: Boolean = true, + private val onResumeLiveLocation: (suspend (MessageId) -> Boolean)? = null, + private val onStopLiveLocation: (suspend (MessageId) -> Boolean)? = null, + val liveLocationErrors: Flow> = flowOf( + emptyMap() + ), + val liveLocationFailure: Flow = flowOf( + null + ), ) : ComponentContext by componentContext { + private val liveLocationEpoch = warmup?.outgoingAccountEpoch() + internal val liveLocationControlKey get() = "$liveLocationEpoch:$stateKey" + private fun liveLocationAccountCurrent() = + liveLocationEpoch == null || warmup?.outgoingAccountIsCurrent(liveLocationEpoch) == true + + val liveLocationControlsAvailable get() = onResumeLiveLocation != null && onStopLiveLocation != null + val liveLocationSessions = warmup?.observeLiveLocations() + ?.map { rows -> if (liveLocationAccountCurrent()) rows.filter { it.messageId.chatId == chatId } else emptyList() } + ?: flowOf(emptyList()) + + suspend fun resumeLiveLocation(messageId: Int): Boolean = + messageId > 0 && liveLocationAccountCurrent() && + onResumeLiveLocation?.invoke( + MessageId( + chatId, + messageId + ) + ) == true + + suspend fun stopLiveLocation(messageId: Int): Boolean = + messageId > 0 && liveLocationAccountCurrent() && + onStopLiveLocation?.invoke( + MessageId( + chatId, + messageId + ) + ) == true + + private val mutableLiveBusy = MutableStateFlow>(emptySet()) + val liveLocationBusy: StateFlow> = mutableLiveBusy + private val mutableLiveFailed = MutableStateFlow>(emptySet()) + val liveLocationActionFailed: StateFlow> = mutableLiveFailed + + fun onResumeLiveLocation(messageId: Int) = changeLiveLocation(messageId, true) + fun onStopLiveLocation(messageId: Int) = changeLiveLocation(messageId, false) + + private fun changeLiveLocation(messageId: Int, resume: Boolean) { + if (messageId <= 0 || !liveLocationControlsAvailable || !liveLocationAccountCurrent()) return + while (true) { + val current = mutableLiveBusy.value + if (messageId in current) return + if (mutableLiveBusy.compareAndSet(current, current + messageId)) break + } + preloadScope.launch { + val epoch = liveLocationEpoch + try { + mutableLiveFailed.update { it - messageId } + val changed = + if (resume) resumeLiveLocation(messageId) else stopLiveLocation(messageId) + if ((epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) && !changed) + mutableLiveFailed.update { it + messageId } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + if (epoch == null || warmup?.outgoingAccountIsCurrent(epoch) == true) + mutableLiveFailed.update { it + messageId } + } finally { + mutableLiveBusy.update { it - messageId } + } + } + } + + val hasScheduledMessages = warmup?.observeScheduledMessages(chatId)?.map { it.isNotEmpty() } + ?: flowOf(false) + /** Dialog identity for saveable state. */ val stateKey: String get() = dialogStateKey(chatId.value, threadTopMsgId, jumpToMessageId) @@ -85,6 +169,18 @@ class DialogComponent( init { warmup?.setOpenChat(chatId.value) + if (warmup != null) preloadScope.launch { + val epoch = warmup.outgoingAccountEpoch() + try { + val revision = warmup.scheduledQueueRevision(chatId, epoch) + val result = client.getScheduledHistory(chatId) + if (result is org.monogram.core.common.Outcome.Ok) + warmup.replaceScheduledMessagesIfCurrent(chatId, revision, result.value, epoch) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + } + } lifecycle.doOnStart { client.setDialogForeground(true) } lifecycle.doOnStop { client.setDialogForeground(false) } lifecycle.doOnDestroy { @@ -128,6 +224,9 @@ class DialogComponent( return filterComposerPayload(parsed, client, isPremium()) } + private val restoredChecklistEdit = + ChecklistEditState.decode(stateKeeper.consume("checklist-edit", String.serializer())) + private val store = instanceKeeper.getStore { DialogStoreFactory( storeFactory, @@ -140,11 +239,20 @@ class DialogComponent( isForum, markup = markup, isPremium = isPremium, + initialChecklistEdit = restoredChecklistEdit, ).create() } val state: StateFlow = store.stateFlow + init { + stateKeeper.register( + "checklist-edit", + String.serializer() + ) { ChecklistEditState.from(store.state)?.encode() } + lifecycle.doOnDestroy { stateKeeper.unregister("checklist-edit") } + } + init { var started = false lifecycle.doOnStart { @@ -253,10 +361,19 @@ class DialogComponent( fun onReply(message: Message, focusComposer: Boolean = false) = store.accept(DialogStore.Intent.ReplyTo(message, focusComposer = focusComposer)) + fun onRetryDraftSync() = store.accept(DialogStore.Intent.RetryDraftSync) + fun onDiscardDraft() = store.accept(DialogStore.Intent.DiscardDraft) fun onClearReply() = store.accept(DialogStore.Intent.ClearReply) fun onEdit(message: Message) = store.accept(DialogStore.Intent.Edit(message)) + fun onReplaceAttachment( + messageId: Int, + expectedKey: String, + item: UploadItem + ) = + store.accept(DialogStore.Intent.ReplaceAttachment(messageId, expectedKey, item)) + fun onCancelEdit() = store.accept(DialogStore.Intent.CancelEdit) fun onDelete(messageId: Int, revoke: Boolean) = store.accept(DialogStore.Intent.Delete(messageId, revoke)) @@ -277,6 +394,11 @@ class DialogComponent( fun onForwardQuery(value: String) = store.accept(DialogStore.Intent.ForwardQuery(value)) fun onForwardTo(peerId: PeerId) = store.accept(DialogStore.Intent.ForwardTo(peerId)) fun onClearForwardHint() = store.accept(DialogStore.Intent.ClearForwardHint) + fun onSendWithOptions(text: String?, options: MessageSendOptions) = + store.accept(DialogStore.Intent.SendWithOptions(text, options)) + + fun onRetryMediaSend(batchId: Long) = store.accept(DialogStore.Intent.RetryMediaSend(batchId)) + fun onRetryTextSend(randomId: Long) = store.accept(DialogStore.Intent.RetryTextSend(randomId)) fun onSend(text: String? = null) = store.accept(DialogStore.Intent.Send(text)) fun onReadOnlyBar() = store.accept(DialogStore.Intent.ReadOnlyBar) fun onRetryFailed() = store.accept(DialogStore.Intent.RetryFailed) @@ -331,18 +453,51 @@ class DialogComponent( } fun onBack() = onBack.invoke() + fun onOpenScheduledQueue() = onOpenScheduledQueue.invoke() fun onOpenProfile() = onOpenProfile.invoke(chatId) fun onOpenPeer(peerId: PeerId) = onOpenProfile.invoke(peerId) fun onLoadMoreTopics() = store.accept(DialogStore.Intent.LoadMoreTopics) fun onToggleTopicHidden(topicId: Int, hidden: Boolean) = store.accept(DialogStore.Intent.ToggleTopicHidden(topicId, hidden)) + fun onRemoveChecklistItem(message: Message, itemId: Int) = + store.accept(DialogStore.Intent.RemoveChecklistItem(message, itemId)) + + fun onEditChecklistItem(message: Message, itemId: Int) = + store.accept(DialogStore.Intent.Edit(message, itemId)) + fun onToggleChecklist(messageId: Int, itemId: Int) = store.accept(DialogStore.Intent.ToggleChecklist(messageId, itemId)) + fun onClosePoll(messageId: Int) = store.accept(DialogStore.Intent.ClosePoll(messageId)) + fun onVotePoll(messageId: Int, options: List) = store.accept(DialogStore.Intent.VotePoll(messageId, options)) + fun onLoadContactImport(spec: org.monogram.core.models.ContactSendSpec) = + store.accept(LoadContactImport(spec)) + + fun onImportContact(spec: org.monogram.core.models.ContactSendSpec, retry: Boolean) = + store.accept(ImportContact(spec, retry)) + + fun onSendContact( + spec: org.monogram.core.models.ContactSendSpec, + options: MessageSendOptions = MessageSendOptions() + ) = + store.accept(DialogStore.Intent.SendContact(spec, options)) + + fun onSendChecklist( + spec: org.monogram.core.models.ChecklistSendSpec, + options: MessageSendOptions = MessageSendOptions() + ) = + store.accept(DialogStore.Intent.SendChecklist(spec, options)) + + fun onSendPoll( + spec: org.monogram.core.models.PollSendSpec, + options: MessageSendOptions = MessageSendOptions() + ) = + store.accept(DialogStore.Intent.SendPoll(spec, options)) + fun onAppendChecklistItems(messageId: Int, firstId: Int, titles: List) = store.accept(DialogStore.Intent.AppendChecklistItems(messageId, firstId, titles)) 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 0c688dd67..cdf71fdf6 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 @@ -1,19 +1,27 @@ package org.monogram.feature.dialog +import android.os.Looper.getMainLooper import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.StoreFactory import kotlinx.coroutines.Dispatchers import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.CloudDraftSyncStatus import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.OutgoingSendSyncState import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingTextSendEntity import org.monogram.core.markup.KotlinMarkupParser import org.monogram.core.markup.MarkupParser import org.monogram.core.models.Chat +import org.monogram.core.models.ContactImportAttempt +import org.monogram.core.models.ContactSendSpec import org.monogram.core.models.ForumIo import org.monogram.core.models.ForumTopic import org.monogram.core.models.InlineBotResults import org.monogram.core.models.Message +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.MessageViewer import org.monogram.core.models.MessageViewers import org.monogram.core.models.OutboxReadState @@ -25,6 +33,7 @@ import org.monogram.core.models.ReplyButton import org.monogram.core.models.SavedGif import org.monogram.core.models.StickerPack import org.monogram.core.models.StyledText +import org.monogram.core.models.TextEntities import org.monogram.core.models.TextEntity import org.monogram.core.models.TypingPresence import org.monogram.core.models.UploadItem @@ -34,6 +43,11 @@ import org.monogram.feature.dialog.store.DialogReducer import org.monogram.network.bridge.MtprotoClient import kotlin.coroutines.CoroutineContext +data class ViewerPageState( + val loaded: Boolean = false, val loading: Boolean = false, val failed: Boolean = false, + val nextOffset: String? = null, val consumedOffsets: Set = emptySet(), +) + interface DialogStore : Store { sealed interface Intent { data object Refresh : Intent @@ -48,6 +62,10 @@ interface DialogStore : Store { data class SetDraftFormatting(val enabled: Boolean) : Intent data class SetDraftEntities(val entities: List) : Intent data class AttachPhoto(val path: String) : Intent + data class ReplaceAttachment( + val messageId: Int, val expectedKey: String, val item: UploadItem + ) : Intent + data class AttachMedia(val items: List) : Intent data class AppendMedia(val items: List) : Intent data object ClearAttach : Intent @@ -57,7 +75,9 @@ interface DialogStore : Store { data class SelectLinkPreview(val url: String) : Intent data class ReplyTo(val message: Message, val focusComposer: Boolean = false) : Intent data object ClearReply : Intent - data class Edit(val message: Message) : Intent + data object DiscardDraft : Intent + data object RetryDraftSync : Intent + data class Edit(val message: Message, val itemId: Int? = null) : Intent data object CancelEdit : Intent data class Delete(val messageId: Int, val revoke: Boolean) : Intent data class ForwardPick(val message: Message) : Intent @@ -65,6 +85,31 @@ interface DialogStore : Store { data class ForwardQuery(val value: String) : Intent data class ForwardTo(val chatId: PeerId) : Intent data object ClearForwardHint : Intent + data class LoadContactImport(val spec: ContactSendSpec) : Intent + data class ImportContact( + val spec: ContactSendSpec, val retry: Boolean + ) : Intent + + data class SendContact( + val spec: ContactSendSpec, val options: MessageSendOptions = MessageSendOptions() + ) : Intent + + data class SendChecklist( + val spec: org.monogram.core.models.ChecklistSendSpec, + val options: MessageSendOptions = MessageSendOptions() + ) : Intent + + data class SendPoll( + val spec: org.monogram.core.models.PollSendSpec, + val options: MessageSendOptions = MessageSendOptions() + ) : Intent + + data class SendWithOptions( + val text: String?, val options: MessageSendOptions + ) : Intent + + data class RetryMediaSend(val batchId: Long) : Intent + data class RetryTextSend(val randomId: Long) : Intent data class Send(val text: String? = null) : Intent data object RetryFailed : Intent data class Search(val query: String) : Intent @@ -125,9 +170,11 @@ interface DialogStore : Store { val fromKeyboard: Boolean, ) : Intent + data class RemoveChecklistItem(val message: Message, val itemId: Int) : Intent data class ToggleChecklist(val messageId: Int, val itemId: Int) : Intent - /** Raw option bytes from the poll payload; the optimistic state arrives with the update. */ + /** Raw option bytes from the poll payload. */ + data class ClosePoll(val messageId: Int) : Intent data class VotePoll(val messageId: Int, val options: List) : Intent data class AppendChecklistItems( val messageId: Int, @@ -166,9 +213,23 @@ interface DialogStore : Store { val senderTags: Map = emptyMap(), val draft: String = "", val draftFormatting: Boolean = false, + val draftReplyId: Int = 0, + val draftUnsupported: Boolean = false, + val outgoingMediaSends: List = emptyList(), + val contactImportAttempt: ContactImportAttempt? = null, + val contactImportBusy: Boolean = false, + val contactImportError: TelegramError? = null, + val contactAdmissionId: Long? = null, + val checklistAdmissionId: Long? = null, + val pollAdmissionId: Long? = null, + val outgoingTextSends: List = emptyList(), + val outgoingSendSync: OutgoingSendSyncState = OutgoingSendSyncState(), + val draftSync: CloudDraftSyncStatus = CloudDraftSyncStatus(), val draftEntities: List = emptyList(), val draftPreview: StyledText? = null, val draftHasMarkdown: Boolean = false, + val attachmentRevision: Long = 0, + val composerRevision: Long = 0, val pendingAttach: List = emptyList(), val linkPreview: WebpagePreview? = null, val linkPreviewUrl: String? = null, @@ -179,6 +240,7 @@ interface DialogStore : Store { val linkPreviewUrls: List = emptyList(), val replyTo: Message? = null, val composerFocusSeq: Int = 0, + val editingChecklistItemId: Int? = null, val editing: Message? = null, val forwardMessage: Message? = null, val forwardTargets: List = emptyList(), @@ -188,6 +250,7 @@ interface DialogStore : Store { val canView: Boolean = true, val canSendPlain: Boolean = true, val canSendPhotos: Boolean = true, + val canSendPolls: Boolean = false, val left: Boolean = false, val muted: Boolean = false, val blockedByMe: Boolean = false, @@ -259,10 +322,13 @@ interface DialogStore : Store { val botAlert: Boolean = false, val botUrl: String? = null, val copyText: String? = null, - val readReceiptConfig: ReadReceiptConfig = - ReadReceiptConfig.Fallback, + val readReceiptConfig: ReadReceiptConfig = ReadReceiptConfig.Fallback, val messageViewers: Map = emptyMap(), + val reactionPages: Map = emptyMap(), + val pollVoterPages: Map = emptyMap(), val reactionUsers: Map> = emptyMap(), + val checklistMutations: Set = emptySet(), + val pollVotesInFlight: Set = emptySet(), val pollVoters: Map> = emptyMap(), val outboxReadStates: Map = emptyMap(), val keyboardDismissedKey: String? = null, @@ -276,6 +342,9 @@ interface DialogStore : Store { val mentionHasMore: Boolean = false, val draftMentions: List = emptyList(), ) { + val canCreatePoll: Boolean get() = canView && !left && !blockedByMe && canSendPolls + val canCreateChecklist: Boolean + get() = canView && !left && !blockedByMe && !isChannel && (if (isGroup) canSendPolls else canSendPlain) val isCommentThread: Boolean get() = threadTopId > 0 && !isForum val showTopicList: Boolean get() = ForumIo.showTopicList(isForum, threadTopId) val inForumTopic: Boolean get() = isForum && threadTopId > 0 @@ -295,18 +364,25 @@ internal class DialogStoreFactory( private val markup: MarkupParser = KotlinMarkupParser(), private val isPremium: () -> Boolean = { false }, private val markupContext: CoroutineContext = Dispatchers.Default, + private val initialChecklistEdit: ChecklistEditState? = null, ) { fun create(): DialogStore { + val editing = initialChecklistEdit?.message(chatId, threadTopMsgId) + val restored = initialChecklistEdit?.takeIf { editing != null } // Navigation calls this on the main thread. A blocking catalog read there // freezes the window (ANR) and leaves "Updating" on screen. The executor // hydrates the same catalogs off the main thread. if (!onAndroidMainThread()) warmPickerMemory(sessionStore) - return object : - DialogStore, + return object : DialogStore, Store by storeFactory.create( name = "DialogStore", initialState = DialogStore.State( chatId = chatId, + editing = editing, + editingChecklistItemId = restored?.itemId, + draft = restored?.draft.orEmpty(), + draftEntities = TextEntities.parse(restored?.entitiesJson), + draftFormatting = restored?.markdown == true && isPremium(), loading = true, pinnedMessages = PinnedBarMemory.get(chatId.value)?.messages.orEmpty(), pinnedIndex = PinnedBarMemory.get(chatId.value)?.index ?: 0, @@ -344,7 +420,7 @@ internal class DialogStoreFactory( } private fun onAndroidMainThread(): Boolean = try { - val main = android.os.Looper.getMainLooper() ?: return false + val main = getMainLooper() ?: return false android.os.Looper.myLooper() === main } catch (_: RuntimeException) { false diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueComponent.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueComponent.kt new file mode 100644 index 000000000..2bb3fc153 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueComponent.kt @@ -0,0 +1,59 @@ +package org.monogram.feature.dialog.scheduled + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.lifecycle.doOnStart +import com.arkivanov.essenty.lifecycle.doOnStop +import com.arkivanov.mvikotlin.core.instancekeeper.getStore +import com.arkivanov.mvikotlin.core.store.StoreFactory +import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.withContext +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.PeerId +import org.monogram.network.bridge.message.MessageOps +import org.monogram.network.http.MediaRepository + +@OptIn(ExperimentalCoroutinesApi::class) +class ScheduledQueueComponent( + componentContext: ComponentContext, + factory: StoreFactory, + client: MessageOps, + warmup: OfflineWarmup?, + val chatId: PeerId, + val onBack: () -> Unit, + val mediaRepository: MediaRepository? = null, +) : ComponentContext by componentContext { + private val store = instanceKeeper.getStore { + ScheduledQueueStoreFactory( + factory, client, warmup, chatId, loadPreview = mediaRepository?.let { repository -> + { message -> + withContext(Dispatchers.IO) { + repository.ensureLocalScheduledMessageMedia(message) + } + } + }).create() + } + val state = store.stateFlow + + init { + lifecycle.doOnStart { store.accept(ScheduledQueueStore.Intent.Resume) } + lifecycle.doOnStop { store.accept(ScheduledQueueStore.Intent.Pause) } + } + + fun onPreview(id: Int) = store.accept(ScheduledQueueStore.Intent.Preview(id)) + fun onClosePreview() = store.accept(ScheduledQueueStore.Intent.ClosePreview) + fun onRetryPreview() = store.accept(ScheduledQueueStore.Intent.RetryPreview) + fun onEdit( + original: org.monogram.core.models.ScheduledMessage, + text: String, + entities: List + ) = store.accept(ScheduledQueueStore.Intent.Edit(original, text, entities)) + + fun onReschedule(id: Int, date: Int) = + store.accept(ScheduledQueueStore.Intent.Reschedule(id, date)) + + fun onSendNow(id: Int) = store.accept(ScheduledQueueStore.Intent.SendNow(id)) + fun onCancel(id: Int) = store.accept(ScheduledQueueStore.Intent.Cancel(id)) + fun onRefresh() = store.accept(ScheduledQueueStore.Intent.Refresh) +} \ No newline at end of file diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueMedia.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueMedia.kt new file mode 100644 index 000000000..615e82db2 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueMedia.kt @@ -0,0 +1,118 @@ +package org.monogram.feature.dialog.scheduled + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.ScheduledMessage +import org.monogram.core.ui.components.MediaPreviewViewer +import org.monogram.core.ui.rememberEnsuredFile +import org.monogram.core.ui.rememberFileLease +import org.monogram.feature.dialog.R +import org.monogram.network.http.MediaRepository +import org.monogram.network.http.mediaThumbCacheKey +import kotlin.time.Duration.Companion.milliseconds + +@Composable +internal fun scheduledMediaLabel(message: ScheduledMessage): String = stringResource( + when (message.message.mediaKind) { + "video" -> R.string.dialog_media_video + "video_note" -> R.string.dialog_media_video_note + "gif" -> R.string.dialog_media_gif + else -> R.string.dialog_media_photo + } +) + +@Composable +internal fun ScheduledQueueMediaThumb( + message: ScheduledMessage, repository: MediaRepository?, onOpen: () -> Unit +) { + val generation = + repository?.cacheGeneration?.collectAsStateWithLifecycle(initialValue = 0L)?.value ?: 0L + val key = message.message.thumbCacheKey?.takeUnless { it == message.message.mediaCacheKey } + ?: message.message.mediaCacheKey?.let(::mediaThumbCacheKey) + val file = rememberEnsuredFile( + generation, + message to key, + resolve = { key?.let { repository?.cachedFile(it) } }, + ensure = { + val repo = repository ?: return@rememberEnsuredFile null + withContext(Dispatchers.IO) { + when (val result = repo.ensureLocalScheduledMessageThumb(message)) { + is Outcome.Ok -> result.value + is Outcome.Err -> { + if (result.telegramError.kind == TelegramError.Kind.Flood) { + kotlinx.coroutines.delay( + (((result.telegramError.retryAfterSeconds ?: 1).coerceAtLeast(1) + .toLong() + 1) * 1000).milliseconds + ) + } + null + } + } + } + }) + val label = scheduledMediaLabel(message) + val icon = + if (message.message.mediaKind == "photo") Icons.Outlined.Image else Icons.Outlined.PlayArrow + Surface( + onClick = onOpen, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier + .size(56.dp) + .semantics { contentDescription = label; role = Role.Button }) { + if (file != null) AsyncImage( + file, + contentDescription = null, + contentScale = ContentScale.Crop, + error = rememberVectorPainter(icon), + modifier = Modifier.fillMaxSize() + ) + else Box( + Modifier.fillMaxSize(), contentAlignment = Alignment.Center + ) { Icon(icon, null) } + } +} + +@Composable +internal fun ScheduledQueueMediaViewer( + preview: ScheduledQueueStore.PreviewState, onDismiss: () -> Unit, onRetry: () -> Unit +) { + rememberFileLease(preview.file) + val kind = preview.message.message.mediaKind + MediaPreviewViewer( + file = preview.file, + onDismiss = onDismiss, + contentDescription = scheduledMediaLabel(preview.message), + loading = preview.loading, + failed = preview.error != null, + onRetry = onRetry, + caption = preview.message.message.text, + forceVideo = kind != "photo", + loop = kind == "gif" || kind == "video_note", + stateKey = "scheduled:${preview.message.message.id}:${preview.message.message.mediaCacheKey}", + ) +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreen.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreen.kt new file mode 100644 index 000000000..16370b2b9 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueScreen.kt @@ -0,0 +1,302 @@ +package org.monogram.feature.dialog.scheduled + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.monogram.core.models.ScheduledMessage +import org.monogram.core.models.TextEntities +import org.monogram.core.models.TextEntity +import org.monogram.core.ui.components.AppStatusBanner +import org.monogram.core.ui.components.AppSyncStatus +import org.monogram.core.ui.loading.MonogramBusyIconButton +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface +import org.monogram.feature.dialog.R +import java.text.DateFormat +import java.util.Date + +@Composable +fun ScheduledQueueContent(component: ScheduledQueueComponent) { + val state by component.state.collectAsStateWithLifecycle() + ScheduledQueueScreen( + state, + component.onBack, + component::onRefresh, + component::onSendNow, + component::onCancel, + component::onReschedule, + component::onEdit, + onPreview = if (component.mediaRepository != null) component::onPreview else null, + onClosePreview = component::onClosePreview, + onRetryPreview = component::onRetryPreview, + mediaRepository = component.mediaRepository + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ScheduledQueueScreen( + state: ScheduledQueueStore.State, + onBack: () -> Unit, + onRefresh: () -> Unit, + onSendNow: ((Int) -> Unit)? = null, + onCancel: ((Int) -> Unit)? = null, + onReschedule: ((Int, Int) -> Unit)? = null, + onEdit: ((ScheduledMessage, String, List) -> Unit)? = null, + onPreview: ((Int) -> Unit)? = null, + onClosePreview: (() -> Unit)? = null, + onRetryPreview: (() -> Unit)? = null, + mediaRepository: org.monogram.network.http.MediaRepository? = null, +) { + var editing by rememberSaveable(stateSaver = QueueTextEditSession.saver) { + mutableStateOf( + null + ) + } + val session = editing + if (session != null) { + val original = state.messages.firstOrNull { it.message.id.id == session.id } + val unchanged = + original != null && QueueTextEditSession.fingerprint(original) == session.fingerprint + LaunchedEffect( + session, + original, + state.loading, + state.fromCache, + state.needsRefresh, + state.mutatingId, + state.error + ) { + if (session.submittedText != null && original != null && !state.loading && !state.fromCache && !state.needsRefresh && state.mutatingId == null && state.error == null && original.message.text.orEmpty() == session.submittedText && original.message.entities.toSet() == TextEntities.parse( + session.submittedEntitiesJson + ).toSet() + ) { + editing = null + } + } + ScheduledQueueTextEditor( + session, + original, + state, + unchanged, + onRefresh, + { editing = null }, + { + editing = editing?.copy(submittedText = null, submittedEntitiesJson = null) + }) { text, entities -> + if (unchanged && original != null) { + editing = session.copy( + submittedText = text, submittedEntitiesJson = TextEntities.serialize(entities) + ) + onEdit?.invoke(original, text, entities) + } + } + return + } + var actionId by rememberSaveable { mutableStateOf(null) } + var sendAction by rememberSaveable { mutableStateOf(false) } + var rescheduleId by rememberSaveable { mutableStateOf(null) } + val rescheduled = state.messages.firstOrNull { it.message.id.id == rescheduleId } + LaunchedEffect(rescheduled) { if (rescheduled == null) rescheduleId = null } + val selected = state.messages.firstOrNull { it.message.id.id == actionId } + LaunchedEffect(selected) { if (selected == null) actionId = null } + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = { + Text( + stringResource(R.string.dialog_scheduled_messages), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + stringResource(R.string.dialog_back) + ) + } + }, + actions = { + MonogramBusyIconButton( + onClick = onRefresh, + busy = state.loading || state.mutatingId != null, + status = stringResource(R.string.dialog_refresh), + icon = { + Icon(Icons.Outlined.Refresh, stringResource(R.string.dialog_refresh)) + }) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + ) { + AppStatusBanner( + sync = when { + state.loading || state.mutatingId != null -> AppSyncStatus.Syncing; state.fromCache -> AppSyncStatus.FromCache; else -> AppSyncStatus.Hidden + }, + error = state.error, onRetry = onRefresh, + ) + if (state.needsRefresh && state.error == null && !state.loading) { + TextButton(onClick = onRefresh, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(stringResource(R.string.dialog_refresh)) + } + } + if (state.uncertainIds.isNotEmpty()) { + Text( + stringResource(R.string.dialog_scheduled_check_result), + Modifier.padding(horizontal = 16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 8.dp)) { + if (state.messages.isEmpty() && !state.loading && state.error == null) item { + Text( + stringResource(R.string.dialog_scheduled_empty), + modifier = Modifier.padding(24.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + items(state.messages, key = { it.message.id.id }) { scheduled -> + val message = scheduled.message + val id = message.id.id + ListItem( + leadingContent = if (scheduled.canPreviewMedia && onPreview != null) ({ + ScheduledQueueMediaThumb(scheduled, mediaRepository) { onPreview(id) } + }) else null, + headlineContent = { + Text( + message.text?.takeIf(String::isNotBlank) + ?: if (scheduled.canPreviewMedia) scheduledMediaLabel(scheduled) else stringResource( + R.string.dialog_message_hint + ), maxLines = 3, overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + if (message.date == 0x7ffffffeL) stringResource(R.string.dialog_schedule_when_online) + else dateFormat.format(Date(message.date * 1000)) + ) + }, + trailingContent = if (onSendNow != null && onCancel != null) ({ + var menu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { menu = true }, + enabled = !state.fromCache && !state.needsRefresh && state.mutatingId == null && id !in state.uncertainIds + ) { + Icon( + Icons.Outlined.MoreVert, + stringResource(R.string.dialog_scheduled_actions) + ) + } + AppMenuPopup(menu, { menu = false }, alignToAnchorEnd = true) { + AppMenuSurface { + AppMenuItem( + stringResource(R.string.dialog_scheduled_send_now), + onClick = { + menu = false; actionId = id; sendAction = true + }) + if (onEdit != null && scheduled.canEditText) AppMenuItem( + stringResource(R.string.dialog_edit), onClick = { + menu = false; editing = + QueueTextEditSession.capture(scheduled) + }) + if (onReschedule != null) AppMenuItem( + stringResource(R.string.dialog_scheduled_reschedule), + onClick = { + menu = false; rescheduleId = id + }) + AppMenuItem( + stringResource(R.string.dialog_scheduled_cancel), + destructive = true, + onClick = { + menu = false; actionId = id; sendAction = false + }) + } + } + } + }) else null, + ) + } + } + } + } + state.preview?.let { preview -> + if (onClosePreview != null && onRetryPreview != null) ScheduledQueueMediaViewer( + preview, onClosePreview, onRetryPreview + ) + } + if (rescheduled != null) org.monogram.feature.dialog.ui.ScheduleMessageSheet( + initialDate = rescheduled.message.date, + titleRes = R.string.dialog_scheduled_reschedule, + enabled = !state.fromCache && !state.needsRefresh && state.mutatingId == null && rescheduled.message.id.id !in state.uncertainIds, + onDismiss = { rescheduleId = null }, + onSchedule = { date -> + rescheduleId = null + onReschedule?.invoke(rescheduled.message.id.id, date) + }, + ) + if (selected != null) AlertDialog( + onDismissRequest = { actionId = null }, + title = { Text(stringResource(if (sendAction) R.string.dialog_scheduled_send_now else R.string.dialog_scheduled_cancel)) }, + text = { Text(stringResource(if (sendAction) R.string.dialog_scheduled_send_confirm else R.string.dialog_scheduled_cancel_confirm)) }, + confirmButton = { + TextButton( + enabled = !state.fromCache && !state.needsRefresh && state.mutatingId == null && selected.message.id.id !in state.uncertainIds, + onClick = { + actionId = null + if (sendAction) onSendNow?.invoke(selected.message.id.id) else onCancel?.invoke( + selected.message.id.id + ) + }, + ) { Text(stringResource(if (sendAction) R.string.dialog_scheduled_send_now else R.string.dialog_scheduled_cancel)) } + }, + dismissButton = { + TextButton(onClick = { + actionId = null + }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStore.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStore.kt new file mode 100644 index 000000000..786949e5f --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStore.kt @@ -0,0 +1,395 @@ +package org.monogram.feature.dialog.scheduled + +import com.arkivanov.mvikotlin.core.store.Reducer +import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper +import com.arkivanov.mvikotlin.core.store.Store +import com.arkivanov.mvikotlin.core.store.StoreFactory +import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.PeerId +import org.monogram.core.models.ScheduledMessage +import org.monogram.core.models.TextEntity +import org.monogram.network.bridge.message.MessageOps +import kotlin.coroutines.CoroutineContext + +private sealed interface QueueMutation { + data object SendNow : QueueMutation + data object Cancel : QueueMutation + data class Reschedule(val date: Int) : QueueMutation + data class Edit( + val original: ScheduledMessage, val text: String, val entities: List + ) : QueueMutation +} + +interface ScheduledQueueStore : + Store { + sealed interface Intent { + data object Resume : Intent + data object Pause : Intent + data class Preview(val messageId: Int) : Intent + data object ClosePreview : Intent + data object RetryPreview : Intent + data object Refresh : Intent + data class SendNow(val messageId: Int) : Intent + data class Cancel(val messageId: Int) : Intent + data class Reschedule(val messageId: Int, val date: Int) : Intent + data class Edit( + val original: ScheduledMessage, val text: String, val entities: List + ) : Intent + } + + data class PreviewState( + val message: ScheduledMessage, + val file: java.io.File? = null, + val loading: Boolean = false, + val error: TelegramError? = null + ) + + data class State( + val preview: PreviewState? = null, + val messages: List = emptyList(), + val loading: Boolean = false, + val fromCache: Boolean = true, + val needsRefresh: Boolean = false, + val error: TelegramError? = null, + val mutatingId: Int? = null, + val uncertainIds: Set = emptySet(), + ) +} + +internal class ScheduledQueueStoreFactory( + private val factory: StoreFactory, + private val client: MessageOps, + private val warmup: OfflineWarmup?, + private val chatId: PeerId, + private val mainContext: CoroutineContext = Dispatchers.Main.immediate, + private val nowMs: () -> Long = System::currentTimeMillis, + private val elapsedMs: () -> Long = { System.nanoTime() / 1_000_000 }, + private val loadPreview: (suspend (ScheduledMessage) -> Outcome)? = null, +) { + fun create(): ScheduledQueueStore = object : ScheduledQueueStore, + Store by factory.create( + name = "ScheduledQueueStore", initialState = ScheduledQueueStore.State(), + bootstrapper = SimpleBootstrapper(Unit), executorFactory = { Executor() }, + reducer = Reducer { it }, + ) {} + + private inner class Executor : + CoroutineExecutor( + mainContext + ) { + private val accountEpoch = warmup?.outgoingAccountEpoch() + private var refresh: Job? = null + private var resumed = false + private var generation = 0L + private var mutationGeneration = 0L + private var mutation: Job? = null + private var previewJob: Job? = null + private var previewGeneration = 0L + private var blockedWall = 0L + private var blockedElapsed = 0L + + private suspend fun floodError(): TelegramError? { + val persisted = warmup?.outgoingSendBlockedUntil() ?: 0 + if (persisted > blockedWall) { + blockedWall = persisted + blockedElapsed = + maxOf(blockedElapsed, elapsedMs() + (persisted - nowMs()).coerceAtLeast(0)) + } + val remaining = maxOf(blockedWall - nowMs(), blockedElapsed - elapsedMs()) + return if (remaining > 0) TelegramError.parse("FLOOD_WAIT_${(remaining + 999) / 1000}") else null + } + + private suspend fun recordFlood(error: TelegramError) { + if (error.kind != TelegramError.Kind.Flood) return + val delay = (error.retryAfterSeconds?.toLong() ?: 0).coerceAtLeast(0) * 1000 + blockedWall = maxOf(blockedWall, nowMs() + delay) + blockedElapsed = maxOf(blockedElapsed, elapsedMs() + delay) + accountEpoch?.let { warmup?.blockScheduledQueueUntil(blockedWall, it) } + } + + override fun executeAction(action: Unit) { + warmup?.observeScheduledMessages(chatId)?.onEach { rows -> + invalidatePreview(rows) + dispatch(state().copy(messages = ordered(rows))) + }?.launchIn(scope) + } + + override fun executeIntent(intent: ScheduledQueueStore.Intent) { + when (intent) { + ScheduledQueueStore.Intent.Resume -> { + resumed = true; fetch() + state().preview?.takeIf { it.file?.let { file -> file.exists() && file.length() > 0 } != true } + ?.let { openPreview(it.message.message.id.id) } + } + + is ScheduledQueueStore.Intent.Preview -> openPreview(intent.messageId) + ScheduledQueueStore.Intent.ClosePreview -> closePreview() + ScheduledQueueStore.Intent.RetryPreview -> state().preview?.let { openPreview(it.message.message.id.id) } + ScheduledQueueStore.Intent.Refresh -> if (resumed) fetch() + is ScheduledQueueStore.Intent.SendNow -> mutate( + intent.messageId, QueueMutation.SendNow + ) + + is ScheduledQueueStore.Intent.Cancel -> mutate( + intent.messageId, QueueMutation.Cancel + ) + + is ScheduledQueueStore.Intent.Reschedule -> if (org.monogram.feature.dialog.store.validSendSchedule( + intent.date, nowMs() / 1000 + ) + ) mutate(intent.messageId, QueueMutation.Reschedule(intent.date)) + + is ScheduledQueueStore.Intent.Edit -> { + val original = intent.original + if (original.canEditText && original.message.date in 1..Int.MAX_VALUE.toLong() && state().messages.contains( + original + ) && (original.message.mediaKind != null || intent.text.isNotBlank()) && intent.entities.all { it.offset >= 0 && it.length > 0 && it.offset <= intent.text.length - it.length } + ) { + mutate( + original.message.id.id, + QueueMutation.Edit(original, intent.text, intent.entities.toList()) + ) + } + } + + ScheduledQueueStore.Intent.Pause -> { + resumed = false + previewGeneration++ + previewJob?.cancel() + state().preview?.let { dispatch(state().copy(preview = it.copy(loading = false))) } + generation++ + refresh?.cancel() + val pending = state().mutatingId + mutationGeneration++ + mutation?.cancel() + dispatch( + state().copy( + loading = false, + mutatingId = null, + uncertainIds = state().uncertainIds + listOfNotNull(pending) + ) + ) + } + } + } + + private fun closePreview() { + previewGeneration++ + previewJob?.cancel() + dispatch(state().copy(preview = null)) + } + + private fun invalidatePreview(rows: List) { + state().preview?.let { selected -> if (rows.none { it == selected.message }) closePreview() } + } + + private fun openPreview(id: Int) { + val load = loadPreview ?: return + if (!resumed) return + val selected = + state().messages.singleOrNull { it.message.id.id == id && it.canPreviewMedia } + ?: return + if (state().preview?.let { it.message == selected && it.loading } == true) return + previewJob?.cancel() + val token = ++previewGeneration + dispatch( + state().copy( + preview = ScheduledQueueStore.PreviewState( + selected, loading = true + ) + ) + ) + previewJob = scope.launch { + try { + accountEpoch?.let { warmup?.scheduledQueueRevision(chatId, it) } + val wait = floodError() + if (wait != null) { + dispatch( + state().copy( + preview = ScheduledQueueStore.PreviewState( + selected, error = wait + ) + ) + ) + return@launch + } + val result = load(selected) + currentCoroutineContext().ensureActive() + accountEpoch?.let { warmup?.scheduledQueueRevision(chatId, it) } + if (token != previewGeneration || !resumed) return@launch + if (result is Outcome.Err) recordFlood(result.telegramError) + currentCoroutineContext().ensureActive() + if (token != previewGeneration || !resumed) return@launch + dispatch( + state().copy( + preview = when (result) { + is Outcome.Ok -> ScheduledQueueStore.PreviewState( + selected, file = result.value + ) + + is Outcome.Err -> ScheduledQueueStore.PreviewState( + selected, error = result.telegramError + ) + } + ) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + if (token == previewGeneration && resumed) dispatch( + state().copy( + preview = ScheduledQueueStore.PreviewState( + selected, error = TelegramError.parse("RPC 500: INTERNAL") + ) + ) + ) + } + } + } + + private fun ordered(rows: List): List { + require(rows.all { it.message.id.chatId == chatId && it.message.id.id > 0 }) + require(rows.map { it.message.id.id }.distinct().size == rows.size) + return rows.sortedWith(compareBy { it.message.date }.thenBy { it.message.id.id }) + } + + private fun mutate(id: Int, action: QueueMutation) { + if (!resumed || state().fromCache || state().needsRefresh || state().mutatingId != null || id in state().uncertainIds || state().messages.none { it.message.id.id == id }) return + generation++ + refresh?.cancel() + val token = ++mutationGeneration + dispatch(state().copy(loading = false, error = null, mutatingId = id)) + mutation = scope.launch { + var confirmed = false + try { + accountEpoch?.let { warmup?.scheduledQueueRevision(chatId, it) } + currentCoroutineContext().ensureActive() + val wait = floodError() + if (wait != null) { + dispatch(state().copy(error = wait, needsRefresh = true)) + return@launch + } + val result = when (action) { + QueueMutation.SendNow -> client.sendScheduledMessages(chatId, listOf(id)) + QueueMutation.Cancel -> client.deleteScheduledMessages(chatId, listOf(id)) + is QueueMutation.Reschedule -> client.editScheduledMessage( + chatId, id, action.date + ) + + is QueueMutation.Edit -> { + if (!state().messages.contains(action.original)) return@launch + client.editScheduledMessage( + chatId, + id, + action.original.message.date.toInt(), + action.text, + org.monogram.core.models.TextEntities.serialize(action.entities), + noWebpage = true + ) + } + } + currentCoroutineContext().ensureActive() + if (token != mutationGeneration) return@launch + when (result) { + is Outcome.Ok -> confirmed = true + is Outcome.Err -> { + recordFlood(result.telegramError) + dispatch( + state().copy( + error = result.telegramError, + needsRefresh = true, + uncertainIds = state().uncertainIds + id + ) + ) + } + } + } catch (cancelled: CancellationException) { + if (token == mutationGeneration) dispatch( + state().copy( + needsRefresh = true, uncertainIds = state().uncertainIds + id + ) + ) + throw cancelled + } catch (_: Exception) { + if (token == mutationGeneration) dispatch( + state().copy( + error = TelegramError.parse("RPC 500: INTERNAL"), + needsRefresh = true, + uncertainIds = state().uncertainIds + id + ) + ) + } finally { + if (token == mutationGeneration) { + dispatch(state().copy(mutatingId = null)) + if (confirmed && resumed) fetch() + } + } + } + } + + private fun fetch() { + if (state().mutatingId != null || refresh?.isActive == true) return + val token = ++generation + dispatch(state().copy(loading = true, error = null)) + refresh = scope.launch { + try { + val revision = accountEpoch?.let { warmup?.scheduledQueueRevision(chatId, it) } + val wait = floodError() + if (wait != null) { + dispatch(state().copy(error = wait, needsRefresh = true)) + return@launch + } + when (val result = client.getScheduledHistory(chatId)) { + is Outcome.Ok -> { + currentCoroutineContext().ensureActive() + val rows = ordered(result.value) + val accepted = + if (warmup != null && accountEpoch != null && revision != null) warmup.replaceScheduledMessagesIfCurrent( + chatId, revision, rows, accountEpoch + ) + else true + if (warmup == null) invalidatePreview(rows) + dispatch( + state().copy( + messages = if (warmup == null) rows else state().messages, + fromCache = !accepted, needsRefresh = !accepted, + uncertainIds = if (accepted) emptySet() else state().uncertainIds, + ) + ) + } + + is Outcome.Err -> if (token == generation) { + recordFlood(result.telegramError) + dispatch( + state().copy( + error = result.telegramError, needsRefresh = true + ) + ) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + if (token == generation) dispatch( + state().copy( + error = TelegramError.parse("RPC 500: INTERNAL"), needsRefresh = true + ) + ) + } finally { + if (token == generation) dispatch(state().copy(loading = false)) + } + } + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueTextEditor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueTextEditor.kt new file mode 100644 index 000000000..5d7d58d4a --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/scheduled/ScheduledQueueTextEditor.kt @@ -0,0 +1,123 @@ +package org.monogram.feature.dialog.scheduled + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import org.monogram.core.models.ScheduledMessage +import org.monogram.core.models.TextEntity +import org.monogram.core.models.TextEntities +import org.monogram.core.models.remapTextEntities +import org.monogram.core.ui.components.AppStatusBanner +import org.monogram.core.ui.components.AppSyncStatus +import org.monogram.feature.dialog.R +import org.monogram.feature.dialog.ui.MarkdownEditorScreen + +internal data class QueueTextEditSession( + val id: Int, val fingerprint: String, val text: String, val entitiesJson: String?, + val submittedText: String? = null, val submittedEntitiesJson: String? = null, +) { + companion object { + fun capture(original: ScheduledMessage) = QueueTextEditSession( + original.message.id.id, fingerprint(original), + original.message.text.orEmpty(), TextEntities.serialize(original.message.entities) + ) + + // A restored draft must not rebase onto a different queue snapshot. + fun fingerprint(original: ScheduledMessage): String = + java.security.MessageDigest.getInstance("SHA-256") + .digest(original.toString().toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + val saver = Saver( + save = { session -> + session?.let { + listOf( + it.id, + it.fingerprint, + it.text, + it.entitiesJson.orEmpty(), + it.submittedText != null, + it.submittedText.orEmpty(), + it.submittedEntitiesJson.orEmpty() + ) + } + }, + restore = { raw -> + runCatching { + val parts = raw as List<*> + QueueTextEditSession( + parts[0] as Int, parts[1] as String, parts[2] as String, parts[3] as String, + if (parts[4] as Boolean) parts[5] as String else null, parts[6] as String + ) + }.getOrNull() + }, + ) + } +} + +@Composable +internal fun ScheduledQueueTextEditor( + session: QueueTextEditSession, + original: ScheduledMessage?, + state: ScheduledQueueStore.State, + unchanged: Boolean, + onRefresh: () -> Unit, + onDismiss: () -> Unit, + onDraftChange: () -> Unit, + onApply: (String, List) -> Unit, +) { + var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + session.text + ) + ) + } + var entityJson by rememberSaveable { mutableStateOf(session.entitiesJson) } + val entities = TextEntities.parse(entityJson) + val enabled = + unchanged && original?.canEditText == true && !state.fromCache && !state.needsRefresh && + state.mutatingId == null && session.id !in state.uncertainIds && + (original.message.mediaKind != null || value.text.isNotBlank()) && + entities.all { it.offset >= 0 && it.length > 0 && it.offset <= value.text.length - it.length } + Surface(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + AppStatusBanner( + sync = when { + state.loading -> AppSyncStatus.Syncing + state.fromCache -> AppSyncStatus.FromCache + else -> AppSyncStatus.Hidden + }, + error = state.error, onRetry = onRefresh, + ) + if (!unchanged && !state.fromCache && !state.loading) { + Text( + stringResource(R.string.dialog_scheduled_edit_changed), + Modifier.padding(16.dp) + ) + } + MarkdownEditorScreen( + value = value, + onValueChange = { next -> + if (next.text != value.text) onDraftChange() + entityJson = + TextEntities.serialize(remapTextEntities(value.text, next.text, entities)) + value = next + }, + entities = entities, + onEntitiesChange = { onDraftChange(); entityJson = TextEntities.serialize(it) }, + applyEnabled = enabled, + onApply = { if (enabled) onApply(value.text, entities) }, + onDismiss = onDismiss, + modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogComposer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogComposer.kt index 1def96ec0..5bbac77ff 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogComposer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogComposer.kt @@ -77,7 +77,7 @@ internal fun DialogExecutor.applyDraft(text: String, mentions: List { + error = result.telegramError; attempt.copy(phase = ContactImportPhase.UNCERTAIN) + } + + is Outcome.Ok -> { + val value = result.value + val id = attempt.spec.clientId + require(value.imported.size <= 1 && value.retryClientIds.size <= 1 && value.popular.size <= 1) + require(value.imported.all { it.clientId == id && it.userId.value > 0 } && value.retryClientIds.all { it == id }) + require(value.popular.all { it.clientId == id && it.importers >= 0 }) + require(value.imported.isEmpty() || value.retryClientIds.isEmpty()) + // Missing matches can reflect discovery privacy, not just unregistered phones. + val match = value.imported.singleOrNull() + attempt.copy( + phase = when { + match != null -> ContactImportPhase.IMPORTED + value.retryClientIds.isNotEmpty() -> ContactImportPhase.RETRY_REQUIRED + else -> ContactImportPhase.NOT_ADDED + }, userId = match?.userId + ) + } + } + withContext(NonCancellable) { cache.finishContactImport(spec, epoch, completed!!) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + error = TelegramError.parse("RPC 500: INTERNAL") + } finally { + withContext(NonCancellable) { + try { + if (completed == null && retained != null) { + completed = retained!!.copy(phase = ContactImportPhase.UNCERTAIN) + cache.finishContactImport(spec, epoch, completed!!) + } else if (retained == null) completed = cache.contactImportAttempt(spec, epoch) + } catch (_: Exception) { + completed = retained?.copy(phase = ContactImportPhase.UNCERTAIN) + } + if (revision == contactImportRevision && cache.outgoingAccountIsCurrent(epoch)) emit( + Msg.ContactImportState(completed, error = error) + ) + } + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogDrafts.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogDrafts.kt new file mode 100644 index 000000000..0ce9ee8e6 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogDrafts.kt @@ -0,0 +1,103 @@ +package org.monogram.feature.dialog.store + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState +import org.monogram.core.models.Message +import org.monogram.core.models.TextEntities +import org.monogram.core.models.TextEntity +import org.monogram.feature.dialog.DialogStore + +internal val DialogStore.State.canSendComposerContent: Boolean + get() = !draftUnsupported || editing != null + +internal val DialogStore.State.composerReplyId: Int? + get() = replyTo?.id?.id ?: draftReplyId.takeIf { it > 0 } + +internal fun DialogStore.State.cloudDraft(base: CloudDraft): CloudDraft = base.copy( + text = draft, + entitiesJson = TextEntities.serialize((draftEntities + draftMentions.map { + TextEntity("mention_name", it.start, it.length, it.peerId.value.toString()) + }).distinct().sortedWith(compareBy { it.offset }.thenByDescending { it.length })), + replyToMessageId = composerReplyId ?: 0, + noWebpage = linkPreviewHidden, +) + +private fun DialogExecutor.acceptComposerDraft(stored: CloudDraftState) { + if (!work.isActive || draftWritesPending > 0 || stored.revision < draftSavedRevision) return + composerCloudDraft = stored.draft + composerMarkdown = stored.markdown + + if (snapshot().editing != null) return + + emit(Msg.CloudDraftRestored(stored.draft, stored.markdown && isPremium())) + + scheduleComposerAt(stored.draft.text) + scheduleDraftPreview() + scheduleLinkPreview(stored.draft.text) +} + +internal fun DialogExecutor.observeComposerDraft() { + warmup?.draftSync?.observe(chatId, threadTopMsgId)?.onEach { emit(Msg.DraftSync(it)) } + ?.launchIn(work) + warmup?.observeCloudDraft(chatId, threadTopMsgId)?.onEach { acceptComposerDraft(it) } + ?.launchIn(work) +} + +private fun DialogExecutor.writeComposerDraft(write: suspend () -> CloudDraftState) { + draftWritesPending++ + // Finish short local writes in edit order even when the editor is disposed. + work.launch(start = CoroutineStart.UNDISPATCHED) { + val stored = try { + withContext(NonCancellable) { + write().also { draftSavedRevision = maxOf(draftSavedRevision, it.revision) } + } + } finally { + draftWritesPending-- + } + acceptComposerDraft(stored) + } +} + +internal fun DialogExecutor.persistComposerDraft() { + if (snapshot().editing != null) return + val draft = snapshot().cloudDraft(composerCloudDraft) + composerCloudDraft = draft + val markdown = snapshot().draftFormatting + composerMarkdown = markdown + val cache = warmup ?: return + writeComposerDraft { cache.saveLocalCloudDraft(draft, markdown) } +} + +internal fun DialogExecutor.discardComposerDraft() { + if (snapshot().editing != null) return + composerCloudDraft = CloudDraft(chatId, threadTopMsgId) + composerMarkdown = false + emit(Msg.CloudDraftRestored(composerCloudDraft)) + val cache = warmup ?: return + writeComposerDraft { cache.discardLocalCloudDraft(chatId, threadTopMsgId) } +} + +internal fun DialogExecutor.setComposerReply(message: Message?) { + emit(Msg.ReplyTo(message)) + persistComposerDraft() +} + +internal fun DialogExecutor.finishComposerEdit() { + val staged = snapshot().pendingAttach.takeUnless { snapshot().sending }.orEmpty() + emit(Msg.PendingAttach(emptyList())) + work.launch(kotlinx.coroutines.Dispatchers.IO) { + staged.forEach { org.monogram.feature.dialog.releaseAttachCopy(it.path) } + } + emit(Msg.Editing(null)) + emit(Msg.CloudDraftRestored(composerCloudDraft, composerMarkdown && isPremium())) + scheduleDraftPreview() + scheduleComposerAt(composerCloudDraft.text) + scheduleLinkPreview(composerCloudDraft.text) +} 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 3d6f1360b..d87122c41 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 @@ -1,5 +1,6 @@ package org.monogram.feature.dialog.store + import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -20,6 +21,7 @@ import org.monogram.core.models.LastSeen import org.monogram.core.models.Message import org.monogram.core.models.NotifyDefaults import org.monogram.core.models.NotifySettings +import org.monogram.core.models.OutgoingMessageResult import org.monogram.core.models.PeerId import org.monogram.core.models.Profile import org.monogram.core.models.UploadItem @@ -48,6 +50,8 @@ import org.monogram.feature.dialog.tagsMetaKey import org.monogram.feature.dialog.unreadDividerIndex import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoUpdate +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.CoroutineContext private fun presenceLabelChanged( @@ -57,8 +61,11 @@ private fun presenceLabelChanged( nextAt: Long?, ): Boolean { val now = System.currentTimeMillis() - return LastSeen.resolve(previousStatus, previousAt, now) != - LastSeen.resolve(nextStatus, nextAt, now) + return LastSeen.resolve(previousStatus, previousAt, now) != LastSeen.resolve( + nextStatus, + nextAt, + now + ) } internal class DialogExecutor( @@ -74,6 +81,8 @@ internal class DialogExecutor( internal val markupContext: CoroutineContext, mainContext: CoroutineContext, ) : CoroutineExecutor(mainContext) { + internal val pendingPollRefreshes = linkedSetOf() + internal var pollRefreshJob: Job? = null internal var readReceiptJob: Job? = null internal var readReceiptSending = false internal var pendingReadMaxId = 0 @@ -110,6 +119,7 @@ internal class DialogExecutor( internal var activeOlderRequestId: Int? = null internal var pinnedRequested = false internal var cachedOlderTail: List = emptyList() + // Only network pages may advance this cursor. Cached rows can be sparse. internal var serverHistoryBoundaryId: Int? = null internal var anchorToUnread = false @@ -119,6 +129,10 @@ internal class DialogExecutor( internal var forumUnknown: Boolean = seedIsForum == null internal var pendingJump = jumpToMessageId internal var inFlightSends = 0 + internal var composerCloudDraft = org.monogram.core.models.CloudDraft(chatId, threadTopMsgId) + internal var composerMarkdown = false + internal var draftWritesPending = 0 + internal var draftSavedRevision = 0L internal var draftPreviewJob: Job? = null private var notifyDefaults: NotifyDefaults? = null private var rawMuted = false @@ -129,236 +143,267 @@ internal class DialogExecutor( internal val typingJobs = HashMap() internal val viewersInFlight = mutableSetOf() internal val readDatesInFlight = mutableSetOf() - internal val configInFlight = java.util.concurrent.atomic.AtomicBoolean(false) - internal val sendSeq = java.util.concurrent.atomic.AtomicInteger(0) + internal val configInFlight = AtomicBoolean(false) + internal val sendSeq = AtomicInteger(0) internal fun emit(msg: Msg) = dispatch(msg) internal fun snapshot(): DialogStore.State = state() internal val work get() = scope override fun executeAction(action: Unit) { - client.updates() - .onEach { update -> - when (update) { - is MtprotoUpdate.NewMessage -> if (update.message.id.chatId == chatId) { - if (state().searchQuery.isNotBlank()) return@onEach - if (threadTopMsgId > 0 && !inCommentThread(update.message)) return@onEach - absorbIncoming(update.message) + observeComposerDraft() + warmup?.observeOutgoingTextSends()?.onEach { rows -> + emit(Msg.OutgoingTextSends(rows.filter { + it.message.chatId == chatId.value && (threadTopMsgId <= 0 || it.message.replyToTopId == threadTopMsgId) + })) + }?.launchIn(work) + warmup?.observeOutgoingMediaSends()?.onEach { rows -> + emit(Msg.OutgoingMediaSends(rows.filter { + it.batch.chatId == chatId.value && (threadTopMsgId <= 0 || it.items.any { item -> item.message.replyToTopId == threadTopMsgId }) + })) + }?.launchIn(work) + warmup?.outgoingSendSync?.state?.onEach { emit(Msg.OutgoingSendSync(it)) }?.launchIn(work) + client.updates().onEach { update -> + when (update) { + is MtprotoUpdate.PollChanged -> refreshPoll(update.pollId) + is MtprotoUpdate.OutgoingMessageAcknowledged -> { + val result = update.result + if (result is OutgoingMessageResult.Sent && result.message.id.chatId == chatId && state().searchQuery.isBlank() && (threadTopMsgId <= 0 || inCommentThread( + result.message + )) + ) { + absorbIncoming( + result.message.copy(randomId = update.randomId), + matchPendingByRandomIdOnly = true + ) } - is MtprotoUpdate.MessageEdited -> if (update.message.id.chatId == chatId) { - val existing = state().messages.firstOrNull { it.id.id == update.message.id.id } + } + + is MtprotoUpdate.NewMessage -> if (update.message.id.chatId == chatId) { + if (state().searchQuery.isNotBlank()) return@onEach + if (threadTopMsgId > 0 && !inCommentThread(update.message)) return@onEach + absorbIncoming(update.message) + } + + is MtprotoUpdate.MessageEdited -> if (update.message.id.chatId == chatId) { + val existing = + state().messages.firstOrNull { it.id.id == update.message.id.id } ?: state().pinnedMessages.firstOrNull { it.id.id == update.message.id.id } - val next = applyMessageEdit( - listOf(existing ?: update.message), - update.message, - ).first() - if (existing != next) { - dispatch(Msg.MessageEdited(update.message)) - rememberPinned() - } + val next = applyMessageEdit( + listOf(existing ?: update.message), + update.message, + ).first() + if (existing != next) { + dispatch(Msg.MessageEdited(update.message)) + rememberPinned() } - is MtprotoUpdate.MessagesDeleted -> { - if (update.chatId == null || update.chatId == chatId) { - val deleted = update.messageIds.toSet() - if (deleted.isNotEmpty()) { - dispatch(Msg.MessagesDeleted(deleted)) - rememberPinned() - scope.launch { - warmup?.deleteMessages(chatId, deleted) - } + } + + is MtprotoUpdate.MessagesDeleted -> { + if (update.chatId == null || update.chatId == chatId) { + val deleted = update.messageIds.toSet() + if (deleted.isNotEmpty()) { + dispatch(Msg.MessagesDeleted(deleted)) + rememberPinned() + scope.launch { + warmup?.deleteMessages(chatId, deleted) } } } - is MtprotoUpdate.PeerTyping -> if (update.chatId == chatId) { - applyTyping(update.userId, update.typing, update.action) + } + + is MtprotoUpdate.PeerTyping -> if (update.chatId == chatId) { + applyTyping(update.userId, update.typing, update.action) + } + + is MtprotoUpdate.UnreadMentions, is MtprotoUpdate.UnreadReactions, is MtprotoUpdate.UnreadMentionsDelta, is MtprotoUpdate.UnreadReactionsDelta -> handleUnreadCounterUpdate( + update + ) + + is MtprotoUpdate.ReadInbox -> if (update.chatId == chatId) { + val current = state() + if (threadTopMsgId > 0 || update.maxId < current.readInboxMaxId) return@onEach + if (current.readInboxMaxId != update.maxId) { + dispatch(Msg.ReadInbox(update.maxId)) } - is MtprotoUpdate.UnreadMentions, - is MtprotoUpdate.UnreadReactions, - is MtprotoUpdate.UnreadMentionsDelta, - is MtprotoUpdate.UnreadReactionsDelta -> handleUnreadCounterUpdate(update) - is MtprotoUpdate.ReadInbox -> if (update.chatId == chatId) { - val current = state() - if (threadTopMsgId > 0 || update.maxId < current.readInboxMaxId) return@onEach - if (current.readInboxMaxId != update.maxId) { - dispatch(Msg.ReadInbox(update.maxId)) - } - val unread = update.stillUnread.coerceAtLeast(0) - if (current.unreadCount != unread) { - dispatch(Msg.UnreadCount(unread)) - } + val unread = update.stillUnread.coerceAtLeast(0) + if (current.unreadCount != unread) { + dispatch(Msg.UnreadCount(unread)) } - is MtprotoUpdate.ReadOutbox -> if (update.chatId == chatId) { - if (state().readOutboxMaxId != update.maxId) { - dispatch(Msg.ReadOutbox(update.maxId)) - } + } + + is MtprotoUpdate.ReadOutbox -> if (update.chatId == chatId) { + if (state().readOutboxMaxId != update.maxId) { + dispatch(Msg.ReadOutbox(update.maxId)) } - is MtprotoUpdate.PeerEmojiStatus -> { - if (update.userId == chatId && - state().emojiStatusDocumentId != update.documentId - ) { - dispatch(Msg.EmojiStatus(update.documentId)) - } - val sender = state().senders[update.userId] - if (sender != null && sender.emojiStatusDocumentId != update.documentId) { - dispatch( - Msg.Senders( - mapOf( - update.userId to sender.copy( - emojiStatusDocumentId = update.documentId, - ), + } + + is MtprotoUpdate.PeerEmojiStatus -> { + if (update.userId == chatId && state().emojiStatusDocumentId != update.documentId) { + dispatch(Msg.EmojiStatus(update.documentId)) + } + val sender = state().senders[update.userId] + if (sender != null && sender.emojiStatusDocumentId != update.documentId) { + dispatch( + Msg.Senders( + mapOf( + update.userId to sender.copy( + emojiStatusDocumentId = update.documentId, ), ), - ) - } - scope.launch { - sessionStore?.updatePeerEmojiStatus( - update.userId.value, - update.documentId, - ) - } + ), + ) } - is MtprotoUpdate.PeerStatus -> if (update.userId == chatId) { - val current = state() - if (!LastSeen.affectsUi( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) - ) { - return@onEach - } - PeerPresence.record( + scope.launch { + sessionStore?.updatePeerEmojiStatus( update.userId.value, + update.documentId, + ) + } + } + + is MtprotoUpdate.PeerStatus -> if (update.userId == chatId) { + val current = state() + if (!LastSeen.affectsUi( + current.peerStatus, + current.peerStatusAt, update.status, update.statusAt, ) - if (presenceLabelChanged( - current.peerStatus, - current.peerStatusAt, - update.status, - update.statusAt, - ) - ) { - dispatch(Msg.PeerStatus(update.status, update.statusAt)) - } - scope.launch { - sessionStore?.updatePeerStatus( - update.userId.value, - update.status, - update.statusAt, - ) - } + ) { + return@onEach } - is MtprotoUpdate.NotifySettingsChanged -> { - if (update.topicId != null) return@onEach - if (update.peerKind == "peer" && update.chatId == chatId) { - applyPeerNotify(update.muteUntil, update.muteSpecified) - } else if (update.peerKind == "users" || - update.peerKind == "chats" || - update.peerKind == "broadcasts" - ) { - loadDialogNotifyDefaults() - } + PeerPresence.record( + update.userId.value, + update.status, + update.statusAt, + ) + if (presenceLabelChanged( + current.peerStatus, + current.peerStatusAt, + update.status, + update.statusAt, + ) + ) { + dispatch(Msg.PeerStatus(update.status, update.statusAt)) } - is MtprotoUpdate.ChatsChanged -> { - val chat = update.chats.firstOrNull { it.id == chatId } ?: return@onEach - val current = state() - val title = preferredPeerTitle(chat.title, current.title, chat.id.value) - if (current.title != title) dispatch(Msg.Title(title)) - if (chat.readInboxMaxId >= current.readInboxMaxId && current.unreadCount != chat.unreadCount) { - dispatch(Msg.UnreadCount(chat.unreadCount)) - } - applyUnreadCountersFromChat(chat.unreadMentionsCount, chat.unreadReactionsCount) - if (chat.readInboxMaxId > current.readInboxMaxId) { - dispatch(Msg.ReadInbox(chat.readInboxMaxId)) - } - if (current.readOutboxMaxId != chat.readOutboxMaxId) { - dispatch(Msg.ReadOutbox(chat.readOutboxMaxId)) - } - PeerPresence.record( - chat.id.value, + scope.launch { + sessionStore?.updatePeerStatus( + update.userId.value, + update.status, + update.statusAt, + ) + } + } + + is MtprotoUpdate.NotifySettingsChanged -> { + if (update.topicId != null) return@onEach + if (update.peerKind == "peer" && update.chatId == chatId) { + applyPeerNotify(update.muteUntil, update.muteSpecified) + } else if (update.peerKind == "users" || update.peerKind == "chats" || update.peerKind == "broadcasts") { + loadDialogNotifyDefaults() + } + } + + is MtprotoUpdate.ChatsChanged -> { + val chat = update.chats.firstOrNull { it.id == chatId } ?: return@onEach + val current = state() + val title = preferredPeerTitle(chat.title, current.title, chat.id.value) + if (current.title != title) dispatch(Msg.Title(title)) + if (chat.readInboxMaxId >= current.readInboxMaxId && current.unreadCount != chat.unreadCount) { + dispatch(Msg.UnreadCount(chat.unreadCount)) + } + applyUnreadCountersFromChat( + chat.unreadMentionsCount, chat.unreadReactionsCount + ) + if (chat.readInboxMaxId > current.readInboxMaxId) { + dispatch(Msg.ReadInbox(chat.readInboxMaxId)) + } + if (current.readOutboxMaxId != chat.readOutboxMaxId) { + dispatch(Msg.ReadOutbox(chat.readOutboxMaxId)) + } + PeerPresence.record( + chat.id.value, + chat.peerStatus, + chat.peerStatusAt, + ) + if (presenceLabelChanged( + current.peerStatus, + current.peerStatusAt, chat.peerStatus, chat.peerStatusAt, ) - if (presenceLabelChanged( - current.peerStatus, - current.peerStatusAt, - chat.peerStatus, - chat.peerStatusAt, - ) - ) { - dispatch(Msg.PeerStatus(chat.peerStatus, chat.peerStatusAt)) - } - if (current.emojiStatusDocumentId != chat.emojiStatusDocumentId) { - dispatch(Msg.EmojiStatus(chat.emojiStatusDocumentId)) - } - if (current.photoCacheKey != chat.photoCacheKey) { - dispatch(Msg.PhotoCacheKey(chat.photoCacheKey)) - } - val rights = rightsMsg(chat) - val stale = - chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId - if (!stale && !(muteOverride && !chat.muteOverride)) { - rawMuted = chat.muted - muteOverride = chat.muteOverride - sourceLastMessageId = chat.lastMessageId - } - val shown = shownMuted() - if (current.left != chat.left || current.muted != shown) { - dispatch(Msg.Membership(left = chat.left, muted = shown)) - } - if (current.canView != rights.canView || - current.canSendPlain != rights.canSendPlain || - current.canSendPhotos != rights.canSendPhotos || - current.canForward != rights.canForward || - current.canDeleteOthers != rights.canDeleteOthers - ) { - dispatch(rights) - } + ) { + dispatch(Msg.PeerStatus(chat.peerStatus, chat.peerStatusAt)) } - is MtprotoUpdate.SavedGifsChanged -> { - SavedGifMemory.clear() - dispatch(Msg.SavedGifsLoaded(false)) - loadSavedGifs() + if (current.emojiStatusDocumentId != chat.emojiStatusDocumentId) { + dispatch(Msg.EmojiStatus(chat.emojiStatusDocumentId)) } - is MtprotoUpdate.MessageReactions -> if (update.chatId == chatId) { - val current = state().messages.firstOrNull { it.id.id == update.messageId } - if (current?.reactionsJson != update.reactionsJson) { - dispatch(Msg.Reactions(update.messageId, update.reactionsJson)) - } + if (current.photoCacheKey != chat.photoCacheKey) { + dispatch(Msg.PhotoCacheKey(chat.photoCacheKey)) } - is MtprotoUpdate.DiscussionInbox -> if (update.channelId == chatId && - update.topMessageId == ForumIo.historyThreadId(threadTopMsgId) - ) { - val confirmed = maxOf(state().readInboxMaxId, update.readMaxId) - dispatch(Msg.ReadInbox(confirmed)) - dispatch(Msg.UnreadCount(state().messages.count { !it.outgoing && it.id.id > confirmed })) + val rights = rightsMsg(chat) + val stale = + chat.lastMessageId > 0 && sourceLastMessageId > chat.lastMessageId + if (!stale && !(muteOverride && !chat.muteOverride)) { + rawMuted = chat.muted + muteOverride = chat.muteOverride + sourceLastMessageId = chat.lastMessageId } - is MtprotoUpdate.Ignored -> { - parseUpdateMessageId(update.kind)?.let { (randomId, messageId) -> - bindPendingId(randomId, messageId) - return@onEach - } - if (update.kind == "difference_too_long" && - state().searchQuery.isBlank() - ) { - // updates.differenceTooLong advances pts but - // cannot carry the missing messages. Refill - // the visible dialog range from history. - refresh() - } + val shown = shownMuted() + if (current.left != chat.left || current.muted != shown) { + dispatch(Msg.Membership(left = chat.left, muted = shown)) + } + if (current.canView != rights.canView || current.canSendPlain != rights.canSendPlain || current.canSendPhotos != rights.canSendPhotos || current.canSendPolls != rights.canSendPolls || current.canForward != rights.canForward || current.canDeleteOthers != rights.canDeleteOthers) { + dispatch(rights) + } + } + + is MtprotoUpdate.SavedGifsChanged -> { + SavedGifMemory.clear() + dispatch(Msg.SavedGifsLoaded(false)) + loadSavedGifs() + } + + is MtprotoUpdate.MessageReactions -> if (update.chatId == chatId) { + val current = state().messages.firstOrNull { it.id.id == update.messageId } + if (current?.reactionsJson != update.reactionsJson) { + dispatch(Msg.Reactions(update.messageId, update.reactionsJson)) + } + } + + is MtprotoUpdate.DiscussionInbox -> if (update.channelId == chatId && update.topMessageId == ForumIo.historyThreadId( + threadTopMsgId + ) + ) { + val confirmed = maxOf(state().readInboxMaxId, update.readMaxId) + dispatch(Msg.ReadInbox(confirmed)) + dispatch(Msg.UnreadCount(state().messages.count { !it.outgoing && it.id.id > confirmed })) + } + + is MtprotoUpdate.Ignored -> { + parseUpdateMessageId(update.kind)?.let { (randomId, messageId) -> + bindPendingId(randomId, messageId) + return@onEach + } + if (update.kind == "difference_too_long" && state().searchQuery.isBlank()) { + // updates.differenceTooLong advances pts but + // cannot carry the missing messages. Refill + // the visible dialog range from history. + refresh() } - else -> Unit } + + else -> Unit } - .launchIn(scope) + }.launchIn(scope) refresh() loadDialogNotifyDefaults() loadRecentReactions() warmPickerCatalogs() } + internal var contactImportRevision = 0L override fun executeIntent(intent: DialogStore.Intent) { when (intent) { DialogStore.Intent.Refresh -> refresh() @@ -370,6 +415,7 @@ internal class DialogExecutor( loadTopics(reset = true) } } + DialogStore.Intent.Leave -> { resumeHistory = historyJob?.isActive == true historyGen += 1 @@ -391,17 +437,22 @@ internal class DialogExecutor( else if (state().searchQuery.isNotBlank()) search(state().searchQuery) resumeHistory = false } + DialogStore.Intent.LoadOlder -> loadOlder() DialogStore.Intent.LoadNewer -> loadNewer() is DialogStore.Intent.SetDraftFormatting -> { dispatch(Msg.DraftFormatting(intent.enabled && isPremium())) scheduleDraftPreview() + persistComposerDraft() } + is DialogStore.Intent.SetDraftEntities -> { dispatch(Msg.DraftEntities(intent.entities)) dispatch(Msg.DraftFormatting(state().draftFormatting)) scheduleDraftPreview() + persistComposerDraft() } + is DialogStore.Intent.DraftChanged -> { if (state().draft != intent.value) { applyDraft(intent.value) @@ -410,6 +461,7 @@ internal class DialogExecutor( scheduleComposerAt(intent.value) scheduleLinkPreview(intent.value) } + DialogStore.Intent.FixLinkPreview -> fixLinkPreview() DialogStore.Intent.DismissLinkPreview -> dismissLinkPreview() DialogStore.Intent.RestoreLinkPreview -> restoreLinkPreview() @@ -425,44 +477,80 @@ internal class DialogExecutor( ), ), ) + is DialogStore.Intent.AttachMedia -> dispatch( Msg.PendingAttach(intent.items.take(10)), ) + is DialogStore.Intent.AppendMedia -> dispatch( Msg.PendingAttach( - (state().pendingAttach + intent.items) - .distinctBy(UploadItem::path) - .take(10), + (state().pendingAttach + intent.items).distinctBy(UploadItem::path).take(10), ), ) + DialogStore.Intent.ClearAttach -> dispatch(Msg.PendingAttach(emptyList())) is DialogStore.Intent.ReplyTo -> { dispatch(Msg.Editing(null)) dispatch(Msg.ReplyTo(intent.message, focusComposer = intent.focusComposer)) + persistComposerDraft() } - DialogStore.Intent.ClearReply -> dispatch(Msg.ReplyTo(null)) + + DialogStore.Intent.ClearReply -> setComposerReply(null) + DialogStore.Intent.DiscardDraft -> discardComposerDraft() + DialogStore.Intent.RetryDraftSync -> warmup?.draftSync?.requestRetry() is DialogStore.Intent.Edit -> { + val checklist = intent.message.checklist + val item = + intent.itemId?.let { id -> checklist?.items?.singleOrNull { it.id == id } } + if (intent.itemId != null && item == null) return + if (checklist != null && (!state().canCreateChecklist || !intent.message.outgoing || intent.message.pending || intent.message.id.id <= 0 || intent.message.id.chatId != chatId || intent.message.id.id in state().checklistMutations)) return dispatch(Msg.ReplyTo(null)) dispatch(Msg.PendingAttach(emptyList())) - dispatch(Msg.Editing(intent.message)) - applyDraft(intent.message.text.orEmpty()) + dispatch(Msg.Editing(intent.message, intent.itemId)) + applyDraft(item?.text ?: checklist?.title ?: intent.message.text.orEmpty()) dispatch(Msg.DraftFormatting(false)) - dispatch(Msg.DraftEntities(intent.message.entities)) + dispatch( + Msg.DraftEntities( + item?.entities ?: checklist?.entities ?: intent.message.entities + ) + ) scheduleDraftPreview() } - DialogStore.Intent.CancelEdit -> { - dispatch(Msg.Editing(null)) - applyDraft("") - } + + is DialogStore.Intent.ReplaceAttachment -> replaceAttachment(intent) + DialogStore.Intent.CancelEdit -> finishComposerEdit() is DialogStore.Intent.Delete -> delete(intent.messageId, intent.revoke) is DialogStore.Intent.ForwardPick -> openForward(intent.message) DialogStore.Intent.ClearForward -> { dispatch(Msg.ForwardMessage(null)) dispatch(Msg.ForwardQuery("")) } + is DialogStore.Intent.ForwardQuery -> dispatch(Msg.ForwardQuery(intent.value)) is DialogStore.Intent.ForwardTo -> forwardTo(intent.chatId) DialogStore.Intent.ClearForwardHint -> dispatch(Msg.ForwardHint(null)) + is DialogStore.Intent.LoadContactImport -> loadContactImport(intent.spec) + is DialogStore.Intent.ImportContact -> importContact(intent.spec, intent.retry) + is DialogStore.Intent.SendContact -> queueContactSend(intent.spec, intent.options) + is DialogStore.Intent.SendChecklist -> queueChecklistSend(intent.spec, intent.options) + is DialogStore.Intent.SendPoll -> queuePollSend(intent.spec, intent.options) + is DialogStore.Intent.SendWithOptions -> if (snapshot().pendingAttach.isEmpty()) queueTextSend( + intent.text, intent.options + ) + else queueMediaSend(intent.text, intent.options) + + is DialogStore.Intent.RetryMediaSend -> work.launch { + warmup?.retryOutgoingMediaSend( + intent.batchId + ) + }.let { Unit } + + is DialogStore.Intent.RetryTextSend -> work.launch { + warmup?.retryOutgoingTextSend( + intent.randomId + ) + }.let { Unit } + is DialogStore.Intent.Send -> send(intent.text) DialogStore.Intent.RetryFailed -> retryFailed() is DialogStore.Intent.Search -> search(intent.query) @@ -471,11 +559,13 @@ internal class DialogExecutor( dispatch(Msg.SearchQuery("")) refresh() } + is DialogStore.Intent.SaveScroll -> { scope.launch { warmup?.setDialogScroll(chatId, intent.messageId) } } + DialogStore.Intent.NextPinned -> nextPinned() DialogStore.Intent.OpenPinnedList -> dispatch(Msg.PinnedListOpen(true)) DialogStore.Intent.ClosePinnedList -> dispatch(Msg.PinnedListOpen(false)) @@ -494,6 +584,7 @@ internal class DialogExecutor( val id = index?.let { current.messages.getOrNull(it)?.id?.id } if (id != null) dispatch(Msg.Anchor(id, atTop = true)) } + DialogStore.Intent.JumpLatest -> { val searching = state().searchQuery.isNotBlank() if (searching) { @@ -509,6 +600,7 @@ internal class DialogExecutor( } markRead() } + DialogStore.Intent.MarkRead -> markRead() is DialogStore.Intent.ReadReceipts -> readReceiptsEnabled = intent.enabled is DialogStore.Intent.VisibleRead -> visibleRead(intent.messageId, intent.atLiveEdge) @@ -519,12 +611,14 @@ internal class DialogExecutor( dispatch(Msg.ComposerPanel(if (open) ComposerPanels.ATTACH else null)) dispatch(Msg.GifPicker(false)) } + DialogStore.Intent.CloseAttachSheet -> { if (state().composerPanel == ComposerPanels.ATTACH) { dispatch(Msg.ComposerPanel(null)) } dispatch(Msg.GifPicker(false)) } + DialogStore.Intent.ToggleEmojiPanel -> { if (state().composerPanel == ComposerPanels.EMOJI) { dispatch(Msg.ComposerPanel(null)) @@ -532,16 +626,19 @@ internal class DialogExecutor( openEmojiTab(state().emojiTab.ifBlank { ComposerPanels.TAB_EMOJI }) } } + is DialogStore.Intent.SetEmojiTab -> openEmojiTab(intent.tab) DialogStore.Intent.ReloadPickerContent -> { loadSavedGifs() loadStickerCatalog(emoji = false) } + is DialogStore.Intent.InsertEmoji -> { if (intent.glyph.isNotEmpty()) { applyDraft(state().draft + intent.glyph) } } + is DialogStore.Intent.SendUpload -> sendUpload( path = intent.path, kind = intent.kind, @@ -551,8 +648,12 @@ internal class DialogExecutor( width = intent.width, height = intent.height, ) + is DialogStore.Intent.SendAlbum -> sendAlbum(intent.items) - is DialogStore.Intent.OpenStickerPack -> loadStickerPack(intent.setId, intent.accessHash) + is DialogStore.Intent.OpenStickerPack -> loadStickerPack( + intent.setId, intent.accessHash + ) + DialogStore.Intent.LoadSavedGifs -> loadSavedGifs() is DialogStore.Intent.LoadReadReceipts -> loadReadReceipts(intent.messageId) is DialogStore.Intent.LoadReactionUsers -> loadReactionUsers(intent.messageId) @@ -583,33 +684,56 @@ internal class DialogExecutor( } } } + is DialogStore.Intent.OpenComments -> scope.launch { when (val result = client.getDiscussionMessage(chatId, intent.message.id.id)) { is Outcome.Ok -> dispatch( Msg.PendingChat(result.value.chatId, result.value.messageId), ) + is Outcome.Err -> handleError(result.telegramError, false) } } + DialogStore.Intent.ClearPendingChat -> dispatch(Msg.PendingChat(null, null)) DialogStore.Intent.LoadMoreTopics -> loadMoreTopics() - is DialogStore.Intent.ToggleTopicHidden -> toggleTopicHidden(intent.topicId, intent.hidden) + is DialogStore.Intent.ToggleTopicHidden -> toggleTopicHidden( + intent.topicId, intent.hidden + ) + is DialogStore.Intent.SendInlineResult -> sendInlineResult(intent.resultId) DialogStore.Intent.LoadMoreInlineResults -> loadMoreInlineResults() DialogStore.Intent.RetryInlineResults -> retryInlineResults() is DialogStore.Intent.SelectMention -> selectMention(intent.candidate) DialogStore.Intent.LoadMoreMentions -> loadMoreMentions() - is DialogStore.Intent.ToggleChecklist -> toggleChecklist(intent.messageId, intent.itemId) + is DialogStore.Intent.RemoveChecklistItem -> removeChecklistItem( + intent.message, intent.itemId + ) + + is DialogStore.Intent.ToggleChecklist -> toggleChecklist( + intent.messageId, intent.itemId + ) + + is DialogStore.Intent.ClosePoll -> closePoll(intent.messageId) is DialogStore.Intent.VotePoll -> votePoll(intent.messageId, intent.options) - is DialogStore.Intent.AppendChecklistItems -> - appendChecklistItems(intent.messageId, intent.firstId, intent.titles) - is DialogStore.Intent.SendLocation -> - sendLocation(intent.latitude, intent.longitude, intent.livePeriodSeconds) + is DialogStore.Intent.AppendChecklistItems -> appendChecklistItems( + intent.messageId, + intent.firstId, + intent.titles + ) + + is DialogStore.Intent.SendLocation -> sendLocation( + intent.latitude, + intent.longitude, + intent.livePeriodSeconds + ) + is DialogStore.Intent.BotButton -> pressBotButton( intent.messageId, intent.button, intent.fromKeyboard, ) + DialogStore.Intent.ClearBotNotice -> dispatch(Msg.BotNotice(null)) DialogStore.Intent.DismissBotAlert -> dispatch(Msg.BotNotice(null)) DialogStore.Intent.ClearBotUrl -> dispatch(Msg.BotUrl(null)) @@ -648,8 +772,8 @@ internal class DialogExecutor( val unread = cachedChat?.unreadCount ?: 0 // Unread work outranks the remembered position: opening a dialog with unread // messages must land on the first unread one, like Telegram does. - anchorToUnread = unread > 0 && threadTopMsgId <= 0 && - pendingJump <= 0 && state().anchorMessageId == null + anchorToUnread = + unread > 0 && threadTopMsgId <= 0 && pendingJump <= 0 && state().anchorMessageId == null if (!chatTitle.isNullOrBlank() && threadTopMsgId <= 0) { dispatch(Msg.Title(chatTitle)) } @@ -668,7 +792,11 @@ internal class DialogExecutor( dispatch(Msg.Membership(left = chat.left, muted = shownMuted())) } cachedChat?.unreadCount?.let { dispatch(Msg.UnreadCount(it)) } - cachedChat?.let { applyUnreadCountersFromChat(it.unreadMentionsCount, it.unreadReactionsCount) } + cachedChat?.let { + applyUnreadCountersFromChat( + it.unreadMentionsCount, it.unreadReactionsCount + ) + } cachedChat?.readInboxMaxId?.let { dispatch(Msg.ReadInbox(it)) } cachedChat?.readOutboxMaxId?.let { dispatch(Msg.ReadOutbox(it)) } dispatch(Msg.PeerStatus(cachedChat?.peerStatus, cachedChat?.peerStatusAt)) @@ -687,17 +815,6 @@ internal class DialogExecutor( ) } } - val savedDraft = warmup?.let { cache -> - if (cache.usesIoDispatcher) { - withContext(Dispatchers.IO) { cache.draft(chatId, threadTopMsgId) } - } else { - cache.draft(chatId, threadTopMsgId) - } - }.orEmpty() - if (savedDraft.isNotEmpty() && state().draft.isEmpty()) { - dispatch(Msg.Draft(savedDraft)) - scheduleComposerAt(savedDraft) - } if (state().anchorMessageId == null && threadTopMsgId <= 0 && unread <= 0) { dispatch(Msg.Anchor(cachedChat?.dialogScrollMessageId)) } @@ -725,21 +842,28 @@ internal class DialogExecutor( dispatch(Msg.ReadInbox(maxOf(state().readInboxMaxId, savedRead))) if (!hasMemory) dispatch(Msg.Loading(true)) AppLog.api("dialog", "getReplies start chat=${chatId.value} top=$historyTop") - when ( - val result = client.getReplies( - chatId, - historyTop, - HISTORY_FIRST_LIMIT, - ) - ) { + when (val result = client.getReplies( + chatId, + historyTop, + HISTORY_FIRST_LIMIT, + )) { is Outcome.Ok -> { if (gen != historyGen) return@launch serverHistoryBoundaryId = result.value.minOfOrNull { it.id.id } - publishPaintedHistory(result.value, fromCache = false, liveEdge = true, generation = gen) + publishPaintedHistory( + result.value, fromCache = false, liveEdge = true, generation = gen + ) if (gen != historyGen) return@launch resolveSendersLater(state().messages) - dispatch(Msg.HasOlder(historyHasMore(result.value.size, HISTORY_FIRST_LIMIT) || cachedOlderTail.isNotEmpty())) + dispatch( + Msg.HasOlder( + historyHasMore( + result.value.size, HISTORY_FIRST_LIMIT + ) || cachedOlderTail.isNotEmpty() + ) + ) } + is Outcome.Err -> handleError(result.telegramError, true) } if (gen != historyGen) return@launch @@ -750,10 +874,7 @@ internal class DialogExecutor( val remembered = warmup?.recentHistory?.peek(chatId.value).orEmpty() if (remembered.isNotEmpty()) { publishPaintedHistory( - remembered, - fromCache = true, - liveEdge = true, - generation = gen + remembered, fromCache = true, liveEdge = true, generation = gen ) if (gen != historyGen) return@launch dispatch(Msg.Loading(false)) @@ -766,8 +887,8 @@ internal class DialogExecutor( } }.orEmpty() if (gen != historyGen) return@launch - val cached = persisted.ifEmpty { remembered } - .replaceComposedDialogSeed(cachedChat).let(::contiguousHistoryFromNewest) + val cached = persisted.ifEmpty { remembered }.replaceComposedDialogSeed(cachedChat) + .let(::contiguousHistoryFromNewest) val lastId = cachedChat?.lastMessageId ?: 0 val newestCachedId = cached.maxOfOrNull { it.id.id } ?: 0 if (cached.isNotEmpty()) { @@ -795,19 +916,12 @@ internal class DialogExecutor( val slice = warmup?.historySlice(chatId, limit = HISTORY_FIRST_LIMIT) if (slice != null && slice.rows.isNotEmpty() && cached.isEmpty()) { publishPaintedHistory( - slice.rows, - fromCache = true, - liveEdge = true, - generation = gen + slice.rows, fromCache = true, liveEdge = true, generation = gen ) } val sliceNewestId = slice?.rows?.maxOfOrNull { it.id.id } ?: newestCachedId val coveredLatest = - slice != null && - slice.covered && - slice.confirmedEnd && - slice.rows.isNotEmpty() && - (lastId <= 0 || sliceNewestId >= lastId) + slice != null && slice.covered && slice.confirmedEnd && slice.rows.isNotEmpty() && (lastId <= 0 || sliceNewestId >= lastId) if (coveredLatest) { serverHistoryBoundaryId = slice.rows.minOf { it.id.id } dispatch(Msg.HasOlder(!slice.confirmedStart)) @@ -829,26 +943,27 @@ internal class DialogExecutor( "phase=page elapsed_ms=${PerfLog.nowMs() - historyAt} result=ok count=${result.value.size}", ) if (gen != historyGen) return@launch - val vanished = cached.map { it.id.id }.toSet() - - result.value.map { it.id.id }.toSet() + val vanished = + cached.map { it.id.id }.toSet() - result.value.map { it.id.id }.toSet() AppLog.api( "dialog", - "network chat=${chatId.value} count=${result.value.size} " + - "vanished=${vanished.size}" + - if (vanished.isEmpty()) { - "" - } else { - " ids=${vanished.take(12).joinToString(",")}" - }, + "network chat=${chatId.value} count=${result.value.size} " + "vanished=${vanished.size}" + if (vanished.isEmpty()) { + "" + } else { + " ids=${vanished.take(12).joinToString(",")}" + }, ) serverHistoryBoundaryId = result.value.minOfOrNull { it.id.id } - publishPaintedHistory(result.value, fromCache = false, liveEdge = true, generation = gen) + publishPaintedHistory( + result.value, fromCache = false, liveEdge = true, generation = gen + ) if (gen != historyGen) return@launch dispatch( Msg.HasOlder( - historyHasMore(result.value.size, HISTORY_FIRST_LIMIT) || - cached.size > result.value.size || - cachedOlderTail.isNotEmpty(), + historyHasMore( + result.value.size, + HISTORY_FIRST_LIMIT + ) || cached.size > result.value.size || cachedOlderTail.isNotEmpty(), ), ) val persist = suspend { @@ -866,6 +981,7 @@ internal class DialogExecutor( } resolveSendersLater(state().messages) } + is Outcome.Err -> { PerfLog.event( "history", @@ -906,12 +1022,14 @@ internal class DialogExecutor( when (val result = client.getProfile(chatId)) { is Outcome.Ok -> { val profile = result.value - val keepTopicTitle = state().inForumTopic || - ForumIo.historyThreadId(threadTopMsgId) > 0 || - threadTopMsgId == org.monogram.core.models.GENERAL_FORUM_TOPIC_ID + val keepTopicTitle = + state().inForumTopic || ForumIo.historyThreadId(threadTopMsgId) > 0 || threadTopMsgId == org.monogram.core.models.GENERAL_FORUM_TOPIC_ID if (!keepTopicTitle) { val title = preferredPeerTitle(profile.title, state().title, chatId.value) - if (!isPlaceholderPeerTitle(title, chatId.value) && state().title != title) { + if (!isPlaceholderPeerTitle( + title, chatId.value + ) && state().title != title + ) { dispatch(Msg.Title(title)) } } @@ -922,12 +1040,14 @@ internal class DialogExecutor( dispatch(Msg.IsChannel(true)) dispatch(Msg.IsGroup(false)) } + "group", "chat" -> { peerIsGroup = true peerIsChannel = false dispatch(Msg.IsGroup(true)) dispatch(Msg.IsChannel(false)) } + else -> Unit } val shown = shownMuted() @@ -951,6 +1071,7 @@ internal class DialogExecutor( warmup?.applyProfileToChat(profile) if (state().isGroup) loadAdminTags() } + is Outcome.Err -> Unit } } @@ -967,6 +1088,7 @@ internal class DialogExecutor( forgetEvictedDialogMeta(sessionStore, evicted, ::tagsMetaKey) sessionStore?.writeMeta(tagsMetaKey(chatId.value), encodeSenderTags(tags)) } + is Outcome.Err -> Unit } } @@ -1033,15 +1155,13 @@ internal class DialogExecutor( internal fun readOnlyBar() { val current = state() - when ( - readOnlyBarAction( - isChannel = current.isChannel, - isGroup = current.isGroup, - left = current.left, - muted = current.muted, - blockedByMe = current.blockedByMe, - ) - ) { + when (readOnlyBarAction( + isChannel = current.isChannel, + isGroup = current.isGroup, + left = current.left, + muted = current.muted, + blockedByMe = current.blockedByMe, + )) { ReadOnlyBarAction.Join -> joinChat() ReadOnlyBarAction.Unblock -> unblockUser() ReadOnlyBarAction.Mute -> setDialogMuted(true) @@ -1052,13 +1172,11 @@ internal class DialogExecutor( private fun setDialogMuted(muted: Boolean) { scope.launch { val until = if (muted) Int.MAX_VALUE else 0 - when ( - val result = client.updateNotifySettings( - peerKind = "peer", - settings = NotifySettings(muteUntil = until), - chatId = chatId, - ) - ) { + when (val result = client.updateNotifySettings( + peerKind = "peer", + settings = NotifySettings(muteUntil = until), + chatId = chatId, + )) { is Outcome.Ok -> { rawMuted = muted muteOverride = true @@ -1081,6 +1199,7 @@ internal class DialogExecutor( left = false, canSendPlain = result.value.canSendPlain, canSendPhotos = result.value.canSendPhotos, + canSendPolls = result.value.canSendPolls, ), ) rememberChat { @@ -1088,6 +1207,7 @@ internal class DialogExecutor( left = false, canSendPlain = result.value.canSendPlain, canSendPhotos = result.value.canSendPhotos, + canSendPolls = result.value.canSendPolls, ) } } @@ -1122,6 +1242,7 @@ internal class DialogExecutor( muted = state().muted, canSendPlain = state().canSendPlain, canSendPhotos = state().canSendPhotos, + canSendPolls = state().canSendPolls, ) val next = transform(current) warmup?.upsertChats(listOf(next)) @@ -1132,6 +1253,7 @@ internal class DialogExecutor( canView = chat.canView, canSendPlain = chat.canSendPlain, canSendPhotos = chat.canSendPhotos, + canSendPolls = chat.canSendPolls, canForward = chat.canForward, canDeleteOthers = chat.canDeleteOthers, canManageTopics = chat.canManageTopics, @@ -1159,8 +1281,7 @@ internal class DialogExecutor( sessionStore?.readProfile(senderId.value) } val title = cached?.title?.takeIf { it.isNotBlank() } - ?: message.senderName?.takeIf { it.isNotBlank() } - ?: return@forEach + ?: message.senderName?.takeIf { it.isNotBlank() } ?: return@forEach val profile = (cached ?: Profile( id = senderId, kind = "user", @@ -1182,15 +1303,14 @@ internal class DialogExecutor( dispatch(Msg.SenderTags(tags)) SenderTagMemory.put(chatId.value, tags) } - resolved.entries - .filter { it.value.avatarCacheKey.isNullOrBlank() } - .take(8) + resolved.entries.filter { it.value.avatarCacheKey.isNullOrBlank() }.take(8) .forEach { (id, _) -> when (val result = client.getProfile(id)) { is Outcome.Ok -> { dispatch(Msg.Senders(mapOf(id to result.value))) sessionStore?.upsertProfile(result.value) } + is Outcome.Err -> Unit } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogInteractions.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogInteractions.kt index 093e4ba29..0fc0d2488 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogInteractions.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogInteractions.kt @@ -1,177 +1,316 @@ package org.monogram.feature.dialog.store -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome -import org.monogram.core.common.PerfLog import org.monogram.core.common.telegram.TelegramError -import org.monogram.core.markup.forSend -import org.monogram.core.models.Chat -import org.monogram.core.models.ChatActionKind +import org.monogram.core.database.encodeLocationSend +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.database.toEntity +import org.monogram.core.models.ChecklistTextEdit import org.monogram.core.models.ForumIo -import org.monogram.core.models.ForumTopic -import org.monogram.core.models.InlineBotResult -import org.monogram.core.models.InlineBotResults +import org.monogram.core.models.GeoPlace import org.monogram.core.models.Message import org.monogram.core.models.MessageId -import org.monogram.core.models.MessageViewers -import org.monogram.core.models.OutboxReadState -import org.monogram.core.models.PeerId -import org.monogram.core.models.Profile -import org.monogram.core.models.ReactionChoice -import org.monogram.core.models.ReadReceiptConfig import org.monogram.core.models.ReplyButton import org.monogram.core.models.ReplyButtonType import org.monogram.core.models.ReplyMarkups -import org.monogram.core.models.SavedGif -import org.monogram.core.models.StickerPack import org.monogram.core.models.StyledText -import org.monogram.core.models.TextEntities -import org.monogram.core.models.TextEntity -import org.monogram.core.models.TypingPresence -import org.monogram.core.models.UploadItem -import org.monogram.core.models.canShowMessageViewers -import org.monogram.core.models.canShowOutboxReadDate -import org.monogram.core.models.geoPlace -import org.monogram.core.models.isPlaceholderPeerTitle -import org.monogram.core.models.peerAvatarCacheKey -import org.monogram.core.models.playedMediaKind -import org.monogram.core.models.preferredPeerTitle -import org.monogram.feature.dialog.ComposerAt -import org.monogram.feature.dialog.ComposerAtToken -import org.monogram.feature.dialog.ComposerPanels -import org.monogram.feature.dialog.DialogStore -import org.monogram.feature.dialog.DraftMention -import org.monogram.feature.dialog.InlineBotQuery -import org.monogram.feature.dialog.MentionCandidate -import org.monogram.feature.dialog.PinnedBarMemory -import org.monogram.feature.dialog.SEARCH_DEBOUNCE_MS -import org.monogram.feature.dialog.SavedGifMemory -import org.monogram.feature.dialog.SenderTagMemory -import org.monogram.feature.dialog.StickerCatalogMemory -import org.monogram.feature.dialog.StickerPackMemory -import org.monogram.feature.dialog.applyMessageEdit -import org.monogram.feature.dialog.historyPagingAllowed -import org.monogram.feature.dialog.isTransientSendFailure -import org.monogram.feature.dialog.jumpNeedsFetch -import org.monogram.feature.dialog.localMediaCacheKey -import org.monogram.feature.dialog.localMediaPath -import org.monogram.feature.dialog.mergeSenderTags -import org.monogram.feature.dialog.nextRetryText -import org.monogram.feature.dialog.parseUpdateMessageId -import org.monogram.network.bridge.MtprotoClient -import org.monogram.network.bridge.MtprotoUpdate -import kotlin.time.Duration.Companion.milliseconds - -internal fun DialogExecutor.toggleChecklist(messageId: Int, itemId: Int) { - val current = snapshot().messages.firstOrNull { it.id.id == messageId } ?: return - val list = current.checklist ?: return - val item = list.items.firstOrNull { it.id == itemId } ?: return - if (!current.outgoing && !list.othersCanComplete) return - val done = !item.done - val updated = current.copy( - checklist = list.copy( - items = list.items.map { entry -> - if (entry.id == itemId) entry.copy(done = done) else entry - }, - ), +import org.monogram.core.models.poll + +internal fun DialogExecutor.toggleChecklist(messageId: Int, itemId: Int) = mutateChecklist( + messageId, + allowed = { message -> + val list = message.checklist + list != null && (message.outgoing || list.othersCanComplete) && list.items.any { it.id == itemId } + }, + request = { message -> + val item = message.checklist!!.items.first { it.id == itemId } + if (!item.done) client.toggleTodoCompleted(chatId, messageId, listOf(itemId), emptyList()) + else client.toggleTodoCompleted(chatId, messageId, emptyList(), listOf(itemId)) + }, +) + +internal fun DialogExecutor.removeChecklistItem(original: Message, itemId: Int) { + val item = original.checklist?.items?.singleOrNull { it.id == itemId } ?: return + mutateChecklist(original.id.id, allowed = { row -> + snapshot().canCreateChecklist && row.id == original.id && row.outgoing && row.checklist?.let { list -> list.items.size > 1 && list.items.singleOrNull { it.id == itemId } == item } == true + }, request = { + client.removeChecklistItem( + chatId, original.id.id, ChecklistTextEdit( + itemId, "", emptyList(), item.text, item.entities + ) + ) + }, reconciled = { row -> + val current = snapshot() + if (row?.checklist?.items?.none { it.id == itemId } == true && current.editing?.id == original.id && current.editingChecklistItemId == itemId) finishComposerEdit() + }) +} + +internal fun DialogExecutor.editChecklistText( + editing: Message, styled: StyledText, itemId: Int?, revision: Long +) { + val current = snapshot() + if (styled.text.isBlank() || current.sending || !current.canCreateChecklist || current.editing?.id != editing.id || current.editingChecklistItemId != itemId || current.composerRevision != revision) return + val original = editing.checklist ?: return + val originalItem = itemId?.let { id -> original.items.singleOrNull { it.id == id } } + if (itemId != null && originalItem == null) return + val expectedText = originalItem?.text ?: original.title + val expectedEntities = originalItem?.entities ?: original.entities + val spec = ChecklistTextEdit( + itemId, styled.text, styled.entities, expectedText, expectedEntities ) - emit( - Msg.Messages( - value = snapshot().messages.map { if (it.id.id == messageId) updated else it }, - fromCache = true, - replace = true, - ), + mutateChecklist( + editing.id.id, + allowed = { row -> + val list = row.checklist + val item = itemId?.let { id -> list?.items?.singleOrNull { it.id == id } } + snapshot().canCreateChecklist && row.outgoing && list != null && (itemId == null || item != null) && (item?.text + ?: list.title) == expectedText && (item?.entities + ?: list.entities) == expectedEntities + }, + request = { client.editChecklistText(chatId, editing.id.id, spec) }, + reconciled = { row -> + val list = row?.checklist + val item = itemId?.let { id -> list?.items?.singleOrNull { it.id == id } } + val latest = snapshot() + if (list != null && (itemId == null || item != null) && (item?.text + ?: list.title) == styled.text && (item?.entities + ?: list.entities) == styled.entities && latest.editing?.id == editing.id && latest.editingChecklistItemId == itemId + ) { + if (latest.composerRevision == revision) finishComposerEdit() + else if (row != null) emit(Msg.Editing(row, itemId)) + } + }, ) +} + +private fun DialogExecutor.mutateChecklist( + messageId: Int, allowed: (Message) -> Boolean, request: suspend (Message) -> Outcome, + reconciled: (Message?) -> Unit = {}, +) { work.launch { - val result = if (done) { - client.toggleTodoCompleted(chatId, messageId, listOf(itemId), emptyList()) - } else { - client.toggleTodoCompleted(chatId, messageId, emptyList(), listOf(itemId)) + val state = snapshot() + if (!state.canView || messageId <= 0 || messageId in state.checklistMutations) return@launch + val message = state.messages.firstOrNull { it.id.id == messageId && it.id.chatId == chatId } + ?: return@launch + if (message.pending || !allowed(message)) return@launch + val epoch = warmup?.outgoingAccountEpoch() + emit(Msg.ChecklistMutating(messageId, true)) + try { + val result = request(message) + if (epoch != null && warmup?.outgoingAccountIsCurrent(epoch) != true) return@launch + if (result is Outcome.Err) handleError(result.telegramError, false) + when (val refreshed = client.getMessage(chatId, messageId)) { + is Outcome.Err -> if (result is Outcome.Ok) handleError( + refreshed.telegramError, false + ) + + is Outcome.Ok -> { + val row = refreshed.value + if (row != null && row.id != message.id) return@launch + if (epoch != null && warmup?.applyMessageRead( + epoch, chatId, messageId, row + ) != true + ) return@launch + if (row == null) emit(Msg.MessagesDeleted(setOf(messageId))) else emit( + Msg.MessageEdited( + row + ) + ) + reconciled(row) + } + } + } finally { + emit(Msg.ChecklistMutating(messageId, false)) } - if (result is Outcome.Err) handleError(result.telegramError, false) } } -internal fun DialogExecutor.votePoll(messageId: Int, options: List) { - if (options.isEmpty()) return - work.launch { - val result = client.sendPollVote(chatId, messageId, options) - if (result is Outcome.Err) handleError(result.telegramError, false) +internal fun pollVoteAllowed(message: Message, options: List): Boolean { + val poll = message.poll ?: return false + if (message.id.id <= 0 || message.pending || poll.closed || poll.isAnswered && (poll.isQuiz || poll.revotingDisabled)) return false + if (options.isEmpty()) return poll.isAnswered && !poll.isQuiz + if (!poll.multipleChoice && options.size > 1) return false + if (options.map { it.toList() }.distinct().size != options.size) return false + return options.all { option -> poll.answers.any { it.option.contentEquals(option) } } +} + +internal fun DialogExecutor.refreshPoll(pollId: Long) { + val state = snapshot() + if (!state.canView || (state.messages + state.pinnedMessages).none { it.poll?.id == pollId }) return + pendingPollRefreshes += pollId + if (pollRefreshJob?.isActive == true) return + pollRefreshJob = work.launch { + try { + while (pendingPollRefreshes.isNotEmpty()) { + val id = pendingPollRefreshes.first() + pendingPollRefreshes.remove(id) + val current = snapshot() + if (!current.canView) continue + val epoch = warmup?.outgoingAccountEpoch() + val messages = (current.messages + current.pinnedMessages).distinctBy { it.id } + .filter { it.id.chatId == chatId && it.id.id > 0 && !it.pending && it.poll?.id == id } + for (message in messages) { + val result = client.getMessage(chatId, message.id.id) + if (epoch != null && warmup?.outgoingAccountIsCurrent(epoch) != true) return@launch + val now = snapshot() + if (!now.canView) continue + val latest = + (now.messages + now.pinnedMessages).firstOrNull { it.id == message.id } + ?: continue + if (latest.fileName != message.fileName) continue + when (result) { + is Outcome.Err -> handleError(result.telegramError, false) + is Outcome.Ok -> { + val row = result.value + if (row != null && (row.id != message.id || row.poll?.id != id)) continue + if (epoch != null && warmup?.applyMessageRead( + epoch, chatId, message.id.id, row + ) != true + ) return@launch + if (row == null) emit(Msg.MessagesDeleted(setOf(message.id.id))) else emit( + Msg.MessageEdited(row) + ) + } + } + } + } + } finally { + pendingPollRefreshes.clear() + pollRefreshJob = null + } } } -internal fun DialogExecutor.appendChecklistItems(messageId: Int, firstId: Int, titles: List) { - val cleaned = titles.map { it.trim() }.filter { it.isNotEmpty() } - if (cleaned.isEmpty()) return +internal fun DialogExecutor.closePoll(messageId: Int) = mutatePoll(messageId, null) + +internal fun DialogExecutor.votePoll(messageId: Int, options: List) = + mutatePoll(messageId, options) + +private fun DialogExecutor.mutatePoll(messageId: Int, options: List?) { + val requested = options?.map { it.copyOf() } work.launch { - val result = client.appendChecklistItems(chatId, messageId, firstId, cleaned) - if (result is Outcome.Err) handleError(result.telegramError, false) + val state = snapshot() + if (!state.canView || messageId in state.pollVotesInFlight) return@launch + val message = state.messages.firstOrNull { it.id.id == messageId && it.id.chatId == chatId } + ?: return@launch + if (requested == null) { + val poll = message.poll ?: return@launch + if (message.id.id <= 0 || message.pending || poll.closed || !poll.creator) return@launch + } else if (!pollVoteAllowed(message, requested)) return@launch + val epoch = warmup?.outgoingAccountEpoch() + emit(Msg.PollVoting(messageId, true)) + try { + val result = + if (requested == null) client.closePoll(chatId, messageId) else client.sendPollVote( + chatId, messageId, requested + ) + if (epoch != null && warmup?.outgoingAccountIsCurrent(epoch) != true) return@launch + if (result is Outcome.Err) handleError(result.telegramError, false) + when (val refreshed = client.getMessage(chatId, messageId)) { + is Outcome.Err -> if (result is Outcome.Ok) handleError( + refreshed.telegramError, false + ) + + is Outcome.Ok -> { + val row = refreshed.value + if (row != null && row.id != message.id) return@launch + if (epoch != null && warmup?.applyMessageRead( + epoch, chatId, messageId, row + ) != true + ) return@launch + if (row == null) emit(Msg.MessagesDeleted(setOf(messageId))) else emit( + Msg.MessageEdited( + row + ) + ) + } + } + } finally { + emit(Msg.PollVoting(messageId, false)) + } } } -internal fun DialogExecutor.sendLocation(latitude: Double, longitude: Double, livePeriodSeconds: Int) { - val reply = snapshot().replyTo - emit(Msg.ReplyTo(null)) - val randomId = pendingRandomId() - val pending = pendingOutgoing( - path = "", - kind = "geo", - caption = null, - fileName = buildString { - append("{\"lat\":") - append(latitude) - append(",\"long\":") - append(longitude) - if (livePeriodSeconds > 0) { - append(",\"live\":true,\"p\":") - append(livePeriodSeconds) - } - append('}') +internal fun DialogExecutor.appendChecklistItems( + messageId: Int, firstId: Int, titles: List +) { + val cleaned = titles.map { it.trim() }.filter { it.isNotEmpty() } + if (cleaned.isEmpty() || firstId < 0 || firstId.toLong() + cleaned.lastIndex > Int.MAX_VALUE) return + mutateChecklist( + messageId, + allowed = { message -> + val list = message.checklist + list != null && (message.outgoing || list.othersCanAppend) && firstId.toLong() == (list.items.maxOfOrNull { it.id } + ?.toLong() ?: 0L) + 1 }, - fileSize = null, - duration = 0, - width = 0, - height = 0, - randomId = randomId, - groupedId = null, - reply = reply, - entities = emptyList(), + request = { client.appendChecklistItems(chatId, messageId, firstId, cleaned) }, ) - emit(Msg.Append(pending)) - work.launch { - warmup?.upsertMessages(listOf(pending)) - val (replyId, _) = ForumIo.sendReplyIds(threadTopMsgId, reply?.id?.id) - val result = client.sendLocation( - chatId, - latitude, - longitude, - livePeriodSeconds, - replyToMsgId = replyId.takeIf { it > 0 }, - ) - when (result) { - is Outcome.Ok -> { - val confirmed = snapshot().messages.firstOrNull { message -> - message.id.id != pending.id.id && - !message.pending && - message.mediaKind == "geo" && - sameGeo(message, latitude, longitude) - } - if (confirmed != null) { - replaceOutgoing(pending.id.id, confirmed.copy(outgoing = true)) - } +} + +internal fun DialogExecutor.sendLocation( + latitude: Double, longitude: Double, livePeriodSeconds: Int +) { + val cache = warmup ?: return + val current = snapshot() + if (current.sending || !current.canView || !current.canSendPlain || current.blockedByMe || current.left || !current.canSendComposerContent || current.editing != null || current.forwardMessage != null || current.pendingAttach.isNotEmpty()) return + val place = GeoPlace( + latitude, longitude, livePeriodSeconds > 0, livePeriodSeconds + ) + val payload = try { + encodeLocationSend(place) + } catch (_: IllegalArgumentException) { + return + } + val epoch = cache.outgoingAccountEpoch() + emit(Msg.Sending(true)) + work.launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + try { + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) + val randomId = pendingRandomId() + val pending = Message( + MessageId(chatId, pendingMessageId()), + null, + null, + System.currentTimeMillis() / 1000, + outgoing = true, + pending = true, + randomId = randomId, + mediaKind = "geo", + fileName = payload, + replyToMsgId = replyId.takeIf { it > 0 }, + replyToTopId = topId.takeIf { it > 0 }, + forumTopic = ForumIo.historyThreadId(threadTopMsgId) > 0 + ) + withContext(kotlinx.coroutines.NonCancellable) { + cache.enqueueOutgoingTextSend( + OutgoingTextSendEntity( + randomId, + pending.toEntity(), + silent = false, + noWebpage = false, + scheduleDate = null, + webpageUrl = null + ), epoch, consumeDraft = false + ) } - is Outcome.Err -> failOrKeepOutgoing(pending.id.id, result.telegramError) + if (cache.outgoingAccountIsCurrent(epoch) && snapshot().composerRevision == current.composerRevision) setComposerReply( + null + ) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) } } } -internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, fromKeyboard: Boolean) { +internal fun DialogExecutor.pressBotButton( + messageId: Int, button: ReplyButton, fromKeyboard: Boolean +) { when (button.type) { ReplyButtonType.Text -> { if (!snapshot().canSendPlain) return @@ -184,6 +323,7 @@ internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, } } } + ReplyButtonType.Url -> emit(Msg.BotUrl(button.url)) ReplyButtonType.Copy -> emit(Msg.CopyText(button.copyText ?: button.text)) ReplyButtonType.SwitchInline -> { @@ -191,9 +331,7 @@ internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, emit(Msg.BotNotice("inline-switch")) return } - val senderId = snapshot().messages - .firstOrNull { it.id.id == messageId } - ?.senderId + val senderId = snapshot().messages.firstOrNull { it.id.id == messageId }?.senderId val cachedUsername = senderId?.let { snapshot().senders[it]?.username } work.launch { val username = cachedUsername ?: senderId?.let { id -> @@ -202,6 +340,7 @@ internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, sessionStore?.upsertProfile(profile.value) profile.value.username } + is Outcome.Err -> null } } @@ -213,16 +352,15 @@ internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, applyDraft("@$username${query.takeIf { it.isNotBlank() }?.let { " $it" } ?: " "}") } } - ReplyButtonType.User -> emit(Msg.BotUrl(button.url?.let { "tg://user?id=$it" } ?: button.url)) + + ReplyButtonType.User -> emit(Msg.BotUrl(button.url?.let { "tg://user?id=$it" } + ?: button.url)) + ReplyButtonType.Disabled -> Unit - ReplyButtonType.WebApp, - ReplyButtonType.Login, - ReplyButtonType.Game, - ReplyButtonType.Pay, - ReplyButtonType.RequestContact, - ReplyButtonType.RequestLocation, - ReplyButtonType.RequestPoll, - ReplyButtonType.RequestPeer -> emit(Msg.BotNotice(button.type.name.lowercase())) + ReplyButtonType.WebApp, ReplyButtonType.Login, ReplyButtonType.Game, ReplyButtonType.Pay, ReplyButtonType.RequestContact, ReplyButtonType.RequestLocation, ReplyButtonType.RequestPoll, ReplyButtonType.RequestPeer -> emit( + Msg.BotNotice(button.type.name.lowercase()) + ) + ReplyButtonType.Callback -> { if (button.requiresPassword) { emit(Msg.BotNotice("callback-password")) @@ -238,11 +376,12 @@ internal fun DialogExecutor.pressBotButton(messageId: Int, button: ReplyButton, emit(Msg.BotNotice(it, alert = result.value.alert)) } } + is Outcome.Err -> handleError(result.telegramError, false) } } } + ReplyButtonType.Other -> Unit } -} - +} \ No newline at end of file diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogLinkPreview.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogLinkPreview.kt index 8fa540dda..cb49faad7 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogLinkPreview.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogLinkPreview.kt @@ -11,10 +11,7 @@ import org.monogram.core.ui.AppearanceSettings internal fun DialogExecutor.clearLinkPreview() { linkPreviewJob?.cancel() val current = snapshot() - if (current.linkPreview != null || current.linkPreviewUrl != null || - current.linkPreviewLoading || current.linkPreviewHidden || - current.linkPreviewChoice != null || current.linkPreviewUrls.isNotEmpty() - ) { + if (current.linkPreview != null || current.linkPreviewUrl != null || current.linkPreviewLoading || current.linkPreviewHidden || current.linkPreviewChoice != null || current.linkPreviewUrls.isNotEmpty()) { emit( Msg.LinkPreview( preview = null, @@ -41,17 +38,34 @@ internal fun InstantViewPage.toComposerPreview(): WebpagePreview = WebpagePrevie internal fun DialogExecutor.scheduleLinkPreview(draft: String, prefer: String? = null) { val urls = FixedLinkPreviewRules.urls(draft) + val current = snapshot() + if (current.linkPreviewHidden && prefer == null) { + linkPreviewJob?.cancel() + val choice = current.linkPreviewChoice?.takeIf { it in urls } ?: urls.firstOrNull() + val retain = choice != null && choice == current.linkPreviewChoice + emit( + Msg.LinkPreview( + preview = current.linkPreview.takeIf { retain }, + url = if (retain) current.linkPreviewUrl else choice, + fixed = retain && current.linkPreviewFixed, + loading = false, + hidden = true, + choice = choice, + urls = urls, + ) + ) + return + } if (urls.isEmpty()) { clearLinkPreview() return } - val current = snapshot() - val choice = prefer?.takeIf { it in urls } - ?: current.linkPreviewChoice?.takeIf { it in urls } - ?: urls.first() + val choice = prefer?.takeIf { it in urls } ?: current.linkPreviewChoice?.takeIf { it in urls } + ?: urls.first() val ready = current.linkPreview?.hasContent == true - val sameChoice = FixedLinkPreviewRules.matchesPreviewSource(choice, current.linkPreviewUrl) || - choice == current.linkPreviewChoice + val sameChoice = FixedLinkPreviewRules.matchesPreviewSource( + choice, current.linkPreviewUrl + ) || choice == current.linkPreviewChoice if (sameChoice && (ready || current.linkPreviewLoading || current.linkPreviewHidden) && prefer == null) { if (urls != current.linkPreviewUrls || choice != current.linkPreviewChoice) { emit( @@ -156,31 +170,32 @@ internal fun DialogExecutor.dismissLinkPreview() { urls = current.linkPreviewUrls, ), ) + persistComposerDraft() } internal fun DialogExecutor.restoreLinkPreview() { val current = snapshot() - if (current.linkPreview != null) { - emit( - Msg.LinkPreview( - preview = current.linkPreview, - url = current.linkPreviewUrl, - fixed = current.linkPreviewFixed, - loading = false, - hidden = false, - choice = current.linkPreviewChoice, - urls = current.linkPreviewUrls, - ), - ) - return - } - scheduleLinkPreview(current.draft, prefer = current.linkPreviewChoice) + emit( + Msg.LinkPreview( + preview = current.linkPreview, + url = current.linkPreviewUrl, + fixed = current.linkPreviewFixed, + loading = false, + hidden = false, + choice = current.linkPreviewChoice, + urls = current.linkPreviewUrls, + ), + ) + persistComposerDraft() + if (current.linkPreview == null) scheduleLinkPreview( + current.draft, prefer = current.linkPreviewChoice + ) } internal fun DialogExecutor.fixLinkPreview() { if (!AppearanceSettings.state.value.fixLinkPreviews) return - val currentUrl = snapshot().linkPreviewUrl ?: snapshot().draft.let(FixedLinkPreviewRules::firstUrl) - ?: return + val currentUrl = + snapshot().linkPreviewUrl ?: snapshot().draft.let(FixedLinkPreviewRules::firstUrl) ?: return val candidates = FixedLinkPreviewRules.candidateFixedUrls(currentUrl) if (candidates.isEmpty()) return linkPreviewJob?.cancel() @@ -200,10 +215,9 @@ internal fun DialogExecutor.fixLinkPreview() { when (val result = client.getWebPagePreview(candidate.url)) { is Outcome.Ok -> { val preview = result.value.toComposerPreview() - if (!preview.title.isNullOrBlank() || !preview.description.isNullOrBlank() || - preview.hasInstantView - ) { - val nextDraft = FixedLinkPreviewRules.replaceFirstUrl(snapshot().draft, candidate.url) + if (!preview.title.isNullOrBlank() || !preview.description.isNullOrBlank() || preview.hasInstantView) { + val nextDraft = + FixedLinkPreviewRules.replaceFirstUrl(snapshot().draft, candidate.url) if (nextDraft != snapshot().draft) applyDraft(nextDraft) emit( Msg.LinkPreview( @@ -218,6 +232,7 @@ internal fun DialogExecutor.fixLinkPreview() { return@launch } } + is Outcome.Err -> Unit } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMediaSendOptions.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMediaSendOptions.kt new file mode 100644 index 000000000..ebcbdeb90 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMediaSendOptions.kt @@ -0,0 +1,103 @@ +package org.monogram.feature.dialog.store + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.OutgoingMediaUpload +import org.monogram.core.database.entity.OutgoingMediaBatchEntity +import org.monogram.core.database.toEntity +import org.monogram.core.models.MessageSendOptions +import org.monogram.feature.dialog.DialogStore +import org.monogram.feature.dialog.releaseAttachCopy + +internal fun mediaSelectionUnchanged(before: DialogStore.State, after: DialogStore.State): Boolean = + before.attachmentRevision == after.attachmentRevision && before.pendingAttach == after.pendingAttach + +internal fun mediaComposerUnchanged(before: DialogStore.State, after: DialogStore.State): Boolean = + before.composerRevision == after.composerRevision && before.pendingAttach == after.pendingAttach && before.draft == after.draft && before.draftEntities == after.draftEntities && before.draftMentions == after.draftMentions && before.draftFormatting == after.draftFormatting && before.composerReplyId == after.composerReplyId && before.editing == after.editing && before.forwardMessage == after.forwardMessage && before.linkPreviewChoice == after.linkPreviewChoice && before.linkPreviewFixed == after.linkPreviewFixed && before.linkPreviewHidden == after.linkPreviewHidden + +internal fun mediaOptionsAllowed(state: DialogStore.State): Boolean { + if (!state.canView || state.left || state.blockedByMe || state.sending || !state.canSendComposerContent || state.editing != null || state.forwardMessage != null || state.pendingAttach.size !in 1..10) return false + val kinds = state.pendingAttach.map { it.kind }.toSet() + return if (kinds.all { it == "photo" || it == "video" }) state.canSendPhotos else kinds == setOf( + "document" + ) && state.canSendPlain +} + +internal fun DialogExecutor.queueMediaSend(text: String?, options: MessageSendOptions) { + val cache = warmup ?: return + val current = snapshot() + if (!mediaOptionsAllowed(current) || cache.outgoingMediaFiles == null || !validSendSchedule( + options.scheduleDate, System.currentTimeMillis() / 1000 + ) + ) return + val epoch = cache.outgoingAccountEpoch() + val draft = current.cloudDraft(composerCloudDraft) + emit(Msg.Sending(true)) + work.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val stored = cache.captureOutgoingTextDraft(chatId, threadTopMsgId, epoch) + if (stored.draft.hasUnsupportedContent) return@launch + val wire = prepareDraftPayload(text ?: current.draft, current) + if (!mediaOptionsAllowed(snapshot().copy(sending = false)) || !mediaComposerUnchanged( + current, snapshot() + ) + ) return@launch + val groupedId = if (current.pendingAttach.size > 1) pendingRandomId() else null + val uploads = current.pendingAttach.mapIndexed { index, item -> + val file = java.io.File(item.path) + val caption = if (index == 0 && wire.text.isNotEmpty()) wire.text else item.caption + val entities = + if (index == 0 && wire.text.isNotEmpty()) wire.entities else item.captionEntities + val pending = pendingOutgoing( + item.path, + item.kind, + caption.ifEmpty { null }, + item.fileName.ifBlank { file.name }, + null, + item.duration, + item.width, + item.height, + pendingRandomId(), + groupedId, + current.replyTo.takeIf { index == 0 }, + entities, + current.composerReplyId + ) + OutgoingMediaUpload(item.path, item.mimeType, pending.toEntity()) + } + val admitted = cache.retainAndEnqueueOutgoingMedia( + OutgoingMediaBatchEntity( + checkNotNull(uploads.first().message.randomId), + chatId.value, + System.currentTimeMillis() / 1000, + options.silent, + options.scheduleDate, + uploads.size > 1 + ), + uploads, + 20L * 1024 * 1024 * 1024, + epoch, + stored, + consumeDraft = stored.draft.copy(date = 0) == draft.copy(date = 0) && stored.markdown == current.draftFormatting + ) + val latest = snapshot() + val consumeComposer = admitted.draftConsumed && mediaComposerUnchanged(current, latest) + if (mediaSelectionUnchanged(current, latest)) { + emit(Msg.PendingAttach(emptyList())) + current.pendingAttach.forEach { releaseAttachCopy(it.path) } + } + if (consumeComposer) { + applyDraft("") + setComposerReply(null) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMentions.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMentions.kt index f08d6e56c..2cf4d56ca 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMentions.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMentions.kt @@ -1,69 +1,21 @@ package org.monogram.feature.dialog.store -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome -import org.monogram.core.common.PerfLog -import org.monogram.core.common.telegram.TelegramError -import org.monogram.core.models.Chat -import org.monogram.core.models.ChatActionKind import org.monogram.core.models.ForumIo -import org.monogram.core.models.ForumTopic import org.monogram.core.models.InlineBotResult import org.monogram.core.models.InlineBotResults -import org.monogram.core.models.Message -import org.monogram.core.models.MessageId -import org.monogram.core.models.MessageViewers -import org.monogram.core.models.OutboxReadState import org.monogram.core.models.PeerId import org.monogram.core.models.Profile -import org.monogram.core.models.ReactionChoice -import org.monogram.core.models.ReadReceiptConfig -import org.monogram.core.models.ReplyButton -import org.monogram.core.models.ReplyButtonType -import org.monogram.core.models.ReplyMarkups -import org.monogram.core.models.SavedGif -import org.monogram.core.models.StickerPack -import org.monogram.core.models.StyledText -import org.monogram.core.models.TextEntities -import org.monogram.core.models.TextEntity -import org.monogram.core.models.TypingPresence -import org.monogram.core.models.UploadItem -import org.monogram.core.models.canShowMessageViewers -import org.monogram.core.models.canShowOutboxReadDate -import org.monogram.core.models.geoPlace -import org.monogram.core.models.isPlaceholderPeerTitle import org.monogram.core.models.peerAvatarCacheKey -import org.monogram.core.models.playedMediaKind -import org.monogram.core.models.preferredPeerTitle import org.monogram.feature.dialog.ComposerAt import org.monogram.feature.dialog.ComposerAtToken -import org.monogram.feature.dialog.ComposerPanels -import org.monogram.feature.dialog.DialogStore import org.monogram.feature.dialog.DraftMention import org.monogram.feature.dialog.InlineBotQuery import org.monogram.feature.dialog.MentionCandidate -import org.monogram.feature.dialog.PinnedBarMemory -import org.monogram.feature.dialog.SEARCH_DEBOUNCE_MS -import org.monogram.feature.dialog.SavedGifMemory -import org.monogram.feature.dialog.SenderTagMemory -import org.monogram.feature.dialog.StickerCatalogMemory -import org.monogram.feature.dialog.StickerPackMemory -import org.monogram.feature.dialog.applyMessageEdit -import org.monogram.feature.dialog.historyPagingAllowed -import org.monogram.feature.dialog.isTransientSendFailure -import org.monogram.feature.dialog.jumpNeedsFetch -import org.monogram.feature.dialog.localMediaCacheKey -import org.monogram.feature.dialog.mergeSenderTags -import org.monogram.feature.dialog.parseUpdateMessageId -import org.monogram.network.bridge.MtprotoClient -import org.monogram.network.bridge.MtprotoUpdate -import kotlin.time.Duration.Companion.milliseconds internal fun DialogExecutor.scheduleComposerAt(draft: String) { val token = ComposerAt.mentionToken(draft) @@ -87,9 +39,7 @@ internal fun DialogExecutor.scheduleComposerAt(draft: String) { if (!token.hasSpace) startMentionSearch(token, debounce = false) return } - if (snapshot().inlineQuery == pendingInline && - (snapshot().inlineResults != null || snapshot().inlineLoading) - ) { + if (snapshot().inlineQuery == pendingInline && (snapshot().inlineResults != null || snapshot().inlineLoading)) { return } val inflightUser = snapshot().inlineQuery?.username ?: confirmingInlineUser @@ -103,6 +53,7 @@ internal fun DialogExecutor.scheduleComposerAt(draft: String) { confirmInlineBot(pendingInline) } } + else -> startInline(pendingInline, cachedBot) } return @@ -240,6 +191,7 @@ internal suspend fun DialogExecutor.confirmInlineBot(parsed: InlineBotQuery) { emit(Msg.InlinePage(results = null, loading = true, error = false)) fetchInlineResults(current, resolved.value.peerId) } + is Outcome.Err -> { if (currentInlineQuery()?.username == parsed.username) { clearInline() @@ -301,6 +253,7 @@ internal suspend fun DialogExecutor.loadMentions(handle: String, append: Boolean mentionMemberOffset = memberOffset + batch.size hasMore = batch.isNotEmpty() && mentionMemberOffset < page.value.count } + else -> if (append) hasMore = false } when (val search = contactsJob?.await()) { @@ -318,6 +271,7 @@ internal suspend fun DialogExecutor.loadMentions(handle: String, append: Boolean ) } } + else -> Unit } when (val resolved = resolveJob?.await()) { @@ -335,6 +289,7 @@ internal suspend fun DialogExecutor.loadMentions(handle: String, append: Boolean ), ) } + else -> Unit } val hydrate = merged.values.filter { it.isBot }.toList().takeLast(8).map { candidate -> @@ -368,10 +323,12 @@ internal suspend fun DialogExecutor.loadMentions(handle: String, append: Boolean ) } -internal fun DialogExecutor.matchesMention(title: String, username: String?, handle: String): Boolean { +internal fun DialogExecutor.matchesMention( + title: String, username: String?, handle: String +): Boolean { if (handle.isEmpty()) return true - return title.contains(handle, ignoreCase = true) || - username.orEmpty().contains(handle, ignoreCase = true) + return title.contains(handle, ignoreCase = true) || username.orEmpty() + .contains(handle, ignoreCase = true) } internal fun DialogExecutor.mentionCandidate( @@ -429,11 +386,7 @@ internal fun DialogExecutor.clearMentions() { internal fun DialogExecutor.clearInline() { confirmingInlineUser = null - if (snapshot().inlineQuery == null && - snapshot().inlineResults == null && - !snapshot().inlineLoading && - !snapshot().inlineError - ) { + if (snapshot().inlineQuery == null && snapshot().inlineResults == null && !snapshot().inlineLoading && !snapshot().inlineError) { return } emit(Msg.InlineQuery(null)) @@ -483,7 +436,8 @@ internal suspend fun DialogExecutor.fetchInlineResults( offset: String = "", append: Boolean = false, ) { - val botId = knownBotId ?: inlineBots[parsed.username] ?: when (val resolved = client.resolveUsername(parsed.username)) { + val botId = knownBotId ?: inlineBots[parsed.username] ?: when (val resolved = + client.resolveUsername(parsed.username)) { is Outcome.Ok -> { if (!resolved.value.isBot) { if (snapshot().inlineQuery == parsed) { @@ -494,6 +448,7 @@ internal suspend fun DialogExecutor.fetchInlineResults( inlineBots[parsed.username] = resolved.value.peerId resolved.value.peerId } + is Outcome.Err -> { if (snapshot().inlineQuery == parsed) { emit(Msg.InlinePage(results = null, loading = false, error = true)) @@ -513,6 +468,7 @@ internal suspend fun DialogExecutor.fetchInlineResults( ) } } + is Outcome.Err -> { if (snapshot().inlineQuery == parsed) { val kept = snapshot().inlineResults?.takeIf { it.results.isNotEmpty() } @@ -533,10 +489,11 @@ internal suspend fun DialogExecutor.fetchInlineResults( } internal fun DialogExecutor.sendInlineResult(resultId: String) { + if (!snapshot().canSendComposerContent) return val page = snapshot().inlineResults ?: return if (snapshot().sending || resultId.isBlank()) return emit(Msg.Sending(true)) - val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, snapshot().replyTo?.id?.id) + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, snapshot().composerReplyId) work.launch { when (val result = client.sendInlineBotResult( chatId, @@ -549,9 +506,10 @@ internal fun DialogExecutor.sendInlineResult(resultId: String) { emit(Msg.Append(result.value)) warmup?.upsertMessages(listOf(result.value)) applyDraft("") - emit(Msg.ReplyTo(null)) + setComposerReply(null) clearInline() } + is Outcome.Err -> handleError(result.telegramError, false) } emit(Msg.Sending(false)) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMessageLists.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMessageLists.kt index d266ffdfa..a5f0df0fb 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMessageLists.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogMessageLists.kt @@ -2,7 +2,6 @@ package org.monogram.feature.dialog.store import org.monogram.core.models.ForumIo import org.monogram.core.models.Message -import org.monogram.core.models.geoPlace import org.monogram.feature.dialog.historyOrder import kotlin.random.Random @@ -22,35 +21,18 @@ internal fun pendingMessageId(): Int { return id } -internal fun sameGeo(message: Message, latitude: Double, longitude: Double): Boolean { - val place = message.geoPlace ?: return false - return kotlin.math.abs(place.latitude - latitude) < 1e-4 && - kotlin.math.abs(place.longitude - longitude) < 1e-4 -} - internal fun matchingPending(messages: List, incoming: Message): Message? { messages.firstOrNull { it.id.id == incoming.id.id }?.let { return null } - if (incoming.randomId != 0L) { + if (incoming.mediaKind == "geo" && !incoming.outgoing) return null + if (incoming.randomId != null && incoming.randomId != 0L) { messages.firstOrNull { - it.randomId == incoming.randomId && (it.pending || it.failed || it.id.id < 0) + it.id.chatId == incoming.id.chatId && it.randomId == incoming.randomId && (it.pending || it.failed || it.id.id < 0) }?.let { return it } } - if (incoming.mediaKind == "geo") { - incoming.geoPlace?.let { place -> - messages.lastOrNull { pending -> - pending.outgoing && - (pending.pending || pending.failed || pending.id.id < 0) && - pending.mediaKind == "geo" && - sameGeo(pending, place.latitude, place.longitude) - }?.let { return it } - } - } + if (incoming.mediaKind == "geo") return null if (!incoming.outgoing) return null val sameKind = messages.filter { - it.outgoing && - (it.pending || it.failed || it.id.id < 0) && - it.mediaKind == incoming.mediaKind && - (incoming.groupedId == null || it.groupedId == incoming.groupedId) + it.outgoing && (it.pending || it.failed || it.id.id < 0) && it.mediaKind == incoming.mediaKind && (incoming.groupedId == null || it.groupedId == incoming.groupedId) } if (sameKind.isEmpty()) return null incoming.groupedId?.let { group -> @@ -62,16 +44,9 @@ internal fun matchingPending(messages: List, incoming: Message): Messag } } -internal fun visibleMessages(messages: List): List = - messages.filter { - !it.text.isNullOrBlank() || - !it.mediaKind.isNullOrBlank() || - !it.fwdFrom.isNullOrBlank() || - !it.viaBot.isNullOrBlank() || - !it.replyMarkup?.rows.isNullOrEmpty() || - it.pending || - it.failed - } +internal fun visibleMessages(messages: List): List = messages.filter { + !it.text.isNullOrBlank() || !it.mediaKind.isNullOrBlank() || !it.fwdFrom.isNullOrBlank() || !it.viaBot.isNullOrBlank() || !it.replyMarkup?.rows.isNullOrEmpty() || it.pending || it.failed +} internal fun filterThreadMessages( messages: List, @@ -130,11 +105,8 @@ internal fun mergeById(messages: List): List { /** A message newer than the loaded window must not be spliced into older history. */ internal fun incomingFitsOpenWindow( - hasNewer: Boolean, - newestLoadedId: Int, - incomingId: Int -): Boolean = - !hasNewer || incomingId <= newestLoadedId + hasNewer: Boolean, newestLoadedId: Int, incomingId: Int +): Boolean = !hasNewer || incomingId <= newestLoadedId internal fun upsertMessage(messages: List, incoming: Message): List { val index = messages.indexOfFirst { it.id.id == incoming.id.id } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReadReceipts.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReadReceipts.kt index a903b0b9a..b5c3cf139 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReadReceipts.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogReadReceipts.kt @@ -1,91 +1,96 @@ package org.monogram.feature.dialog.store -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome -import org.monogram.core.common.PerfLog -import org.monogram.core.common.telegram.TelegramError -import org.monogram.core.models.Chat -import org.monogram.core.models.ChatActionKind import org.monogram.core.models.ForumIo -import org.monogram.core.models.ForumTopic -import org.monogram.core.models.InlineBotResult -import org.monogram.core.models.InlineBotResults -import org.monogram.core.models.Message -import org.monogram.core.models.MessageId import org.monogram.core.models.MessageViewers import org.monogram.core.models.OutboxReadState import org.monogram.core.models.OutboxReadState.Loading -import org.monogram.core.models.PeerId -import org.monogram.core.models.Profile -import org.monogram.core.models.ReactionChoice -import org.monogram.core.models.ReadReceiptConfig -import org.monogram.core.models.ReplyButton -import org.monogram.core.models.ReplyButtonType -import org.monogram.core.models.ReplyMarkups -import org.monogram.core.models.SavedGif -import org.monogram.core.models.StickerPack -import org.monogram.core.models.StyledText -import org.monogram.core.models.TextEntities -import org.monogram.core.models.TextEntity -import org.monogram.core.models.TypingPresence -import org.monogram.core.models.UploadItem import org.monogram.core.models.canShowMessageViewers import org.monogram.core.models.canShowOutboxReadDate -import org.monogram.core.models.geoPlace -import org.monogram.core.models.isPlaceholderPeerTitle -import org.monogram.core.models.peerAvatarCacheKey import org.monogram.core.models.playedMediaKind -import org.monogram.core.models.preferredPeerTitle -import org.monogram.feature.dialog.ComposerAt -import org.monogram.feature.dialog.ComposerAtToken -import org.monogram.feature.dialog.ComposerPanels -import org.monogram.feature.dialog.DialogStore -import org.monogram.feature.dialog.DraftMention -import org.monogram.feature.dialog.InlineBotQuery -import org.monogram.feature.dialog.MentionCandidate -import org.monogram.feature.dialog.PinnedBarMemory +import org.monogram.core.models.poll import org.monogram.feature.dialog.READ_RECEIPT_DEBOUNCE_MS -import org.monogram.feature.dialog.SEARCH_DEBOUNCE_MS +import org.monogram.feature.dialog.ViewerPageState import org.monogram.feature.dialog.readReceiptTarget import org.monogram.feature.dialog.suppressLiveEdgeRead -import org.monogram.feature.dialog.SavedGifMemory -import org.monogram.feature.dialog.SenderTagMemory -import org.monogram.feature.dialog.StickerCatalogMemory -import org.monogram.feature.dialog.StickerPackMemory -import org.monogram.feature.dialog.applyMessageEdit -import org.monogram.feature.dialog.historyPagingAllowed -import org.monogram.feature.dialog.isTransientSendFailure -import org.monogram.feature.dialog.jumpNeedsFetch -import org.monogram.feature.dialog.localMediaCacheKey -import org.monogram.feature.dialog.mergeSenderTags -import org.monogram.feature.dialog.parseUpdateMessageId -import org.monogram.network.bridge.MtprotoClient -import org.monogram.network.bridge.MtprotoUpdate -import kotlin.time.Duration.Companion.milliseconds -internal fun DialogExecutor.loadReactionUsers(messageId: Int) { - if (snapshot().reactionUsers.containsKey(messageId)) return - work.launch { - when (val result = client.getReactionUsers(chatId, messageId)) { - is Outcome.Ok -> emit(Msg.ReactionUsers(messageId, result.value)) - is Outcome.Err -> emit(Msg.ReactionUsers(messageId, emptyList())) - } - } -} +internal fun DialogExecutor.loadReactionUsers(messageId: Int) = loadViewerPage(messageId, false) + +internal fun DialogExecutor.loadPollVoters(messageId: Int) = loadViewerPage(messageId, true) -internal fun DialogExecutor.loadPollVoters(messageId: Int) { - if (snapshot().pollVoters.containsKey(messageId)) return +private fun DialogExecutor.loadViewerPage(messageId: Int, poll: Boolean) { + val current = snapshot() + if (!current.canView || messageId <= 0) return + val message = + (current.messages + current.pinnedMessages).firstOrNull { it.id.id == messageId && it.id.chatId == chatId } + ?: return + if (poll && message.poll?.publicVoters != true) return + val page = (if (poll) current.pollVoterPages else current.reactionPages)[messageId] + ?: ViewerPageState() + if (page.loading || page.loaded && page.nextOffset == null && !page.failed) return + val offset = if (page.loaded) page.nextOffset else null + val epoch = warmup?.outgoingAccountEpoch() + emit(Msg.ViewerPage(messageId, poll, page.copy(loading = true, failed = false))) work.launch { - when (val result = client.getPollVoters(chatId, messageId)) { - is Outcome.Ok -> emit(Msg.PollVoters(messageId, result.value)) - is Outcome.Err -> emit(Msg.PollVoters(messageId, emptyList())) + try { + val result = if (poll) client.getPollVotersPage(chatId, messageId, offset) + else client.getReactionUsersPage(chatId, messageId, offset) + if (epoch != null && warmup?.outgoingAccountIsCurrent(epoch) != true) return@launch + if (!snapshot().canView) return@launch + when (result) { + is Outcome.Err -> { + emit(Msg.ViewerPage(messageId, poll, page.copy(failed = true))) + handleError(result.telegramError, false) + } + + is Outcome.Ok -> { + val consumed = page.consumedOffsets + (offset ?: "") + if (result.value.nextOffset != null && result.value.nextOffset in consumed) { + emit(Msg.ViewerPage(messageId, poll, page.copy(failed = true))) + return@launch + } + val old = + (if (poll) snapshot().pollVoters else snapshot().reactionUsers)[messageId].orEmpty() + val rows = + (if (page.loaded) old + result.value.viewers else result.value.viewers).distinctBy { + listOf( + it.peerId.value, + it.date, + it.emoticon, + it.documentId, + it.pollOptionHex + ) + } + if (poll) emit(Msg.PollVoters(messageId, rows)) else emit( + Msg.ReactionUsers( + messageId, rows + ) + ) + emit( + Msg.ViewerPage( + messageId, poll, page.copy( + loaded = true, + loading = false, + failed = false, + nextOffset = result.value.nextOffset, + consumedOffsets = consumed + ) + ) + ) + } + } + } finally { + val latest = + (if (poll) snapshot().pollVoterPages else snapshot().reactionPages)[messageId] + if (latest?.loading == true) emit( + Msg.ViewerPage( + messageId, poll, latest.copy(loading = false) + ) + ) } } } @@ -112,20 +117,19 @@ internal fun DialogExecutor.loadReadReceipts(messageId: Int) { emit(Msg.Viewers(messageId, MessageViewers.Loading)) work.launch { ensureReadReceiptConfig() - when ( - val result = client.getMessageViewers( - chatId = snapshot().chatId, - messageId = messageId, - played = played, - memberScanLimit = snapshot().readReceiptConfig.chatReadMarkSizeThreshold, - ) - ) { + when (val result = client.getMessageViewers( + chatId = snapshot().chatId, + messageId = messageId, + played = played, + memberScanLimit = snapshot().readReceiptConfig.chatReadMarkSizeThreshold, + )) { is Outcome.Ok -> { if (result.value !is MessageViewers.Ready) { AppLog.api("dialog", "receipts hidden state=${result.value} id=$messageId") } emit(Msg.Viewers(messageId, result.value)) } + is Outcome.Err -> { AppLog.api("dialog", "getMessageViewers ${result.telegramError}") emit(Msg.Viewers(messageId, MessageViewers.Unavailable)) @@ -137,9 +141,7 @@ internal fun DialogExecutor.loadReadReceipts(messageId: Int) { } AppLog.api( "dialog", - "receipts gated off id=$messageId group=${current.isGroup} channel=${current.isChannel} " + - "members=${current.membersCount} out=${message.outgoing} mid=${message.id.id} " + - "age=${now - message.date}", + "receipts gated off id=$messageId group=${current.isGroup} channel=${current.isChannel} " + "members=${current.membersCount} out=${message.outgoing} mid=${message.id.id} " + "age=${now - message.date}", ) if (canShowOutboxReadDate( message = message, @@ -162,6 +164,7 @@ internal fun DialogExecutor.loadReadReceipts(messageId: Int) { } emit(Msg.OutboxRead(messageId, result.value)) } + is Outcome.Err -> { AppLog.api("dialog", "getOutboxReadDate ${result.telegramError}") emit(Msg.OutboxRead(messageId, OutboxReadState.Unavailable)) @@ -173,7 +176,10 @@ internal fun DialogExecutor.loadReadReceipts(messageId: Int) { } internal fun DialogExecutor.ensureReadReceiptConfig() { - if (snapshot().readReceiptConfig.fromServer || !configInFlight.compareAndSet(false, true)) return + if (snapshot().readReceiptConfig.fromServer || !configInFlight.compareAndSet( + false, true + ) + ) return work.launch { when (val result = client.readReceiptConfig()) { is Outcome.Ok -> emit(Msg.ReadReceiptConfig(result.value)) @@ -277,6 +283,7 @@ internal fun DialogExecutor.acknowledgeRead(tillId: Int, immediate: Boolean) { is Outcome.Ok -> { AppLog.api("dialog", "read ok chat=${chatId.value} max=$maxId") } + is Outcome.Err -> { AppLog.api("dialog", "read failed chat=${chatId.value}") // Put the optimistic state back so a failed receipt does not leave the 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 7fb7cde25..239b95ae7 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 @@ -5,6 +5,7 @@ import org.monogram.core.models.ChatActionKind import org.monogram.core.models.ForumIo import org.monogram.core.models.MessageId import org.monogram.core.models.Presence +import org.monogram.core.models.TextEntities import org.monogram.core.models.TypingPresence import org.monogram.feature.dialog.DialogStore import org.monogram.feature.dialog.applyMessageEdit @@ -20,7 +21,19 @@ internal object DialogReducer : Reducer { loadingOlder = msg.value, prefetchingOlder = msg.value && msg.prefetch, ) + is Msg.LoadingNewer -> copy(loadingNewer = msg.value) + is Msg.ChecklistMutating -> copy(checklistMutations = if (msg.value) checklistMutations + msg.messageId else checklistMutations - msg.messageId) + is Msg.PollVoting -> copy(pollVotesInFlight = if (msg.value) pollVotesInFlight + msg.messageId else pollVotesInFlight - msg.messageId) + is Msg.ContactImportState -> copy( + contactImportAttempt = msg.attempt, + contactImportBusy = msg.busy, + contactImportError = msg.error + ) + + is Msg.ContactAdmitted -> copy(contactAdmissionId = msg.randomId) + is Msg.ChecklistAdmitted -> copy(checklistAdmissionId = msg.randomId) + is Msg.PollAdmitted -> copy(pollAdmissionId = msg.randomId) is Msg.Sending -> copy(sending = msg.value) is Msg.Title -> copy(title = msg.value) is Msg.IsSelf -> copy(isSelf = msg.value) @@ -30,7 +43,40 @@ internal object DialogReducer : Reducer { is Msg.IsForum -> copy(isForum = msg.value) is Msg.Senders -> copy(senders = senders + msg.value) is Msg.SenderTags -> copy(senderTags = msg.value) - is Msg.Draft -> if (draft == msg.value) this else copy( + is Msg.CloudDraftRestored -> { + val entities = TextEntities.parse(msg.value.entitiesJson) + val mentions = entities.filter { it.kind == "mention_name" }.mapNotNull { entity -> + entity.url?.toLongOrNull()?.let { + org.monogram.feature.dialog.DraftMention( + org.monogram.core.models.PeerId(it), + entity.offset, + entity.length + ) + } + } + copy( + composerRevision = composerRevision + 1, + draft = msg.value.text, + draftEntities = entities.filterNot { it.kind == "mention_name" && it.url?.toLongOrNull() != null }, + draftMentions = mentions, + draftReplyId = msg.value.replyToMessageId, + replyTo = (messages + pinnedMessages + listOfNotNull(replyTo)).firstOrNull { + it.id.id == msg.value.replyToMessageId + }, + draftUnsupported = msg.value.hasUnsupportedContent, + linkPreviewHidden = msg.value.noWebpage, + draftFormatting = msg.markdown, + draftHasMarkdown = draftHasMarkdown && draft == msg.value.text, + draftPreview = null, + ) + } + + is Msg.OutgoingMediaSends -> copy(outgoingMediaSends = msg.value) + is Msg.OutgoingTextSends -> copy(outgoingTextSends = msg.value) + is Msg.OutgoingSendSync -> copy(outgoingSendSync = msg.value) + is Msg.DraftSync -> copy(draftSync = msg.value) + is Msg.Draft -> if (draft == msg.value) copy(composerRevision = composerRevision + 1) else copy( + composerRevision = composerRevision + 1, draft = msg.value, draftFormatting = draftFormatting && msg.value.isNotEmpty(), draftEntities = if (msg.value.isEmpty()) emptyList() else draftEntities, @@ -39,13 +85,24 @@ internal object DialogReducer : Reducer { ) is Msg.DraftFormatting -> copy( + composerRevision = composerRevision + 1, draftFormatting = msg.value && draft.isNotEmpty(), draftPreview = null ) - is Msg.DraftEntities -> copy(draftEntities = msg.value, draftPreview = null) + is Msg.DraftEntities -> copy( + draftEntities = msg.value, + draftPreview = null, + composerRevision = composerRevision + 1 + ) + is Msg.DraftPreview -> copy(draftPreview = msg.value, draftHasMarkdown = msg.hasMarkdown) - is Msg.PendingAttach -> copy(pendingAttach = msg.items) + is Msg.PendingAttach -> copy( + pendingAttach = msg.items, + composerRevision = composerRevision + 1, + attachmentRevision = attachmentRevision + 1 + ) + is Msg.LinkPreview -> copy( linkPreview = msg.preview, linkPreviewUrl = msg.url, @@ -55,11 +112,15 @@ internal object DialogReducer : Reducer { linkPreviewChoice = msg.choice, linkPreviewUrls = msg.urls, ) + is Msg.ReplyTo -> copy( + composerRevision = composerRevision + 1, replyTo = msg.value, + draftReplyId = msg.value?.id?.id ?: 0, composerFocusSeq = if (msg.focusComposer) composerFocusSeq + 1 else composerFocusSeq, ) - is Msg.Editing -> copy(editing = msg.value) + + is Msg.Editing -> copy(editing = msg.value, editingChecklistItemId = msg.itemId) is Msg.ForwardMessage -> copy(forwardMessage = msg.value) is Msg.ForwardTargets -> copy(forwardTargets = msg.value) is Msg.ForwardQuery -> copy(forwardQuery = msg.value) @@ -71,15 +132,19 @@ internal object DialogReducer : Reducer { blockedByMe = msg.blockedByMe ?: blockedByMe, canSendPlain = msg.canSendPlain ?: canSendPlain, canSendPhotos = msg.canSendPhotos ?: canSendPhotos, + canSendPolls = msg.canSendPolls ?: canSendPolls, ) + is Msg.Rights -> copy( canView = msg.canView, canSendPlain = msg.canSendPlain, canSendPhotos = msg.canSendPhotos, + canSendPolls = msg.canSendPolls, canForward = msg.canForward, canDeleteOthers = msg.canDeleteOthers, canManageTopics = msg.canManageTopics, ) + is Msg.SearchQuery -> copy(searchQuery = msg.value) is Msg.Searching -> copy(searching = msg.value) is Msg.Messages -> copy( @@ -117,17 +182,26 @@ internal object DialogReducer : Reducer { else -> false }, ) + is Msg.Prepend -> copy( messages = filterThreadMessages(mergeById(msg.value + messages), threadTopId, isForum), ) + is Msg.AppendOlder -> copy( messages = filterThreadMessages(mergeById(messages + msg.value), threadTopId, isForum), ) + is Msg.ReplacePending -> copy( messages = mergeById( - messages.map { if (it.id.id == msg.pendingId) msg.sent else it }, + messages.map { + if (it.id.id == msg.pendingId) msg.sent.copy( + randomId = msg.sent.randomId ?: it.randomId + ) + else it + }, ), ) + is Msg.BindPending -> copy( messages = mergeById( messages.map { message -> @@ -145,10 +219,12 @@ internal object DialogReducer : Reducer { }, ), ) + is Msg.MessageEdited -> copy( messages = applyMessageEdit(messages, msg.value), pinnedMessages = applyMessageEdit(pinnedMessages, msg.value), ) + is Msg.MessagesDeleted -> copy( messages = removeDeletedMessages(messages, msg.ids), pinnedMessages = removeDeletedMessages(pinnedMessages, msg.ids), @@ -156,12 +232,14 @@ internal object DialogReducer : Reducer { (pinnedMessages.count { it.id.id !in msg.ids } - 1).coerceAtLeast(0), ), ) + is Msg.Append -> copy( messages = Presence.withReadState( filterThreadMessages(upsertMessage(messages, msg.value), threadTopId, isForum), readOutboxMaxId, ), ) + is Msg.HasOlder -> copy(hasOlder = msg.value) is Msg.HasNewer -> copy(hasNewer = msg.value) is Msg.PinnedListOpen -> copy(pinnedListOpen = msg.value) @@ -176,6 +254,7 @@ internal object DialogReducer : Reducer { msg.value, ), ) + is Msg.PinnedIndex -> copy(pinnedIndex = msg.value) is Msg.Error -> copy(error = msg.value) is Msg.Typing -> { @@ -191,18 +270,24 @@ internal object DialogReducer : Reducer { } copy(typingUsers = next, typing = next.isNotEmpty()) } + is Msg.PeerStatus -> copy(peerStatus = msg.status, peerStatusAt = msg.at) is Msg.EmojiStatus -> copy(emojiStatusDocumentId = msg.documentId) is Msg.ChatProfile -> copy( membersCount = msg.members ?: membersCount, onlineCount = if (msg.authoritative) msg.online else (msg.online ?: onlineCount), ) + is Msg.ReadOutbox -> copy( readOutboxMaxId = msg.value, messages = Presence.withReadState(messages, msg.value), ) + is Msg.ReadReceiptConfig -> copy(readReceiptConfig = msg.value) is Msg.Viewers -> copy(messageViewers = messageViewers + (msg.messageId to msg.value)) + is Msg.ViewerPage -> if (msg.poll) copy(pollVoterPages = pollVoterPages + (msg.messageId to msg.value)) + else copy(reactionPages = reactionPages + (msg.messageId to msg.value)) + is Msg.ReactionUsers -> copy(reactionUsers = reactionUsers + (msg.messageId to msg.value)) is Msg.PollVoters -> copy(pollVoters = pollVoters + (msg.messageId to msg.value)) is Msg.OutboxRead -> copy(outboxReadStates = outboxReadStates + (msg.messageId to msg.value)) @@ -211,6 +296,7 @@ internal object DialogReducer : Reducer { if (it.id.id == msg.pendingId) it.copy(pending = false, failed = true) else it }, ) + is Msg.Drop -> copy(messages = messages.filterNot { it.id.id == msg.messageId }) is Msg.PhotoCacheKey -> copy(photoCacheKey = msg.value) is Msg.Anchor -> copy( @@ -218,11 +304,13 @@ internal object DialogReducer : Reducer { anchorAtTop = msg.atTop, anchorRequestId = msg.requestId, ) + is Msg.SavedGifs -> copy( savedGifs = msg.value, savedGifsLoaded = true, savedGifsError = msg.error, ) + is Msg.SavedGifsLoaded -> copy(savedGifsLoaded = msg.value) is Msg.GifPicker -> copy(gifPickerOpen = msg.open) is Msg.ComposerPanel -> copy(composerPanel = msg.value) @@ -232,30 +320,36 @@ internal object DialogReducer : Reducer { stickerSetsLoaded = true, stickerSetsError = msg.error, ) + is Msg.EmojiSets -> copy( emojiSets = msg.value, emojiSetsLoaded = true, emojiSetsError = msg.error, ) + is Msg.StickerPackLoading -> copy( loadingStickerPackIds = loadingStickerPackIds + msg.setId, failedStickerPackIds = failedStickerPackIds - msg.setId, ) + is Msg.StickerPackLoaded -> copy( openStickerPack = msg.value, loadedStickerPacks = loadedStickerPacks + (msg.value.id to msg.value), loadingStickerPackIds = loadingStickerPackIds - msg.value.id, failedStickerPackIds = failedStickerPackIds - msg.value.id, ) + is Msg.StickerPackFailed -> copy( loadingStickerPackIds = loadingStickerPackIds - msg.setId, failedStickerPackIds = failedStickerPackIds + msg.setId, ) + is Msg.Reactions -> copy( messages = messages.map { if (it.id.id == msg.messageId) it.copy(reactionsJson = msg.json) else it }, ) + is Msg.PendingChat -> copy(pendingChatId = msg.chatId, pendingChatMessageId = msg.messageId) is Msg.DiscussionUnread -> copy(discussionUnread = msg.value) is Msg.RecentReactions -> copy(recentReactions = msg.value) @@ -268,17 +362,21 @@ internal object DialogReducer : Reducer { } copy(topics = ForumIo.sortForumTopics(merged), topicsCount = msg.count) } + is Msg.LoadingTopics -> copy(loadingTopics = msg.value) is Msg.HasMoreTopics -> copy(hasMoreTopics = msg.value) is Msg.TopicClosed -> copy( topicClosed = msg.value, canSendPlain = canSendPlain && !msg.value, + canSendPolls = canSendPolls && !msg.value, ) + is Msg.TopicHeader -> copy( title = msg.title.ifBlank { title }, topicIconColor = msg.iconColor, topicIconEmojiId = msg.iconEmojiId, ) + is Msg.BotNotice -> copy(botNotice = msg.text, botAlert = msg.alert && msg.text != null) is Msg.BotUrl -> copy(botUrl = msg.value) is Msg.CopyText -> copy(copyText = msg.value) @@ -289,12 +387,17 @@ internal object DialogReducer : Reducer { inlineLoading = msg.loading, inlineError = msg.error, ) + is Msg.MentionQuery -> copy(mentionToken = msg.value) is Msg.MentionPage -> copy( mentionCandidates = msg.candidates, mentionLoading = msg.loading, mentionHasMore = msg.hasMore, ) - is Msg.DraftMentions -> copy(draftMentions = msg.value) + + is Msg.DraftMentions -> copy( + draftMentions = msg.value, + composerRevision = composerRevision + 1 + ) } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogScheduledSend.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogScheduledSend.kt new file mode 100644 index 000000000..796ceee54 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogScheduledSend.kt @@ -0,0 +1,251 @@ +package org.monogram.feature.dialog.store + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.encodeContactSend +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.database.toEntity +import org.monogram.core.models.ChecklistSendSpec +import org.monogram.core.models.ContactSendSpec +import org.monogram.core.models.ForumIo +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.MessageSendOptions +import org.monogram.core.models.PollSendSpec + +internal fun validSendSchedule(date: Int?, now: Long): Boolean = + date == null || date.toLong() in (now + 60)..minOf( + now + 365L * 24 * 60 * 60, Int.MAX_VALUE.toLong() - 2 + ) + +internal fun DialogExecutor.queueChecklistSend( + spec: ChecklistSendSpec, options: MessageSendOptions +) { + val cache = warmup ?: return + val current = snapshot() + if (!validSendSchedule(options.scheduleDate, System.currentTimeMillis() / 1000)) return + try { + spec.requireValid() + } catch (_: IllegalArgumentException) { + return + } + val epoch = cache.outgoingAccountEpoch() + emit(Msg.Sending(true)) + work.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) + val randomId = pendingRandomId() + val pending = Message( + id = MessageId(chatId, pendingMessageId()), + senderId = null, + text = spec.title, + date = System.currentTimeMillis() / 1000, + outgoing = true, + pending = true, + randomId = randomId, + mediaKind = "todo", + checklist = spec.pendingChecklist(), + replyToMsgId = replyId.takeIf { it > 0 }, + replyToTopId = topId.takeIf { it > 0 }, + forumTopic = ForumIo.historyThreadId(threadTopMsgId) > 0 + ) + withContext(NonCancellable) { + cache.enqueueOutgoingTextSend( + OutgoingTextSendEntity( + randomId, + pending.toEntity(), + options.silent, + false, + options.scheduleDate, + null + ), epoch, consumeDraft = false + ) + emit(Msg.ChecklistAdmitted(randomId)) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) + } + } +} + +internal fun DialogExecutor.queueContactSend( + spec: ContactSendSpec, options: MessageSendOptions +) { + val cache = warmup ?: return + val current = snapshot() + if (current.sending || !current.canView || !current.canSendComposerContent || !current.canSendPlain || current.editing != null || current.pendingAttach.isNotEmpty() || current.forwardMessage != null || options.noWebpage) return + if (!validSendSchedule(options.scheduleDate, System.currentTimeMillis() / 1000)) return + try { + spec.requireValid() + } catch (_: IllegalArgumentException) { + return + } + val epoch = cache.outgoingAccountEpoch() + emit(Msg.Sending(true)) + work.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) + val randomId = pendingRandomId() + val pending = Message( + id = MessageId(chatId, pendingMessageId()), + senderId = null, + text = spec.displayName, + date = System.currentTimeMillis() / 1000, + outgoing = true, + pending = true, + mediaKind = "contact", + fileName = encodeContactSend(spec), + replyToMsgId = replyId.takeIf { it > 0 }, + replyToTopId = topId.takeIf { it > 0 }, + forumTopic = ForumIo.historyThreadId(threadTopMsgId) > 0, + randomId = randomId + ) + withContext(NonCancellable) { + cache.enqueueOutgoingTextSend( + OutgoingTextSendEntity( + randomId, + pending.toEntity(), + options.silent, + false, + options.scheduleDate, + null + ), epoch, consumeDraft = false + ) + emit(Msg.ContactAdmitted(randomId)) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) + } + } +} + +internal fun DialogExecutor.queuePollSend( + spec: PollSendSpec, options: MessageSendOptions +) { + val cache = warmup ?: return + val current = snapshot() + if (current.sending || !current.canCreatePoll || !current.canSendComposerContent || current.editing != null || current.pendingAttach.isNotEmpty() || current.forwardMessage != null || options.noWebpage) return + if (!validSendSchedule(options.scheduleDate, System.currentTimeMillis() / 1000)) return + try { + spec.requireValid() + } catch (_: IllegalArgumentException) { + return + } + val epoch = cache.outgoingAccountEpoch() + emit(Msg.Sending(true)) + work.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) + val randomId = pendingRandomId() + val pending = Message( + id = MessageId(chatId, pendingMessageId()), + senderId = null, + text = spec.question, + date = System.currentTimeMillis() / 1000, + outgoing = true, + pending = true, + randomId = randomId, + mediaKind = "poll", + fileName = org.monogram.core.database.encodePollSend(spec), + replyToMsgId = replyId.takeIf { it > 0 }, + replyToTopId = topId.takeIf { it > 0 }, + forumTopic = ForumIo.historyThreadId(threadTopMsgId) > 0 + ) + withContext(NonCancellable) { + cache.enqueueOutgoingTextSend( + OutgoingTextSendEntity( + randomId, + pending.toEntity(), + options.silent, + false, + options.scheduleDate, + null + ), epoch, consumeDraft = false + ) + emit(Msg.PollAdmitted(randomId)) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) + } + } +} + +internal fun DialogExecutor.queueTextSend(text: String?, options: MessageSendOptions) { + val cache = warmup ?: return + val current = snapshot() + val raw = text ?: current.draft + if (current.sending || !current.canSendComposerContent || !current.canSendPlain || current.editing != null || current.pendingAttach.isNotEmpty() || current.forwardMessage != null || raw.isBlank()) return + if (!validSendSchedule(options.scheduleDate, System.currentTimeMillis() / 1000)) return + val epoch = cache.outgoingAccountEpoch() + val draft = current.cloudDraft(composerCloudDraft) + emit(Msg.Sending(true)) + work.launch(start = CoroutineStart.UNDISPATCHED) { + try { + val storedDraft = cache.captureOutgoingTextDraft(chatId, threadTopMsgId, epoch) + if (storedDraft.draft.hasUnsupportedContent) return@launch + val consumeDraft = + storedDraft.draft.copy(date = 0) == draft.copy(date = 0) && storedDraft.markdown == current.draftFormatting + val wire = prepareDraftPayload(raw, current) + if (!snapshot().canSendComposerContent || !snapshot().canSendPlain) return@launch + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) + val randomId = pendingRandomId() + val dice = org.monogram.core.models.Dice.emoticonForSend(wire.text) + .takeIf { wire.entities.isEmpty() && options.scheduleDate == null } + val pending = Message( + id = MessageId(chatId, pendingMessageId()), + senderId = null, + text = wire.text.takeIf { dice == null }, + mediaKind = if (dice != null) "dice" else null, + fileName = dice?.let { "{\"e\":\"$it\",\"v\":0}" }, + date = System.currentTimeMillis() / 1000, + outgoing = true, + pending = true, + randomId = randomId, + entities = wire.entities, + replyQuote = current.replyTo?.text, + replyToMsgId = replyId.takeIf { it > 0 }, + replyToTopId = topId.takeIf { it > 0 }, + forumTopic = ForumIo.historyThreadId(threadTopMsgId) > 0, + ) + val consumed = withContext(NonCancellable) { + cache.enqueueOutgoingTextSend( + OutgoingTextSendEntity( + randomId, + pending.toEntity(), + options.silent, + dice == null && (options.noWebpage || current.linkPreviewHidden), + options.scheduleDate, + if (dice == null) webpageUrlForSend(wire.text, current) else null, + ), epoch, storedDraft, consumeDraft + ) + } + val latest = snapshot() + if (consumed && latest.draft == current.draft && latest.draftEntities == current.draftEntities && latest.draftMentions == current.draftMentions && latest.draftFormatting == current.draftFormatting && latest.composerReplyId == current.composerReplyId && latest.pendingAttach == current.pendingAttach && latest.editing == current.editing && latest.linkPreviewChoice == current.linkPreviewChoice && latest.linkPreviewFixed == current.linkPreviewFixed && latest.linkPreviewHidden == current.linkPreviewHidden) { + applyDraft("") + setComposerReply(null) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + handleError(TelegramError.parse("RPC 500: INTERNAL"), false) + } finally { + emit(Msg.Sending(false)) + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt index fbed08b6a..3df5dbeb5 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt @@ -1,5 +1,6 @@ package org.monogram.feature.dialog.store + import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.core.common.AppLog @@ -7,10 +8,12 @@ import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog import org.monogram.core.common.telegram.TelegramError import org.monogram.core.markup.forSend +import org.monogram.core.models.Dice import org.monogram.core.models.FixedLinkPreviewRules import org.monogram.core.models.ForumIo import org.monogram.core.models.Message import org.monogram.core.models.MessageId +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.StyledText import org.monogram.core.models.TextEntities import org.monogram.core.models.TextEntity @@ -19,6 +22,7 @@ import org.monogram.core.models.prepareComposerText import org.monogram.core.models.remapTextEntities import org.monogram.core.ui.AppearanceSettings import org.monogram.feature.dialog.DialogStore +import org.monogram.feature.dialog.checklistEditEntities import org.monogram.feature.dialog.isTransientSendFailure import org.monogram.feature.dialog.localMediaCacheKey import org.monogram.feature.dialog.localMediaPath @@ -27,6 +31,93 @@ import org.monogram.feature.dialog.releaseAttachCopy import org.monogram.network.bridge.MtprotoClient import kotlin.coroutines.cancellation.CancellationException +internal fun DialogExecutor.replaceAttachment(intent: DialogStore.Intent.ReplaceAttachment) { + val current = snapshot() + val original = current.editing + val allowed = + original != null && !current.sending && current.canView && !current.left && !current.blockedByMe && + original.id.chatId == chatId && original.id.id == intent.messageId && original.id.id > 0 && + original.outgoing && !original.pending && original.mediaCacheKey == intent.expectedKey && + intent.expectedKey.isNotBlank() && original.mediaKind in setOf( + "photo", + "video", + "document" + ) && + intent.item.kind == original.mediaKind && intent.item.caption.isEmpty() && intent.item.captionEntities.isEmpty() && + (if (original.mediaKind == "document") current.canSendPlain else current.canSendPhotos) + val discarded = if (allowed) current.pendingAttach else listOf(intent.item) + if (allowed) emit(Msg.PendingAttach(listOf(intent.item))) + work.launch(kotlinx.coroutines.Dispatchers.IO) { + discarded.filter { item -> + if (allowed) item.path != intent.item.path + else current.pendingAttach.none { it.path == item.path } + }.forEach { releaseAttachCopy(it.path) } + } +} + +private fun DialogExecutor.sendAttachmentReplacement( + intent: DialogStore.Intent.ReplaceAttachment, + prepared: StyledText, + captured: DialogStore.State, + consumeDraft: Boolean, +) { + work.launch { + var admitted = false + try { + val current = snapshot() + val original = current.editing ?: return@launch + if (current.sending || !current.canView || current.left || current.blockedByMe || original.id.chatId != chatId || original.id.id != intent.messageId || original.id.id <= 0 || !original.outgoing || original.pending || original.mediaCacheKey != intent.expectedKey || intent.expectedKey.isBlank() || original.mediaKind !in setOf( + "photo", "video", "document" + ) || intent.item.kind != original.mediaKind || intent.item.caption.isNotEmpty() || intent.item.captionEntities.isNotEmpty() || (if (original.mediaKind == "document") !current.canSendPlain else !current.canSendPhotos) + ) return@launch + val epoch = warmup?.outgoingAccountEpoch() + emit(Msg.Sending(true)) + admitted = true + val result = + client.replaceUploadedMedia(chatId, original.id.id, intent.expectedKey, intent.item) + if (epoch != null && !warmup.outgoingAccountIsCurrent(epoch)) return@launch + when (result) { + is Outcome.Err -> handleError(result.telegramError, false) + is Outcome.Ok -> { + if (epoch != null && warmup?.outgoingAccountIsCurrent(epoch) != true) return@launch + val row = result.value + if (row.id != original.id || row.mediaKind != original.mediaKind || row.groupedId != original.groupedId) return@launch + if (epoch != null && !warmup.applyMessageRead( + epoch, chatId, original.id.id, row + ) + ) return@launch + emit(Msg.MessageEdited(row)) + if (snapshot().editing?.id == original.id && snapshot().editing?.mediaCacheKey == intent.expectedKey) { + val unchanged = snapshot().composerRevision == captured.composerRevision + emit(Msg.PendingAttach(emptyList())) + emit(Msg.Editing(row)) + if (unchanged) { + if (prepared.text == row.text.orEmpty() && prepared.entities == row.entities) { + finishComposerEdit() + } else { + admitted = false + emit(Msg.Sending(false)) + send(prepared.text, prepared, snapshot(), consumeDraft) + } + } + } + } + } + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + handleError(TelegramError.parse("Request failed"), false) + } finally { + if (admitted) emit(Msg.Sending(false)) + if (snapshot().pendingAttach.none { it.path == intent.item.path }) { + withContext(kotlinx.coroutines.NonCancellable + kotlinx.coroutines.Dispatchers.IO) { + releaseAttachCopy(intent.item.path) + } + } + } + } +} + internal fun DialogExecutor.sendUpload( path: String, kind: String, @@ -38,7 +129,7 @@ internal fun DialogExecutor.sendUpload( caption: String? = null, captionEntities: List = emptyList(), ) { - if (path.isBlank()) return + if (path.isBlank() || !snapshot().canSendComposerContent) return if ((kind == "photo" || kind == "video") && !snapshot().canSendPhotos) return val file = java.io.File(path) if (!file.isFile || file.length() <= 0L) return @@ -49,15 +140,7 @@ internal fun DialogExecutor.sendUpload( val wire = prepareDraftPayload(current.draft, current) if (snapshot().draft == current.draft) applyDraft("") sendUpload( - path, - kind, - fileName, - mimeType, - duration, - width, - height, - wire.text, - wire.entities + path, kind, fileName, mimeType, duration, width, height, wire.text, wire.entities ) } return @@ -65,7 +148,7 @@ internal fun DialogExecutor.sendUpload( val text = caption.orEmpty() if (caption == null && text.isNotEmpty()) applyDraft("") val reply = current.replyTo - emit(Msg.ReplyTo(null)) + setComposerReply(null) val randomId = pendingRandomId() val name = fileName.ifBlank { file.name }.ifBlank { "file" } val pending = pendingOutgoing( @@ -80,12 +163,13 @@ internal fun DialogExecutor.sendUpload( randomId = randomId, groupedId = null, reply = reply, + replyMessageId = current.composerReplyId, entities = captionEntities, ) emit(Msg.Append(pending)) work.launch { warmup?.upsertMessages(listOf(pending)) - val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, reply?.id?.id) + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) val item = UploadItem( path = path, kind = kind, @@ -101,17 +185,14 @@ internal fun DialogExecutor.sendUpload( finishOutgoingSend( pendingId = pending.id.id, result = client.sendUploadedMedia( - chatId, - item, - replyId, - topId, - TextEntities.serialize(captionEntities) + chatId, item, replyId, topId, TextEntities.serialize(captionEntities) ), ) } } internal fun DialogExecutor.sendAlbum(items: List, useDraft: Boolean = true) { + if (!snapshot().canSendComposerContent) return if (items.isEmpty() || items.size > 10) return val kinds = items.map { it.kind } val photos = kinds.all { it == "photo" || it == "video" } @@ -127,15 +208,14 @@ internal fun DialogExecutor.sendAlbum(items: List, useDraft: Boolean if (snapshot().draft == current.draft) applyDraft("") sendAlbum(items.mapIndexed { index, item -> if (index == 0) item.copy( - caption = wire.text, - captionEntities = wire.entities + caption = wire.text, captionEntities = wire.entities ) else item }) } return } val reply = current.replyTo - emit(Msg.ReplyTo(null)) + setComposerReply(null) val groupedId = pendingRandomId() val pending = items.mapIndexed { index, raw -> val file = java.io.File(raw.path) @@ -156,6 +236,7 @@ internal fun DialogExecutor.sendAlbum(items: List, useDraft: Boolean randomId = randomId, groupedId = groupedId, reply = reply.takeIf { index == 0 }, + replyMessageId = current.composerReplyId.takeIf { index == 0 }, entities = raw.captionEntities, ) to raw.copy( fileName = name, @@ -167,7 +248,7 @@ internal fun DialogExecutor.sendAlbum(items: List, useDraft: Boolean pending.forEach { emit(Msg.Append(it.first)) } work.launch { warmup?.upsertMessages(pending.map { it.first }) - val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, reply?.id?.id) + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) when (val result = client.sendUploadedAlbum( chatId, pending.map { it.second }, @@ -190,8 +271,7 @@ internal fun DialogExecutor.sendAlbum(items: List, useDraft: Boolean is Outcome.Err -> { pending.forEach { (local, _) -> failOrKeepOutgoing( - local.id.id, - result.telegramError + local.id.id, result.telegramError ) } } @@ -212,8 +292,9 @@ internal fun DialogExecutor.pendingOutgoing( groupedId: Long?, reply: Message?, entities: List, + replyMessageId: Int? = reply?.id?.id, ): Message { - val replyIds = ForumIo.sendReplyIds(threadTopMsgId, reply?.id?.id) + val replyIds = ForumIo.sendReplyIds(threadTopMsgId, replyMessageId) return Message( id = MessageId(chatId, pendingMessageId()), senderId = null, @@ -270,7 +351,9 @@ internal suspend fun DialogExecutor.failOrKeepOutgoing(pendingId: Int, error: Te handleError(error, false) } -internal suspend fun DialogExecutor.absorbIncoming(incoming: Message) { +internal suspend fun DialogExecutor.absorbIncoming( + incoming: Message, matchPendingByRandomIdOnly: Boolean = false +) { val current = snapshot() val existing = current.messages.firstOrNull { it.id.id == incoming.id.id } if (existing != null) { @@ -281,7 +364,9 @@ internal suspend fun DialogExecutor.absorbIncoming(incoming: Message) { } return } - val pending = matchingPending(current.messages, incoming) + val pending = if (matchPendingByRandomIdOnly) current.messages.firstOrNull { + incoming.randomId != 0L && it.randomId == incoming.randomId && (it.pending || it.failed || it.id.id < 0) + } else matchingPending(current.messages, incoming) if (pending != null) { PerfLog.event("send", "phase=pending_reconcile via=new_message") replaceOutgoing(pending.id.id, incoming.copy(outgoing = true)) @@ -302,11 +387,7 @@ internal suspend fun DialogExecutor.absorbIncoming(incoming: Message) { /** Native text sends may mint a different random_id. Bind only when this chat has one pending. */ internal fun DialogExecutor.uniqueUnmatchedPending(): Message? { val unmatched = snapshot().messages.filter { - it.outgoing && - it.pending && - !it.failed && - (it.randomId ?: 0L) != 0L && - it.id.id < 0 + it.outgoing && it.pending && !it.failed && (it.randomId ?: 0L) != 0L && it.id.id < 0 } return unmatched.singleOrNull() } @@ -329,17 +410,18 @@ internal fun DialogExecutor.bindPendingId(randomId: Long, messageId: Int) { } internal fun DialogExecutor.sendSavedGif(documentId: Long) { + if (!snapshot().canSendComposerContent) return if (snapshot().sending) return emit(Msg.Sending(true)) emit(Msg.GifPicker(false)) emit(Msg.ComposerPanel(null)) - val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, snapshot().replyTo?.id?.id) + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, snapshot().composerReplyId) work.launch { when (val result = client.sendSavedGif(chatId, documentId, replyId, topId)) { is Outcome.Ok -> { emit(Msg.Append(result.value)) warmup?.upsertMessages(listOf(result.value)) - emit(Msg.ReplyTo(null)) + setComposerReply(null) } is Outcome.Err -> handleError(result.telegramError, false) @@ -352,16 +434,10 @@ internal suspend fun DialogExecutor.styleForSend(parsed: StyledText): StyledText filterComposerPayload(parsed, client, isPremium()) internal suspend fun filterComposerPayload( - parsed: StyledText, - client: MtprotoClient, - premium: Boolean + parsed: StyledText, client: MtprotoClient, premium: Boolean ): StyledText { - val documentIds = parsed.entities - .asSequence() - .filter { it.kind == "custom_emoji" } - .mapNotNull { it.url?.toLongOrNull()?.takeIf { id -> id > 0 } } - .distinct() - .toList() + val documentIds = parsed.entities.asSequence().filter { it.kind == "custom_emoji" } + .mapNotNull { it.url?.toLongOrNull()?.takeIf { id -> id > 0 } }.distinct().toList() if (documentIds.isEmpty()) return parsed.forSend(isPremium = premium) val maxCustomEmoji = when (val result = client.animatedEmojiMax()) { @@ -403,15 +479,15 @@ internal fun webpageUrlForSend(text: String, current: DialogStore.State): String } internal suspend fun DialogExecutor.prepareDraftPayload( - raw: String, - current: DialogStore.State + raw: String, current: DialogStore.State ): StyledText { val mentions = current.draftMentions.map { mention -> TextEntity("mention_name", mention.start, mention.length, mention.peerId.value.toString()) } val parsed = withContext(markupContext) { prepareComposerText( - raw, current.draftFormatting && isPremium(), + raw, + current.draftFormatting && isPremium(), remapTextEntities(current.draft, raw, current.draftEntities + mentions) ) } @@ -425,7 +501,24 @@ internal fun DialogExecutor.send( consumeDraft: Boolean = true, ) { val current = captured ?: snapshot() + if (!current.canSendComposerContent || !snapshot().canSendComposerContent) return val raw = overrideText ?: current.draft + if (prepared == null && current.editing?.checklist != null && !current.draftFormatting) { + val entities = remapTextEntities( + current.draft, raw, current.checklistEditEntities() + ) + editChecklistText( + current.editing, + StyledText(raw, entities), + current.editingChecklistItemId, + current.composerRevision + ) + return + } + if (prepared == null && warmup != null && Dice.emoticonForSend(raw) != null && current.draftEntities.isEmpty() && !current.draftFormatting && current.pendingAttach.isEmpty() && current.editing == null && current.forwardMessage == null) { + queueTextSend(raw.trim(), MessageSendOptions()) + return + } if (prepared == null) { if (current.sending) return emit(Msg.Sending(true)) @@ -450,7 +543,31 @@ internal fun DialogExecutor.send( val attach = current.pendingAttach val editing = current.editing if (editing != null) { - if (current.sending || text.isEmpty() || !current.canSendPlain) return + if (attach.isNotEmpty()) { + if (!current.sending && attach.size == 1) { + sendAttachmentReplacement( + DialogStore.Intent.ReplaceAttachment( + editing.id.id, + editing.mediaCacheKey.orEmpty(), + attach.single() + ), + prepared, current, consumeDraft, + ) + } + return + } + if (editing.checklist != null) { + editChecklistText( + editing, prepared, current.editingChecklistItemId, current.composerRevision + ) + return + } + if (current.sending || (text.isEmpty() && editing.mediaKind !in setOf( + "photo", + "video", + "document" + )) || !current.canSendPlain + ) return emit(Msg.Sending(true)) work.launch { try { @@ -464,8 +581,9 @@ internal fun DialogExecutor.send( is Outcome.Ok -> { emit(Msg.ReplacePending(editing.id.id, result.value)) warmup?.upsertMessages(listOf(result.value)) - emit(Msg.Editing(null)) - if (consumeDraft && snapshot().draft == current.draft) applyDraft("") + if (consumeDraft && snapshot().draft == current.draft && snapshot().editing?.id == editing.id) { + finishComposerEdit() + } } is Outcome.Err -> handleError(result.telegramError, false) @@ -491,8 +609,7 @@ internal fun DialogExecutor.send( sendAlbum( attach.mapIndexed { index, item -> if (index == 0 && prepared.text.isNotEmpty()) item.copy( - caption = prepared.text, - captionEntities = prepared.entities + caption = prepared.text, captionEntities = prepared.entities ) else item }, ) @@ -513,7 +630,7 @@ internal fun DialogExecutor.send( return } if (!current.canSendPlain) return - val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.replyTo?.id?.id) + val (replyId, topId) = ForumIo.sendReplyIds(threadTopMsgId, current.composerReplyId) val pendingId = pendingMessageId() val randomId = pendingRandomId() val sendOp = sendSeq.incrementAndGet() @@ -521,7 +638,7 @@ internal fun DialogExecutor.send( if (consumeDraft && snapshot().draft == current.draft) applyDraft("") if (consumeDraft) { emit(Msg.PendingAttach(emptyList())) - emit(Msg.ReplyTo(null)) + setComposerReply(null) } PerfLog.event("send", "id=$sendOp phase=intent") val pending = Message( 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 4d15b5671..fa38aed65 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 @@ -1,8 +1,14 @@ package org.monogram.feature.dialog.store import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.database.CloudDraftSyncStatus +import org.monogram.core.database.OutgoingSendSyncState +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingTextSendEntity import org.monogram.core.models.Chat import org.monogram.core.models.ChatActionKind +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.ContactImportAttempt import org.monogram.core.models.ForumTopic import org.monogram.core.models.InlineBotResults import org.monogram.core.models.Message @@ -21,11 +27,23 @@ import org.monogram.core.models.WebpagePreview import org.monogram.feature.dialog.DraftMention import org.monogram.feature.dialog.InlineBotQuery import org.monogram.feature.dialog.MentionCandidate +import org.monogram.feature.dialog.ViewerPageState internal sealed interface Msg { data class Loading(val value: Boolean) : Msg data class LoadingOlder(val value: Boolean, val prefetch: Boolean = false) : Msg data class LoadingNewer(val value: Boolean) : Msg + data class ChecklistMutating(val messageId: Int, val value: Boolean) : Msg + data class PollVoting(val messageId: Int, val value: Boolean) : Msg + data class ContactImportState( + val attempt: ContactImportAttempt?, + val busy: Boolean = false, + val error: TelegramError? = null + ) : Msg + + data class ContactAdmitted(val randomId: Long) : Msg + data class ChecklistAdmitted(val randomId: Long) : Msg + data class PollAdmitted(val randomId: Long) : Msg data class Sending(val value: Boolean) : Msg data class Title(val value: String) : Msg data class IsSelf(val value: Boolean) : Msg @@ -34,13 +52,23 @@ internal sealed interface Msg { data class IsBot(val value: Boolean) : Msg data class Senders(val value: Map) : Msg data class SenderTags(val value: Map) : Msg + data class CloudDraftRestored( + val value: CloudDraft, val markdown: Boolean = false + ) : Msg + + data class OutgoingMediaSends(val value: List) : Msg + + data class OutgoingTextSends(val value: List) : Msg + + data class OutgoingSendSync(val value: OutgoingSendSyncState) : Msg + data class DraftSync(val value: CloudDraftSyncStatus) : Msg data class Draft(val value: String) : Msg data class DraftFormatting(val value: Boolean) : Msg data class DraftEntities(val value: List) : Msg data class DraftPreview(val value: StyledText?, val hasMarkdown: Boolean) : Msg data class PendingAttach(val items: List) : Msg data class ReplyTo(val value: Message?, val focusComposer: Boolean = false) : Msg - data class Editing(val value: Message?) : Msg + data class Editing(val value: Message?, val itemId: Int? = null) : Msg data class ForwardMessage(val value: Message?) : Msg data class ForwardTargets(val value: List) : Msg data class ForwardQuery(val value: String) : Msg @@ -53,6 +81,7 @@ internal sealed interface Msg { val canForward: Boolean, val canDeleteOthers: Boolean, val canManageTopics: Boolean = false, + val canSendPolls: Boolean = false, ) : Msg data class SearchQuery(val value: String) : Msg @@ -100,6 +129,10 @@ internal sealed interface Msg { data class ReadOutbox(val value: Int) : Msg data class ReadReceiptConfig(val value: org.monogram.core.models.ReadReceiptConfig) : Msg data class Viewers(val messageId: Int, val value: MessageViewers) : Msg + data class ViewerPage( + val messageId: Int, val poll: Boolean, val value: ViewerPageState + ) : Msg + data class ReactionUsers( val messageId: Int, val value: List, @@ -119,6 +152,7 @@ internal sealed interface Msg { val atTop: Boolean = false, val requestId: String = java.util.UUID.randomUUID().toString(), ) : Msg + data class SavedGifs(val value: List, val error: Boolean = false) : Msg data class SavedGifsLoaded(val value: Boolean) : Msg data class GifPicker(val open: Boolean) : Msg @@ -174,6 +208,7 @@ internal sealed interface Msg { val blockedByMe: Boolean? = null, val canSendPlain: Boolean? = null, val canSendPhotos: Boolean? = null, + val canSendPolls: Boolean? = null, ) : Msg data class LinkPreview( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachDock.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachDock.kt index dc13f1219..26e2e41a5 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachDock.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachDock.kt @@ -1,63 +1,54 @@ package org.monogram.feature.dialog.ui +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -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.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.Checklist import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.PhotoLibrary -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material.icons.outlined.Poll import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.monogram.feature.dialog.R internal val DockInset = 16.dp internal val DockTopPadding = 8.dp -private val DockGap = 8.dp -private val DockCornerRadius = 28.dp -private val DockAboveWriteBar = 10.dp - -/** Height of the attach dock: the Material 3 Expressive medium button container. */ -internal val AttachDockHeight = 56.dp @Composable internal fun navBarInset(): Dp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() -/** - * Attach dock: the three bodies are a connected single-select toggle group (M3 Expressive), sized to - * a 56dp medium button. The checked segment morphs to the "full" shape and takes the primary fill, - * so the selected body reads from shape as well as color. "File" is an action, not a body, so it is a - * separate tonal icon button instead of a fourth segment. - */ +private data class AttachAction( + val icon: ImageVector, + val label: Int, + val onClick: () -> Unit, + val enabled: Boolean = true, + val selected: Boolean = false, +) + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun AttachDock( @@ -67,106 +58,81 @@ internal fun AttachDock( onSelectGallery: () -> Unit, onPickFile: () -> Unit, onSendLocation: () -> Unit, + canSendLocation: Boolean = true, + onCreatePoll: (() -> Unit)? = null, + onCreateChecklist: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val bodies = buildList { - if (canSendPhotos) add(AttachBody.Gallery) + val actions = buildList { + if (canSendPhotos) add( + AttachAction( + Icons.Outlined.PhotoLibrary, R.string.dialog_attach_gallery, + onSelectGallery, selected = current == AttachBody.Gallery + ) + ) + if (canSendFiles) add( + AttachAction( + Icons.AutoMirrored.Outlined.InsertDriveFile, + R.string.dialog_attach_file, onPickFile + ) + ) + add( + AttachAction( + Icons.Outlined.LocationOn, R.string.dialog_location_action, onSendLocation, + enabled = canSendLocation + ) + ) + onCreatePoll?.let { + add( + AttachAction( + Icons.Outlined.Poll, + R.string.dialog_poll_create, + it + ) + ) + } + onCreateChecklist?.let { + add( + AttachAction( + Icons.Outlined.Checklist, + R.string.dialog_checklist_create, + it + ) + ) + } } Row( - modifier = modifier + modifier .fillMaxWidth() + .horizontalScroll(rememberScrollState()) .padding( - start = DockInset, - end = DockInset, - top = DockTopPadding, - bottom = navBarInset() + DockAboveWriteBar, + start = DockInset, end = DockInset, top = DockTopPadding, + bottom = navBarInset() + 10.dp ) .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.72f), - shape = RoundedCornerShape(DockCornerRadius), + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.72f), + RoundedCornerShape(28.dp) ) - .padding(horizontal = 8.dp, vertical = 6.dp), + .padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(DockGap), ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - ) { - bodies.forEachIndexed { index, entry -> - val selected = current == entry - ToggleButton( - checked = selected, - onCheckedChange = { if (!selected) entry.select(onSelectGallery) }, - shapes = when (index) { - 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - bodies.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp), - modifier = Modifier - .weight(1f) - .height(AttachDockHeight) - .semantics { role = Role.RadioButton }, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = entry.icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(entry.label), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - FilledTonalIconButton( - onClick = onSendLocation, - shapes = IconButtonDefaults.shapes(), - modifier = Modifier.size(AttachDockHeight), - ) { - Icon( - imageVector = Icons.Outlined.LocationOn, - contentDescription = stringResource(R.string.dialog_location_action), - modifier = Modifier.size(IconButtonDefaults.mediumIconSize), - ) - } - if (canSendFiles) { + actions.forEach { action -> FilledTonalIconButton( - onClick = onPickFile, + onClick = action.onClick, + enabled = action.enabled, + colors = if (action.selected) IconButtonDefaults.filledIconButtonColors() + else IconButtonDefaults.filledTonalIconButtonColors(), shapes = IconButtonDefaults.shapes(), - modifier = Modifier.size(AttachDockHeight), + modifier = Modifier + .size(48.dp) + .semantics { selected = action.selected }, ) { Icon( - imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, - contentDescription = stringResource(R.string.dialog_attach_file), - modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + action.icon, contentDescription = stringResource(action.label), + modifier = Modifier.size(24.dp) ) } } } } - -private val AttachBody.icon: ImageVector - get() = when (this) { - AttachBody.Gallery -> Icons.Outlined.PhotoLibrary - } - -private val AttachBody.label: Int - get() = when (this) { - AttachBody.Gallery -> R.string.dialog_attach_gallery - } - -private fun AttachBody.select(onSelectGallery: () -> Unit) = when (this) { - AttachBody.Gallery -> onSelectGallery() -} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachNoAccess.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachNoAccess.kt index 17c34ea3c..560acc2d2 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachNoAccess.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachNoAccess.kt @@ -4,15 +4,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.compose.material.icons.outlined.Image -import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material3.Button import androidx.compose.material3.Icon @@ -25,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.monogram.feature.dialog.R @@ -35,12 +32,9 @@ private val FallbackCellHeight = 80.dp internal fun NoAccessBody( denied: Boolean, canSendPhotos: Boolean, - canSendFiles: Boolean, onRequestAccess: () -> Unit, onPickPhoto: () -> Unit, onPickVideo: () -> Unit, - onPickFile: () -> Unit, - onSendLocation: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -90,20 +84,7 @@ internal fun NoAccessBody( modifier = Modifier.weight(1f), ) } - if (canSendFiles) { - FallbackCell( - icon = Icons.AutoMirrored.Outlined.InsertDriveFile, - label = stringResource(R.string.dialog_attach_file), - onClick = onPickFile, - modifier = Modifier.weight(1f), - ) - } - FallbackCell( - icon = Icons.Outlined.LocationOn, - label = stringResource(R.string.dialog_location_action), - onClick = onSendLocation, - modifier = Modifier.weight(1f), - ) + } } } @@ -117,12 +98,12 @@ private fun FallbackCell( ) { Surface( onClick = onClick, - modifier = modifier.height(FallbackCellHeight), + modifier = modifier.heightIn(min = FallbackCellHeight), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerHighest, ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.padding(vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -137,8 +118,7 @@ private fun FallbackCell( text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, ) } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachSheet.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachSheet.kt index caf3a79aa..1a4ca7bb9 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachSheet.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/AttachSheet.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity @@ -77,7 +79,6 @@ import org.monogram.network.http.MediaRepository import kotlin.math.abs private val HandleTopPadding = 16.dp -private const val SheetBackdropBlurPx = 40 private val FabSize = 56.dp private const val BodyCrossfadeMs = 150 private const val TallSheetFraction = 0.92f @@ -122,23 +123,14 @@ internal fun AttachSheet( onPickVideo: () -> Unit, onPickFile: () -> Unit, onSendLocation: () -> Unit, + canSendLocation: Boolean = true, + onCreatePoll: (() -> Unit)? = null, + onCreateChecklist: (() -> Unit)? = null, onSendDocument: (Long) -> Unit, onDismiss: () -> Unit, requestHide: androidx.compose.runtime.MutableState<(() -> Unit)?>, ) { val scope = rememberCoroutineScope() - val sheetView = LocalView.current - DisposableEffect(sheetView) { - val window = (sheetView.parent as? DialogWindowProvider)?.window - if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setBackgroundBlurRadius(SheetBackdropBlurPx) - } - onDispose { - if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setBackgroundBlurRadius(0) - } - } - } val retryMedia = rememberUpdatedState(onRetryMedia) val loadMoreMedia = rememberUpdatedState(onLoadMoreMedia) val sheetState = rememberBottomSheetState( @@ -217,15 +209,12 @@ internal fun AttachSheet( if (wrapState) { Column(modifier = Modifier.fillMaxWidth()) { AttachDragHandle() - NoAccessBody( + if (canSendPhotos) NoAccessBody( denied = accessDenied, canSendPhotos = canSendPhotos, - canSendFiles = canSendFiles, onRequestAccess = onRequestAccess, onPickPhoto = { leave(onPickPhoto) }, onPickVideo = { leave(onPickVideo) }, - onPickFile = { leave(onPickFile) }, - onSendLocation = { leave(onSendLocation) }, ) Spacer(modifier = Modifier.height(12.dp)) AttachDock( @@ -235,11 +224,15 @@ internal fun AttachSheet( onSelectGallery = { body = AttachBody.Gallery.name }, onPickFile = { leave(onPickFile) }, onSendLocation = { leave(onSendLocation) }, + onCreatePoll = onCreatePoll?.let { action -> { leave(action) } }, + canSendLocation = canSendLocation, + onCreateChecklist = onCreateChecklist?.let { action -> { leave(action) } }, ) } return@Crossfade } - val dockBlock = DockTopPadding + AttachDockHeight + navBarInset() + var dockHeightPx by remember { mutableIntStateOf(0) } + val dockBlock = with(density) { dockHeightPx.toDp() } val tileSizePx = with(density) { ((maxWidth - TileGap * (TileColumns - 1)) / TileColumns).roundToPx() } @@ -302,8 +295,18 @@ internal fun AttachSheet( onSelectGallery = { body = AttachBody.Gallery.name }, onPickFile = { leave(onPickFile) }, onSendLocation = { leave(onSendLocation) }, + onCreatePoll = onCreatePoll?.let { action -> { leave(action) } }, + canSendLocation = canSendLocation, + onCreateChecklist = onCreateChecklist?.let { action -> + { + leave( + action + ) + } + }, modifier = Modifier .align(Alignment.BottomCenter) + .onSizeChanged { dockHeightPx = it.height } .blockSheetDrag(), ) } @@ -408,6 +411,7 @@ private fun LeaveComposerClear(gap: Dp) { } window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> apply() } decor.addOnLayoutChangeListener(listener) decor.post { apply() } @@ -431,9 +435,9 @@ private fun SendFab(count: Int, onClick: () -> Unit, modifier: Modifier = Modifi AnimatedVisibility( visible = count > 0, enter = scaleIn(animationSpec = motion.fastSpatialSpec(), initialScale = 0.8f) + - fadeIn(animationSpec = motion.fastEffectsSpec()), + fadeIn(animationSpec = motion.fastEffectsSpec()), exit = scaleOut(animationSpec = motion.fastSpatialSpec()) + - fadeOut(animationSpec = motion.fastEffectsSpec()), + fadeOut(animationSpec = motion.fastEffectsSpec()), modifier = modifier, ) { BadgedBox(badge = { Badge { Text(count.toString()) } }) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CreationSendControls.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CreationSendControls.kt new file mode 100644 index 000000000..a8f41f6af --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CreationSendControls.kt @@ -0,0 +1,175 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.MessageSendOptions +import org.monogram.core.ui.components.AppStatusBanner +import org.monogram.core.ui.components.toUiMessage +import org.monogram.core.ui.loading.MonogramBusyButton +import org.monogram.feature.dialog.R +import org.monogram.feature.dialog.store.validSendSchedule +import java.text.DateFormat.getDateTimeInstance + +@Composable +internal fun CreationSheetHeader(title: String, enabled: Boolean = true, onDismiss: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + title, + style = MaterialTheme.typography.titleLargeEmphasized, + modifier = Modifier.weight(1f) + ) + FilledTonalIconButton( + onClick = onDismiss, + enabled = enabled, + shapes = IconButtonDefaults.shapes() + ) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.dialog_cancel)) + } + } +} + +@Stable +internal class CreationSendState( + silent: Boolean = false, showSchedule: Boolean = false, lastSchedule: Long? = null +) { + var silent by mutableStateOf(silent) + var showSchedule by mutableStateOf(showSchedule) + var lastSchedule by mutableStateOf(lastSchedule) + + companion object { + val Saver = listSaver(save = { + listOf( + it.silent, it.showSchedule, it.lastSchedule ?: 0L + ) + }, restore = { + CreationSendState( + it[0] as Boolean, it[1] as Boolean, (it[2] as Long).takeIf { date -> date != 0L }) + }) + } +} + +@Composable +internal fun CreationSendControls( + state: CreationSendState, + valid: Boolean, + busy: Boolean, + allowed: Boolean, + error: TelegramError?, + onSend: (MessageSendOptions) -> Unit, +) { + with(state) { + var showOptions by remember { mutableStateOf(false) } + var now by remember { mutableLongStateOf(System.currentTimeMillis() / 1000) } + LaunchedEffect(lastSchedule) { + while (lastSchedule != null) { + now = System.currentTimeMillis() / 1000; kotlinx.coroutines.delay(1000) + } + } + val scheduleValid = validSendSchedule(lastSchedule?.toInt(), now) + val interactive = allowed && !busy + CreationSetting(stringResource(R.string.dialog_send_silent), silent, interactive) { + silent = it + } + if (error != null) AppStatusBanner( + text = error.toUiMessage(), failed = true, progress = false + ) + if (lastSchedule != null) Text( + getDateTimeInstance().format(java.util.Date(lastSchedule!! * 1000)) + ) + if (lastSchedule != null) TextButton(enabled = valid && interactive, onClick = { + lastSchedule = null; onSend(MessageSendOptions(silent = silent)) + }) { Text(stringResource(R.string.dialog_scheduled_send_now)) } + if (!scheduleValid) Text( + stringResource(R.string.dialog_schedule_invalid), + color = MaterialTheme.colorScheme.error + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MonogramBusyButton( + onClick = { + if (valid && interactive && scheduleValid) onSend( + MessageSendOptions( + silent = silent, scheduleDate = lastSchedule?.toInt() + ) + ) + }, + busy = busy, + enabled = valid && interactive && scheduleValid, + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .heightIn(min = 56.dp) + ) { + Text(stringResource(if (lastSchedule != null) R.string.dialog_send_schedule else R.string.dialog_send)) + } + Box { + IconButton(onClick = { showOptions = true }, enabled = valid && interactive) { + Icon( + Icons.Outlined.MoreVert, + contentDescription = stringResource(R.string.dialog_send_options) + ) + } + SendOptionsMenu( + showOptions && valid && interactive, + onDismiss = { showOptions = false }, + onSilent = { + showOptions = false; if (scheduleValid) onSend( + MessageSendOptions( + silent = true, scheduleDate = lastSchedule?.toInt() + ) + ) + }, + onSchedule = { showOptions = false; showSchedule = true }) + } + } + if (showSchedule) ScheduleMessageSheet( + onDismiss = { showSchedule = false }, + initialDate = lastSchedule, + enabled = valid && interactive, + onSchedule = { date -> + lastSchedule = date.toLong(); showSchedule = false + if (valid && interactive) onSend( + MessageSendOptions( + silent = silent, scheduleDate = date + ) + ) + }) + } +} \ No newline at end of file diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogComposerDock.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogComposerDock.kt index 5706ecaa5..a41a47db6 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogComposerDock.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogComposerDock.kt @@ -41,6 +41,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.ReplyMarkup import org.monogram.core.models.StyledText import org.monogram.core.models.TextEntities @@ -64,10 +65,12 @@ internal fun DialogComposerDock( draftEntities: List, draftPreview: StyledText?, draftHasMarkdown: Boolean, + draftUnsupported: Boolean, sending: Boolean, canView: Boolean, canSendPlain: Boolean, canSendPhotos: Boolean, + canSendStructured: Boolean = false, isChannel: Boolean, isGroup: Boolean = false, left: Boolean = false, @@ -92,6 +95,8 @@ internal fun DialogComposerDock( onDismissLinkPreview: () -> Unit = {}, onRestoreLinkPreview: () -> Unit = {}, onPhotos: () -> Unit = {}, + onResumeLiveLocation: (Int) -> Unit = component::onResumeLiveLocation, + onReplaceAttachment: (() -> Unit)? = null, onFile: () -> Unit = {}, onLocation: () -> Unit = {}, onCloseAttachAnimated: () -> Unit = {}, @@ -99,6 +104,17 @@ internal fun DialogComposerDock( emojiTab: String = "", ) { var value by composer + var showSendOptions by rememberSaveable { mutableStateOf(false) } + var showSchedule by rememberSaveable { mutableStateOf(false) } + val mediaAllowed = + pendingAttach.isNotEmpty() && pendingAttach.size <= 10 && (pendingAttach.all { it.kind == "photo" || it.kind == "video" } && canSendPhotos || pendingAttach.all { it.kind == "document" } && canSendPlain) + val sendOptionsEnabled = + canView && !left && !blockedByMe && !editing && !draftUnsupported && !sending && (mediaAllowed || pendingAttach.isEmpty() && canSendPlain && value.text.isNotBlank()) + LaunchedEffect(sendOptionsEnabled) { + if (!sendOptionsEnabled) { + showSendOptions = false; showSchedule = false + } + } val context = LocalContext.current val editorSlot by component.editorSlot.subscribeAsState() var editorMarkdown by rememberSaveable { mutableStateOf(false) } @@ -140,15 +156,11 @@ internal fun DialogComposerDock( }.distinct() } val receiveMedia: (List, TransferableContent?) -> Unit = { uris, transferableContent -> - val imageUris = uris.asSequence() - .distinct() - .filter { uri -> - runCatching { - context.contentResolver.getType(uri)?.startsWith("image/") == true - }.getOrDefault(false) - } - .take(10) - .toList() + val imageUris = uris.asSequence().distinct().filter { uri -> + runCatching { + context.contentResolver.getType(uri)?.startsWith("image/") == true + }.getOrDefault(false) + }.take(10).toList() if (!editing && canSendPhotos && imageUris.isNotEmpty()) { clipboardScope.launch { try { @@ -200,6 +212,11 @@ internal fun DialogComposerDock( } .blockWriteBarChatSwipe(), ) { + LiveLocationControls(component, onResumeLiveLocation) + if (draftUnsupported && !editing) { + UnsupportedDraftNotice(draft, component::onDiscardDraft) + return@Column + } botKeyboard?.let { keyboard -> BotKeyboardGrid( markup = keyboard, @@ -223,8 +240,7 @@ internal fun DialogComposerDock( clipboard.setClipEntry( ClipEntry( android.content.ClipData.newPlainText( - "text", - slice + "text", slice ) ) ) @@ -236,8 +252,7 @@ internal fun DialogComposerDock( clipboard.setClipEntry( ClipEntry( android.content.ClipData.newPlainText( - "text", - slice + "text", slice ) ) ) @@ -249,8 +264,8 @@ internal fun DialogComposerDock( if (pasteText.isNotEmpty()) { val lo = value.selection.min val hi = value.selection.max - val next = value.text.substring(0, lo) + pasteText + - value.text.substring(hi) + val next = + value.text.substring(0, lo) + pasteText + value.text.substring(hi) value = TextFieldValue( text = next, selection = TextRange(lo + pasteText.length), @@ -326,6 +341,7 @@ internal fun DialogComposerDock( sending = sending, canSendPlain = canSendPlain, canSendPhotos = canSendPhotos, + canSendStructured = canSendStructured, isChannel = isChannel, isGroup = isGroup, left = left, @@ -334,6 +350,7 @@ internal fun DialogComposerDock( onReadOnlyBar = onReadOnlyBar, editing = editing, editingBody = editingBody, + onReplaceAttachment = onReplaceAttachment, replyBody = replyBody, pendingAttach = pendingAttach, hasFailed = hasFailed, @@ -353,6 +370,20 @@ internal fun DialogComposerDock( ComposerPanels.EMOJI -> component.onToggleEmojiPanel() } }, + sendMenu = { + SendOptionsMenu( + showSendOptions && sendOptionsEnabled, + onDismiss = { showSendOptions = false }, + onSilent = { + showSendOptions = false + component.onSendWithOptions( + value.text, + MessageSendOptions(silent = true) + ) + }, + onSchedule = { showSendOptions = false; showSchedule = true }) + }, + onSendLongClick = if (sendOptionsEnabled) ({ showSendOptions = true }) else null, onSend = { val sent = value.text component.onSend(sent) @@ -381,14 +412,22 @@ internal fun DialogComposerDock( ) } } + if (showSchedule && sendOptionsEnabled) ScheduleMessageSheet( + onDismiss = { showSchedule = false }, + onSchedule = { date -> + showSchedule = false + component.onSendWithOptions( + value.text, MessageSendOptions(scheduleDate = date) + ) + }, + ) val editorTransition = remember { MutableTransitionState(false) } editorTransition.targetState = editorSlot.child != null if (editorTransition.currentState || editorTransition.targetState) { Dialog( onDismissRequest = component::closeMarkdownEditor, properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false + usePlatformDefaultWidth = false, decorFitsSystemWindows = false ), ) { Box(modifier = Modifier.fillMaxSize()) { @@ -408,9 +447,7 @@ internal fun DialogComposerDock( onEntitiesChange = { editorEntities = it }, onValueChange = { editorEntities = remapTextEntities( - editorValue.text, - it.text, - editorEntities + editorValue.text, it.text, editorEntities ) editorValue = it }, 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 fc13e1222..03fee39b9 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 @@ -1,5 +1,7 @@ package org.monogram.feature.dialog.ui + +import android.media.MediaMetadataRetriever import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -8,7 +10,9 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -28,25 +32,28 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import org.monogram.core.models.Message import org.monogram.core.models.UploadItem import org.monogram.core.models.canForwardFrom import org.monogram.core.models.isForwardSourceShape +import org.monogram.core.ui.media.MediaMotion +import org.monogram.core.ui.media.MediaPlaybackHolder +import org.monogram.core.ui.media.MiniPlayerBar +import org.monogram.core.ui.media.MiniPlayerRestoreViewer +import org.monogram.core.ui.media.mediaViewerMotionEnabled import org.monogram.core.ui.media.showsMiniPlayer import org.monogram.feature.dialog.DialogComponent import org.monogram.feature.dialog.DialogDayKind import org.monogram.feature.dialog.DialogStore import org.monogram.feature.dialog.DialogTime +import org.monogram.feature.dialog.DialogTime.sameLocalDay import org.monogram.feature.dialog.R import java.io.File import java.time.ZoneId import java.util.Locale -import kotlin.time.Duration.Companion.milliseconds internal fun messageMenuActions( @@ -59,10 +66,7 @@ internal fun messageMenuActions( return MessageMenuActions( canReply = canSend && !message.pending && message.id.id > 0, canCopy = !message.text.isNullOrBlank(), - canEdit = message.outgoing && - !message.pending && - !message.text.isNullOrBlank() && - state.canSendPlain, + canEdit = message.outgoing && message.id.id > 0 && message.id.id !in state.checklistMutations && !message.pending && (!message.text.isNullOrBlank() || message.checklist != null) && (if (message.checklist != null) state.canCreateChecklist else state.canSendPlain), canDelete = canDeleteMessage(state, message), canForward = canForward, forwardRestricted = sourceShape && state.canForward && message.noforwards, @@ -120,8 +124,8 @@ internal fun DialogDateSeparator(epochSeconds: Long, zone: ZoneId) { .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { - androidx.compose.material3.Surface( - shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + Surface( + shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.8f), ) { Text( @@ -189,16 +193,29 @@ internal suspend fun copyPickedMedia( val ext = runCatching { android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) }.getOrNull()?.takeIf { it.isNotBlank() } ?: fallbackExt - val displayName = queryDisplayName(context, uri) - ?.substringAfterLast('/') - ?.ifBlank { null } - ?: "file.$ext" + val displayName = + queryDisplayName(context, uri)?.substringAfterLast('/')?.ifBlank { null } ?: "file.$ext" val safeName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_") val dest = File(context.cacheDir, "attach-${System.nanoTime()}-$safeName") runCatching { - context.contentResolver.openInputStream(uri)?.use { input -> - dest.outputStream().use { output -> input.copyTo(output) } + val stream = context.contentResolver.openInputStream(uri) ?: return@withContext null + stream.use { input -> + dest.outputStream().use { output -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > 4L * 1024 * 1024 * 1024) throw java.io.IOException("selected media exceeds the size limit") + output.write(buffer, 0, count) + } + } } + }.onFailure { error -> + dest.delete() + if (error is kotlinx.coroutines.CancellationException) throw error } if (!dest.exists() || dest.length() <= 0L) return@withContext null if (resolvedKind == "photo" && reencodablePhoto(mime, safeName)) { @@ -221,10 +238,14 @@ internal suspend fun copyPickedMedia( val out = File(context.cacheDir, "attach-video-${System.nanoTime()}.mp4") val compressed = runCatching { compressVideoLikeAndroid(context, dest, out) + }.onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error }.getOrNull() if (compressed != null) { dest.delete() - val (width, height, duration) = localMediaBounds(compressed.absolutePath, "video", "video/mp4") + val (width, height, duration) = localMediaBounds( + compressed.absolutePath, "video", "video/mp4" + ) return@withContext PickedMedia( path = compressed.absolutePath, kind = resolvedKind, @@ -282,17 +303,17 @@ internal fun localMediaBounds(path: String, kind: String, mime: String): Triple< return Triple(opts.outWidth.coerceAtLeast(0), opts.outHeight.coerceAtLeast(0), 0) } if (kind == "video" || mime.startsWith("video/")) { - val retriever = android.media.MediaMetadataRetriever() + val retriever = MediaMetadataRetriever() return runCatching { retriever.setDataSource(path) val width = retriever.extractMetadata( - android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, )?.toIntOrNull() ?: 0 val height = retriever.extractMetadata( - android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, )?.toIntOrNull() ?: 0 val durationMs = retriever.extractMetadata( - android.media.MediaMetadataRetriever.METADATA_KEY_DURATION, + MediaMetadataRetriever.METADATA_KEY_DURATION, )?.toLongOrNull() ?: 0L Triple(width, height, ((durationMs + 500) / 1000).toInt()) }.getOrDefault(Triple(0, 0, 0)).also { @@ -303,11 +324,9 @@ internal fun localMediaBounds(path: String, kind: String, mime: String): Triple< } internal fun Message.belongsToSameBlockAs(other: Message?, zone: ZoneId): Boolean = - other != null && - outgoing == other.outgoing && - senderId == other.senderId && - DialogTime.sameLocalDay(date, other.date, zone) && - kotlin.math.abs(date - other.date) <= MESSAGE_GROUPING_WINDOW_SECONDS + other != null && outgoing == other.outgoing && senderId == other.senderId && sameLocalDay( + date, other.date, zone + ) && kotlin.math.abs(date - other.date) <= MESSAGE_GROUPING_WINDOW_SECONDS internal const val MESSAGE_GROUPING_WINDOW_SECONDS = 5 * 60L @@ -320,13 +339,13 @@ internal const val MESSAGE_GROUPING_WINDOW_SECONDS = 5 * 60L @Composable internal fun DialogMiniPlayer() { val context = LocalContext.current - val session = remember(context) { org.monogram.core.ui.media.MediaPlaybackHolder.session(context) } + val session = remember(context) { MediaPlaybackHolder.session(context) } val reopen = LocalReopenMediaViewer.current var restore by remember { mutableStateOf(false) } val visible = session.showsMiniPlayer() - val motion = org.monogram.core.ui.media.mediaViewerMotionEnabled() + val motion = mediaViewerMotionEnabled() if (restore) { - org.monogram.core.ui.media.MiniPlayerRestoreViewer( + MiniPlayerRestoreViewer( session = session, onDismiss = { restore = false }, ) @@ -334,112 +353,16 @@ internal fun DialogMiniPlayer() { AnimatedVisibility( visible = visible && !restore, enter = slideInVertically( - org.monogram.core.ui.media.MediaMotion.spatial(motion), + MediaMotion.spatial(motion), ) { it } + fadeIn(), exit = slideOutVertically( - org.monogram.core.ui.media.MediaMotion.spatial(motion), + MediaMotion.spatial(motion), ) { it } + fadeOut(), ) { - org.monogram.core.ui.media.MiniPlayerBar( + MiniPlayerBar( session = session, onExpand = { if (!reopen()) restore = true }, onStop = { session.stop() }, ) } } - - -@Suppress("MissingPermission") -internal fun locationManager( - context: android.content.Context, -): android.location.LocationManager? = - context.getSystemService(android.content.Context.LOCATION_SERVICE) - as? android.location.LocationManager - -internal fun locationProviders( - manager: android.location.LocationManager, -): List = linkedSetOf().apply { - addAll(manager.getProviders(true)) - add(android.location.LocationManager.GPS_PROVIDER) - add(android.location.LocationManager.NETWORK_PROVIDER) - add(android.location.LocationManager.PASSIVE_PROVIDER) - if (android.os.Build.VERSION.SDK_INT >= 31) { - add(android.location.LocationManager.FUSED_PROVIDER) - } -}.filter { name -> - name == android.location.LocationManager.PASSIVE_PROVIDER || - (android.os.Build.VERSION.SDK_INT >= 31 && - name == android.location.LocationManager.FUSED_PROVIDER) || - manager.isProviderEnabled(name) -} - -@Suppress("MissingPermission") -internal fun lastKnownLocation( - context: android.content.Context, -): Pair? { - val manager = locationManager(context) ?: return null - return locationProviders(manager).mapNotNull { provider -> - runCatching { manager.getLastKnownLocation(provider) }.getOrNull() - }.maxByOrNull { it.time }?.let { it.latitude to it.longitude } -} - -@Suppress("MissingPermission") -internal suspend fun currentLocation( - context: android.content.Context, - timeoutMs: Long = 8_000, -): Pair? = withContext(Dispatchers.IO) { - val manager = locationManager(context) ?: return@withContext null - val providers = locationProviders(manager) - if (providers.isEmpty()) return@withContext null - val lastKnown = providers.mapNotNull { provider -> - runCatching { manager.getLastKnownLocation(provider) }.getOrNull() - }.maxByOrNull { it.time } - val fresh = coroutineScope { - providers.map { provider -> - async { - withTimeoutOrNull(timeoutMs.milliseconds) { awaitSingleFix(manager, provider) } - } - }.mapNotNull { it.await() }.maxByOrNull { it.time } - } - listOfNotNull(fresh, lastKnown).maxByOrNull { it.time }?.let { it.latitude to it.longitude } -} - -/** One fix from the platform service, suspended until it arrives, the caller cancels or it errors. */ -@Suppress("MissingPermission") -internal suspend fun awaitSingleFix( - manager: android.location.LocationManager, - provider: String, -): android.location.Location? = suspendCancellableCoroutine { continuation -> - val listener = object : android.location.LocationListener { - override fun onLocationChanged(location: android.location.Location) { - runCatching { manager.removeUpdates(this) } - if (continuation.isActive) continuation.resumeWith(Result.success(location)) - } - - override fun onProviderDisabled(disabled: String) = Unit - - override fun onProviderEnabled(enabled: String) = Unit - - override fun onStatusChanged(provider: String?, status: Int, extras: android.os.Bundle?) = Unit - } - continuation.invokeOnCancellation { - runCatching { manager.removeUpdates(listener) } - } - try { - manager.requestLocationUpdates( - provider, - 0L, - 0f, - listener, - android.os.Looper.getMainLooper(), - ) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - manager.getCurrentLocation(provider, null, { runnable -> runnable.run() }) { location -> - runCatching { manager.removeUpdates(listener) } - if (continuation.isActive) continuation.resumeWith(Result.success(location)) - } - } - } catch (error: Throwable) { - if (continuation.isActive) continuation.resumeWith(Result.success(null)) - } -} 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 719e34052..0aca5c518 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 @@ -4,6 +4,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -82,6 +83,8 @@ import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.ui.components.toUiMessage import org.monogram.core.models.Message import org.monogram.core.models.PeerId import org.monogram.core.ui.AppearanceSettings @@ -128,6 +131,13 @@ internal fun ColumnScope.DialogHistoryPane( clipboard: androidx.compose.ui.platform.Clipboard, attachScope: kotlinx.coroutines.CoroutineScope, ) { + val state = if (state.searchQuery.isBlank()) state.copy( + messages = outgoingHistoryMessages( + state.messages, + state.outgoingTextSends, + state.outgoingMediaSends + ), + ) else state val listState = rememberLazyListState( cacheWindow = LazyLayoutCacheWindow(ahead = 1000.dp, behind = 1000.dp), ) @@ -195,9 +205,10 @@ internal fun ColumnScope.DialogHistoryPane( menuExpanded = false } } + val appearance by AppearanceSettings.state.collectAsStateWithLifecycle() - val pinned = state.pinnedMessages.getOrNull(state.pinnedIndex) - ?: state.pinnedMessages.firstOrNull() + val pinned = + state.pinnedMessages.getOrNull(state.pinnedIndex) ?: state.pinnedMessages.firstOrNull() val showPinned = !state.isCommentThread && pinned != null // A bar we already had on the first frame must not slide in again on the next open. val pinWasReady = remember { showPinned } @@ -265,8 +276,7 @@ internal fun ColumnScope.DialogHistoryPane( val olderPageGrew = grew && !newestArrived previousCount = state.messages.size previousNewestId = newestId - if ( - shouldFollowIncomingNewest( + if (shouldFollowIncomingNewest( followBottom = followBottom, scrolling = listState.isScrollInProgress, newestArrived = newestArrived, @@ -306,8 +316,7 @@ internal fun ColumnScope.DialogHistoryPane( val anchor = state.anchorMessageId ?: return@LaunchedEffect if (state.anchorRequestId == lastScrolledAnchorRequest) return@LaunchedEffect val alreadyVisible = - !state.anchorAtTop && listState.firstVisibleItemIndex == 0 && - state.messages.firstOrNull()?.id?.id == anchor + !state.anchorAtTop && listState.firstVisibleItemIndex == 0 && state.messages.firstOrNull()?.id?.id == anchor if (alreadyVisible) { lastScrolledAnchorRequest = state.anchorRequestId return@LaunchedEffect @@ -325,8 +334,8 @@ internal fun ColumnScope.DialogHistoryPane( val row = layout.visibleItemsInfo.firstOrNull { it.index == index } ?: return@repeat - val height = layout.viewportSize.height - - layout.beforeContentPadding - layout.afterContentPadding + val height = + layout.viewportSize.height - layout.beforeContentPadding - layout.afterContentPadding listState.scrollToItem(index, row.size - height) } } @@ -349,9 +358,7 @@ internal fun ColumnScope.DialogHistoryPane( visible.maxOfOrNull { it.index }, ) }.collect { (first, _, last) -> - if (last != null && - historyPagingAllowed(state.searchQuery) && - shouldPageOlder( + if (last != null && historyPagingAllowed(state.searchQuery) && shouldPageOlder( lastVisibleIndex = last, size = messageHeads.size, hasOlder = state.hasOlder, @@ -373,9 +380,11 @@ internal fun ColumnScope.DialogHistoryPane( snapshotFlow { listState.firstVisibleItemIndex }.collect { first -> - if ( - historyPagingAllowed(state.searchQuery) && - shouldPageNewer(first, state.hasNewer, state.loadingNewer) + if (historyPagingAllowed(state.searchQuery) && shouldPageNewer( + first, + state.hasNewer, + state.loadingNewer + ) ) { component.onLoadNewer() } @@ -406,9 +415,7 @@ internal fun ColumnScope.DialogHistoryPane( }.collect { rows -> component.onVisibleWindow( visibleAlbumMessageIds( - state.messages, - rows, - rowMessageIndices + state.messages, rows, rowMessageIndices ) ) } @@ -436,7 +443,12 @@ internal fun ColumnScope.DialogHistoryPane( ) { items( items = messageHeads, - key = { (_, msg) -> "${msg.id.chatId.value}:${msg.id.id}" }, + key = { (_, msg) -> + val identity = + msg.randomId?.takeIf { it != 0L }?.let { "random:$it" } + ?: "message:${msg.id.id}" + "${msg.id.chatId.value}:$identity" + }, contentType = { (_, msg) -> msg.mediaKind ?: "text" }, ) { (index, message) -> val album = albumSlice(state.messages, index) @@ -444,8 +456,11 @@ internal fun ColumnScope.DialogHistoryPane( val newerMessage = state.messages.getOrNull(index - 1) val joinsOlder = message.belongsToSameBlockAs(olderMessage, zone) val joinsNewer = message.belongsToSameBlockAs(newerMessage, zone) - val newDay = olderMessage == null || - !DialogTime.sameLocalDay(message.date, olderMessage.date, zone) + val newDay = olderMessage == null || !DialogTime.sameLocalDay( + message.date, + olderMessage.date, + zone + ) if (unreadAt != null && index == unreadAt) { Text( text = stringResource(R.string.dialog_unread), @@ -457,12 +472,17 @@ internal fun ColumnScope.DialogHistoryPane( color = MaterialTheme.colorScheme.primary, ) } - Column { + Column( + Modifier.animateItem( + fadeInSpec = tween(180), + placementSpec = spring(dampingRatio = 1f, stiffness = 400f), + fadeOutSpec = tween(120), + ) + ) { if (newDay) { Box(Modifier.padding(horizontal = 10.dp)) { DialogDateSeparator( - epochSeconds = message.date, - zone = zone + epochSeconds = message.date, zone = zone ) } } @@ -479,29 +499,18 @@ internal fun ColumnScope.DialogHistoryPane( } .joinedSelectionHighlight( selected = selectedForForwarding, - extendAbove = olderMessage != null && - olderMessage.id.id in selectedIds && - !newDay, - extendBelow = newer != null && - newer.id.id in selectedIds && - DialogTime.sameLocalDay( - message.date, - newer.date, - zone - ), + extendAbove = olderMessage != null && olderMessage.id.id in selectedIds && !newDay, + extendBelow = newer != null && newer.id.id in selectedIds && DialogTime.sameLocalDay( + message.date, newer.date, zone + ), color = selectionColor, ) .then( - if ( - selectableForForwarding && - selectingMessage?.id != message.id - ) { + if (selectableForForwarding && selectingMessage?.id != message.id) { hold.onToggle = { local -> val window = hold.coordinates?.localToWindow(local) applyHistoryGesture( - album, - HistoryMessageGesture.Tap, - window + album, HistoryMessageGesture.Tap, window ) } hold.onLongPress = { @@ -524,42 +533,72 @@ internal fun ColumnScope.DialogHistoryPane( .then(rowModifier) .padding(horizontal = 10.dp), ) { + val outgoingSend = outgoingBubbleSend( + message, + state.outgoingTextSends, + state.outgoingMediaSends + ) + val sendFailed = outgoingSend?.phase in setOf( + OutgoingSendPhase.FAILED, + OutgoingSendPhase.UNCERTAIN + ) + val bubbleMessage = if (outgoingSend != null) message.copy( + pending = !sendFailed, + failed = sendFailed, + ) else message val quoted = message.replyToMsgId?.let(messageById::get) val caption = album.firstOrNull { !it.text.isNullOrBlank() } SwipeToReply( - enabled = menuMessage == null && selectingMessage == null && !multiSelecting && - message.mediaKind != "service" && !message.pending && message.id.id > 0 && - (state.canSendPlain || state.canSendPhotos), + enabled = menuMessage == null && selectingMessage == null && !multiSelecting && message.mediaKind != "service" && !message.pending && message.id.id > 0 && (state.canSendPlain || state.canSendPhotos), onReply = { component.onReply( - message, - focusComposer = true + message, focusComposer = true ) }, ) { MessageBubble( + onRetrySend = outgoingSend?.takeIf { sendFailed } + ?.let { send -> + { + if (send.media) component.onRetryMediaSend( + send.id + ) + else component.onRetryTextSend(send.id) + } + }, + sendUncertain = outgoingSend?.phase == OutgoingSendPhase.UNCERTAIN, + sendError = outgoingSend?.let { send -> + (state.outgoingSendSync.errors[send.id] + ?: state.outgoingSendSync.error)?.toUiMessage() + }, onMarkupButton = { button -> component.onBotButton( - message.id.id, - button, - fromKeyboard = false + message.id.id, button, fromKeyboard = false ) }, - onToggleChecklist = { itemId -> + checklistBusy = message.id.id in state.checklistMutations, + onRemoveChecklistItem = if (state.canCreateChecklist) { itemId -> + component.onRemoveChecklistItem(message, itemId) + } else null, + onEditChecklistItem = if (state.canCreateChecklist) { itemId -> + component.onEditChecklistItem(message, itemId) + } else null, + onToggleChecklist = if (state.canView) { itemId -> component.onToggleChecklist(message.id.id, itemId) - }, - onPollVote = component::onVotePoll, - onAddChecklistItem = { messageId, nextId -> + } else null, + onClosePoll = component::onClosePoll.takeIf { state.canView }, + pollVoting = message.id.id in state.pollVotesInFlight, + onPollVote = component::onVotePoll.takeIf { state.canView }, + onAddChecklistItem = if (state.canView) { messageId, nextId -> taskDraftFor = messageId to nextId - }, + } else null, selectable = selectedForForwarding, message = if (album.size > 1 && caption != null) { - message.copy( - text = caption.text, - entities = caption.entities + bubbleMessage.copy( + text = caption.text, entities = caption.entities ) } else { - message + bubbleMessage }, album = album, sender = state.senders[message.senderId], @@ -610,8 +649,7 @@ internal fun ColumnScope.DialogHistoryPane( }, quoted = quoted, quotedSender = quoted?.senderId?.let { state.senders[it]?.title }, - hideTopicRootReply = state.inForumTopic && - message.replyToMsgId == state.threadTopId, + hideTopicRootReply = state.inForumTopic && message.replyToMsgId == state.threadTopId, onReact = { emoji, doc -> component.onReact(message.id.id, emoji, doc) }, @@ -647,9 +685,7 @@ internal fun ColumnScope.DialogHistoryPane( modifier = Modifier.padding(top = if (joinsOlder) 0.dp else 6.dp), ) } - if (menuMessage?.id == message.id && - (!menuVisibility.isIdle || menuVisibility.currentState || menuVisibility.targetState) - ) { + if (menuMessage?.id == message.id && (!menuVisibility.isIdle || menuVisibility.currentState || menuVisibility.targetState)) { val menuPlacement = remember { AppMenuPlacementState() } AppMenuScrimPopup( visible = menuExpanded, @@ -681,8 +717,7 @@ internal fun ColumnScope.DialogHistoryPane( viewers = state.messageViewers[message.id.id], mediaRepository = component.mediaRepository, viewerAvatar = { viewer -> - viewer.avatarCacheKey - ?.let { + viewer.avatarCacheKey?.let { component.mediaRepository?.cachedFile( it ) @@ -712,8 +747,7 @@ internal fun ColumnScope.DialogHistoryPane( clipboard.setClipEntry( ClipEntry( android.content.ClipData.newPlainText( - "text", - it + "text", it ) ) ) @@ -867,8 +901,8 @@ internal fun ColumnScope.DialogHistoryPane( } } val hasInlineHits = state.inlineResults?.results?.isNotEmpty() == true - val showInline = state.inlineQuery != null && - (state.inlineLoading || hasInlineHits || (state.inlineResults != null && !state.inlineError)) + val showInline = + state.inlineQuery != null && (state.inlineLoading || hasInlineHits || (state.inlineResults != null && !state.inlineError)) if (showInline) { InlineBotResultsOverlay( query = state.inlineQuery!!, @@ -882,11 +916,8 @@ internal fun ColumnScope.DialogHistoryPane( modifier = Modifier.align(Alignment.BottomCenter), ) } else { - val showMentions = state.mentionToken != null && - (state.mentionLoading || - state.mentionCandidates.isNotEmpty() || - state.isGroup || - state.isChannel) + val showMentions = + state.mentionToken != null && (state.mentionLoading || state.mentionCandidates.isNotEmpty() || state.isGroup || state.isChannel) MentionOverlayHost( visible = showMentions, handle = state.mentionToken.orEmpty(), @@ -960,50 +991,50 @@ private class RowHold { private fun Modifier.messageSelectionTouch(hold: RowHold): Modifier = semantics(mergeDescendants = true) { - role = Role.Checkbox + role = Role.Checkbox toggleableState = if (hold.selected) ToggleableState.On else ToggleableState.Off - onClick { - hold.onToggle(Offset.Zero) - true - } + onClick { + hold.onToggle(Offset.Zero) + true + } }.pointerInput(hold) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - val slop = viewConfiguration.touchSlop - var claimed = false - val completed = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { - var current = down - while (current.pressed) { - val event = awaitPointerEvent() - val change = - event.changes.firstOrNull { it.id == down.id } ?: return@withTimeoutOrNull false - if (change.isConsumed) { - claimed = true - return@withTimeoutOrNull false + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val slop = viewConfiguration.touchSlop + var claimed = false + val completed = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis + 48) { + var current = down + while (current.pressed) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + ?: return@withTimeoutOrNull false + if (change.isConsumed) { + claimed = true + return@withTimeoutOrNull false + } + val delta = change.position - down.position + if (delta.x * delta.x + delta.y * delta.y > slop * slop) { + return@withTimeoutOrNull false + } + current = change } - val delta = change.position - down.position - if (delta.x * delta.x + delta.y * delta.y > slop * slop) { - return@withTimeoutOrNull false + if (!current.isConsumed) { + current.consume() + hold.onToggle(current.position) } - current = change - } - if (!current.isConsumed) { - current.consume() - hold.onToggle(current.position) + true } - true - } - if (completed == null && !claimed && !down.isConsumed) { - hold.onLongPress() - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } - if (change == null || !change.pressed) break - change.consume() + if (completed == null && !claimed && !down.isConsumed) { + hold.onLongPress() + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) break + change.consume() + } } } } - } private val SelectionGap = 2.dp 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 ba7f85c57..25f75a532 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 @@ -54,6 +54,8 @@ import org.monogram.core.models.remapTextEntities import org.monogram.core.ui.components.AppStatusBanner import org.monogram.core.ui.components.AppSyncStatus import org.monogram.core.ui.components.SearchField +import org.monogram.core.ui.currentLocation +import org.monogram.core.ui.lastKnownLocation import org.monogram.feature.dialog.ComposerPanels import org.monogram.feature.dialog.DialogComponent import org.monogram.feature.dialog.R @@ -105,6 +107,8 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { val selectedMessageIds = rememberSaveable(state.chatId.value) { mutableStateOf>(emptyList()) } val taskDraftFor = remember { mutableStateOf?>(null) } + var checklistCreation by rememberSaveable { mutableStateOf(false) } + var pollCreation by rememberSaveable { mutableStateOf(false) } val taskDraft = remember { mutableStateOf("") } LaunchedEffect(state.messages) { val pruned = pruneForwardSelection(selectedMessageIds.value, state.messages) @@ -138,6 +142,34 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { val composerChrome = remember { ComposerChromeHeight() } val context = LocalContext.current val attachScope = rememberCoroutineScope() + var replacementId by rememberSaveable { mutableIntStateOf(0) } + var replacementKey by rememberSaveable { mutableStateOf("") } + var replacementKind by rememberSaveable { mutableStateOf("") } + val receiveReplacement: (android.net.Uri?) -> Unit = { uri -> + val id = replacementId + val key = replacementKey + val kind = replacementKind + replacementId = 0 + if (uri != null && id > 0 && component.state.value.editing?.let { + it.id.id == id && it.mediaCacheKey == key && it.mediaKind == kind + } == true) { + attachScope.launch { + val picked = copyPickedMedia( + context, + uri, + kind, + if (kind == "photo") "jpg" else if (kind == "video") "mp4" else "bin" + ) + if (picked != null) component.onReplaceAttachment(id, key, picked.toUploadItem()) + } + } + } + val replacementVisualPicker = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), receiveReplacement + ) + val replacementFilePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), receiveReplacement + ) val photoPicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(10), ) { uris -> @@ -228,10 +260,31 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { var locationDraft by remember { mutableStateOf?>(null) } var locating by remember { mutableStateOf(false) } var pendingLocationPreview by remember { mutableStateOf(false) } + var liveLocationJob by remember { mutableStateOf(null) } + var liveLocationGeneration by remember { mutableStateOf(0L) } + var liveLocating by remember { mutableStateOf(false) } + fun cancelLiveFix() { + liveLocationGeneration++ + liveLocationJob?.cancel() + liveLocationJob = null + liveLocating = false + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { cancelLiveFix() } + var pendingResumeId by rememberSaveable { mutableIntStateOf(0) } + var pendingResumeKey by rememberSaveable { mutableStateOf("") } + val resumePermission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + val id = pendingResumeId + pendingResumeId = 0 + if (id > 0 && pendingResumeKey == component.liveLocationControlKey) { + if (grants.values.any { it }) component.onResumeLiveLocation(id) + else attachScope.launch { snackbarHostState.showSnackbar(context.getString(R.string.dialog_location_permission_needed)) } + } + } val locationPermission = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted) { + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + if (grants.values.any { it }) { pendingLocationPreview = true } else { attachScope.launch { @@ -253,7 +306,12 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { if (fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED) { pendingLocationPreview = true } else { - locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) + locationPermission.launch( + arrayOf( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) } } LaunchedEffect(attachOpen, pendingLocationPreview) { @@ -461,6 +519,13 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { }, onSendDocument = component::onSendSavedGif, onSendLocation = sendLocation, + canSendLocation = state.canView && state.canSendPlain, + onCreatePoll = if (state.canCreatePoll && !state.sending && state.editing == null && state.pendingAttach.isEmpty() && state.forwardMessage == null) { + { pollCreation = true } + } else null, + onCreateChecklist = if (state.canCreateChecklist && !state.sending && state.editing == null && state.pendingAttach.isEmpty() && state.forwardMessage == null) { + { checklistCreation = true } + } else null, // The sheet closes itself exactly once; a toggle here would double-dismiss it. onDismiss = component::onCloseAttachSheet, requestHide = attachHide, @@ -469,15 +534,55 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { if (locationPicker) { LocationPickerSheet( place = locationDraft?.let { GeoPlace(it.first, it.second) }, - locating = locating, + locating = locating || liveLocating, mapTiles = component.mediaRepository?.mapTiles, onSend = { val draft = locationDraft ?: return@LocationPickerSheet + cancelLiveFix() component.onSendLocation(draft.first, draft.second) locationPicker = false locating = false }, + onShareLive = if (component.liveLocationControlsAvailable) ({ period -> + cancelLiveFix() + val generation = liveLocationGeneration + liveLocating = true + liveLocationJob = attachScope.launch { + try { + val fix = currentLocation(context, allowCached = false) + val permitted = ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (generation == liveLocationGeneration && locationPicker) { + if (fix != null && permitted) { + component.onSendLocation( + fix.first, fix.second, period + ) + locationPicker = false + } else snackbarHostState.showSnackbar( + context.getString( + if (permitted) R.string.dialog_location_unavailable else R.string.dialog_location_permission_needed + ) + ) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + if (generation == liveLocationGeneration && locationPicker) snackbarHostState.showSnackbar( + context.getString(R.string.dialog_location_unavailable) + ) + } finally { + if (generation == liveLocationGeneration) liveLocating = + false + } + } + }) else null, onDismiss = { + cancelLiveFix() locationPicker = false locating = false }, @@ -510,16 +615,10 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { val glyph = "🙂" composer.value = insertComposerText(before, glyph) val entities = remapTextEntities( - before.text, - composer.value.text, - state.draftEntities - ) + - TextEntity( - "custom_emoji", - offset, - glyph.length, - documentId.toString() - ) + before.text, composer.value.text, state.draftEntities + ) + TextEntity( + "custom_emoji", offset, glyph.length, documentId.toString() + ) component.onDraftChanged(composer.value.text) component.onDraftEntities(entities) }, @@ -552,6 +651,14 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { } else { state.reactionUsers[listId] } + val viewerPage = + (if (peerListKind.value == "poll") state.pollVoterPages else state.reactionPages)[listId] + LaunchedEffect(listId, peerListKind.value) { + if (viewerPage == null) { + if (peerListKind.value == "poll") component.onLoadPollVoters(listId) + else component.onLoadReactionUsers(listId) + } + } PeerListSheet( kind = peerListKind.value, users = users, @@ -561,16 +668,20 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { null }, initialFilter = peerListFilter.value, + page = viewerPage, + onLoadMore = { + if (peerListKind.value == "poll") component.onLoadPollVoters(listId) + else component.onLoadReactionUsers(listId) + }, mediaRepository = component.mediaRepository, viewerAvatar = { viewer -> viewer.avatarCacheKey?.let { component.mediaRepository?.cachedFile( it ) - } - ?: component.mediaRepository?.cachedAvatar( - PeerId(viewer.peerId.value), - ) + } ?: component.mediaRepository?.cachedAvatar( + PeerId(viewer.peerId.value), + ) }, onOpenProfile = { id -> peerListId.value = null @@ -603,6 +714,16 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { clipboard = clipboard, attachScope = attachScope, ) + if (pollCreation || checklistCreation) MessageCreationSheet( + if (checklistCreation) MessageCreationKind.Checklist else MessageCreationKind.Poll, + if (checklistCreation) state.checklistAdmissionId else state.pollAdmissionId, + state.sending, + state.error, + component::onSendPoll, + component::onSendChecklist, + onDismiss = { pollCreation = false; checklistCreation = false }, + allowed = (if (checklistCreation) state.canCreateChecklist else state.canCreatePoll) && !state.draftUnsupported && state.editing == null && state.pendingAttach.isEmpty() && state.forwardMessage == null + ) DialogScreenDialogs( component = component, state = state, @@ -618,6 +739,45 @@ internal fun DialogScreen(component: DialogComponent, modifier: Modifier) { PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo), ) }, + onResumeLiveLocation = { id -> + val permitted = ContextCompat.checkSelfPermission( + context, android.Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( + context, android.Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (permitted) component.onResumeLiveLocation(id) + else { + pendingResumeId = id + pendingResumeKey = component.liveLocationControlKey + resumePermission.launch( + arrayOf( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + }, + onReplaceAttachment = if (state.editing?.let { + it.outgoing && !it.pending && it.id.id > 0 && !it.mediaCacheKey.isNullOrBlank() && (it.mediaKind in setOf( + "photo", "video" + ) && state.canSendPhotos || it.mediaKind == "document" && state.canSendPlain) + } == true) ({ + val target = component.state.value.editing + if (target != null && !component.state.value.sending) { + replacementId = target.id.id + replacementKey = target.mediaCacheKey.orEmpty() + replacementKind = target.mediaKind.orEmpty() + if (replacementKind == "document") replacementFilePicker.launch( + arrayOf("*/*") + ) + else replacementVisualPicker.launch( + PickVisualMediaRequest( + if (replacementKind == "video") ActivityResultContracts.PickVisualMedia.VideoOnly + else ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + }) else null, onFile = { filePicker.launch(arrayOf("*/*")) }, onLocation = sendLocation, onCloseAttachAnimated = { 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 d39c66ffc..bf8382f5b 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 @@ -46,6 +46,8 @@ internal fun DialogScreenDialogs( taskDraftFor: MutableState?>, taskDraft: MutableState, onPhotos: () -> Unit, + onResumeLiveLocation: (Int) -> Unit = component::onResumeLiveLocation, + onReplaceAttachment: (() -> Unit)? = null, onFile: () -> Unit, onLocation: () -> Unit, onCloseAttachAnimated: () -> Unit, @@ -85,10 +87,12 @@ internal fun DialogScreenDialogs( draftEntities = state.draftEntities, draftPreview = state.draftPreview, draftHasMarkdown = state.draftHasMarkdown, - sending = state.sending, + draftUnsupported = state.draftUnsupported, + sending = state.sending || state.editing?.id?.id in state.checklistMutations, canView = state.canView, canSendPlain = state.canSendPlain, canSendPhotos = state.canSendPhotos, + canSendStructured = state.canCreatePoll || state.canCreateChecklist, isChannel = state.isChannel, isGroup = state.isGroup, left = state.left, @@ -96,10 +100,13 @@ internal fun DialogScreenDialogs( blockedByMe = state.blockedByMe, onReadOnlyBar = component::onReadOnlyBar, editing = state.editing != null, - editingBody = state.editing?.text.orEmpty(), + editingBody = state.editing?.let { message -> + state.editingChecklistItemId?.let { id -> message.checklist?.items?.singleOrNull { it.id == id }?.text } + ?: message.checklist?.title ?: message.text + }.orEmpty(), replyBody = state.replyTo?.let { it.text ?: replyMediaFallbackLabel(it.mediaKind) - }, + } ?: if (state.draftReplyId > 0) replyMediaFallbackLabel(null) else null, pendingAttach = state.pendingAttach, hasFailed = state.messages.any { it.failed }, composerFocusSeq = state.composerFocusSeq, @@ -112,6 +119,8 @@ internal fun DialogScreenDialogs( onDismissLinkPreview = component::onDismissLinkPreview, onRestoreLinkPreview = component::onRestoreLinkPreview, onPhotos = onPhotos, + onResumeLiveLocation = onResumeLiveLocation, + onReplaceAttachment = onReplaceAttachment, onFile = onFile, onLocation = onLocation, onCloseAttachAnimated = onCloseAttachAnimated, 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 b135be731..579bcef7b 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 @@ -18,6 +18,7 @@ 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.Schedule import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -39,6 +40,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.MutableStateFlow import org.monogram.core.common.Outcome import org.monogram.core.models.DisplayedChatAction @@ -61,7 +63,6 @@ import org.monogram.feature.dialog.DialogComponent import org.monogram.feature.dialog.DialogStore import org.monogram.feature.dialog.R import org.monogram.network.http.MediaPriority -import org.monogram.network.http.UserDownload @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -80,6 +81,9 @@ internal fun DialogTopBar( onForwardSelectedMessages: () -> Unit, onDeleteSelectedMessages: () -> Unit, ) { + val hasScheduledMessages by component.hasScheduledMessages.collectAsStateWithLifecycle( + initialValue = false + ) if (selectedMessageCount > 0) { TopAppBar( windowInsets = WindowInsets.statusBars, @@ -130,152 +134,160 @@ internal fun DialogTopBar( CompositionLocalProvider( LocalDialogMedia provides component.mediaRepository, ) { - val mediaRepository = component.mediaRepository - val userDownloads by remember(mediaRepository) { - mediaRepository?.userDownloads ?: MutableStateFlow(emptyMap()) - }.collectAsState() - val downloadProgress by remember(mediaRepository) { - mediaRepository?.downloadProgress ?: MutableStateFlow(emptyMap()) - }.collectAsState() - var showDownloads by remember { mutableStateOf(false) } - Column(modifier = Modifier.fillMaxWidth()) { - TopAppBar( - windowInsets = WindowInsets.statusBars, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.clickable(onClick = component::onOpenProfile), - ) { - val cacheGeneration = - component.mediaRepository?.cacheGeneration?.collectAsState()?.value ?: 0L - val chatAvatar = rememberEnsuredFile( - generation = cacheGeneration, - identity = state.chatId.value to state.photoCacheKey, - resolve = { - state.photoCacheKey?.let { component.mediaRepository?.cachedFile(it) } - ?: component.mediaRepository?.cachedAvatar(state.chatId) - }, - ensure = { - val repo = component.mediaRepository ?: return@rememberEnsuredFile null - val key = peerAvatarCacheKey( - state.chatId, - state.photoCacheKey, - ) - when ( - val result = repo.ensureLocalAvatar( - state.chatId, - key, - priority = MediaPriority.VISIBLE, - ) - ) { - is Outcome.Ok -> result.value - is Outcome.Err -> null - } - }, - ) - if (state.inForumTopic) { - TopicIcon( - org.monogram.core.models.ForumTopic( - id = state.threadTopId, - title = title, - iconColor = state.topicIconColor, - iconEmojiId = state.topicIconEmojiId, - ), - ) - } else { - PeerAvatar( - title = title, - size = 38.dp, - imageFile = chatAvatar, - previewBytes = rememberStrippedPreview(state.chatId) { - component.mediaRepository?.peerInlineThumbJpeg(state.chatId) - }, - ) - } - Column(modifier = Modifier.weight(1f)) { + val mediaRepository = component.mediaRepository + val userDownloads by remember(mediaRepository) { + mediaRepository?.userDownloads ?: MutableStateFlow(emptyMap()) + }.collectAsState() + val downloadProgress by remember(mediaRepository) { + mediaRepository?.downloadProgress ?: MutableStateFlow(emptyMap()) + }.collectAsState() + var showDownloads by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxWidth()) { + TopAppBar( + windowInsets = WindowInsets.statusBars, + title = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.clickable(onClick = component::onOpenProfile), ) { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f, fill = false), + val cacheGeneration = + component.mediaRepository?.cacheGeneration?.collectAsState()?.value + ?: 0L + val chatAvatar = rememberEnsuredFile( + generation = cacheGeneration, + identity = state.chatId.value to state.photoCacheKey, + resolve = { + state.photoCacheKey?.let { component.mediaRepository?.cachedFile(it) } + ?: component.mediaRepository?.cachedAvatar(state.chatId) + }, + ensure = { + val repo = + component.mediaRepository ?: return@rememberEnsuredFile null + val key = peerAvatarCacheKey( + state.chatId, + state.photoCacheKey, + ) + when ( + val result = repo.ensureLocalAvatar( + state.chatId, + key, + priority = MediaPriority.VISIBLE, + ) + ) { + is Outcome.Ok -> result.value + is Outcome.Err -> null + } + }, ) - SponsorBadge(peerId = state.chatId.value, size = 18.dp) - state.emojiStatusDocumentId?.let { documentId -> - CustomEmojiGlyph( - documentId = documentId, - size = 18.dp, - onClick = { onOpenEmojiStatus(documentId) }, + if (state.inForumTopic) { + TopicIcon( + org.monogram.core.models.ForumTopic( + id = state.threadTopId, + title = title, + iconColor = state.topicIconColor, + iconEmojiId = state.topicIconEmojiId, + ), + ) + } else { + PeerAvatar( + title = title, + size = 38.dp, + imageFile = chatAvatar, + previewBytes = rememberStrippedPreview(state.chatId) { + component.mediaRepository?.peerInlineThumbJpeg(state.chatId) + }, + ) + } + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f, fill = false), + ) + SponsorBadge(peerId = state.chatId.value, size = 18.dp) + state.emojiStatusDocumentId?.let { documentId -> + CustomEmojiGlyph( + documentId = documentId, + size = 18.dp, + onClick = { onOpenEmojiStatus(documentId) }, + ) + } + } + DialogStatusLine( + chatId = state.chatId.value, + enabled = !state.isSelf, + status = state.peerStatus, + statusAt = state.peerStatusAt, + onExpired = component::onRefreshPresence, + typing = displayedChatAction( + state.typingUsers.values, + named = state.isGroup || state.isChannel, + ), + syncLabel = syncStatus.uiLabel(), + stats = chatStatsSubtitle( + isChannel = state.isChannel, + isGroup = state.isGroup, + members = state.membersCount, + online = state.onlineCount, + ), + channelLabel = stringResource(R.string.dialog_channel), + groupLabel = stringResource(R.string.dialog_group), + isChannel = state.isChannel, + isGroup = state.isGroup, ) } } - DialogStatusLine( - chatId = state.chatId.value, - enabled = !state.isSelf, - status = state.peerStatus, - statusAt = state.peerStatusAt, - onExpired = component::onRefreshPresence, - typing = displayedChatAction( - state.typingUsers.values, - named = state.isGroup || state.isChannel, - ), - syncLabel = syncStatus.uiLabel(), - stats = chatStatsSubtitle( - isChannel = state.isChannel, - isGroup = state.isGroup, - members = state.membersCount, - online = state.onlineCount, - ), - channelLabel = stringResource(R.string.dialog_channel), - groupLabel = stringResource(R.string.dialog_group), - isChannel = state.isChannel, - isGroup = state.isGroup, - ) - } - } - }, - navigationIcon = { - if (component.showBackButton) IconButton(onClick = component::onBack) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.dialog_back), - ) - } - }, - actions = { - if (userDownloads.isNotEmpty()) { - IconButton(onClick = { showDownloads = true }) { - Icon( - imageVector = Icons.Outlined.Download, - contentDescription = stringResource(R.string.dialog_downloads), - ) - } - } - IconButton( - onClick = onToggleSearch, - ) { - Icon( - imageVector = if (showSearch) Icons.Outlined.Close else Icons.Outlined.Search, - contentDescription = stringResource( - if (showSearch) R.string.dialog_search_close else R.string.dialog_search, - ), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) - DialogDownloadProgressBar( - downloads = userDownloads, - progress = downloadProgress, - ) - } + }, + navigationIcon = { + if (component.showBackButton) IconButton(onClick = component::onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.dialog_back), + ) + } + }, + actions = { + if (userDownloads.isNotEmpty()) { + IconButton(onClick = { showDownloads = true }) { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = stringResource(R.string.dialog_downloads), + ) + } + } + if (hasScheduledMessages) IconButton(onClick = component::onOpenScheduledQueue) { + Icon( + Icons.Outlined.Schedule, + stringResource(R.string.dialog_scheduled_messages) + ) + } + IconButton( + onClick = onToggleSearch, + ) { + Icon( + imageVector = if (showSearch) Icons.Outlined.Close else Icons.Outlined.Search, + contentDescription = stringResource( + if (showSearch) R.string.dialog_search_close else R.string.dialog_search, + ), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) + DialogDownloadProgressBar( + downloads = userDownloads, + progress = downloadProgress, + ) + } if (showDownloads) { DialogDownloadsSheet( downloads = userDownloads, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogWriteBar.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogWriteBar.kt index b079f44f1..86b0282e6 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogWriteBar.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogWriteBar.kt @@ -46,6 +46,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material.icons.outlined.OpenInFull import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -141,6 +143,7 @@ internal fun DialogWriteBar( sending: Boolean, canSendPlain: Boolean, canSendPhotos: Boolean, + canSendStructured: Boolean = false, isChannel: Boolean, isGroup: Boolean = false, left: Boolean = false, @@ -153,6 +156,9 @@ internal fun DialogWriteBar( pendingAttach: List, hasFailed: Boolean, onSend: () -> Unit, + sendMenu: (@Composable () -> Unit)? = null, + onSendLongClick: (() -> Unit)? = null, + onReplaceAttachment: (() -> Unit)? = null, onSlot: (ComposerSlotAction) -> Unit = {}, onRetryFailed: () -> Unit, onCancelEdit: () -> Unit, @@ -193,7 +199,7 @@ internal fun DialogWriteBar( keyboard?.show() } val appearance by AppearanceSettings.state.collectAsStateWithLifecycle() - val canCompose = (canSendPlain || canSendPhotos) && !blockedByMe + val canCompose = (canSendPlain || canSendPhotos || canSendStructured) && !blockedByMe val readOnly = remember { MutableTransitionState(!canCompose) } readOnly.targetState = !canCompose val motion = MaterialTheme.motionScheme @@ -220,7 +226,7 @@ internal fun DialogWriteBar( val sendEnabled = when { sending -> false - editing -> composer.text.isNotBlank() + editing -> composer.text.isNotBlank() || pendingAttach.isNotEmpty() else -> (composer.text.isNotBlank() && canSendPlain) || (pendingAttach.isNotEmpty() && canSendPhotos) } @@ -266,6 +272,22 @@ internal fun DialogWriteBar( title = stringResource(R.string.dialog_editing), body = editingBody, onClear = onCancelEdit, + leading = onReplaceAttachment?.let { replace -> + { + FilledTonalIconButton( + onClick = replace, + enabled = !sending, + shapes = IconButtonDefaults.shapes(), + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Outlined.PhotoLibrary, + contentDescription = stringResource(R.string.dialog_replace_attachment), + modifier = Modifier.size(24.dp) + ) + } + } + }, ) } replyBody?.let { body -> @@ -318,8 +340,16 @@ internal fun DialogWriteBar( style = appearance.composerStyle, leftAction = leftAction, rightAction = rightAction, - leftEnabled = !editing && leftAction.canSend(canSendPlain, canSendPhotos), - rightEnabled = !editing && rightAction.canSend(canSendPlain, canSendPhotos), + leftEnabled = !editing && leftAction.canSend( + canSendPlain, + canSendPhotos, + canSendStructured + ), + rightEnabled = !editing && rightAction.canSend( + canSendPlain, + canSendPhotos, + canSendStructured + ), sendEnabled = sendEnabled, sending = sending, editing = editing, @@ -335,6 +365,9 @@ internal fun DialogWriteBar( onSlot(rightAction) }, onSend = onSend, + sendMenu = sendMenu, + onSendLongClick = onSendLongClick, + sendOptionsDescription = stringResource(R.string.dialog_send_options), ) { Box(contentAlignment = Alignment.CenterStart) { if (composer.text.isEmpty()) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LiveLocationNotices.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LiveLocationNotices.kt new file mode 100644 index 000000000..02fa8881b --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LiveLocationNotices.kt @@ -0,0 +1,193 @@ +package org.monogram.feature.dialog.ui + +import android.icu.text.DateFormat +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.LiveLocationPhase +import org.monogram.core.models.LiveLocationSession +import org.monogram.core.models.MessageId +import org.monogram.core.ui.components.toUiMessage +import org.monogram.core.ui.loading.MonogramBusyIconButton +import org.monogram.feature.dialog.DialogComponent +import org.monogram.feature.dialog.R + +@Composable +internal fun LiveLocationControls(component: DialogComponent, onResume: (Int) -> Unit) { + if (!component.liveLocationControlsAvailable) return + val sessions by component.liveLocationSessions.collectAsStateWithLifecycle(emptyList()) + val errors by component.liveLocationErrors.collectAsStateWithLifecycle(emptyMap()) + val failure by component.liveLocationFailure.collectAsStateWithLifecycle(null) + val busy by component.liveLocationBusy.collectAsStateWithLifecycle() + val failed by component.liveLocationActionFailed.collectAsStateWithLifecycle() + LiveLocationNotices( + sessions, + errors, + failure, + busy, + failed, + onResume, + component::onStopLiveLocation, + component::onJumpToMessage + ) +} + +@Composable +internal fun LiveLocationNotices( + sessions: List, + errors: Map, + failure: TelegramError?, + busy: Set, + failed: Set, + onResume: (Int) -> Unit, + onStop: (Int) -> Unit, + onOpen: (Int) -> Unit, +) { + val visible = + sessions.filter { it.phase !in setOf(LiveLocationPhase.STOPPED, LiveLocationPhase.EXPIRED) } + if (visible.isEmpty()) return + val locale = android.icu.util.ULocale.forLocale(LocalResources.current.configuration.locales[0]) + val time = remember(locale) { + DateFormat.getTimeInstance( + DateFormat.SHORT, locale + ) + } + val date = remember(locale) { + DateFormat.getDateTimeInstance( + DateFormat.SHORT, DateFormat.SHORT, locale + ) + } + Column( + Modifier + .fillMaxWidth() + .heightIn(max = 160.dp) + .verticalScroll(rememberScrollState()) + ) { + visible.forEach { session -> + key(session.messageId) { + val id = session.messageId.id + val pending = session.phase == LiveLocationPhase.STOPPING || id in busy + val error = errors[session.messageId] ?: failure + val uncertain = session.phase == LiveLocationPhase.UNCERTAIN_STOP + val status = when (session.phase) { + LiveLocationPhase.PAUSED -> stringResource(R.string.dialog_location_paused) + LiveLocationPhase.STOPPING -> stringResource(R.string.dialog_location_stopping) + LiveLocationPhase.UNCERTAIN_STOP -> stringResource(R.string.dialog_location_stop_uncertain) + else -> null + } + val until = session.expiresAt?.let { + val millis = it * 1000 + stringResource( + R.string.dialog_location_until, + (if (android.text.format.DateUtils.isToday(millis)) time else date).format( + java.util.Date(millis) + ) + ) + } + val problem = error?.toUiMessage() + ?: if (id in failed) stringResource(org.monogram.core.ui.R.string.status_generic) else null + val subtitle = listOfNotNull(problem ?: status, until).joinToString(" · ") + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.testTag("live-location-$id") + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Outlined.LocationOn, + null, + modifier = Modifier.size(20.dp), + tint = if (uncertain || problem != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + Column( + Modifier + .weight(1f) + .heightIn(min = 48.dp) + .clickable(role = Role.Button, onClick = { onOpen(id) }), + verticalArrangement = Arrangement.Center + ) { + Text( + stringResource(R.string.dialog_location_live), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotEmpty()) Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (uncertain || problem != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (session.phase == LiveLocationPhase.PAUSED || session.phase == LiveLocationPhase.ACTIVE && failure != null) { + IconButton( + onClick = { onResume(id) }, + enabled = !pending, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Outlined.PlayArrow, + stringResource(R.string.dialog_location_resume) + ) + } + } + MonogramBusyIconButton( + onClick = { onStop(id) }, + busy = pending, + modifier = Modifier + .size(48.dp) + .testTag("live-location-stop-$id"), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = if (uncertain) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ), + status = stringResource(R.string.dialog_location_stopping) + ) { + Icon( + Icons.Outlined.StopCircle, + stringResource(R.string.dialog_location_stop) + ) + } + } + } + } + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LocationPickerSheet.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LocationPickerSheet.kt index 910a15d48..0fd38c3d6 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LocationPickerSheet.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/LocationPickerSheet.kt @@ -1,42 +1,63 @@ package org.monogram.feature.dialog.ui +import android.icu.text.MeasureFormat +import android.icu.util.MeasureUnit import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.RadioButton +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.selectable +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.draw.shadow +import org.monogram.core.ui.components.SettingsCard import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.monogram.core.models.GeoPlace import org.monogram.core.ui.ExpressiveDefaults import org.monogram.core.ui.components.AppModalSheet import org.monogram.feature.dialog.R -/** Location window: [onSend] confirms and closes, [onDismiss] cancels. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LocationPickerSheet( place: GeoPlace?, @@ -44,131 +65,204 @@ internal fun LocationPickerSheet( mapTiles: org.monogram.network.http.MapTileStore?, onSend: () -> Unit, onDismiss: () -> Unit, + onShareLive: ((Int) -> Unit)? = null, ) { - // The app-wide Shapes.small is 12.dp, so the 16.dp press radius is pinned here. + var livePeriod by rememberSaveable { mutableIntStateOf(900) } + var liveMode by rememberSaveable { mutableStateOf(false) } + val sharingLive = liveMode && onShareLive != null val actionShapes = ButtonDefaults.shapes( shape = CircleShape, pressedShape = RoundedCornerShape(ExpressiveDefaults.PressRadius), ) val mapWidth = (LocalConfiguration.current.screenWidthDp - 48).dp - // 16:10; the bubble cards keep their own size. - val mapHeight = mapWidth * 10f / 16f - AppModalSheet(onDismissRequest = onDismiss) { + var closing by remember { mutableStateOf(false) } + AppModalSheet( + onDismissRequest = { closing = true }, visible = !closing, onExited = onDismiss, + containerColor = MaterialTheme.colorScheme.surface + ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.9f).dp) ) { - Text( - text = stringResource(R.string.dialog_location_pick_title), - style = ExpressiveDefaults.titleSemiBold(), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - when { - place != null -> { - LocationMap( - place = place, - mapTiles = mapTiles, - mapWidth = mapWidth, - mapHeight = mapHeight, - mapShape = LocationMapCardShape, - modifier = Modifier.shadow( - elevation = 2.dp, - shape = LocationMapCardShape, - ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), - spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f), - ), - marker = { LocationPin(Modifier.align(Alignment.Center)) }, - ) + CreationSheetHeader( + title = stringResource(R.string.dialog_location_pick_title), + onDismiss = { closing = true }) + Column( + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + ) { + Spacer(Modifier.height(8.dp)) + when { + place != null -> { + LocationMap( + place = place, mapTiles = mapTiles, mapWidth = mapWidth, + mapHeight = 160.dp, mapShape = MaterialTheme.shapes.large, + marker = { LocationPin(Modifier.align(Alignment.Center)) }, + ) + Text( + place.coordinates, style = ExpressiveDefaults.tabularLabel(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + + locating -> Box( + Modifier + .fillMaxWidth() + .height(160.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator() + Text( + stringResource(R.string.dialog_location_locating), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + else -> Box( + Modifier + .fillMaxWidth() + .height(160.dp), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(R.string.dialog_location_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (place != null && locating) LinearProgressIndicator(Modifier.fillMaxWidth()) + if (onShareLive != null) { Spacer(Modifier.height(16.dp)) - CoordinatesChip(place.coordinates) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + listOf(R.string.dialog_location_pick_title, R.string.dialog_location_live) + .forEachIndexed { index, label -> + ToggleButton( + checked = sharingLive == (index == 1), + onCheckedChange = { liveMode = index == 1 }, + enabled = !locating, + shapes = if (index == 0) ButtonGroupDefaults.connectedLeadingButtonShapes() + else ButtonGroupDefaults.connectedTrailingButtonShapes(), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .heightIn(min = 56.dp), + ) { Text(stringResource(label)) } + } + } } - locating -> { - CircularProgressIndicator(modifier = Modifier.padding(vertical = 24.dp)) + if (sharingLive) { + val locale = LocalResources.current.configuration.locales[0] + val formatter = remember(locale) { + MeasureFormat.getInstance( + locale, + MeasureFormat.FormatWidth.WIDE + ) + } + Spacer(Modifier.height(16.dp)) + SettingsCard { + Column(Modifier.selectableGroup()) { + for (period in listOf(900, 3600, 28800)) { + val unit = + if (period < 3600) MeasureUnit.MINUTE else MeasureUnit.HOUR + val value = if (period < 3600) period / 60 else period / 3600 + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .selectable( + livePeriod == period, + enabled = !locating, + role = Role.RadioButton, + onClick = { livePeriod = period }) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + formatter.formatMeasures( + android.icu.util.Measure( + value, + unit + ) + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + RadioButton( + selected = livePeriod == period, + onClick = null, + enabled = !locating + ) + } + } + } + } Text( - text = stringResource(R.string.dialog_location_locating), - style = MaterialTheme.typography.bodyLarge, + stringResource(R.string.dialog_location_foreground), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) ) } - else -> { - Box(modifier = Modifier.height(120.dp), contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.dialog_location_unavailable), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - Spacer(Modifier.height(24.dp)) - Button( - onClick = onSend, - enabled = place != null, - shapes = actionShapes, - modifier = Modifier.fillMaxWidth().height(56.dp), - ) { - Text( - text = stringResource(R.string.dialog_location_send_this), - style = ExpressiveDefaults.actionSemiBold(), - ) } - Spacer(Modifier.height(4.dp)) - TextButton( - onClick = onDismiss, - shapes = actionShapes, - modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 16.dp, bottom = 12.dp) ) { - Text( - text = stringResource(R.string.dialog_cancel), - style = ExpressiveDefaults.actionSemiBold(), - ) + Button( + onClick = { if (sharingLive) onShareLive?.invoke(livePeriod) else onSend() }, + enabled = !locating && (sharingLive || place != null), shapes = actionShapes, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + ) { + Text(stringResource(if (sharingLive) R.string.dialog_location_share_live else R.string.dialog_location_send_this)) + } + TextButton( + onClick = { closing = true }, + shapes = actionShapes, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + ) { + Text(stringResource(R.string.dialog_cancel)) + } } } } } -private val LocationMapCardShape = RoundedCornerShape(28.dp) - @Composable private fun LocationPin(modifier: Modifier = Modifier) { Box(modifier = modifier.size(48.dp), contentAlignment = Alignment.Center) { Box( - modifier = Modifier + Modifier .size(40.dp) + .shadow(3.dp, CircleShape) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.55f)), - ) - Icon( - imageVector = Icons.Filled.LocationOn, - contentDescription = stringResource(R.string.dialog_location_marker), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(44.dp), - ) - } -} - -@Composable -private fun CoordinatesChip(coordinates: String) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - shape = CircleShape, - ) { - Box( - modifier = Modifier - .height(28.dp) - .padding(horizontal = 14.dp), + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { - Text( - text = coordinates, - style = ExpressiveDefaults.tabularLabel(), - maxLines = 1, + Icon( + Icons.Filled.LocationOn, stringResource(R.string.dialog_location_marker), + tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(24.dp) ) } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MarkdownEditor.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MarkdownEditor.kt index a7e6a4bcd..af109f4a2 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MarkdownEditor.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MarkdownEditor.kt @@ -173,6 +173,7 @@ internal fun MarkdownEditorScreen( onEmoji: (() -> Unit)? = null, onApply: () -> Unit, onDismiss: () -> Unit, + applyEnabled: Boolean = true, modifier: Modifier = Modifier, ) { BackHandler(onBack = onDismiss) @@ -284,7 +285,11 @@ internal fun MarkdownEditorScreen( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Button(onClick = onApply, modifier = Modifier.heightIn(min = 48.dp)) { + Button( + onClick = onApply, + enabled = applyEnabled, + modifier = Modifier.heightIn(min = 48.dp) + ) { Icon(Icons.Outlined.Check, null, Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.dialog_markdown_apply)) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt index 7159c42c7..99307bbc8 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageBubble.kt @@ -1,5 +1,17 @@ package org.monogram.feature.dialog.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.animateContentSize +import androidx.compose.material.icons.outlined.MoreVert +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface + import android.text.format.DateFormat.getTimeFormat import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -23,10 +35,15 @@ 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.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -68,6 +85,7 @@ import org.monogram.core.common.Outcome import org.monogram.core.models.Message import org.monogram.core.models.MessageReaction import org.monogram.core.models.Profile +import org.monogram.core.models.ReplyMarkupKind import org.monogram.core.models.peerAvatarCacheKey import org.monogram.core.ui.components.PeerAvatar import org.monogram.core.ui.localizedServiceMessage @@ -80,7 +98,10 @@ import org.monogram.network.http.MediaRepository import java.text.DateFormat.getDateInstance import java.util.Locale -@OptIn(ExperimentalFoundationApi::class) +@OptIn( + ExperimentalFoundationApi::class, + androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class +) @Composable fun MessageBubble( message: Message, @@ -111,9 +132,17 @@ fun MessageBubble( onOpenStickerPack: ((documentId: Long, emojiPack: Boolean) -> Unit)? = null, onInstantView: ((org.monogram.core.models.WebpagePreview) -> Unit)? = null, onMarkupButton: ((org.monogram.core.models.ReplyButton) -> Unit)? = null, + checklistBusy: Boolean = false, onToggleChecklist: ((Int) -> Unit)? = null, + onRemoveChecklistItem: ((Int) -> Unit)? = null, + onEditChecklistItem: ((Int) -> Unit)? = null, + onClosePoll: ((Int) -> Unit)? = null, + pollVoting: Boolean = false, onPollVote: ((Int, List) -> Unit)? = null, onAddChecklistItem: ((Int, Int) -> Unit)? = null, + onRetrySend: (() -> Unit)? = null, + sendUncertain: Boolean = false, + sendError: String? = null, hideTopicRootReply: Boolean = false, selectable: Boolean = false, selectAllNonce: Int = 0, @@ -149,13 +178,14 @@ fun MessageBubble( return } val stickerOnly = isStickerOnly(message) - var appearancePlayed by rememberSaveable(message.id) { + val appearanceKey = message.randomId?.takeIf { it != 0L } ?: message.id.id.toLong() + var appearancePlayed by rememberSaveable(appearanceKey) { mutableStateOf(false) } - val appear = remember(message.id) { + val appear = remember(appearanceKey) { Animatable(if (!appearancePlayed && message.shouldPlayAppear()) 0f else 1f) } - LaunchedEffect(message.id) { + LaunchedEffect(appearanceKey) { appearancePlayed = true if (appear.value < 1f) { appear.animateTo( @@ -167,8 +197,8 @@ fun MessageBubble( var spoilersVisible by remember(message.id) { mutableStateOf(false) } val renderedBlocks = rememberMessageBlocks(message.text.orEmpty(), message.entities) val hasSpoiler = renderedBlocks.containsSpoilers() - val senderTitle = sender?.title?.takeIf { it.isNotBlank() } - ?: message.senderName?.takeIf { it.isNotBlank() } + val senderTitle = + sender?.title?.takeIf { it.isNotBlank() } ?: message.senderName?.takeIf { it.isNotBlank() } val quote = if (quoted?.mediaKind == "service") { // Service payloads are deliberately compact on the wire; never expose // their unit separators in a reply preview. @@ -177,16 +207,12 @@ fun MessageBubble( outgoing = quoted.outgoing, ) } else { - message.replyQuote?.takeIf { it.isNotBlank() } - ?: quoted?.text?.takeIf { it.isNotBlank() } + message.replyQuote?.takeIf { it.isNotBlank() } ?: quoted?.text?.takeIf { it.isNotBlank() } } - val showReplyQuote = !hideTopicRootReply && - (message.replyToMsgId != null || !quote.isNullOrBlank()) - val bubbleHeader = (showSender && senderTitle != null) || - !message.fwdFrom.isNullOrBlank() || - message.fwdFromId != null || - message.fwdDate != null || - !message.viaBot.isNullOrBlank() + val showReplyQuote = + !hideTopicRootReply && (message.replyToMsgId != null || !quote.isNullOrBlank()) + val bubbleHeader = + (showSender && senderTitle != null) || !message.fwdFrom.isNullOrBlank() || message.fwdFromId != null || message.fwdDate != null || !message.viaBot.isNullOrBlank() val mediaCaption = shouldShowMessageCaption(message.mediaKind, message.text, message.fileName) val isVisual = isVisualAlbum(album) val isNonVisual = isNonVisualAlbum(album) @@ -195,11 +221,8 @@ fun MessageBubble( val edgeVisualMedia = singleVisualMedia || groupedAlbum // A picture, video, GIF or album alone in its bubble drops the bubble fill entirely. val hasComments = onComments != null && message.discussionPeerId != null - val mediaOnlyBubble = edgeVisualMedia && - !mediaCaption && - !bubbleHeader && - !showReplyQuote && - !hasComments + val mediaOnlyBubble = + edgeVisualMedia && !mediaCaption && !bubbleHeader && !showReplyQuote && !hasComments // Captioned or chromed media still reaches the bubble edge, over the content inset. val edgeMedia = edgeVisualMedia && !mediaOnlyBubble val mediaBleedTop = edgeMedia && !bubbleHeader && !showReplyQuote @@ -215,9 +238,11 @@ fun MessageBubble( // A caption never widens a picture bubble: the bubble hugs the picture and the // caption wraps, so the picture stays flush with both edges. val mediaFrameWidth = if (singleVisualMedia) { - bubbleEdgeMediaDisplaySize(message.mediaKind, message.mediaWidth, message.mediaHeight) - .first - .dp + bubbleEdgeMediaDisplaySize( + message.mediaKind, + message.mediaWidth, + message.mediaHeight + ).first.dp } else { null } @@ -389,8 +414,7 @@ fun MessageBubble( onClick = onOpenStickerPack?.let { open -> { open( - documentId, - true + documentId, true ) } }, @@ -441,8 +465,7 @@ fun MessageBubble( val original = java.util.Date(date * 1_000L) getDateInstance(java.text.DateFormat.MEDIUM, locale).format( original - ) + - " " + getTimeFormat(context).format(original) + ) + " " + getTimeFormat(context).format(original) } Text( text = formatted, @@ -533,11 +556,12 @@ fun MessageBubble( ServiceMediaCard( message = message, mediaRepository = mediaRepository, + onClosePoll = onClosePoll?.let { close -> { close(message.id.id) } }, + pollVoting = pollVoting, onPollVote = onPollVote?.let { vote -> { options -> vote( - message.id.id, - options + message.id.id, options ) } }, @@ -588,6 +612,214 @@ fun MessageBubble( ) } } + message.checklist?.let { list -> + Column( + modifier = Modifier + .fillMaxWidthInBubble() + .padding(horizontal = 12.dp, vertical = 8.dp) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (list.title.isNotBlank()) { + MessageText( + text = list.title, + entities = list.entities, + textStyle = MaterialTheme.typography.titleMediumEmphasized, + contentColor = onContainer, + linkColor = MaterialTheme.colorScheme.primary, + revealSpoilers = spoilersVisible, + onSpoilerClick = { spoilersVisible = !spoilersVisible }, + onOpenStickerPack = onOpenStickerPack, + ) + } + + val completed = list.items.count { it.done } + val completion by androidx.compose.animation.core.animateFloatAsState( + if (list.items.isEmpty()) 0f else completed.toFloat() / list.items.size, + label = "checklistCompletion", + ) + Text( + stringResource( + R.string.dialog_checklist_progress, + completed, + list.items.size + ), + style = MaterialTheme.typography.labelMedium, + color = onContainer.copy(alpha = 0.72f) + ) + androidx.compose.material3.LinearProgressIndicator( + progress = { completion }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + drawStopIndicator = {}, + ) + if (checklistBusy) org.monogram.core.ui.loading.MonogramLoading() + val addEnabled = + onAddChecklistItem != null && (message.outgoing || list.othersCanAppend) && !message.pending && message.id.id > 0 && list.items.none { it.id == Int.MAX_VALUE } + list.items.forEach { item -> + androidx.compose.runtime.key(item.id) { + val itemColor by androidx.compose.animation.animateColorAsState( + if (item.done) onContainer.copy(alpha = 0.65f) else onContainer, + label = "taskColor" + ) + val enabled = + onToggleChecklist != null && !checklistBusy && !message.pending && message.id.id > 0 && (message.outgoing || list.othersCanComplete) + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .toggleable( + value = item.done, + enabled = enabled, + role = androidx.compose.ui.semantics.Role.Checkbox, + onValueChange = { onToggleChecklist?.invoke(item.id) }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TelegramRoundCheck( + checked = item.done, + color = if (item.done) MaterialTheme.colorScheme.primary else onContainer, + ) + MessageText( + modifier = Modifier.weight(1f), + text = item.text, + entities = item.entities, + textStyle = MaterialTheme.typography.bodyMedium.copy( + textDecoration = if (item.done) androidx.compose.ui.text.style.TextDecoration.LineThrough else null + ), + contentColor = itemColor, + linkColor = MaterialTheme.colorScheme.primary, + revealSpoilers = spoilersVisible, + onSpoilerClick = { spoilersVisible = !spoilersVisible }, + onOpenStickerPack = onOpenStickerPack, + ) + val canManage = + message.outgoing && !message.pending && message.id.id > 0 + val canRemove = + canManage && onRemoveChecklistItem != null && list.items.size > 1 + val canEdit = canManage && onEditChecklistItem != null + var confirmRemoval by rememberSaveable( + message.id, + item.id + ) { mutableStateOf(false) } + if (canRemove || canEdit) { + var menuOpen by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { menuOpen = true }, + enabled = !checklistBusy, + shapes = IconButtonDefaults.shapes() + ) { + Icon( + Icons.Outlined.MoreVert, + contentDescription = stringResource( + org.monogram.core.ui.R.string.media_more_actions + ) + ) + } + AppMenuPopup( + menuOpen, + { menuOpen = false }, + alignToAnchorEnd = true, + scrim = true + ) { + AppMenuSurface { + Column { + if (canEdit) AppMenuItem( + text = stringResource( + R.string.dialog_checklist_edit_item, + item.text + ), + icon = Icons.Outlined.Edit, + onClick = { + menuOpen = + false; onEditChecklistItem?.invoke( + item.id + ) + }, + ) + if (canRemove) AppMenuItem( + text = stringResource( + R.string.dialog_checklist_remove_item, + item.text + ), + icon = Icons.Outlined.Delete, + onClick = { + menuOpen = false; confirmRemoval = + true + }, + ) + } + } + } + } + } + if (confirmRemoval && canRemove) androidx.compose.material3.AlertDialog( + onDismissRequest = { confirmRemoval = false }, + text = { + Text( + stringResource( + R.string.dialog_checklist_remove_confirm, + item.text + ) + ) + }, + confirmButton = { + androidx.compose.material3.TextButton( + enabled = !checklistBusy, + onClick = { + confirmRemoval = + false; onRemoveChecklistItem?.invoke(item.id) + }) { Text(stringResource(R.string.dialog_delete)) } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { + confirmRemoval = false + }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) + } + } + } + if (addEnabled) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.secondaryContainer) + .heightIn(min = 48.dp) + .clickable(enabled = !checklistBusy) { + val nextId = + (list.items.maxOfOrNull { it.id } ?: 0).toLong() + 1 + if (nextId <= Int.MAX_VALUE) onAddChecklistItem.invoke( + message.id.id, nextId.toInt() + ) + } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally + ), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.dialog_checklist_add), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } if (message.mediaKind == "unsupported" && message.text.isNullOrEmpty()) { Text( text = stringResource(R.string.dialog_unsupported_message), @@ -596,14 +828,10 @@ fun MessageBubble( modifier = Modifier.padding(horizontal = edgeContentPad), ) } - if (mediaCaption && !mediaOverlayMeta) { + if (mediaCaption && !mediaOverlayMeta && message.text != message.checklist?.title) { var textLayoutInfo by remember { mutableStateOf(null) } - val hasWideChrome = message.replyToMsgId != null || - !quote.isNullOrBlank() || - message.mediaKind != null || - showSender || - !message.fwdFrom.isNullOrBlank() || - !message.viaBot.isNullOrBlank() + val hasWideChrome = + message.replyToMsgId != null || !quote.isNullOrBlank() || message.mediaKind != null || showSender || !message.fwdFrom.isNullOrBlank() || !message.viaBot.isNullOrBlank() TextWithTimestampLayout( textLayoutInfo = textLayoutInfo, modifier = Modifier @@ -647,6 +875,19 @@ fun MessageBubble( showReadStatus = showReadStatus, ) } + AnimatedVisibility( + visible = onRetrySend != null, + enter = fadeIn(tween(160)) + expandVertically( + tween(220), + expandFrom = Alignment.Top + ), + exit = fadeOut(tween(100)) + shrinkVertically( + tween(180), + shrinkTowards = Alignment.Top + ), + ) { + OutgoingBubbleFailure(sendUncertain, sendError) { onRetrySend?.invoke() } + } if (onComments != null && message.discussionPeerId != null) { CommentFooter( repliesCount = message.repliesCount, @@ -658,79 +899,8 @@ fun MessageBubble( ) } } - message.checklist?.let { list -> - Column( - modifier = Modifier - .fillMaxWidthInBubble() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (list.title.isNotBlank()) { - Text( - text = list.title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = onContainer, - ) - } - - val addEnabled = onAddChecklistItem != null && - (message.outgoing || list.othersCanAppend) && !message.pending - if (addEnabled) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 36.dp) - .clickable { - val nextId = (list.items.maxOfOrNull { it.id } ?: 0) + 1 - onAddChecklistItem.invoke(message.id.id, nextId) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - Text( - text = stringResource(R.string.dialog_checklist_add), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - list.items.forEach { item -> - val enabled = - onToggleChecklist != null && (message.outgoing || list.othersCanComplete) - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 40.dp) - .then(if (enabled) Modifier.clickable { - onToggleChecklist.invoke( - item.id - ) - } else Modifier), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TelegramRoundCheck( - checked = item.done, - color = if (item.done) MaterialTheme.colorScheme.primary else onContainer, - ) - Text( - text = item.text, - style = MaterialTheme.typography.bodyMedium, - color = if (item.done) onContainer.copy(alpha = 0.72f) else onContainer, - ) - } - } - } - } val inlineMarkup = message.replyMarkup?.takeIf { - it.kind == org.monogram.core.models.ReplyMarkupKind.Inline && it.rows.isNotEmpty() + it.kind == ReplyMarkupKind.Inline && it.rows.isNotEmpty() } if (inlineMarkup != null && onMarkupButton != null) { BotKeyboardGrid( @@ -797,11 +967,10 @@ internal fun Modifier.bleedBubbleMedia( ) val x = -hInset + (slot - placeable.width) / 2 val y = if (bleedTop) -vInset else 0 - val height = ( - placeable.height - - (if (bleedTop) vInset else 0) - - (if (bleedBottom) vInset else 0) - ).coerceAtLeast(0) + val height = + (placeable.height - (if (bleedTop) vInset else 0) - (if (bleedBottom) vInset else 0)).coerceAtLeast( + 0 + ) layout(constraints.maxWidth, height) { placeable.place(x, y) } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageCreationSheet.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageCreationSheet.kt new file mode 100644 index 000000000..da4e7bb37 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageCreationSheet.kt @@ -0,0 +1,352 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.runtime.withFrameNanos +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Checklist +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.Quiz +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.ChecklistSendSpec +import org.monogram.core.models.MessageSendOptions +import org.monogram.core.models.PollSendSpec +import org.monogram.core.ui.components.AppModalSheet +import org.monogram.core.ui.components.SettingsCard +import org.monogram.core.ui.components.SettingsCardRow +import org.monogram.feature.dialog.R + +internal enum class MessageCreationKind { Poll, Checklist } + +@Composable +internal fun MessageCreationSheet( + kind: MessageCreationKind, admissionId: Long?, busy: Boolean, error: TelegramError?, + onSendPoll: (PollSendSpec, MessageSendOptions) -> Unit, + onSendChecklist: (ChecklistSendSpec, MessageSendOptions) -> Unit, + onDismiss: () -> Unit, allowed: Boolean = true, +) { + val checklist = kind == MessageCreationKind.Checklist + val minItems = if (checklist) 1 else 2 + val maxItems = if (checklist) 30 else 10 + var question by rememberSaveable(kind) { mutableStateOf("") } + var answers by rememberSaveable(kind) { mutableStateOf(ArrayList(List(minItems) { "" })) } + var itemIds by rememberSaveable(kind) { mutableStateOf(ArrayList(List(minItems) { it })) } + var nextItemId by rememberSaveable(kind) { mutableIntStateOf(minItems) } + var removingIds by remember { mutableStateOf(setOf()) } + var focusItemId by remember { mutableIntStateOf(-1) } + var append by rememberSaveable(kind) { mutableStateOf(false) } + var complete by rememberSaveable(kind) { mutableStateOf(false) } + var anonymous by rememberSaveable(kind) { mutableStateOf(true) } + var multiple by rememberSaveable(kind) { mutableStateOf(false) } + var quiz by rememberSaveable(kind) { mutableStateOf(false) } + var correct by rememberSaveable(kind) { mutableIntStateOf(-1) } + var solution by rememberSaveable(kind) { mutableStateOf("") } + val sendState = rememberSaveable(kind, saver = CreationSendState.Saver) { CreationSendState() } + val interactive = allowed && !busy + val previousAdmission = remember(kind) { admissionId } + var closing by remember { mutableStateOf(false) } + LaunchedEffect(admissionId) { if (admissionId != previousAdmission) closing = true } + val pollSpec = PollSendSpec( + question, + answers.toList(), + anonymous, + multiple, + quiz, + correct.takeIf { quiz && it >= 0 }, + solution.takeIf { quiz && it.isNotBlank() }) + val checklistSpec = ChecklistSendSpec(question, answers.toList(), append, complete) + val valid = + removingIds.isEmpty() && runCatching { if (checklist) checklistSpec.requireValid() else pollSpec.requireValid() }.isSuccess + val send: (MessageSendOptions) -> Unit = { options -> + if (checklist) onSendChecklist(checklistSpec, options) else onSendPoll(pollSpec, options) + } + val itemLabel = if (checklist) R.string.dialog_checklist_item else R.string.dialog_poll_option + val fieldColors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) + AppModalSheet( + modifier = Modifier.fillMaxHeight(0.95f), + onDismissRequest = { if (!busy) closing = true }, + visible = !closing, onExited = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + CreationSheetHeader( + title = stringResource(if (checklist) R.string.dialog_checklist_create else R.string.dialog_poll_create), + enabled = !busy, onDismiss = { closing = true }, + ) + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextField( + question, + { question = it }, + shape = MaterialTheme.shapes.large, + label = { Text(stringResource(if (checklist) R.string.dialog_checklist_title else R.string.dialog_poll_question)) }, + enabled = interactive, + colors = fieldColors, + isError = question.length > if (checklist) 32 else 255, + modifier = Modifier.fillMaxWidth(), + ) + Column( + Modifier.animateContentSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemIds.forEachIndexed { index, id -> + key(id) { + val optionLabel = stringResource(itemLabel, index + 1) + val focusRequester = remember { FocusRequester() } + val bringIntoView = remember { BringIntoViewRequester() } + LaunchedEffect(focusItemId == id) { + if (focusItemId == id) { + focusRequester.requestFocus() + withFrameNanos { } + bringIntoView.bringIntoView() + focusItemId = -1 + } + } + val rowVisible = remember { + MutableTransitionState(focusItemId != id).apply { + targetState = true + } + } + LaunchedEffect(rowVisible.isIdle, rowVisible.currentState) { + if (rowVisible.isIdle && !rowVisible.currentState && !rowVisible.targetState) { + val removedIndex = itemIds.indexOf(id) + if (removedIndex >= 0) { + answers = + ArrayList(answers).apply { removeAt(removedIndex) } + itemIds = + ArrayList(itemIds).apply { removeAt(removedIndex) } + correct = when { + correct == removedIndex -> -1 + correct > removedIndex -> correct - 1 + else -> correct + } + } + removingIds = removingIds - id + } + } + AnimatedVisibility( + rowVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextField( + answers[index], + { value -> + answers = ArrayList(answers).apply { set(index, value) } + }, + shape = MaterialTheme.shapes.large, + label = { Text(optionLabel) }, + singleLine = true, + enabled = interactive, + colors = fieldColors, + isError = answers[index].length > if (checklist) 64 else 100, + leadingIcon = if (checklist) ({ + Icon( + Icons.Outlined.Checklist, + contentDescription = null + ) + }) else null, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoView), + ) + AnimatedVisibility( + !checklist && quiz, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + val correctLabel = stringResource( + R.string.dialog_poll_correct_option, + index + 1 + ) + RadioButton( + selected = correct == index, + onClick = { correct = index }, + enabled = interactive, + modifier = Modifier.semantics { + contentDescription = correctLabel + }, + ) + } + if (answers.size > minItems) FilledTonalIconButton( + onClick = { + removingIds = removingIds + id + rowVisible.targetState = false + }, + enabled = interactive && answers.size - removingIds.size > minItems && id !in removingIds, + shapes = IconButtonDefaults.shapes(), + ) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.dialog_delete) + ": " + optionLabel + ) + } + } + } + } + } + } + if (answers.size < maxItems) FilledTonalButton( + onClick = { + answers = ArrayList(answers).apply { add("") } + focusItemId = nextItemId + itemIds = ArrayList(itemIds).apply { add(nextItemId++) } + }, + enabled = interactive, + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { + Icon( + Icons.Outlined.Add, contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(if (checklist) R.string.dialog_checklist_add else R.string.dialog_poll_add_option)) + } + SettingsCard { + if (checklist) { + CreationSetting( + stringResource(R.string.dialog_checklist_allow_append), + append, + interactive, + icon = Icons.Outlined.PlaylistAdd + ) { append = it } + CreationSetting( + stringResource(R.string.dialog_checklist_allow_complete), + complete, + interactive, + icon = Icons.Outlined.DoneAll + ) { complete = it } + } else { + CreationSetting( + stringResource(R.string.dialog_poll_anonymous), anonymous, interactive, + icon = Icons.Outlined.VisibilityOff + ) { anonymous = it } + CreationSetting( + stringResource(R.string.dialog_poll_multiple), + multiple, + interactive && !quiz, + icon = Icons.Outlined.DoneAll + ) { multiple = it } + CreationSetting( + stringResource(R.string.dialog_poll_quiz), quiz, interactive, + icon = Icons.Outlined.Quiz + ) { quiz = it; if (it) multiple = false } + } + } + AnimatedVisibility( + !checklist && quiz, + enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() + ) { + TextField( + solution, + { solution = it }, + shape = MaterialTheme.shapes.large, + label = { Text(stringResource(R.string.dialog_poll_explanation)) }, + enabled = interactive, + colors = fieldColors, + isError = solution.length > 200, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CreationSendControls(sendState, valid, busy, allowed, error, send) + } + } + } +} + +@Composable +internal fun CreationSetting( + label: String, + checked: Boolean, + enabled: Boolean, + icon: ImageVector = Icons.Outlined.NotificationsOff, + onChecked: (Boolean) -> Unit, +) { + SettingsCardRow( + icon = icon, + title = label, + iconColor = MaterialTheme.colorScheme.primary, + enabled = enabled, + toggle = checked, + onToggle = onChecked, + trailingContent = { Switch(checked, onCheckedChange = null, enabled = enabled) }, + ) +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt index e7a1ad501..d6e0ef69d 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageMedia.kt @@ -50,6 +50,16 @@ import org.monogram.network.http.MediaRepository import org.monogram.network.http.photoDisplayCacheKey import java.io.File +private val announcementKinds = setOf( + "giveaway", + "giveaway_results", + "paid_media", + "game", + "invoice", + "story", + "video_stream", +) + @Composable fun MessageMedia( message: Message, @@ -64,7 +74,7 @@ fun MessageMedia( ) { val kind = message.mediaKind ?: return val animationEnabled = LocalMediaAnimationEnabled.current - if (kind == "unsupported") return + if (kind == "unsupported" || kind in announcementKinds) return val localFile = localOutgoingFile(message.mediaCacheKey) ?: localOutgoingFile(message.thumbCacheKey) if (localFile != null) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt index 9ad11ef21..d12ba3395 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MessageSeenRow.kt @@ -53,6 +53,7 @@ import org.monogram.core.models.pollAnswersLabel import org.monogram.core.models.reactionChoiceKey import org.monogram.core.ui.components.AppModalSheet import org.monogram.core.ui.components.PeerAvatar +import org.monogram.core.ui.loading.MonogramBusyButton import org.monogram.core.ui.menu.AppMenuAvatar import org.monogram.core.ui.menu.AppMenuAvatarStack import org.monogram.core.ui.menu.AppMenuDivider @@ -151,6 +152,8 @@ internal fun PeerListSheet( onDismiss: () -> Unit, poll: Poll? = null, initialFilter: String? = null, + page: org.monogram.feature.dialog.ViewerPageState? = null, + onLoadMore: (() -> Unit)? = null, mediaRepository: MediaRepository? = null, ) { val title = stringResource( @@ -197,7 +200,7 @@ internal fun PeerListSheet( } } when { - users == null -> { + users == null && page?.failed != true -> { Box( modifier = Modifier .fillMaxWidth() @@ -207,7 +210,8 @@ internal fun PeerListSheet( CircularProgressIndicator() } } - users.isEmpty() || visible.isEmpty() -> { + + users.isNullOrEmpty() || visible.isEmpty() -> { Text( text = stringResource(R.string.dialog_peer_list_empty), style = MaterialTheme.typography.bodyMedium, @@ -215,18 +219,19 @@ internal fun PeerListSheet( modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp), ) } + else -> { LazyColumn( modifier = Modifier .fillMaxWidth() - .heightIn(max = 560.dp), + .heightIn(max = if (onLoadMore != null) 440.dp else 560.dp), contentPadding = PaddingValues(bottom = 24.dp), ) { items( visible, key = { "${it.peerId.value}:${it.date}:${it.emoticon}:${it.documentId}:" + - it.pollOptionHex.joinToString() + it.pollOptionHex.joinToString() }, contentType = { "seen-peer" }, ) { viewer -> @@ -243,6 +248,19 @@ internal fun PeerListSheet( } } } + if (page?.failed == true) Text( + stringResource(R.string.dialog_peer_list_load_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + if (onLoadMore != null && page != null && (page.loaded || page.failed) && (page.nextOffset != null || page.failed)) { + MonogramBusyButton( + onClick = onLoadMore, busy = page.loading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { Text(stringResource(if (page.failed) org.monogram.core.ui.R.string.status_retry else R.string.dialog_peer_list_load_more)) } + } } } @@ -400,7 +418,8 @@ private fun CollapsedViewerRow( ) } }, - trailingText = single?.takeIf { it.date > 0 }?.let { formatSeenDate(it.date, yesterdayLabel = yesterday) }, + trailingText = single?.takeIf { it.date > 0 } + ?.let { formatSeenDate(it.date, yesterdayLabel = yesterday) }, enabled = list.isNotEmpty(), contentDescription = single?.title?.let { stringResource(R.string.dialog_seen_accessibility, it) @@ -448,7 +467,8 @@ private fun ExpandedViewerList( }, ) }, - trailingText = viewer.date.takeIf { it > 0 }?.let { formatSeenDate(it, yesterdayLabel = yesterday) }, + trailingText = viewer.date.takeIf { it > 0 } + ?.let { formatSeenDate(it, yesterdayLabel = yesterday) }, onClick = { onOpenProfile(viewer.peerId.value) }, ) } @@ -467,7 +487,11 @@ fun OutboxReadRow( null -> return OutboxReadState.Loading -> stringResource(R.string.dialog_seen_loading) is OutboxReadState.Read -> - stringResource(R.string.dialog_read_at, formatSeenDate(state.date, yesterdayLabel = yesterday)) + stringResource( + R.string.dialog_read_at, + formatSeenDate(state.date, yesterdayLabel = yesterday) + ) + OutboxReadState.Unread -> stringResource(R.string.dialog_read_unread) OutboxReadState.Expired -> stringResource(R.string.dialog_read_receipts_expired) OutboxReadState.PeerPrivacyHidden -> stringResource(R.string.dialog_read_unknown) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/OutgoingBubbleSend.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/OutgoingBubbleSend.kt new file mode 100644 index 000000000..ca7de1780 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/OutgoingBubbleSend.kt @@ -0,0 +1,72 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.monogram.core.database.toModel +import org.monogram.core.database.entity.OutgoingMediaSend +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.models.Message +import org.monogram.feature.dialog.R + +internal fun outgoingHistoryMessages( + messages: List, + text: List, + media: List, +): List { + val pending = text.filter { it.scheduleDate == null }.map { it.message } + + media.filter { it.batch.scheduleDate == null } + .flatMap { it.items.map { item -> item.message } } + val ids = messages.mapTo(mutableSetOf()) { it.id.id } + val randomIds = messages.mapNotNullTo(mutableSetOf()) { it.randomId?.takeIf { id -> id != 0L } } + val missing = pending.filter { row -> + row.id !in ids && (row.randomId == null || row.randomId !in randomIds) + }.distinctBy { it.id }.map { it.toModel() }.sortedByDescending { it.date } + return (missing + messages).sortedByDescending { it.date } +} + +internal data class OutgoingBubbleSend( + val id: Long, + val phase: OutgoingSendPhase, + val media: Boolean +) + +internal fun outgoingBubbleSend( + message: Message, + text: List, + media: List, +): OutgoingBubbleSend? { + if (!message.outgoing) return null + text.firstOrNull { + it.scheduleDate == null && it.message.chatId == message.id.chatId.value && + (it.randomId == message.randomId || it.message.id == message.id.id) + }?.let { return OutgoingBubbleSend(it.randomId, it.phase, false) } + return media.firstOrNull { send -> + send.batch.scheduleDate == null && send.batch.chatId == message.id.chatId.value && + send.items.any { it.message.id == message.id.id || it.randomId == message.randomId } + }?.let { OutgoingBubbleSend(it.batch.batchId, it.batch.phase, true) } +} + +@Composable +internal fun OutgoingBubbleFailure(uncertain: Boolean, error: String?, onRetry: () -> Unit) { + Column(Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = listOfNotNull( + stringResource(if (uncertain) R.string.dialog_outbox_uncertain else R.string.dialog_outbox_failed), + error + ).joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + TextButton(onClick = onRetry) { + Text(stringResource(org.monogram.core.ui.R.string.status_retry)) + } + } +} diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SendOptionsMenu.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SendOptionsMenu.kt new file mode 100644 index 000000000..7070664c5 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/SendOptionsMenu.kt @@ -0,0 +1,177 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +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.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.monogram.core.ui.components.AppModalSheet +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface +import org.monogram.feature.dialog.R +import org.monogram.feature.dialog.store.validSendSchedule +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@Composable +internal fun SendOptionsMenu( + expanded: Boolean, onDismiss: () -> Unit, onSilent: () -> Unit, onSchedule: () -> Unit +) { + AppMenuPopup(expanded, onDismiss, alignToAnchorEnd = true, scrim = true) { + AppMenuSurface { + Column { + AppMenuItem( + text = stringResource(R.string.dialog_send_silent), + icon = Icons.Outlined.NotificationsOff, + onClick = onSilent + ) + AppMenuItem( + text = stringResource(R.string.dialog_send_schedule), + icon = Icons.Outlined.Schedule, + onClick = onSchedule + ) + } + } + } +} + +internal fun scheduledEpoch(dateMillis: Long?, hour: Int, minute: Int, zone: ZoneId): Int? { + if (dateMillis == null || hour !in 0..23 || minute !in 0..59) return null + val dateTime = + Instant.ofEpochMilli(dateMillis).atZone(ZoneOffset.UTC).toLocalDate().atTime(hour, minute) + val offset = zone.rules.getValidOffsets(dateTime).firstOrNull() ?: return null + val seconds = dateTime.toEpochSecond(offset) + return seconds.takeIf { it in 1 until Int.MAX_VALUE.toLong() - 1 }?.toInt() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ScheduleMessageSheet( + onDismiss: () -> Unit, onSchedule: (Int) -> Unit, + initialDate: Long? = null, + @androidx.annotation.StringRes titleRes: Int = R.string.dialog_send_schedule, + enabled: Boolean = true, +) { + val zone = remember { ZoneId.systemDefault() } + val initial = remember(initialDate) { + if (initialDate != null && initialDate > 0 && initialDate != 0x7ffffffeL) Instant.ofEpochSecond( + initialDate + ).atZone(zone) + else Instant.now().atZone(zone).plusMinutes(5) + } + var showCalendar by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } + val date = rememberDatePickerState( + initialSelectedDateMillis = initial.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant() + .toEpochMilli() + ) + val time = rememberTimePickerState( + initialHour = initial.hour, + initialMinute = initial.minute, + is24Hour = android.text.format.DateFormat.is24HourFormat(androidx.compose.ui.platform.LocalContext.current) + ) + var now by remember { mutableLongStateOf(System.currentTimeMillis() / 1000) } + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(1_000); now = System.currentTimeMillis() / 1000 + } + } + val epoch = scheduledEpoch(date.selectedDateMillis, time.hour, time.minute, zone) + val valid = epoch != null && validSendSchedule(epoch, now) + AppModalSheet( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface + ) { + Column( + Modifier + .fillMaxWidth() + .heightIn(max = (androidx.compose.ui.platform.LocalConfiguration.current.screenHeightDp * 0.9f).dp) + ) { + CreationSheetHeader(title = stringResource(titleRes), onDismiss = onDismiss) + Column( + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FilledTonalButton( + onClick = { showCalendar = true }, modifier = Modifier.fillMaxWidth() + ) { + Text(date.selectedDateMillis?.let { + java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.FULL) + .withLocale(androidx.compose.ui.platform.LocalConfiguration.current.locales[0]) + .format(Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate()) + } ?: stringResource(R.string.dialog_jump_date)) + } + TimeInput( + state = time, + modifier = Modifier.align(androidx.compose.ui.Alignment.CenterHorizontally) + ) + if (!valid) Text( + stringResource(R.string.dialog_schedule_invalid), + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 16.dp, bottom = 16.dp) + ) { + Button( + onClick = { + if (enabled) epoch?.takeIf { + validSendSchedule( + it, System.currentTimeMillis() / 1000 + ) + }?.let(onSchedule) + }, enabled = valid && enabled, + shapes = androidx.compose.material3.ButtonDefaults.shapes(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + ) { + Text(stringResource(titleRes)) + } + } + } + } + if (showCalendar) DatePickerDialog( + onDismissRequest = { showCalendar = false }, + confirmButton = { + TextButton(onClick = { + showCalendar = false + }) { Text(stringResource(android.R.string.ok)) } + }) { DatePicker(state = date) } +} \ No newline at end of file diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ServiceMediaCards.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ServiceMediaCards.kt index ae8c367f2..a5371003a 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ServiceMediaCards.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/ServiceMediaCards.kt @@ -1,8 +1,24 @@ package org.monogram.feature.dialog.ui +import androidx.compose.material.icons.automirrored.outlined.Undo +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import org.monogram.core.ui.menu.AppMenuItem +import org.monogram.core.ui.menu.AppMenuPopup +import org.monogram.core.ui.menu.AppMenuSurface +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.material3.RadioButton +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.key +import androidx.compose.ui.graphics.Color +import java.text.NumberFormat + import androidx.compose.foundation.background -import androidx.compose.foundation.clickable 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 @@ -14,41 +30,52 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.monogram.core.models.ContactCard import org.monogram.core.models.Dice import org.monogram.core.models.GeoPlace +import org.monogram.core.models.MediaAnnouncement import org.monogram.core.models.Message import org.monogram.core.models.Poll import org.monogram.core.models.VenueCard +import org.monogram.core.models.announcementCard import org.monogram.core.models.contactCard import org.monogram.core.models.dice import org.monogram.core.models.geoPlace import org.monogram.core.models.poll +import org.monogram.core.models.pollOptionHex import org.monogram.core.models.venueCard +import org.monogram.core.ui.loading.MonogramBusyButton import org.monogram.feature.dialog.R import org.monogram.network.http.MapTileStore import org.monogram.network.http.MediaRepository @@ -63,14 +90,34 @@ internal fun ServiceMediaCard( modifier: Modifier = Modifier, onPollVote: ((List) -> Unit)? = null, onShowPollVoters: (() -> Unit)? = null, + onClosePoll: (() -> Unit)? = null, + pollVoting: Boolean = false, ) { val mapTiles = mediaRepository?.mapTiles when (message.mediaKind) { - "poll" -> message.poll?.let { PollBubbleCard(it, onPollVote, onShowPollVoters, modifier) } + "poll" -> message.poll?.let { + PollBubbleCard( + it, + onPollVote.takeIf { message.id.id > 0 && !message.pending }, + onShowPollVoters, + modifier, + pollVoting, + onClose = onClosePoll.takeIf { message.id.id > 0 && !message.pending }) + } + "geo" -> message.geoPlace?.let { LocationBubbleCard(it, mapTiles, modifier) } "venue" -> message.venueCard?.let { VenueBubbleCard(it, mapTiles, modifier) } "contact" -> message.contactCard?.let { ContactBubbleCard(it, modifier) } "dice" -> message.dice?.let { DiceBubbleCard(it, modifier) } + "giveaway", "giveaway_results", "paid_media", "game", "invoice", "story", "video_stream" -> + message.announcementCard?.let { + AnnouncementBubbleCard( + message.mediaKind, + it, + modifier + ) + } + else -> Unit } } @@ -93,25 +140,82 @@ private fun CardShell( } @Composable -private fun PollBubbleCard( +internal fun PollBubbleCard( poll: Poll, onVote: ((List) -> Unit)?, onShowVoters: (() -> Unit)?, modifier: Modifier = Modifier, + voting: Boolean = false, + onClose: (() -> Unit)? = null, ) { - CardShell(modifier) { + var confirmClose by rememberSaveable(poll) { mutableStateOf(false) } + val canClose = poll.creator && !poll.closed && onClose != null + val canRetract = + poll.isAnswered && !poll.isQuiz && !poll.revotingDisabled && !poll.closed && onVote != null + var actionsOpen by remember { mutableStateOf(false) } + if (confirmClose && canClose) AlertDialog( + onDismissRequest = { confirmClose = false }, + title = { Text(stringResource(R.string.dialog_poll_close)) }, + text = { Text(stringResource(R.string.dialog_poll_close_confirm)) }, + confirmButton = { + TextButton(onClick = { confirmClose = false; onClose?.invoke() }, enabled = !voting) { + Text(stringResource(R.string.dialog_poll_close)) + } + }, + dismissButton = { + TextButton(onClick = { + confirmClose = false + }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + var selected by rememberSaveable(poll) { mutableStateOf(listOf()) } + val canVote = onVote != null && !poll.closed && !poll.isAnswered && !voting + Surface(modifier.width(CardWidthDp), color = Color.Transparent) { Column( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .padding(12.dp) + .animateContentSize(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = poll.question, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onSurface, ) poll.answers.forEach { answer -> - PollAnswerRow(poll = poll, text = answer.text, answer = answer, onVote = onVote) + val key = pollOptionHex(answer.option) + key(key) { + PollAnswerRow( + poll = poll, text = answer.text, answer = answer, + selected = if (poll.multipleChoice && !poll.isAnswered) key in selected else answer.chosen, + onClick = if (canVote && answer.option.isNotEmpty()) { + { + if (poll.multipleChoice) { + selected = + if (key in selected) selected - key else selected + key + } else onVote?.invoke(listOf(answer.option.copyOf())) + } + } else null) + } } + if (poll.multipleChoice && !poll.isAnswered && !poll.closed && onVote != null) { + MonogramBusyButton( + onClick = { + onVote(poll.answers.filter { pollOptionHex(it.option) in selected } + .map { it.option.copyOf() }) + }, + busy = voting, + enabled = selected.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + shapes = ButtonDefaults.shapes(), + ) { Text(stringResource(R.string.dialog_poll_submit)) } + } + if (voting) Text( + stringResource(R.string.dialog_poll_updating), + style = MaterialTheme.typography.labelSmall + ) if (poll.isQuiz && poll.isAnswered && poll.solution.isNotBlank()) { Text( text = stringResource(R.string.dialog_poll_solution, poll.solution), @@ -119,22 +223,63 @@ private fun PollBubbleCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = pollFooter(poll), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 40.dp) - .then( - if (poll.publicVoters && onShowVoters != null) { - Modifier.clickable(onClick = onShowVoters) - } else { - Modifier - }, + Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + Modifier + .weight(1f) + .heightIn(min = 48.dp) + .then( + if (poll.publicVoters && onShowVoters != null) + Modifier.clickable(onClick = onShowVoters) else Modifier + ), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = pollFooter(poll), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - .padding(vertical = 8.dp), - ) + } + if (canClose || canRetract) Box { + IconButton( + onClick = { actionsOpen = true }, enabled = !voting, + shapes = IconButtonDefaults.shapes() + ) { + Icon( + Icons.Outlined.MoreVert, contentDescription = stringResource( + org.monogram.core.ui.R.string.media_more_actions + ) + ) + } + AppMenuPopup( + actionsOpen, + { actionsOpen = false }, + alignToAnchorEnd = true, + scrim = true + ) { + AppMenuSurface { + Column { + if (canRetract) AppMenuItem( + text = stringResource(R.string.dialog_poll_retract), + icon = Icons.AutoMirrored.Outlined.Undo, + enabled = !voting, + onClick = { actionsOpen = false; onVote?.invoke(emptyList()) }, + ) + if (canClose) AppMenuItem( + text = stringResource(R.string.dialog_poll_close), + icon = Icons.Filled.Close, + destructive = true, + enabled = !voting, + onClick = { actionsOpen = false; confirmClose = true }, + ) + } + } + } + } + } } } } @@ -144,17 +289,53 @@ private fun PollAnswerRow( poll: Poll, text: String, answer: org.monogram.core.models.PollAnswer, - onVote: ((List) -> Unit)?, + selected: Boolean, + onClick: (() -> Unit)?, ) { - // One tap votes for that answer; the server state comes back as an update. - val canVote = onVote != null && !poll.closed && answer.option.isNotEmpty() && !answer.chosen + val showResults = (poll.isAnswered || poll.closed) && poll.showsShares + val fraction = if (poll.totalVoters > 0) (answer.voters.toFloat() / poll.totalVoters).coerceIn( + 0f, + 1f + ) else 0f + val share by animateFloatAsState(fraction, label = "pollShare") + val rowColor by animateColorAsState( + if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + label = "pollOption", + ) Column( modifier = Modifier .fillMaxWidth() - .then(if (canVote) Modifier.clickable { onVote.invoke(listOf(answer.option)) } else Modifier), + .clip(MaterialTheme.shapes.medium) + .background(rowColor) + .heightIn(min = 48.dp) + .toggleable( + value = selected, enabled = onClick != null, + role = if (poll.multipleChoice) Role.Checkbox else Role.RadioButton, + onValueChange = { onClick?.invoke() }) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(3.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { + if (poll.multipleChoice && !poll.isAnswered && !poll.closed) { + Checkbox( + checked = selected, + onCheckedChange = null, + enabled = onClick != null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(8.dp)) + } else if (!poll.isAnswered && !poll.closed && !poll.multipleChoice) { + RadioButton( + selected = selected, + onClick = null, + enabled = onClick != null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(8.dp)) + } else if (answer.chosen && !poll.isQuiz) { + Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + } if (poll.isQuiz && answer.hasVotes && (answer.correct || answer.chosen)) { Icon( imageVector = if (answer.correct) Icons.Filled.Check else Icons.Filled.Close, @@ -172,14 +353,19 @@ private fun PollAnswerRow( text = text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (showResults) Text( + NumberFormat.getPercentInstance().format(fraction.toDouble()), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), ) } - if (poll.showsShares) { + if (showResults) { if (poll.totalVoters > 0 || answer.hasVotes) { LinearProgressIndicator( - progress = { poll.share(answer) }, + progress = { share }, + drawStopIndicator = {}, modifier = Modifier .fillMaxWidth() .height(4.dp) @@ -202,7 +388,8 @@ private fun PollAnswerRow( @Composable private fun pollFooter(poll: Poll): String { - val votes = pluralStringResource(R.plurals.dialog_poll_voters, poll.totalVoters, poll.totalVoters) + val votes = + pluralStringResource(R.plurals.dialog_poll_voters, poll.totalVoters, poll.totalVoters) val trait = when { poll.closed -> stringResource(R.string.dialog_poll_closed) poll.isQuiz -> stringResource(R.string.dialog_poll_quiz) @@ -283,31 +470,31 @@ private fun VenueBubbleCard( modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Text( - text = venue.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - if (venue.address.isNotBlank()) { Text( - text = venue.address, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = venue.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, ) - } - if (venue.provider.isNotBlank()) { + if (venue.address.isNotBlank()) { + Text( + text = venue.address, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (venue.provider.isNotBlank()) { + Text( + text = stringResource(R.string.dialog_venue_via, venue.provider), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( - text = stringResource(R.string.dialog_venue_via, venue.provider), + text = venue.place.coordinates, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = venue.place.coordinates, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } @@ -352,6 +539,124 @@ private fun ContactBubbleCard(contact: ContactCard, modifier: Modifier = Modifie } } +@Composable +private fun AnnouncementBubbleCard( + kind: String?, + announcement: MediaAnnouncement, + modifier: Modifier = Modifier, +) { + val heading = when (kind) { + "giveaway" -> R.string.dialog_media_giveaway + "giveaway_results" -> R.string.dialog_media_giveaway_results + "paid_media" -> R.string.dialog_media_paid_media + "game" -> R.string.dialog_media_game + "invoice" -> R.string.dialog_media_invoice + "story" -> R.string.dialog_media_story + "video_stream" -> R.string.dialog_media_video_stream + else -> R.string.dialog_media_document + } + val stats = buildList { + when (kind) { + "giveaway" -> { + if (announcement.quantity > 0) { + add( + stringResource( + R.string.dialog_media_giveaway_prizes, + announcement.quantity + ) + ) + } + if (announcement.months > 0) { + add(stringResource(R.string.dialog_media_giveaway_months, announcement.months)) + } + if (announcement.stars > 0) { + add(stringResource(R.string.dialog_media_giveaway_stars, announcement.stars)) + } + } + + "giveaway_results" -> add( + stringResource( + R.string.dialog_media_giveaway_winners, + announcement.winners, + announcement.unclaimed, + ), + ) + + "paid_media" -> add( + stringResource( + R.string.dialog_media_paid_media_items, + announcement.stars, + announcement.items, + ), + ) + + "story" -> if (announcement.storyId > 0) { + add(stringResource(R.string.dialog_media_story_number, announcement.storyId)) + } + + "video_stream" -> add( + stringResource( + if (announcement.rtmp) R.string.dialog_media_rtmp_stream + else R.string.dialog_media_live_stream, + ), + ) + } + } + CardShell(modifier) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(heading), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (announcement.title.isNotBlank()) { + Text( + text = announcement.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + stats.filter { it.isNotBlank() }.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (announcement.description.isNotBlank()) { + Text( + text = announcement.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + if (announcement.detail.isNotBlank()) { + Text( + text = announcement.detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + @Composable private fun DiceBubbleCard(dice: Dice, modifier: Modifier = Modifier) { CardShell(modifier) { @@ -364,7 +669,8 @@ private fun DiceBubbleCard(dice: Dice, modifier: Modifier = Modifier) { style = MaterialTheme.typography.displaySmall, ) Text( - text = stringResource(R.string.dialog_dice_result, dice.value), + text = if (dice.value <= 0) stringResource(R.string.dialog_dice_pending) + else stringResource(R.string.dialog_dice_result, dice.value), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/UnsupportedDraftNotice.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/UnsupportedDraftNotice.kt new file mode 100644 index 000000000..f13147143 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/UnsupportedDraftNotice.kt @@ -0,0 +1,71 @@ +package org.monogram.feature.dialog.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.monogram.core.ui.components.AppStatusBanner +import org.monogram.feature.dialog.R + +@Composable +internal fun UnsupportedDraftNotice(text: String, onDiscard: () -> Unit) { + var confirmDiscard by rememberSaveable { mutableStateOf(false) } + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + Column { + AppStatusBanner( + text = stringResource(R.string.dialog_draft_unsupported), + failed = false, + progress = false, + maxLines = Int.MAX_VALUE, + ) + if (text.isNotBlank()) SelectionContainer { + Text( + text, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + maxLines = 4, overflow = TextOverflow.Ellipsis, + ) + } + TextButton( + onClick = { confirmDiscard = true }, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.dialog_discard_draft)) + } + } + } + if (confirmDiscard) AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { Text(stringResource(R.string.dialog_discard_draft)) }, + text = { Text(stringResource(R.string.dialog_discard_draft_confirm)) }, + confirmButton = { + TextButton(onClick = { + confirmDiscard = false + onDiscard() + }) { + Text( + stringResource(R.string.dialog_discard_draft), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = { + confirmDiscard = false + }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} \ No newline at end of file diff --git a/feature/dialog/src/main/res/values-es/strings.xml b/feature/dialog/src/main/res/values-es/strings.xml index 5e3cba0e3..097294931 100644 --- a/feature/dialog/src/main/res/values-es/strings.xml +++ b/feature/dialog/src/main/res/values-es/strings.xml @@ -355,4 +355,74 @@ Ampliar editor %1$s a las %2$s %1$s · %2$s/s + Sorteo + Resultados del sorteo + Contenido de pago + Juego + Factura + Historia + Transmisión en vivo + %1$d premios + %1$d meses + %1$d Stars + %1$d ganadores · %2$d sin reclamar + %1$d Stars · %2$d elementos + Historia n.º %1$d + Transmisión RTMP + Transmisión en vivo + Este borrador contiene contenido que no se puede editar aquí. Ábrelo en otra aplicación de Telegram o descártalo + Descartar borrador + ¿Descartar este borrador de Telegram? Esta acción no se puede deshacer + Opciones de envío + Enviar sin sonido + Programar mensaje + Elige una hora válida entre un minuto y un año a partir de ahora + En espera de envío + Esperando confirmación + Entrega sin confirmar + Mensajes programados + No hay mensajes programados + Cuando esté en línea + Acciones del mensaje programado + Enviar ahora + Cancelar mensaje programado + ¿Enviar este mensaje programado ahora? + ¿Eliminar este mensaje programado sin enviarlo? + Actualiza para comprobar el resultado antes de volver a intentarlo + Reprogramar mensaje + El mensaje ha cambiado. Vuelve a abrir el editor + Enviar voto + Retirar voto + Actualizando encuesta… + Cerrar encuesta + ¿Cerrar esta encuesta? No se aceptarán más votos + Cargar más + No se pudieron cargar los participantes + Esperando el resultado + Crear encuesta + Pregunta + Opción %1$d + Añadir opción + Varias respuestas + Explicación (opcional) + Opción correcta %1$d + Crear lista de tareas + Título + Tarea %1$d + Otros pueden añadir tareas + Otros pueden completar tareas + Editar elemento: %1$s + Eliminar elemento: %1$s + ¿Eliminar la tarea «%1$s»? + Reemplazar archivo adjunto + Compartir ubicación en tiempo real + Solo se actualiza mientras Monogram está visible. Reanuda el uso compartido al volver + En pausa + Dejando de compartir… + No se confirmó la detención. Intenta detenerla de nuevo + Reanudar uso compartido + Dejar de compartir + Hasta %1$s + No enviado + %1$d de %2$d completadas diff --git a/feature/dialog/src/main/res/values-hy/strings.xml b/feature/dialog/src/main/res/values-hy/strings.xml index d45b64d0e..f765f3660 100644 --- a/feature/dialog/src/main/res/values-hy/strings.xml +++ b/feature/dialog/src/main/res/values-hy/strings.xml @@ -355,4 +355,74 @@ Ընդլայնել խմբագիրը %1$s, %2$s %1$s · %2$s/վ + Հեռացնել սևագիրը + Հեռացնե՞լ այս սևագիրը Telegram-ից։ Այս գործողությունը հնարավոր չէ հետարկել + Այս սևագիրը պարունակում է բովանդակություն, որը հնարավոր չէ խմբագրել այստեղ։ Բացեք այն Telegram-ի մեկ այլ հավելվածում կամ հեռացրեք սևագիրը + Խաղ + Վիճակախաղ + %1$d ամիս + %1$d մրցանակ + Վիճակախաղի արդյունքներ + %1$d Stars + %1$d հաղթող · %2$d չստացված + Հաշիվ + Ուղիղ հեռարձակում + Վճարովի բովանդակություն + %1$d Stars · %2$d տարր + RTMP հեռարձակում + Պատմություն + Պատմություն #%1$d + Ուղիղ հեռարձակում + Սպասում է ուղարկմանը + Ուղարկման ընտրանքներ + Առաքումը հաստատված չէ + Պլանավորել հաղորդագրությունը + Ուղարկել առանց ձայնի + Ընտրեք վավեր ժամ՝ այս պահից մեկ րոպեից մինչև մեկ տարի անց + Սպասում է հաստատման + Պլանավորված հաղորդագրություններ չկան + Պլանավորված հաղորդագրություններ + Երբ լինի առցանց + Պլանավորված հաղորդագրության գործողություններ + Կրկին փորձելուց առաջ թարմացրեք՝ արդյունքը ստուգելու համար + Ուղարկե՞լ այս պլանավորված հաղորդագրությունը հիմա + Հեռացնե՞լ այս պլանավորված հաղորդագրությունը՝ առանց ուղարկելու + Չեղարկել պլանավորված հաղորդագրությունը + Ուղարկել հիմա + Փոխել ուղարկման ժամը + Հաղորդագրությունը փոխվել է։ Կրկին բացեք խմբագրիչը + Քվեարկել + Հետ կանչել քվեն + Հարցումը թարմացվում է… + Փակել հարցումը + Փակե՞լ այս հարցումը։ Այլևս ձայներ չեն ընդունվի + Բեռնել ավելին + Չհաջողվեց բեռնել մասնակիցներին + Սպասում է արդյունքին + Ստեղծել հարցում + Հարց + Տարբերակ %1$d + Ավելացնել տարբերակ + Մի քանի պատասխան + Բացատրություն (ոչ պարտադիր) + Ճիշտ տարբերակ %1$d + Ստեղծել առաջադրանքների ցանկ + Վերնագիր + Առաջադրանք %1$d + Մյուսները կարող են ավելացնել առաջադրանքներ + Մյուսները կարող են ավարտել առաջադրանքները + Խմբագրել տարրը՝ %1$s + Հեռացնել տարրը՝ %1$s + Հեռացնե՞լ «%1$s» առաջադրանքը + Փոխարինել կցորդը + Կիսվել ընթացիկ տեղադրությամբ + Թարմացվում է միայն, երբ Monogram-ը տեսանելի է։ Վերադառնալուց հետո վերսկսեք տեղադրության փոխանցումը + Դադարեցված է + Տեղադրության փոխանցումը դադարեցվում է… + Դադարեցումը հաստատված չէ։ Կրկին փորձեք դադարեցնել + Վերսկսել փոխանցումը + Դադարեցնել փոխանցումը + Մինչև %1$s + Չի ուղարկվել + Կատարված է %1$d՝ %2$d-ից diff --git a/feature/dialog/src/main/res/values-ja/strings.xml b/feature/dialog/src/main/res/values-ja/strings.xml index 2e4bf0ce3..4893d905f 100644 --- a/feature/dialog/src/main/res/values-ja/strings.xml +++ b/feature/dialog/src/main/res/values-ja/strings.xml @@ -344,4 +344,74 @@ エディターを展開 %1$s %2$s %1$s · %2$s/秒 + 抽選 + 抽選結果 + 有料メディア + ゲーム + 請求書 + ストーリー + ライブ配信 + %1$d件の賞品 + %1$dか月 + %1$d Stars + %1$d人の当選者 · %2$d件未受取 + %1$d Stars · %2$d件 + ストーリー #%1$d + RTMP配信 + ライブ配信 + この下書きには、ここでは編集できない内容が含まれています。別のTelegramアプリで開くか、下書きを破棄してください + 下書きを破棄 + Telegramからこの下書きを破棄しますか?この操作は取り消せません + 送信オプション + 通知音なしで送信 + メッセージの送信を予約 + 現在から1分後〜1年後の有効な日時を選択してください + 送信待ち + 確認待ち + 配信未確認 + 送信予約メッセージ + 送信予約メッセージはありません + オンラインになったら送信 + 送信予約メッセージの操作 + 今すぐ送信 + 送信予約を取り消す + この送信予約メッセージを今すぐ送信しますか? + この送信予約メッセージを送信せずに削除しますか? + 再試行する前に更新して結果を確認してください + 送信日時を変更 + メッセージが変更されました。エディターを開き直してください + 投票する + 投票を取り消す + 投票を更新中… + 投票を締め切る + この投票を締め切りますか?以降の投票は受け付けられません + さらに読み込む + 参加者を読み込めませんでした + 結果を待っています + 投票を作成 + 質問 + 選択肢 %1$d + 選択肢を追加 + 複数回答 + 解説(任意) + 正解の選択肢 %1$d + チェックリストを作成 + タイトル + タスク %1$d + 他の人もタスクを追加できる + 他の人もタスクを完了できる + 項目を編集: %1$s + 項目を削除: %1$s + タスク「%1$s」を削除しますか? + 添付ファイルを置き換え + 現在地をリアルタイムで共有 + Monogramの表示中のみ更新されます。戻った後に共有を再開してください + 一時停止中 + 共有を停止しています… + 停止を確認できません。もう一度停止してください + 共有を再開 + 共有を停止 + %1$sまで + 未送信 + %2$d件中%1$d件完了 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 f6b57d4c7..62709167b 100644 --- a/feature/dialog/src/main/res/values-pt-rBR/strings.xml +++ b/feature/dialog/src/main/res/values-pt-rBR/strings.xml @@ -355,4 +355,74 @@ Expandir editor %1$s às %2$s %1$s · %2$s/s + Sorteio + Resultados do sorteio + Mídia paga + Jogo + Fatura + Story + Transmissão ao vivo + %1$d prêmios + %1$d meses + %1$d Stars + %1$d vencedores · %2$d não resgatados + %1$d Stars · %2$d itens + Story nº %1$d + Transmissão RTMP + Transmissão ao vivo + Este rascunho contém conteúdo que não pode ser editado aqui. Abra-o em outro aplicativo do Telegram ou descarte o rascunho + Descartar rascunho + Descartar este rascunho do Telegram? Esta ação não pode ser desfeita + Opções de envio + Enviar sem som + Agendar mensagem + Escolha um horário válido entre um minuto e um ano a partir de agora + Aguardando envio + Aguardando confirmação + Entrega não confirmada + Mensagens agendadas + Nenhuma mensagem agendada + Quando estiver online + Ações da mensagem agendada + Enviar agora + Cancelar mensagem agendada + Enviar esta mensagem agendada agora? + Remover esta mensagem agendada sem enviá-la? + Atualize para verificar o resultado antes de tentar novamente + Reagendar mensagem + A mensagem mudou. Abra o editor novamente + Enviar voto + Retirar voto + Atualizando enquete… + Encerrar enquete + Encerrar esta enquete? Nenhum outro voto será aceito + Carregar mais + Não foi possível carregar os participantes + Aguardando resultado + Criar enquete + Pergunta + Opção %1$d + Adicionar opção + Várias respostas + Explicação (opcional) + Opção correta %1$d + Criar lista de tarefas + Título + Tarefa %1$d + Outros podem adicionar tarefas + Outros podem concluir tarefas + Editar item: %1$s + Remover item: %1$s + Remover a tarefa “%1$s”? + Substituir anexo + Compartilhar localização em tempo real + Atualiza apenas enquanto o Monogram está visível. Retome o compartilhamento ao voltar + Pausada + Parando de compartilhar… + A interrupção não foi confirmada. Tente parar novamente + Retomar compartilhamento + Parar de compartilhar + Até %1$s + Não enviado + %1$d de %2$d concluídas diff --git a/feature/dialog/src/main/res/values-ru/strings.xml b/feature/dialog/src/main/res/values-ru/strings.xml index 487282cf9..14ecc0ed0 100644 --- a/feature/dialog/src/main/res/values-ru/strings.xml +++ b/feature/dialog/src/main/res/values-ru/strings.xml @@ -377,4 +377,74 @@ Развернуть редактор %1$s в %2$s %1$s · %2$s/с + Розыгрыш + Итоги розыгрыша + Платный контент + Игра + Счёт + История + Прямая трансляция + %1$d призов + %1$d мес + %1$d Stars + %1$d победителей · %2$d не получено + %1$d Stars · %2$d элементов + История №%1$d + RTMP-трансляция + Прямая трансляция + В этом черновике есть содержимое, которое нельзя редактировать здесь. Откройте его в другом приложении Telegram или удалите черновик + Удалить черновик + Удалить этот черновик из Telegram? Это действие нельзя отменить + Параметры отправки + Отправить без звука + Отложить сообщение + Выберите допустимое время: не раньше чем через минуту и не позже чем через год + Ожидание отправки + Ожидание подтверждения + Доставка не подтверждена + Отложенные сообщения + Нет отложенных сообщений + Когда будет в сети + Действия с отложенным сообщением + Отправить сейчас + Отменить отложенное сообщение + Отправить это отложенное сообщение сейчас? + Удалить это отложенное сообщение без отправки? + Обновите список, чтобы проверить результат перед повторной попыткой + Изменить время отправки + Сообщение изменилось. Откройте редактор заново + Проголосовать + Отозвать голос + Обновление опроса… + Закрыть опрос + Закрыть этот опрос? Новые голоса больше не будут приниматься + Загрузить ещё + Не удалось загрузить участников + Ожидание результата + Создать опрос + Вопрос + Вариант %1$d + Добавить вариант + Несколько ответов + Пояснение (необязательно) + Правильный вариант %1$d + Создать список задач + Заголовок + Задача %1$d + Другие могут добавлять задачи + Другие могут выполнять задачи + Изменить пункт: %1$s + Удалить пункт: %1$s + Удалить задачу «%1$s»? + Заменить вложение + Делиться геопозицией + Геопозиция обновляется, только пока открыт Monogram. Вернувшись, возобновите трансляцию + Приостановлена + Остановка трансляции… + Остановка не подтверждена. Попробуйте остановить трансляцию ещё раз + Возобновить трансляцию + Остановить трансляцию + До %1$s + Не отправлено + Выполнено %1$d из %2$d diff --git a/feature/dialog/src/main/res/values-sk/strings.xml b/feature/dialog/src/main/res/values-sk/strings.xml index a32adc054..e74262a47 100644 --- a/feature/dialog/src/main/res/values-sk/strings.xml +++ b/feature/dialog/src/main/res/values-sk/strings.xml @@ -377,4 +377,74 @@ Rozbaliť editor %1$s o %2$s %1$s · %2$s/s + Žrebovanie + Výsledky žrebovania + Platený obsah + Hra + Faktúra + Príbeh + Živé vysielanie + %1$d cien + %1$d mesiacov + %1$d Stars + %1$d výhercov · %2$d nevyzdvihnutých + %1$d Stars · %2$d položiek + Príbeh č. %1$d + RTMP vysielanie + Živé vysielanie + Tento koncept obsahuje obsah, ktorý tu nie je možné upraviť. Otvorte ho v inej aplikácii Telegram alebo koncept zahoďte + Zahodiť koncept + Zahodiť tento koncept z Telegramu? Túto akciu nemožno vrátiť späť + Možnosti odoslania + Odoslať bez zvuku + Naplánovať správu + Vyberte platný čas od jednej minúty do jedného roka odteraz + Čaká na odoslanie + Čaká na potvrdenie + Doručenie nepotvrdené + Naplánované správy + Žiadne naplánované správy + Keď bude online + Akcie naplánovanej správy + Odoslať teraz + Zrušiť naplánovanú správu + Odoslať túto naplánovanú správu teraz? + Odstrániť túto naplánovanú správu bez odoslania? + Pred ďalším pokusom obnovte zoznam a skontrolujte výsledok + Zmeniť čas odoslania + Správa sa zmenila. Znova otvorte editor + Odoslať hlas + Odvolať hlas + Aktualizácia ankety… + Ukončiť anketu + Ukončiť túto anketu? Ďalšie hlasy sa už nebudú prijímať + Načítať ďalších + Účastníkov sa nepodarilo načítať + Čaká sa na výsledok + Vytvoriť anketu + Otázka + Možnosť %1$d + Pridať možnosť + Viac odpovedí + Vysvetlenie (voliteľné) + Správna možnosť %1$d + Vytvoriť zoznam úloh + Názov + Úloha %1$d + Ostatní môžu pridávať úlohy + Ostatní môžu dokončovať úlohy + Upraviť položku: %1$s + Odstrániť položku: %1$s + Odstrániť úlohu „%1$s“? + Nahradiť prílohu + Zdieľať polohu naživo + Aktualizuje sa iba vtedy, keď je Monogram viditeľný. Po návrate obnovte zdieľanie + Pozastavené + Zastavuje sa zdieľanie… + Zastavenie nebolo potvrdené. Skúste ho znova + Obnoviť zdieľanie + Zastaviť zdieľanie + Do %1$s + Neodoslané + Dokončené: %1$d z %2$d diff --git a/feature/dialog/src/main/res/values-tr/strings.xml b/feature/dialog/src/main/res/values-tr/strings.xml index 5f0eb2bb2..cbffe86e2 100644 --- a/feature/dialog/src/main/res/values-tr/strings.xml +++ b/feature/dialog/src/main/res/values-tr/strings.xml @@ -355,4 +355,74 @@ Düzenleyiciyi genişlet %1$s saat %2$s %1$s · %2$s/sn + Çekiliş + Çekiliş sonuçları + Ücretli medya + Oyun + Fatura + Hikâye + Canlı yayın + %1$d ödül + %1$d ay + %1$d Stars + %1$d kazanan · %2$d alınmayan + %1$d Stars · %2$d öğe + Hikâye #%1$d + RTMP yayını + Canlı yayın + Bu taslak burada düzenlenemeyen içerik barındırıyor. Başka bir Telegram uygulamasında açın veya taslağı silin + Taslağı sil + Telegram üzerindeki bu taslak silinsin mi? Bu işlem geri alınamaz + Gönderim seçenekleri + Sessiz gönder + Mesajı zamanla + Şu andan itibaren bir dakika ile bir yıl arasında geçerli bir zaman seçin + Gönderilmeyi bekliyor + Onay bekleniyor + Teslimat doğrulanmadı + Zamanlanmış mesajlar + Zamanlanmış mesaj yok + Çevrimiçi olduğunda + Zamanlanmış mesaj işlemleri + Şimdi gönder + Zamanlanmış mesajı iptal et + Bu zamanlanmış mesaj şimdi gönderilsin mi? + Bu zamanlanmış mesaj gönderilmeden kaldırılsın mı? + Tekrar denemeden önce sonucu kontrol etmek için yenileyin + Gönderim zamanını değiştir + Mesaj değişti. Düzenleyiciyi yeniden açın + Oy gönder + Oyu geri çek + Anket güncelleniyor… + Anketi kapat + Bu anket kapatılsın mı? Artık oy kabul edilmeyecek + Daha fazla yükle + Katılımcılar yüklenemedi + Sonuç bekleniyor + Anket oluştur + Soru + Seçenek %1$d + Seçenek ekle + Birden fazla yanıt + Açıklama (isteğe bağlı) + Doğru seçenek %1$d + Kontrol listesi oluştur + Başlık + Görev %1$d + Başkaları görev ekleyebilir + Başkaları görevleri tamamlayabilir + Öğeyi düzenle: %1$s + Öğeyi kaldır: %1$s + “%1$s” görevi kaldırılsın mı? + Eki değiştir + Canlı konum paylaş + Yalnızca Monogram görünürken güncellenir. Geri döndüğünüzde paylaşımı sürdürün + Duraklatıldı + Paylaşım durduruluyor… + Durdurma onaylanmadı. Tekrar durdurmayı deneyin + Paylaşımı sürdür + Paylaşımı durdur + Bitiş: %1$s + Gönderilmedi + %2$d görevden %1$d tamamlandı diff --git a/feature/dialog/src/main/res/values-uk/strings.xml b/feature/dialog/src/main/res/values-uk/strings.xml index 37e7f0c8a..b87588307 100644 --- a/feature/dialog/src/main/res/values-uk/strings.xml +++ b/feature/dialog/src/main/res/values-uk/strings.xml @@ -377,4 +377,74 @@ Розгорнути редактор %1$s о %2$s %1$s · %2$s/с + Розіграш + Підсумки розіграшу + Платний контент + Гра + Рахунок + Історія + Пряма трансляція + %1$d призів + %1$d міс + %1$d Stars + %1$d переможців · %2$d не отримано + %1$d Stars · %2$d елементів + Історія №%1$d + RTMP-трансляція + Пряма трансляція + Ця чернетка містить вміст, який тут неможливо редагувати. Відкрийте її в іншому застосунку Telegram або видаліть чернетку + Видалити чернетку + Видалити цю чернетку з Telegram? Цю дію неможливо скасувати + Параметри надсилання + Надіслати без звуку + Запланувати повідомлення + Виберіть допустимий час: не раніше ніж за хвилину й не пізніше ніж за рік + Очікування надсилання + Очікування підтвердження + Доставку не підтверджено + Заплановані повідомлення + Немає запланованих повідомлень + Коли буде в мережі + Дії із запланованим повідомленням + Надіслати зараз + Скасувати заплановане повідомлення + Надіслати це заплановане повідомлення зараз? + Видалити це заплановане повідомлення без надсилання? + Оновіть список, щоб перевірити результат перед повторною спробою + Змінити час надсилання + Повідомлення змінилося. Відкрийте редактор знову + Проголосувати + Відкликати голос + Оновлення опитування… + Закрити опитування + Закрити це опитування? Нові голоси більше не прийматимуться + Завантажити ще + Не вдалося завантажити учасників + Очікування результату + Створити опитування + Запитання + Варіант %1$d + Додати варіант + Кілька відповідей + Пояснення (необов’язково) + Правильний варіант %1$d + Створити список завдань + Заголовок + Завдання %1$d + Інші можуть додавати завдання + Інші можуть виконувати завдання + Змінити пункт: %1$s + Видалити пункт: %1$s + Видалити завдання «%1$s»? + Замінити вкладення + Транслювати геопозицію + Геопозиція оновлюється, лише поки Monogram відкритий. Повернувшись, відновіть трансляцію + Призупинено + Зупинка трансляції… + Зупинку не підтверджено. Спробуйте зупинити трансляцію ще раз + Відновити трансляцію + Зупинити трансляцію + До %1$s + Не надіслано + Виконано %1$d із %2$d diff --git a/feature/dialog/src/main/res/values-zh/strings.xml b/feature/dialog/src/main/res/values-zh/strings.xml index 60a28ff5b..1fe6a8bea 100644 --- a/feature/dialog/src/main/res/values-zh/strings.xml +++ b/feature/dialog/src/main/res/values-zh/strings.xml @@ -344,4 +344,74 @@ 展开编辑器 %1$s %2$s %1$s · %2$s/秒 + 抽奖 + 抽奖结果 + 付费内容 + 游戏 + 账单 + 故事 + 直播 + %1$d 个奖品 + %1$d 个月 + %1$d Stars + %1$d 位获奖者 · %2$d 个未领取 + %1$d Stars · %2$d 项 + 故事 #%1$d + RTMP 直播 + 直播 + 此草稿包含无法在此处编辑的内容。请在其他 Telegram 应用中打开,或舍弃此草稿 + 舍弃草稿 + 要从 Telegram 中舍弃此草稿吗?此操作无法撤销 + 发送选项 + 无声发送 + 定时发送消息 + 请选择从现在起一分钟到一年之间的有效时间 + 等待发送 + 等待确认 + 尚未确认送达 + 定时消息 + 没有定时消息 + 上线时发送 + 定时消息操作 + 立即发送 + 取消定时消息 + 立即发送这条定时消息? + 移除这条定时消息而不发送? + 重试前请刷新以确认操作结果 + 修改发送时间 + 消息已更改。请重新打开编辑器 + 提交投票 + 撤回投票 + 正在更新投票… + 关闭投票 + 关闭此投票?关闭后将不再接受投票 + 加载更多 + 无法加载参与者 + 等待结果 + 创建投票 + 问题 + 选项 %1$d + 添加选项 + 多选 + 解释(可选) + 正确选项 %1$d + 创建清单 + 标题 + 任务 %1$d + 允许其他人添加任务 + 允许其他人完成任务 + 编辑事项:%1$s + 删除事项:%1$s + 要删除任务“%1$s”吗? + 替换附件 + 共享实时位置 + 仅在 Monogram 可见时更新。返回应用后请恢复共享 + 已暂停 + 正在停止共享… + 尚未确认停止,请再次尝试停止共享 + 恢复共享 + 停止共享 + 截止 %1$s + 未发送 + 已完成 %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 d14a335ea..911d53db6 100644 --- a/feature/dialog/src/main/res/values/strings.xml +++ b/feature/dialog/src/main/res/values/strings.xml @@ -356,4 +356,74 @@ Expand editor %1$s at %2$s %1$s · %2$s/s + Discard draft + Discard this draft from Telegram? This cannot be undone + This draft contains content that cannot be edited here. Open it in another Telegram app, or discard the draft + Game + Giveaway + %1$d months + %1$d prizes + Giveaway results + %1$d Stars + %1$d winners · %2$d unclaimed + Invoice + Live stream + Paid media + %1$d Stars · %2$d items + RTMP stream + Story + Story #%1$d + Live stream + Delivery unconfirmed + Send options + Waiting for confirmation + Waiting to send + Schedule message + Choose a valid time between one minute and one year from now + Send without sound + When online + Scheduled messages + No scheduled messages + Send this scheduled message now? + Scheduled message actions + Cancel scheduled message + Refresh to check the result before trying again + Send now + Remove this scheduled message without sending it? + Reschedule message + Message changed. Reopen the editor + Submit vote + Retract vote + Updating poll… + Close poll + Close this poll? No further votes will be accepted + Load more + Could not load viewers + Waiting for result + Create poll + Question + Option %1$d + Add option + Multiple answers + Explanation (optional) + Correct option %1$d + Create checklist + Title + Task %1$d + Others can add tasks + Others can complete tasks + Edit item: %1$s + Remove item: %1$s + Remove task “%1$s”? + Replace attachment + Share live location + Updates only while Monogram is visible. Resume sharing after returning + Paused + Stopping sharing… + Stop not confirmed. Try stopping again + Resume sharing + Stop sharing + Until %1$s + Not sent + %1$d of %2$d completed diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt index 2bb54edca..fe29502a5 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogForumStoreTest.kt @@ -1,11 +1,18 @@ package org.monogram.feature.dialog import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.async +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.first import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.CloudDraftState import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.runBlocking @@ -19,6 +26,8 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.yield import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -74,6 +83,222 @@ class DialogForumStoreTest { StickerPackMemory.clear() } + @Test + fun structuredCloudDraftRestoresMentionsReplyAndPreviewSuppression() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val peer = PeerId(5) + val cache = FakeWarmup() + val entities = org.monogram.core.models.TextEntities.serialize( + listOf( + TextEntity("mention_name", 0, 5, "42"), TextEntity("bold", 6, 4), + ) + ) + cache.cloudDrafts[draftMetaKey(peer.value)] = CloudDraftState( + CloudDraft( + peer, + text = "Alice text", + entitiesJson = entities, + replyToMessageId = 123, + noWebpage = true, + ) + ) + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + cache, + chatId = peer, + sessionStore = null, + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + runCurrent() + assertEquals("Alice text", store.state.draft) + assertEquals(42L, store.state.draftMentions.single().peerId.value) + assertEquals(123, store.state.draftReplyId) + assertTrue(store.state.linkPreviewHidden) + store.accept(DialogStore.Intent.ClearReply) + runCurrent() + assertEquals(0, cache.cloudDraftState(peer).draft.replyToMessageId) + assertEquals( + 2, + org.monogram.core.models.TextEntities.parse(cache.cloudDraftState(peer).draft.entitiesJson).size + ) + } finally { + store.dispose() + } + } + + @Test + fun remoteDraftDoesNotReplaceTypingWhileLocalStorageIsSuspended() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val peer = PeerId(5) + val cache = FakeWarmup() + val gate = CompletableDeferred() + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + cache, + chatId = peer, + sessionStore = null, + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + runCurrent() + cache.draftSaveGate = gate + store.accept(DialogStore.Intent.DraftChanged("local typing")) + runCurrent() + cache.applyCloudDraft( + CloudDraft( + peer, + text = "remote update", + date = 20, + hasUnsupportedContent = true + ) + ) + runCurrent() + assertEquals("local typing", store.state.draft) + gate.complete(Unit) + runCurrent() + assertEquals("local typing", cache.cloudDraftState(peer).draft.text) + assertEquals("local typing", store.state.draft) + assertTrue(cache.cloudDraftState(peer).draft.hasUnsupportedContent) + assertTrue(store.state.draftUnsupported) + } finally { + gate.complete(Unit) + store.dispose() + runCurrent() + } + } + + @Test + fun cancellingMessageEditingRestoresTheComposerWithoutSavingTemporaryText() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val peer = PeerId(5) + val cache = FakeWarmup() + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + cache, + chatId = peer, + sessionStore = null, + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + runCurrent() + store.accept(DialogStore.Intent.DraftChanged("unsent draft")) + runCurrent() + store.accept( + DialogStore.Intent.Edit( + Message( + id = MessageId(peer, 99), + senderId = null, + text = "existing message", + date = 1L, + outgoing = true + ) + ) + ) + store.accept(DialogStore.Intent.DraftChanged("temporary edit")) + runCurrent() + assertEquals("unsent draft", cache.cloudDraftState(peer).draft.text) + store.accept(DialogStore.Intent.CancelEdit) + runCurrent() + assertEquals("unsent draft", store.state.draft) + assertEquals("unsent draft", cache.cloudDraftState(peer).draft.text) + } finally { + store.dispose() + } + } + + @Test + fun unsupportedCloudDraftRequiresExplicitDiscardBeforeSending() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val peer = PeerId(5) + val cache = FakeWarmup() + cache.cloudDrafts[draftMetaKey(peer.value)] = CloudDraftState( + CloudDraft(peer, text = "unsupported draft", date = 20, hasUnsupportedContent = true) + ) + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + cache, + chatId = peer, + sessionStore = null, + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + runCurrent() + assertTrue(store.state.draftUnsupported) + store.accept(DialogStore.Intent.Send()) + runCurrent() + assertTrue(client.sentTexts.isEmpty()) + store.accept(DialogStore.Intent.DiscardDraft) + runCurrent() + assertFalse(store.state.draftUnsupported) + assertEquals("", store.state.draft) + assertTrue(cache.cloudDraftState(peer).discardingUnsupported) + store.accept(DialogStore.Intent.DraftChanged("new draft")) + runCurrent() + assertFalse(cache.cloudDraftState(peer).draft.hasUnsupportedContent) + assertEquals("new draft", cache.cloudDraftState(peer).draft.text) + } finally { + store.dispose() + } + } + + @Test + fun draftSyncStatusAndRetryAreScopedToTheEditor() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val peer = PeerId(5) + val cache = FakeWarmup() + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + cache, + chatId = peer, + sessionStore = null, + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + val retry = async(start = CoroutineStart.UNDISPATCHED) { cache.draftSync.retries.first() } + try { + runCurrent() + val unrelated = cache.draftSync.begin(PeerId(6) to 0) + cache.draftSync.finish( + PeerId(6) to 0, unrelated, + org.monogram.core.common.telegram.TelegramError.parse("CHAT_WRITE_FORBIDDEN") + ) + runCurrent() + assertNull(store.state.draftSync.error) + val current = cache.draftSync.begin(peer to 0) + runCurrent() + assertTrue(store.state.draftSync.syncing) + cache.draftSync.finish( + peer to 0, current, + org.monogram.core.common.telegram.TelegramError.parse("CHAT_WRITE_FORBIDDEN") + ) + runCurrent() + assertFalse(store.state.draftSync.syncing) + assertNotNull(store.state.draftSync.error) + store.accept(DialogStore.Intent.RetryDraftSync) + runCurrent() + assertTrue(retry.isCompleted) + } finally { + retry.cancel() + store.dispose() + } + } + @Test fun slowPinnedCacheDoesNotDelayFirstHistoryPublication() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) @@ -1108,456 +1333,503 @@ class DialogForumStoreTest { } @Test - fun toggleChecklistCompletesItem() = runTest { + fun reactionViewerPagesLoadBeyondFiftyPreserveRowsOnFailureAndRetrySameCursor() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val checklist = org.monogram.core.models.Checklist( - title = "Shop", - othersCanComplete = true, - items = listOf( - org.monogram.core.models.ChecklistItem(1, "Milk", done = false), - org.monogram.core.models.ChecklistItem(2, "Eggs", done = true), - ), - ) - val client = FakeClient().apply { - history = listOf(topicMessage(8).copy(mediaKind = "todo", checklist = checklist)) + val client = FakeClient().apply { history = listOf(topicMessage(8)) } + fun rows(range: IntRange) = range.map { + org.monogram.core.models.MessageViewer( + PeerId(it.toLong()), + 100, + title = "Fixture $it", + emoticon = "👍" + ) } + client.viewerPageResult = Outcome.Ok( + org.monogram.core.models.MessageViewerPage( + rows(1..50), + 60, + "opaque+/=second" + ) + ) + val gate = CompletableDeferred(); client.viewerPageGate = gate val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), client, null, null, PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher ).create() try { advanceUntilIdle() - store.accept(DialogStore.Intent.ToggleChecklist(8, 1)) - advanceUntilIdle() - assertEquals(8, client.lastTodoToggle?.first) - assertEquals(listOf(1), client.lastTodoToggle?.second) - assertEquals(emptyList(), client.lastTodoToggle?.third) - val updated = store.state.messages.single { it.id.id == 8 }.checklist - requireNotNull(updated) - assertTrue(updated.items[0].done) - store.accept(DialogStore.Intent.ToggleChecklist(8, 2)) - advanceUntilIdle() - assertEquals(8, client.lastTodoToggle?.first) - assertEquals(emptyList(), client.lastTodoToggle?.second) - assertEquals(listOf(2), client.lastTodoToggle?.third) + store.accept(DialogStore.Intent.LoadReactionUsers(8)); store.accept( + DialogStore.Intent.LoadReactionUsers( + 8 + ) + ) + assertEquals(listOf(null), client.viewerPageOffsets) + gate.complete(Unit); advanceUntilIdle() + assertEquals(50, store.state.reactionUsers[8]?.size) + client.viewerPageResult = Outcome.Err("RPC 500: INTERNAL") + store.accept(DialogStore.Intent.LoadReactionUsers(8)); advanceUntilIdle() + assertEquals(50, store.state.reactionUsers[8]?.size) + assertTrue(store.state.reactionPages[8]!!.failed) + client.viewerPageResult = + Outcome.Ok(org.monogram.core.models.MessageViewerPage(rows(50..60), 60, null)) + store.accept(DialogStore.Intent.LoadReactionUsers(8)); advanceUntilIdle() + assertEquals(60, store.state.reactionUsers[8]?.size) + assertEquals( + listOf(null, "opaque+/=second", "opaque+/=second"), + client.viewerPageOffsets + ) + assertFalse(store.state.reactionPages[8]!!.failed) + store.accept(DialogStore.Intent.LoadReactionUsers(8)); advanceUntilIdle() + assertEquals(3, client.viewerPageOffsets.size) } finally { store.dispose() } } @Test - fun attachAndEmojiPanelIntentsSwitchTabsAndInsertEmoji() = runTest { + fun pollViewerPagesRequirePublicPollAndRejectRepeatedCursorWithoutLosingRows() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient() + val client = FakeClient().apply { + history = listOf( + topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Public","o":1,"a":[]}""" + ), + topicMessage(9).copy(mediaKind = "poll", fileName = """{"q":"Private","a":[]}""") + ) + } + val viewer = + org.monogram.core.models.MessageViewer(PeerId(7), 100, pollOptionHex = listOf("00ff")) + client.viewerPageResult = + Outcome.Ok(org.monogram.core.models.MessageViewerPage(listOf(viewer), 2, "next")) val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), client, null, null, PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.ToggleAttachSheet) - assertEquals(ComposerPanels.ATTACH, store.state.composerPanel) - // Closing is idempotent: a second close must not re-open the panel behind a scrim. - store.accept(DialogStore.Intent.CloseAttachSheet) - assertEquals(null, store.state.composerPanel) - store.accept(DialogStore.Intent.CloseAttachSheet) - assertEquals(null, store.state.composerPanel) - store.accept(DialogStore.Intent.ToggleAttachSheet) - assertEquals(ComposerPanels.ATTACH, store.state.composerPanel) - store.accept(DialogStore.Intent.CloseAttachSheet) - assertEquals(null, store.state.composerPanel) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) - advanceUntilIdle() - assertEquals(ComposerPanels.EMOJI, store.state.composerPanel) - assertEquals(ComposerPanels.TAB_GIFS, store.state.emojiTab) - assertTrue(store.state.gifPickerOpen) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) - advanceUntilIdle() - assertEquals(ComposerPanels.TAB_STICKERS, store.state.emojiTab) - store.accept(DialogStore.Intent.InsertEmoji("😀")) - assertTrue(store.state.draft.endsWith("😀")) - val clip = kotlin.io.path.createTempFile(suffix = ".mp4").toFile().apply { - writeBytes(byteArrayOf(1, 2, 3, 4)) - } - store.accept(DialogStore.Intent.SendUpload(clip.absolutePath, "video")) - advanceUntilIdle() - assertEquals(clip.absolutePath, client.lastUpload?.path) - assertEquals("video", client.lastUpload?.kind) - assertEquals(null, store.state.composerPanel) + advanceUntilIdle(); store.accept(DialogStore.Intent.LoadPollVoters(9)); advanceUntilIdle() + assertTrue(client.viewerPageOffsets.isEmpty()) + store.accept(DialogStore.Intent.LoadPollVoters(8)); advanceUntilIdle() + assertEquals(listOf("00ff"), store.state.pollVoters[8]!!.single().pollOptionHex) + store.accept(DialogStore.Intent.LoadPollVoters(8)); advanceUntilIdle() + assertTrue(store.state.pollVoterPages[8]!!.failed) + assertEquals(1, store.state.pollVoters[8]!!.size) } finally { store.dispose() } } @Test - fun savedGifsUseProcessCacheOnSecondOpen() = runTest { - SavedGifMemory.clear() + fun pollPushCoalescesReadsAndPreservesExactLongIdentity() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val client = FakeClient() + val before = client.topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","id":"9223372036854775807","n":0,"a":[]}""" + ) + val after = + before.copy(fileName = """{"q":"Fixture","id":"9223372036854775807","n":12,"a":[]}""") + val gate = CompletableDeferred() + client.history = listOf(before); client.pollRead = Outcome.Ok(after); client.pollReadGate = + gate val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) - advanceUntilIdle() - assertEquals(1, client.savedGifCalls) - assertEquals(11L, store.state.savedGifs.single().documentId) - assertEquals(false, store.state.savedGifsError) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) advanceUntilIdle() - assertEquals(1, client.savedGifCalls) + client.events.emit(MtprotoUpdate.PollChanged(123)) + assertEquals(0, client.pollReadCalls) + client.events.emit(MtprotoUpdate.PollChanged(Long.MAX_VALUE)) + client.events.emit(MtprotoUpdate.PollChanged(Long.MAX_VALUE)) + client.events.emit(MtprotoUpdate.PollChanged(Long.MAX_VALUE)) + assertEquals(1, client.pollReadCalls) + gate.complete(Unit); advanceUntilIdle() + assertEquals(2, client.pollReadCalls) + assertEquals(after.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + assertEquals(0, client.pollVoteCalls) } finally { store.dispose() } } @Test - fun pickerCatalogsAreOnInitialStateBeforeAnySchedulerWork() = runTest { - StickerCatalogMemory.clear() - SavedGifMemory.clear() - val dispatcher = StandardTestDispatcher(testScheduler) + fun latePollPushReadCannotReplaceNewerMessageEdit() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) val client = FakeClient() - val meta = FakeMeta() - val diskGif = org.monogram.core.models.SavedGif( - documentId = 5L, - cacheKey = "gif:5", - thumbCacheKey = "gif:5:thumb", - ) - val pack = StickerPack( - id = 9L, - title = "Cats", - shortName = "cats", - count = 1, - isEmoji = false, - previewDocumentIds = listOf(42L), - accessHash = 11L, - ) - meta.values["picker.stickers"] = PickerDisk.encodeCatalog( - StickerCatalogSnapshot(7L, listOf(pack)), - ) - meta.values["picker.gifs"] = PickerDisk.encodeGifs(listOf(diskGif)) + val before = client.topicMessage(8) + .copy(mediaKind = "poll", fileName = """{"q":"Fixture","id":"123","n":0,"a":[]}""") + val newer = before.copy(fileName = """{"q":"Fixture","id":"123","n":20,"a":[]}""") + val gate = CompletableDeferred() + client.history = listOf(before); client.pollRead = Outcome.Ok(before); client.pollReadGate = + gate val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = meta, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - assertEquals(listOf(9L), store.state.stickerSets.map { it.id }) - assertEquals(true, store.state.stickerSetsLoaded) - assertEquals(5L, store.state.savedGifs.single().documentId) - assertEquals(true, store.state.savedGifsLoaded) - assertEquals(0, client.stickerCatalogCalls) - assertEquals(0, client.savedGifCalls) + advanceUntilIdle() + client.events.emit(MtprotoUpdate.PollChanged(123)) + client.events.emit(MtprotoUpdate.MessageEdited(newer)) + gate.complete(Unit); advanceUntilIdle() + assertEquals(newer.fileName, store.state.messages.single { it.id.id == 8 }.fileName) } finally { store.dispose() - StickerCatalogMemory.clear() - SavedGifMemory.clear() } } @Test - fun pickerCatalogsHydrateFromDiskOnStoreStart() = runTest { - StickerCatalogMemory.clear() - SavedGifMemory.clear() + fun closePollRequiresCreatorAndReconcilesUncertainEditWithoutReplay() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) val client = FakeClient() - val meta = FakeMeta() - val diskGif = org.monogram.core.models.SavedGif( - documentId = 5L, - cacheKey = "gif:5", - thumbCacheKey = "gif:5:thumb", - ) - client.savedGifs = listOf(diskGif) - val pack = StickerPack( - id = 9L, - title = "Cats", - shortName = "cats", - count = 1, - isEmoji = false, - previewDocumentIds = listOf(42L), - accessHash = 11L, - ) - meta.values["picker.stickers"] = PickerDisk.encodeCatalog( - StickerCatalogSnapshot(7L, listOf(pack)), + val before = client.topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","creator":1,"a":[{"t":"A","o":"00"}]}""" ) - meta.values["picker.gifs"] = PickerDisk.encodeGifs(listOf(diskGif)) + val after = + before.copy(fileName = """{"q":"Fixture","creator":1,"x":1,"a":[{"t":"A","o":"00"}]}""") + val gate = CompletableDeferred() + client.history = listOf(before) + client.pollRead = Outcome.Ok(after) + client.pollCloseGate = gate + client.pollCloseResult = Outcome.Err("RPC 500: INTERNAL") val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = meta, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - assertEquals(listOf(9L), store.state.stickerSets.map { it.id }) - assertEquals(true, store.state.stickerSetsLoaded) - assertEquals(5L, store.state.savedGifs.single().documentId) - assertEquals(true, store.state.savedGifsLoaded) advanceUntilIdle() - assertEquals(1, client.stickerCatalogCalls) - assertEquals(7L, client.lastStickerHash) - assertEquals(1, client.savedGifCalls) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) - store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) + store.accept(DialogStore.Intent.ClosePoll(8)) + store.accept(DialogStore.Intent.ClosePoll(8)) + store.accept(DialogStore.Intent.VotePoll(8, listOf(byteArrayOf(0)))) + assertEquals(1, client.pollCloseCalls) + assertEquals(0, client.pollVoteCalls) + assertEquals(before.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + gate.complete(Unit) advanceUntilIdle() - assertEquals(1, client.stickerCatalogCalls) - assertEquals(1, client.savedGifCalls) + assertEquals(after.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + assertNotNull(store.state.error) + store.accept(DialogStore.Intent.ClosePoll(8)) + advanceUntilIdle() + assertEquals(1, client.pollCloseCalls) } finally { store.dispose() - StickerCatalogMemory.clear() - SavedGifMemory.clear() } } @Test - fun stickerCatalogUsesProcessCacheOnSecondOpen() = runTest { - StickerCatalogMemory.clear() - StickerPackMemory.clear() + fun closePollRejectsNonCreatorWithoutNetwork() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient() - val first = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, - ).create() - try { - first.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) - advanceUntilIdle() - assertEquals(1, client.stickerCatalogCalls) - assertEquals(0L, client.lastStickerHash) - assertEquals(listOf(9L), first.state.stickerSets.map { it.id }) - first.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) - advanceUntilIdle() - assertEquals(1, client.stickerSetCalls) - assertEquals(listOf(42L), first.state.loadedStickerPacks[9L]?.previewDocumentIds) - } finally { - first.dispose() + val client = FakeClient().apply { + history = listOf( + topicMessage(8).copy( + mediaKind = "poll", outgoing = true, + fileName = """{"q":"Fixture","a":[{"t":"A","o":"00"}]}""" + ) + ) } - val second = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(6), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - assertEquals(listOf(9L), second.state.stickerSets.map { it.id }) - assertEquals(listOf(42L), second.state.loadedStickerPacks[9L]?.previewDocumentIds) - second.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) - second.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertEquals(1, client.stickerCatalogCalls) - assertEquals(1, client.stickerSetCalls) + store.accept(DialogStore.Intent.ClosePoll(8)) + advanceUntilIdle() + assertEquals(0, client.pollCloseCalls) } finally { - second.dispose() + store.dispose() } } @Test - fun diskStickerPackStillFetchesNativeLocationsOnce() = runTest { - StickerCatalogMemory.clear() - StickerPackMemory.clear() + fun answeredPollWithRevotingDisabledNeverCallsVoteRpc() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient() - val meta = FakeMeta() - val catalog = PickerDisk.encodeCatalog( - StickerCatalogSnapshot(0L, listOf(client.loadedPack)), - ) - meta.values["picker.pack.9"] = catalog.substringAfter("\"sets\":[").substringBeforeLast("]") + val client = FakeClient().apply { + history = listOf( + topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","r":1,"a":[{"t":"A","o":"00","c":true},{"t":"B","o":"01"}]}""" + ) + ) + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = meta, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) - assertEquals(1, client.stickerSetCalls) - store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) + store.accept(DialogStore.Intent.VotePoll(8, emptyList())) + store.accept(DialogStore.Intent.VotePoll(8, listOf(byteArrayOf(1)))) advanceUntilIdle() - assertEquals(1, client.stickerSetCalls) + assertEquals(0, client.pollVoteCalls) + assertTrue(store.state.pollVotesInFlight.isEmpty()) } finally { store.dispose() - StickerPackMemory.clear() } } @Test - fun openedStickerPackTracksLoadingAndLoadsEmojiOrStickers() = runTest { - StickerCatalogMemory.clear() - StickerPackMemory.clear() + fun pollVoteWaitsForAuthoritativeReadAndSuppressesConcurrentSubmission() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) + val before = FakeClient().topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","m":true,"a":[{"t":"A","o":"00ff","c":false}]}""" + ) + val after = + before.copy(fileName = """{"q":"Fixture","m":true,"n":12,"a":[{"t":"A","o":"00ff","c":true,"v":12}]}""") val gate = CompletableDeferred() - val client = FakeClient().apply { stickerSetGate = gate } + val client = FakeClient().apply { + history = listOf(before) + pollRead = Outcome.Ok(after) + pollVoteGate = gate + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - // While the pack is in flight the menu has to show a placeholder, not an empty grid. - assertTrue(9L in store.state.loadingStickerPackIds) - assertTrue(store.state.failedStickerPackIds.isEmpty()) - + val bytes = byteArrayOf(0, -1) + store.accept(DialogStore.Intent.VotePoll(8, listOf(bytes))) + bytes[0] = 12 + assertTrue(8 in store.state.pollVotesInFlight) + assertEquals(before.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + store.accept(DialogStore.Intent.VotePoll(8, listOf(byteArrayOf(0, -1)))) + assertEquals(1, client.pollVoteCalls) gate.complete(Unit) advanceUntilIdle() - assertFalse(9L in store.state.loadingStickerPackIds) - assertTrue(store.state.failedStickerPackIds.isEmpty()) - assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) + assertEquals(listOf(0, -1), client.pollOptions.single().toList()) + assertEquals(after.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + assertTrue(store.state.pollVotesInFlight.isEmpty()) } finally { store.dispose() } } @Test - fun failedStickerPackLeavesThePlaceholderAndOffersRetry() = runTest { - StickerCatalogMemory.clear() - StickerPackMemory.clear() + fun failedPollVoteReconcilesByReadingWithoutReplayingMutation() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { stickerSetError = "STICKER_SET_INVALID" } + val before = FakeClient().topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","a":[{"t":"A","o":"00","c":true}]}""" + ) + val after = before.copy(fileName = """{"q":"Fixture","a":[{"t":"A","o":"00","c":false}]}""") + val client = FakeClient().apply { + history = listOf(before); pollRead = Outcome.Ok(after); pollVoteResult = + Outcome.Err("RPC 500: INTERNAL") + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertFalse(9L in store.state.loadingStickerPackIds) - // A failed pack must stop shimmering forever and become retryable. - assertTrue(9L in store.state.failedStickerPackIds) - assertFalse(store.state.loadedStickerPacks.containsKey(9L)) - - // Retrying the same pack clears the failure and loads it. - client.stickerSetError = null - store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) + store.accept(DialogStore.Intent.VotePoll(8, emptyList())) advanceUntilIdle() - assertTrue(store.state.failedStickerPackIds.isEmpty()) - assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) + assertEquals(1, client.pollVoteCalls) + assertTrue(client.pollOptions.isEmpty()) + assertEquals(after.fileName, store.state.messages.single { it.id.id == 8 }.fileName) + assertNotNull(store.state.error) + assertTrue(store.state.pollVotesInFlight.isEmpty()) } finally { store.dispose() } } @Test - fun inlineGifQueryResolvesDebouncesAndSends() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient() + fun deletedPollReadRemovesMessageAfterVote() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { + history = listOf( + topicMessage(8).copy( + mediaKind = "poll", + fileName = """{"q":"Fixture","a":[{"t":"A","o":"00"}]}""" + ) + ) + pollRead = Outcome.Ok(null) + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@gif cats")) - testScheduler.runCurrent() - assertEquals(null, client.lastInlineQuery) - assertEquals(null, store.state.inlineQuery) - assertEquals(false, store.state.inlineLoading) - advanceTimeBy(300) advanceUntilIdle() - assertEquals("gif", client.lastResolve) - assertEquals("cats", client.lastInlineQuery) - assertEquals(true, store.state.inlineResults?.gallery) - assertEquals("gif-1", store.state.inlineResults?.results?.single()?.id) - store.accept(DialogStore.Intent.SendInlineResult("gif-1")) + store.accept(DialogStore.Intent.VotePoll(8, listOf(byteArrayOf(0)))) advanceUntilIdle() - assertEquals("gif-1", client.lastInlineSend) - assertEquals("", store.state.draft) - assertEquals(null, store.state.inlineQuery) + assertEquals(1, client.pollVoteCalls) + assertFalse(store.state.messages.any { it.id.id == 8 }) + assertTrue(store.state.pollVotesInFlight.isEmpty()) } finally { store.dispose() } } @Test - fun inlinePicGallerySendsWithoutDocumentId() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { inlineGalleryKind = "photo" } + fun checklistFailurePreservesAuthoritativeStateAndSerializesAppendWithToggle() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val list = org.monogram.core.models.Checklist( + "Fixture", othersCanComplete = true, othersCanAppend = true, + items = listOf(org.monogram.core.models.ChecklistItem(1, "Item")) + ) + val client = FakeClient().apply { + history = listOf(topicMessage(8).copy(mediaKind = "todo", checklist = list)) + todoGate = gate; todoResult = Outcome.Err("RPC 500: INTERNAL") + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), client, null, null, PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@pic hi")) - advanceTimeBy(300) - advanceUntilIdle() - assertEquals("pic", client.lastResolve) - assertEquals("photo", store.state.inlineResults?.results?.single()?.kind) - assertEquals("photo:88", store.state.inlineResults?.results?.single()?.thumbCacheKey) - store.accept(DialogStore.Intent.SendInlineResult("p1")) - advanceUntilIdle() - assertEquals("p1", client.lastInlineSend) - assertEquals("", store.state.draft) + advanceUntilIdle(); store.accept(DialogStore.Intent.ToggleChecklist(8, 1)) + assertTrue(8 in store.state.checklistMutations) + assertFalse(store.state.messages.single { it.id.id == 8 }.checklist!!.items.single().done) + store.accept(DialogStore.Intent.ToggleChecklist(8, 1)) + store.accept(DialogStore.Intent.AppendChecklistItems(8, 2, listOf("New"))) + assertEquals(1, client.todoCalls) + gate.complete(Unit); advanceUntilIdle() + assertFalse(store.state.messages.single { it.id.id == 8 }.checklist!!.items.single().done) + assertTrue(store.state.checklistMutations.isEmpty()); assertNotNull(store.state.error) } finally { store.dispose() } } @Test - fun inlineResultsLoadNextPageAndDeduplicateResults() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { inlinePaging = true } + fun checklistAppendChecksAccessAndFreshItemIdsAndRefreshesServerList() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val list = org.monogram.core.models.Checklist( + "Fixture", othersCanAppend = true, + items = listOf(org.monogram.core.models.ChecklistItem(1, "Item")) + ) + val client = FakeClient().apply { + history = listOf( + topicMessage(8).copy(mediaKind = "todo", checklist = list), + topicMessage(9).copy( + mediaKind = "todo", + checklist = list.copy(othersCanAppend = false) + ) + ) + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), client, null, null, PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@gif cats")) - advanceTimeBy(300) advanceUntilIdle() - assertEquals(listOf(""), client.inlineOffsets) - assertEquals(listOf("gif-1"), store.state.inlineResults?.results?.map { it.id }) - assertEquals("more", store.state.inlineResults?.nextOffset) - - store.accept(DialogStore.Intent.LoadMoreInlineResults) - advanceUntilIdle() - assertEquals(listOf("", "more"), client.inlineOffsets) - assertEquals(listOf("gif-1", "gif-2"), store.state.inlineResults?.results?.map { it.id }) - assertEquals(null, store.state.inlineResults?.nextOffset) + store.accept(DialogStore.Intent.AppendChecklistItems(9, 2, listOf("Denied"))) + store.accept(DialogStore.Intent.AppendChecklistItems(8, 1, listOf("Stale"))) + store.accept( + DialogStore.Intent.AppendChecklistItems( + 8, + Int.MAX_VALUE, + listOf("Overflow", "Overflow") + ) + ) + advanceUntilIdle(); assertEquals(0, client.todoCalls) + store.accept(DialogStore.Intent.AppendChecklistItems(8, 2, listOf(" New ", " "))) + advanceUntilIdle(); assertEquals(Triple(8, 2, listOf("New")), client.lastTodoAppend) + assertEquals( + listOf("Item", "New"), + store.state.messages.first { it.id.id == 8 }.checklist!!.items.map { it.text }) + store.accept(DialogStore.Intent.AppendChecklistItems(8, 2, listOf("Stale again"))) + advanceUntilIdle(); assertEquals(1, client.todoCalls) } finally { store.dispose() } } @Test - fun inlineEmptyPageWithOffsetStopsAfterOneRetry() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { inlineEmptyPage = true } + fun toggleChecklistCompletesItem() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val checklist = org.monogram.core.models.Checklist( + title = "Shop", + othersCanComplete = true, + items = listOf( + org.monogram.core.models.ChecklistItem(1, "Milk", done = false), + org.monogram.core.models.ChecklistItem(2, "Eggs", done = true), + ), + ) + val client = FakeClient().apply { + history = listOf(topicMessage(8).copy(mediaKind = "todo", checklist = checklist)) + } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@gif cats")) - advanceTimeBy(300) advanceUntilIdle() - assertEquals(listOf(""), client.inlineOffsets) - assertEquals(true, store.state.inlineResults?.results?.isEmpty()) - assertEquals("more", store.state.inlineResults?.nextOffset) - - store.accept(DialogStore.Intent.LoadMoreInlineResults) + store.accept(DialogStore.Intent.ToggleChecklist(8, 1)) advanceUntilIdle() - assertEquals(listOf("", "more"), client.inlineOffsets) - assertEquals(null, store.state.inlineResults?.nextOffset) - - store.accept(DialogStore.Intent.LoadMoreInlineResults) + assertEquals(8, client.lastTodoToggle?.first) + assertEquals(listOf(1), client.lastTodoToggle?.second) + assertEquals(emptyList(), client.lastTodoToggle?.third) + val updated = store.state.messages.single { it.id.id == 8 }.checklist + requireNotNull(updated) + assertTrue(updated.items[0].done) + store.accept(DialogStore.Intent.ToggleChecklist(8, 2)) advanceUntilIdle() - assertEquals(listOf("", "more"), client.inlineOffsets) + assertEquals(8, client.lastTodoToggle?.first) + assertEquals(emptyList(), client.lastTodoToggle?.second) + assertEquals(listOf(2), client.lastTodoToggle?.third) } finally { store.dispose() } } @Test - fun atBotWithoutSpaceOpensInlineAfterConfirm() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) + fun attachAndEmojiPanelIntentsSwitchTabsAndInsertEmoji() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) val client = FakeClient() val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, @@ -1565,404 +1837,483 @@ class DialogForumStoreTest { mainContext = dispatcher, markupContext = dispatcher, ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@pic")) - testScheduler.runCurrent() - assertEquals(null, store.state.inlineQuery) - assertEquals(null, store.state.mentionToken) - advanceTimeBy(300) + store.accept(DialogStore.Intent.ToggleAttachSheet) + assertEquals(ComposerPanels.ATTACH, store.state.composerPanel) + // Closing is idempotent: a second close must not re-open the panel behind a scrim. + store.accept(DialogStore.Intent.CloseAttachSheet) + assertEquals(null, store.state.composerPanel) + store.accept(DialogStore.Intent.CloseAttachSheet) + assertEquals(null, store.state.composerPanel) + store.accept(DialogStore.Intent.ToggleAttachSheet) + assertEquals(ComposerPanels.ATTACH, store.state.composerPanel) + store.accept(DialogStore.Intent.CloseAttachSheet) + assertEquals(null, store.state.composerPanel) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) advanceUntilIdle() - assertEquals("pic", client.lastResolve) - assertEquals("", client.lastInlineQuery) - assertEquals("pic", store.state.inlineQuery?.username) - assertEquals(null, store.state.mentionToken) - assertEquals(false, store.state.inlineError) + assertEquals(ComposerPanels.EMOJI, store.state.composerPanel) + assertEquals(ComposerPanels.TAB_GIFS, store.state.emojiTab) + assertTrue(store.state.gifPickerOpen) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) + advanceUntilIdle() + assertEquals(ComposerPanels.TAB_STICKERS, store.state.emojiTab) + store.accept(DialogStore.Intent.InsertEmoji("😀")) + assertTrue(store.state.draft.endsWith("😀")) + val clip = kotlin.io.path.createTempFile(suffix = ".mp4").toFile().apply { + writeBytes(byteArrayOf(1, 2, 3, 4)) + } + store.accept(DialogStore.Intent.SendUpload(clip.absolutePath, "video")) + advanceUntilIdle() + assertEquals(clip.absolutePath, client.lastUpload?.path) + assertEquals("video", client.lastUpload?.kind) + assertEquals(null, store.state.composerPanel) } finally { store.dispose() } } @Test - fun nonBotAtQueryNeverOpensInlineOverlay() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { resolveIsBot = false } + fun savedGifsUseProcessCacheOnSecondOpen() = runTest { + SavedGifMemory.clear() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@user query")) - advanceTimeBy(300) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) advanceUntilIdle() - assertEquals("user", client.lastResolve) - assertEquals(null, client.lastInlineQuery) - assertEquals(null, store.state.inlineQuery) - assertEquals(false, store.state.inlineLoading) - assertEquals(false, store.state.inlineError) + assertEquals(1, client.savedGifCalls) + assertEquals(11L, store.state.savedGifs.single().documentId) + assertEquals(false, store.state.savedGifsError) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) + advanceUntilIdle() + assertEquals(1, client.savedGifCalls) } finally { store.dispose() } } @Test - fun mentionMenuLoadsMembersAndGlobalHandle() = runTest { + fun pickerCatalogsAreOnInitialStateBeforeAnySchedulerWork() = runTest { + StickerCatalogMemory.clear() + SavedGifMemory.clear() val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { resolveIsBot = false } + val client = FakeClient() + val meta = FakeMeta() + val diskGif = org.monogram.core.models.SavedGif( + documentId = 5L, + cacheKey = "gif:5", + thumbCacheKey = "gif:5:thumb", + ) + val pack = StickerPack( + id = 9L, + title = "Cats", + shortName = "cats", + count = 1, + isEmoji = false, + previewDocumentIds = listOf(42L), + accessHash = 11L, + ) + meta.values["picker.stickers"] = PickerDisk.encodeCatalog( + StickerCatalogSnapshot(7L, listOf(pack)), + ) + meta.values["picker.gifs"] = PickerDisk.encodeGifs(listOf(diskGif)) val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, + DefaultStoreFactory(), client, warmup = null, sessionStore = meta, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { - advanceUntilIdle() - assertTrue(store.state.isGroup) - store.accept(DialogStore.Intent.DraftChanged("@ada")) - advanceTimeBy(300) - advanceUntilIdle() - assertEquals("ada", client.lastMembersQuery) - assertEquals("ada", client.lastContactsQuery) - assertEquals("ada", client.lastResolve) - assertEquals(listOf(7L, 11L, 99L), store.state.mentionCandidates.map { it.peerId.value }) - assertEquals(null, store.state.inlineQuery) + assertEquals(listOf(9L), store.state.stickerSets.map { it.id }) + assertEquals(true, store.state.stickerSetsLoaded) + assertEquals(5L, store.state.savedGifs.single().documentId) + assertEquals(true, store.state.savedGifsLoaded) + assertEquals(0, client.stickerCatalogCalls) + assertEquals(0, client.savedGifCalls) } finally { store.dispose() + StickerCatalogMemory.clear() + SavedGifMemory.clear() } } @Test - fun selectingUsernameMentionInsertsHandle() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { resolveIsBot = false } + fun pickerCatalogsHydrateFromDiskOnStoreStart() = runTest { + StickerCatalogMemory.clear() + SavedGifMemory.clear() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() + val meta = FakeMeta() + val diskGif = org.monogram.core.models.SavedGif( + documentId = 5L, + cacheKey = "gif:5", + thumbCacheKey = "gif:5:thumb", + ) + client.savedGifs = listOf(diskGif) + val pack = StickerPack( + id = 9L, + title = "Cats", + shortName = "cats", + count = 1, + isEmoji = false, + previewDocumentIds = listOf(42L), + accessHash = 11L, + ) + meta.values["picker.stickers"] = PickerDisk.encodeCatalog( + StickerCatalogSnapshot(7L, listOf(pack)), + ) + meta.values["picker.gifs"] = PickerDisk.encodeGifs(listOf(diskGif)) val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, + DefaultStoreFactory(), client, warmup = null, sessionStore = meta, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + assertEquals(listOf(9L), store.state.stickerSets.map { it.id }) + assertEquals(true, store.state.stickerSetsLoaded) + assertEquals(5L, store.state.savedGifs.single().documentId) + assertEquals(true, store.state.savedGifsLoaded) advanceUntilIdle() - store.accept(DialogStore.Intent.DraftChanged("@ad")) - advanceTimeBy(300) + assertEquals(1, client.stickerCatalogCalls) + assertEquals(7L, client.lastStickerHash) + assertEquals(1, client.savedGifCalls) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) + store.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_GIFS)) advanceUntilIdle() - val ada = store.state.mentionCandidates.first { it.username == "ada" } - store.accept(DialogStore.Intent.SelectMention(ada)) - assertEquals("@ada ", store.state.draft) - assertTrue(store.state.draftMentions.isEmpty()) + assertEquals(1, client.stickerCatalogCalls) + assertEquals(1, client.savedGifCalls) } finally { store.dispose() + StickerCatalogMemory.clear() + SavedGifMemory.clear() } } @Test - fun selectingNamelessMentionSendsMentionNameEntity() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient().apply { resolveIsBot = false } - val store = DialogStoreFactory( + fun stickerCatalogUsesProcessCacheOnSecondOpen() = runTest { + StickerCatalogMemory.clear() + StickerPackMemory.clear() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() + val first = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + first.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) advanceUntilIdle() - store.accept(DialogStore.Intent.DraftChanged("@No")) - advanceTimeBy(300) - advanceUntilIdle() - val member = store.state.mentionCandidates.first { it.username == null } - store.accept(DialogStore.Intent.SelectMention(member)) - assertEquals("No Handle ", store.state.draft) - store.accept(DialogStore.Intent.Send()) + assertEquals(1, client.stickerCatalogCalls) + assertEquals(0L, client.lastStickerHash) + assertEquals(listOf(9L), first.state.stickerSets.map { it.id }) + first.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - val entities = org.monogram.core.models.TextEntities.parse(client.lastSendEntities) - assertEquals("mention_name", entities.single().kind) - assertEquals("8", entities.single().url) - assertEquals(0, entities.single().offset) - assertEquals(9, entities.single().length) + assertEquals(1, client.stickerSetCalls) + assertEquals(listOf(42L), first.state.loadedStickerPacks[9L]?.previewDocumentIds) } finally { - store.dispose() + first.dispose() } - } - - @Test - fun inlineQueryClearsWhenDraftLeavesAtMention() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val client = FakeClient() - val store = DialogStoreFactory( + val second = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, + chatId = PeerId(6), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { - store.accept(DialogStore.Intent.DraftChanged("@gif cats")) - advanceTimeBy(300) + assertEquals(listOf(9L), second.state.stickerSets.map { it.id }) + assertEquals(listOf(42L), second.state.loadedStickerPacks[9L]?.previewDocumentIds) + second.accept(DialogStore.Intent.SetEmojiTab(ComposerPanels.TAB_STICKERS)) + second.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertEquals("gif-1", store.state.inlineResults?.results?.single()?.id) - store.accept(DialogStore.Intent.DraftChanged("hello")) - testScheduler.runCurrent() - assertEquals(null, store.state.inlineQuery) - assertEquals(null, store.state.inlineResults) - assertEquals(false, store.state.inlineLoading) - } finally { - store.dispose() + assertEquals(1, client.stickerCatalogCalls) + assertEquals(1, client.stickerSetCalls) + } finally { + second.dispose() } } @Test - fun switchInlineSamePeerBuildsBotMentionDraft() = runTest { + fun diskStickerPackStillFetchesNativeLocationsOnce() = runTest { + StickerCatalogMemory.clear() + StickerPackMemory.clear() val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { - profileUsername = "samplebot" - history = listOf(topicMessage(4).copy(senderId = PeerId(99))) - } + val client = FakeClient() + val meta = FakeMeta() + val catalog = PickerDisk.encodeCatalog( + StickerCatalogSnapshot(0L, listOf(client.loadedPack)), + ) + meta.values["picker.pack.9"] = catalog.substringAfter("\"sets\":[").substringBeforeLast("]") val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, + DefaultStoreFactory(), client, warmup = null, sessionStore = meta, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { - store.accept( - DialogStore.Intent.BotButton( - messageId = 4, - button = org.monogram.core.models.ReplyButton( - org.monogram.core.models.ReplyButtonType.SwitchInline, - "Search", - query = "cats", - samePeer = true, - ), - fromKeyboard = false, - ), - ) + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertEquals("@samplebot cats", store.state.draft) + assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) + assertEquals(1, client.stickerSetCalls) + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) + advanceUntilIdle() + assertEquals(1, client.stickerSetCalls) } finally { store.dispose() + StickerPackMemory.clear() } } @Test - fun failedOlderPageKeepsPaginationRetryable() = runTest { + fun openedStickerPackTracksLoadingAndLoadsEmojiOrStickers() = runTest { + StickerCatalogMemory.clear() + StickerPackMemory.clear() val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { - history = (10..109).reversed().map { topicMessage(it) } - historyPage = (1..9).reversed().map { topicMessage(it) } - historyPageError = "offline" - } + val gate = CompletableDeferred() + val client = FakeClient().apply { stickerSetGate = gate } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertTrue(store.state.hasOlder) - store.accept(DialogStore.Intent.LoadOlder) - advanceUntilIdle() - assertTrue("a transient page failure must remain retryable", store.state.hasOlder) - client.historyPageError = null - store.accept(DialogStore.Intent.LoadOlder) + // While the pack is in flight the menu has to show a placeholder, not an empty grid. + assertTrue(9L in store.state.loadingStickerPackIds) + assertTrue(store.state.failedStickerPackIds.isEmpty()) + + gate.complete(Unit) advanceUntilIdle() - assertTrue(store.state.messages.any { it.id.id == 1 }) + assertFalse(9L in store.state.loadingStickerPackIds) + assertTrue(store.state.failedStickerPackIds.isEmpty()) + assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) } finally { store.dispose() } } @Test - fun failedNewerPageKeepsPaginationRetryable() = runTest { + fun failedStickerPackLeavesThePlaceholderAndOffersRetry() = runTest { + StickerCatalogMemory.clear() + StickerPackMemory.clear() val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { - history = (1..80).reversed().map { topicMessage(it) } - historyPage = (81..120).reversed().map { topicMessage(it) } - } + val client = FakeClient().apply { stickerSetError = "STICKER_SET_INVALID" } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - store.accept(DialogStore.Intent.JumpToDate(42)) - advanceUntilIdle() - assertTrue(store.state.hasNewer) - client.historyPageError = "offline" - store.accept(DialogStore.Intent.LoadNewer) - advanceUntilIdle() - assertTrue("a transient page failure must remain retryable", store.state.hasNewer) - client.historyPageError = null - store.accept(DialogStore.Intent.LoadNewer) + assertFalse(9L in store.state.loadingStickerPackIds) + // A failed pack must stop shimmering forever and become retryable. + assertTrue(9L in store.state.failedStickerPackIds) + assertFalse(store.state.loadedStickerPacks.containsKey(9L)) + + // Retrying the same pack clears the failure and loads it. + client.stickerSetError = null + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) advanceUntilIdle() - assertTrue(store.state.messages.any { it.id.id == 120 }) + assertTrue(store.state.failedStickerPackIds.isEmpty()) + assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) } finally { store.dispose() } } @Test - fun inWindowJumpCancelsObsoleteJumpLoading() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val obsoleteGate = CompletableDeferred() - val client = FakeClient().apply { - history = (161..240).reversed().map { topicMessage(it) } - historyPage = (1..40).reversed().map { topicMessage(it) } - historyPageGate = obsoleteGate - } + fun inlineGifQueryResolvesDebouncesAndSends() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient() val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.DraftChanged("@gif cats")) + testScheduler.runCurrent() + assertEquals(null, client.lastInlineQuery) + assertEquals(null, store.state.inlineQuery) + assertEquals(false, store.state.inlineLoading) + advanceTimeBy(300) advanceUntilIdle() - store.accept(DialogStore.Intent.JumpToMessage(999)) - advanceUntilIdle() - assertTrue(store.state.loading) - val visibleId = store.state.messages.last().id.id - store.accept(DialogStore.Intent.JumpToMessage(visibleId)) - advanceUntilIdle() - assertEquals(visibleId, store.state.anchorMessageId) - assertFalse(store.state.loading) - assertFalse(store.state.loadingOlder) - assertFalse(store.state.loadingNewer) - obsoleteGate.complete(Unit) + assertEquals("gif", client.lastResolve) + assertEquals("cats", client.lastInlineQuery) + assertEquals(true, store.state.inlineResults?.gallery) + assertEquals("gif-1", store.state.inlineResults?.results?.single()?.id) + store.accept(DialogStore.Intent.SendInlineResult("gif-1")) advanceUntilIdle() - assertFalse(store.state.loading) - assertTrue(store.state.messages.all { it.id.id in 161..240 }) + assertEquals("gif-1", client.lastInlineSend) + assertEquals("", store.state.draft) + assertEquals(null, store.state.inlineQuery) } finally { store.dispose() } } @Test - fun networkRefreshPublishesFormattingAndAlbumChangesWithUnchangedText() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val gate = CompletableDeferred() - val client = FakeClient() - val cached = client.topicMessage(44).copy(text = "same text") - val updated = cached.copy( - groupedId = 9L, - entities = listOf(TextEntity("bold", 0, 4)), - ) - client.history = listOf(updated) - client.historyGate = gate - val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } + fun inlinePicGallerySendsWithoutDocumentId() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { inlineGalleryKind = "photo" } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.DraftChanged("@pic hi")) + advanceTimeBy(300) advanceUntilIdle() - assertEquals(cached.entities, store.state.messages.single().entities) - gate.complete(Unit) + assertEquals("pic", client.lastResolve) + assertEquals("photo", store.state.inlineResults?.results?.single()?.kind) + assertEquals("photo:88", store.state.inlineResults?.results?.single()?.thumbCacheKey) + store.accept(DialogStore.Intent.SendInlineResult("p1")) advanceUntilIdle() - assertEquals(updated.entities, store.state.messages.single().entities) - assertEquals(updated.groupedId, store.state.messages.single().groupedId) + assertEquals("p1", client.lastInlineSend) + assertEquals("", store.state.draft) } finally { store.dispose() } } @Test - fun recreatedStoreDoesNotReuseThePreviousScrollRequestToken() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } - val factory = DialogStoreFactory( + fun inlineResultsLoadNextPageAndDeduplicateResults() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { inlinePaging = true } + val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, - ) - val first = factory.create() - val previousToken = try { - advanceUntilIdle() - first.accept(DialogStore.Intent.JumpToMessage(40)) - first.state.anchorRequestId.also { assertTrue(it != null) } - } finally { - first.dispose() - } - val recreated = factory.create() + ).create() try { + store.accept(DialogStore.Intent.DraftChanged("@gif cats")) + advanceTimeBy(300) advanceUntilIdle() - recreated.accept(DialogStore.Intent.JumpToMessage(40)) - assertEquals(40, recreated.state.anchorMessageId) - assertTrue(recreated.state.anchorRequestId != null) - assertFalse(previousToken == recreated.state.anchorRequestId) + assertEquals(listOf(""), client.inlineOffsets) + assertEquals(listOf("gif-1"), store.state.inlineResults?.results?.map { it.id }) + assertEquals("more", store.state.inlineResults?.nextOffset) + + store.accept(DialogStore.Intent.LoadMoreInlineResults) + advanceUntilIdle() + assertEquals(listOf("", "more"), client.inlineOffsets) + assertEquals(listOf("gif-1", "gif-2"), store.state.inlineResults?.results?.map { it.id }) + assertEquals(null, store.state.inlineResults?.nextOffset) } finally { - recreated.dispose() + store.dispose() } } @Test - fun repeatedJumpToSameMessagePublishesAnotherScrollRequest() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } + fun inlineEmptyPageWithOffsetStopsAfterOneRetry() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { inlineEmptyPage = true } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.DraftChanged("@gif cats")) + advanceTimeBy(300) advanceUntilIdle() - val id = store.state.messages.first().id.id - store.accept(DialogStore.Intent.JumpToMessage(id)) + assertEquals(listOf(""), client.inlineOffsets) + assertEquals(true, store.state.inlineResults?.results?.isEmpty()) + assertEquals("more", store.state.inlineResults?.nextOffset) + + store.accept(DialogStore.Intent.LoadMoreInlineResults) advanceUntilIdle() - val previous = store.state - store.accept(DialogStore.Intent.JumpToMessage(id)) + assertEquals(listOf("", "more"), client.inlineOffsets) + assertEquals(null, store.state.inlineResults?.nextOffset) + + store.accept(DialogStore.Intent.LoadMoreInlineResults) advanceUntilIdle() - assertEquals(id, store.state.anchorMessageId) - assertEquals(previous.messages, store.state.messages) - assertFalse( - "repeated navigation must survive StateFlow equality", - previous == store.state - ) + assertEquals(listOf("", "more"), client.inlineOffsets) } finally { store.dispose() } } @Test - fun dateJumpRejectsLateNewerCacheRowsAndEndMarker() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val gate = CompletableDeferred() - val client = FakeClient().apply { - history = (1..80).reversed().map { topicMessage(it) } - jumpPage = (101..140).reversed().map { topicMessage(it) } - } - val warmup = FakeWarmup().apply { - newerSliceGate = gate - newerSliceRows = (141..180).reversed().map(client::topicMessage) - } + fun atBotWithoutSpaceOpensInlineAfterConfirm() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient() val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept(DialogStore.Intent.DraftChanged("@pic")) + testScheduler.runCurrent() + assertEquals(null, store.state.inlineQuery) + assertEquals(null, store.state.mentionToken) + advanceTimeBy(300) advanceUntilIdle() - store.accept(DialogStore.Intent.JumpToDate(42)) - advanceUntilIdle() - store.accept(DialogStore.Intent.LoadNewer) - advanceUntilIdle() - assertTrue(store.state.loadingNewer) - client.jumpPage = (401..440).reversed().map(client::topicMessage) - store.accept(DialogStore.Intent.JumpToDate(43)) - advanceUntilIdle() - gate.complete(Unit) + assertEquals("pic", client.lastResolve) + assertEquals("", client.lastInlineQuery) + assertEquals("pic", store.state.inlineQuery?.username) + assertEquals(null, store.state.mentionToken) + assertEquals(false, store.state.inlineError) + } finally { + store.dispose() + } + } + + @Test + fun nonBotAtQueryNeverOpensInlineOverlay() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { resolveIsBot = false } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + store.accept(DialogStore.Intent.DraftChanged("@user query")) + advanceTimeBy(300) advanceUntilIdle() - assertTrue(store.state.messages.all { it.id.id in 401..440 }) - assertTrue(store.state.hasNewer) - assertFalse(store.state.loadingNewer) + assertEquals("user", client.lastResolve) + assertEquals(null, client.lastInlineQuery) + assertEquals(null, store.state.inlineQuery) + assertEquals(false, store.state.inlineLoading) + assertEquals(false, store.state.inlineError) } finally { store.dispose() } } @Test - fun dateJumpInvalidatesInFlightNewerRequest() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val newerGate = CompletableDeferred() - val client = FakeClient().apply { - history = (1..80).reversed().map { topicMessage(it) } - historyPage = (401..440).reversed().map { topicMessage(it) } - historyPageGate = newerGate + fun mentionMenuLoadsMembersAndGlobalHandle() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { resolveIsBot = false } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + assertTrue(store.state.isGroup) + store.accept(DialogStore.Intent.DraftChanged("@ada")) + advanceTimeBy(300) + advanceUntilIdle() + assertEquals("ada", client.lastMembersQuery) + assertEquals("ada", client.lastContactsQuery) + assertEquals("ada", client.lastResolve) + assertEquals(listOf(7L, 11L, 99L), store.state.mentionCandidates.map { it.peerId.value }) + assertEquals(null, store.state.inlineQuery) + } finally { + store.dispose() } + } + + @Test + fun selectingUsernameMentionInsertsHandle() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { resolveIsBot = false } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, chatId = PeerId(5), seedIsForum = false, @@ -1970,31 +2321,77 @@ class DialogForumStoreTest { ).create() try { advanceUntilIdle() - store.accept(DialogStore.Intent.JumpToDate(42)) + store.accept(DialogStore.Intent.DraftChanged("@ad")) + advanceTimeBy(300) advanceUntilIdle() - store.accept(DialogStore.Intent.LoadNewer) + val ada = store.state.mentionCandidates.first { it.username == "ada" } + store.accept(DialogStore.Intent.SelectMention(ada)) + assertEquals("@ada ", store.state.draft) + assertTrue(store.state.draftMentions.isEmpty()) + } finally { + store.dispose() + } + } + + @Test + fun selectingNamelessMentionSendsMentionNameEntity() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient().apply { resolveIsBot = false } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { advanceUntilIdle() - assertTrue(store.state.loadingNewer) - store.accept(DialogStore.Intent.JumpToDate(43)) + store.accept(DialogStore.Intent.DraftChanged("@No")) + advanceTimeBy(300) advanceUntilIdle() - newerGate.complete(Unit) + val member = store.state.mentionCandidates.first { it.username == null } + store.accept(DialogStore.Intent.SelectMention(member)) + assertEquals("No Handle ", store.state.draft) + store.accept(DialogStore.Intent.Send()) advanceUntilIdle() - assertFalse(store.state.loadingNewer) - assertEquals(401, store.state.messages.minOf { it.id.id }) + val entities = org.monogram.core.models.TextEntities.parse(client.lastSendEntities) + assertEquals("mention_name", entities.single().kind) + assertEquals("8", entities.single().url) + assertEquals(0, entities.single().offset) + assertEquals(9, entities.single().length) } finally { store.dispose() } } @Test - fun dateJumpInvalidatesOlderRequestAndContinuationCursor() = runTest { + fun inlineQueryClearsWhenDraftLeavesAtMention() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + store.accept(DialogStore.Intent.DraftChanged("@gif cats")) + advanceTimeBy(300) + advanceUntilIdle() + assertEquals("gif-1", store.state.inlineResults?.results?.single()?.id) + store.accept(DialogStore.Intent.DraftChanged("hello")) + testScheduler.runCurrent() + assertEquals(null, store.state.inlineQuery) + assertEquals(null, store.state.inlineResults) + assertEquals(false, store.state.inlineLoading) + } finally { + store.dispose() + } + } + + @Test + fun switchInlineSamePeerBuildsBotMentionDraft() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val olderGate = CompletableDeferred() val client = FakeClient().apply { - history = (101..180).reversed().map { topicMessage(it) } - historyPage = (61..100).reversed().map { topicMessage(it) } - historyPageGate = olderGate - jumpPage = (401..440).reversed().map { topicMessage(it) } + profileUsername = "samplebot" + history = listOf(topicMessage(4).copy(senderId = PeerId(99))) } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, @@ -2002,31 +2399,32 @@ class DialogForumStoreTest { mainContext = dispatcher, markupContext = dispatcher, ).create() try { + store.accept( + DialogStore.Intent.BotButton( + messageId = 4, + button = org.monogram.core.models.ReplyButton( + org.monogram.core.models.ReplyButtonType.SwitchInline, + "Search", + query = "cats", + samePeer = true, + ), + fromKeyboard = false, + ), + ) advanceUntilIdle() - store.accept(DialogStore.Intent.LoadOlder) - advanceUntilIdle() - store.accept(DialogStore.Intent.JumpToDate(42)) - advanceUntilIdle() - assertEquals(401, store.state.messages.minOf { it.id.id }) - olderGate.complete(Unit) - advanceUntilIdle() - assertEquals(401, store.state.messages.minOf { it.id.id }) - store.accept(DialogStore.Intent.LoadOlder) - advanceUntilIdle() - assertEquals(401, client.lastHistoryOffsetId) + assertEquals("@samplebot cats", store.state.draft) } finally { store.dispose() } } @Test - fun refreshInvalidatesInFlightPaginationOwners() = runTest { + fun failedOlderPageKeepsPaginationRetryable() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val olderGate = CompletableDeferred() val client = FakeClient().apply { - history = (101..180).reversed().map { topicMessage(it) } - historyPage = (61..100).reversed().map { topicMessage(it) } - historyPageGate = olderGate + history = (10..109).reversed().map { topicMessage(it) } + historyPage = (1..9).reversed().map { topicMessage(it) } + historyPageError = "offline" } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, @@ -2035,29 +2433,25 @@ class DialogForumStoreTest { ).create() try { advanceUntilIdle() + assertTrue(store.state.hasOlder) store.accept(DialogStore.Intent.LoadOlder) advanceUntilIdle() - assertTrue(store.state.loadingOlder) - store.accept(DialogStore.Intent.Refresh) - advanceUntilIdle() - assertFalse(store.state.loadingOlder) - olderGate.complete(Unit) + assertTrue("a transient page failure must remain retryable", store.state.hasOlder) + client.historyPageError = null + store.accept(DialogStore.Intent.LoadOlder) advanceUntilIdle() - assertFalse(store.state.loadingOlder) + assertTrue(store.state.messages.any { it.id.id == 1 }) } finally { store.dispose() } } @Test - fun searchInvalidatesInFlightHistoryPagination() = runTest { + fun failedNewerPageKeepsPaginationRetryable() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val olderGate = CompletableDeferred() val client = FakeClient().apply { - history = (101..180).reversed().map { topicMessage(it) } - historyPage = (61..100).reversed().map { topicMessage(it) } - historyPageGate = olderGate - searchResults = listOf(topicMessage(999)) + history = (1..80).reversed().map { topicMessage(it) } + historyPage = (81..120).reversed().map { topicMessage(it) } } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, @@ -2066,28 +2460,30 @@ class DialogForumStoreTest { ).create() try { advanceUntilIdle() - store.accept(DialogStore.Intent.LoadOlder) + store.accept(DialogStore.Intent.JumpToDate(42)) advanceUntilIdle() - store.accept(DialogStore.Intent.Search("needle")) - advanceTimeBy(500) + assertTrue(store.state.hasNewer) + client.historyPageError = "offline" + store.accept(DialogStore.Intent.LoadNewer) advanceUntilIdle() - assertEquals(listOf(999), store.state.messages.map { it.id.id }) - olderGate.complete(Unit) + assertTrue("a transient page failure must remain retryable", store.state.hasNewer) + client.historyPageError = null + store.accept(DialogStore.Intent.LoadNewer) advanceUntilIdle() - assertEquals(listOf(999), store.state.messages.map { it.id.id }) + assertTrue(store.state.messages.any { it.id.id == 120 }) } finally { store.dispose() } } @Test - fun clearingSearchCancelsBusyState() = runTest { + fun inWindowJumpCancelsObsoleteJumpLoading() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val searchGate = CompletableDeferred() + val obsoleteGate = CompletableDeferred() val client = FakeClient().apply { - history = (101..180).reversed().map { topicMessage(it) } - this.searchGate = searchGate - searchResults = listOf(topicMessage(999)) + history = (161..240).reversed().map { topicMessage(it) } + historyPage = (1..40).reversed().map { topicMessage(it) } + historyPageGate = obsoleteGate } val store = DialogStoreFactory( DefaultStoreFactory(), client, warmup = null, sessionStore = null, @@ -2096,177 +2492,1107 @@ class DialogForumStoreTest { ).create() try { advanceUntilIdle() - store.accept(DialogStore.Intent.Search("needle")) - advanceTimeBy(500) + store.accept(DialogStore.Intent.JumpToMessage(999)) advanceUntilIdle() - assertTrue(store.state.searching) - store.accept(DialogStore.Intent.Search("")) + assertTrue(store.state.loading) + val visibleId = store.state.messages.last().id.id + store.accept(DialogStore.Intent.JumpToMessage(visibleId)) advanceUntilIdle() - assertFalse(store.state.searching) - searchGate.complete(Unit) + assertEquals(visibleId, store.state.anchorMessageId) + assertFalse(store.state.loading) + assertFalse(store.state.loadingOlder) + assertFalse(store.state.loadingNewer) + obsoleteGate.complete(Unit) advanceUntilIdle() - assertFalse(store.state.searching) + assertFalse(store.state.loading) + assertTrue(store.state.messages.all { it.id.id in 161..240 }) } finally { store.dispose() } } @Test - fun jumpLatestCancelsSearchBusyState() = runTest { + fun networkRefreshPublishesFormattingAndAlbumChangesWithUnchangedText() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient() + val cached = client.topicMessage(44).copy(text = "same text") + val updated = cached.copy( + groupedId = 9L, + entities = listOf(TextEntity("bold", 0, 4)), + ) + client.history = listOf(updated) + client.historyGate = gate + val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + assertEquals(cached.entities, store.state.messages.single().entities) + gate.complete(Unit) + advanceUntilIdle() + assertEquals(updated.entities, store.state.messages.single().entities) + assertEquals(updated.groupedId, store.state.messages.single().groupedId) + } finally { + store.dispose() + } + } + + @Test + fun recreatedStoreDoesNotReuseThePreviousScrollRequestToken() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } + val factory = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ) + val first = factory.create() + val previousToken = try { + advanceUntilIdle() + first.accept(DialogStore.Intent.JumpToMessage(40)) + first.state.anchorRequestId.also { assertTrue(it != null) } + } finally { + first.dispose() + } + val recreated = factory.create() + try { + advanceUntilIdle() + recreated.accept(DialogStore.Intent.JumpToMessage(40)) + assertEquals(40, recreated.state.anchorMessageId) + assertTrue(recreated.state.anchorRequestId != null) + assertFalse(previousToken == recreated.state.anchorRequestId) + } finally { + recreated.dispose() + } + } + + @Test + fun repeatedJumpToSameMessagePublishesAnotherScrollRequest() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient().apply { history = listOf(topicMessage(40), topicMessage(39)) } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + val id = store.state.messages.first().id.id + store.accept(DialogStore.Intent.JumpToMessage(id)) + advanceUntilIdle() + val previous = store.state + store.accept(DialogStore.Intent.JumpToMessage(id)) + advanceUntilIdle() + assertEquals(id, store.state.anchorMessageId) + assertEquals(previous.messages, store.state.messages) + assertFalse( + "repeated navigation must survive StateFlow equality", + previous == store.state + ) + } finally { + store.dispose() + } + } + + @Test + fun dateJumpRejectsLateNewerCacheRowsAndEndMarker() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient().apply { + history = (1..80).reversed().map { topicMessage(it) } + jumpPage = (101..140).reversed().map { topicMessage(it) } + } + val warmup = FakeWarmup().apply { + newerSliceGate = gate + newerSliceRows = (141..180).reversed().map(client::topicMessage) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.JumpToDate(42)) + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadNewer) + advanceUntilIdle() + assertTrue(store.state.loadingNewer) + client.jumpPage = (401..440).reversed().map(client::topicMessage) + store.accept(DialogStore.Intent.JumpToDate(43)) + advanceUntilIdle() + gate.complete(Unit) + advanceUntilIdle() + assertTrue(store.state.messages.all { it.id.id in 401..440 }) + assertTrue(store.state.hasNewer) + assertFalse(store.state.loadingNewer) + } finally { + store.dispose() + } + } + + @Test + fun dateJumpInvalidatesInFlightNewerRequest() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val newerGate = CompletableDeferred() + val client = FakeClient().apply { + history = (1..80).reversed().map { topicMessage(it) } + historyPage = (401..440).reversed().map { topicMessage(it) } + historyPageGate = newerGate + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.JumpToDate(42)) + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadNewer) + advanceUntilIdle() + assertTrue(store.state.loadingNewer) + store.accept(DialogStore.Intent.JumpToDate(43)) + advanceUntilIdle() + newerGate.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.loadingNewer) + assertEquals(401, store.state.messages.minOf { it.id.id }) + } finally { + store.dispose() + } + } + + @Test + fun dateJumpInvalidatesOlderRequestAndContinuationCursor() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val olderGate = CompletableDeferred() + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + historyPage = (61..100).reversed().map { topicMessage(it) } + historyPageGate = olderGate + jumpPage = (401..440).reversed().map { topicMessage(it) } + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + store.accept(DialogStore.Intent.JumpToDate(42)) + advanceUntilIdle() + assertEquals(401, store.state.messages.minOf { it.id.id }) + olderGate.complete(Unit) + advanceUntilIdle() + assertEquals(401, store.state.messages.minOf { it.id.id }) + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertEquals(401, client.lastHistoryOffsetId) + } finally { + store.dispose() + } + } + + @Test + fun refreshInvalidatesInFlightPaginationOwners() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val olderGate = CompletableDeferred() + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + historyPage = (61..100).reversed().map { topicMessage(it) } + historyPageGate = olderGate + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + assertTrue(store.state.loadingOlder) + store.accept(DialogStore.Intent.Refresh) + advanceUntilIdle() + assertFalse(store.state.loadingOlder) + olderGate.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.loadingOlder) + } finally { + store.dispose() + } + } + + @Test + fun searchInvalidatesInFlightHistoryPagination() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val olderGate = CompletableDeferred() + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + historyPage = (61..100).reversed().map { topicMessage(it) } + historyPageGate = olderGate + searchResults = listOf(topicMessage(999)) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.LoadOlder) + advanceUntilIdle() + store.accept(DialogStore.Intent.Search("needle")) + advanceTimeBy(500) + advanceUntilIdle() + assertEquals(listOf(999), store.state.messages.map { it.id.id }) + olderGate.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(999), store.state.messages.map { it.id.id }) + } finally { + store.dispose() + } + } + + @Test + fun clearingSearchCancelsBusyState() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val searchGate = CompletableDeferred() + val client = FakeClient().apply { + history = (101..180).reversed().map { topicMessage(it) } + this.searchGate = searchGate + searchResults = listOf(topicMessage(999)) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.Search("needle")) + advanceTimeBy(500) + advanceUntilIdle() + assertTrue(store.state.searching) + store.accept(DialogStore.Intent.Search("")) + advanceUntilIdle() + assertFalse(store.state.searching) + searchGate.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.searching) + } finally { + store.dispose() + } + } + + @Test + fun jumpLatestCancelsSearchBusyState() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val searchGate = CompletableDeferred() + val client = FakeClient().apply { this.searchGate = searchGate } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.Search("needle")) + advanceTimeBy(500) + advanceUntilIdle() + assertTrue(store.state.searching) + store.accept(DialogStore.Intent.JumpLatest) + advanceUntilIdle() + assertFalse(store.state.searching) + searchGate.complete(Unit) + advanceUntilIdle() + assertFalse(store.state.searching) + } finally { + store.dispose() + } + } + + @Test + fun cachedHistoryPaintsBeforeNetwork() = runBlocking { + Dispatchers.setMain(Dispatchers.Unconfined) + val cached = Message( + id = MessageId(PeerId(5), 4), + senderId = PeerId(1), + text = "cached", + date = 1L, + outgoing = false, + ) + val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } + val gate = CompletableDeferred() + val client = FakeClient().apply { + historyGate = gate + history = listOf(cached.copy(text = "network")) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, + ).create() + try { + assertEquals("cached", store.state.messages.single().text) + assertTrue(store.state.fromCache) + assertFalse(store.state.loading) + } finally { + gate.complete(Unit) + store.dispose() + Dispatchers.resetMain() + } + } + + @Test + fun cachedHistoryStaysWhenNetworkFails() = runBlocking { + Dispatchers.setMain(Dispatchers.Unconfined) + val cached = Message( + id = MessageId(PeerId(5), 4), + senderId = PeerId(1), + text = "cached", + date = 1L, + outgoing = false, + ) + val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } + val client = FakeClient().apply { historyError = "offline" } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, + ).create() + try { + assertEquals("cached", store.state.messages.single().text) + assertTrue(store.state.fromCache) + } finally { + store.dispose() + } + } + + @Test + fun draftSurvivesStoreRecreate() = runBlocking { + Dispatchers.setMain(Dispatchers.Unconfined) + val warmup = FakeWarmup() + val first = DialogStoreFactory( + DefaultStoreFactory(), FakeClient(), warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, + ).create() + try { + first.accept(DialogStore.Intent.DraftChanged("hello")) + assertEquals("hello", warmup.draft(PeerId(5))) + } finally { + first.dispose() + } + val second = DialogStoreFactory( + DefaultStoreFactory(), FakeClient(), warmup, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, + ).create() + try { + assertEquals("hello", second.state.draft) + } finally { + second.dispose() + } + } + + @Test + fun sendUsesComposerTextEvenIfDraftStale() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + store.accept(DialogStore.Intent.DraftChanged("old")) + store.accept(DialogStore.Intent.Send("hello")) + advanceUntilIdle() + assertEquals("hello", client.lastSendText) + assertEquals("", store.state.draft) + assertFalse(store.state.sending) + } finally { + store.dispose() + } + } + + @Test + fun overlappingTextSendsStayPendingUntilEachRpcFinishes() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient().apply { sendGate = gate } + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = null, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + store.accept(DialogStore.Intent.Send("one")) + store.accept(DialogStore.Intent.Send("two")) + assertEquals(listOf("one", "two"), client.sentTexts) + assertEquals(2, store.state.messages.count { it.outgoing && it.pending }) + assertFalse(store.state.sending) + assertEquals("", store.state.draft) + gate.complete(Unit) + advanceUntilIdle() + assertEquals( + setOf("one", "two"), + store.state.messages.filter { it.outgoing }.map { it.text }.toSet(), + ) + assertTrue(store.state.messages.none { it.pending }) + } finally { + store.dispose() + } + } + + @Test + fun mediaOptionsPreserveNewerComposerSelectionWhileAdmittingOriginalAlbum() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val cache = FakeWarmup( + org.monogram.core.database.OutgoingMediaFiles( + java.io.File( + System.getProperty("java.io.tmpdir"), "monogram-media-fixture-unused" + ) + ) + ).apply { enqueueGate = gate } + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + val first = org.monogram.core.models.UploadItem("synthetic-one", "photo") + val second = org.monogram.core.models.UploadItem("synthetic-two", "video") + try { + store.accept(DialogStore.Intent.DraftChanged("original caption")) + store.accept(DialogStore.Intent.AttachMedia(listOf(first, second))) + val scheduleDate = (System.currentTimeMillis() / 1000 + 3600).toInt() + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions( + silent = true, + scheduleDate = scheduleDate + ) + ) + ) + assertTrue(cache.queuedMedia.value.isEmpty()) + assertEquals(2, store.state.pendingAttach.size) + store.accept(DialogStore.Intent.AttachMedia(listOf(second))) + store.accept(DialogStore.Intent.DraftChanged("new typing")) + gate.complete(Unit); advanceUntilIdle() + val send = cache.queuedMedia.value.single() + assertTrue(send.batch.silent && send.batch.album) + assertEquals(scheduleDate, send.batch.scheduleDate) + assertEquals(2, send.items.size) + assertEquals("original caption", send.items.first().message.text) + assertEquals(2, send.items.map { it.randomId }.toSet().size) + assertEquals(listOf(second), store.state.pendingAttach) + assertEquals("new typing", store.state.draft) + assertTrue(store.state.messages.none { it.pending }) + } finally { + gate.complete(Unit); store.dispose() + } + } + + @Test + fun identicalReselectionDuringMediaAdmissionIsNotConsumed() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val cache = FakeWarmup( + org.monogram.core.database.OutgoingMediaFiles( + java.io.File( + System.getProperty("java.io.tmpdir"), "monogram-media-fixture-unused" + ) + ) + ).apply { enqueueGate = gate } + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + val item = org.monogram.core.models.UploadItem("synthetic-photo", "photo") + try { + store.accept(DialogStore.Intent.AttachMedia(listOf(item))) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true) + ) + ) + store.accept(DialogStore.Intent.AttachMedia(listOf(item))) + gate.complete(Unit); advanceUntilIdle() + assertEquals(1, cache.queuedMedia.value.size) + assertEquals(listOf(item), store.state.pendingAttach) + } finally { + gate.complete(Unit); store.dispose() + } + } + + @Test + fun mediaOptionsConsumeOnlyAfterCommitAndStorageFailureKeepsTheSelection() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val cache = FakeWarmup( + org.monogram.core.database.OutgoingMediaFiles( + java.io.File( + System.getProperty("java.io.tmpdir"), "monogram-media-fixture-unused" + ) + ) + ) + val store = DialogStoreFactory( + DefaultStoreFactory(), + FakeClient(), + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + val item = org.monogram.core.models.UploadItem("synthetic-document", "document") + try { + store.accept(DialogStore.Intent.AttachMedia(listOf(item))) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true) + ) + ) + advanceUntilIdle() + assertTrue(store.state.pendingAttach.isEmpty()) + assertFalse(cache.queuedMedia.value.single().batch.album) + cache.enqueueFails = true + store.accept(DialogStore.Intent.AttachMedia(listOf(item))) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true) + ) + ) + advanceUntilIdle() + assertEquals(listOf(item), store.state.pendingAttach) + assertEquals(1, cache.queuedMedia.value.size) + assertFalse(store.state.sending) + } finally { + store.dispose() + } + } + + @Test + fun authoritativeTextReceiptDoesNotConsumeAnotherIdenticalLegacyPendingMessage() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val client = FakeClient().apply { sendGate = gate } + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + store.accept(DialogStore.Intent.Send("same text")); advanceUntilIdle() + val pending = store.state.messages.single { it.pending } + val receipt = Message( + MessageId(PeerId(5), 400), + senderId = null, + text = "same text", + date = 123, + outgoing = true + ) + client.events.emit( + MtprotoUpdate.OutgoingMessageAcknowledged( + 99L, + org.monogram.core.models.OutgoingMessageResult.Sent(receipt) + ) + ) + advanceUntilIdle() + assertTrue(store.state.messages.any { it.id == pending.id && it.pending }) + assertEquals(99L, store.state.messages.single { it.id == receipt.id }.randomId) + client.events.emit( + MtprotoUpdate.OutgoingMessageAcknowledged( + 100L, + org.monogram.core.models.OutgoingMessageResult.Scheduled( + org.monogram.core.models.ScheduledMessage( + receipt.copy(text = "queue only") + ) + ) + ) + ) + advanceUntilIdle() + assertEquals("same text", store.state.messages.single { it.id == receipt.id }.text) + } finally { + gate.complete(Unit); advanceUntilIdle(); store.dispose() + } + } + + @Test + fun scheduledTextPersistsBeforeDraftClearAndNeverEntersNormalPendingHistory() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val gate = CompletableDeferred() + val cache = FakeWarmup().apply { enqueueGate = gate } + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + store.accept(DialogStore.Intent.DraftChanged("first")) + val date = (System.currentTimeMillis() / 1000 + 3600).toInt() + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(scheduleDate = date) + ) + ) + assertEquals("first", store.state.draft) + assertTrue(cache.queuedText.value.isEmpty()) + assertTrue(store.state.messages.none { it.pending }) + store.accept(DialogStore.Intent.DraftChanged("new typing")) + gate.complete(Unit); advanceUntilIdle() + val row = cache.queuedText.value.single() + assertEquals("first", row.message.text) + assertEquals(date, row.scheduleDate) + assertTrue(row.randomId != 0L) + assertEquals(row.randomId, row.message.randomId) + assertEquals("new typing", store.state.draft) + assertEquals(null, client.lastSendText) + assertTrue(store.state.messages.none { it.pending }) + } finally { + gate.complete(Unit); store.dispose() + } + } + + @Test + fun diceUsesDurableAdmissionWhileScheduledEmojiRemainsText() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val cache = FakeWarmup() + val client = FakeClient() + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + store.accept(DialogStore.Intent.DraftChanged("⚽\uFE0F")) + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + val first = cache.queuedText.value.single() + assertEquals("dice", first.message.mediaKind) + assertEquals( + "⚽", + org.monogram.core.models.ServiceMedia.dice(first.message.fileName)?.emoticon + ) + assertEquals( + 0, + org.monogram.core.models.ServiceMedia.dice(first.message.fileName)?.value + ) + assertTrue(first.randomId != 0L) + assertEquals(first.randomId, first.message.randomId) + assertEquals(null, client.lastSendText) + assertEquals("", store.state.draft) + store.accept(DialogStore.Intent.DraftChanged("🎲")) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true) + ) + ) + advanceUntilIdle() + assertTrue(cache.queuedText.value.last().silent) + assertEquals("dice", cache.queuedText.value.last().message.mediaKind) + store.accept(DialogStore.Intent.DraftChanged("🎲")) + store.accept( + DialogStore.Intent.SendWithOptions( + null, org.monogram.core.models.MessageSendOptions( + scheduleDate = (System.currentTimeMillis() / 1000 + 120).toInt() + ) + ) + ) + advanceUntilIdle() + assertEquals(3, cache.queuedText.value.size) + assertEquals(null, cache.queuedText.value.last().message.mediaKind) + assertEquals("🎲", cache.queuedText.value.last().message.text) + } finally { + store.dispose() + } + } + + @Test + fun attachmentReplacementKeepsKindAlbumAndNewerCaptionWithoutImplicitRetry() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + for (kind in listOf("photo", "video", "document")) { + val client = FakeClient().apply { + profileKindOverride = "user" + history = listOf( + topicMessage(8).copy( + outgoing = true, text = "Server caption", + mediaKind = kind, mediaCacheKey = "old", groupedId = 12 + ) + ) + } + val store = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher + ).create() + try { + advanceUntilIdle() + store.accept(DialogStore.Intent.Edit(store.state.messages.single())) + val upload = org.monogram.core.models.UploadItem("synthetic-unused", kind) + store.accept(DialogStore.Intent.ReplaceAttachment(8, "wrong", upload)) + store.accept( + DialogStore.Intent.ReplaceAttachment( + 8, + "old", + upload.copy(kind = if (kind == "photo") "video" else "photo") + ) + ) + advanceUntilIdle() + assertEquals(0, client.replacementCalls) + val gate = CompletableDeferred() + client.replacementGate = gate + store.accept(DialogStore.Intent.ReplaceAttachment(8, "old", upload)) + runCurrent() + assertFalse(store.state.sending) + assertEquals(0, client.replacementCalls) + assertEquals(listOf(upload), store.state.pendingAttach) + store.accept(DialogStore.Intent.CancelEdit) + assertTrue(store.state.pendingAttach.isEmpty()) + assertEquals(0, client.replacementCalls) + store.accept(DialogStore.Intent.Edit(store.state.messages.single())) + store.accept(DialogStore.Intent.ReplaceAttachment(8, "old", upload)) + store.accept(DialogStore.Intent.Send()) + runCurrent() + assertTrue(store.state.sending) + store.accept(DialogStore.Intent.DraftChanged("Newer caption")) + store.accept(DialogStore.Intent.ReplaceAttachment(8, "old", upload)) + gate.complete(Unit) + advanceUntilIdle() + assertEquals(1, client.replacementCalls) + assertEquals("Newer caption", store.state.draft) + assertEquals("new", store.state.editing?.mediaCacheKey) + assertEquals("Server caption", store.state.messages.single().text) + assertEquals(12L, store.state.messages.single().groupedId) + assertEquals(kind, store.state.messages.single().mediaKind) + client.replacementGate = null + client.replacementFailure = true + store.accept(DialogStore.Intent.ReplaceAttachment(8, "new", upload)) + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals(2, client.replacementCalls) + assertEquals("Newer caption", store.state.draft) + assertEquals("new", store.state.editing?.mediaCacheKey) + assertFalse(store.state.sending) + assertNotNull(store.state.error) + advanceUntilIdle() + assertEquals(2, client.replacementCalls) + client.replacementFailure = false + client.replacementGate = CompletableDeferred() + store.accept(DialogStore.Intent.ReplaceAttachment(8, "new", upload)) + store.accept(DialogStore.Intent.Send()) + runCurrent() + store.accept(DialogStore.Intent.CancelEdit) + client.replacementGate!!.complete(Unit) + advanceUntilIdle() + assertNull(store.state.editing) + } finally { + store.dispose() + } + } + } + + @Test + fun replacementWithEmptyCaptionWaitsForSendAndFinishesEditing() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val searchGate = CompletableDeferred() - val client = FakeClient().apply { this.searchGate = searchGate } + val client = FakeClient().apply { + profileKindOverride = "user" + history = listOf( + topicMessage(8).copy( + outgoing = true, text = "", + mediaKind = "photo", mediaCacheKey = "old" + ) + ) + } val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { advanceUntilIdle() - store.accept(DialogStore.Intent.Search("needle")) - advanceTimeBy(500) - advanceUntilIdle() - assertTrue(store.state.searching) - store.accept(DialogStore.Intent.JumpLatest) + store.accept(DialogStore.Intent.Edit(store.state.messages.single())) + store.accept( + DialogStore.Intent.ReplaceAttachment( + 8, + "old", + org.monogram.core.models.UploadItem("synthetic-unused", "photo") + ) + ) advanceUntilIdle() - assertFalse(store.state.searching) - searchGate.complete(Unit) + assertEquals(0, client.replacementCalls) + assertFalse(store.state.sending) + store.accept(DialogStore.Intent.Send()) advanceUntilIdle() - assertFalse(store.state.searching) + assertEquals(1, client.replacementCalls) + assertEquals("new", store.state.messages.single().mediaCacheKey) + assertNull(store.state.editing) + assertTrue(store.state.pendingAttach.isEmpty()) } finally { store.dispose() } } @Test - fun cachedHistoryPaintsBeforeNetwork() = runBlocking { - Dispatchers.setMain(Dispatchers.Unconfined) - val cached = Message( - id = MessageId(PeerId(5), 4), - senderId = PeerId(1), - text = "cached", - date = 1L, - outgoing = false, + fun checklistTitleEditWaitsForAuthorityAndPreservesEditorAfterFailure() = runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val list = org.monogram.core.models.Checklist( + "Shop", + items = listOf(org.monogram.core.models.ChecklistItem(7, "Milk")) ) - val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } - val gate = CompletableDeferred() val client = FakeClient().apply { - historyGate = gate - history = listOf(cached.copy(text = "network")) - } - val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, - ).create() - try { - assertEquals("cached", store.state.messages.single().text) - assertTrue(store.state.fromCache) - assertFalse(store.state.loading) - } finally { - gate.complete(Unit) - store.dispose() - Dispatchers.resetMain() + profileKindOverride = "user" + history = listOf( + topicMessage(8).copy( + outgoing = true, + mediaKind = "todo", + text = null, + checklist = list + ) + ) } - } - @Test - fun cachedHistoryStaysWhenNetworkFails() = runBlocking { - Dispatchers.setMain(Dispatchers.Unconfined) - val cached = Message( - id = MessageId(PeerId(5), 4), - senderId = PeerId(1), - text = "cached", - date = 1L, - outgoing = false, - ) - val warmup = FakeWarmup().apply { storedMessages[5L] = listOf(cached) } - val client = FakeClient().apply { historyError = "offline" } - val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, + fun create(saved: ChecklistEditState? = null) = DialogStoreFactory( + DefaultStoreFactory(), + client, + warmup = null, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher, + initialChecklistEdit = saved ).create() - try { - assertEquals("cached", store.state.messages.single().text) - assertTrue(store.state.fromCache) - } finally { - store.dispose() - } - } - @Test - fun draftSurvivesStoreRecreate() = runBlocking { - Dispatchers.setMain(Dispatchers.Unconfined) - val warmup = FakeWarmup() - val first = DialogStoreFactory( - DefaultStoreFactory(), FakeClient(), warmup, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, - ).create() - try { - first.accept(DialogStore.Intent.DraftChanged("hello")) - assertEquals("hello", warmup.draft(PeerId(5))) - } finally { - first.dispose() - } - val second = DialogStoreFactory( - DefaultStoreFactory(), FakeClient(), warmup, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = Dispatchers.Unconfined, markupContext = Dispatchers.Unconfined, - ).create() - try { - assertEquals("hello", second.state.draft) - } finally { - second.dispose() + var store = create() + fun restoreEditor() { + val snapshot = ChecklistEditState.from(store.state)!! + val saved = ChecklistEditState.decode(snapshot.encode())!! + assertNull(saved.message(PeerId(6), 0)) + assertNull(saved.message(PeerId(5), 1)) + assertNull(saved.copy(itemId = Int.MAX_VALUE).message(PeerId(5), 0)) + store.dispose() + store = create(saved) } - } - - @Test - fun sendUsesComposerTextEvenIfDraftStale() = runTest { - val dispatcher = UnconfinedTestDispatcher(testScheduler) - val client = FakeClient() - val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, - ).create() try { - store.accept(DialogStore.Intent.DraftChanged("old")) - store.accept(DialogStore.Intent.Send("hello")) advanceUntilIdle() - assertEquals("hello", client.lastSendText) - assertEquals("", store.state.draft) - assertFalse(store.state.sending) + store.accept(DialogStore.Intent.Edit(store.state.messages.single())) + assertEquals("Shop", store.state.draft) + store.accept(DialogStore.Intent.DraftChanged("New title")) + client.todoResult = Outcome.Err("RPC 500: INTERNAL") + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals("Shop", store.state.messages.single().checklist?.title) + assertEquals("New title", store.state.draft) + assertNotNull(store.state.editing) + restoreEditor() + advanceUntilIdle() + assertEquals("New title", store.state.draft) + assertEquals("Shop", store.state.editing!!.checklist!!.title) + assertEquals(1, client.todoCalls) + client.todoResult = Outcome.Ok(Unit) + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals("New title", store.state.messages.single().checklist?.title) + assertEquals(list.items, store.state.messages.single().checklist?.items) + assertNull(store.state.editing) + assertEquals(2, client.todoCalls) + store.accept(DialogStore.Intent.Edit(store.state.messages.single(), 7)) + assertEquals("Milk", store.state.draft) + store.accept(DialogStore.Intent.DraftChanged("Milk edited")) + val gate = CompletableDeferred() + client.todoGate = gate + store.accept(DialogStore.Intent.Send()) + runCurrent() + store.accept(DialogStore.Intent.DraftChanged("Milk newer")) + gate.complete(Unit) + advanceUntilIdle() + assertEquals( + "Milk edited", + store.state.messages.single().checklist!!.items.single().text + ) + assertEquals("Milk newer", store.state.draft) + assertEquals("Milk edited", store.state.editing!!.checklist!!.items.single().text) + assertEquals(7, store.state.editingChecklistItemId) + restoreEditor() + advanceUntilIdle() + assertEquals("Milk newer", store.state.draft) + assertEquals(7, store.state.editingChecklistItemId) + assertEquals("Milk edited", store.state.editing!!.checklist!!.items.single().text) + assertEquals(3, client.todoCalls) + client.todoGate = null + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals( + "Milk newer", + store.state.messages.single().checklist!!.items.single().text + ) + assertNull(store.state.editing) + assertEquals(4, client.todoCalls) + val mention = org.monogram.core.models.TextEntity("mention_name", 0, 12, "42") + store.accept(DialogStore.Intent.Edit(store.state.messages.single())) + store.accept(DialogStore.Intent.DraftChanged("@fi")) + store.accept( + DialogStore.Intent.SelectMention( + MentionCandidate( + PeerId(42), + "Fixture User" + ) + ) + ) + assertEquals("Fixture User ", store.state.draft) + assertEquals(1, store.state.draftMentions.size) + restoreEditor() + advanceUntilIdle() + assertEquals(listOf(mention), store.state.draftEntities) + assertEquals(4, client.todoCalls) + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals(listOf(mention), store.state.messages.single().checklist!!.entities) + assertEquals("Fixture User ", store.state.messages.single().checklist!!.title) + store.accept(DialogStore.Intent.Edit(store.state.messages.single(), 7)) + store.accept(DialogStore.Intent.DraftChanged("@fi")) + store.accept( + DialogStore.Intent.SelectMention( + MentionCandidate( + PeerId(42), + "Fixture User" + ) + ) + ) + assertEquals(1, store.state.draftMentions.size) + store.accept(DialogStore.Intent.Send()) + advanceUntilIdle() + assertEquals( + listOf(mention), + store.state.messages.single().checklist!!.items.single().entities + ) + assertEquals(6, client.todoCalls) } finally { store.dispose() } } @Test - fun overlappingTextSendsStayPendingUntilEachRpcFinishes() = runTest { + fun silentTextClearsOnlyAfterPersistenceAndStorageFailurePreservesDraft() = runTest { val dispatcher = UnconfinedTestDispatcher(testScheduler) - val gate = CompletableDeferred() - val client = FakeClient().apply { sendGate = gate } + val cache = FakeWarmup() val store = DialogStoreFactory( - DefaultStoreFactory(), client, warmup = null, sessionStore = null, - chatId = PeerId(5), seedIsForum = false, - mainContext = dispatcher, markupContext = dispatcher, + DefaultStoreFactory(), + FakeClient(), + warmup = cache, + sessionStore = null, + chatId = PeerId(5), + seedIsForum = false, + mainContext = dispatcher, + markupContext = dispatcher ).create() try { - store.accept(DialogStore.Intent.Send("one")) - store.accept(DialogStore.Intent.Send("two")) - assertEquals(listOf("one", "two"), client.sentTexts) - assertEquals(2, store.state.messages.count { it.outgoing && it.pending }) - assertFalse(store.state.sending) + store.accept(DialogStore.Intent.DraftChanged("silent")) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true, noWebpage = true) + ) + ) + advanceUntilIdle() + assertTrue(cache.queuedText.value.single().silent) + assertTrue(cache.queuedText.value.single().noWebpage) assertEquals("", store.state.draft) - gate.complete(Unit) + cache.enqueueFails = true + store.accept(DialogStore.Intent.DraftChanged("keep me")) + store.accept( + DialogStore.Intent.SendWithOptions( + null, + org.monogram.core.models.MessageSendOptions(silent = true) + ) + ) advanceUntilIdle() + assertEquals("keep me", store.state.draft) + assertEquals(1, cache.queuedText.value.size) + assertFalse(store.state.sending) + store.accept( + DialogStore.Intent.ReplyTo( + Message( + org.monogram.core.models.MessageId( + PeerId(5), + 42 + ), null, "Reply", 1, outgoing = false + ) + ) + ) + store.accept(DialogStore.Intent.SendLocation(1.0, 2.0, 0)) + advanceUntilIdle() + assertEquals(1, cache.queuedText.value.size) + assertEquals(42, store.state.replyTo?.id?.id) + cache.enqueueFails = false + store.accept(DialogStore.Intent.SendLocation(Double.NaN, 2.0, 0)) + advanceUntilIdle() + assertEquals(1, cache.queuedText.value.size) + store.accept(DialogStore.Intent.SendLocation(1.0, 2.0, 900)) + advanceUntilIdle() + val location = cache.queuedText.value.last() + assertEquals(2, cache.queuedText.value.size) + assertEquals("geo", location.message.mediaKind) assertEquals( - setOf("one", "two"), - store.state.messages.filter { it.outgoing }.map { it.text }.toSet(), + org.monogram.core.models.GeoPlace(1.0, 2.0, true, 900), + org.monogram.core.database.decodeLocationSend(location.message.fileName) ) - assertTrue(store.state.messages.none { it.pending }) + assertEquals(42, location.message.replyToMsgId) + assertEquals("keep me", store.state.draft) + assertNull(store.state.replyTo) } finally { store.dispose() } @@ -3783,7 +5109,59 @@ class DialogForumStoreTest { } } - private class FakeWarmup : OfflineWarmup() { + private class FakeWarmup(mediaFiles: org.monogram.core.database.OutgoingMediaFiles? = null) : + OfflineWarmup(outgoingMediaFiles = mediaFiles) { + val queuedMedia = + MutableStateFlow>(emptyList()) + + override fun observeOutgoingMediaSends() = queuedMedia + override suspend fun retainAndEnqueueOutgoingMedia( + batch: org.monogram.core.database.entity.OutgoingMediaBatchEntity, + uploads: List, + maxTotalBytes: Long, + expectedEpoch: Long, + draftSnapshot: CloudDraftState?, + consumeDraft: Boolean + ): org.monogram.core.database.OutgoingMediaAdmission { + enqueueGate?.await() + check(!enqueueFails) + val send = org.monogram.core.database.entity.OutgoingMediaSend( + batch, + uploads.mapIndexed { index, upload -> + org.monogram.core.database.entity.OutgoingMediaItemEntity( + checkNotNull(upload.message.randomId), + batch.batchId, + index, + upload.mimeType, + org.monogram.core.database.RetainedMediaSource( + java.util.UUID.randomUUID().toString() + ".bin", 3, "0".repeat(64) + ), + upload.message + ) + }) + queuedMedia.value += send + return org.monogram.core.database.OutgoingMediaAdmission( + send, + draftSnapshot != null && consumeDraft + ) + } + + val queuedText = + MutableStateFlow>( + emptyList() + ) + var enqueueGate: CompletableDeferred? = null + var enqueueFails = false + override fun observeOutgoingTextSends() = queuedText + override suspend fun enqueueOutgoingTextSend( + row: org.monogram.core.database.entity.OutgoingTextSendEntity, expectedEpoch: Long, + draftSnapshot: CloudDraftState?, consumeDraft: Boolean + ): Boolean { + enqueueGate?.await() + check(!enqueueFails) + queuedText.value += row + return draftSnapshot != null && consumeDraft + } var newerSliceGate: CompletableDeferred? = null var newerSliceRows: List = emptyList() override suspend fun historySlice( @@ -3838,6 +5216,60 @@ class DialogForumStoreTest { chats.forEach { storedChats[it.id.value] = it } } val drafts = mutableMapOf() + val cloudDrafts = mutableMapOf() + val draftChanges = MutableStateFlow(0L) + var draftSaveGate: CompletableDeferred? = null + override suspend fun cloudDraftState(chatId: PeerId, threadId: Int): CloudDraftState { + val key = draftMetaKey(chatId.value, threadId) + return cloudDrafts[key] ?: CloudDraftState( + CloudDraft( + chatId, + threadId, + text = drafts[key].orEmpty() + ) + ) + } + + override fun observeCloudDraft(chatId: PeerId, threadId: Int) = + draftChanges.map { cloudDraftState(chatId, threadId) } + + private fun putDraft(state: CloudDraftState): CloudDraftState { + val key = draftMetaKey(state.draft.chatId.value, state.draft.threadId) + cloudDrafts[key] = state + if (state.draft.text.isEmpty()) drafts.remove(key) else drafts[key] = state.draft.text + draftChanges.value++ + return state + } + + override suspend fun saveLocalCloudDraft( + draft: CloudDraft, + markdown: Boolean? + ): CloudDraftState { + draftSaveGate?.await() + return putDraft( + cloudDraftState(draft.chatId, draft.threadId).localEdit( + draft, + markdown = markdown + ) + ) + } + + override suspend fun discardLocalCloudDraft( + chatId: PeerId, + threadId: Int + ): CloudDraftState = + putDraft( + cloudDraftState(chatId, threadId).localEdit( + CloudDraft(chatId, threadId), + discardUnsupported = true + ) + ) + + override suspend fun applyCloudDraft(draft: CloudDraft, expectedRevision: Long?) { + val current = cloudDraftState(draft.chatId, draft.threadId) + if (expectedRevision != null && current.revision != expectedRevision) return + putDraft(current.mergeRemote(draft)) + } val upserted = mutableListOf() val deletedIds = mutableListOf() override suspend fun messages(chatId: PeerId, limit: Int) = @@ -3851,8 +5283,12 @@ class DialogForumStoreTest { override suspend fun draft(chatId: PeerId, threadId: Int) = drafts[draftMetaKey(chatId.value, threadId)].orEmpty() override suspend fun setDraft(chatId: PeerId, threadId: Int, text: String) { - val key = draftMetaKey(chatId.value, threadId) - if (text.isEmpty()) drafts.remove(key) else drafts[key] = text + saveLocalCloudDraft( + cloudDraftState(chatId, threadId).draft.copy( + text = text, + entitiesJson = null + ) + ) } override suspend fun upsertMessages(messages: List) { upserted += messages @@ -4205,6 +5641,57 @@ class DialogForumStoreTest { private class FakeClient( private val topicCount: Int = 1, ) : MtprotoClient { + var viewerPageResult: Outcome = + Outcome.Ok(org.monogram.core.models.MessageViewerPage(emptyList(), 0, null)) + var viewerPageGate: CompletableDeferred? = null + val viewerPageOffsets = mutableListOf() + override suspend fun getReactionUsersPage( + chatId: PeerId, + messageId: Int, + offset: String? + ): Outcome { + viewerPageOffsets += offset + val response = viewerPageResult + viewerPageGate?.await() + return response + } + + override suspend fun getPollVotersPage(chatId: PeerId, messageId: Int, offset: String?) = + getReactionUsersPage(chatId, messageId, offset) + + var pollCloseCalls = 0 + var pollCloseResult: Outcome = Outcome.Ok(Unit) + var pollCloseGate: CompletableDeferred? = null + override suspend fun closePoll(chatId: PeerId, messageId: Int): Outcome { + pollCloseCalls++ + pollCloseGate?.await() + return pollCloseResult + } + + var pollVoteCalls = 0 + var pollOptions: List = emptyList() + var pollVoteGate: CompletableDeferred? = null + var pollVoteResult: Outcome = Outcome.Ok(Unit) + var pollRead: Outcome = Outcome.Err("fixture message unavailable") + override suspend fun sendPollVote( + chatId: PeerId, + messageId: Int, + options: List + ): Outcome { + pollVoteCalls++ + pollOptions = options + pollVoteGate?.await() + return pollVoteResult + } + + var pollReadCalls = 0 + var pollReadGate: CompletableDeferred? = null + override suspend fun getMessage(chatId: PeerId, messageId: Int): Outcome { + pollReadCalls++ + val response = pollRead + pollReadGate?.await() + return response + } var forumCalls = 0 var emojiConfigCalls = 0 val emojiFreeCalls = mutableListOf() @@ -4542,14 +6029,80 @@ class DialogForumStoreTest { lastCallback = messageId to dataHex return Outcome.Ok(org.monogram.core.models.BotCallbackAnswer(message = "pong")) } + var todoCalls = 0 + var todoGate: CompletableDeferred? = null + var todoResult: Outcome = Outcome.Ok(Unit) + var lastTodoAppend: Triple>? = null + override suspend fun editChecklistText( + chatId: PeerId, + messageId: Int, + edit: org.monogram.core.models.ChecklistTextEdit + ): Outcome { + todoCalls++ + todoGate?.await() + if (todoResult is Outcome.Ok) history = history.map { message -> + if (message.id.id != messageId) message else message.copy(checklist = message.checklist?.let { list -> + if (edit.itemId == null) list.copy(title = edit.text, entities = edit.entities) + else list.copy(items = list.items.map { + if (it.id == edit.itemId) it.copy( + text = edit.text, + entities = edit.entities + ) else it + }) + }) + } + pollRead = Outcome.Ok(history.firstOrNull { it.id.id == messageId }) + return todoResult + } + + override suspend fun appendChecklistItems( + chatId: PeerId, + messageId: Int, + firstId: Int, + titles: List + ): Outcome { + todoCalls++ + lastTodoAppend = Triple(messageId, firstId, titles) + todoGate?.await() + if (todoResult is Outcome.Ok) { + history = history.map { message -> + if (message.id.id != messageId) message else message.copy(checklist = message.checklist?.let { list -> + list.copy(items = list.items + titles.mapIndexed { index, title -> + org.monogram.core.models.ChecklistItem( + firstId + index, + title + ) + }) + }) + } + } + pollRead = Outcome.Ok(history.firstOrNull { it.id.id == messageId }) + return todoResult + } override suspend fun toggleTodoCompleted( chatId: PeerId, messageId: Int, completed: List, incompleted: List, ): Outcome { + todoCalls++ lastTodoToggle = Triple(messageId, completed, incompleted) - return Outcome.Ok(Unit) + todoGate?.await() + if (todoResult is Outcome.Ok) { + history = history.map { message -> + if (message.id.id != messageId) message else message.copy(checklist = message.checklist?.let { list -> + list.copy(items = list.items.map { item -> + when (item.id) { + in completed -> item.copy(done = true) + in incompleted -> item.copy(done = false) + else -> item + } + }) + }) + } + } + pollRead = Outcome.Ok(history.firstOrNull { it.id.id == messageId }) + return todoResult } override suspend fun sendPhoto( chatId: PeerId, @@ -4559,6 +6112,26 @@ class DialogForumStoreTest { topMsgId: Int, entitiesJson: String?, ) = unused() + var replacementCalls = 0 + var replacementGate: CompletableDeferred? = null + var replacementFailure = false + override suspend fun replaceUploadedMedia( + chatId: PeerId, messageId: Int, expectedKey: String, + item: org.monogram.core.models.UploadItem + ): Outcome { + replacementCalls++ + assertEquals("", item.caption) + assertTrue(item.captionEntities.isEmpty()) + replacementGate?.await() + if (replacementFailure) return Outcome.Err("RPC 500: INTERNAL") + val row = history.single { it.id.id == messageId } + assertEquals(row.mediaCacheKey, expectedKey) + assertEquals(row.mediaKind, item.kind) + val edited = row.copy(mediaCacheKey = "new") + history = listOf(edited) + return Outcome.Ok(edited) + } + override suspend fun editText( chatId: PeerId, messageId: Int, diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/OutgoingMediaTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/OutgoingMediaTest.kt index 824002f20..7041be987 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/OutgoingMediaTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/OutgoingMediaTest.kt @@ -59,6 +59,24 @@ class OutgoingMediaTest { ) val matched = matchingPending(listOf(newer, older), incoming) assertEquals(-2, matched?.id?.id) + val pin = older.copy(mediaKind = "geo", fileName = "{\"lat\":1.0,\"long\":2.0}") + val second = pin.copy(id = MessageId(chat, -3), randomId = 3) + val receipt = pin.copy(id = MessageId(chat, 45), pending = false, randomId = null) + assertNull(matchingPending(listOf(pin, second), receipt)) + assertNull(matchingPending(listOf(pin.copy(randomId = null)), receipt)) + assertEquals(second, matchingPending(listOf(pin, second), receipt.copy(randomId = 3))) + assertNull( + matchingPending( + listOf(pin, second), + receipt.copy(randomId = 3, outgoing = false) + ) + ) + assertNull( + matchingPending( + listOf(pin, second), + receipt.copy(id = MessageId(PeerId(6), 45), randomId = 3) + ) + ) } @Test diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStoreTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStoreTest.kt new file mode 100644 index 000000000..38c82b426 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/scheduled/ScheduledQueueStoreTest.kt @@ -0,0 +1,606 @@ +package org.monogram.feature.dialog.scheduled + +import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.common.Outcome +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.models.* +import org.monogram.network.bridge.message.MessageOps +import java.lang.reflect.Proxy + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ScheduledQueueStoreTest { + private val peer = PeerId(7) + private fun message(id: Int, date: Long = 100) = ScheduledMessage( + Message( + id = MessageId(peer, id), + senderId = null, + text = "fixture", + date = date, + outgoing = true, + ) + ) + + private class Cache : OfflineWarmup() { + val rows = MutableStateFlow>(emptyList()) + var revision = 0L + var blockedUntil = 0L + override suspend fun outgoingSendBlockedUntil() = blockedUntil + override suspend fun blockScheduledQueueUntil(deadline: Long, expectedEpoch: Long) { + blockedUntil = maxOf(blockedUntil, deadline) + } + + override fun observeScheduledMessages(chatId: PeerId) = rows + override suspend fun scheduledQueueRevision(chatId: PeerId, expectedEpoch: Long) = revision + override suspend fun replaceScheduledMessagesIfCurrent( + chatId: PeerId, expectedRevision: Long, + messages: List, expectedEpoch: Long + ): Boolean { + if (expectedRevision != revision) return false + revision++ + rows.value = messages + return true + } + } + + private fun client( + send: suspend (List) -> Outcome = { error("Unexpected send") }, + cancel: suspend (List) -> Outcome = { error("Unexpected cancellation") }, + edit: suspend (Int, Int, String?, String?, Boolean) -> Outcome = { _, _, _, _, _ -> + error( + "Unexpected edit" + ) + }, + query: suspend () -> Outcome>, + ): MessageOps { + val unused = Proxy.newProxyInstance( + MessageOps::class.java.classLoader, + arrayOf(MessageOps::class.java) + ) { _, method, _ -> + error("Unexpected API: ${method.name}") + } as MessageOps + return object : MessageOps by unused { + override suspend fun getScheduledHistory(chatId: PeerId) = query() + override suspend fun sendScheduledMessages(chatId: PeerId, ids: List) = send(ids) + override suspend fun deleteScheduledMessages(chatId: PeerId, ids: List) = + cancel(ids) + + override suspend fun editScheduledMessage( + chatId: PeerId, messageId: Int, scheduleDate: Int, + text: String?, entitiesJson: String?, noWebpage: Boolean + ) = edit(messageId, scheduleDate, text, entitiesJson, noWebpage) + } + } + + @Test + fun previewCancellationDeletionAndLateCompletionNeverRestoreAStaleSelection() = runTest { + val row = message(8).copy( + message = message(8).message.copy( + mediaKind = "photo", + mediaCacheKey = "queue-photo" + ) + ) + val cache = Cache().apply { rows.value = listOf(row) } + val gate = CompletableDeferred>() + var loads = 0 + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { Outcome.Ok(listOf(row)) }, cache, peer, + UnconfinedTestDispatcher(testScheduler), loadPreview = { + assertEquals(8, it.message.id.id) + loads++ + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { gate.await() } + }).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + store.accept(ScheduledQueueStore.Intent.Preview(8)); store.accept( + ScheduledQueueStore.Intent.Preview( + 8 + ) + ) + assertEquals(1, loads); assertTrue(store.state.preview!!.loading) + cache.rows.value = emptyList(); advanceUntilIdle() + assertNull(store.state.preview) + gate.complete(Outcome.Ok(java.io.File("synthetic-preview"))); advanceUntilIdle() + assertNull(store.state.preview) + } finally { + gate.complete(Outcome.Err("cancelled")); store.dispose() + } + } + + @Test + fun resumingPreviewRefetchesAnEvictedFile() = runTest { + val row = message(8).copy( + message = message(8).message.copy( + mediaKind = "photo", + mediaCacheKey = "queue-photo" + ) + ) + val file = java.io.File.createTempFile("scheduled-preview-", ".png") + var calls = 0 + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { Outcome.Ok(listOf(row)) }, null, peer, + UnconfinedTestDispatcher(testScheduler), loadPreview = { + calls++; file.writeBytes(byteArrayOf(1)); Outcome.Ok(file) + }).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + store.accept(ScheduledQueueStore.Intent.Preview(8)); advanceUntilIdle() + assertEquals(1, calls) + store.accept(ScheduledQueueStore.Intent.Pause); advanceUntilIdle() + assertTrue(file.delete()) + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + assertEquals(2, calls); assertTrue(store.state.preview!!.file!!.exists()) + } finally { + store.dispose(); file.delete() + } + } + + @Test + fun previewPauseResumeAndFailureRetryAreReadsOnly() = runTest { + val row = message(8).copy( + message = message(8).message.copy( + mediaKind = "photo", + mediaCacheKey = "queue-photo" + ) + ) + val first = CompletableDeferred>() + val file = java.io.File("synthetic-preview") + var calls = 0 + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { Outcome.Ok(listOf(row)) }, null, peer, + UnconfinedTestDispatcher(testScheduler), loadPreview = { + calls++ + when (calls) { + 1 -> first.await(); 2 -> Outcome.Err("RPC 500: INTERNAL"); else -> Outcome.Ok( + file + ) + } + }).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + store.accept(ScheduledQueueStore.Intent.Preview(8)); assertTrue(store.state.preview!!.loading) + store.accept(ScheduledQueueStore.Intent.Pause); advanceUntilIdle() + assertFalse(store.state.preview!!.loading) + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + assertEquals(2, calls); assertNotNull(store.state.preview!!.error) + store.accept(ScheduledQueueStore.Intent.RetryPreview); advanceUntilIdle() + assertEquals(file, store.state.preview!!.file) + store.accept(ScheduledQueueStore.Intent.ClosePreview) + assertNull(store.state.preview) + } finally { + store.dispose() + } + } + + @Test + fun previewFloodCannotBeBypassedByRetryOrWallClockMovement() = runTest { + val row = message(8).copy( + message = message(8).message.copy( + mediaKind = "photo", + mediaCacheKey = "queue-photo" + ) + ) + var wall = 1_000_000L + var monotonic = 1000L + var calls = 0 + val cache = Cache() + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), + client { Outcome.Ok(listOf(row)) }, + cache, + peer, + UnconfinedTestDispatcher(testScheduler), + { wall }, + { monotonic }, + loadPreview = { + calls++; Outcome.Err("FLOOD_WAIT_10") + }).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + store.accept(ScheduledQueueStore.Intent.Preview(8)); advanceUntilIdle() + assertEquals(1_010_000L, cache.blockedUntil) + wall += 20_000 + store.accept(ScheduledQueueStore.Intent.RetryPreview); advanceUntilIdle() + assertEquals(1, calls) + monotonic += 10_001 + store.accept(ScheduledQueueStore.Intent.RetryPreview); advanceUntilIdle() + assertEquals(2, calls) + } finally { + store.dispose() + } + } + + @Test + fun captionEditPreservesDateAndFormattingAndRejectsStaleOrUnsupportedSnapshots() = runTest { + val original = message(8, 1900).copy( + hasUnsupportedContent = false, + message = message(8, 1900).message.copy( + mediaKind = "photo", + mediaCacheKey = "scheduled-photo" + ) + ) + val cache = Cache().apply { rows.value = listOf(original) } + var server = listOf(original) + var edits = 0 + val api = client(edit = { id, date, text, json, noWebpage -> + assertEquals(8, id); assertEquals(1900, date) + assertEquals("new caption", text) + assertEquals(listOf(TextEntity("bold", 0, 3)), TextEntities.parse(json)) + assertTrue(noWebpage) + edits++ + server = listOf( + original.copy( + message = original.message.copy( + text = text, + entities = TextEntities.parse(json) + ) + ) + ) + Outcome.Ok(Unit) + }) { Outcome.Ok(server) } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, cache, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + store.accept( + ScheduledQueueStore.Intent.Edit( + original.copy(hasUnsupportedContent = true), + "new caption", + emptyList() + ) + ) + store.accept( + ScheduledQueueStore.Intent.Edit( + original.copy( + message = original.message.copy( + date = 1800 + ) + ), "new caption", emptyList() + ) + ) + store.accept( + ScheduledQueueStore.Intent.Edit( + original, + "new caption", + listOf(TextEntity("bold", 0, 99)) + ) + ) + assertEquals(0, edits) + store.accept( + ScheduledQueueStore.Intent.Edit( + original, + "new caption", + listOf(TextEntity("bold", 0, 3)) + ) + ) + advanceUntilIdle() + assertEquals(1, edits) + val saved = store.state.messages.single().message + assertEquals("scheduled-photo", saved.mediaCacheKey) + assertEquals(1900L, saved.date) + assertEquals("new caption", saved.text) + store.accept(ScheduledQueueStore.Intent.Edit(original, "stale", emptyList())) + advanceUntilIdle(); assertEquals(1, edits) + } finally { + store.dispose() + } + } + + @Test + fun rescheduleChangesOnlyTheDateAndRejectsInvalidDatesBeforeRpc() = runTest { + val original = ScheduledMessage( + message(8, 1900).message.copy( + mediaKind = "photo", + entities = listOf(TextEntity("bold", 0, 3)) + ) + ) + var server = listOf(original) + var edits = 0 + val api = client(edit = { id, date, text, entities, noWebpage -> + assertEquals(8, id) + assertEquals(2000, date) + assertNull(text); assertNull(entities); assertFalse(noWebpage) + edits++ + server = listOf(ScheduledMessage(original.message.copy(date = date.toLong()))) + Outcome.Ok(Unit) + }) { Outcome.Ok(server) } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, null, peer, + UnconfinedTestDispatcher(testScheduler), { 1_000_000L }).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.Reschedule(8, 1001)) + store.accept(ScheduledQueueStore.Intent.Reschedule(8, Int.MAX_VALUE)) + store.accept(ScheduledQueueStore.Intent.Reschedule(0, 2000)) + assertEquals(0, edits) + store.accept(ScheduledQueueStore.Intent.Reschedule(8, 2000)); advanceUntilIdle() + assertEquals(1, edits) + assertEquals( + listOf(ScheduledMessage(original.message.copy(date = 2000))), + store.state.messages + ) + assertNull(store.state.mutatingId) + } finally { + store.dispose() + } + } + + @Test + fun restoredCacheCannotMutateBeforeAFreshServerSnapshot() = runTest { + val cache = Cache().apply { rows.value = listOf(message(8)) } + val gate = CompletableDeferred>>() + var sends = 0 + val api = client(send = { sends++; Outcome.Ok(Unit) }) { gate.await() } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, cache, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.SendNow(8)) + assertEquals(0, sends) + gate.complete(Outcome.Ok(listOf(message(8)))) + advanceUntilIdle() + store.accept(ScheduledQueueStore.Intent.SendNow(8)); advanceUntilIdle() + assertEquals(1, sends) + } finally { + store.dispose() + } + } + + @Test + fun floodDeadlineSurvivesStoreRecreationAndRefreshCannotBypassMonotonicWait() = runTest { + val cache = Cache().apply { rows.value = listOf(message(8)) } + var wall = 1_000L + var elapsed = 5_000L + var sends = 0 + var reads = 0 + val api = client(send = { sends++; Outcome.Err("FLOOD_WAIT_10") }) { + reads++; Outcome.Ok(listOf(message(8))) + } + + fun create() = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, cache, peer, + UnconfinedTestDispatcher(testScheduler), { wall }, { elapsed }).create() + + var store = create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.SendNow(8)); advanceUntilIdle() + assertEquals(11_000L, cache.blockedUntil) + assertEquals(1, sends) + store.dispose(); store = create() + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + assertEquals(1, reads) + wall = 100_000L + store.accept(ScheduledQueueStore.Intent.Refresh) + store.accept(ScheduledQueueStore.Intent.SendNow(8)); advanceUntilIdle() + assertEquals(1, sends) + assertEquals(1, reads) + wall = 11_000L; elapsed = 15_000L + store.accept(ScheduledQueueStore.Intent.Refresh); advanceUntilIdle() + assertEquals(2, reads) + assertEquals(1, sends) + assertNull(store.state.error) + } finally { + store.dispose() + } + } + + @Test + fun sendNowClaimsOneVisibleIdAndRefreshesWithoutOptimisticHistoryChanges() = runTest { + val gate = CompletableDeferred>() + var calls = 0 + var server = listOf(message(8)) + val api = client(send = { ids -> + assertEquals( + listOf(8), + ids + ); calls++; gate.await() + }) { Outcome.Ok(server) } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, null, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.SendNow(0)) + store.accept(ScheduledQueueStore.Intent.SendNow(99)) + assertEquals(0, calls) + store.accept(ScheduledQueueStore.Intent.SendNow(8)) + store.accept(ScheduledQueueStore.Intent.SendNow(8)) + assertEquals(1, calls) + assertEquals(8, store.state.mutatingId) + assertEquals(listOf(message(8)), store.state.messages) + server = emptyList(); gate.complete(Outcome.Ok(Unit)); advanceUntilIdle() + assertTrue(store.state.messages.isEmpty()) + assertNull(store.state.mutatingId) + } finally { + store.dispose() + } + } + + @Test + fun uncertainMutationRequiresAnAuthoritativeReadAndAnotherExplicitIntent() = runTest { + var calls = 0 + val api = client(send = { calls++; Outcome.Err("NETWORK_UNAVAILABLE") }) { + Outcome.Ok( + listOf(message(8)) + ) + } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, null, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.SendNow(8)); advanceUntilIdle() + assertEquals(setOf(8), store.state.uncertainIds) + assertNotNull(store.state.error) + store.accept(ScheduledQueueStore.Intent.SendNow(8)) + assertEquals(1, calls) + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + assertTrue(store.state.uncertainIds.isEmpty()) + assertEquals(1, calls) + store.accept(ScheduledQueueStore.Intent.SendNow(8)); advanceUntilIdle() + assertEquals(2, calls) + } finally { + store.dispose() + } + } + + @Test + fun pausingCancellationDoesNotReplayItOnResume() = runTest { + val gate = CompletableDeferred>() + var cancellations = 0 + val api = client(cancel = { ids -> + assertEquals( + listOf(8), + ids + ); cancellations++; gate.await() + }) { + Outcome.Ok(listOf(message(8))) + } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), api, null, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.Cancel(8)) + store.accept(ScheduledQueueStore.Intent.Pause); advanceUntilIdle() + assertEquals(setOf(8), store.state.uncertainIds) + assertNull(store.state.mutatingId) + store.accept(ScheduledQueueStore.Intent.Resume); advanceUntilIdle() + assertEquals(1, cancellations) + assertEquals(listOf(message(8)), store.state.messages) + } finally { + store.dispose() + } + } + + @Test + fun cachedQueuePaintsBeforeFetchAndPushDeletionRejectsStaleSnapshot() = runTest { + val cache = Cache().apply { rows.value = listOf(message(1)) } + val gate = CompletableDeferred>>() + var calls = 0 + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { calls++; gate.await() }, cache, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + assertEquals(listOf(message(1)), store.state.messages) + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.Refresh) + assertEquals(1, calls) + assertTrue(store.state.loading) + cache.revision++; cache.rows.value = emptyList() + gate.complete(Outcome.Ok(listOf(message(1)))) + advanceUntilIdle() + assertTrue(store.state.messages.isEmpty()) + assertTrue(store.state.needsRefresh) + assertFalse(store.state.loading) + } finally { + store.dispose() + } + } + + @Test + fun pauseCancelsFetchAndFailedRefreshPreservesCachedContent() = runTest { + val cache = Cache().apply { rows.value = listOf(message(1)) } + var query: suspend () -> Outcome> = + { CompletableDeferred>>().await() } + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { query() }, cache, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + store.accept(ScheduledQueueStore.Intent.Pause) + advanceUntilIdle() + assertFalse(store.state.loading) + assertNull(store.state.error) + query = { Outcome.Err("NETWORK_UNAVAILABLE") } + store.accept(ScheduledQueueStore.Intent.Resume) + advanceUntilIdle() + assertNotNull(store.state.error) + assertEquals(listOf(message(1)), store.state.messages) + query = { Outcome.Ok(listOf(message(2))) } + store.accept(ScheduledQueueStore.Intent.Refresh) + advanceUntilIdle() + assertNull(store.state.error) + assertFalse(store.state.fromCache) + assertEquals(listOf(message(2)), store.state.messages) + } finally { + store.dispose() + } + } + + @Test + fun cancelledFetchCannotClearLoadingOfAnImmediateResume() = runTest { + val first = CompletableDeferred>>() + val second = CompletableDeferred>>() + var calls = 0 + val store = ScheduledQueueStoreFactory(DefaultStoreFactory(), client { + calls++ + if (calls == 1) first.await() else second.await() + }, null, peer, kotlinx.coroutines.test.StandardTestDispatcher(testScheduler)).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + testScheduler.runCurrent() + store.accept(ScheduledQueueStore.Intent.Pause) + store.accept(ScheduledQueueStore.Intent.Resume) + testScheduler.runCurrent() + assertEquals(2, calls) + assertTrue(store.state.loading) + second.complete(Outcome.Ok(listOf(message(2)))) + advanceUntilIdle() + assertFalse(store.state.loading) + assertEquals(listOf(message(2)), store.state.messages) + } finally { + store.dispose() + } + } + + @Test + fun uncachedQueueIsOrderedAndForeignPeerResponsesAreRejected() = runTest { + var result: Outcome> = + Outcome.Ok(listOf(message(2, 200), message(1))) + val store = ScheduledQueueStoreFactory( + DefaultStoreFactory(), client { result }, null, peer, + UnconfinedTestDispatcher(testScheduler) + ).create() + try { + store.accept(ScheduledQueueStore.Intent.Resume) + advanceUntilIdle() + assertEquals(listOf(1, 2), store.state.messages.map { it.message.id.id }) + assertFalse(store.state.fromCache) + result = Outcome.Ok( + listOf( + ScheduledMessage( + message(3).message.copy( + id = MessageId( + PeerId(8), + 3 + ) + ) + ) + ) + ) + store.accept(ScheduledQueueStore.Intent.Refresh) + advanceUntilIdle() + assertNotNull(store.state.error) + assertEquals(listOf(1, 2), store.state.messages.map { it.message.id.id }) + } finally { + store.dispose() + } + } +} diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/store/CloudDraftEditorStateTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/CloudDraftEditorStateTest.kt new file mode 100644 index 000000000..ecfa3b591 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/CloudDraftEditorStateTest.kt @@ -0,0 +1,79 @@ +package org.monogram.feature.dialog.store + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId +import org.monogram.core.models.TextEntities +import org.monogram.core.models.TextEntity +import org.monogram.feature.dialog.DialogStore + +class CloudDraftEditorStateTest { + private val peer = PeerId(7) + private fun restore(draft: CloudDraft, state: DialogStore.State = DialogStore.State(peer)) = + with(DialogReducer) { state.reduce(Msg.CloudDraftRestored(draft)) } + + @Test + fun namedMentionsAndCollapsedQuotesRoundTripWithoutDuplicateEntities() { + val entities = listOf( + TextEntity("mention_name", 0, 5, "42"), + TextEntity("blockquote", 6, 4, "collapsed") + ) + val draft = CloudDraft( + peer, + text = "Alice test", + entitiesJson = TextEntities.serialize(entities), + noWebpage = true + ) + val state = restore(draft) + assertEquals(42L, state.draftMentions.single().peerId.value) + assertEquals(listOf(entities.last()), state.draftEntities) + assertEquals( + entities.toSet(), + TextEntities.parse(state.cloudDraft(draft).entitiesJson).toSet() + ) + assertTrue(state.linkPreviewHidden) + assertEquals(draft, state.cloudDraft(draft)) + } + + @Test + fun anUnloadedReplyRetainsItsTargetAndDoesNotRequestFocus() { + val state = restore(CloudDraft(peer, text = "reply", replyToMessageId = 123)) + assertNull(state.replyTo) + assertEquals(123, state.composerReplyId) + assertEquals(123, state.cloudDraft(CloudDraft(peer)).replyToMessageId) + assertEquals(0, state.composerFocusSeq) + val cleared = with(DialogReducer) { state.reduce(Msg.ReplyTo(null)) } + assertNull(cleared.composerReplyId) + } + + @Test + fun aLoadedReplySuppliesItsPreviewAndLegacyStateStillRetainsItsTarget() { + val reply = Message( + id = MessageId(peer, 123), + senderId = null, + text = "original", + date = 1L, + outgoing = false + ) + val state = restore( + CloudDraft(peer, replyToMessageId = 123), + DialogStore.State(peer, messages = listOf(reply)) + ) + assertEquals(reply, state.replyTo) + assertEquals(123, DialogStore.State(peer, replyTo = reply).composerReplyId) + } + + @Test + fun unsupportedDraftsKeepTheirGuardAndTopicWhenEditorFieldsChange() { + val draft = CloudDraft(peer, threadId = 9, text = "original", hasUnsupportedContent = true) + val state = restore(draft).copy(draft = "local edit") + assertTrue(state.draftUnsupported) + val payload = state.cloudDraft(draft) + assertTrue(payload.hasUnsupportedContent) + assertEquals(9, payload.threadId) + assertEquals("local edit", payload.text) + } +} diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/store/MediaSendOptionsTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/MediaSendOptionsTest.kt new file mode 100644 index 000000000..786e28fef --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/MediaSendOptionsTest.kt @@ -0,0 +1,69 @@ +package org.monogram.feature.dialog.store + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.PeerId +import org.monogram.core.models.UploadItem +import org.monogram.feature.dialog.DialogStore + +class MediaSendOptionsTest { + private val photo = UploadItem("synthetic-photo", "photo") + private val state = DialogStore.State(PeerId(7), pendingAttach = listOf(photo)) + + @Test + fun attachmentOnlyAndPhotoOnlyPermissionsAreSupported() { + assertTrue(mediaOptionsAllowed(state.copy(canSendPlain = false))) + assertTrue( + mediaOptionsAllowed( + state.copy( + pendingAttach = listOf( + UploadItem( + "synthetic-file", + "document" + ) + ), canSendPhotos = false + ) + ) + ) + assertFalse(mediaOptionsAllowed(state.copy(canSendPhotos = false))) + } + + @Test + fun invalidAlbumsAndRestrictedComposerStatesAreRejected() { + assertFalse( + mediaOptionsAllowed( + state.copy( + pendingAttach = listOf( + photo, + UploadItem("synthetic-file", "document") + ) + ) + ) + ) + assertFalse(mediaOptionsAllowed(state.copy(pendingAttach = List(11) { photo }))) + assertFalse(mediaOptionsAllowed(state.copy(pendingAttach = emptyList()))) + assertFalse(mediaOptionsAllowed(state.copy(sending = true))) + assertFalse(mediaOptionsAllowed(state.copy(draftUnsupported = true))) + assertFalse(mediaOptionsAllowed(state.copy(left = true))) + assertFalse(mediaOptionsAllowed(state.copy(blockedByMe = true))) + assertFalse(mediaOptionsAllowed(state.copy(canView = false))) + } + + @Test + fun newerAttachmentDraftReplyAndFormattingAreNeverConsumed() { + assertTrue( + mediaSelectionUnchanged( + state, + state.copy(draft = "new text", composerRevision = 1) + ) + ) + assertFalse(mediaSelectionUnchanged(state, state.copy(attachmentRevision = 1))) + assertTrue(mediaComposerUnchanged(state, state.copy(sending = true))) + assertFalse(mediaComposerUnchanged(state, state.copy(composerRevision = 1))) + assertFalse(mediaComposerUnchanged(state, state.copy(pendingAttach = emptyList()))) + assertFalse(mediaComposerUnchanged(state, state.copy(draft = "new text"))) + assertFalse(mediaComposerUnchanged(state, state.copy(draftReplyId = 42))) + assertFalse(mediaComposerUnchanged(state, state.copy(draftFormatting = true))) + assertFalse(mediaComposerUnchanged(state, state.copy(linkPreviewHidden = true))) + } +} diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/store/PollVotePolicyTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/PollVotePolicyTest.kt new file mode 100644 index 000000000..d80fea085 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/store/PollVotePolicyTest.kt @@ -0,0 +1,83 @@ +package org.monogram.feature.dialog.store + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.* + +class PollVotePolicyTest { + private val first = byteArrayOf(0, -1) + private val second = byteArrayOf(3, 0, 7) + private val poll = Poll( + "Fixture", + listOf(PollAnswer("First", option = first), PollAnswer("Second", option = second)) + ) + + private fun message(value: Poll = poll): Message { + val answers = value.answers.joinToString(",") { + """{"t":"Fixture answer","c":${it.chosen},"o":"${pollOptionHex(it.option)}"}""" + } + val payload = + """{"q":"Fixture","z":${value.isQuiz},"m":${value.multipleChoice},"x":${value.closed},"r":${value.revotingDisabled},"a":[$answers]}""" + return Message( + MessageId(PeerId(7), 37), null, null, 100, outgoing = false, + mediaKind = "poll", fileName = payload + ) + } + + @Test + fun revotingDisabledStillAllowsFirstVoteButForbidsRetractionAndReplacement() { + val locked = poll.copy(revotingDisabled = true) + assertTrue(pollVoteAllowed(message(locked), listOf(first))) + val answered = locked.copy( + answers = listOf( + locked.answers.first().copy(chosen = true), + locked.answers.last() + ) + ) + assertFalse(pollVoteAllowed(message(answered), emptyList())) + assertFalse(pollVoteAllowed(message(answered), listOf(second))) + assertTrue(message(locked).poll!!.revotingDisabled) + assertFalse(message().poll!!.revotingDisabled) + } + + @Test + fun ordinaryAnsweredPollCanRetractButUnansweredCannot() { + assertFalse(pollVoteAllowed(message(), emptyList())) + val answered = poll.copy( + answers = listOf( + poll.answers.first().copy(chosen = true), + poll.answers.last() + ) + ) + assertTrue(pollVoteAllowed(message(answered), emptyList())) + assertFalse(pollVoteAllowed(message(answered.copy(isQuiz = true)), emptyList())) + } + + @Test + fun multipleChoiceSubmitsEveryOpaqueOptionButSingleChoiceRejectsSeveral() { + val options = listOf(first.copyOf(), second.copyOf()) + assertFalse(pollVoteAllowed(message(), options)) + assertTrue(pollVoteAllowed(message(poll.copy(multipleChoice = true)), options)) + assertTrue(pollVoteAllowed(message(), listOf(first.copyOf()))) + } + + @Test + fun duplicatesAndUnknownOptionsAreRejectedByByteContent() { + val multiple = message(poll.copy(multipleChoice = true)) + assertFalse(pollVoteAllowed(multiple, listOf(first, first.copyOf()))) + assertFalse(pollVoteAllowed(multiple, listOf(byteArrayOf(5)))) + } + + @Test + fun closedPendingMissingAndLocalMessagesCannotVote() { + assertFalse(pollVoteAllowed(message(poll.copy(closed = true)), listOf(first))) + assertFalse(pollVoteAllowed(message().copy(pending = true), listOf(first))) + assertFalse(pollVoteAllowed(message().copy(fileName = null), listOf(first))) + assertFalse(pollVoteAllowed(message().copy(id = MessageId(PeerId(7), -1)), listOf(first))) + val quiz = poll.copy( + isQuiz = true, + answers = listOf(poll.answers.first().copy(chosen = true), poll.answers.last()) + ) + assertFalse(pollVoteAllowed(message(quiz), listOf(second))) + } +} diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/OutgoingBubbleSendTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/OutgoingBubbleSendTest.kt new file mode 100644 index 000000000..2e8a47ed9 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/OutgoingBubbleSendTest.kt @@ -0,0 +1,118 @@ +package org.monogram.feature.dialog.ui + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.database.entity.OutgoingSendPhase +import org.monogram.core.database.entity.OutgoingTextSendEntity +import org.monogram.core.database.toEntity +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +class OutgoingBubbleSendTest { + private val message = Message( + id = MessageId(PeerId(5), -1), senderId = null, text = "Pending", date = 10, + outgoing = true, pending = true, randomId = 42, + ) + + private fun row() = OutgoingTextSendEntity(42, message.toEntity(), false, false, null, null) + + @Test + fun restoredSendAppearsOnceAndAcknowledgementSuppressesLocalCopy() { + val row = row() + assertEquals( + listOf(message), + outgoingHistoryMessages(emptyList(), listOf(row), emptyList()) + ) + assertEquals( + listOf(message), + outgoingHistoryMessages(listOf(message), listOf(row), emptyList()) + ) + val sent = message.copy(id = MessageId(PeerId(5), 100), pending = false) + assertEquals(listOf(sent), outgoingHistoryMessages(listOf(sent), listOf(row), emptyList())) + } + + @Test + fun scheduledSendStaysOutsideNormalHistory() { + assertTrue( + outgoingHistoryMessages( + emptyList(), + listOf(row().copy(scheduleDate = 1000)), + emptyList() + ).isEmpty() + ) + } + + @Test + fun mediaAlbumRestoresEachItemAndSharesTheBatchRetry() { + val batch = org.monogram.core.database.entity.OutgoingMediaBatchEntity( + 77, + 5, + 10, + false, + null, + true, + OutgoingSendPhase.FAILED + ) + val source = org.monogram.core.database.RetainedMediaSource("fixture", 1, "hash") + val first = org.monogram.core.database.entity.OutgoingMediaItemEntity( + 42, + 77, + 0, + "image/jpeg", + source, + message.toEntity() + ) + val secondMessage = message.copy(id = MessageId(PeerId(5), -2), randomId = 43) + val second = first.copy(randomId = 43, position = 1, message = secondMessage.toEntity()) + val send = org.monogram.core.database.entity.OutgoingMediaSend(batch, listOf(first, second)) + assertEquals( + listOf(message, secondMessage), + outgoingHistoryMessages(emptyList(), emptyList(), listOf(send)) + ) + for (item in listOf(message, secondMessage)) { + assertEquals( + OutgoingBubbleSend(77, OutgoingSendPhase.FAILED, true), + outgoingBubbleSend(item, emptyList(), listOf(send)) + ) + } + assertTrue( + outgoingHistoryMessages( + emptyList(), + emptyList(), + listOf(send.copy(batch = batch.copy(scheduleDate = 1000))) + ).isEmpty() + ) + } + + @Test + fun acknowledgementKeepsTheOutgoingIdentityForTheStatusTransition() { + val state = + org.monogram.feature.dialog.DialogStore.State(PeerId(5), messages = listOf(message)) + val sent = message.copy(id = MessageId(PeerId(5), 100), randomId = null, pending = false) + val result = with(org.monogram.feature.dialog.store.DialogReducer) { + state.reduce(org.monogram.feature.dialog.store.Msg.ReplacePending(-1, sent)) + } + assertEquals(1, result.messages.size) + assertEquals(sent.id, result.messages.single().id) + assertEquals(message.randomId, result.messages.single().randomId) + assertFalse(result.messages.single().pending) + } + + @Test + fun failureStateRemainsAttachedToTheMatchingBubble() { + for (phase in OutgoingSendPhase.entries) { + assertEquals( + OutgoingBubbleSend(42, phase, false), + outgoingBubbleSend(message, listOf(row().copy(phase = phase)), emptyList()) + ) + } + assertNull( + outgoingBubbleSend( + message.copy(randomId = 99, id = MessageId(PeerId(5), -2)), + listOf(row()), + emptyList() + ) + ) + } +} diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/SendScheduleTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/SendScheduleTest.kt new file mode 100644 index 000000000..169818373 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/ui/SendScheduleTest.kt @@ -0,0 +1,43 @@ +package org.monogram.feature.dialog.ui + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.feature.dialog.store.validSendSchedule +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset + +class SendScheduleTest { + private fun day(value: String) = + LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + + @Test + fun pickerDatesRepresentLocalCalendarDaysRatherThanUtcTimes() { + assertEquals( + Instant.parse("2026-03-01T04:30:00Z").epochSecond.toInt(), + scheduledEpoch(day("2026-03-01"), 13, 30, ZoneId.of("Asia/Tokyo")) + ) + } + + @Test + fun nonexistentDstTimeIsRejectedAndOverlapUsesTheEarlierOffset() { + val zone = ZoneId.of("Europe/Berlin") + assertNull(scheduledEpoch(day("2026-03-29"), 2, 30, zone)) + assertEquals( + Instant.parse("2026-10-25T00:30:00Z").epochSecond.toInt(), + scheduledEpoch(day("2026-10-25"), 2, 30, zone) + ) + } + + @Test + fun scheduleBoundsAndMalformedPickerFieldsCannotBecomeImmediateSends() { + assertTrue(validSendSchedule(null, 1_000)) + assertFalse(validSendSchedule(1_059, 1_000)) + assertTrue(validSendSchedule(1_060, 1_000)) + assertFalse(validSendSchedule((1_000 + 365L * 86400 + 1).toInt(), 1_000)) + assertFalse(validSendSchedule(Int.MAX_VALUE, 1_000)) + assertNull(scheduledEpoch(null, 12, 0, ZoneOffset.UTC)) + assertNull(scheduledEpoch(day("2026-03-01"), 24, 0, ZoneOffset.UTC)) + } +} 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 be9538cef..02274ae07 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 @@ -56,6 +56,7 @@ class SettingsComponent( openNotifications: Boolean = false, val appUpdate: AppUpdateController? = null, val updatesEnabled: Boolean = true, + onBeforeLogout: suspend () -> Unit = {}, ) : ComponentContext by componentContext, ProxySession { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -69,6 +70,7 @@ class SettingsComponent( appVersion = appVersion, buildStamp = buildStamp, pushRegistration = pushRegistration, + beforeLogout = onBeforeLogout, ).create() } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt index 66030f6ea..6bb15e610 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt @@ -69,6 +69,7 @@ internal class SettingsStoreFactory( private val appVersion: String, private val buildStamp: String, private val pushRegistration: PushRegistration?, + private val beforeLogout: suspend () -> Unit = {}, private val clearKindCache: (String) -> Long = { kind -> mediaRepository?.clearKindCache(kind) ?: 0L }, @@ -185,6 +186,7 @@ internal class SettingsStoreFactory( dispatch(Msg.Loading(true)) scope.launch { try { + beforeLogout() pushRegistration?.unregisterPush() when (val result = client.logout()) { is Outcome.Err -> dispatch(Msg.Error(result.telegramError)) diff --git a/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt b/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt index ef162f7bf..5f835b91a 100644 --- a/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt +++ b/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt @@ -119,7 +119,36 @@ class SettingsStoreCacheTest { } } - private class StubClient : MtprotoClient { + @Test + fun logoutWaitsForWorkersBeforeRpcAndCacheClearing() { + val steps = mutableListOf() + val stopped = kotlinx.coroutines.CompletableDeferred() + val store = SettingsStoreFactory( + DefaultStoreFactory(), + StubClient { steps += "rpc" }, + sessionStore = null, + warmup = object : org.monogram.core.database.OfflineWarmup() { + override suspend fun clearAccountCache() { + steps += "clear" + } + }, + mediaRepository = null, + appVersion = "test", + buildStamp = "test", + pushRegistration = null, + beforeLogout = { steps += "stop-start"; stopped.await(); steps += "stop-end" }, + ).create() + try { + store.accept(SettingsStore.Intent.Logout) + assertEquals(listOf("stop-start"), steps) + stopped.complete(Unit) + assertEquals(listOf("stop-start", "stop-end", "rpc", "clear"), steps) + } finally { + store.dispose() + } + } + + private class StubClient(private val onLogout: suspend () -> Unit = {}) : MtprotoClient { override suspend fun connect() = Outcome.Ok(Unit) override suspend fun sendAuthCode(phone: String) = unused() override suspend fun signIn(phone: String, phoneCodeHash: String, code: String) = unused() @@ -172,7 +201,9 @@ class SettingsStoreCacheTest { override fun updates(): Flow = MutableSharedFlow() override fun sessionLost(): Flow = emptyFlow() override fun libraryVersion() = "test" - override suspend fun logout() = Outcome.Ok(Unit) + override suspend fun logout(): Outcome { + onLogout(); return Outcome.Ok(Unit) + } override fun close() = Unit private fun unused(): Outcome = Outcome.Err("unused") } diff --git a/native/Cargo.lock b/native/Cargo.lock index 03515272f..212ea2004 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -101,7 +101,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -239,9 +239,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "shlex", @@ -305,7 +305,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -331,9 +331,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -414,9 +414,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -474,9 +474,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", ] @@ -531,9 +531,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -553,9 +553,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -568,11 +568,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ "autocfg", ] @@ -728,9 +734,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] @@ -812,9 +818,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -866,63 +872,246 @@ dependencies = [ ] [[package]] -name = "monogram-markup" +name = "mono-proto" +version = "0.2.0+layer.232" +dependencies = [ + "bnum", + "mono-proto-codec", + "thiserror", +] + +[[package]] +name = "mono-proto-client" version = "0.1.0" dependencies = [ - "compact_str", - "tree-sitter", - "tree-sitter-md", - "uniffi", + "mono-proto", + "mono-proto-crypto", + "mono-proto-media", + "mono-proto-messages", + "mono-proto-observability", + "mono-proto-runtime", + "mono-proto-session", + "mono-proto-socket", + "mono-proto-storage", + "mono-proto-transport", + "mono-proto-types", + "mono-proto-updates", + "parking_lot", + "serde_json", + "zeroize", ] [[package]] -name = "monogram-mtproto" +name = "mono-proto-codec" version = "0.1.0" dependencies = [ - "aes", - "aes-gcm", "bnum", + "thiserror", +] + +[[package]] +name = "mono-proto-crypto" +version = "0.1.0" +dependencies = [ + "aes", "cipher", - "compact_str", "ctr", + "getrandom 0.4.3", + "num-bigint 0.5.1", + "num-integer", + "num-traits", + "rsa", + "sha1 0.11.0", + "sha2 0.11.1", + "subtle", + "thiserror", +] + +[[package]] +name = "mono-proto-engine" +version = "0.1.0" +dependencies = [ "flate2", - "indexmap", - "jni", - "monogram-mtproto-transport", + "mono-proto-codec", + "mono-proto-crypto", + "mono-proto-session", + "mono-proto-transport", + "thiserror", +] + +[[package]] +name = "mono-proto-media" +version = "0.1.0" +dependencies = [ + "aes", + "cipher", + "ctr", + "mono-proto", + "mono-proto-observability", + "mono-proto-runtime", + "mono-proto-session", + "mono-proto-types", + "serde", + "serde_json", + "sha2 0.11.1", +] + +[[package]] +name = "mono-proto-messages" +version = "0.1.0" +dependencies = [ + "mono-proto", + "mono-proto-crypto", + "mono-proto-media", + "mono-proto-runtime", + "mono-proto-session", + "mono-proto-types", + "serde", + "serde_json", +] + +[[package]] +name = "mono-proto-observability" +version = "0.1.0" +dependencies = [ + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "mono-proto-runtime" +version = "0.1.0" +dependencies = [ + "aes", + "bnum", + "cipher", + "flate2", + "mono-proto", + "mono-proto-codec", + "mono-proto-crypto", + "mono-proto-engine", + "mono-proto-observability", + "mono-proto-session", + "mono-proto-socket", + "mono-proto-storage", + "mono-proto-transport", + "mono-proto-types", "num-bigint 0.5.1", "num-traits", "parking_lot", "pbkdf2", - "rapidhash", + "serde_json", + "sha2 0.11.1", + "zeroize", +] + +[[package]] +name = "mono-proto-session" +version = "0.1.0" +dependencies = [ + "getrandom 0.4.3", + "serde", + "thiserror", + "zeroize", +] + +[[package]] +name = "mono-proto-socket" +version = "0.1.0" +dependencies = [ + "base64", + "mono-proto-crypto", + "mono-proto-transport", + "parking_lot", + "rustls", "rustls-platform-verifier", - "rxing", + "thiserror", +] + +[[package]] +name = "mono-proto-storage" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "mono-proto-crypto", + "mono-proto-observability", + "mono-proto-session", + "mono-proto-types", + "parking_lot", + "serde", + "serde_json", + "zeroize", +] + +[[package]] +name = "mono-proto-transport" +version = "0.1.0" +dependencies = [ + "crc32fast", + "getrandom 0.4.3", + "mono-proto-crypto", + "thiserror", +] + +[[package]] +name = "mono-proto-types" +version = "0.1.0" +dependencies = [ + "compact_str", + "indexmap", + "mono-proto", + "rapidhash", "serde", "serde_json", - "sha2 0.11.0", "smallvec", - "tellers-mtproto", - "tellers-mtproto-codec", - "tellers-mtproto-crypto", - "tellers-mtproto-engine", - "tellers-mtproto-session", - "tellers-mtproto-transport", "thiserror", - "tlottie", +] + +[[package]] +name = "mono-proto-updates" +version = "0.1.0" +dependencies = [ + "mono-proto", + "mono-proto-media", + "mono-proto-messages", + "mono-proto-runtime", + "mono-proto-session", + "mono-proto-types", +] + +[[package]] +name = "monogram-markup" +version = "0.1.0" +dependencies = [ + "compact_str", + "tree-sitter", + "tree-sitter-md", "uniffi", - "zeroize", ] [[package]] -name = "monogram-mtproto-transport" +name = "monogram-mtproto" version = "0.1.0" dependencies = [ - "base64", + "flate2", + "jni", + "mono-proto", + "mono-proto-client", + "mono-proto-media", + "mono-proto-messages", + "mono-proto-observability", + "mono-proto-runtime", + "mono-proto-socket", + "mono-proto-types", "parking_lot", - "rustls", "rustls-platform-verifier", - "tellers-mtproto-crypto", - "tellers-mtproto-transport", + "rxing", + "serde", + "serde_json", "thiserror", + "tlottie", + "uniffi", ] [[package]] @@ -1312,9 +1501,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -1325,9 +1514,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -1387,9 +1576,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1528,15 +1717,16 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", "indexmap", "itoa", "memchr", @@ -1547,9 +1737,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -1589,9 +1779,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -1638,9 +1828,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -1650,9 +1840,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "smawk" @@ -1713,81 +1903,15 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "tellers-mtproto" -version = "0.2.0+layer.232" -dependencies = [ - "bnum", - "thiserror", -] - -[[package]] -name = "tellers-mtproto-codec" -version = "0.1.0" -dependencies = [ - "tellers-mtproto", - "thiserror", -] - -[[package]] -name = "tellers-mtproto-crypto" -version = "0.1.0" -dependencies = [ - "aes", - "cipher", - "ctr", - "getrandom 0.4.3", - "num-bigint 0.5.1", - "num-integer", - "num-traits", - "rsa", - "sha1 0.11.0", - "sha2 0.11.0", - "subtle", - "thiserror", -] - -[[package]] -name = "tellers-mtproto-engine" -version = "0.1.0" -dependencies = [ - "flate2", - "tellers-mtproto-codec", - "tellers-mtproto-crypto", - "tellers-mtproto-session", - "tellers-mtproto-transport", - "thiserror", -] - -[[package]] -name = "tellers-mtproto-session" -version = "0.1.0" -dependencies = [ - "getrandom 0.4.3", - "serde", - "thiserror", - "zeroize", -] - -[[package]] -name = "tellers-mtproto-transport" -version = "0.1.0" -dependencies = [ - "crc32fast", - "getrandom 0.4.3", - "tellers-mtproto-crypto", - "thiserror", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -1828,7 +1952,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1837,9 +1961,9 @@ version = "1.0.6" [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -1852,27 +1976,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tree-sitter" @@ -1890,15 +2014,15 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "tree-sitter-md" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +checksum = "8ed661bfe3c264961425f77cd89ad2998bf5d629e71ce8bab1e5d7e940d6246e" dependencies = [ "cc", "tree-sitter-language", @@ -1912,9 +2036,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -1924,15 +2048,15 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] name = "uniffi" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf78ecfb9bb8d7c4f8da11e8eb2ab6304e8ec0adb3ab32d8fc77c21a5bb0b86" +checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" dependencies = [ "anyhow", "camino", @@ -1947,9 +2071,9 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdea7c4574723928bef87dfc04b203d96321d0bf34ab295a56379e633be579f3" +checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" dependencies = [ "anyhow", "askama", @@ -1973,9 +2097,9 @@ dependencies = [ [[package]] name = "uniffi_build" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfe2d9769b6c0c15d7c7832d53eaa595c683bb80a30512614a67268ce16132f9" +checksum = "925be5c36297fff0d416fa2f03585ace718b6e6c635466754128d00f0862d5f1" dependencies = [ "anyhow", "camino", @@ -1984,9 +2108,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b1c62ee415b805f063c82e8ef6bbcaa1b87f8eed360cf217537b7724e05601" +checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" dependencies = [ "anyhow", "bytes", @@ -1996,9 +2120,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79d4a3129d6c2c15e367d3954886a0533dd3e85d445ce4d93379f62c6949d7b" +checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" dependencies = [ "anyhow", "indexmap", @@ -2009,9 +2133,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f6a9826a83f1140b2ec67c7944f562e1ef616e6eaf40e76247adccbd5b8a091" +checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" dependencies = [ "camino", "fs-err", @@ -2026,9 +2150,9 @@ dependencies = [ [[package]] name = "uniffi_meta" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf233eb014b595e8997c98df4ac6863b486a4ac6d54bd00230c7700753d7ef13" +checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" dependencies = [ "anyhow", "siphasher", @@ -2038,9 +2162,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55179706fbb6e7a23e729780d2c6165e6afe1ce725fa63754bcf217b440afc04" +checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" dependencies = [ "anyhow", "heck", @@ -2051,9 +2175,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421a9add7a19c5a264a2aba760514125ef3840a8b246ec88237fc91e3f2f79b1" +checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" dependencies = [ "anyhow", "textwrap", @@ -2131,7 +2255,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -2323,18 +2447,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -2343,9 +2467,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zlib-rs" diff --git a/native/Cargo.toml b/native/Cargo.toml index 802081a6a..061a03071 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["mtproto-rs", "mtproto-transport", "markup-rs"] +members = ["mtproto-rs", "markup-rs"] [profile.release] opt-level = "z" diff --git a/native/markup/src/main/java/uniffi/monogram_markup/monogram_markup.kt b/native/markup/src/main/java/uniffi/monogram_markup/monogram_markup.kt index 9bc19206b..a4f8d9d72 100644 --- a/native/markup/src/main/java/uniffi/monogram_markup/monogram_markup.kt +++ b/native/markup/src/main/java/uniffi/monogram_markup/monogram_markup.kt @@ -672,6 +672,8 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + + internal fun ensureInitialized() = Unit external fun uniffi_monogram_markup_checksum_func_extract_math( ): Int external fun uniffi_monogram_markup_checksum_func_highlight_code( @@ -697,6 +699,8 @@ internal object UniffiLib { Native.register(UniffiLib::class.java, findLibraryName(componentName = "monogram_markup")) } + + internal fun ensureInitialized() = Unit external fun uniffi_monogram_markup_fn_func_extract_math(`raw`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_markup_fn_func_highlight_code(`code`: RustBuffer.ByValue,`language`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -852,10 +856,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { * @suppress */ public fun uniffiEnsureInitialized() { - IntegrityCheckingUniffiLib - // UniffiLib() initialized as objects are used, but we still need to explicitly - // reference it so initialization across crates works as expected. - UniffiLib + // Call arbitrary methods on IntegrityCheckingUniffiLib and UniffiLib to ensure that + // their init blocks run. This ensures initialization across crates works as expected. + IntegrityCheckingUniffiLib.ensureInitialized() + UniffiLib.ensureInitialized() } // Async support diff --git a/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so b/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so index 0f5f85c0e..4d929c5ef 100644 Binary files a/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so and b/native/markup/src/main/jniLibs/arm64-v8a/libmonogram_markup.so differ diff --git a/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so b/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so index ba9849bc7..03253a5ef 100644 Binary files a/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so and b/native/markup/src/main/jniLibs/armeabi-v7a/libmonogram_markup.so differ diff --git a/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so b/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so index 0ac8810f2..f7e652740 100644 Binary files a/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so and b/native/markup/src/main/jniLibs/x86_64/libmonogram_markup.so differ diff --git a/native/mtproto-rs/ARCHITECTURE.md b/native/mtproto-rs/ARCHITECTURE.md deleted file mode 100644 index 3c7907803..000000000 --- a/native/mtproto-rs/ARCHITECTURE.md +++ /dev/null @@ -1,969 +0,0 @@ -# Monogram MTProto architecture - -How the library is built and why. This page is for maintainers, reviewers, and -anyone embedding the library who wants to know what happens behind an exported -call. For the API see [USAGE.md](USAGE.md); for setup see [README.md](README.md). - -## Contents - -- [Overview](#overview) -- [End-to-end flows](#end-to-end-flows) -- [Source map](#source-map) -- [Design decisions](#design-decisions) -- [Handles, state, and concurrency](#handles-state-and-concurrency) -- [FFI boundary](#ffi-boundary) -- [Threads](#threads) -- [Scheduling](#scheduling) -- [RPC path](#rpc-path) -- [Live updates](#live-updates) -- [Downloads](#downloads) -- [Media index](#media-index) -- [Uploads](#uploads) -- [Auth and sessions](#auth-and-sessions) -- [Network and proxies](#network-and-proxies) -- [Security model](#security-model) -- [Limits and backpressure](#limits-and-backpressure) -- [Failure matrix](#failure-matrix) -- [TL layers and schema](#tl-layers-and-schema) -- [Maintenance](#maintenance) -- [Extending the library](#extending-the-library) -- [Testing](#testing) -- [Invariants](#invariants) -- [References](#references) - -## Overview - -```text -host application (Kotlin, Swift, Python, Rust, ...) - | generated bindings - v -src/ffi.rs, src/dto.rs exported functions, handles, DTOs - | blocking calls - v -client/, rpc/ handles, lanes, scheduling, updates, media, - framing, retries, salts, timeouts, migration - | engine + codec - v -tellers-mtproto-* TL types, codec, crypto, engine, session - | framed bytes - v -monogram-mtproto-transport TCP, HTTP, SOCKS5, MTProxy - | - v -Telegram datacenters -``` - -The library lives in one process and is addressed through integer handles. Calls -block at the FFI edge. The host wrapper decides which thread runs them and -attaches a request token and a dispatch class. The library owns all socket, retry, -and session state. - -Monogram's Kotlin bridge (`network/bridge`) is one such wrapper. It runs calls on -worker dispatchers and maps DTOs to app models. Nothing in the library depends on -it. - -Three ideas shape most of the design: - -1. **Do protocol work in Rust, scheduling in the host.** The boundary stays small - and synchronous. Priorities and cancellation are passed in through thread - state. -2. **Never lose or duplicate work.** Updates are journaled and acknowledged, - downloads are staged and resumable, sends are matched by random id, and - mutations are not replayed blindly. -3. **Fail closed.** Unknown constructors, unverified CDN data, wrong keys, and - mixed schemas stop the operation instead of guessing. - -## End-to-end flows - -**Login** - -1. The host calls `sendAuthCode` with the phone number. -2. The auth path creates or restores the auth key and sends `auth.sendCode`. -3. `AuthCodeSent` returns the phone hash, `codeType`, and length. -4. `signIn` sends `auth.signIn`. The server may answer that a password is needed. -5. `checkPassword` completes SRP when required. -6. The host calls `startUpdates` and begins draining. - -**Update delivery** - -1. The update lane receives packets and the reorder buffer resolves sequencing. -2. Durable events enter the journal together with the cursor they advanced. -3. The host drains a batch, commits it, then acknowledges it. -4. A crash before acknowledgement replays the batch. - -**Download** - -1. The host sets a media dispatch class and calls a download export. -2. A media lane binds a temporary key if needed and the transfer is admitted per - datacenter. -3. `upload.getFile` parts fill the profile's window. -4. Parts are written to the staging file and the ranges sidecar is updated. -5. CDN parts are decrypted and hash-verified, or the download falls back to - origin. -6. The staging file is published by rename under the session lease. - -**Send** - -1. The host supplies a fresh random id and calls a send export. -2. The request goes out on the main lane (or the upload lanes first, for media). -3. The call returns the message, or fails with an error that says whether delivery - is uncertain. -4. Independently, `UpdateMessageId::` arrives as a normal - update, so even a lost response is resolved. - -## Source map - -| Path | Responsibility | -| --- | --- | -| `native/mtproto-rs/src/ffi.rs` | UniFFI exports, handle arguments, DTO construction | -| `src/dto.rs` | Records and enums that cross the boundary | -| `src/client/mod.rs` | Handle registry, client data, merge-back, persistence scheduling | -| `src/client/lanes.rs` | Main, read, media, and upload lane types and acquisition | -| `src/client/updates.rs` | Drain loop, journal integration, channel recovery | -| `src/scheduler.rs` | Lane gates, request classes, aging | -| `src/rpc/invoke.rs` | Attempt loop, framing, correlation, retries | -| `src/rpc/supervisor.rs` | Failure classification and retry ownership | -| `src/rpc/salts.rs` | Server salt windows | -| `src/rpc/timeout.rs` | RPC budgets, streaming extension, keepalive | -| `src/rpc/dc.rs` | Static datacenter endpoints and rotation | -| `src/media/download.rs` | Download windows, staging, resume, publication | -| `src/media/cdn.rs` | CDN redirects, decryption, hash verification | -| `src/media/index.rs` | Media index, persistence limits, avatar helpers | -| `src/upload/upload_rpc.rs` | File parts, big files, albums | -| `src/auth/auth_rpc.rs` | Phone code, sign-in, password | -| `src/auth/auth_key.rs` | DH auth-key creation | -| `src/auth/bind_temp.rs` | Temporary key binding for extra sessions | -| `src/auth/srp.rs` | SRP for 2FA | -| `src/session_file.rs` | Session JSON and atomic persistence | -| `src/session_crypto.rs` | AES-256-GCM session envelope | -| `src/update_journal.rs` | Durable update batches | -| `src/update_buffer.rs` | Bounded reorder window | -| `src/request_control.rs` | Cancellation tokens and socket registration | -| `native/mtproto-transport` | TCP, proxies, obfuscation, TLS and fake TLS | -| `native/mtproto` | Generated Kotlin bindings used by the Android app | - -## Design decisions - -| Problem | Solution | Cost | Main source | -| --- | --- | --- | --- | -| The TL schema changes every layer | Generated schema and codec from the sibling `tellers-mtproto` runtime | External checkout; layer is pinned | `Cargo.toml` | -| Hosts have different async models | Blocking FFI; the host wraps calls in its own concurrency | Worker threads and per-thread state | `ffi.rs` | -| One slow request blocks unrelated work | Main, read, media, and upload lanes with priority gates | More sockets and temporary keys | `scheduler.rs`, `lanes.rs` | -| Lanes can overwrite each other's state | Clone `ClientState`, run the call, merge only changed entries | Field-diff discipline | `client/mod.rs` | -| Updates must survive crashes | Durable journal with explicit acknowledgement | Consumers must be idempotent | `update_journal.rs` | -| Downloads must survive process death | Staging file, missing-range sidecar, identity sidecar | Disk use and cleanup | `media/download.rs` | -| CDN data can be corrupt | SHA-256 check against master-DC hashes, origin fallback | Slower fallback | `media/cdn.rs` | -| One authorization allows one main session | Extra sessions use bound temporary keys and `tmp_sessions` | Extra auth work; fallback to main | `auth/bind_temp.rs`, `scheduler.rs` | -| Blocking and censorship | SOCKS5, HTTP, HTTPS, MTProxy, DoH `help.configSimple` | Proxy compatibility | `mtproto-transport` | -| Session files hold sensitive data | AES-256-GCM with a caller-supplied key | Device-bound backups, caller manages the key | `session_crypto.rs` | - -Trade-offs behind these choices: - -- **Generated protocol code over hand-written TL.** Telegram declarations are - never edited by hand, so a layer bump is a deliberate, tested change. -- **Blocking FFI over a callback reactor.** The boundary stays small and easy to - bind. Scheduling and cancellation belong to the host. -- **Separate lanes over one socket.** No head-of-line blocking, and it matches - Telegram's parallel-session rules, at the cost of sockets and key management. -- **At-least-once updates over exactly-once.** A batch can be replayed, so the - application must be idempotent. The alternative, losing a batch on a crash, is - worse. -- **Staged media over writing the final path.** A failed download never destroys - the previous file and can resume, but staging files need cleanup. -- **Origin fallback over trusting the CDN.** Correctness over edge bandwidth. -- **Caller-owned session key over built-in key storage.** Each platform has its - own secure storage, and a library cannot pick one for all of them. - -## Handles, state, and concurrency - -`create_client` returns a `u64` that indexes a process-global `CLIENTS` map. -Every other export looks the handle up. A handle owns one session file and one -authorization; a second handle or process on the same authorization can cause -`AUTH_KEY_DUPLICATED`. - -A `Client` contains: - -- one `main` lane and gate, carrying ordinary RPCs and the update connection -- two extra read lanes, used only when the server allows parallel sessions and a - temporary key is bound -- up to eight media lanes, opened as needed up to the configured count -- four upload lanes -- a connection control object, a transfer policy, and shared `ClientData` - (peers, update cursors, media index, journal, snapshots, caches) - -Most stateful calls go through `with_client_mut`. It clones a `ClientState` from -`ClientData` and the main-lane snapshot, runs the operation, and merges back only -what changed. This stops a lane that spent time on the network from overwriting -peer or media entries another lane wrote in the meantime. The merge is -field-diffed on purpose: assigning the whole state back would bring the -lost-update race back. - -If the closure changes the auth key, session id, or DC, the lane's transport is -dropped instead of being returned to the pool. A socket bound to an old session -must not be reused after an auth restart, bad-message recovery, or migration. - -`session_dead` is set when the server revokes the authorization or the auth key -is duplicated. It is saved with the session, so later calls fail fast with the -stored reason. - -## FFI boundary - -The crate exports top-level functions, records, enums, one error enum, and one -callback interface. It does not export UniFFI objects or async functions: - -- client handles are `u64` values -- DTOs are `uniffi::Record` and `uniffi::Enum` types in `src/dto.rs` -- errors are `MtprotoError` variants in `src/error.rs` -- download progress uses the `DownloadProgressListener` callback interface -- every exported call is synchronous - -There is no generated `close` or `Dispose`. `destroyClient` closes sockets and -flushes the session; after that the handle is invalid. - -Type mapping: - -| Rust/UniFFI | Kotlin | Swift | Python | Ruby | -| --- | --- | --- | --- | --- | -| `u64` | `ULong` | `UInt64` | `int` | `Integer` | -| `i64` | `Long` | `Int64` | `int` | `Integer` | -| `String` | `String` | `String` | `str` | `String` | -| `Vec` | `ByteArray` | `Data` | `bytes` | `String`/bytes | -| record | data class | struct | class | generated record | -| enum | sealed class | enum | enum/class | enum/class | -| `Result` | exception | `throws` | exception | exception | - -Collections and byte buffers are copied across the boundary. Media bytes never -cross it: downloads write to a destination path and return the path. - -Bindings are generated from the compiled library, not from a hand-written IDL -(commands in [USAGE](USAGE.md#other-languages)). The generated Kotlin is checked -in at `native/mtproto/src/main/java`; bindings for other languages are the -embedder's artifacts. The crate type is `cdylib`, and static linking needs a -`staticlib` target. - -An exported function runs on the caller's thread. Hosts must supply their own -worker thread and request-control binding. Progress callbacks run on the native -download thread. - -## Threads - -There is no hidden async runtime. Work runs on the caller's thread and on a few -named native threads: - -- **Caller thread:** enters through `ffi.rs` and blocks until the lane returns. - It may itself be one of the lane threads. -- **Media preparation:** a second media lane may be prepared on a short-lived - worker while the first lane starts downloading. -- **`mtproto-maintenance`:** closes idle file transports and flushes idle - acknowledgement work. -- **`mtproto-persist`:** coalesces session writes, retries with bounded backoff, - and marks persistence as exhausted after repeated failures. -- **`mtproto-transfer-config`:** refreshes server transfer limits - (`help.getAppConfig` queue settings) without blocking a user call. - -Thread-local state is the dispatch class (`set_dispatch_class`), the bound -request-control token (`bind_request_control`), and the transfer policy used by -media and scheduler code. - -A handle is created once, used from several threads, and destroyed once. -`destroyClient` removes it from the registry, flushes the session, and closes -registered sockets. Later calls fail with `UnknownClient`. Do not recreate a -handle for the same authorization in the same process. - -## Scheduling - -Lane families and dispatch classes are listed in -[USAGE: Scheduling](USAGE.md#scheduling). This section covers the mechanics. - -**Why lanes exist.** A single MTProto connection serializes everything on it. A -50 MB download would delay a history request behind it, and a stalled upload -would delay everything else. Lanes separate traffic types onto their own sockets -so each kind of work only queues behind its own kind. - -**Extra read lanes** need both the server's `config.tmp_sessions` allowance and a -successfully bound temporary key. If either is missing, reads run on the main -lane. Media lanes open on demand and upload lanes are fixed. See -[Parallel sessions](https://core.telegram.org/api/datacenter#parallel-sessions). - -**LaneGate** admits the best waiter, FIFO within a priority. Waiters above -priority 2 move up one step every 5 seconds, down to priority 1. Interactive -writes stay at priority 0. Cancellation is checked while a request is queued, so -a cancelled waiter leaves the queue without ever holding a lane. - -**Aging** is the answer to starvation. Without it, steady interactive traffic -would keep background work waiting forever. With it, a background request gains a -step every five seconds until it competes with interactive media. The floor at 1 -keeps interactive writes (0) strictly ahead. - -**Profiles from classes.** Classes 3, 5, 6, and 7 also set a download profile. -Classes 5, 6, and 7 share interactive-media priority but differ in chunk size and -parts in flight. `promoteRequestControl` changes queue priority for waiting read -requests and does not change a download profile. - -**Admission** is per datacenter and per transfer size. The defaults are five -small and two large transfers (20 MB and up); `help.getAppConfig` can supply other -queue limits, and large transfers are capped at two either way. While a DC is -recovering from a flood wait and still has active transfers, new admissions -wait. They resume once the active count reaches zero. - -## RPC path - -### Request lifecycle - -The API encodes a TL request and calls `invoke_encoded` or a batch variant. On a -fresh socket the first request is wrapped in `invokeWithLayer(initConnection(...))`; -later requests are sent bare. Non-update lanes use `invokeWithoutUpdates` so the -connection does not subscribe to updates. - -`rpc/invoke.rs` runs attempts under a supervisor: - -1. Take a parked transport or dial an endpoint. Endpoints rotate DC IPs and - ports, 443 first. -2. Ask the engine for the next outbound message. -3. Frame it, padded to a multiple of 16 bytes. -4. Send, read, and feed packets to the engine until the correlated response - arrives. - -A transport is parked only if the exchange ended on a clean frame boundary. -Timed-out or half-read transports are closed, because the next request on them -could be answered with leftover bytes. - -Specs: [MTProto](https://core.telegram.org/mtproto), -[Invoking methods](https://core.telegram.org/api/invoking). - -### Recovery - -- `bad_server_salt`: cache the salt windows and resend. -- `bad_msg_notification`: timing and sequence codes trigger session-id recovery. - Codes 19 and 64 abort the request. -- DC migration: `PHONE_MIGRATE_X`, `NETWORK_MIGRATE_X`, and `FILE_MIGRATE_X` - re-route the call. `FILE_MIGRATE_X` changes only the media DC. Media on a - foreign DC uses `auth.exportAuthorization` and a temporary key. -- Flood wait: a per-(DC, class) deadline parks transfers. Windows shrink and - then recover one step at a time after sustained success. -- Uncertain delivery: reads and idempotent part uploads may be replayed. - Mutations that may have reached the server come back as errors. -- Silent requests: overdue requests are probed with `msgs_state_req`. A status - showing the server never received the message becomes a timeout, and a - mutation is not resent. - -Gzip containers are accepted in both directions with an 8 MiB inflation limit, so -a small packet cannot expand into unbounded memory. Unknown inbound constructors -fail with the constructor id; they are never skipped. Specs: -[Errors](https://core.telegram.org/api/errors), -[Service messages](https://core.telegram.org/mtproto/service_messages). - -### Timeouts and keepalive - -The default RPC budget is 20 seconds. Media windows get 45 seconds and extend -while parts keep arriving. A socket idle for 19 seconds counts as stale for -keepalive. - -### Error shapes - -Server failures reach the host as `MtprotoError::Message("RPC : ")`. -A session the library knows is dead is reported as `session invalidated: ` -and is deliberately not shaped like a 401, so the real cause stays visible in -logs. Transport failures arrive as text such as `RPC timeout`, `connection error`, -`msg_key`, or `bad_msg_notification`. Classification of these strings is left to -the host. - -## Live updates - -### What Telegram requires - -Telegram pushes events over the last active connection and numbers them. Three -independent sequence families exist, and a client has to keep all of them -consistent (see [Working with Updates](https://core.telegram.org/api/updates)): - -| Sequence | Scope | -| --- | --- | -| `seq` | The top-level update packets (`updates`, `updatesCombined`) | -| `pts` | One per message box: the common box (private chats and basic groups), plus one for each channel or supergroup | -| `qts` | A secondary box for secret chats and certain bot events | - -Each event carries the counter value after it, and `pts` updates also carry how -many events they contain (`pts_count`). The rule for a box with local state -`local_pts`: - -| Check | Meaning | Action | -| --- | --- | --- | -| `local_pts + pts_count == pts` | In order | Apply, set `local_pts = pts` | -| `local_pts + pts_count > pts` | Already applied | Ignore | -| `local_pts + pts_count < pts` | Updates were missed | Fill the gap | - -`qts` works the same way with a count of one. `seq` has a special case: a -`seq_start` of 0 means the update is unordered and applies immediately. - -Gaps are filled with `updates.getDifference` (common and secret state) or -`updates.getChannelDifference` (one channel). A `differenceSlice` means more is -waiting, so the call repeats with the intermediate state; a channel difference -without the `final` flag repeats the same way. A difference can also come back -"too long" when the client's state is older than the server keeps, in which case -the client has to fetch fresh state and refill old history with explicit message -fetches. - -Telegram lists the situations that require manual recovery: a gap in `seq`, -`pts`, or `qts` (it suggests allowing a short time for reordered packets to fill -the hole first), a "new session created" service notification, data that cannot be -decoded, an incomplete short-form update, a long silence, and the server's own -`updatesTooLong` and `updateChannelTooLong` hints. Updates received on an -unencrypted connection must always be ignored. - -Message ids form separate sequences too: one shared by private chats and basic -groups, one per channel or supergroup, one per secret chat (identified by -`random_id`), and separate ones for scheduled and quick-reply messages. The -`random_id` on a send is stored by the server permanently and used to deduplicate, -and `updateMessageID` ties it to the generated message id. - -### What the library does - -**Polling and the reorder buffer.** The host calls `startUpdates` once and then -polls `drainUpdates`; the crate never calls the host for updates. -`drainUpdates` does nothing until the client is authorized and `startUpdates` has -set the flag. - -Inbound packets go into a bounded reorder window (256 packets or 8 MiB). -Sequenced events wait for their predecessor, which absorbs reordering without -asking the server for anything. A gap that cannot be resolved triggers difference -recovery. - -**Journal and acknowledgement.** - -1. Durable events are grouped into an `UpdateJournal` batch. -2. The journal is saved with its cursor and channel cursors, capped at 256 - unacknowledged batches or 8 MiB. -3. While undelivered batches exist, `drainUpdates` returns `replayRequired`. The - host reads `pendingUpdateBatches`, applies them, and acknowledges. -4. Acknowledging removes the batch. Until then a crash replays it. - -Non-durable events (typing, presence, sync edges) are returned in `liveEvents` -and never journaled: replaying "user is typing" after a restart would be wrong. - -**Cursors.** `pts`, `qts`, `seq`, and `date` become durable only after -`getDifference` applies a cursor. The head from `updates.getState` is an -in-memory bootstrap value and is not saved, so a crash cannot claim work that was -never applied. The journal stores each batch with the cursor it advanced, so -replay and cursor stay consistent. - -**Channels.** Channel `pts` is tracked per channel. Lagging channels enter a -recovery queue processed at a measured rate, so catching up after a long offline -period does not hammer the server or starve other traffic. `updateLazySyncConfig` -keeps off-screen gaps durable without recovering them immediately, and exception -chat ids stay eager. - -**Vocabulary.** `UpdateEventDto` is a closed vocabulary: messages, edits, -deletions, typing, presence and emoji status, read receipts, reactions, -discussion reads, dialog patches, folder and chat changes, saved GIFs, and -`SyncState`. Unknown constructors become `Ignored { kind }`. A pending-send -confirmation is `UpdateMessageId::`. - -### Why a journal - -An in-memory queue loses events when the process dies between "received from the -server" and "applied by the app". Telegram has already advanced its own state for -the connection, and the client's saved cursor must not move past work the app -never applied. The journal makes the hand-off explicit: the library stores the -batch with its cursor, the host applies and commits, and only the host's -acknowledgement releases the batch. That is the full contract, and it is why the -order "apply, commit, acknowledge" must never be swapped. - -Spec: [Updates](https://core.telegram.org/api/updates). - -## Downloads - -### Telegram's file rules - -Files are fetched in parts with `upload.getFile`. Telegram requires the offset and -limit to be multiples of 4 KiB and a request not to cross a 1 MiB boundary, so -every chunk size the library offers (128, 256, 512 KiB) divides 1 MiB. Files can -live on a different datacenter than the account (`FILE_MIGRATE_X`), can be served -from a CDN (`fileCdnRedirect`), and are addressed by a file reference that -expires. Specs: [Files](https://core.telegram.org/api/files), -[Optimization](https://core.telegram.org/api/optimisation). - -### Profile selection - -`download_message_media` picks a `DownloadProfile` in this order: - -| Request | Profile | -| --- | --- | -| `Thumb` | Thumb | -| `Full` with an offset | Playing | -| Media class 3, 5, 6, or 7 set | Background, Ordinary, Visible, or User | -| `Display` with no profile | Visible | -| Any other `Full` | User | - -Chunk sizes and parts in flight per profile are in -[USAGE: Download profiles](USAGE.md#download-profiles); Thumb is 128 KiB with one -part, and Playing is 512 KiB with eight. `set_download_chunk_kib` can lower the -chunk, and `set_download_concurrency` sets the pipeline limit each profile is -capped by. - -### The pipeline - -A download keeps a window of parts in flight on one media lane, sized by the -profile. As parts complete, new offsets are issued so the connection stays full -instead of waiting for a round trip per part. A second media lane may be prepared -on a short-lived worker while the first one starts, which hides the cost of -opening a socket and binding a temporary key. Part ordering does not matter for -correctness because each part is written at its own offset and recorded in the -ranges file. - -### Staging and publication - -A network download writes to `...part`. The missing ranges -go to `...ranges.part` and the file identity (file id, access hash, DC, size, -version) to `...identity.part`. The ranges file lets an interrupted transfer -resume from its holes instead of discarding complete bytes. On the next full -download to the same destination, the largest staging file is chosen and its -identity compared; a mismatch starts over, so bytes from an older version of a -file can never be spliced into a new one. - -A per-destination lock serializes downloads to the same path and is held for the -lifetime of the staging file. - -Publication checks the session lease again. The staging file is renamed onto the -destination, the sidecars are removed, and orphaned sidecars are swept. -Retryable errors and cancellation keep the staging file; other errors drop it. A -stale-staging sweep collects files older than two minutes and, if they exceed -32 MiB, deletes the oldest first until the directory is under the cap. - -`.cancel` markers are checked between parts and before publication. The library -never removes the marker. - -Progress goes through one process-wide callback. Inline media (a -`photoStrippedSize` or cached thumbnail) skips the network and is written -directly. - -### CDN - -When `upload.getFile` returns `fileCdnRedirect`, the downloader: - -1. fetches `help.getCdnConfig` and selects the public key for the CDN DC -2. builds a temporary auth key from that key -3. requests `upload.getCdnFile` parts -4. decrypts each part with AES-256-CTR, using a counter IV derived from the part - offset -5. checks the part against the master-DC `file_hashes` before writing - -A part the CDN does not have yet is re-uploaded on request (`REUPLOAD_NEEDED`); -after more than three reuploads of one part the download gives up on the CDN. A -nonzero CDN offset is not resumed and falls back to the origin DC. Any CDN -failure also falls back to a plain origin download with the CDN disabled: a -missing hash, a hash mismatch, or bad ciphertext. The CDN is never needed for -correctness, and the trust anchor is the master datacenter, not the CDN host. -Spec: [CDN verification](https://core.telegram.org/cdn#verifying-files). - -### File references - -`FILE_REFERENCE_*` failures re-fetch the source constructor (message, sticker -set, wallpaper, or inline query) and retry once, even if the returned token looks -unchanged. Thumbnail failures try the next getFile-sized thumbnail. Spec: -[File references](https://core.telegram.org/api/file-references). - -### Flood recovery - -A flood wait parks the DC and class, halves the window, and then restores one -in-flight slot at a time after sustained success. CDN and origin downloads share -the same admission policy. The shape (cut fast, recover slowly) keeps a -rate-limited datacenter from being hit again immediately after the wait ends. - -## Media index - -`MediaIndex` is an insertion-ordered map from `(chat_id, message_id)` to a -`MediaRef`. An entry carries the location, size, kind, cache key, and optional -thumb and display locations. Persisted limits: - -- 500 message entries -- 200 zero-id document entries (stickers, GIFs, inline files, custom emoji) -- 32 instant-view entries -- unlimited peer avatars - -Instant-view inline previews larger than 4 KiB are stripped before saving. -Message and avatar inline bytes are saved as they are. - -Peer avatars use `(peerId, 0)`. They are inserted by dialog indexing (`getChats`), -profile loading, and user lists. If a download targets `(peerId, 0)` and the -entry is missing, the client refreshes the peer's profile or the dialog list -before failing. A profile photo with a video variant is stored as `video_avatar`. - -`peek_message_inline_thumb` and `peek_peer_inline_thumb` return an already -expanded JPEG or `null`. They make no RPC and skip the main lane lock, but they -briefly hold the client data mutex. - -## Uploads - -Uploads open the source file, read it in parts, and send `upload.saveFilePart` or -`upload.saveBigFilePart` over the upload lanes. Part size is 32 KiB or 512 KiB. -If the link round trip is one second or more, or the file is 32 KiB or smaller, -one 32 KiB part is in flight at a time; otherwise up to eight. - -Files over 10 MiB use the big-file method, and photos over 10 MiB are rejected. -Albums are validated before sending: at most ten items, and photos or videos -cannot be mixed with documents. - -Upload parts are idempotent, which is why a part that may not have arrived can -be resent, while the final send is a mutation and is not. - -## Auth and sessions - -**Auth-key creation** runs the standard DH exchange, validates the server's DH -parameters, and uses the production or test RSA key. Specs: -[MTProto description](https://core.telegram.org/mtproto/description), -[auth-key test vectors](https://core.telegram.org/mtproto/samples-auth_key). - -### Temporary keys and PFS - -Extra read lanes and media lanes use short-lived keys bound with -`auth.bindTempAuthKey`. The permanent key stays on the main session. The bind -call uses the MTProto v1 encrypted envelope (`auth/bind_temp.rs`). - -Why this gives forward secrecy: traffic on these lanes is encrypted under keys -that expire and are never written to disk. Compromising the stored permanent key -later does not decrypt what was sent under an expired temporary key. For -foreign-DC media the library first exports an authorization to the target DC and -then binds a temporary key there. CDN downloads use their own temporary key built -from the CDN public key. - -Servers grant parallel sessions through `config.tmp_sessions`; without it the -extra lanes are simply not used and reads go to the main lane. Specs: -[PFS](https://core.telegram.org/api/pfs), -[Datacenters](https://core.telegram.org/api/datacenter). - -### SRP - -Two-factor sign-in lives in `auth/srp.rs`, driven by `checkPassword`. `signIn` only -reports `PasswordRequired`. The password is processed locally to answer the -server's challenge and is not sent in the clear. Spec: -[SRP](https://core.telegram.org/api/srp). - -### Session file and encryption - -`FileSessionStore` writes one JSON document with the snapshot, user id, peers, -update cursors, channel recovery queue, media index, journal, and imported -foreign-DC keys. Writes are coalesced by an epoch counter and replaced atomically -through a temporary file and rename, so a crash leaves either the old file or the -new one, never half of each. - -With `create_encrypted_client` the document is encrypted with AES-256-GCM under a -32-byte key from the caller. GCM authenticates as well as encrypts, which is why a -wrong key or a modified file fails the restore outright and the stored session is -left untouched. The library neither wraps nor stores the key. A plaintext session -migrates to encrypted on the first encrypted restore. - -Persistence is off the hot path. The RPC result is merged in memory first, and the -write happens later on `mtproto-persist`; if repeated writes fail, persistence is -marked exhausted rather than blocking calls. - -### Dead sessions - -A session marked dead (revoked authorization or `AUTH_KEY_DUPLICATED`) keeps its -reason across restarts. A new sign-in creates a fresh MTProto session, and the -caller decides when to clear stored local state. Spec: -[Errors, 406](https://core.telegram.org/api/errors#406-not-acceptable). - -## Network and proxies - -**DC discovery.** Endpoints come from static production and test lists in -`rpc/dc.rs`, with 443 first and other ports rotated to get around blocks. They -can be extended by the signed `help.configSimple` blob fetched over DoH from -`dc_txt_domain_name` TXT records. The blob is RSA-unwrapped, AES-CBC-decrypted, -and checksum-verified before its IPs are trusted. Specs: -[Datacenters](https://core.telegram.org/api/datacenter), -[Config](https://core.telegram.org/api/config). - -**Proxies** live in `native/mtproto-transport`: SOCKS5, HTTP CONNECT, HTTPS -CONNECT, and MTProxy. CONNECT replies are validated strictly and HTTPS uses -`rustls-platform-verifier`. MTProxy supports simple, `dd`, and `ee` secrets; `ee` -uses fake TLS authenticated by the proxy secret, which is not certificate-verified -TLS. Specs: [Transports](https://core.telegram.org/mtproto/mtproto-transports), -[Security guidelines](https://core.telegram.org/mtproto/security_guidelines). - -**Transports.** The default is padded intermediate. The HTTP transport is -available for networks that only pass HTTP. - -## Security model - -- **Telegram auth keys** are created and used inside Rust. They never cross the - binding boundary and never appear in DTOs, logs, or `Debug` output. -- **Session data key.** `createEncryptedClient` takes a raw 32-byte key from the - embedder. Callers must zero their copy and protect the key themselves. The - Android app keeps a Keystore-wrapped copy. -- **Session file.** AES-256-GCM with an atomic replace. A copy on another device - cannot be restored when the wrapping key is device-bound. -- **Temporary keys** are bound to the permanent key, kept in process memory, and - never saved as plaintext credentials. -- **CDN data** is verified against the master datacenter's hashes before it is - written. -- **Logs and perf data** hold fixed labels, error types, timing spans, and - counters only. Message bodies, phone numbers, codes, API hashes, proxy secrets, - and session contents are never logged. -- **API credentials** are application credentials. The embedder decides how to - store them. -- **Proxy secrets** are raw bytes. Fake TLS authenticates the proxy secret only. -- **Media cache.** Downloaded media can be private. Eviction and clearing belong - to the application. -- **Not covered.** The default test suite does not claim live Telegram behavior, - proxy interoperability, long soak runs, or independent security review. - -## Limits and backpressure - -Queues are bounded instead of buffered without limit: - -| Stage | Bound | -| --- | --- | -| Lane waiting | Priority gate, FIFO, 5-second aging | -| Download admission | 5 small and 2 large per DC by default; large capped at 2 | -| Download window | Chunk 128, 256, or 512 KiB; 1 to 8 parts in flight | -| Media lanes | 2 by default, 8 at most, opened as needed | -| Upload parts | Up to 8 in flight, 32 or 512 KiB each | -| Update reorder buffer | 256 packets or 8 MiB | -| Update journal | 256 unacknowledged batches or 8 MiB | -| Gzip inflation | 8 MiB | -| Media index | 500 messages, 200 zero-id documents, 32 instant-view entries | -| Instant-view inline data | 4 KiB per entry | -| Staging cleanup | Files older than 120 s; 32 MiB directory cap | - -Persistence stays off the hot path. Session writes are coalesced by an epoch -counter and run on `mtproto-persist`. The RPC result is merged in memory first, -and the write is crash recovery, not part of the call's success. - -More lanes or parts mean more sockets, temporary keys, memory, and server load. -Raise them only for a measured workload; the server can still answer with flood -waits. - -Performance telemetry is opt-in. `perf_set_enabled(true)` records fixed-name -spans (API calls, first media chunk, persist, lane waits) and counters. -`perf_snapshot(reset)` returns them as JSON with per-operation counts, total and -max microseconds, and a small sample ring. - -## Failure matrix - -| Situation | What happens | -| --- | --- | -| Packets arrive out of order | The reorder buffer holds them until the predecessor arrives | -| A real gap in `pts`, `qts`, or `seq` | Difference recovery runs; the batch then enters the journal | -| Process dies before acknowledgement | The batch is replayed on the next `drainUpdates` | -| Process dies mid-download | Staging file and sidecars stay; the next call resumes | -| Staging file belongs to another file version | Identity mismatch; the download starts over | -| CDN returns a bad or unhashed part | Fall back to the origin datacenter | -| `FILE_REFERENCE_*` | Re-fetch the source and retry once | -| `FILE_MIGRATE_X` | Re-route to the media DC; export authorization if needed | -| Flood wait on a datacenter | Transfers park; the window halves and recovers slowly | -| Server says `bad_server_salt` | Cache the salt windows and resend | -| Timing or sequence `bad_msg_notification` | Session-id recovery; codes 19 and 64 abort | -| Request goes silent | `msgs_state_req` probe; a mutation is not resent | -| Reply may have been lost for a send | Matched later by `UpdateMessageId` and the random id | -| Authorization revoked or key duplicated | Session marked dead; calls fail fast; caller signs in again | -| Wrong session key or damaged file | Restore fails; the stored session is left unchanged | -| Session write fails repeatedly | Persistence marked exhausted; calls are not blocked | -| Unknown inbound constructor | Fails with the constructor id; never skipped | -| Mixed TL layers in one build | Unknown constructors or decode failures; avoid by regenerating all layers | - -## TL layers and schema - -A TL layer is a frozen snapshot of constructor and method IDs, field layouts, and -types. Telegram changes the schema between layers, so constructors and -signatures from different layers must never be mixed. Specs: -[TL layers](https://core.telegram.org/api/layers), -[TL schema](https://core.telegram.org/schema), -[invokeWithLayer](https://core.telegram.org/method/invokeWithLayer). - -### Pinned layer - -This checkout is pinned to layer 232: - -- `tellers-mtproto` is `0.2.0+layer.232` -- the default Cargo feature is `layer-232` and `LATEST_API_LAYER` is 232 -- generated modules for `layer-223`, `layer-229`, `layer-230`, `layer-231`, - and `layer-232` exist side by side -- `libraryVersion()` reports the crate version and runtime layer - -The runtime decodes and encodes with the generated constructor IDs of the -selected layer, and `api_invoke` wraps the first request on a fresh socket in -`invokeWithLayer(initConnection(...))`. - -Older layers stay for comparison, migration, and generator tests. The layer is -chosen at build time: there is no call to switch it, and mixing modules would -produce an inconsistent constructor table. A mixed build shows up as unknown -constructors, `TYPE_CONSTRUCTOR_INVALID`, decode failures, or requests the -server rejects. Unknown inbound constructors fail closed with the constructor id -in the error. - -### Schema explorer - -[schema.jppgr.am](https://schema.jppgr.am/) archives TL layers. It is useful for -diagnosing wire mismatches and planning a bump: search constructors, methods, and -types, diff layers, look up errors, and decode payloads. - -- Raw pinned layer: https://schema.jppgr.am/layers/232.tl -- Layer 231 to 232 diff: https://schema.jppgr.am/diff/231-232 -- Changelog feed: https://schema.jppgr.am/api/feed - -It archives layers newer than this checkout. A constructor found there is not -usable until the schema is regenerated, so check the ID in `layers/232.tl` first. - -### Upgrading a layer - -A layer bump is a protocol migration, not a dependency bump: - -1. Choose an immutable schema revision and record its SHA-256. -2. In a separate generator checkout, regenerate all layers. Do not edit - `vendor/tellers-tl` or generated runtime declarations by hand: - - ```console - cargo run -p tellers-xtask -- generate --all-layers - cargo run -p tellers-xtask -- generate --all-layers --check - cargo test -p tellers-tl-rust --test current_generated - ``` - -3. Copy the generated layer modules into the sibling `tellers-mtproto` checkout. - Update its default and latest feature and the Cargo locks, and keep the older - layers. -4. Compile this crate, add explicit DTO handling for every new or changed variant - that crosses the FFI, and regenerate the bindings. -5. Run the generator freshness, host, transport, and wrapper tests, and check - that `libraryVersion()` reports the new layer. -6. Make sure stored login identity and update cursors still restore. Never drop - them to make a build pass. - -## Maintenance - -- **Generated bindings** are rebuilt, never edited. The Gradle task - `:native:mtproto:buildNativeMtproto` builds the library and regenerates the - Kotlin. An export or DTO change needs regeneration and a wrapper update. - `-PskipNativeBuild=true` skips both steps and is safe only when both artifacts - are current. -- **Session compatibility.** A change to the session format needs a - forward-compatible restore path: handle the old document explicitly and test - the migration. Encrypted sessions stay AES-256-GCM. -- **Android error catalog.** The Monogram app classifies errors with - `TelegramErrorCatalog.kt`, generated from the official `errors.json` by - `scripts/gen_telegram_errors.py`. It is app code, not part of the crate. -- **Versions.** The crate version is in `native/mtproto-rs/Cargo.toml`, the TL - layer is `tellers-mtproto::LATEST_API_LAYER`, and `libraryVersion()` reports - both. - -## Extending the library - -**Add an exported function.** - -1. Implement it in the owning module (`client/`, `media/`, `auth/`, `upload/`). - Go through `with_client_mut` if it touches client state, and pick the lane - family that matches the work. -2. Export it from `ffi.rs` with a handle argument and DTO types from `dto.rs`. -3. Map errors to `MtprotoError` and keep local-dead-session reasons intact. -4. Regenerate the bindings and update the host wrapper. -5. Add a unit test at the narrowest layer and a wire test if it sends a new RPC. -6. Add it to the [API index](USAGE.md#api-index) and document its dispatch class. - -**Add an update event.** - -1. Extend `UpdateEventDto` and map the TL update in the update path. -2. Decide whether it is durable (journaled) or live-only. -3. Keep it out of `Ignored` only after the host handles the new variant; - regenerate bindings in the same change. - -**Support a new TL variant after a layer bump.** Add explicit DTO handling for -every changed variant that crosses the FFI, as in step 4 of -[Upgrading a layer](#upgrading-a-layer). - -**Change the journal or session shape.** Both are contracts: the journal with the -host's acknowledgement protocol, the session with every stored file already on -users' devices. Change them with a migration and a test, never in place. - -## Testing - -Host tests need no device, account, or live Telegram connection: - -```console -cargo test --manifest-path native/mtproto-rs/Cargo.toml -cargo test --manifest-path native/mtproto-transport/Cargo.toml -``` - -| Layer | What it checks | -| --- | --- | -| Rust unit tests | Scheduler, journal, DTO mapping, session crypto, media policy | -| RPC wire tests | Encrypted local sockets, fragmented replies, correlation, retries, cancellation | -| Transport tests | SOCKS5, HTTP, HTTPS, and MTProxy handshakes; TLS and fake-TLS validation | -| Media orchestration | Staging, hash verification, CDN fallback, publication lease | - -Client tests feed crafted inbound packets into the deterministic engine to cover -recovery paths. Transport tests use real local TCP servers, including fragmented -writes. The Monogram app adds Kotlin bridge tests for DTO mapping, error parsing, -update delivery, and session lifecycle. - -The default suite does not cover live Telegram behavior, proxy interoperability, -long soak runs, or independent security review. These stay manual: login code -types and 2FA, reconnects and DC migration during a call, large downloads with -resume and cancel, background updates after a process restart, and proxy or -network changes. - -After a change, run the narrowest relevant test plus the layer above it. -Protocol, auth, media, and session changes always need both host suites and the -wrapper tests. - -## Invariants - -- The permanent auth key belongs to one handle in one process. Temporary lane - keys are bound from it and never shared. -- Outgoing random ids are fresh per logical message, and the caller keeps pending - sends until `UpdateMessageId` arrives. -- Cursors advance only through applied difference. Channel `pts` is separate - state. -- A transport whose auth key, session id, or DC changed is dropped, never pooled. -- Merge-back stays field-diffed. Assigning state wholesale brings back lost-update - races between lanes. -- Telegram auth keys never cross the FFI. The session data key does, as 32 raw - bytes passed to `create_encrypted_client`, and callers must zero their copy. - Keys never appear in logs or `Debug` output. -- The update journal is a delivery contract with the host. Changing its shape - means changing the acknowledgement protocol and the host wrapper. -- Generated bindings are rebuilt, never hand-edited. -- A download publishes only through the session lease, so a changed session - cannot publish an old transfer. -- CDN data is written only after hash verification. - -## References - -| Topic | Page | -| --- | --- | -| MTProto overview | https://core.telegram.org/mtproto | -| MTProto description | https://core.telegram.org/mtproto/description | -| Transports | https://core.telegram.org/mtproto/mtproto-transports | -| Service messages | https://core.telegram.org/mtproto/service_messages | -| Security guidelines | https://core.telegram.org/mtproto/security_guidelines | -| Auth-key vectors | https://core.telegram.org/mtproto/samples-auth_key | -| Authorization | https://core.telegram.org/api/auth | -| Code types | https://core.telegram.org/api/auth#code-types | -| SRP | https://core.telegram.org/api/srp | -| Errors | https://core.telegram.org/api/errors | -| Errors JSON | https://core.telegram.org/api/errors.json | -| Invoking | https://core.telegram.org/api/invoking | -| Layers | https://core.telegram.org/api/layers | -| TL schema explorer | https://schema.jppgr.am/ | -| Pinned layer 232 | https://schema.jppgr.am/layers/232.tl | -| Layer 231 -> 232 diff | https://schema.jppgr.am/diff/231-232 | -| Layer changelog feed | https://schema.jppgr.am/api/feed | -| Updates | https://core.telegram.org/api/updates | -| Files | https://core.telegram.org/api/files | -| File references | https://core.telegram.org/api/file-references | -| CDN verification | https://core.telegram.org/cdn#verifying-files | -| Datacenters | https://core.telegram.org/api/datacenter | -| Parallel sessions | https://core.telegram.org/api/datacenter#parallel-sessions | -| PFS | https://core.telegram.org/api/pfs | -| Optimization | https://core.telegram.org/api/optimisation | -| Config | https://core.telegram.org/api/config | -| Folders | https://core.telegram.org/api/folders | -| Offsets | https://core.telegram.org/api/offsets | - -Full grouped list: [README: Telegram documentation map](README.md#telegram-documentation-map). - -**Protocol:** [MTProto index](https://core.telegram.org/mtproto), [TL schema](https://core.telegram.org/schema), [layers](https://core.telegram.org/api/layers), [invoking](https://core.telegram.org/api/invoking), [datacenters](https://core.telegram.org/api/datacenter), [PFS](https://core.telegram.org/api/pfs), [config](https://core.telegram.org/api/config). - -**Data model:** [peers](https://core.telegram.org/api/peers), [minimum constructors](https://core.telegram.org/api/min), [entities](https://core.telegram.org/api/entities), [links](https://core.telegram.org/api/links), [channels](https://core.telegram.org/api/channel), [discussion](https://core.telegram.org/api/discussion), [forum](https://core.telegram.org/api/forum), [threads](https://core.telegram.org/api/threads). - -**Media:** [files](https://core.telegram.org/api/files), [file references](https://core.telegram.org/api/file-references), [CDN](https://core.telegram.org/cdn#verifying-files), [optimization](https://core.telegram.org/api/optimisation), [stickers](https://core.telegram.org/api/stickers), [custom emoji](https://core.telegram.org/api/custom-emoji), [wallpapers](https://core.telegram.org/api/wallpapers). - -**Methods:** [auth.bindTempAuthKey](https://core.telegram.org/method/auth.bindTempAuthKey), [upload.getFile](https://core.telegram.org/method/upload.getFile), [updates.getDifference](https://core.telegram.org/method/updates.getDifference), [help.getAppConfig](https://core.telegram.org/method/help.getAppConfig). diff --git a/native/mtproto-rs/Cargo.toml b/native/mtproto-rs/Cargo.toml index 37b87f8f4..9ba11991b 100644 --- a/native/mtproto-rs/Cargo.toml +++ b/native/mtproto-rs/Cargo.toml @@ -3,8 +3,8 @@ name = "monogram-mtproto" version = "0.1.0" edition = "2024" rust-version = "1.98" -license = "MPL-2.0" -description = "UniFFI MTProto bridge for Monogram (telers-mtproto-impl runtime)" +license = "GPL-3.0-only" +description = "UniFFI MTProto bridge for Monogram" publish = false [lib] @@ -19,33 +19,22 @@ bindgen-cli = ["uniffi/cli"] uniffi = { version = "0.32", default-features = false } parking_lot = "0.12" rxing = { version = "0.9.3", default-features = false, features = ["qrcode", "encoders", "decoders", "encoding_rs"] } -rapidhash = "4" -compact_str = { version = "0.9", features = ["serde"] } -smallvec = "1" -indexmap = "2" thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -bnum = "0.14" -num-bigint = "0.5" -num-traits = "0.2" -sha2 = "0.11" -aes = "0.9" -ctr = "0.10" -aes-gcm = "0.11" -zeroize = "1" -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" } +mono-proto-client = { path = "../../vendor/mono-rs/crates/mono-proto-client", version = "0.1.0" } +mono-proto = { path = "../../vendor/mono-rs/crates/mono-proto", version = "0.2.0" } +mono-proto-media = { path = "../../vendor/mono-rs/crates/mono-proto-media", version = "0.1.0" } +mono-proto-messages = { path = "../../vendor/mono-rs/crates/mono-proto-messages", version = "0.1.0" } +mono-proto-observability = { path = "../../vendor/mono-rs/crates/mono-proto-observability", version = "0.1.0" } +mono-proto-runtime = { path = "../../vendor/mono-rs/crates/mono-proto-runtime", version = "0.1.0" } +mono-proto-socket = { path = "../../vendor/mono-rs/crates/mono-proto-socket", version = "0.1.0" } +mono-proto-types = { path = "../../vendor/mono-rs/crates/mono-proto-types", version = "0.1.0" } -tellers-mtproto = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto" } -tellers-mtproto-codec = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-codec" } -tellers-mtproto-crypto = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-crypto" } -tellers-mtproto-transport = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-transport" } -tellers-mtproto-session = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-session" } -tellers-mtproto-engine = { path = "../../../telers-mtproto-impl/crates/tellers-mtproto-engine" } +[dev-dependencies] +mono-proto-client = { path = "../../vendor/mono-rs/crates/mono-proto-client", version = "0.1.0", features = ["test-support"] } [build-dependencies] uniffi = { version = "0.32", features = ["build"] } diff --git a/native/mtproto-rs/README.md b/native/mtproto-rs/README.md deleted file mode 100644 index cf12b0046..000000000 --- a/native/mtproto-rs/README.md +++ /dev/null @@ -1,264 +0,0 @@ -# Monogram MTProto - -A Telegram MTProto client library written in Rust. It gives you a blocking, -handle-based API for sign-in, dialogs, messages, live updates, media transfers, -stickers, instant view, wallpapers, and proxies, with bindings for Kotlin, -Swift, Python, and Ruby generated by -[UniFFI](https://mozilla.github.io/uniffi-rs/latest/). Rust applications can use -the crate directly. It is the engine behind the -[Monogram](https://github.com/monogram-android/monogram) Android client. - -- [USAGE.md](USAGE.md): how to call the library, with recipes -- [ARCHITECTURE.md](ARCHITECTURE.md): how it works inside - -## Quick start - -Kotlin: - -```kotlin -val handle: ULong = createEncryptedClient(apiId, apiHash, sessionPath, key32) -connect(handle) - -if (!isAuthorized(handle)) { - val sent = sendAuthCode(handle, phone) - // show a prompt that matches sent.codeType, then: - signIn(handle, phone, sent.phoneCodeHash, code) -} - -startUpdates(handle) -val chats = getChats(handle) -``` - -Rust: - -```toml -[dependencies] -monogram-mtproto = { path = "../monogram/native/mtproto-rs" } -``` - -```rust -let handle = monogram_mtproto::create_client(api_id, api_hash, session_path); -monogram_mtproto::connect(handle)?; - -// after signing in -let chats = monogram_mtproto::get_chats(handle)?; -let history = monogram_mtproto::get_history_page(handle, chats[0].id, 50, 0, 0, 0)?; -``` - -Rust uses snake_case names and `u64` handles; generated bindings follow the -target language's conventions. Every call blocks the calling thread, so run -calls on a worker thread. The full walkthrough, including 2FA, updates, -downloads, and error handling, is in [USAGE](USAGE.md#a-complete-session). - -## Where to go next - -| You want to | Read | -| --- | --- | -| Log a user in and handle every code type | [Signing in](USAGE.md#signing-in) | -| Receive messages reliably | [Live updates](USAGE.md#live-updates) | -| Download media fast and resume it | [Downloads](USAGE.md#downloads) | -| Pick the right priority for a call | [Scheduling](USAGE.md#scheduling) | -| Use a proxy | [Proxies and transport](USAGE.md#proxies-and-transport) | -| Understand an error | [Errors](USAGE.md#errors) | -| Use it from Swift, Python, or Ruby | [Other languages](USAGE.md#other-languages) | -| Understand the update pipeline | [ARCHITECTURE: Live updates](ARCHITECTURE.md#live-updates) | -| Understand the CDN and download path | [ARCHITECTURE: Downloads](ARCHITECTURE.md#downloads) | -| Upgrade the TL layer | [ARCHITECTURE: TL layers and schema](ARCHITECTURE.md#tl-layers-and-schema) | - -## Credentials - -1. Create the Telegram account in an official app. The library signs in - existing accounts only. -2. Get an API ID and hash at [my.telegram.org/apps](https://my.telegram.org/apps). - One phone number gets one API ID. -3. Pass them to `createClient` or `createEncryptedClient`. Do not commit them. - -Sample API IDs from Telegram's open-source clients are rate-limited -(`API_ID_PUBLISHED_FLOOD`) and only suitable for testing. Your app must follow -the [API Terms of Service](https://core.telegram.org/api/terms): it has to say -that it uses the Telegram API and is part of the Telegram ecosystem, its title -must not include "Telegram" unless preceded by "Unofficial", and it must not use -the official logo. - -> [!WARNING] -> my.telegram.org can refuse to create an API ID from a VPN, proxy, datacenter, -> or Tor address, usually with a generic form error. There is no client-side -> workaround; retry from a regular connection. See -> [API ID troubleshooting](USAGE.md#api-id-troubleshooting). - -## Build - -Requirements: - -- Rust 1.98 or newer and Cargo -- The runtime checkout `../telers-mtproto-impl`, next to this repository. The - `vendor/telers-mtproto-impl` submodule is not the build dependency. - -```console -git clone --recurse-submodules https://github.com/monogram-android/monogram.git -git clone --recurse-submodules https://github.com/gdlbo/telers-mtproto-impl.git -cd monogram -git submodule update --init --recursive -``` - -Host build and tests: - -```console -cargo build --release --manifest-path native/mtproto-rs/Cargo.toml -cargo test --manifest-path native/mtproto-rs/Cargo.toml -cargo test --manifest-path native/mtproto-transport/Cargo.toml -``` - -The tests use local sockets and deterministic protocol fixtures. They need no -device, no account, and no connection to Telegram. - -The crate is built as a `cdylib` and is not published to crates.io -(`publish = false`), so depend on it by path. It builds for the host and for -Android; other targets need their own build and a transport/TLS review. - -### Android - -You also need JDK 17, Android SDK 37, and an NDK (CI uses r28c): - -```console -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android -cargo install cargo-ndk --locked -``` - -Build the native library and regenerate the Kotlin bindings: - -```console -./gradlew :native:mtproto:buildNativeMtproto -``` - -On Windows use `gradlew.bat`. `-PskipNativeBuild=true` reuses the existing -library and bindings; use it only when both are current, never after a Rust, -protocol, DTO, or schema change. - -## Troubleshooting - -| Symptom | Check | Details | -| --- | --- | --- | -| Login fails with a generic error | `api_id`/`api_hash`, sample IDs, IP restrictions | [API ID troubleshooting](USAGE.md#api-id-troubleshooting) | -| "No account" for a number | The account must already exist | [Sign-in only](USAGE.md#sign-in-only) | -| No SMS arrives | `codeType`; the code may be in an official app | [No code or no SMS](USAGE.md#no-code-or-no-sms) | -| Code is rejected | Expiry, resend, the `phoneCodeHash` you pass | [Submit the code and password](USAGE.md#submit-the-code-and-password) | -| Session dies right after login | Another client uses the same authorization | [Common errors](USAGE.md#common-errors) | -| Download fails | Media not indexed, `FILE_REFERENCE`, wrong dispatch class | [Download errors](USAGE.md#download-errors) | -| Downloads are slow | Dispatch class, lanes, parts, chunk size | [Tuning downloads](USAGE.md#tuning-downloads) | -| Updates seem to stall | Drain loop running, batches acknowledged | [Live updates](USAGE.md#live-updates) | -| Missing or stale native library | Rebuild without `skipNativeBuild` | [Android](#android) | -| Unfamiliar error | Look it up in the official error database | [Errors](USAGE.md#errors) | - -## Documentation map - -| Document | Use it for | -| --- | --- | -| [README.md](README.md) | What the library is, features, build, Telegram reference map | -| [USAGE.md](USAGE.md) | API calls, recipes, tuning, errors, other languages | -| [ARCHITECTURE.md](ARCHITECTURE.md) | Design decisions, scheduling, RPC recovery, update pipeline, downloads and CDN, sessions, security | - -## Telegram documentation map - -The [API index](https://core.telegram.org/api) is the source of truth. These are -the pages this library implements or depends on, grouped by area. - -### Protocol and setup - -| Topic | Page | -| --- | --- | -| API index | https://core.telegram.org/api | -| MTProto index | https://core.telegram.org/mtproto | -| MTProto description | https://core.telegram.org/mtproto/description | -| Transports | https://core.telegram.org/mtproto/mtproto-transports | -| Service messages | https://core.telegram.org/mtproto/service_messages | -| Security guidelines | https://core.telegram.org/mtproto/security_guidelines | -| Auth-key test vectors | https://core.telegram.org/mtproto/samples-auth_key | -| TL schema | https://core.telegram.org/schema | -| TL schema explorer | https://schema.jppgr.am/ | -| Pinned layer 232 | https://schema.jppgr.am/layers/232.tl | -| Layer 231 -> 232 diff | https://schema.jppgr.am/diff/231-232 | -| Layer changelog feed | https://schema.jppgr.am/api/feed | -| TL layers | https://core.telegram.org/api/layers | -| Invoking methods | https://core.telegram.org/api/invoking | -| API ID | https://core.telegram.org/api/obtaining_api_id | -| API terms | https://core.telegram.org/api/terms | - -### Authorization and account - -| Topic | Page | -| --- | --- | -| Authorization and code types | https://core.telegram.org/api/auth | -| SRP / 2FA | https://core.telegram.org/api/srp | -| QR login | https://core.telegram.org/api/qr-login | -| Passkeys | https://core.telegram.org/api/passkeys | -| URL authorization | https://core.telegram.org/api/url-authorization | -| Datacenters | https://core.telegram.org/api/datacenter | -| Parallel sessions | https://core.telegram.org/api/datacenter#parallel-sessions | -| PFS | https://core.telegram.org/api/pfs | -| Profile | https://core.telegram.org/api/profile | -| Privacy | https://core.telegram.org/api/privacy | -| Blocking | https://core.telegram.org/api/block | - -### Messages, chats, and state - -| Topic | Page | -| --- | --- | -| Errors | https://core.telegram.org/api/errors | -| Error database | https://core.telegram.org/api/errors.json | -| Peers | https://core.telegram.org/api/peers | -| Minimum constructors | https://core.telegram.org/api/min | -| Entities | https://core.telegram.org/api/entities | -| Links | https://core.telegram.org/api/links | -| Offsets | https://core.telegram.org/api/offsets | -| Folders | https://core.telegram.org/api/folders | -| Channels | https://core.telegram.org/api/channel | -| Discussion | https://core.telegram.org/api/discussion | -| Forum topics | https://core.telegram.org/api/forum | -| Threads | https://core.telegram.org/api/threads | -| Drafts | https://core.telegram.org/api/drafts | -| Pinned messages | https://core.telegram.org/api/pin | -| Scheduled messages | https://core.telegram.org/api/scheduled-messages | -| Search | https://core.telegram.org/api/search | -| Reactions | https://core.telegram.org/api/reactions | -| Mentions | https://core.telegram.org/api/mentions | - -### Media and presentation - -| Topic | Page | -| --- | --- | -| Files | https://core.telegram.org/api/files | -| File references | https://core.telegram.org/api/file-references | -| CDN verification | https://core.telegram.org/cdn#verifying-files | -| Optimization | https://core.telegram.org/api/optimisation | -| Stickers | https://core.telegram.org/api/stickers | -| Custom emoji | https://core.telegram.org/api/custom-emoji | -| Animated emoji | https://core.telegram.org/api/animated-emojis | -| GIFs | https://core.telegram.org/api/gifs | -| Wallpapers | https://core.telegram.org/api/wallpapers | -| Themes | https://core.telegram.org/api/themes | -| Colors | https://core.telegram.org/api/colors | - -### Updates and delivery - -| Topic | Page | -| --- | --- | -| Updates | https://core.telegram.org/api/updates | -| Push updates | https://core.telegram.org/api/push-updates | -| Config | https://core.telegram.org/api/config | - -### UniFFI - -| Topic | Page | -| --- | --- | -| User guide | https://mozilla.github.io/uniffi-rs/latest/ | -| Generating bindings | https://mozilla.github.io/uniffi-rs/latest/bindings.html | -| Swift bindings | https://mozilla.github.io/uniffi-rs/latest/swift/overview.html | -| Python configuration | https://mozilla.github.io/uniffi-rs/latest/python/configuration.html | -| Ruby configuration | https://mozilla.github.io/uniffi-rs/latest/ruby/configuration.html | -| Repository | https://github.com/mozilla/uniffi-rs | - -## License - -The crate is MPL-2.0. The Monogram Android app is GPL-3.0. Vendored components -keep their own licenses; see the repository `LICENSE`. \ No newline at end of file diff --git a/native/mtproto-rs/USAGE.md b/native/mtproto-rs/USAGE.md deleted file mode 100644 index 2fff8180b..000000000 --- a/native/mtproto-rs/USAGE.md +++ /dev/null @@ -1,1439 +0,0 @@ -# Using Monogram MTProto - -How to call the library: credentials, sign-in, live updates, sending, -downloads, scheduling, proxies, and errors, with recipes you can adapt. For -build instructions see the [README](README.md); for how it works inside see -[ARCHITECTURE.md](ARCHITECTURE.md). - -## Contents - -- [Conventions](#conventions) -- [A complete session](#a-complete-session) -- [Credentials](#credentials) -- [Client lifecycle](#client-lifecycle) -- [Signing in](#signing-in) -- [Reading chats and history](#reading-chats-and-history) -- [Live updates](#live-updates) -- [Sending messages](#sending-messages) -- [Downloads](#downloads) -- [Scheduling](#scheduling) -- [Cancellation](#cancellation) -- [Proxies and transport](#proxies-and-transport) -- [Errors](#errors) -- [Security and privacy](#security-and-privacy) -- [Other languages](#other-languages) -- [Diagnostics](#diagnostics) -- [FAQ](#faq) -- [API index](#api-index) -- [Checklist](#checklist) -- [Glossary](#glossary) -- [References](#references) - -## Conventions - -- Examples are Kotlin. Rust uses snake_case names and `u64` handles; Kotlin uses - camelCase and `ULong`. -- The API is blocking. Every call blocks the calling thread, so call it from a - worker thread, never from a UI thread. -- A client is a `u64` handle, not an object. Nothing closes it automatically; - call `destroyClient`. -- Errors are `MtprotoException` in Kotlin and `MtprotoError` in Rust. -- Some behavior depends on thread-local state: the dispatch class and the bound - request-control token. Set both on the thread that makes the call. -- Code in the recipes that is not a library function (`applyEvents`, - `savePendingMessage`, `freshRandomId`, and so on) stands for your own code. - -## A complete session - -This is the whole path from nothing to a running client. The sections after it -explain each step. - -```kotlin -// 1. Describe your app to Telegram. Sent once per new socket. -setInitConnectionInfo( - InitConnectionInfo( - deviceModel = "Pixel 8", - systemVersion = "Android 15", - appVersion = "1.0", - systemLangCode = "en", - langPack = "android", - langCode = "en", - ) -) - -// 2. Create the client. key32 is 32 random bytes that you keep in a keystore. -val handle: ULong = createEncryptedClient(apiId, apiHash, sessionPath, key32) - -// 3. Optional: configure before connecting. -setDownloadConcurrency(2, 4) -// setClientTestDc(handle, true) // test datacenters only -// setProxy("socks5", host, port, user, pass, ByteArray(0)) - -connect(handle) - -// 4. Sign in if there is no stored authorization. -if (!isAuthorized(handle)) { - val sent = sendAuthCode(handle, phone) - // Show a prompt that matches sent.codeType and sent.codeLength. - try { - signIn(handle, phone, sent.phoneCodeHash, code) - } catch (e: MtprotoException.PasswordRequired) { - checkPassword(handle, password) - } -} - -// 5. Start updates and run the drain loop on a background thread (see Live updates). -startUpdates(handle) - -// 6. Load the first screen. -val chats = getChats(handle) -val page = getHistoryPage(handle, chats.first().id, 50, 0, 0, 0) - -// 7. Send, with a pending row kept until the server confirms. -val randomId = Random.nextLong(1, Long.MAX_VALUE) -savePendingMessage(randomId, draft) -sendTextMessage(handle, chats.first().id, draft.text, 0, null, 0, null, randomId) - -// 8. On exit. -destroyClient(handle) // keeps the authorization -// logout(handle) // revokes it on the server -``` - -## Credentials - -### Sign-in only - -The library signs in existing accounts only. It has no `auth.signUp`, and -Telegram can reject sign-up from third-party apps with -`PHONE_NUMBER_APP_SIGNUP_FORBIDDEN`. Create the account in an official Telegram -app first. - -`auth.authorizationSignUpRequired`, `PHONE_NUMBER_UNOCCUPIED`, and -`SIGN_UP_REQUIRED` all surface as `MtprotoError::RegistrationRequired`. -Supporting sign-up would be a new API, not a missing flag. See -[Sign in/sign up](https://core.telegram.org/api/auth#sign-in-sign-up). - -### Creating an API ID - -1. Log in to [my.telegram.org](https://my.telegram.org) with your phone number. -2. Open "API development tools" and fill in the form. -3. Copy `api_id` and `api_hash`. - -Notes: - -- One phone number can have one `api_id`. Telegram sends developer notices to - that number, so keep it active. -- Sample API IDs from Telegram's source are rate-limited and produce - `API_ID_PUBLISHED_FLOOD`. Do not use them in a released app. -- The ID identifies your application and the hash is sent with `auth.sendCode`. - Keep both out of public repositories, logs, and bug reports. -- Follow the [API Terms of Service](https://core.telegram.org/api/terms). The - app must say it uses the Telegram API and must not imply it is official. - -> [!WARNING] -> my.telegram.org applies server-side anti-abuse checks. It can refuse API ID -> creation from a VPN, proxy, datacenter or Tor address, or a flagged IP, usually -> with a generic form error. No MTProto parameter changes this. Retry later from -> a regular connection. The procedure is documented in -> [Creating your Telegram Application](https://core.telegram.org/api/obtaining_api_id); -> the IP check itself is not a documented rule. - -### API ID troubleshooting - -| You see | Likely cause | Fix | -| --- | --- | --- | -| `API_ID_INVALID` | Wrong `api_id`/`api_hash` pair, or values from different apps | Copy both from the same app | -| `API_ID_PUBLISHED_FLOOD` | Sample or published API ID | Use your own private ID; wait if you hit the limit | -| Generic error while creating the app | VPN, proxy, datacenter, or flagged IP; repeated submissions | Use a regular connection and try later | -| Login fails for every number | Empty or wrong credentials in your config | Check what you pass to `createClient` | -| Login works with a test number only | Test datacenter flag still set | Remove `setClientTestDc` for production | - -Many apps show a generic message for the two API ID errors. When login fails -without a useful message, log the sanitized error type before suspecting the -phone number. - -## Client lifecycle - -### Create a client - -```kotlin -setInitConnectionInfo(InitConnectionInfo(/* see A complete session */)) - -val handle: ULong = createClient(apiId, apiHash, sessionPath) -connect(handle) -``` - -The `initConnection` values are sent when a fresh socket is created, wrapped -around the first request together with the TL layer. Blank fields fall back to -defaults. Set them before the first `connect`. - -A handle owns one session file and one authorization. Do not open the same -session from two handles or two processes. Telegram answers a second main -connection on the same authorization with `AUTH_KEY_DUPLICATED`, and the session -is then marked dead. - -Other calls: - -- `isAuthorized(handle)` checks the stored authorization -- `setClientTestDc(handle, true)` switches to test datacenters; call it before - `connect` -- `logout(handle)` revokes the authorization on the server and clears local - state -- `destroyClient(handle)` closes sockets and flushes the session. It does not - revoke the authorization. Using the handle afterwards throws - `MtprotoException.UnknownClient` - -### Encrypted sessions - -`createClient` writes the session as plaintext JSON. -`createEncryptedClient(apiId, apiHash, sessionPath, key)` takes exactly 32 random -bytes and encrypts the same document with AES-256-GCM. - -What the session file contains: the auth snapshot, user id, peers, update -cursors, the channel recovery queue, the media index, the update journal, and -imported keys for other datacenters. Writes are atomic. - -Rules for the key: - -- Generate it once with a secure random source and store it in the platform - keystore. The library does not store or wrap it. -- Pass the same 32 bytes on every launch. Losing the key means losing the local - session; sign in again. -- Zero your copy of the key after the call returns. -- A wrong key or a damaged file fails the restore and leaves the stored session - untouched, so a bad key cannot destroy a good session. -- A plaintext session is migrated to encrypted on the first encrypted restore. - Going the other way is not supported. - -Example for Android, where the data key is wrapped by a non-exportable Keystore -key and saved next to the session (this wrapper is app code, not part of the -library): - -```kotlin -fun createClient(apiId: Int, apiHash: String, sessionPath: String): ULong { - val key = SessionKeyStore.loadOrCreate(sessionPath) // 32 bytes, unwrapped - return try { - createEncryptedClient(apiId, apiHash, File(sessionPath).canonicalPath, key) - } finally { - key.fill(0) - } -} -``` - -A backup of the session file cannot be restored on another device when the -wrapping key is device-bound. That is intended. Outside Android, protect the key -yourself, and use plaintext sessions only in tests. - -### Connect and authorization state - -`connect(handle)` opens the transport and prepares the session. It does not -create an account. Use `isAuthorized` or try `sendAuthCode` to find out whether -the user has to sign in. - -A connection error can be retried. A `session invalidated` error cannot: the -authorization is gone and the user must sign in again. - -### Destroy versus logout - -`logout` is the user-facing "log out" action. It revokes the authorization on -the server and clears local state. `destroyClient` is process teardown: it closes -sockets and flushes the session but leaves the authorization alive, so the next -launch restores it without a login. - -## Signing in - -### Send a code - -```kotlin -val sent = sendAuthCode(handle, phone) -// sent.phone, sent.phoneCodeHash, sent.codeType, sent.codeLength -``` - -`phone` is the number in international format. Keep `sent.phoneCodeHash`; every -later step needs it. `resendAuthCode(handle, phone, sent.phoneCodeHash)` asks for -another delivery attempt, often through a different channel. - -### Code types - -Telegram chooses how the code is delivered. Show the returned `codeType` instead -of assuming SMS. Details: -[Code types](https://core.telegram.org/api/auth#code-types). - -| `codeType` | Meaning | What to tell the user | -| --- | --- | --- | -| `app` | Service message in another logged-in Telegram session | Open the official Telegram app | -| `sms`, `sms_word`, `sms_phrase` | SMS, including word/phrase variants | Check the phone; wait before resending | -| `call`, `flash_call`, `missed_call` | Voice or missed call | Answer, or read the caller number as instructed | -| `email` | Code sent to the login email | Check that inbox | -| `email_setup` | Telegram requires a login email | Set it up in an official app, then retry | -| `fragment` | Code available through Fragment | Use an official app. `AuthCodeSent` has no Fragment URL field | -| `firebase` | Firebase SMS flow | Official mobile apps only; resend or pick another method | - -`codeLength` tells you how many digits to accept in the input field. If you build -your own login screen, handle every type in the table explicitly. - -### Submit the code and password - -```kotlin -try { - val signedIn = signIn(handle, phone, sent.phoneCodeHash, code) - // signedIn.userId, signedIn.dcId -} catch (e: MtprotoException.PasswordRequired) { - // ask for the cloud password, then call checkPassword -} catch (e: MtprotoException.RegistrationRequired) { - // no account for this number, or Telegram rejected app sign-up -} -``` - -`checkPassword(handle, password)` runs the SRP exchange and returns -`AuthSignedIn`. The password is used locally for the exchange and is not logged. -Errors you will see there: - -- `PASSWORD_HASH_INVALID`: wrong password -- `SRP_ID_INVALID`: the challenge expired; ask for the password again -- `AUTH_RESTART`: start over with a new code -- `SESSION_PASSWORD_NEEDED`: 2FA is required (the same signal as - `PasswordRequired`, seen in failure paths) - -Spec: [Two-factor authentication](https://core.telegram.org/api/srp). - -A login state machine for your UI usually has these states: - -| State | Entered when | Leaves when | -| --- | --- | --- | -| Phone | Nothing is authorized | `sendAuthCode` succeeds | -| Code | `AuthCodeSent` returned | `signIn` succeeds, `PasswordRequired` is thrown, or the code expires | -| Password | `PasswordRequired` | `checkPassword` succeeds | -| Done | `signIn` or `checkPassword` returned | You call `startUpdates` | - -Return to the Phone state on `AUTH_RESTART`, `PHONE_CODE_EXPIRED`, or -`PHONE_NUMBER_INVALID`. - -### No code or no SMS - -A custom client is not guaranteed an SMS. When no code arrives: - -1. Check `codeType`. If it is `app`, the code is in an official session that is - already logged in. -2. `firebase`: resend, request the next type, or sign in through an official app. - Third-party apps cannot complete this flow. -3. `fragment`: use an official app. The DTO exposes type and length only. -4. `email` or `email_setup`: check the login email, or set it up in an official - app. -5. `PHONE_NUMBER_FLOOD`: wait. Resending faster makes it worse. -6. `SEND_CODE_UNAVAILABLE`: every delivery option for this attempt was used. - Resend later or use the suggested next type. -7. New number: create the account in an official app first. -8. Try another network if the current one is blocked. - -Developers whose app really needs SMS can ask Telegram to enable it by emailing -`sms@telegram.org` with `#enableSMS` in the subject. The client cannot switch it -on. - -### Test datacenters - -Call `setClientTestDc(handle, true)` before `connect`. Test accounts are -separate from production accounts, and a build that uses them must never ship as -production. - -## Reading chats and history - -```kotlin -val chats = getChats(handle) // dialogs, also indexes avatars -val page = getHistoryPage(handle, chatId, 50, 0, 0, 0) -``` - -`getHistoryPage(handle, chatId, limit, offsetId, offsetDate, addOffset)` follows -Telegram's [offset rules](https://core.telegram.org/api/offsets). `limit` is -clamped to 1..100. To load older messages, pass the id of the oldest message you -have as `offsetId`. - -These calls run on the read lane. Set a read dispatch class on the calling thread: -`0` for something the user is waiting for, `1` for prefetching. See -[Scheduling](#scheduling). - -Reading also fills the media index that downloads depend on: `getChats` indexes -dialog avatars and last-message media, `getHistoryPage` indexes message media, and -`getProfile` indexes a peer's avatar and profile media. Fetch the chat or history -first, then download. - -`peerId` values come from `ChatDto.id`, `MessageDto.senderId`, or `ProfileDto.id`. -Do not build or re-encode channel ids yourself. The crate encodes users as -positive, basic groups as negative, and channels below -1000000000000. - -## Live updates - -### The model - -Telegram delivers events as a stream and numbers them so that a client can tell -whether it missed something. Three counters matter (see -[Working with Updates](https://core.telegram.org/api/updates)): - -| Counter | What it orders | -| --- | --- | -| `pts` | Events in one message box: the common box for private chats and basic groups, plus one box per channel or supergroup | -| `qts` | A secondary box for secret chats and certain bot events | -| `seq` | Whole update packets on the top level | - -For each box the rule is the same: if the local counter plus the event count -equals the new counter, apply it; if it is already past, drop the duplicate; if -there is a hole, recover the gap with `updates.getDifference` or -`updates.getChannelDifference`. - -You do not implement this. The library applies the rules, recovers gaps, and -turns the result into batches. Your job is to apply batches to your own storage -and acknowledge them. - -### Drain loop - -Updates are polled. Call `startUpdates` once, then run a drain loop on a -background thread with the background-read dispatch class: - -```kotlin -setDispatchClass(1) - -while (running) { - val drain = try { - drainUpdates(handle) - } catch (e: MtprotoException) { - Thread.sleep(2000) - continue - } - - if (drain.replayRequired) { - for (batch in pendingUpdateBatches(handle)) { - applyEvents(batch.events) - acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) - } - Thread.sleep(25) - continue - } - - for (batch in drain.batches) { - applyEvents(batch.events) - acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) - } - applyEvents(drain.liveEvents) - Thread.sleep(2000) -} -``` - -`drainUpdates` returns nothing until the client is authorized and -`startUpdates` has been called. The two-second sleep is a polling interval you -can tune; the replay branch sleeps briefly so a backlog drains quickly. - -### Journal and acknowledgements - -- `batches` are durable and already stored in the session journal. Apply the - events, commit them to your storage, then acknowledge. -- `liveEvents` (typing, presence, sync edges) are not durable and not journaled. -- Delivery is at-least-once. A batch stays in the journal until you acknowledge - it, so a crash replays it. Event handling must be idempotent. -- While `replayRequired` is true, unacknowledged batches exist and live events - are held back until you apply them. -- The journal is capped at 256 unacknowledged batches or 8 MiB. A consumer that - never acknowledges eventually stalls the pipeline. - -Acknowledge only after your own state is committed. Acknowledging first loses -events on a crash. - -Making event handling idempotent is usually easy: - -- Messages: use the message id as the primary key for the common box, and - `(peerId, messageId)` for channels. Upsert instead of insert. -- Deletes: deleting something that is already gone is a no-op. -- Reads and reactions: store the latest value, not a delta. - -### Events - -`UpdateEventDto` covers new, edited, and deleted messages, typing, presence and -emoji status, read receipts, reactions, discussion reads, dialog patches, folder -and chat changes, saved GIFs, and `SyncState`. - -Constructors the library does not map arrive as `Ignored(kind)`. Match on the -kind string if you need one of them. One of them, -`UpdateMessageId::`, confirms a pending send. - -`SyncState` marks edges of recovery work. You can use it to show a "Updating..." -indicator while the library catches up after being offline. - -### Lazy sync - -`updateLazySyncConfig(handle, lazy, exceptions)` keeps gaps in off-screen -channels durable without recovering them right away. Chat ids in `exceptions` -stay eager. Use it to avoid downloading the backlog of hundreds of channels the -user is not looking at, while keeping the chat on screen fully live. - -### Recipe: pending sends and confirmation - -An outgoing message has three moments: you created it, the server accepted it, -and you saw its id. The `random_id` ties them together, and Telegram stores used -random ids permanently, so a repeated send with the same id returns the original -message instead of creating a duplicate. This is why the id must be fresh per -logical message and kept until the confirmation arrives. - -```kotlin -fun send(chatId: Long, text: String) { - val randomId = Random.nextLong(1, Long.MAX_VALUE) - savePendingMessage(randomId, chatId, text) // 1. visible, "sending" - try { - sendTextMessage(handle, chatId, text, 0, null, 0, null, randomId) - // the returned MessageDto already has the server id - markSent(randomId) - } catch (e: MtprotoException) { - // 2. unknown outcome: keep the row; the confirmation may still arrive - } -} - -fun onEvent(e: UpdateEventDto) { - if (e is UpdateEventDto.Ignored && e.kind.startsWith("UpdateMessageId:")) { - val (_, randomId, messageId) = e.kind.split(":") - markSent(randomId.toLong(), messageId.toInt()) // 3. confirmed - } -} -``` - -If the response to a send is lost (a dropped session, an internal server error), -the confirmation still arrives through updates and your pending row resolves -correctly. Do not resend with a new random id just because a call failed. - -### Recipe: surviving process death - -Because of the journal, the order "apply, commit, acknowledge" is the whole -contract. After a crash the next `drainUpdates` returns `replayRequired` and -`pendingUpdateBatches` hands you the unacknowledged batches again, and your -idempotent handlers bring storage to the same state. You do not need to track -update counters yourself. - -## Sending messages - -### Text - -Give each send a fresh, nonzero random `Long`: - -```kotlin -val randomId = Random.nextLong(1, Long.MAX_VALUE) -savePendingMessage(randomId, draft) - -val message = sendTextMessage( - handle = handle, - chatId = chatId, - text = draft.text, - replyToMsgId = 0, - entitiesJson = null, - topMsgId = 0, - webpageUrl = null, - clientRandomId = randomId, -) -``` - -- `replyToMsgId` is the message being replied to, or `0`. -- `entitiesJson` is a compact JSON array of `{"kind","offset","length","url?"}` - objects. Offsets and lengths follow Telegram's - [entity rules](https://core.telegram.org/api/entities) (UTF-16 code units). -- `topMsgId` is the forum topic or thread root. -- `webpageUrl` attaches a preview for an explicit URL. - -Keep the pending message in your own storage until -`UpdateEventDto.Ignored("UpdateMessageId::")` arrives. The library -does not store drafts. Passing `0` as the random id makes the library generate -one, but then you cannot match the send to its confirmation. - -### Photos - -```kotlin -val photo = sendPhotoMessage( - handle, chatId, path, caption, - replyToMsgId = 0, topMsgId = 0, entitiesJson = null, -) -``` - -The photo is uploaded and sent in one call. This helper takes no client random -id, so a lost response cannot be matched; use `sendUploadedMedia` when you need -the pending-row flow. - -### Documents, video, and albums - -```kotlin -val item = UploadItemDto( - path = file.absolutePath, - kind = "video", - mimeType = "video/mp4", - fileName = "clip.mp4", - caption = "", - entitiesJson = null, - duration = 12, - width = 1080, - height = 1920, - randomId = freshRandomId(), -) -val sent = sendUploadedMedia(handle, chatId, item, 0, 0, null) -val album = sendUploadedAlbum(handle, chatId, items, 0, 0) -``` - -An album holds at most ten items, and photos or videos cannot be mixed with -documents. Each item carries its own `randomId`. Files over 10 MiB use -`upload.saveBigFilePart`. Photos over 10 MiB are rejected. - -### How uploads behave - -- Uploads run on four fixed upload lanes, separate from reads and downloads, so a - large upload does not delay chat loading. -- Part size is 32 KiB or 512 KiB (`setFilePartKib`: below 512 gives 32 KiB, 512 - and above gives 512 KiB). On a slow link (round trip of a second or more) or - for a tiny file, one 32 KiB part goes at a time; otherwise up to eight parts are - in flight. -- Uploads honor cancellation tokens like any other call. See - [Cancellation](#cancellation). - -### Edit, delete, forward, and other actions - -`editTextMessage`, `deleteMessage`, `forwardMessages`, `readHistory`, -`readMessageContents`, `setTyping`, `sendReaction`, and -`getMessageReactionsList` are in the [API index](#api-index). They take the -client handle first and use the same dispatch-class and error rules as the calls -shown above. Use the interactive-write class (`4`) for actions the user just -performed. - -## Downloads - -All download calls block until the file is complete or fails, and return the -destination path. - -| Function | Kind | Result | -| --- | --- | --- | -| `downloadMessageMedia(handle, chatId, messageId, destPath)` | Full | Original photo, video, or document | -| `downloadMessageThumb(handle, chatId, messageId, destPath)` | Thumb | Smallest preview; may use inline bytes with no network | -| `downloadMessageDisplay(handle, chatId, messageId, destPath)` | Display | Sized photo variant | -| `downloadMessageMediaChunk(handle, chatId, messageId, destPath, offset)` | Full from offset | Bytes from `offset`, written from byte 0 of `destPath` | - -`destPath` is a file path, not a directory. Missing parent directories are -created. A network download replaces the destination only when it is published, -so a failed download never destroys the previous file. Spec: -[Files](https://core.telegram.org/api/files). - -### Dispatch class - -Downloads run on media lanes, so set a media dispatch class on the calling -thread first: - -```kotlin -setDispatchClass(6) // visible media -val path = downloadMessageMedia(handle, chatId, messageId, destPath) -``` - -Valid classes are 2 (interactive media), 3 (background media), 5 (ordinary -media), 6 (visible media), and 7 (user media). Classes 0 and 1 are read classes; -a full download with one of them fails with `read class on the media family`. -The class also picks the download profile. See [Scheduling](#scheduling). - -### Picking a class - -| Situation | Class | Why | -| --- | --- | --- | -| Avatar in a list the user is scrolling | 2 | Small and urgent; the user sees it right now | -| Photo in the chat that is on screen | 6 | Visible media: large chunks, high parallelism | -| User tapped "download" or opened a file | 7 | User-initiated: highest throughput | -| Thumbnails for items near the viewport | 5 | Ordinary: steady, modest | -| Prefetch, auto-download on Wi-Fi, cache warming | 3 | Background: gentle, ages upward if it waits | - -### Download profiles - -| Class | Profile (Full) | Chunk | Parts in flight | -| --- | --- | --- | --- | -| 2 interactive media | User | 512 KiB | 8 | -| 3 background media | Background | 128 KiB | 4 | -| 5 ordinary media | Ordinary | 128 KiB | 4 | -| 6 visible media | Visible | 512 KiB | 8 | -| 7 user media | User | 512 KiB | 8 | - -- Thumb always uses 128 KiB and one part. -- Full with an offset always uses the Playing profile: 512 KiB, up to 8 parts. -- Display uses Visible when the class sets no profile. -- `setDownloadChunkKib` accepts 128, 256, or 512 and falls back to 128 for - anything else. It does not change the number of parts in flight. - -Telegram requires file request offsets and limits to be multiples of 4 KiB and -a request to stay inside one 1 MiB block. The chunk sizes above all divide 1 MiB. -Spec: [Optimization](https://core.telegram.org/api/optimisation). - -### Media index - -Downloads read from the library's media index. These calls fill it: - -- `getChats` indexes dialog avatars and last-message media -- `getHistoryPage(handle, chatId, limit, offsetId, offsetDate, addOffset)` - indexes message media; `limit` is clamped to 1..100 -- `getProfile` indexes the peer's avatar and profile media - -If a message entry is missing, the download re-fetches its file source and looks -back up to three messages. Avatar entries trigger a profile or dialog refresh. -If the entry is still missing you get `no media for chat message -`. Fetch the dialog, profile, or history first, then retry. - -The index is bounded: 500 message entries, 200 entries for stickers, GIFs, inline -files, and custom emoji, 32 instant-view entries, and unlimited peer avatars. -Very old messages fall out of the persisted index, so re-read history before -downloading media from far back. - -### Resume and cancel - -A full download writes to `...part`, with two sidecar -files next to it: - -- `...ranges.part` records the missing byte ranges -- `...identity.part` records file id, access hash, DC, size, and version - -On success the staging file is renamed to `destPath`. Retryable failures -(timeouts, flood waits, network loss, file references) and cancellation keep the -staging file and sidecars. The next call with the same `destPath` resumes from -the largest staging file if the identity matches, and starts over if it does -not. Other errors delete the staging file. Stale staging files older than two -minutes are swept, and the oldest are deleted first if the total passes 32 MiB. - -Offset downloads are not resumed. `offset` must be non-negative and a multiple -of 128 KiB, or the call fails with `invalid media range offset`. - -To cancel from another thread, write any content to `destPath + ".cancel"`. The -marker is checked between parts and before publishing. The library never -deletes it, so remove it before the next download to the same path. - -Concurrent downloads to the same `destPath` run one after the other under a -per-path lock. Finished files are not deduplicated, so check for an existing -file yourself before starting. - -### Progress - -```kotlin -setDownloadProgressListener(object : DownloadProgressListener { - override fun onProgress(path: String, downloaded: Long, total: Long) { - // runs on the native download thread; hop to your UI thread - } -}) -``` - -The listener is process-wide, so one callback serves every download; route by -`path`. `downloaded` starts at the resume offset, and `total` is usually 0 -because the final size is not always known. Use `MessageDto.fileSize` when you -have it. - -### Recipe: image grid - -A scrolling grid wants something on screen fast and sharpness later: - -1. For every visible cell, call `peekMessageInlineThumb` first. It does no - network I/O, so it is safe on the render path. -2. If it returns null, call `downloadMessageThumb` with class `5`. -3. When a cell settles on screen, call `downloadMessageDisplay` with class `6`. -4. Only on tap, call `downloadMessageMedia` with class `7`. -5. When a cell scrolls away, cancel its pending request with a request-control - token (see [Cancellation](#cancellation)) so it stops competing for lanes. - -### Recipe: streaming a video - -`downloadMessageMediaChunk(handle, chatId, messageId, destPath, offset)` returns -bytes from `offset` onward and writes them from byte 0 of `destPath`. Use it to -serve a player: pick a 128 KiB-aligned `offset` for the position the player -requests, download into a scratch file, and feed the player from it. This path -uses the high-throughput Playing profile and is not resumed, so keep a separate -full download going at class `3` if you also want the file cached. - -### Recipe: a background cache warmer - -```kotlin -setDispatchClass(3) // background media -for (item in queue) { - if (isCached(item)) continue - try { - downloadMessageMedia(handle, item.chatId, item.messageId, item.path) - } catch (e: MtprotoException) { - if (isFloodWait(e)) { Thread.sleep(waitSeconds(e) * 1000L) } else continue - } -} -``` - -Class `3` uses small chunks and four parts, and ages toward interactive priority -while it waits, so it never starves behind a steady stream of visible media but -also never crowds it out. - -### Tuning downloads - -`setDownloadConcurrency(lanes, parts)` sets media lanes and parts in flight. -Both are clamped to 1..8; the defaults are 2 and 4. `downloadConcurrency()` -returns `[lanes, parts]`. More lanes mean more TCP sessions, and media on a -foreign DC needs temporary keys too, so raise them only when the device and -network can use them. - -| Goal | Change | -| --- | --- | -| Faster large files on a fast link | Higher parts (6 or 8), chunk 512 KiB, use class 6 or 7 | -| Several files at once | More lanes (3 or 4) | -| Lower memory and socket use on weak devices | Fewer lanes and parts, chunk 128 KiB | -| Smoother UI during bulk work | Use class 3 for bulk and keep visible items on 6 | -| Flood waits keep appearing | Reduce lanes and parts; the library also halves its own window | - -Admission is limited per datacenter: by default five small files and two large -ones (20 MB or more) at once, or whatever `help.getAppConfig` says. Queued work -ages. While a DC is recovering from a flood wait and still has active transfers, -new ones wait. - -Measure before tuning. `perfSnapshot` reports lane waits and first-chunk timing -(see [Diagnostics](#diagnostics)). - -### CDN and file references - -CDN redirects are followed automatically. Each part is decrypted and checked -against the master DC's SHA-256 hashes, and any failure falls back to the origin -DC, so a download never accepts unverified CDN data. Spec: -[CDN](https://core.telegram.org/cdn#verifying-files). - -`FILE_REFERENCE` errors re-fetch the message, sticker set, wallpaper, or inline -query that produced the media and retry once. A thumbnail tries the next -getFile-sized thumbnail first. `FILE_MIGRATE_X` only changes the media DC, not -your account session, and is followed automatically. Spec: -[File references](https://core.telegram.org/api/file-references). - -### Download errors - -| Message | Cause | -| --- | --- | -| `no media for chat ...` | Media not indexed yet, or peer/message not cached | -| `no downloadable thumb` | No usable thumbnail size | -| `no display size` | No display-size variant | -| `invalid media range offset` | Offset negative or not chunk-aligned | -| `cancelled` / `request cancelled` | Cancel marker or request-control token | -| `read class on the media family` | No media dispatch class set | - -A flood error that reaches you means the request was rejected. Schedule it again -after the wait the server gave. - -### Avatars and other media - -Peer photos are indexed at `(peerId, 0)` and downloaded with `messageId = 0`: - -```kotlin -setDispatchClass(2) - -getProfile(handle, peerId) -val avatarPath = downloadMessageMedia(handle, peerId, 0, destPath) -val inlineJpeg = peekPeerInlineThumb(handle, peerId) // ByteArray? or null -``` - -`peekPeerInlineThumb` returns an expanded in-memory JPEG or null. It does no -network I/O and does not take the main lane lock, so it is safe to call while -rendering; it only holds the client data mutex briefly. `peekMessageInlineThumb` -is the message version. - -If `(peerId, 0)` is missing, the download asks for the profile or refreshes the -dialogs before failing. After that, `no media for chat` means the peer has no -photo or is not cached. - -For a sized still use `downloadMessageDisplay(handle, peerId, 0, path)`. Video -avatars are indexed as `video_avatar`: a full download fetches the video, while -the thumb stays a still image. The library upgrades a peer photo to the video -variant when the profile reports one. - -More media entry points: - -- `downloadCustomEmoji(handle, documentId, destPath)` for a custom emoji indexed - at `(documentId, 0)` -- `downloadWallpaper(handle, id, accessHash, destPath)` for a wallpaper from - `getWallpapers` - -## Scheduling - -### Lanes - -| Family | Count | Used for | -| --- | --- | --- | -| Main | 1 | RPCs that must see updates, plus the update connection | -| Read | up to 2 extra | History, dialogs, profiles, other `invokeWithoutUpdates` reads | -| Media | 2 by default, up to 8 | File downloads and wallpapers | -| Upload | 4 | Outgoing file parts | - -Extra read lanes exist only when the server allows parallel sessions -(`config.tmp_sessions`) and a temporary key is bound. Otherwise reads use the -main lane. Media lanes open as needed up to the configured count. Upload lanes -are fixed at four. - -Keep large history loads and downloads off the main lane. `getHistoryPage` and -profile calls use the read lane; downloads use media lanes. The dispatch class -must match the lane family of the call. Spec: -[Invoking methods](https://core.telegram.org/api/invoking), -[Parallel sessions](https://core.telegram.org/api/datacenter#parallel-sessions). - -### Dispatch classes - -`setDispatchClass` sets a thread-local value that selects the lane family and -the waiting priority. A fresh thread starts at 0. - -| Value | Name | Family | Priority | -| --- | --- | --- | --- | -| 0 | interactive read | read | 1 | -| 1 | background read | read | 3 | -| 2 | interactive media | media | 2 | -| 3 | background media | media | 4, ages to 1 | -| 4 | interactive write | read | 0 | -| 5 | ordinary media | media | 2 | -| 6 | visible media | media | 2 | -| 7 | user media | media | 2 | - -Lower numbers go first, FIFO within a priority. Classes with a priority above 2 -move up one step every 5 seconds, down to 1. A background request can therefore -eventually pass interactive media (2) but never an interactive write (0). - -Set the class right before each blocking call and restore it afterwards in a -`finally`. In coroutine code, set it from a thread-context element so it follows -the coroutine onto its worker thread: - -```kotlin -class DispatchClass(private val value: Int) : ThreadContextElement { - companion object Key : CoroutineContext.Key - override val key get() = Key - override fun updateThreadContext(context: CoroutineContext) { setDispatchClass(value) } - override fun restoreThreadContext(context: CoroutineContext, oldState: Unit) { setDispatchClass(0) } -} - -withContext(Dispatchers.IO + DispatchClass(6)) { - downloadMessageMedia(handle, chatId, messageId, destPath) -} -``` - -`promoteRequestControl(token, class)` raises the priority of a request that is -already queued on the read lanes, for example when an item the user scrolled -past comes back on screen. It does not change the download profile. Media-lane -waits and download admission use the calling thread's class, so set that before -starting a download. - -### Settings - -- `setDownloadConcurrency(lanes, parts)`: 1..8 each -- `setDownloadChunkKib(kib)`: 128, 256, or 512; anything else becomes 128 -- `setFilePartKib(kib)`: upload part size; below 512 gives 32 KiB, 512 and above - gives 512 KiB -- `downloadConcurrency()`, `downloadChunkKib()`: current values - -## Cancellation - -```kotlin -val token = createRequestControl() -val previous = bindRequestControl(token) -try { - val page = getHistoryPage(handle, chatId, 50, 0, 0, 0) -} finally { - bindRequestControl(previous) - releaseRequestControl(token) -} - -// from another thread -cancelRequestControl(token) -``` - -- `createRequestControl` allocates a token. -- `bindRequestControl` attaches it to the calling thread and returns the - previous token, which you restore in `finally`. -- `cancelRequestControl` closes the sockets registered to the token and makes - later checks fail. It does not wait for the worker to return. -- `releaseRequestControl` frees the token. - -A token works once: after cancellation, calls that use it fail with `request -cancelled`. Create a new token for the next request. Reusing a released token is -not supported. - -Cancellation is checked while a request waits in the queue, so a cancelled -request that never got a lane exits immediately. - -Downloads can also be cancelled with the `.cancel` marker, which needs no token. - -### Recipe: cancellable suspend wrapper - -```kotlin -suspend fun nativeCall(dispatchClass: Int, block: () -> T): T = - suspendCancellableCoroutine { cont -> - val token = createRequestControl() - val worker = executor.submit { - val previous = bindRequestControl(token) - setDispatchClass(dispatchClass) - try { - cont.resume(block()) - } catch (e: Throwable) { - cont.resumeWithException(e) - } finally { - setDispatchClass(0) - bindRequestControl(previous) - releaseRequestControl(token) - } - } - cont.invokeOnCancellation { - cancelRequestControl(token) - worker.cancel(true) - } - } -``` - -This is the pattern the Monogram Android bridge uses, reduced to its essentials: -bind a token and a class on the worker thread, cancel the token and the worker -together when the coroutine is cancelled. - -## Proxies and transport - -```kotlin -setProxy("socks5", "proxy.example.com", 1080, "user", "pass", ByteArray(0)) -setProxy("http", "proxy.example.com", 8080, null, null, ByteArray(0)) -setProxy("https", "proxy.example.com", 443, null, null, ByteArray(0)) - -val latencyMs = pingProxy("socks5", "proxy.example.com", 1080, null, null, ByteArray(0)) -clearProxy() -``` - -Supported kinds: `socks5`, `http`, `https` (TLS plus CONNECT), and `mtproto`. -`pingProxy` times a throwaway connection and does not change the active proxy. -`clearProxy` goes back to direct connections. - -MTProxy secrets are raw bytes: - -- simple: the 16-byte key -- `dd`: `0xdd` followed by the 16-byte key (17 bytes) -- `ee`: `0xee`, the 16-byte key, then the ASCII SNI domain - -```kotlin -fun ddSecret(key: ByteArray) = byteArrayOf(0xdd.toByte()) + key -fun eeSecret(key: ByteArray, domain: String) = - byteArrayOf(0xee.toByte()) + key + domain.toByteArray(Charsets.US_ASCII) - -setProxy("mtproto", "proxy.example.com", 443, null, null, eeSecret(key, "example.com")) -``` - -Fake TLS in `ee` mode hides the traffic and is authenticated with the proxy -secret. It is not certificate-verified TLS. - -`setTransportMode("padded_intermediate")` is the default; -`setTransportMode("http")` selects the HTTP transport. Spec: -[MTProto transports](https://core.telegram.org/mtproto/mtproto-transports). - -HTTPS proxy certificates are checked with `rustls-platform-verifier`. On -Android, call the JNI function `initPlatformVerifier(context)` once before -using an HTTPS proxy. - -### Recipe: choosing among several proxies - -```kotlin -val best = proxies - .mapNotNull { p -> runCatching { p to pingProxy(p.kind, p.host, p.port, p.user, p.pass, p.secret) }.getOrNull() } - .minByOrNull { it.second } - ?.first -if (best != null) setProxy(best.kind, best.host, best.port, best.user, best.pass, best.secret) -``` - -Apply the proxy before `connect`, or reconnect after changing it. When a network -is blocked, direct connections rotate datacenter ports (443 first) on their own; -a proxy is for networks where that is not enough. - -## Errors - -### Error shape - -Server failures arrive as `MtprotoException.Message` in one of these forms: - -- `RPC : `, for example `RPC 420: FLOOD_WAIT_5` -- `session invalidated: `, for a session the library already knows is - dead. It is deliberately not reported as a fake 401, so logs show the real - reason. -- Local or transport text: `RPC timeout`, `connection error`, `msg_key`, - `bad_msg_notification`, or an unknown constructor - -The official references are [Error handling](https://core.telegram.org/api/errors) -and the machine-readable -[errors.json](https://core.telegram.org/api/errors.json): `errors` maps code to -error type to the methods that can emit it, `descriptions` holds readable text, -and durations use printf-style `%d` placeholders. The library returns the raw -strings and leaves classification to you. Monogram's Android bridge classifies -them with a Kotlin catalog generated from `errors.json`. - -### A small classifier - -```kotlin -sealed interface Failure { - data class Flood(val seconds: Int) : Failure - object SessionDead : Failure - object Network : Failure - data class Rpc(val code: Int, val type: String) : Failure - data class Other(val text: String) : Failure -} - -fun classify(text: String): Failure { - if (text.startsWith("session invalidated")) return Failure.SessionDead - Regex("""RPC (\d+): (\w+)""").find(text)?.let { m -> - val code = m.groupValues[1].toInt() - val type = m.groupValues[2] - Regex("""FLOOD(?:_PREMIUM)?_WAIT_(\d+)""").find(type)?.let { - return Failure.Flood(it.groupValues[1].toInt()) - } - if (type in setOf("AUTH_KEY_UNREGISTERED", "AUTH_KEY_INVALID", "SESSION_REVOKED", - "SESSION_EXPIRED", "AUTH_KEY_DUPLICATED")) return Failure.SessionDead - return Failure.Rpc(code, type) - } - if (text.startsWith("RPC timeout") || text.startsWith("connection error")) return Failure.Network - return Failure.Other(text) -} -``` - -Match on the type, not on message substrings, and keep the raw type for bug -reports. - -### Common errors - -| Error | Meaning | What to do | -| --- | --- | --- | -| `API_ID_INVALID` | Invalid `api_id`/`api_hash` pair | Fix your credentials | -| `API_ID_PUBLISHED_FLOOD` | Published API ID, rate-limited | Use your own private ID | -| `PHONE_NUMBER_APP_SIGNUP_FORBIDDEN` | Sign-up through this app is not allowed | Create the account in an official app | -| `PHONE_NUMBER_UNOCCUPIED` | No account for this number | Same as above | -| `PHONE_NUMBER_BANNED` | Number is banned | Do not retry | -| `PHONE_NUMBER_FLOOD` | Too many code requests | Wait; do not resend repeatedly | -| `PHONE_NUMBER_INVALID` | Invalid phone number | Fix the number or format | -| `SEND_CODE_UNAVAILABLE` | All code delivery options used | Resend later or use the next type | -| `PHONE_CODE_INVALID` / `PHONE_CODE_EXPIRED` | Wrong or expired code | Re-enter or resend | -| `SESSION_PASSWORD_NEEDED` | 2FA is enabled | Ask for the password, call `checkPassword` | -| `PASSWORD_HASH_INVALID` | Wrong cloud password | Ask again | -| `SRP_ID_INVALID` | SRP challenge expired | Restart password entry | -| `AUTH_RESTART` | Restart the sign-in flow | Send a new code | -| `AUTH_KEY_UNREGISTERED`, `AUTH_KEY_INVALID`, `SESSION_REVOKED`, `SESSION_EXPIRED` | Authorization is dead | Sign in again | -| `AUTH_KEY_DUPLICATED` | The same authorization is used by parallel connections | Run one main session per authorization; sign in again | -| `AUTH_KEY_PERM_EMPTY` | Temporary key not bound to the permanent key | Retry; not a logout | -| `AUTH_KEY_UNSYNCHRONIZED` | Key state mismatch | Retry; not a logout | -| `USER_DEACTIVATED` | Account deleted or deactivated | Stop and tell the user | -| `PHONE_MIGRATE_X`, `NETWORK_MIGRATE_X`, `USER_MIGRATE_X`, `FILE_MIGRATE_X` | Another DC owns the phone, network, user, or file | Handled inside the library | -| `FILE_REFERENCE_*` | Expired or invalid file reference | Handled inside downloads: re-fetch the source and retry once | -| `FILE_ID_INVALID` | Invalid file id | Refresh the message or media index | -| `FILE_PART_INVALID` / `FILE_PARTS_INVALID` | Bad upload part metadata | Fix the upload parameters | -| `FLOOD_WAIT_X` | Wait X seconds | Retry after the wait; show long waits to the user | -| `FLOOD_PREMIUM_WAIT_X` | Same, with a Premium alternative | Respect the wait or offer Premium | -| `Timeout` / `MSG_WAIT_TIMEOUT` | Request timed out | Retry reads; do not blindly retry sends | -| `RPC timeout` | Local attempt budget used up | Retry with care | -| `connection error` | Socket or transport failure | Reconnect | -| `msg_key` | Ciphertext or key mismatch | Treat the session as suspect; reconnect | -| `bad_msg_notification` | Timing or sequence problem | Recovered internally; reconnect if it persists | -| unknown constructor | The codec does not know the constructor | Protocol update needed | -| `updates queue overflow` | Local update buffer overflowed | Difference recovery is required | - -### Retry policy - -| Kind of call | Retry on timeout or network error | Retry on flood wait | -| --- | --- | --- | -| Read (history, dialogs, profile) | Yes, with backoff | Yes, after the wait | -| Download | Yes; it resumes from the staging file | Yes, after the wait | -| Upload part | Handled inside the library | Handled inside the library | -| Send, edit, delete, forward | Check the outcome first (pending row, confirmation) | Yes, after the wait, with the same random id | -| Sign-in steps | No, ask the user | After the wait | - -### What the library handles - -- DC migration, file-reference refresh, server salt and message-id recovery -- CDN failures (fallback to origin) and flood pacing for transfers -- Replay of reads and idempotent upload parts after uncertain delivery. - Mutations that may have reached the server are returned as errors and never - replayed automatically. - -### What your code must handle - -- A dead session (`session invalidated`, `AUTH_KEY_*`, `SESSION_*`): clear local - state and ask the user to sign in again -- Flood waits: retry after the wait, and show a countdown for long ones -- `RegistrationRequired`: tell the user the account has to exist already -- `API_ID_INVALID` and `API_ID_PUBLISHED_FLOOD`: these are configuration - problems, not a phone number problem -- Retrying sends: check whether the message arrived before sending again -- `UnknownClient`: the handle was destroyed - -### Reporting an unknown error - -Include the error code and type (for example `RPC 400: SOME_NEW_ERROR`), the -`libraryVersion()` output, what you were doing and whether it reproduces, and -whether a proxy or VPN was involved. Leave out phone numbers, login codes, -session files, API hashes, auth keys, and message text. - -## Security and privacy - -- Telegram auth keys stay inside Rust. The session data key crosses the binding - boundary only for `createEncryptedClient`; zero your copy afterwards. -- Keep the session file and any wrapped key in protected storage. A copied - session cannot be restored on another device when the wrapping key is - device-bound. -- `api_id` and `api_hash` identify your app, not your users. They can be - extracted from a shipped binary, so treat them as app credentials. Do not put - API hashes, login codes, phone numbers, auth keys, or session files in logs or - issue reports. -- Proxy credentials and MTProxy secrets are raw bytes. Fake TLS is camouflage, not - certificate-verified TLS; HTTPS proxies use the platform verifier. -- Media caches can contain private content. Expose cache clearing and respect the - user's storage choice. The library writes only to the paths you give it, plus - staging files next to them. -- On Android, updates must be signed by the same identity. Do not ship - debug-signed APKs as production updates. - -## Other languages - -The library exports plain functions, records, enums, one error type, and one -callback interface (`DownloadProgressListener`). Generate bindings from the -built library: - -```console -cargo build --release - -cargo run --features bindgen-cli --bin uniffi-bindgen -- \ - generate --library target/release/libmonogram_mtproto.so \ - --language swift --out-dir out/swift -``` - -Use `.dylib` on macOS and `.dll` on Windows. For Kotlin the Gradle task -`:native:mtproto:buildNativeMtproto` does this and writes the output to -`native/mtproto/src/main/java`. To run it by hand, pass `--language kotlin ---no-format` and point `--library` at the built `.so`. Never edit generated -bindings; after an export or DTO change, regenerate and update your wrapper. - -| Language | Generator | -| --- | --- | -| Kotlin, Swift, Python, Ruby | `uniffi-bindgen --language `; for Swift also `uniffi-bindgen-swift`; Ruby needs the `ffi` gem | -| C# | [uniffi-bindgen-cs](https://github.com/NordSecurity/uniffi-bindgen-cs) (community) | -| Go | [uniffi-bindgen-go](https://github.com/NordSecurity/uniffi-bindgen-go) (community) | -| Dart / Flutter | [uniffi-bindgen-dart](https://github.com/nchapman/uniffi-bindgen-dart) (community); check that it supports UniFFI 0.32 | - -Generated API shape: - -| Rust / UniFFI | Kotlin | Swift | Python | Ruby | -| --- | --- | --- | --- | --- | -| `u64` handle | `ULong` | `UInt64` | `int` | `Integer` | -| `Result` | exception | `throws` | exception | exception | -| record | data class | struct | class | generated record | -| enum | sealed class | enum | enum/class | enum/class | -| callback interface | interface | protocol | callback object | callback object | - -The bindings add no async runtime, retries, or object lifetimes. Callbacks run -on the native download thread. - -### What every embedder has to supply - -The Kotlin wrapper does these things for you in the Monogram app. In another -language you do them yourself: - -1. A worker thread for every call. UniFFI calls are synchronous. -2. Per-call dispatch class and request-control binding (thread-local state). -3. Marshalling of `DownloadProgressListener` to your UI thread. -4. Storage for the 32-byte session key, and zeroing of your copy. -5. The drain loop for updates and an acknowledgement after each committed batch. - -### Examples - -Python (generated names are snake_case; check the generated module for exact -names): - -```python -import monogram_mtproto as mt - -handle = mt.create_client(api_id, api_hash, "session.json") -mt.connect(handle) - -if not mt.is_authorized(handle): - sent = mt.send_auth_code(handle, phone) - mt.sign_in(handle, phone, sent.phone_code_hash, input("code: ")) - -mt.start_updates(handle) -chats = mt.get_chats(handle) -``` - -Swift (argument labels follow the Rust parameter names): - -```swift -let handle = createClient(apiId: apiId, apiHash: apiHash, sessionPath: path) -try connect(handle: handle) -let sent = try sendAuthCode(handle: handle, phone: phone) -``` - -### Targets - -The repository builds for host and Android. iOS, desktop, and WASM need their own -build and a transport/TLS review (the platform verifier setup is -Android-oriented). The crate type is `cdylib`; static linking needs a -`staticlib` target. WASM is not possible as-is because the transport uses -blocking sockets, threads, and the filesystem. - -Session encryption is the same everywhere. Only the Android app has Keystore -integration, so elsewhere you pass a 32-byte key to `createEncryptedClient` and -protect it yourself. - -For Flutter and Dart, build the native library for every target you ship, load -the right one per platform, run the blocking API on an isolate or worker, and -forward progress callbacks to the UI isolate. `flutter_rust_bridge` is an -alternative if you prefer a different FFI model. - -See [Generating bindings](https://mozilla.github.io/uniffi-rs/latest/bindings.html) -and [Swift bindings](https://mozilla.github.io/uniffi-rs/latest/swift/overview.html). - -## Diagnostics - -```kotlin -val version = libraryVersion() -perfSetEnabled(true) -val snapshotJson = perfSnapshot(reset = true) -``` - -`libraryVersion()` returns the crate version and TL layer. `perfSetEnabled` is -off by default. `perfSnapshot` returns JSON with per-operation counts, total and -maximum microseconds, a small ring of samples, and counters. It never contains -message payloads or keys. - -How to use it: - -1. Enable it, reproduce the slow scenario, then take a snapshot with - `reset = true` so the next one starts clean. -2. Look at lane-wait spans first: long waits mean too few lanes or a priority - problem, not a slow network. -3. Look at first-chunk timing for media: a large value points at datacenter - distance, a missing temporary key, or admission queueing. -4. Compare snapshots before and after a tuning change. - -Span names are fixed strings such as API calls, media first chunk, persist, and -lane waits. Your own logs should follow the same rule: error type, code, flood -wait, DC, and your operation name, never content. - -## FAQ - -**Can I use one client from several threads?** Yes. A handle can be used from -many threads. What you must not do is open the same session through a second -handle or process. - -**Why does every call block?** The boundary stays small and binds well to many -languages. Scheduling and cancellation belong to the host, and the recipes above -show the pattern. - -**Why did my download fail with `no media for chat`?** The media index does not -have the entry yet. Load the dialog, history page, or profile first, then retry. - -**Why do I get `read class on the media family`?** The calling thread has a read -dispatch class (0 or 1). Set 2, 3, 5, 6, or 7 before a download. - -**Can I sign users up?** No. Create accounts in an official Telegram app. - -**Do I need to track `pts`, `qts`, or `seq`?** No. The library does and hands you -acknowledged batches. See [Live updates](#live-updates). - -**Is CDN data trusted?** No. Every part is hash-verified against the master -datacenter, and any failure falls back to the origin. - -**What happens if the process dies in the middle of a download?** The staging file -and its sidecars stay. The next call with the same destination resumes if the file -identity matches. - -**Can I run it on iOS or desktop?** Not out of the box; see -[Targets](#targets). - -## API index - -All functions take the client handle. Kotlin names are shown. - -| Task | Functions | -| --- | --- | -| Client | `createClient`, `createEncryptedClient`, `connect`, `isAuthorized`, `destroyClient` | -| Sign-in | `sendAuthCode`, `resendAuthCode`, `signIn`, `checkPassword`, `logout` | -| Dialogs | `getChats` | -| History | `getHistoryPage`, `getHistory`, `getReplies` | -| Profile | `getProfile`, `resolveUsername` | -| Search | `searchMessagesPage`, `searchGlobal`, `contactsSearch` | -| Send | `sendTextMessage`, `sendPhotoMessage`, `sendUploadedMedia`, `sendUploadedAlbum` | -| Edit, delete, forward | `editTextMessage`, `deleteMessage`, `forwardMessages` | -| Read state | `readHistory`, `readMessageContents`, `setTyping` | -| Reactions | `sendReaction`, `getMessageReactionsList` | -| Downloads | `downloadMessageMedia`, `downloadMessageThumb`, `downloadMessageDisplay`, `downloadMessageMediaChunk` | -| Inline previews | `peekMessageInlineThumb`, `peekPeerInlineThumb` | -| Other media | `downloadCustomEmoji`, `downloadWallpaper` | -| Updates | `startUpdates`, `drainUpdates`, `pendingUpdateBatches`, `acknowledgeUpdateBatch`, `updateLazySyncConfig` | -| Scheduling and settings | `setDispatchClass`, `setDownloadConcurrency`, `setDownloadChunkKib`, `setFilePartKib` | -| Cancellation | `createRequestControl`, `bindRequestControl`, `cancelRequestControl`, `releaseRequestControl`, `promoteRequestControl` | -| Proxy and transport | `setProxy`, `pingProxy`, `clearProxy`, `setTransportMode` | -| Diagnostics | `libraryVersion`, `perfSetEnabled`, `perfSnapshot` | - -## Checklist - -- One session file, one handle, one process -- Your own API ID, not a sample one -- The account already exists in an official app -- `startUpdates` called after sign-in and a drain loop running -- Update batches acknowledged only after your state is committed, and handlers are - idempotent -- A fresh random id for every outgoing message, pending row kept until confirmed -- A dispatch class set before every blocking call, a media class for downloads -- Download destination is a file path; the `.cancel` marker is cleaned up -- The same 32-byte key on every launch of an encrypted session, and your copy - zeroed -- Errors classified by type, not by substring -- Generated bindings regenerated, never hand-edited - -## Glossary - -| Term | Meaning | -| --- | --- | -| API ID / hash | Application credentials from my.telegram.org | -| Auth key | The long-term MTProto key of an authorization | -| Session | The session file plus the server-side authorization | -| DC | Telegram datacenter. The account DC and a file's DC can differ | -| TL layer | Version of Telegram's schema. This build is pinned to layer 232 | -| DTO | A record or enum that crosses the binding boundary | -| Dispatch class | Thread-local value (0..7) that picks the lane family and priority | -| Lane | An internal session used for one kind of request | -| Flood wait | Server-imposed delay, `FLOOD_WAIT_` | -| File reference | Opaque file token that can expire and must be refreshed | -| Temporary key (PFS) | Short-lived key bound to the auth key, used for extra sessions | -| SRP | The challenge used for cloud-password (2FA) login | -| `pts`, `qts`, `seq` | Telegram's counters for update ordering | -| Message box | One independent event sequence: the common box or a channel box | -| Journal | The durable list of update batches awaiting acknowledgement | -| CDN | Separate file servers; downloads verify hashes and fall back to origin | - -## References - -| Topic | Page | -| --- | --- | -| API overview | https://core.telegram.org/api | -| API ID creation | https://core.telegram.org/api/obtaining_api_id | -| API terms | https://core.telegram.org/api/terms | -| Authorization | https://core.telegram.org/api/auth | -| Code types | https://core.telegram.org/api/auth#code-types | -| Sign in / sign up | https://core.telegram.org/api/auth#sign-in-sign-up | -| SRP / 2FA | https://core.telegram.org/api/srp | -| Errors | https://core.telegram.org/api/errors | -| Errors JSON | https://core.telegram.org/api/errors.json | -| Invoking | https://core.telegram.org/api/invoking | -| Layers | https://core.telegram.org/api/layers | -| TL schema explorer | https://schema.jppgr.am/ | -| Pinned layer 232 | https://schema.jppgr.am/layers/232.tl | -| Layer 231 -> 232 diff | https://schema.jppgr.am/diff/231-232 | -| Layer changelog feed | https://schema.jppgr.am/api/feed | -| Updates | https://core.telegram.org/api/updates | -| Files | https://core.telegram.org/api/files | -| File references | https://core.telegram.org/api/file-references | -| CDN verification | https://core.telegram.org/cdn#verifying-files | -| Datacenters | https://core.telegram.org/api/datacenter | -| Parallel sessions | https://core.telegram.org/api/datacenter#parallel-sessions | -| Optimization | https://core.telegram.org/api/optimisation | -| Config | https://core.telegram.org/api/config | -| MTProto | https://core.telegram.org/mtproto | -| MTProto description | https://core.telegram.org/mtproto/description | -| Transports | https://core.telegram.org/mtproto/mtproto-transports | -| Service messages | https://core.telegram.org/mtproto/service_messages | -| Security guidelines | https://core.telegram.org/mtproto/security_guidelines | -| Auth-key vectors | https://core.telegram.org/mtproto/samples-auth_key | -| Folders | https://core.telegram.org/api/folders | -| Offsets | https://core.telegram.org/api/offsets | - -Full grouped list: [README: Telegram documentation map](README.md#telegram-documentation-map). - -**Protocol and API basics:** [MTProto index](https://core.telegram.org/mtproto), [TL schema](https://core.telegram.org/schema), [MTProto description](https://core.telegram.org/mtproto/description), [transports](https://core.telegram.org/mtproto/mtproto-transports), [service messages](https://core.telegram.org/mtproto/service_messages), [security guidelines](https://core.telegram.org/mtproto/security_guidelines), [auth-key vectors](https://core.telegram.org/mtproto/samples-auth_key), [layers](https://core.telegram.org/api/layers), [invoking](https://core.telegram.org/api/invoking), [datacenters](https://core.telegram.org/api/datacenter), [config](https://core.telegram.org/api/config). - -**Data model and chats:** [peers](https://core.telegram.org/api/peers), [minimum constructors](https://core.telegram.org/api/min), [entities](https://core.telegram.org/api/entities), [links](https://core.telegram.org/api/links), [channels](https://core.telegram.org/api/channel), [discussion](https://core.telegram.org/api/discussion), [forum](https://core.telegram.org/api/forum), [threads](https://core.telegram.org/api/threads), [drafts](https://core.telegram.org/api/drafts), [pinned messages](https://core.telegram.org/api/pin), [scheduled messages](https://core.telegram.org/api/scheduled-messages), [search](https://core.telegram.org/api/search), [reactions](https://core.telegram.org/api/reactions), [mentions](https://core.telegram.org/api/mentions). - -**Media:** [files](https://core.telegram.org/api/files), [file references](https://core.telegram.org/api/file-references), [CDN](https://core.telegram.org/cdn#verifying-files), [optimization](https://core.telegram.org/api/optimisation), [stickers](https://core.telegram.org/api/stickers), [custom emoji](https://core.telegram.org/api/custom-emoji), [animated emoji](https://core.telegram.org/api/animated-emojis), [GIFs](https://core.telegram.org/api/gifs), [wallpapers](https://core.telegram.org/api/wallpapers), [themes](https://core.telegram.org/api/themes), [colors](https://core.telegram.org/api/colors). - -**Updates and account:** [updates](https://core.telegram.org/api/updates), [push updates](https://core.telegram.org/api/push-updates), [profile](https://core.telegram.org/api/profile), [privacy](https://core.telegram.org/api/privacy), [blocking](https://core.telegram.org/api/block), [PFS](https://core.telegram.org/api/pfs), [QR login](https://core.telegram.org/api/qr-login), [passkeys](https://core.telegram.org/api/passkeys). - -**Methods used by this crate:** [auth.sendCode](https://core.telegram.org/method/auth.sendCode), [auth.signIn](https://core.telegram.org/method/auth.signIn), [auth.bindTempAuthKey](https://core.telegram.org/method/auth.bindTempAuthKey), [messages.getHistory](https://core.telegram.org/method/messages.getHistory), [messages.sendMessage](https://core.telegram.org/method/messages.sendMessage), [messages.sendMedia](https://core.telegram.org/method/messages.sendMedia), [upload.getFile](https://core.telegram.org/method/upload.getFile), [updates.getDifference](https://core.telegram.org/method/updates.getDifference), [help.getCdnConfig](https://core.telegram.org/method/help.getCdnConfig), [help.getAppConfig](https://core.telegram.org/method/help.getAppConfig). diff --git a/native/mtproto-rs/src/api_invoke.rs b/native/mtproto-rs/src/api_invoke.rs index abaa47af8..f07b97735 100644 --- a/native/mtproto-rs/src/api_invoke.rs +++ b/native/mtproto-rs/src/api_invoke.rs @@ -1,383 +1,20 @@ -//! Shared InvokeWithLayer(InitConnection(...)) helpers for API RPCs. - -use std::cell::Cell; -use std::sync::LazyLock; - -use parking_lot::Mutex; -use tellers_mtproto::LATEST_API_LAYER; -use tellers_mtproto::codec::{Boxed, BoxedDecode, TlEncode}; -use tellers_mtproto::latest::api::{ - InitConnectionRequest, InvokeWithLayerRequest, InvokeWithoutUpdatesRequest, -}; -use tellers_mtproto_session::Snapshot; +use std::sync::Once; use crate::InitConnectionInfo; -use crate::MtprotoError; -use crate::rpc::{self, BoxedQuery}; - -thread_local! { - static WITHOUT_UPDATES: Cell = const { Cell::new(false) }; -} - -static INIT_CONNECTION: LazyLock> = - LazyLock::new(|| Mutex::new(InitConnectionInfo::default())); - -pub fn set_init_connection_info(info: InitConnectionInfo) { - *INIT_CONNECTION.lock() = info.sanitized(); -} - -fn current_init_connection() -> InitConnectionInfo { - INIT_CONNECTION.lock().clone() -} - -/// Extra home-DC RPC sessions must not subscribe for updates. -/// https://core.telegram.org/api/invoking#disabling-updates -pub fn invoking_without_updates() -> bool { - WITHOUT_UPDATES.with(Cell::get) -} -pub fn with_invoke_without_updates(body: impl FnOnce() -> T) -> T { - WITHOUT_UPDATES.with(|cell| { - struct Restore<'a>(&'a Cell, bool); - impl Drop for Restore<'_> { - fn drop(&mut self) { - self.0.set(self.1); - } - } - let previous = cell.replace(true); - let _restore = Restore(cell, previous); - body() - }) -} - -pub fn wrap_init_connection( - api_id: i32, - query: X, -) -> InvokeWithLayerRequest>>> { - wrap_init_connection_at_layer(api_id, LATEST_API_LAYER as i32, query) -} - -pub fn wrap_init_connection_at_layer( - api_id: i32, - layer: i32, - query: X, -) -> InvokeWithLayerRequest>>> { - let info = current_init_connection(); - InvokeWithLayerRequest { - layer, - query: BoxedQuery(InitConnectionRequest { - flags: 0, - api_id, - device_model: info.device_model, - system_version: info.system_version, - app_version: info.app_version, - system_lang_code: info.system_lang_code, - lang_pack: info.lang_pack, - lang_code: info.lang_code, - proxy: None, - params: None, - query: BoxedQuery(query), - }), +fn fallback() -> InitConnectionInfo { + InitConnectionInfo { + app_version: env!("CARGO_PKG_VERSION").into(), + ..InitConnectionInfo::default() } } -/// File queries must not subscribe the connection for updates. -/// https://core.telegram.org/api/invoking#disabling-updates -/// https://core.telegram.org/api/files (separate sessions; default for file queries) -pub fn wrap_init_connection_without_updates( - api_id: i32, - query: X, -) -> InvokeWithLayerRequest< - BoxedQuery>>>>, -> { - wrap_init_connection( - api_id, - InvokeWithoutUpdatesRequest { - query: BoxedQuery(query), - }, - ) +pub(crate) fn initialize() { + static INIT: Once = Once::new(); + INIT.call_once(|| mono_proto_runtime::api_invoke::set_init_connection_info(fallback())); } -pub fn wrap_without_updates( - query: X, -) -> InvokeWithoutUpdatesRequest> { - InvokeWithoutUpdatesRequest { - query: BoxedQuery(query), - } -} - -pub fn migrate_dc(err: &MtprotoError) -> Option { - let MtprotoError::Message(msg) = err else { - return None; - }; - if let Some(idx) = msg.find("MIGRATE_") { - let rest = &msg[idx + "MIGRATE_".len()..]; - let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - if let Ok(dc) = digits.parse::() { - return Some(dc); - } - } - None -} - -pub fn invoke_api( - snapshot: &mut Snapshot, - api_id: i32, - request: Req, -) -> Result -where - Req: TlEncode + Boxed + Clone, - Res: BoxedDecode, -{ - if invoking_without_updates() { - invoke_api_without_updates(snapshot, api_id, request) - } else { - // The main connection receives both correlated RPC results and updates. - invoke_api_allow_updates(snapshot, api_id, request) - } -} - -/// `updates.getDifference` / `updates.getState` / channel difference: this -/// lane is the updates subscriber. Do not wrap `invokeWithoutUpdates`. -/// https://core.telegram.org/api/invoking#disabling-updates -/// Body actually written on the updates lane, including gzip when it shrinks the query. -pub(crate) fn encode_updates_lane_body(body: &[u8]) -> Vec { - rpc::gzip_if_smaller(body) -} - -pub fn invoke_api_allow_updates( - snapshot: &mut Snapshot, - api_id: i32, - request: Req, -) -> Result -where - Req: TlEncode + Boxed + Clone, - Res: BoxedDecode, -{ - let span = crate::perf::span_dyn(|| perf_op(Req::CONSTRUCTOR_ID)); - span.with(|| { - invoke_encoded( - snapshot, - replay_safe_method(Req::CONSTRUCTOR_ID), - |new_connection| { - if new_connection { - rpc::encode_boxed_bytes(&wrap_init_connection(api_id, request.clone())) - .map(|body| encode_updates_lane_body(&body)) - } else { - rpc::encode_boxed_bytes(&request).map(|body| encode_updates_lane_body(&body)) - } - }, - ) - }) -} - -/// `upload.getFile` / other file RPCs: wrap `invokeWithoutUpdates` so the -/// file TCP does not receive `updates#74ae4240` while waiting for `rpc_result`. -pub fn invoke_api_without_updates( - snapshot: &mut Snapshot, - api_id: i32, - request: Req, -) -> Result -where - Req: TlEncode + Boxed + Clone, - Res: BoxedDecode, -{ - let span = crate::perf::span_dyn(|| perf_op(Req::CONSTRUCTOR_ID)); - span.with(|| { - invoke_encoded( - snapshot, - replay_safe_method(Req::CONSTRUCTOR_ID), - |new_connection| { - if new_connection { - rpc::encode_boxed_bytes(&wrap_init_connection_without_updates( - api_id, - request.clone(), - )) - } else { - rpc::encode_boxed_bytes(&wrap_without_updates(request.clone())) - } - }, - ) - }) -} - -/// File RPCs sent as one batch on one session, each wrapped with `invokeWithoutUpdates`; -/// the first query on a fresh connection carries InitConnection. -pub fn invoke_api_batch_without_updates( - snapshot: &mut Snapshot, - api_id: i32, - requests: Vec, -) -> Result>, MtprotoError> -where - Req: TlEncode + Boxed + Clone, - Res: BoxedDecode, -{ - invoke_api_batch_without_updates_streaming(snapshot, api_id, requests, &mut |_, _| None) -} - -pub fn invoke_api_batch_without_updates_streaming( - snapshot: &mut Snapshot, - api_id: i32, - requests: Vec, - on_chunk: &mut dyn FnMut(usize, Result) -> Option, -) -> Result>, MtprotoError> -where - Req: TlEncode + Boxed + Clone, - Res: BoxedDecode, -{ - if requests.is_empty() { - return Ok(Vec::new()); - } - let replay_safe = replay_safe_method(Req::CONSTRUCTOR_ID); - let span = crate::perf::span("rpc:pipeline"); - let raw = rpc::invoke_batch_raw_with_retry_streaming( - snapshot, - replay_safe, - |reused| { - let mut bodies = Vec::with_capacity(requests.len()); - for (index, request) in requests.iter().enumerate() { - let encoded = if !reused && index == 0 { - rpc::encode_boxed_bytes(&wrap_init_connection_without_updates( - api_id, - request.clone(), - ))? - } else { - rpc::encode_boxed_bytes(&wrap_without_updates(request.clone()))? - }; - bodies.push(encoded); - } - Ok(bodies) - }, - &mut |index, raw_res| { - let res = match raw_res { - Ok(bytes) => decode_response::(bytes), - Err(err) => Err((*err).clone()), - }; - let refill = on_chunk(index, res)?; - rpc::encode_boxed_bytes(&wrap_without_updates(refill)).ok() - }, - ); - drop(span); - Ok(raw? - .into_iter() - .map(|entry| entry.and_then(|body| decode_response::(&body))) - .collect()) -} - -/// Short method label for perf spans. Unknown ids stay as hex so no method is -/// silently mislabelled. -fn perf_op(id: u32) -> String { - use tellers_mtproto::latest::api::*; - let name = match id { - HelpGetConfigRequest::ID => "getConfig", - MessagesGetDialogsRequest::ID => "getDialogs", - MessagesGetPinnedDialogsRequest::ID => "getPinnedDialogs", - MessagesGetWebPagePreviewRequest::ID => "getWebPagePreview", - MessagesGetDialogFiltersRequest::ID => "getDialogFilters", - MessagesGetHistoryRequest::ID => "getHistory", - MessagesGetMessagesRequest::ID => "getMessages", - MessagesGetRepliesRequest::ID => "getReplies", - MessagesGetForumTopicsRequest::ID => "getForumTopics", - UploadGetFileRequest::ID => "getFile", - UsersGetFullUserRequest::ID => "getFullUser", - ChannelsGetFullChannelRequest::ID => "getFullChannel", - UpdatesGetDifferenceRequest::ID => "getDifference", - UpdatesGetChannelDifferenceRequest::ID => "getChannelDifference", - UpdatesGetStateRequest::ID => "getState", - MessagesSendMessageRequest::ID => "sendMessage", - MessagesSendMediaRequest::ID => "sendMedia", - _ => return format!("rpc:{id:#010x}"), - }; - format!("rpc:{name}") -} - -/// Only read methods can be replayed after an uncertain transport failure. -/// Mutations need application-level correlation; unknown methods default to no replay. -fn replay_safe_method(id: u32) -> bool { - use tellers_mtproto::latest::api::*; - matches!( - id, - HelpGetConfigRequest::ID - | HelpGetAppConfigRequest::ID - | AccountGetPasswordRequest::ID - | AccountGetAuthorizationsRequest::ID - | MessagesGetDialogsRequest::ID - | MessagesGetPinnedDialogsRequest::ID - | MessagesGetWebPagePreviewRequest::ID - | MessagesGetDialogFiltersRequest::ID - | MessagesGetHistoryRequest::ID - | MessagesGetMessagesRequest::ID - | MessagesGetScheduledHistoryRequest::ID - | MessagesGetScheduledMessagesRequest::ID - | MessagesGetRepliesRequest::ID - | MessagesGetForumTopicsRequest::ID - | MessagesGetForumTopicsByIdRequest::ID - | ChannelsGetMessagesRequest::ID - | UpdatesGetDifferenceRequest::ID - | UpdatesGetChannelDifferenceRequest::ID - | UpdatesGetStateRequest::ID - | UploadGetFileRequest::ID - | UploadSaveFilePartRequest::ID - | UploadSaveBigFilePartRequest::ID - | UsersGetFullUserRequest::ID - | ChannelsGetFullChannelRequest::ID - | ChannelsGetParticipantsRequest::ID - | MessagesGetFullChatRequest::ID - | MessagesGetDiscussionMessageRequest::ID - | MessagesGetRecentReactionsRequest::ID - | MessagesGetTopReactionsRequest::ID - | MessagesGetSavedGifsRequest::ID - | MessagesGetCustomEmojiDocumentsRequest::ID - | MessagesGetStickerSetRequest::ID - | MessagesSearchRequest::ID - | MessagesGetWebPageRequest::ID - | UploadGetWebFileRequest::ID - ) -} - -fn invoke_encoded( - snapshot: &mut Snapshot, - replay_safe: bool, - encode: impl FnMut(bool) -> Result, MtprotoError>, -) -> Result -where - Res: BoxedDecode, -{ - match rpc::invoke_raw_with_retry_factory(snapshot, replay_safe, encode) { - Ok(body) => decode_response(&body), - Err(err) => { - if let Some(dc) = migrate_dc(&err) { - rpc::drop_live_transport(); - let MtprotoError::Message(message) = &err else { - return Err(err); - }; - // Keep FILE_MIGRATE distinguishable: media moves only its - // file lane, while USER/NETWORK_MIGRATE may transfer the - // account authorization to a new home DC. - let prefix = if message.contains("FILE_MIGRATE") { - "FILE_MIGRATE" - } else { - "MIGRATE" - }; - return Err(MtprotoError::Message(format!("{prefix}_{dc}"))); - } - Err(err) - } - } -} - -fn decode_response(body: &[u8]) -> Result { - let mut decoder = - tellers_mtproto::codec::Decoder::new(body, tellers_mtproto::codec::Limits::default()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let response = - Res::decode_boxed(&mut decoder).map_err(|e| MtprotoError::Message(e.to_string()))?; - decoder - .finish() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(response) +pub fn set_init_connection_info(info: InitConnectionInfo) { + initialize(); + mono_proto_runtime::api_invoke::set_init_connection_info(info.sanitized_with(&fallback())); } - -#[cfg(test)] -#[path = "../tests/unit/api_invoke_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/auth/auth_key.rs b/native/mtproto-rs/src/auth/auth_key.rs deleted file mode 100644 index 6f990b200..000000000 --- a/native/mtproto-rs/src/auth/auth_key.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! Telegram authorization-key creation using telers-mtproto-impl + tellers-mtproto types. - -use bnum::types::{I128, I256}; -use tellers_mtproto::codec::{Boxed, BoxedDecode, Decoder, Encoder, Limits}; -use tellers_mtproto::transport::{ - PQInnerDataDcConstructor, PQInnerDataTempDcConstructor, ReqDhParamsRequest, ReqPqMultiRequest, - ResPq, ServerDhInnerData, ServerDhParams, SetClientDhParamsAnswer, SetClientDhParamsRequest, -}; -use tellers_mtproto_crypto::{ - aes_ige_decrypt, aes_ige_encrypt, derive_auth_key, factor_pq, fill_random, initial_server_salt, - new_nonce_hash, parse_rsa_public_key, rsa_pad, rsa_public_key_fingerprint, - temporary_aes_key_iv, validate_dh_parameters, validate_dh_public_value, -}; -use tellers_mtproto_engine::{ - Authorization, AuthorizationAdapter, AuthorizationPhase, PlainMessage, decode_plain_message, - encode_plain_message, -}; -use tellers_mtproto_session::{Clock, Snapshot}; -use tellers_mtproto_transport::{Connection, Framing, PaddedIntermediate}; - -use crate::MtprotoError; -use zeroize::Zeroize; - -const PROD_RSA_PEM: &str = "-----BEGIN RSA PUBLIC KEY-----\n\ -MIIBCgKCAQEA6LszBcC1LGzyr992NzE0ieY+BSaOW622Aa9Bd4ZHLl+TuFQ4lo4g\n\ -5nKaMBwK/BIb9xUfg0Q29/2mgIR6Zr9krM7HjuIcCzFvDtr+L0GQjae9H0pRB2OO\n\ -62cECs5HKhT5DZ98K33vmWiLowc621dQuwKWSQKjWf50XYFw42h21P2KXUGyp2y/\n\ -+aEyZ+uVgLLQbRA1dEjSDZ2iGRy12Mk5gpYc397aYp438fsJoHIgJ2lgMv5h7WY9\n\ -t6N/byY9Nw9p21Og3AoXSL2q/2IJ1WRUhebgAdGVMlV1fkuOQoEzR7EdpqtQD9Cs\n\ -5+bfo3Nhmcyvk5ftB0WkJ9z6bNZ7yxrP8wIDAQAB\n\ ------END RSA PUBLIC KEY-----"; - -/// Test-DC RSA from Telegram Desktop `kTestPublicRSAKeys`. -const TEST_RSA_PEM: &str = "-----BEGIN RSA PUBLIC KEY-----\n\ -MIIBCgKCAQEAyMEdY1aR+sCR3ZSJrtztKTKqigvO/vBfqACJLZtS7QMgCGXJ6XIR\n\ -yy7mx66W0/sOFa7/1mAZtEoIokDP3ShoqF4fVNb6XeqgQfaUHd8wJpDWHcR2OFwv\n\ -plUUI1PLTktZ9uW2WE23b+ixNwJjJGwBDJPQEQFBE+vfmH0JP503wr5INS1poWg/\n\ -j25sIWeYPHYeOrFp/eXaqhISP6G+q2IeTaWTXpwZj4LzXq5YOpk4bYEQ6mvRq7D1\n\ -aHWfYmlEGepfaYR8Q0YqvvhYtMte3ITnuSJs171+GDqpdKcSwHnd6FudwGO4pcCO\n\ -j4WcDuXc2CTHgH8gFTNhp/Y8/SpDOhvn9QIDAQAB\n\ ------END RSA PUBLIC KEY-----"; - -fn rsa_pem() -> &'static str { - if crate::rpc::use_test_dc() { - TEST_RSA_PEM - } else { - PROD_RSA_PEM - } -} - -/// `p_q_inner_data_dc.dc`: add 10000 on test DCs. -/// https://core.telegram.org/mtproto/auth_key -fn inner_data_dc(dc_id: i32) -> i32 { - if crate::rpc::use_test_dc() { - dc_id + 10_000 - } else { - dc_id - } -} - -struct SystemClock; -impl Clock for SystemClock { - fn unix_micros(&self) -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros() as i64) - .unwrap_or(0) - } -} - -fn i128_from_bytes(bytes: [u8; 16]) -> I128 { - I128::from_le_slice(&bytes).expect("16-byte I128") -} - -fn i256_from_bytes(bytes: [u8; 32]) -> I256 { - I256::from_le_slice(&bytes).expect("32-byte I256") -} - -fn i128_to_bytes(value: &I128) -> [u8; 16] { - value.to_le_bytes() -} - -fn i256_to_bytes(value: &I256) -> [u8; 32] { - value.to_le_bytes() -} - -fn encode_boxed(value: &T) -> Result, MtprotoError> { - let mut encoder = Encoder::new(); - value - .encode_boxed(&mut encoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(encoder.into_bytes()) -} - -fn decode_boxed(bytes: &[u8]) -> Result { - let mut decoder = - Decoder::new(bytes, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let value = T::decode_boxed(&mut decoder).map_err(|e| MtprotoError::Message(e.to_string()))?; - decoder - .finish() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(value) -} - -/// Decode a boxed value that may be followed by random AES padding. -fn decode_boxed_prefix(bytes: &[u8]) -> Result<(T, usize), MtprotoError> { - let mut decoder = - Decoder::new(bytes, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let value = T::decode_boxed(&mut decoder).map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok((value, decoder.offset())) -} - -fn pad_be(bytes: &[u8], size: usize) -> Vec { - if bytes.len() >= size { - return bytes[bytes.len() - size..].to_vec(); - } - let mut out = vec![0_u8; size]; - out[size - bytes.len()..].copy_from_slice(bytes); - out -} - -struct AuthAdapter { - dc_id: i32, - rsa_pem: String, - nonce: I128, - server_nonce: Option, - new_nonce: Option, - p: Option>, - q: Option>, - g_b: Option>, - auth_key: Option>, - expires_in: Option, - expires_at: Option, - time_offset_micros: Option, - clock: fn() -> i64, -} - -impl Drop for AuthAdapter { - fn drop(&mut self) { - self.auth_key.zeroize(); - } -} - -impl AuthAdapter { - fn new(dc_id: i32) -> Result { - Self::with_pem(dc_id, rsa_pem().to_string()) - } - - fn with_pem(dc_id: i32, rsa_pem: String) -> Result { - let mut nonce_bytes = [0_u8; 16]; - fill_random(&mut nonce_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(Self { - dc_id, - rsa_pem, - nonce: i128_from_bytes(nonce_bytes), - server_nonce: None, - new_nonce: None, - p: None, - q: None, - g_b: None, - auth_key: None, - expires_in: None, - expires_at: None, - time_offset_micros: None, - clock: || SystemClock.unix_micros(), - }) - } -} - -impl AuthorizationAdapter for AuthAdapter { - fn request_parameters(&mut self) -> Result, tellers_mtproto_engine::Error> { - let req = ReqPqMultiRequest { - nonce: self.nonce.clone(), - }; - encode_boxed(&req).map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string())) - } - - fn accept_parameters( - &mut self, - response: &[u8], - ) -> Result, tellers_mtproto_engine::Error> { - let res_pq = decode_boxed::(response) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let ResPq::ResPq(res) = res_pq; - if res.nonce != self.nonce { - return Err(tellers_mtproto_engine::Error::Authorization( - "resPQ nonce mismatch".into(), - )); - } - self.server_nonce = Some(res.server_nonce.clone()); - - let pq = { - // pq is big-endian bytes representing u64 - let mut buf = [0_u8; 8]; - let src = &res.pq; - if src.len() > 8 { - return Err(tellers_mtproto_engine::Error::Authorization( - "pq too large".into(), - )); - } - buf[8 - src.len()..].copy_from_slice(src); - u64::from_be_bytes(buf) - }; - let (p, q) = factor_pq(pq) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let (p, q) = if p < q { (p, q) } else { (q, p) }; - let p_bytes = p.to_be_bytes().to_vec(); - let q_bytes = q.to_be_bytes().to_vec(); - // trim leading zeros for TL bytes - let trim = |v: Vec| { - let i = v - .iter() - .position(|b| *b != 0) - .unwrap_or(v.len().saturating_sub(1)); - v[i..].to_vec() - }; - let p_bytes = trim(p_bytes); - let q_bytes = trim(q_bytes); - self.p = Some(p_bytes.clone()); - self.q = Some(q_bytes.clone()); - - let mut new_nonce_bytes = [0_u8; 32]; - fill_random(&mut new_nonce_bytes) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let new_nonce = i256_from_bytes(new_nonce_bytes); - self.new_nonce = Some(new_nonce.clone()); - - let inner_bytes = if let Some(expires_in) = self.expires_in { - encode_boxed(&PQInnerDataTempDcConstructor { - pq: res.pq.clone(), - p: p_bytes, - q: q_bytes, - nonce: self.nonce.clone(), - server_nonce: res.server_nonce.clone(), - new_nonce, - dc: inner_data_dc(self.dc_id), - expires_in, - }) - } else { - encode_boxed(&PQInnerDataDcConstructor { - pq: res.pq.clone(), - p: p_bytes, - q: q_bytes, - nonce: self.nonce.clone(), - server_nonce: res.server_nonce.clone(), - new_nonce, - dc: inner_data_dc(self.dc_id), - }) - } - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - - let key = parse_rsa_public_key(&self.rsa_pem) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let fingerprint = rsa_public_key_fingerprint(&key); - let fingerprints = match &*res.server_public_key_fingerprints { - tellers_mtproto::transport::Vector::Vector(v) => &v.field_1, - }; - if !fingerprints.iter().any(|fp| *fp == fingerprint) { - return Err(tellers_mtproto_engine::Error::Authorization( - "RSA fingerprint not accepted by server".into(), - )); - } - let encrypted = rsa_pad(&key, &inner_bytes) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - - let req = ReqDhParamsRequest { - nonce: self.nonce.clone(), - server_nonce: res.server_nonce, - p: self.p.clone().unwrap(), - q: self.q.clone().unwrap(), - public_key_fingerprint: fingerprint, - encrypted_data: encrypted, - }; - encode_boxed(&req).map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string())) - } - - fn accept_dh(&mut self, response: &[u8]) -> Result, tellers_mtproto_engine::Error> { - let params = decode_boxed::(response) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let ServerDhParams::ServerDhParamsOk(ok) = params else { - return Err(tellers_mtproto_engine::Error::Authorization( - "server_DH_params_fail".into(), - )); - }; - let server_nonce = self.server_nonce.clone().ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization("missing server_nonce".into()) - })?; - if ok.nonce != self.nonce || ok.server_nonce != server_nonce { - return Err(tellers_mtproto_engine::Error::Authorization( - "DH envelope nonce mismatch".into(), - )); - } - let new_nonce = self.new_nonce.clone().ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization("missing new_nonce".into()) - })?; - let (aes_key, aes_iv) = - temporary_aes_key_iv(&i256_to_bytes(&new_nonce), &i128_to_bytes(&server_nonce)); - let answer = aes_ige_decrypt(&ok.encrypted_answer, &aes_key, &aes_iv) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - // answer = sha1(inner) + inner + random padding (pad is NOT covered by sha1) - if answer.len() < 24 { - return Err(tellers_mtproto_engine::Error::Authorization( - "DH answer too short".into(), - )); - } - let (inner, inner_len) = decode_boxed_prefix::(&answer[20..]) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let expected_hash = tellers_mtproto_crypto::sha1(&answer[20..20 + inner_len]); - if answer[..20] != expected_hash { - return Err(tellers_mtproto_engine::Error::Authorization( - "DH answer SHA1 mismatch (bad AES key/nonce?)".into(), - )); - } - let ServerDhInnerData::ServerDhInnerData(inner) = inner else { - return Err(tellers_mtproto_engine::Error::Authorization( - "unexpected server_DH_inner_data".into(), - )); - }; - - if inner.nonce != self.nonce || inner.server_nonce != server_nonce { - return Err(tellers_mtproto_engine::Error::Authorization( - "DH inner nonce mismatch".into(), - )); - } - let received_at = (self.clock)(); - if inner.server_time <= 0 { - return Err(tellers_mtproto_engine::Error::Authorization( - "invalid DH server time".into(), - )); - } - let expires_at = self - .expires_in - .map(|seconds| { - inner - .server_time - .checked_add(seconds) - .filter(|expiry| *expiry > inner.server_time) - .ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization( - "invalid temporary key expiry".into(), - ) - }) - }) - .transpose()?; - - use num_bigint::BigUint; - let prime = BigUint::from_bytes_be(&inner.dh_prime); - let g_a = BigUint::from_bytes_be(&inner.g_a); - validate_dh_parameters(&prime, inner.g as u32) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - validate_dh_public_value(&g_a, &prime) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - - let prime_bytes = usize::try_from(prime.bits().div_ceil(8)) - .unwrap_or(256) - .max(256); - let mut b_bytes = zeroize::Zeroizing::new(vec![0_u8; prime_bytes]); - fill_random(&mut b_bytes) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let b = BigUint::from_bytes_be(&b_bytes); - let g = BigUint::from(inner.g as u32); - let g_b = g.modpow(&b, &prime); - let g_b_bytes = pad_be(&g_b.to_bytes_be(), prime_bytes); - self.g_b = Some(g_b_bytes.clone()); - - let auth_key = derive_auth_key(&g_a, &b, &prime) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - self.auth_key = Some(auth_key); - self.expires_at = expires_at; - self.time_offset_micros = - Some((i64::from(inner.server_time) * 1_000_000).saturating_sub(received_at)); - - let client_inner = tellers_mtproto::transport::ClientDhInnerDataConstructor { - nonce: self.nonce.clone(), - server_nonce: server_nonce.clone(), - retry_id: 0, - g_b: g_b_bytes, - }; - let client_bytes = encode_boxed(&client_inner) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - // sha1 covers the exact TL object only; AES padding follows. - let mut payload = tellers_mtproto_crypto::sha1(&client_bytes).to_vec(); - payload.extend_from_slice(&client_bytes); - let mut pad = vec![0_u8; (16 - (payload.len() % 16)) % 16]; - if !pad.is_empty() { - fill_random(&mut pad) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - payload.extend_from_slice(&pad); - } - let encrypted = aes_ige_encrypt(&payload, &aes_key, &aes_iv) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - - let req = SetClientDhParamsRequest { - nonce: self.nonce.clone(), - server_nonce, - encrypted_data: encrypted, - }; - encode_boxed(&req).map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string())) - } - - fn accept_authorization( - &mut self, - response: &[u8], - ) -> Result<(Vec, i64), tellers_mtproto_engine::Error> { - let answer = decode_boxed::(response) - .map_err(|e| tellers_mtproto_engine::Error::Authorization(e.to_string()))?; - let auth_key = self.auth_key.as_ref().ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization("missing auth_key".into()) - })?; - let new_nonce = self.new_nonce.clone().ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization("missing new_nonce".into()) - })?; - let server_nonce = self.server_nonce.clone().ok_or_else(|| { - tellers_mtproto_engine::Error::Authorization("missing server_nonce".into()) - })?; - match answer { - SetClientDhParamsAnswer::DhGenOk(ok) => { - if ok.nonce != self.nonce || ok.server_nonce != server_nonce { - return Err(tellers_mtproto_engine::Error::Authorization( - "DH completion nonce mismatch".into(), - )); - } - let expected = new_nonce_hash(&i256_to_bytes(&new_nonce), &auth_key, 1); - let got = i128_to_bytes(&ok.new_nonce_hash1); - if got != expected { - return Err(tellers_mtproto_engine::Error::Authorization( - "new_nonce_hash1 mismatch".into(), - )); - } - let salt = - initial_server_salt(&i256_to_bytes(&new_nonce), &i128_to_bytes(&server_nonce)); - Ok((self.auth_key.take().expect("validated auth key"), salt)) - } - _ => Err(tellers_mtproto_engine::Error::Authorization( - "dh_gen not ok".into(), - )), - } - } - - fn time_offset_micros(&self) -> Option { - self.time_offset_micros - } -} - -fn send_plain( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, - snapshot: &mut Snapshot, - body: Vec, -) -> Result<(), MtprotoError> { - let message_id = snapshot - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let plain = encode_plain_message(&PlainMessage { message_id, body }) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let packet = framing - .encode(&plain) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - conn.send(&packet) - .map_err(|e| MtprotoError::Message(e.to_string())) -} - -fn recv_plain( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, -) -> Result, MtprotoError> { - let mut buf = vec![0_u8; 64 * 1024]; - let mut input = Vec::new(); - loop { - let n = conn - .receive(&mut buf) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if n == 0 { - return Err(MtprotoError::Message("connection closed".into())); - } - if input.len().saturating_add(n) > 1024 * 1024 + 4 { - return Err(MtprotoError::Message( - "authorization response exceeds size limit".into(), - )); - } - input.extend_from_slice(&buf[..n]); - match framing.decode(&input) { - Ok(packet) => { - let payload = trim_padded_plain_packet(&packet.payload); - let plain = decode_plain_message(payload, 1024 * 1024) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - return Ok(plain.body); - } - Err(tellers_mtproto_transport::Error::Incomplete { .. }) => continue, - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - } -} - -/// Negotiate an authorization key over an already-framed padded-intermediate connection. -pub fn create_auth_key( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - snapshot: &mut Snapshot, -) -> Result<(), MtprotoError> { - create_auth_key_with_pem(conn, framing, snapshot, rsa_pem()) -} - -pub fn create_temp_auth_key( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - snapshot: &mut Snapshot, - expires_in: i32, -) -> Result { - let mut adapter = AuthAdapter::with_pem(snapshot.dc_id, rsa_pem().to_string())?; - adapter.expires_in = Some(expires_in); - run_exchange(adapter, conn, framing, snapshot)? - .ok_or_else(|| MtprotoError::Message("missing temporary key expiry".into())) -} - -pub fn create_auth_key_with_pem( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - snapshot: &mut Snapshot, - pem: &str, -) -> Result<(), MtprotoError> { - let adapter = AuthAdapter::with_pem(snapshot.dc_id, pem.to_string())?; - run_exchange(adapter, conn, framing, snapshot).map(|_| ()) -} - -fn run_exchange( - adapter: AuthAdapter, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - snapshot: &mut Snapshot, -) -> Result, MtprotoError> { - let clock = SystemClock; - let mut auth = Authorization::new(adapter); - - let body = auth - .start() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_plain(conn, framing, &clock, snapshot, body)?; - let response = recv_plain(conn, framing)?; - let next = auth - .advance(&response, snapshot) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let Some(body) = next else { - return Err(MtprotoError::Message("auth ended early".into())); - }; - send_plain(conn, framing, &clock, snapshot, body)?; - let response = recv_plain(conn, framing)?; - let next = auth - .advance(&response, snapshot) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let Some(body) = next else { - return Err(MtprotoError::Message("auth ended early after DH".into())); - }; - send_plain(conn, framing, &clock, snapshot, body)?; - let response = recv_plain(conn, framing)?; - let next = auth - .advance(&response, snapshot) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if next.is_some() || auth.phase() != AuthorizationPhase::Authorized { - return Err(MtprotoError::Message("auth key not installed".into())); - } - Ok(auth.into_adapter().expires_at) -} - -/// Padded-intermediate returns payload + 0..=15 extra bytes. Plain envelopes -/// encode their length at offset 16; strip the transport pad before decode. -fn trim_padded_plain_packet(packet: &[u8]) -> &[u8] { - if packet.len() < 20 || packet[..8] != [0; 8] { - return packet; - } - let length = u32::from_le_bytes(packet[16..20].try_into().expect("plain length")) as usize; - let total = 20usize.saturating_add(length); - if length > 0 && length % 4 == 0 && total <= packet.len() { - &packet[..total] - } else { - packet - } -} - -#[cfg(test)] -#[path = "../../tests/unit/auth_auth_key_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/auth/auth_rpc.rs b/native/mtproto-rs/src/auth/auth_rpc.rs deleted file mode 100644 index 998a18a38..000000000 --- a/native/mtproto-rs/src/auth/auth_rpc.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Auth API methods (`auth.sendCode` / `signIn` / `checkPassword`). -//! https://core.telegram.org/api/auth -//! https://core.telegram.org/method/auth.sendCode -//! https://core.telegram.org/api/srp - -use tellers_mtproto::latest::api::{ - AccountGetPasswordRequest, AccountPassword, AuthAuthorization, AuthCheckPasswordRequest, - AuthLogOutRequest, AuthLoggedOut, AuthResendCodeRequest, AuthSendCodeRequest, AuthSentCode, - AuthSentCodeType, AuthSignInRequest, CodeSettings, CodeSettingsConstructor, - InputCheckPasswordSrp, PasswordKdfAlgo, True, TrueConstructor, User, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::auth::srp; -use crate::{AuthCodeSent, AuthSignedIn, MtprotoError}; - -pub(crate) fn has_login_token_update(packet: &[u8]) -> bool { - use tellers_mtproto::codec::{BoxedDecode, Decoder, Limits}; - use tellers_mtproto::latest::api::{Update, Updates}; - let Ok(mut decoder) = Decoder::new( - packet, - Limits { - max_bytes: 1024 * 1024, - max_total_bytes: 1024 * 1024, - max_vector_len: 4096, - max_depth: 32, - }, - ) else { - return false; - }; - let Ok(updates) = Updates::decode_boxed(&mut decoder) else { - return false; - }; - if decoder.finish().is_err() { - return false; - } - match updates { - Updates::UpdateShort(short) => matches!(*short.update, Update::UpdateLoginToken(_)), - Updates::Updates(updates) => { - let Vector::Vector(updates) = *updates.updates; - updates - .field_1 - .iter() - .any(|u| matches!(**u, Update::UpdateLoginToken(_))) - } - Updates::UpdatesCombined(updates) => { - let Vector::Vector(updates) = *updates.updates; - updates - .field_1 - .iter() - .any(|u| matches!(**u, Update::UpdateLoginToken(_))) - } - _ => false, - } -} - -fn device_session( - authorization: tellers_mtproto::latest::api::Authorization, -) -> crate::DeviceSessionDto { - let tellers_mtproto::latest::api::Authorization::Authorization(a) = authorization; - crate::DeviceSessionDto { - hash: a.hash, - current: a.current.is_some(), - password_pending: a.password_pending.is_some(), - device_model: a.device_model, - platform: a.platform, - system_version: a.system_version, - app_name: a.app_name, - app_version: a.app_version, - date_active: a.date_active, - ip: a.ip, - country: a.country, - region: a.region, - } -} - -pub fn get_device_sessions( - snapshot: &mut Snapshot, - api_id: i32, -) -> Result, MtprotoError> { - use tellers_mtproto::latest::api::{AccountAuthorizations, AccountGetAuthorizationsRequest}; - let sessions: AccountAuthorizations = - api_invoke::invoke_api(snapshot, api_id, AccountGetAuthorizationsRequest {})?; - let AccountAuthorizations::AccountAuthorizations(sessions) = sessions; - let Vector::Vector(sessions) = *sessions.authorizations; - Ok(sessions - .field_1 - .into_iter() - .map(|a| device_session(*a)) - .collect()) -} - -pub fn accept_login_token( - snapshot: &mut Snapshot, - api_id: i32, - token: &[u8], -) -> Result { - use tellers_mtproto::latest::api::AuthAcceptLoginTokenRequest; - if token.is_empty() || token.len() > 512 { - return Err(MtprotoError::Message("RPC 400: AUTH_TOKEN_INVALID".into())); - } - let authorization = api_invoke::invoke_api( - snapshot, - api_id, - AuthAcceptLoginTokenRequest { - token: token.to_vec(), - }, - )?; - Ok(device_session(authorization)) -} - -pub fn revoke_device_session( - snapshot: &mut Snapshot, - api_id: i32, - hash: i64, -) -> Result<(), MtprotoError> { - use tellers_mtproto::latest::api::{AccountResetAuthorizationRequest, Bool}; - let result: Bool = - api_invoke::invoke_api(snapshot, api_id, AccountResetAuthorizationRequest { hash })?; - match result { - Bool::BoolTrue(_) => Ok(()), - _ => Err(MtprotoError::Message("session revocation failed".into())), - } -} - -fn code_settings(logout_tokens: &[Vec]) -> Box { - let logout_tokens = (!logout_tokens.is_empty()).then(|| { - Box::new(Vector::Vector(VectorConstructor { - field_0: logout_tokens.len() as u32, - field_1: logout_tokens.to_vec(), - })) - }); - let mut flags = CodeSettingsConstructor::ALLOW_APP_HASH_FLAG; - if logout_tokens.is_some() { - flags |= CodeSettingsConstructor::LOGOUT_TOKENS_FLAG; - } - Box::new(CodeSettings::CodeSettings(CodeSettingsConstructor { - flags, - allow_flashcall: None, - current_number: None, - allow_app_hash: Some(Box::new(True::True(TrueConstructor {}))), - allow_missed_call: None, - allow_firebase: None, - unknown_number: None, - logout_tokens, - token: None, - app_sandbox: None, - })) -} - -fn user_id(user: &User) -> Result { - match user { - User::User(u) => Ok(u.id), - User::UserEmpty(u) => Ok(u.id), - _ => Err(MtprotoError::Message("unexpected user constructor".into())), - } -} - -pub(crate) fn authorization_to_signed( - auth: AuthAuthorization, - dc_id: i32, -) -> Result { - match auth { - AuthAuthorization::AuthAuthorization(a) => Ok(AuthSignedIn { - user_id: user_id(&a.user)?, - dc_id, - }), - AuthAuthorization::AuthAuthorizationSignUpRequired(_) => { - Err(MtprotoError::RegistrationRequired) - } - _ => Err(MtprotoError::Message("unexpected authorization".into())), - } -} - -/// Manual test accounts: `99966XYYYY` on test DCs (X = 1..3). -/// https://core.telegram.org/api/obtaining_api_id#test-accounts -pub fn test_dc_from_phone(phone: &str) -> Option { - let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); - if digits.len() != 10 || !digits.starts_with("99966") { - return None; - } - let dc = (digits.as_bytes()[5] - b'0') as i32; - (1..=3).contains(&dc).then_some(dc) -} - -pub fn send_code( - snapshot: &mut Snapshot, - api_id: i32, - api_hash: &str, - phone: &str, - logout_tokens: &[Vec], -) -> Result { - let phone = phone.to_string(); - let sent: AuthSentCode = api_invoke::invoke_api( - snapshot, - api_id, - AuthSendCodeRequest { - phone_number: phone.clone(), - api_id, - api_hash: api_hash.into(), - settings: code_settings(logout_tokens), - }, - )?; - sent_code_to_dto(phone, sent) -} - -/// https://core.telegram.org/method/auth.resendCode -pub fn resend_code( - snapshot: &mut Snapshot, - api_id: i32, - phone: &str, - phone_code_hash: &str, -) -> Result { - let phone = phone.to_string(); - let sent: AuthSentCode = api_invoke::invoke_api( - snapshot, - api_id, - AuthResendCodeRequest { - flags: 0, - phone_number: phone.clone(), - phone_code_hash: phone_code_hash.into(), - reason: None, - }, - )?; - sent_code_to_dto(phone, sent) -} - -fn sent_code_to_dto(phone: String, sent: AuthSentCode) -> Result { - let (hash, code_type, code_length) = match sent { - AuthSentCode::AuthSentCode(s) => ( - s.phone_code_hash, - sent_code_type_name(s.type_.as_ref()), - sent_code_type_length(s.type_.as_ref()), - ), - AuthSentCode::AuthSentCodeSuccess(_) => { - return Err(MtprotoError::Message( - "auth.sentCodeSuccess not supported in thin slice".into(), - )); - } - _ => return Err(MtprotoError::Message("unexpected auth.sentCode".into())), - }; - Ok(AuthCodeSent { - phone, - phone_code_hash: hash, - code_type: code_type.into(), - code_length, - }) -} - -fn sent_code_type_length(sent: &AuthSentCodeType) -> i32 { - let len = match sent { - AuthSentCodeType::AuthSentCodeTypeApp(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeSms(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeCall(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeMissedCall(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeEmailCode(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeFragmentSms(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeFirebaseSms(x) => x.length, - AuthSentCodeType::AuthSentCodeTypeFlashCall(x) => x.pattern.len() as i32, - _ => 5, - }; - if len > 0 { len } else { 5 } -} - -fn sent_code_type_name(sent: &AuthSentCodeType) -> &'static str { - match sent { - AuthSentCodeType::AuthSentCodeTypeApp(_) => "app", - AuthSentCodeType::AuthSentCodeTypeSms(_) => "sms", - AuthSentCodeType::AuthSentCodeTypeCall(_) => "call", - AuthSentCodeType::AuthSentCodeTypeFlashCall(_) => "flash_call", - AuthSentCodeType::AuthSentCodeTypeMissedCall(_) => "missed_call", - AuthSentCodeType::AuthSentCodeTypeEmailCode(_) => "email", - AuthSentCodeType::AuthSentCodeTypeSetUpEmailRequired(_) => "email_setup", - AuthSentCodeType::AuthSentCodeTypeFragmentSms(_) => "fragment", - AuthSentCodeType::AuthSentCodeTypeFirebaseSms(_) => "firebase", - AuthSentCodeType::AuthSentCodeTypeSmsWord(_) => "sms_word", - AuthSentCodeType::AuthSentCodeTypeSmsPhrase(_) => "sms_phrase", - } -} - -pub fn sign_in( - snapshot: &mut Snapshot, - api_id: i32, - phone: &str, - phone_code_hash: &str, - phone_code: &str, -) -> Result { - let auth: AuthAuthorization = api_invoke::invoke_api( - snapshot, - api_id, - AuthSignInRequest { - flags: 1, - phone_number: phone.into(), - phone_code_hash: phone_code_hash.into(), - phone_code: Some(phone_code.into()), - email_verification: None, - }, - )?; - authorization_to_signed(auth, snapshot.dc_id) -} - -pub(crate) fn is_srp_id_invalid(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if message.contains("SRP_ID_INVALID")) -} - -/// `account.getPassword` then `auth.checkPassword`. Retry once on `SRP_ID_INVALID` -/// (srp_id is short-lived; https://core.telegram.org/api/srp). -pub fn check_password( - snapshot: &mut Snapshot, - api_id: i32, - password: &str, -) -> Result { - match check_password_once(snapshot, api_id, password) { - Ok(signed) => Ok(signed), - Err(err) if is_srp_id_invalid(&err) => check_password_once(snapshot, api_id, password), - Err(err) => Err(err), - } -} - -/// Revoke the current authorization on Telegram. Local teardown is owned by -/// the client manager even when this RPC fails because the key was already -/// revoked or the connection is unavailable. -pub fn log_out(snapshot: &mut Snapshot, api_id: i32) -> Result>, MtprotoError> { - let logged: AuthLoggedOut = api_invoke::invoke_api(snapshot, api_id, AuthLogOutRequest {})?; - let AuthLoggedOut::AuthLoggedOut(logged) = logged; - Ok(logged.future_auth_token) -} - -fn check_password_once( - snapshot: &mut Snapshot, - api_id: i32, - password: &str, -) -> Result { - let pwd: AccountPassword = - api_invoke::invoke_api(snapshot, api_id, AccountGetPasswordRequest {})?; - let AccountPassword::AccountPassword(pwd) = pwd else { - return Err(MtprotoError::Message("unexpected account.password".into())); - }; - let Some(algo) = pwd.current_algo else { - return Err(MtprotoError::Message("no current password algo".into())); - }; - let PasswordKdfAlgo::PasswordKdfAlgoSha256sha256pbkdf2hmacsha512iter100000Sha256ModPow(algo) = - *algo - else { - return Err(MtprotoError::Message( - "unsupported password KDF (client outdated?)".into(), - )); - }; - let srp_b = pwd - .srp_b - .ok_or_else(|| MtprotoError::Message("missing srp_B".into()))?; - let srp_id = pwd - .srp_id - .ok_or_else(|| MtprotoError::Message("missing srp_id".into()))?; - let srp_input: InputCheckPasswordSrp = - srp::input_check_password(password, &algo, &srp_b, srp_id)?; - let auth: AuthAuthorization = api_invoke::invoke_api( - snapshot, - api_id, - AuthCheckPasswordRequest { - password: Box::new(srp_input), - }, - )?; - authorization_to_signed(auth, snapshot.dc_id) -} - -#[cfg(test)] -#[path = "../../tests/unit/auth_auth_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/auth/bind_temp.rs b/native/mtproto-rs/src/auth/bind_temp.rs deleted file mode 100644 index 659367bd5..000000000 --- a/native/mtproto-rs/src/auth/bind_temp.rs +++ /dev/null @@ -1,757 +0,0 @@ -//! Bind a temporary auth key so an extra home-DC session does not reuse the -//! permanent key. https://core.telegram.org/api/pfs -//! https://core.telegram.org/method/auth.bindTempAuthKey -//! https://core.telegram.org/mtproto/description_v1 - -use tellers_mtproto::codec::{Boxed, Decoder, Encoder, Limits, TlDecode}; -use tellers_mtproto::transport::BindAuthKeyInnerConstructor; -use tellers_mtproto_crypto::{aes_ige_decrypt, aes_ige_encrypt, auth_key_id, fill_random, sha1}; -use tellers_mtproto_session::{Clock, OsRandom, Snapshot}; -use tellers_mtproto_transport::{Connection, Framing, PaddedIntermediate}; - -use crate::MtprotoError; -use crate::auth::auth_key::create_temp_auth_key; - -const TEMP_EXPIRES_IN: i32 = 24 * 60 * 60; -const BOOL_TRUE: u32 = 0x9972_75b5; -const BOOL_FALSE: u32 = 0xbc79_9737; -const RPC_RESULT: u32 = 0xf35c_6d01; -const RPC_ERROR: u32 = 0x2144_ca19; -const MSG_CONTAINER: u32 = 0x73f1_f8dc; - -pub(crate) fn unix_now() -> i32 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| i32::try_from(d.as_secs()).unwrap_or(i32::MAX)) - .unwrap_or(0) -} - -/// MTProto v1 `encrypted_message` for `auth.bindTempAuthKey`. -/// `msg_key` covers the envelope before alignment padding. -pub(crate) fn encrypt_bind_message( - perm_key: &[u8], - msg_id: i64, - inner: &BindAuthKeyInnerConstructor, - random_prefix: &[u8; 16], - padding: &[u8], -) -> Result, MtprotoError> { - if perm_key.len() != 256 { - return Err(MtprotoError::Message("permanent key required".into())); - } - let body = encode_inner(inner)?; - if body.len() != 40 { - return Err(MtprotoError::Message("bind inner length".into())); - } - let mut plain = Vec::with_capacity(72 + padding.len()); - plain.extend_from_slice(random_prefix); - plain.extend_from_slice(&msg_id.to_le_bytes()); - plain.extend_from_slice(&0_i32.to_le_bytes()); - plain.extend_from_slice(&40_i32.to_le_bytes()); - plain.extend_from_slice(&body); - let msg_key = v1_msg_key(&plain); - if padding.len() > 15 || (plain.len() + padding.len()) % 16 != 0 { - return Err(MtprotoError::Message("bind padding".into())); - } - plain.extend_from_slice(padding); - let (aes_key, aes_iv) = v1_aes(&msg_key, perm_key); - let encrypted = aes_ige_encrypt(&plain, &aes_key, &aes_iv) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut out = Vec::with_capacity(24 + encrypted.len()); - out.extend_from_slice(&auth_key_id(perm_key).to_le_bytes()); - out.extend_from_slice(&msg_key); - out.extend_from_slice(&encrypted); - Ok(out) -} - -fn v1_msg_key(unpadded: &[u8]) -> [u8; 16] { - sha1(unpadded)[4..20].try_into().expect("sha1 slice") -} - -fn v1_aes(msg_key: &[u8; 16], auth_key: &[u8]) -> ([u8; 32], [u8; 32]) { - let sha_a = sha1_parts(&[msg_key, &auth_key[0..32]]); - let sha_b = sha1_parts(&[&auth_key[32..48], msg_key, &auth_key[48..64]]); - let sha_c = sha1_parts(&[&auth_key[64..96], msg_key]); - let sha_d = sha1_parts(&[msg_key, &auth_key[96..128]]); - let mut key = [0_u8; 32]; - key[..8].copy_from_slice(&sha_a[..8]); - key[8..20].copy_from_slice(&sha_b[8..20]); - key[20..].copy_from_slice(&sha_c[4..16]); - let mut iv = [0_u8; 32]; - iv[..12].copy_from_slice(&sha_a[8..20]); - iv[12..20].copy_from_slice(&sha_b[..8]); - iv[20..24].copy_from_slice(&sha_c[16..20]); - iv[24..].copy_from_slice(&sha_d[..8]); - (key, iv) -} - -fn sha1_parts(parts: &[&[u8]]) -> [u8; 20] { - let mut buf = Vec::new(); - for part in parts { - buf.extend_from_slice(part); - } - sha1(&buf) -} - -fn encode_inner(inner: &BindAuthKeyInnerConstructor) -> Result, MtprotoError> { - let mut encoder = Encoder::new(); - inner - .encode_boxed(&mut encoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(encoder.into_bytes()) -} - -struct SystemClock; -impl Clock for SystemClock { - fn unix_micros(&self) -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX)) - .unwrap_or(0) - } -} - -pub(crate) struct BoundTemp { - pub snapshot: Snapshot, - pub expires_at: i32, -} - -pub(crate) fn temp_key_valid(snapshot: &Snapshot, expires_at: i32) -> bool { - temp_key_valid_at(snapshot, expires_at, &SystemClock) -} - -fn temp_key_valid_at(snapshot: &Snapshot, expires_at: i32, clock: &impl Clock) -> bool { - let server_now = clock - .unix_micros() - .saturating_add(snapshot.time_offset_micros) - .div_euclid(1_000_000); - i64::from(expires_at) > server_now.saturating_add(60) -} - -pub(crate) fn ensure_main_temp( - client: &crate::client::Client, - io: &mut crate::client::lanes::SessionIo, - activate: bool, -) -> Result { - ensure_main_temp_with(client, io, activate, &SystemClock, negotiate_bound_temp) -} - -pub(crate) fn ensure_main_temp_with( - client: &crate::client::Client, - io: &mut crate::client::lanes::SessionIo, - activate: bool, - clock: &impl Clock, - negotiate: impl FnOnce(&[u8], i32, i64) -> Result, -) -> Result { - client - .connections - .wait(std::time::Duration::ZERO) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if !activate && io.temp_expires_at == 0 && io.perm_key.is_none() { - return Ok(false); - } - let dc = { - let data = client.data.lock(); - if data.user_id.is_none() || data.session_dead { - return Ok(false); - } - data.home_dc - }; - ensure_dc_temp_with(client, io, dc, None, clock, negotiate) -} - -pub(crate) fn dc_permanent_key(data: &crate::client::ClientData, dc: i32) -> Option<&[u8]> { - let key = if dc == data.home_dc { - data.home_auth_key.as_deref() - } else { - data.dc_auth_keys.get(&dc).map(|entry| entry.key.as_slice()) - }; - key.filter(|key| key.len() == 256) -} - -pub(crate) fn ensure_dc_temp_with( - client: &crate::client::Client, - io: &mut crate::client::lanes::SessionIo, - dc: i32, - expected_home: Option, - clock: &impl Clock, - negotiate: impl FnOnce(&[u8], i32, i64) -> Result, -) -> Result { - client - .connections - .wait(std::time::Duration::ZERO) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let (permanent, owner, user, cached) = { - let data = client.data.lock(); - if data.user_id.is_none() - || data.session_dead - || expected_home.is_some_and(|session| session != data.home_session_id) - { - return Err(crate::client::expired_session_lease()); - } - let permanent = dc_permanent_key(&data, dc) - .ok_or_else(|| MtprotoError::Message("permanent session identity unavailable".into()))? - .to_vec(); - let cached = expected_home - .and_then(|_| data.dc_temp_auth.get(&dc)) - .filter(|entry| { - entry.perm_key == permanent - && temp_key_valid_at(&entry.snapshot, entry.temp_expires_at, clock) - }) - .cloned(); - (permanent, data.home_snapshot.clone(), data.user_id, cached) - }; - if io.perm_key.as_deref() == Some(permanent.as_slice()) - && io.snapshot.dc_id == dc - && io - .snapshot - .auth_key - .as_ref() - .is_some_and(|key| key.len() == 256 && key != &permanent) - && temp_key_valid_at(&io.snapshot, io.temp_expires_at, clock) - { - return Ok(false); - } - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - let bound = if let Some(cached) = cached { - BoundTemp { - snapshot: crate::client::fork_session(&cached.snapshot), - expires_at: cached.temp_expires_at, - } - } else { - crate::tcp::with_connection_control(&client.connections, || { - negotiate(&permanent, dc, owner.time_offset_micros) - })? - }; - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - bound - .snapshot - .validate() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if bound.snapshot.dc_id != dc - || bound.snapshot.session_id == owner.session_id - || !bound - .snapshot - .auth_key - .as_ref() - .is_some_and(|key| key.len() == 256 && key != &permanent) - || !temp_key_valid_at(&bound.snapshot, bound.expires_at, clock) - { - return Err(MtprotoError::Message( - "invalid bound temporary session".into(), - )); - } - let mut data = client.data.lock(); - if !crate::client::session_lease_valid(&data, owner.session_id) - || data.user_id != user - || data.home_dc != owner.dc_id - || data.home_auth_key != owner.auth_key - || dc_permanent_key(&data, dc) != Some(permanent.as_slice()) - { - return Err(crate::client::expired_session_lease()); - } - client - .connections - .while_open(|| { - client.connections.wait(std::time::Duration::ZERO)?; - if let Some(mut previous) = io.transport.take() { - let _ = previous.conn.close(); - } - crate::rpc::salts::forget_session(io.snapshot.session_id); - if expected_home.is_some() { - data.dc_temp_auth.insert( - dc, - crate::client::DcTempAuth { - snapshot: bound.snapshot.clone(), - temp_expires_at: bound.expires_at, - perm_key: permanent.clone(), - }, - ); - } - io.snapshot = bound.snapshot; - io.temp_expires_at = bound.expires_at; - io.perm_key = Some(permanent); - io.pending_push = Default::default(); - io.last_difference = None; - Ok::<_, tellers_mtproto_transport::Error>(()) - }) - .map_err(|e| MtprotoError::Message(e.to_string()))? - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(true) -} - -struct DeadlineConnection<'a> { - inner: &'a mut crate::tcp::ObfuscatedTcp, - deadline: std::time::Instant, -} - -impl DeadlineConnection<'_> { - fn prepare(&mut self) -> Result<(), tellers_mtproto_transport::Error> { - let remaining = self - .deadline - .saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return Err(tellers_mtproto_transport::Error::Connection( - "temporary key negotiation timed out".into(), - )); - } - self.inner - .set_io_timeout_ms(remaining.as_millis().min(u128::from(u64::MAX)) as u64); - Ok(()) - } -} - -impl Connection for DeadlineConnection<'_> { - fn send(&mut self, packet: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { - self.prepare()?; - self.inner.send(packet) - } - - fn receive(&mut self, output: &mut [u8]) -> Result { - self.prepare()?; - self.inner.receive(output) - } - - fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { - self.inner.close() - } -} - -/// Handshake a temporary key and bind it. Leaves the permanent key untouched. -pub(crate) fn negotiate_bound_temp( - perm_key: &[u8], - dc_id: i32, - time_offset_micros: i64, -) -> Result { - if perm_key.len() != 256 { - return Err(MtprotoError::Message("permanent key required".into())); - } - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - let mut snapshot = - Snapshot::new(dc_id, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; - snapshot.time_offset_micros = time_offset_micros; - let mut conn = crate::tcp::connect_obfuscated_dc(crate::rpc::dc_endpoints(dc_id)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut conn = DeadlineConnection { - inner: &mut conn, - deadline, - }; - let mut framing = PaddedIntermediate::default(); - let expires_at = create_temp_auth_key(&mut conn, &mut framing, &mut snapshot, TEMP_EXPIRES_IN)?; - bind_temporary_key( - &mut conn, - &mut framing, - &mut snapshot, - perm_key, - expires_at, - &SystemClock, - )?; - Ok(BoundTemp { - snapshot, - expires_at, - }) -} - -fn bind_temporary_key( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - snapshot: &mut Snapshot, - perm_key: &[u8], - expires_at: i32, - clock: &impl Clock, -) -> Result<(), MtprotoError> { - let temp_key = zeroize::Zeroizing::new( - snapshot - .auth_key - .clone() - .ok_or_else(|| MtprotoError::Message("temp key missing".into()))?, - ); - if temp_key.as_slice() == perm_key { - return Err(MtprotoError::Message( - "temp key matched permanent key".into(), - )); - } - let msg_id = snapshot - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut nonce_bytes = [0_u8; 8]; - fill_random(&mut nonce_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut prefix = [0_u8; 16]; - fill_random(&mut prefix).map_err(|e| MtprotoError::Message(e.to_string()))?; - let inner = BindAuthKeyInnerConstructor { - nonce: i64::from_le_bytes(nonce_bytes), - temp_auth_key_id: auth_key_id(&temp_key), - perm_auth_key_id: auth_key_id(perm_key), - temp_session_id: snapshot.session_id, - expires_at, - }; - let mut padding = [0_u8; 8]; - fill_random(&mut padding).map_err(|e| MtprotoError::Message(e.to_string()))?; - let encrypted = encrypt_bind_message(perm_key, msg_id, &inner, &prefix, &padding)?; - let request = tellers_mtproto::latest::api::AuthBindTempAuthKeyRequest { - perm_auth_key_id: inner.perm_auth_key_id, - nonce: inner.nonce, - expires_at, - encrypted_message: encrypted, - }; - let body = crate::rpc::encode_boxed_bytes(&request)?; - let seq = snapshot - .next_sequence(true) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_v2(conn, framing, &temp_key, snapshot, msg_id, seq, &body)?; - read_bind_result(conn, framing, &temp_key, snapshot, msg_id, clock) -} - -fn send_v2( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - auth_key: &[u8], - snapshot: &mut Snapshot, - message_id: i64, - sequence: i32, - body: &[u8], -) -> Result<(), MtprotoError> { - let pad_len = v2_pad_len(body.len()); - let mut padding = vec![0_u8; pad_len]; - fill_random(&mut padding).map_err(|e| MtprotoError::Message(e.to_string()))?; - let packet = tellers_mtproto_crypto::encrypt_message( - auth_key, - tellers_mtproto_crypto::MessageToEncrypt { - server_salt: snapshot.server_salt, - session_id: snapshot.session_id, - message_id, - sequence, - body, - padding: &padding, - }, - tellers_mtproto_crypto::Direction::ClientToServer, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let framed = framing - .encode(&packet) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - conn.send(&framed) - .map_err(|e| MtprotoError::Message(e.to_string())) -} - -fn v2_pad_len(body_len: usize) -> usize { - let mut pad = 12; - while (32 + body_len + pad) % 16 != 0 { - pad += 1; - } - pad -} - -fn read_bind_result( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - temp_key: &[u8], - snapshot: &mut Snapshot, - msg_id: i64, - clock: &impl Clock, -) -> Result<(), MtprotoError> { - let mut buf = vec![0_u8; 64 * 1024]; - let mut input = Vec::new(); - for _ in 0..8 { - loop { - match framing.decode(&input) { - Ok(packet) => { - let consumed = packet.consumed; - let payload = crate::rpc::trim_padded_mtproto_packet(&packet.payload); - input.drain(..consumed.min(input.len())); - if let Some(control) = tellers_mtproto_transport::payload_control(payload) { - match control { - tellers_mtproto_transport::Control::QuickAck(_) => break, - tellers_mtproto_transport::Control::TransportError(code) => { - return Err(MtprotoError::Message(format!( - "temp bind transport {code}" - ))); - } - } - } - let message = tellers_mtproto_crypto::decrypt_message( - temp_key, - payload, - tellers_mtproto_crypto::Direction::ServerToClient, - snapshot.session_id, - 1024 * 1024, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if message.sequence < 0 { - return Err(malformed_bind()); - } - let received = snapshot - .register_received_message( - message.message_id, - message.sequence & 1 != 0, - clock, - true, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if received != tellers_mtproto_session::ReceivedMessageResult::Accepted { - break; - } - let response = - bind_response(&message.body, msg_id, snapshot, clock, message.message_id)?; - if let Some(salt) = response.server_salt { - snapshot.server_salt = salt; - } - if response.accepted { - return Ok(()); - } - break; - } - Err(tellers_mtproto_transport::Error::Incomplete { .. }) => { - let n = conn - .receive(&mut buf) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if n == 0 { - return Err(MtprotoError::Message("temp bind connection closed".into())); - } - input.extend_from_slice(&buf[..n]); - } - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - } - } - Err(MtprotoError::Message("temp bind had no result".into())) -} - -#[derive(Default)] -struct BindResponse { - accepted: bool, - server_salt: Option, -} - -fn bind_response( - body: &[u8], - msg_id: i64, - snapshot: &mut Snapshot, - clock: &impl Clock, - outer_id: i64, -) -> Result { - bind_response_at_depth(body, msg_id, 0, &mut 1024, snapshot, clock, outer_id) -} - -fn malformed_bind() -> MtprotoError { - MtprotoError::Message("malformed temporary key binding response".into()) -} - -fn bind_response_at_depth( - body: &[u8], - msg_id: i64, - depth: usize, - remaining: &mut usize, - snapshot: &mut Snapshot, - clock: &impl Clock, - outer_id: i64, -) -> Result { - if depth > 8 || *remaining == 0 || body.len() < 4 || body.len() % 4 != 0 { - return Err(malformed_bind()); - } - *remaining -= 1; - let ctor = u32::from_le_bytes(body[..4].try_into().expect("ctor")); - if ctor == MSG_CONTAINER { - return container_accepted(body, msg_id, depth, remaining, snapshot, clock, outer_id); - } - if ctor == RPC_RESULT { - return Ok(BindResponse { - accepted: rpc_result_accepted(body, msg_id)?, - ..Default::default() - }); - } - if ctor == 0x9ec2_0908 { - if body.len() != 28 { - return Err(malformed_bind()); - } - return Ok(BindResponse { - server_salt: Some(i64::from_le_bytes(body[20..28].try_into().expect("salt"))), - ..Default::default() - }); - } - Ok(BindResponse::default()) -} - -fn container_accepted( - body: &[u8], - msg_id: i64, - depth: usize, - remaining: &mut usize, - snapshot: &mut Snapshot, - clock: &impl Clock, - outer_id: i64, -) -> Result { - if body.len() < 8 { - return Err(malformed_bind()); - } - let count = u32::from_le_bytes(body[4..8].try_into().expect("count")) as usize; - if count > *remaining || count > (body.len() - 8) / 20 { - return Err(malformed_bind()); - } - let mut offset = 8; - let mut response = BindResponse::default(); - for _ in 0..count { - if offset + 16 > body.len() { - return Err(malformed_bind()); - } - let child_id = i64::from_le_bytes( - body[offset..offset + 8] - .try_into() - .expect("child message id"), - ); - let sequence = - i32::from_le_bytes(body[offset + 8..offset + 12].try_into().expect("sequence")); - if child_id >= outer_id || child_id & 1 == 0 || sequence < 0 { - return Err(malformed_bind()); - } - offset += 8 + 4; - let len = u32::from_le_bytes(body[offset..offset + 4].try_into().expect("len")) as usize; - offset += 4; - let end = offset.checked_add(len).ok_or_else(malformed_bind)?; - if end > body.len() || len < 4 || len % 4 != 0 { - return Err(malformed_bind()); - } - // Fresh containers can retransmit old children; their IDs still need - // replay registration and content-related acknowledgements. - let disposition = snapshot - .register_received_message(child_id, sequence & 1 != 0, clock, false) - .map_err(|_| malformed_bind())?; - if disposition == tellers_mtproto_session::ReceivedMessageResult::Accepted { - let child = bind_response_at_depth( - &body[offset..end], - msg_id, - depth + 1, - remaining, - snapshot, - clock, - child_id, - )?; - response.accepted |= child.accepted; - response.server_salt = child.server_salt.or(response.server_salt); - } else { - *remaining = remaining.checked_sub(1).ok_or_else(malformed_bind)?; - } - offset = end; - } - if offset != body.len() { - return Err(malformed_bind()); - } - Ok(response) -} - -fn rpc_result_accepted(body: &[u8], msg_id: i64) -> Result { - if body.len() < 16 { - return Err(malformed_bind()); - } - let req = i64::from_le_bytes(body[4..12].try_into().expect("req")); - if req != msg_id { - return Ok(false); - } - let inner = u32::from_le_bytes(body[12..16].try_into().expect("inner")); - if inner == BOOL_TRUE || inner == BOOL_FALSE { - if body.len() != 16 { - return Err(malformed_bind()); - } - return if inner == BOOL_TRUE { - Ok(true) - } else { - Err(MtprotoError::Message("temp bind rejected".into())) - }; - } - if inner == RPC_ERROR { - if body.len() > 148 { - return Err(malformed_bind()); - } - let mut decoder = - Decoder::new(&body[16..], Limits::default()).map_err(|_| malformed_bind())?; - let error = tellers_mtproto::transport::RpcErrorConstructor::decode(&mut decoder) - .map_err(|_| malformed_bind())?; - decoder.finish().map_err(|_| malformed_bind())?; - let token = if error.error_message.len() <= 128 - && error - .error_message - .bytes() - .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') - { - error.error_message.as_str() - } else { - "UNKNOWN" - }; - return Err(MtprotoError::Message(format!( - "temp bind RPC {}: {token}", - error.error_code - ))); - } - Err(MtprotoError::Message("temp bind result".into())) -} - -fn promote_main( - client: &crate::client::Client, - negotiate: &mut impl FnMut(&[u8], i32, i64) -> Result, -) -> Result { - let _gate = client - .main_gate - .acquire(crate::scheduler::RequestClass::BackgroundRead)?; - let mut main = crate::client::lanes::lock_request_lane(&client.main, "lane_wait.pfs")?; - ensure_main_temp_with(client, &mut main, true, &SystemClock, negotiate) -} - -fn bind_lane( - client: &crate::client::Client, - lane: &crate::client::lanes::Lane, - negotiate: &mut impl FnMut(&[u8], i32, i64) -> Result, -) -> bool { - let Some(_gate) = lane - .gate - .try_acquire(crate::scheduler::RequestClass::BackgroundRead) - else { - return false; - }; - let Some(mut io) = lane.io.try_lock() else { - return false; - }; - ensure_main_temp_with(client, &mut io, true, &SystemClock, negotiate).is_ok() - && io.perm_key.is_some() -} - -/// Additional main sessions are admitted only after their temporary key is bound. -pub(crate) fn prepare_extra_main_sessions( - handle: u64, - mut negotiate: impl FnMut(&[u8], i32, i64) -> Result, -) -> Result<(), MtprotoError> { - let client = crate::client::get_client(handle)?; - let _policy = crate::transfer_policy::bind(client.policy.clone()); - { - let data = client.data.lock(); - if data.user_id.is_none() || data.session_dead || data.home_auth_key.is_none() { - crate::scheduler::set_bound_extra_sessions(0); - return Ok(()); - } - } - promote_main(&client, &mut negotiate)?; - for lane in &client.upload { - let _ = bind_lane(&client, lane, &mut negotiate); - } - let allowance = crate::scheduler::main_session_allowance(); - if allowance <= 1 { - crate::scheduler::set_bound_extra_sessions(0); - return Ok(()); - } - let want = usize::try_from(allowance.saturating_sub(1)).unwrap_or(0); - let want = want.min(crate::scheduler::READ_LANES); - let mut bound = 0_usize; - for lane in client.rpc.iter().take(want) { - if bind_lane(&client, lane, &mut negotiate) { - bound += 1; - } - } - crate::scheduler::set_bound_extra_sessions(bound); - Ok(()) -} - -#[cfg(test)] -#[path = "../../tests/unit/auth_bind_temp_tests.rs"] -mod wire_tests; - -#[cfg(test)] -#[path = "../../tests/unit/auth_bind_temp_crypto_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/auth/mod.rs b/native/mtproto-rs/src/auth/mod.rs deleted file mode 100644 index 3bd5d4540..000000000 --- a/native/mtproto-rs/src/auth/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod auth_key; -pub(crate) mod auth_rpc; -pub(crate) mod bind_temp; -pub(crate) mod srp; diff --git a/native/mtproto-rs/src/auth/srp.rs b/native/mtproto-rs/src/auth/srp.rs deleted file mode 100644 index 7dc509b6e..000000000 --- a/native/mtproto-rs/src/auth/srp.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Telegram 2FA SRP (`passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow`). -//! -//! Algorithm matches -//! (PH1 = SH(SH(password, salt1), salt2)). - -use num_bigint::BigUint; -use num_traits::Zero; -use pbkdf2::pbkdf2_hmac; -use sha2::{Digest, Sha256, Sha512}; -use tellers_mtproto::latest::api::{ - InputCheckPasswordSrp, InputCheckPasswordSrpConstructor, - PasswordKdfAlgoSha256sha256pbkdf2hmacsha512iter100000Sha256ModPowConstructor, -}; -use tellers_mtproto_crypto::fill_random; - -use crate::MtprotoError; - -fn h(parts: &[&[u8]]) -> [u8; 32] { - let mut hasher = Sha256::new(); - for part in parts { - hasher.update(part); - } - hasher.finalize().into() -} - -/// SH(data, salt) := H(salt | data | salt) -fn sh(data: &[u8], salt: &[u8]) -> [u8; 32] { - h(&[salt, data, salt]) -} - -/// PH1(password, salt1, salt2) := SH(SH(password, salt1), salt2) -fn ph1(password: &[u8], salt1: &[u8], salt2: &[u8]) -> [u8; 32] { - sh(&sh(password, salt1), salt2) -} - -/// PH2(password, salt1, salt2) := SH(pbkdf2(sha512, PH1(...), salt1, 100000), salt2) -fn ph2(password: &[u8], salt1: &[u8], salt2: &[u8]) -> [u8; 32] { - let hash1 = ph1(password, salt1, salt2); - let mut dk = [0_u8; 64]; - pbkdf2_hmac::(&hash1, salt1, 100_000, &mut dk); - sh(&dk, salt2) -} - -fn pad_to_256(data: &[u8]) -> [u8; 256] { - let mut out = [0_u8; 256]; - let data = if data.len() > 256 { - &data[data.len() - 256..] - } else { - data - }; - out[256 - data.len()..].copy_from_slice(data); - out -} - -fn xor32(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - let mut out = [0_u8; 32]; - for i in 0..32 { - out[i] = left[i] ^ right[i]; - } - out -} - -/// Build `inputCheckPasswordSRP` for `auth.checkPassword`. -pub fn input_check_password( - password: &str, - algo: &PasswordKdfAlgoSha256sha256pbkdf2hmacsha512iter100000Sha256ModPowConstructor, - srp_b: &[u8], - srp_id: i64, -) -> Result { - if algo.p.len() != 256 { - return Err(MtprotoError::Message(format!( - "unexpected SRP p length {}", - algo.p.len() - ))); - } - let big_p = BigUint::from_bytes_be(&algo.p); - if big_p.is_zero() || algo.g <= 0 { - return Err(MtprotoError::Message("invalid SRP modulus".into())); - } - - let g_b = pad_to_256(srp_b); - let mut a_bytes = [0_u8; 256]; - fill_random(&mut a_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; - let a = pad_to_256(&a_bytes); - - let g_for_hash = pad_to_256(&[algo.g as u8]); - let big_g_b = BigUint::from_bytes_be(&g_b); - let big_g = BigUint::from(algo.g as u32); - let big_a = BigUint::from_bytes_be(&a); - - // k := H(p | g) - let k = h(&[&algo.p, &g_for_hash]); - let big_k = BigUint::from_bytes_be(&k); - - // g_a := pow(g, a) mod p - let g_a = pad_to_256(&big_g.modpow(&big_a, &big_p).to_bytes_be()); - - // u := H(g_a | g_b) - let u = BigUint::from_bytes_be(&h(&[&g_a, &g_b])); - if u.is_zero() { - return Err(MtprotoError::Message("SRP u is zero".into())); - } - - // x := PH2(password, salt1, salt2) - let x = BigUint::from_bytes_be(&ph2(password.as_bytes(), &algo.salt1, &algo.salt2)); - - // v := pow(g, x) mod p - let big_v = big_g.modpow(&x, &big_p); - let k_v = (&big_k * &big_v) % &big_p; - - // t := (g_b - k_v) mod p (positive) - let big_t = if big_g_b >= k_v { - (&big_g_b - &k_v) % &big_p - } else { - (&big_p + &big_g_b - &k_v) % &big_p - }; - if big_t.is_zero() { - return Err(MtprotoError::Message("SRP t is zero".into())); - } - - // s_a := pow(t, a + u * x) mod p - let big_s_a = big_t.modpow(&(&big_a + &u * &x), &big_p); - let k_a = h(&[&pad_to_256(&big_s_a.to_bytes_be())]); - - // M1 := H(H(p) xor H(g) | H(salt1) | H(salt2) | g_a | g_b | k_a) - let m1 = h(&[ - &xor32(&h(&[&algo.p]), &h(&[&g_for_hash])), - &h(&[&algo.salt1]), - &h(&[&algo.salt2]), - &g_a, - &g_b, - &k_a, - ]); - - Ok(InputCheckPasswordSrp::InputCheckPasswordSrp( - InputCheckPasswordSrpConstructor { - srp_id, - a: g_a.to_vec(), - m1: m1.to_vec(), - }, - )) -} - -#[cfg(test)] -#[path = "../../tests/unit/auth_srp_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/auth.rs b/native/mtproto-rs/src/client/auth.rs deleted file mode 100644 index 6dc9fbaf1..000000000 --- a/native/mtproto-rs/src/client/auth.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! https://core.telegram.org/api/auth -//! https://core.telegram.org/method/auth.sendCode - -use tellers_mtproto_session::{OsRandom, Snapshot}; - -use crate::auth::auth_rpc; -use crate::session_file::FileSessionStore; -use crate::{AuthCodeSent, AuthSignedIn, MtprotoError}; - -use super::*; - -pub(super) fn login_key_error(state: &ClientState, error: MtprotoError) -> MtprotoError { - // Missing login keys require a fresh QR. PFS key failures must stay on the - // temporary-key recovery path instead of logging out the permanent session. - if state.permanent.is_none() - && matches!(&error, MtprotoError::Message(message) if message == "MTProto transport error -404") - { - MtprotoError::Message("RPC 401: AUTH_KEY_INVALID".into()) - } else { - error - } -} - -pub fn export_login_token(handle: u64) -> Result { - use tellers_mtproto::latest::api::{ - AuthExportLoginTokenRequest, AuthImportLoginTokenRequest, AuthLoginToken, Vector, - VectorConstructor, - }; - let result = with_client_mut(handle, |state| { - if let Some(user_id) = state.user_id { - reject_if_dead(state)?; - return Ok(crate::QrLoginDto::Authorized { - session: AuthSignedIn { - user_id, - dc_id: state.snapshot.dc_id, - }, - }); - } - if state.session_dead && state.user_id.is_none() { - if state.session_dead_reason.as_deref() == Some("AUTH_KEY_UNREGISTERED") { - state.session_dead = false; - state.session_dead_reason = None; - } else { - recreate_mtproto_session(state)?; - } - } - let result = (|| { - let mut token: AuthLoginToken = call_with_migrate(state, |state| { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AuthExportLoginTokenRequest { - api_id: state.api_id, - api_hash: state.api_hash.clone(), - except_ids: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }, - ) - })?; - for _ in 0..3 { - match token { - AuthLoginToken::AuthLoginToken(token) => { - return Ok(crate::QrLoginDto::Token { - token: token.token, - expires: token.expires, - }); - } - AuthLoginToken::AuthLoginTokenSuccess(success) => { - let signed = auth_rpc::authorization_to_signed( - *success.authorization, - state.snapshot.dc_id, - )?; - complete_sign_in(state, signed.user_id)?; - return Ok(crate::QrLoginDto::Authorized { session: signed }); - } - AuthLoginToken::AuthLoginTokenMigrateTo(migrate) => { - if !(1..=5).contains(&migrate.dc_id) { - return Err(MtprotoError::Message("invalid login datacenter".into())); - } - ensure_ready_after_migrate( - state, - MtprotoError::Message(format!("MIGRATE_{}", migrate.dc_id)), - )?; - token = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AuthImportLoginTokenRequest { - token: migrate.token, - }, - )?; - } - _ => return Err(MtprotoError::Message("unexpected login token".into())), - } - } - Err(MtprotoError::Message( - "too many login datacenter migrations".into(), - )) - })(); - if matches!(result, Err(MtprotoError::PasswordRequired)) { - persist(state)?; - } - result.map_err(|error| login_key_error(state, error)) - })?; - if matches!(result, crate::QrLoginDto::Authorized { .. }) { - drop_updates_transport(handle); - } - Ok(result) -} - -pub fn poll_login_token(handle: u64) -> Result { - poll_login_token_with(handle, crate::rpc::receive_updates) -} - -pub(super) fn poll_login_token_with( - handle: u64, - receive: impl FnOnce(&mut Snapshot) -> Result>, MtprotoError>, -) -> Result { - with_client_mut(handle, |state| { - reject_if_dead(state)?; - let packets = - receive(&mut state.snapshot).map_err(|error| login_key_error(state, error))?; - Ok(packets - .iter() - .any(|packet| auth_rpc::has_login_token_update(packet))) - }) -} - -pub fn get_device_sessions(handle: u64) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - auth_rpc::get_device_sessions(&mut state.snapshot, state.api_id) - }) - }) -} - -pub fn accept_login_token( - handle: u64, - token: Vec, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - auth_rpc::accept_login_token(&mut state.snapshot, state.api_id, &token) - }) - }) -} - -pub fn revoke_device_session(handle: u64, hash: i64) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - auth_rpc::revoke_device_session(&mut state.snapshot, state.api_id, hash) - }) - }) -} - -pub fn connect(handle: u64) -> Result<(), MtprotoError> { - connect_with_binding(handle, crate::auth::bind_temp::negotiate_bound_temp) -} - -pub(crate) fn connect_with_binding( - handle: u64, - negotiate: impl FnMut(&[u8], i32, i64) -> Result, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - if state.session_dead { - // A server-revoked key must never be reused. Auth flow will create - // a fresh key from sendCode; connect itself stays a harmless probe. - return Ok(()); - } - crate::rpc::set_use_test_dc(state.test_dc); - let mut persist_needed = false; - if !state.test_dc { - crate::dns_txt::load_sidecar(&state.session_path); - let home_dc = state.snapshot.dc_id; - let cached = crate::dns_txt::apply_fresh_cache(&state.session_path, home_dc); - ensure_ready(state)?; - if !cached { - crate::dns_txt::refresh_home_from_backup( - &state.session_path, - home_dc, - state.api_id, - &mut state.snapshot, - ); - persist_needed = true; - } - } else { - ensure_ready(state)?; - } - if state.user_id.is_some() - && !state.test_dc - && !crate::scheduler::main_session_allowance_known() - { - // One `help.getConfig` on the main session decides whether extra home-DC - // sessions (read lanes, home-DC file lanes) are permitted at all. - crate::dns_txt::probe_main_session_allowance( - state.snapshot.dc_id, - state.api_id, - &mut state.snapshot, - ); - persist_needed = true; - } - if persist_needed { - persist(state)?; - } - recover_imported_user(state)?; - Ok(()) - })?; - crate::auth::bind_temp::prepare_extra_main_sessions(handle, negotiate)?; - Ok(()) -} - -fn recover_imported_user(state: &mut ClientState) -> Result<(), MtprotoError> { - if state.session_dead - || state.user_id.is_some() - || state.snapshot.auth_key.as_ref().map(|k| k.len()) != Some(256) - { - return Ok(()); - } - match crate::profile::get_profile( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - None, - 0, - ) { - Ok(profile) if profile.id > 0 => { - state.user_id = Some(profile.id); - persist(state) - } - Ok(_) => Ok(()), - Err(_) => Ok(()), - } -} - -pub fn is_authorized(handle: u64) -> Result { - let client = get_client(handle)?; - let data = client.data.lock(); - Ok(!data.session_dead - && data.user_id.is_some() - && data.home_auth_key.as_ref().map(Vec::len) == Some(256)) -} - -pub(crate) fn is_auth_restart(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(m) if m.contains("AUTH_RESTART")) -} - -pub(crate) fn is_password_required(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::PasswordRequired) - || matches!(err, MtprotoError::Message(m) if m.contains("SESSION_PASSWORD_NEEDED")) -} - -pub(crate) fn apply_test_dc( - state: &mut ClientState, - want_test: bool, - want_dc: i32, -) -> Result<(), MtprotoError> { - if state.test_dc == want_test && (!want_test || state.snapshot.dc_id == want_dc) { - crate::rpc::set_use_test_dc(want_test); - return Ok(()); - } - crate::rpc::drop_live_transport(); - crate::rpc::set_use_test_dc(want_test); - state.test_dc = want_test; - state.dc_auth_keys.clear(); - state.permanent = None; - state.snapshot = - Snapshot::new(want_dc, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; - state.session_dead = false; - persist(state) -} - -pub(crate) fn apply_test_dc_for_phone( - state: &mut ClientState, - phone: &str, -) -> Result<(), MtprotoError> { - let from_phone = auth_rpc::test_dc_from_phone(phone); - let want_test = state.test_dc || from_phone.is_some(); - let want_dc = from_phone.unwrap_or(if want_test { - DEFAULT_DC_ID - } else { - state.snapshot.dc_id - }); - apply_test_dc(state, want_test, want_dc) -} - -pub fn set_client_test_dc(handle: u64, test: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| apply_test_dc(state, test, DEFAULT_DC_ID)) -} - -pub fn send_auth_code(handle: u64, phone: String) -> Result { - let sent = with_client_mut(handle, |state| { - apply_test_dc_for_phone(state, &phone)?; - if state.session_dead { - // account.* before sign-in returns AUTH_KEY_UNREGISTERED. The key is - // still the one auth.sendCode must use; replacing it drops the login. - let unregistered = state.user_id.is_none() - && state.session_dead_reason.as_deref() == Some("AUTH_KEY_UNREGISTERED"); - if unregistered { - state.session_dead = false; - state.session_dead_reason = None; - } else { - recreate_mtproto_session(state)?; - } - } - let attempt = |state: &mut ClientState| { - call_with_migrate(state, |state| { - auth_rpc::send_code( - &mut state.snapshot, - state.api_id, - &state.api_hash, - &phone, - &state.logout_tokens, - ) - }) - }; - let sent = match attempt(state) { - Ok(sent) => sent, - Err(err) if is_password_required(&err) => { - // The previous sign-in may have left this auth key in a - // server-side 2FA-pending state while the process/UI died. - // sendCode is the explicit start of a new auth attempt, so - // rotate the key and retry once instead of surfacing the stale - // SESSION_PASSWORD_NEEDED to the phone form. - recreate_mtproto_session(state)?; - ensure_ready(state)?; - attempt(state)? - } - Err(err) if is_auth_restart(&err) || crate::auth::auth_rpc::is_srp_id_invalid(&err) => { - // Stale 2FA/auth-key leftover from a previous attempt. - recreate_mtproto_session(state)?; - ensure_ready(state)?; - attempt(state)? - } - Err(err) => return Err(err), - }; - persist(state)?; - Ok(sent) - })?; - drop_updates_transport(handle); - Ok(sent) -} - -pub fn resend_auth_code( - handle: u64, - phone: String, - phone_code_hash: String, -) -> Result { - with_client_mut(handle, |state| { - let sent = call_with_migrate(state, |state| { - auth_rpc::resend_code(&mut state.snapshot, state.api_id, &phone, &phone_code_hash) - })?; - persist(state)?; - Ok(sent) - }) -} - -pub(crate) fn complete_sign_in(state: &mut ClientState, user_id: i64) -> Result<(), MtprotoError> { - state.user_id = Some(user_id); - if state.permanent.is_none() { - state.permanent = Some(state.snapshot.clone()); - state.temp_expires_at = 1; - } - persist(state) -} - -pub fn sign_in( - handle: u64, - phone: String, - phone_code_hash: String, - phone_code: String, -) -> Result { - let signed = with_client_mut(handle, |state| { - let signed = match call_with_migrate(state, |state| { - auth_rpc::sign_in( - &mut state.snapshot, - state.api_id, - &phone, - &phone_code_hash, - &phone_code, - ) - }) { - Ok(signed) => signed, - Err(err) if is_auth_restart(&err) => { - recreate_mtproto_session(state)?; - return Err(err); - } - Err(MtprotoError::PasswordRequired) => { - // The auth key/session counters are valid and must survive a - // process restart while the UI is on the 2FA password step. - persist(state)?; - return Err(MtprotoError::PasswordRequired); - } - Err(err) => return Err(err), - }; - complete_sign_in(state, signed.user_id)?; - Ok(signed) - })?; - drop_updates_transport(handle); - Ok(signed) -} - -pub fn check_password(handle: u64, password: String) -> Result { - let signed = with_client_mut(handle, |state| { - let signed = match call_with_migrate(state, |state| { - auth_rpc::check_password(&mut state.snapshot, state.api_id, &password) - }) { - Ok(signed) => signed, - Err(err) if is_auth_restart(&err) => { - // Surface restart to UI; session is reset so the next sendCode is clean. - recreate_mtproto_session(state)?; - return Err(err); - } - Err(err) => return Err(err), - }; - complete_sign_in(state, signed.user_id)?; - Ok(signed) - })?; - drop_updates_transport(handle); - Ok(signed) -} - -/// Revoke the server-side authorization when possible, then always tear down -/// the local session. A remote failure is returned to the bridge after the -/// local snapshot and all transports have been cleared. -pub fn logout(handle: u64) -> Result<(), MtprotoError> { - let result = with_client_mut(handle, |state| { - let remote = if state.user_id.is_some() - && !state.session_dead - && state.snapshot.auth_key.as_ref().map(|key| key.len()) == Some(256) - { - auth_rpc::log_out(&mut state.snapshot, state.api_id) - } else { - Ok(None) - }; - - let dc = state.snapshot.dc_id; - if let Ok(Some(token)) = &remote { - if !token.is_empty() && token.len() <= 256 { - state.logout_tokens.push(token.clone()); - if state.logout_tokens.len() > 20 { - let keep_from = state.logout_tokens.len() - 20; - state.logout_tokens.drain(..keep_from); - } - } - } - state.user_id = None; - state.peers.clear(); - state.updates = None; - state.media.clear(); - state.channel_pts.clear(); - state.channel_recovery.clear(); - state.seen_messages.clear(); - std::sync::Arc::make_mut(&mut state.journal).reset_account(); - state.updates_started = false; - state.session_dead = false; - state.new_session = None; - state.dc_auth_keys.clear(); - state.permanent = None; - state.snapshot = - Snapshot::new(dc, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; - if state.logout_tokens.is_empty() { - FileSessionStore::new(&state.session_path) - .clear() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } else { - persist(state)?; - } - - remote.map(|_| ()) - }); - if let Ok(client) = get_client(handle) { - client.data.lock().dc_temp_auth.clear(); - } - drop_all_transports(handle); - result -} diff --git a/native/mtproto-rs/src/client/chat_invites.rs b/native/mtproto-rs/src/client/chat_invites.rs deleted file mode 100644 index 2030a7e94..000000000 --- a/native/mtproto-rs/src/client/chat_invites.rs +++ /dev/null @@ -1,335 +0,0 @@ -#[cfg(test)] -#[path = "../../tests/unit/client_chat_invites_tests.rs"] -mod tests; - -use super::group_management::{group_peer, user_peer}; -use super::management::enqueue_response; -use super::{ClientState, call_with_migrate, with_client_mut}; -use crate::MtprotoError; -use tellers_mtproto::latest::api::*; - -#[derive(Clone, Default)] -pub struct InviteOptions { - pub expire_date: Option, - pub usage_limit: Option, - pub title: Option, - pub request_needed: bool, - pub revoke_permanent: bool, -} - -#[derive(Clone, Default)] -pub struct InviteChanges { - pub expire_date: Option, - pub usage_limit: Option, - pub title: Option, - pub request_needed: Option, - pub revoked: bool, -} - -#[derive(Clone)] -pub struct InviteCursor { - pub date: i32, - pub link: String, -} - -#[derive(Clone)] -pub struct ImporterCursor { - pub date: i32, - pub user_id: i64, -} - -pub fn export_invite( - handle: u64, - chat_id: i64, - options: InviteOptions, -) -> Result { - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - export_request(peer, &options), - ) - }) -} - -pub fn edit_invite( - handle: u64, - chat_id: i64, - link: &str, - changes: InviteChanges, -) -> Result { - validate_link(link)?; - if !changes.revoked - && changes.expire_date.is_none() - && changes.usage_limit.is_none() - && changes.title.is_none() - && changes.request_needed.is_none() - { - return Err(MtprotoError::Message( - "an invitation change is required".into(), - )); - } - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditExportedChatInviteRequest { - flags: u32::from(changes.expire_date.is_some()) - | (u32::from(changes.usage_limit.is_some()) << 1) - | (u32::from(changes.revoked) << 2) - | (u32::from(changes.request_needed.is_some()) << 3) - | (u32::from(changes.title.is_some()) << 4), - revoked: flag(changes.revoked), - peer: Box::new(peer), - link: link.to_owned(), - expire_date: changes.expire_date, - usage_limit: changes.usage_limit, - request_needed: changes.request_needed.map(|value| Box::new(boolean(value))), - title: changes.title.clone(), - }, - ) - }) -} - -/// Use both fields of the last returned invitation as the next-page cursor. -pub fn get_invites( - handle: u64, - chat_id: i64, - admin_id: Option, - cursor: Option, - revoked: bool, - limit: i32, -) -> Result { - validate_limit(limit)?; - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - let admin = admin_id - .map(|id| user_peer(state, id)) - .transpose()? - .unwrap_or(InputUser::InputUserSelf(InputUserSelfConstructor {})); - let response: MessagesExportedChatInvites = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - list_request(peer, admin, cursor.as_ref(), revoked, limit), - )?; - let MessagesExportedChatInvites::MessagesExportedChatInvites(result) = &response; - cache_users(state, &result.users); - Ok(response) - }) -} - -pub fn delete_invite(handle: u64, chat_id: i64, link: &str) -> Result<(), MtprotoError> { - validate_link(link)?; - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - let result: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesDeleteExportedChatInviteRequest { - peer: Box::new(peer), - link: link.to_owned(), - }, - )?; - match result { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => Err(MtprotoError::Message( - "invite deletion was not applied".into(), - )), - } - }) -} - -pub fn check_invite(handle: u64, hash: &str) -> Result { - validate_link(hash)?; - call(handle, |state| { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesCheckChatInviteRequest { - hash: hash.to_owned(), - }, - ) - }) -} - -pub fn join_invite(handle: u64, hash: &str) -> Result { - validate_link(hash)?; - call(handle, |state| { - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesImportChatInviteRequest { - hash: hash.to_owned(), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -pub fn get_importers( - handle: u64, - chat_id: i64, - link: Option, - requested: bool, - cursor: Option, - limit: i32, -) -> Result { - validate_limit(limit)?; - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - let (offset_date, offset_user) = match &cursor { - Some(cursor) => (cursor.date, user_peer(state, cursor.user_id)?), - None => (0, InputUser::InputUserEmpty(InputUserEmptyConstructor {})), - }; - let response: MessagesChatInviteImporters = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetChatInviteImportersRequest { - flags: u32::from(requested) | (u32::from(link.is_some()) << 1), - requested: flag(requested), - subscription_expired: None, - peer: Box::new(peer), - link: link.clone(), - q: None, - offset_date, - offset_user: Box::new(offset_user), - limit, - }, - )?; - let MessagesChatInviteImporters::MessagesChatInviteImporters(result) = &response; - cache_users(state, &result.users); - Ok(response) - }) -} - -pub fn resolve_join_request( - handle: u64, - chat_id: i64, - user_id: i64, - approved: bool, -) -> Result { - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesHideChatJoinRequestRequest { - flags: u32::from(approved), - approved: flag(approved), - peer: Box::new(peer), - user_id: Box::new(user), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -pub fn resolve_all_join_requests( - handle: u64, - chat_id: i64, - link: Option, - approved: bool, -) -> Result { - call(handle, |state| { - let peer = group_peer(state, chat_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesHideAllChatJoinRequestsRequest { - flags: u32::from(approved) | (u32::from(link.is_some()) << 1), - approved: flag(approved), - peer: Box::new(peer), - link: link.clone(), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -fn export_request(peer: InputPeer, options: &InviteOptions) -> MessagesExportChatInviteRequest { - MessagesExportChatInviteRequest { - flags: u32::from(options.expire_date.is_some()) - | (u32::from(options.usage_limit.is_some()) << 1) - | (u32::from(options.revoke_permanent) << 2) - | (u32::from(options.request_needed) << 3) - | (u32::from(options.title.is_some()) << 4), - legacy_revoke_permanent: flag(options.revoke_permanent), - request_needed: flag(options.request_needed), - peer: Box::new(peer), - expire_date: options.expire_date, - usage_limit: options.usage_limit, - title: options.title.clone(), - subscription_pricing: None, - } -} - -fn list_request( - peer: InputPeer, - admin: InputUser, - cursor: Option<&InviteCursor>, - revoked: bool, - limit: i32, -) -> MessagesGetExportedChatInvitesRequest { - MessagesGetExportedChatInvitesRequest { - flags: (u32::from(revoked) << 3) | (u32::from(cursor.is_some()) << 2), - revoked: flag(revoked), - peer: Box::new(peer), - admin_id: Box::new(admin), - offset_date: cursor.map(|value| value.date), - offset_link: cursor.map(|value| value.link.clone()), - limit, - } -} - -fn call( - handle: u64, - operation: impl Fn(&mut ClientState) -> Result, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| operation(state)) - }) -} - -fn cache_users(state: &mut ClientState, users: &Vector>) { - crate::dialogs::cache_from_users_chats( - &mut state.peers, - &mut state.media, - crate::peers::vector_boxed_items(users).cloned(), - std::iter::empty(), - ); -} - -fn validate_limit(limit: i32) -> Result<(), MtprotoError> { - if !(1..=100).contains(&limit) { - return Err(MtprotoError::Message( - "page limit must be between 1 and 100".into(), - )); - } - Ok(()) -} - -fn validate_link(link: &str) -> Result<(), MtprotoError> { - if link.trim().is_empty() { - return Err(MtprotoError::Message( - "an invite link or hash is required".into(), - )); - } - Ok(()) -} - -fn flag(value: bool) -> Option> { - value.then(|| Box::new(True::True(TrueConstructor {}))) -} -fn boolean(value: bool) -> Bool { - if value { - Bool::BoolTrue(BoolTrueConstructor {}) - } else { - Bool::BoolFalse(BoolFalseConstructor {}) - } -} diff --git a/native/mtproto-rs/src/client/checklists.rs b/native/mtproto-rs/src/client/checklists.rs deleted file mode 100644 index c778c71cc..000000000 --- a/native/mtproto-rs/src/client/checklists.rs +++ /dev/null @@ -1,336 +0,0 @@ -use super::*; -use crate::peers::input_peer_from_cached; -use tellers_mtproto::latest::api::*; - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ChecklistTextEditDto { - pub item_id: Option, - pub text: String, - pub entities_json: Option, - pub expected_text: String, - pub expected_entities_json: Option, -} - -pub fn edit_checklist_text( - handle: u64, - chat_id: i64, - message_id: i32, - edit: ChecklistTextEditDto, -) -> Result<(), MtprotoError> { - mutate_text(handle, chat_id, message_id, edit, false) -} - -pub fn remove_checklist_item( - handle: u64, - chat_id: i64, - message_id: i32, - edit: ChecklistTextEditDto, -) -> Result<(), MtprotoError> { - if edit.item_id.is_none() { - return Err(MtprotoError::Message("checklist item required".into())); - } - mutate_text(handle, chat_id, message_id, edit, true) -} - -fn mutate_text( - handle: u64, - chat_id: i64, - message_id: i32, - edit: ChecklistTextEditDto, - remove: bool, -) -> Result<(), MtprotoError> { - if message_id <= 0 { - return Err(MtprotoError::Message("checklist message required".into())); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let rows = crate::dialogs::fetch_message( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - )?; - let peer = input_peer_from_cached(crate::peers::require_usable_peer( - &state.peers, - chat_id, - )?); - let request = content_request( - &rows, - peer, - chat_id, - message_id, - &edit, - &state.peers, - remove, - )?; - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) - }) -} - -pub fn edit_checklist_title( - handle: u64, - chat_id: i64, - message_id: i32, - title: String, -) -> Result<(), MtprotoError> { - if message_id <= 0 || title.trim().is_empty() { - return Err(MtprotoError::Message( - "checklist title and message required".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let messages = crate::dialogs::fetch_message( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - )?; - let peer = input_peer_from_cached(crate::peers::require_usable_peer( - &state.peers, - chat_id, - )?); - let request = title_request(&messages, peer, chat_id, message_id, &title)?; - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) - }) -} - -fn title_request( - messages: &Vector>, - peer: InputPeer, - chat_id: i64, - message_id: i32, - title: &str, -) -> Result { - let media = checked_media(messages, chat_id, message_id)?; - Ok(media_request( - peer, - message_id, - title_media(&media.todo, title)?, - )) -} - -fn checked_media( - messages: &Vector>, - chat_id: i64, - message_id: i32, -) -> Result<&MessageMediaToDoConstructor, MtprotoError> { - let mut rows = crate::peers::vector_boxed_items(messages); - let Some(Message::Message(message)) = rows.next() else { - return Err(MtprotoError::Message("checklist unavailable".into())); - }; - if rows.next().is_some() - || message.id != message_id - || crate::peers::peer_chat_id(&message.peer_id) != chat_id - { - return Err(MtprotoError::Message("unexpected message identity".into())); - } - if message.out.is_none() { - return Err(MtprotoError::Message("checklist owner required".into())); - } - let Some(MessageMedia::MessageMediaToDo(media)) = message.media.as_deref() else { - return Err(MtprotoError::Message("checklist unavailable".into())); - }; - Ok(media) -} - -fn media_request( - peer: InputPeer, - message_id: i32, - media: InputMedia, -) -> MessagesEditMessageRequest { - MessagesEditMessageRequest { - flags: MessagesEditMessageRequest::MEDIA_FLAG, - no_webpage: None, - invert_media: None, - peer: Box::new(peer), - id: message_id, - message: None, - media: Some(Box::new(media)), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - } -} - -fn text_request( - messages: &Vector>, - peer: InputPeer, - chat_id: i64, - message_id: i32, - edit: &ChecklistTextEditDto, - peers: &crate::HashMap, -) -> Result { - content_request(messages, peer, chat_id, message_id, edit, peers, false) -} - -fn content_request( - messages: &Vector>, - peer: InputPeer, - chat_id: i64, - message_id: i32, - edit: &ChecklistTextEditDto, - peers: &crate::HashMap, - remove: bool, -) -> Result { - let media = checked_media(messages, chat_id, message_id)?; - let TodoList::TodoList(mut todo) = (*media.todo).clone(); - let target = if let Some(id) = edit.item_id { - let Vector::Vector(items) = todo.list.as_mut(); - let mut found = None; - for item in &mut items.field_1 { - let TodoItem::TodoItem(item) = item.as_mut(); - if item.id == id { - if found.is_some() { - return Err(MtprotoError::Message("ambiguous checklist item".into())); - } - found = Some(&mut item.title); - } - } - found.ok_or_else(|| MtprotoError::Message("checklist item unavailable".into()))? - } else { - &mut todo.title - }; - let TextWithEntities::TextWithEntities(original) = target.as_ref(); - let expected = parse_entity_rows(edit.expected_entities_json.as_deref())?; - let current = - parse_entity_rows(crate::dialogs::entities_to_json(Some(&original.entities)).as_deref())?; - if original.text != edit.expected_text - || expected != current - || crate::peers::vector_boxed_items(&original.entities).count() != current.len() - { - return Err(MtprotoError::Message( - "checklist text changed or formatting unsupported".into(), - )); - } - if remove { - let id = edit - .item_id - .ok_or_else(|| MtprotoError::Message("checklist item required".into()))?; - let Vector::Vector(items) = todo.list.as_mut(); - if items.field_1.len() <= 1 { - return Err(MtprotoError::Message( - "cannot remove final checklist item".into(), - )); - } - items.field_1.retain(|item| match item.as_ref() { - TodoItem::TodoItem(item) => item.id != id, - }); - items.field_0 = items.field_1.len() as u32; - return Ok(media_request( - peer, - message_id, - InputMedia::InputMediaTodo(InputMediaTodoConstructor { - todo: Box::new(TodoList::TodoList(todo)), - }), - )); - } - let limit = if edit.item_id.is_some() { 64 } else { 32 }; - if edit.text.trim().is_empty() || edit.text.encode_utf16().count() > limit { - return Err(MtprotoError::Message( - "invalid checklist text length".into(), - )); - } - let rows = parse_entity_rows(edit.entities_json.as_deref())?; - let mut boundaries = vec![0_i64]; - for ch in edit.text.chars() { - boundaries.push(boundaries.last().copied().unwrap() + ch.len_utf16() as i64); - } - for row in &rows { - let start = row.get("offset").and_then(|v| v.as_i64()).unwrap_or(-1); - let length = row.get("length").and_then(|v| v.as_i64()).unwrap_or(0); - if length <= 0 - || !boundaries.contains(&start) - || !start - .checked_add(length) - .is_some_and(|end| boundaries.contains(&end)) - { - return Err(MtprotoError::Message( - "invalid checklist entity range".into(), - )); - } - } - let (_, entities) = - crate::messages::input_entities_from_json(edit.entities_json.as_deref(), peers)?; - if entities - .as_deref() - .map(crate::peers::vector_boxed_items) - .map(|v| v.count()) - .unwrap_or(0) - != rows.len() - { - return Err(MtprotoError::Message( - "unsupported checklist formatting".into(), - )); - } - *target = Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: edit.text.clone(), - entities: entities.unwrap_or_else(|| { - Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })) - }), - }, - )); - Ok(media_request( - peer, - message_id, - InputMedia::InputMediaTodo(InputMediaTodoConstructor { - todo: Box::new(TodoList::TodoList(todo)), - }), - )) -} - -fn parse_entity_rows(raw: Option<&str>) -> Result, MtprotoError> { - let raw = raw.unwrap_or("[]"); - if raw.len() > 65536 { - return Err(MtprotoError::Message( - "checklist formatting too large".into(), - )); - } - serde_json::from_str(raw) - .map_err(|_| MtprotoError::Message("invalid checklist formatting".into())) -} - -fn title_media(todo: &TodoList, title: &str) -> Result { - let title = title.trim(); - if title.is_empty() { - return Err(MtprotoError::Message("checklist title required".into())); - } - let TodoList::TodoList(mut todo) = todo.clone(); - todo.title = Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: title.to_owned(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - }, - )); - Ok(InputMedia::InputMediaTodo(InputMediaTodoConstructor { - todo: Box::new(TodoList::TodoList(todo)), - })) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_checklists_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/contacts.rs b/native/mtproto-rs/src/client/contacts.rs deleted file mode 100644 index 77f0f4c8a..000000000 --- a/native/mtproto-rs/src/client/contacts.rs +++ /dev/null @@ -1,144 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::*; - -#[derive(Clone, Debug, uniffi::Record)] -pub struct ContactImportSpec { - pub client_id: i64, - pub phone: String, - pub first_name: String, - pub last_name: String, -} -#[derive(Clone, Debug, PartialEq, uniffi::Record)] -pub struct ContactImportMatch { - pub client_id: i64, - pub user_id: i64, -} -#[derive(Clone, Debug, PartialEq, uniffi::Record)] -pub struct ContactImportPopularity { - pub client_id: i64, - pub importers: i32, -} -#[derive(Clone, Debug, uniffi::Record)] -pub struct ContactImportResult { - pub imported: Vec, - pub retry_client_ids: Vec, - pub popular: Vec, -} - -fn import_request( - contacts: &[ContactImportSpec], -) -> Result { - if !(1..=100).contains(&contacts.len()) { - return Err(MtprotoError::Message("contact batch size invalid".into())); - } - let mut ids = std::collections::HashSet::new(); - let mut phones = std::collections::HashSet::new(); - let mut rows = Vec::with_capacity(contacts.len()); - for contact in contacts { - crate::messages::validate_contact(&contact.phone, &contact.first_name, &contact.last_name)?; - if contact.client_id == 0 - || !ids.insert(contact.client_id) - || !phones.insert(contact.phone.trim_start_matches('+')) - { - return Err(MtprotoError::Message( - "contact batch identity invalid".into(), - )); - } - rows.push(Box::new(InputContact::InputPhoneContact( - InputPhoneContactConstructor { - flags: 0, - client_id: contact.client_id, - phone: contact.phone.clone(), - first_name: contact.first_name.clone(), - last_name: contact.last_name.clone(), - note: None, - }, - ))); - } - Ok(ContactsImportContactsRequest { - contacts: Box::new(Vector::Vector(VectorConstructor { - field_0: rows.len() as u32, - field_1: rows, - })), - }) -} - -fn import_result( - contacts: &[ContactImportSpec], - body: &ContactsImportedContactsConstructor, -) -> Result { - let requested = contacts - .iter() - .map(|c| c.client_id) - .collect::>(); - let mut imported = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for row in crate::peers::vector_boxed_items(&body.imported) { - let ImportedContact::ImportedContact(row) = row; - if row.user_id <= 0 || !requested.contains(&row.client_id) || !seen.insert(row.client_id) { - return Err(MtprotoError::Message( - "contact import response identity invalid".into(), - )); - } - imported.push(ContactImportMatch { - client_id: row.client_id, - user_id: row.user_id, - }); - } - let Vector::Vector(retries) = body.retry_contacts.as_ref(); - for id in &retries.field_1 { - if !requested.contains(id) || !seen.insert(*id) { - return Err(MtprotoError::Message( - "contact import retry identity invalid".into(), - )); - } - } - let mut popular = Vec::new(); - let mut popular_ids = std::collections::HashSet::new(); - for row in crate::peers::vector_boxed_items(&body.popular_invites) { - let PopularContact::PopularContact(row) = row; - if !requested.contains(&row.client_id) - || !popular_ids.insert(row.client_id) - || row.importers < 0 - { - return Err(MtprotoError::Message( - "contact popularity response invalid".into(), - )); - } - popular.push(ContactImportPopularity { - client_id: row.client_id, - importers: row.importers, - }); - } - Ok(ContactImportResult { - imported, - retry_client_ids: retries.field_1.clone(), - popular, - }) -} - -pub fn import_contacts( - handle: u64, - contacts: Vec, -) -> Result { - let request = import_request(&contacts)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let response: ContactsImportedContacts = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request.clone())?; - let ContactsImportedContacts::ContactsImportedContacts(body) = response; - let result = import_result(&contacts, &body)?; - crate::dialogs::cache_from_users_chats( - &mut state.peers, - &mut state.media, - crate::peers::vector_boxed_items(&body.users).cloned(), - std::iter::empty(), - ); - Ok(result) - }) - }) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_contact_import_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/dispatch.rs b/native/mtproto-rs/src/client/dispatch.rs deleted file mode 100644 index 64e3d1ffd..000000000 --- a/native/mtproto-rs/src/client/dispatch.rs +++ /dev/null @@ -1,1853 +0,0 @@ -use crate::dialogs; -use crate::peers; -use crate::scheduler; -use crate::upload::upload_rpc::UploadStaging; -use crate::{ - ChatDto, FolderDto, ForumTopicsPageDto, JoinChatDto, MessageDto, MtprotoError, - NotifyExceptionDto, NotifySettingsDto, ProfileDto, -}; - -use super::*; - -pub fn get_chats(handle: u64) -> Result, MtprotoError> { - with_read_lane(handle, |state| { - let chats = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - dialogs::get_dialogs( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &mut state.channel_pts, - 0, - 0, - 0, - None, - ) - }) - })?; - Ok(chats) - }) -} - -pub fn update_folder(handle: u64, folder: FolderDto) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - dialogs::update_folder(&mut state.snapshot, state.api_id, &state.peers, &folder) - })?; - Ok(()) - }) -} - -pub fn delete_folder(handle: u64, id: i32) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - dialogs::delete_folder(&mut state.snapshot, state.api_id, id) - })?; - Ok(()) - }) -} - -pub fn update_folder_order(handle: u64, order: Vec) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - dialogs::update_folder_order(&mut state.snapshot, state.api_id, &order) - })?; - Ok(()) - }) -} - -pub fn get_folders(handle: u64) -> Result, MtprotoError> { - with_read_lane(handle, |state| { - let folders = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - dialogs::get_folders(&mut state.snapshot, state.api_id, &state.peers) - }) - })?; - Ok(folders) - }) -} - -pub fn get_group_admin_tags(handle: u64, chat_id: i64) -> Result { - with_client_mut(handle, |state| { - let tags = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::profile::get_group_admin_tags( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - ) - }) - }) - })?; - Ok(tags) - }) -} - -pub fn get_profile(handle: u64, peer_id: i64) -> Result { - with_read_lane(handle, |state| { - // Extra read lane so full-user does not sit behind history on main. - let profile = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, peer_id, |state| { - crate::profile::get_profile( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - state.user_id, - peer_id, - ) - }) - }) - })?; - Ok(profile) - }) -} - -/// `messages.getSearchCounters` for the requested filters, as compact JSON. -pub fn get_search_counters( - handle: u64, - chat_id: i64, - filters: Vec, -) -> Result { - with_read_lane(handle, |state| { - let json = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::get_search_counters( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - &filters, - ) - }) - }) - })?; - Ok(json) - }) -} - -/// https://core.telegram.org/method/channels.getParticipants -pub fn get_participants( - handle: u64, - chat_id: i64, - filter: String, - query: String, - offset: i32, - limit: i32, -) -> Result { - with_read_lane(handle, |state| { - let json = crate::rpc::with_rpc_timeout_secs(10, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::profile::get_participants( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &filter, - &query, - offset, - limit, - ) - }) - }) - })?; - Ok(json) - }) -} - -/// https://core.telegram.org/method/messages.getCommonChats -pub fn get_common_chats( - handle: u64, - user_id: i64, - max_id: i64, - limit: i32, -) -> Result { - with_read_lane(handle, |state| { - let json = crate::rpc::with_rpc_timeout_secs(8, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, user_id, |state| { - crate::profile::get_common_chats( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - user_id, - max_id, - limit, - ) - }) - }) - })?; - Ok(json) - }) -} - -pub fn load_more_chats( - handle: u64, - offset_date: i32, - offset_id: i32, - offset_peer_id: i64, - folder_id: i32, -) -> Result, MtprotoError> { - with_read_lane(handle, |state| { - let chats = call_with_migrate(state, |state| { - with_peer_refresh(state, offset_peer_id, |state| { - dialogs::get_dialogs( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &mut state.channel_pts, - offset_date, - offset_id, - offset_peer_id, - // 0 is the main list, which the plain page already covers. - (folder_id > 0).then_some(folder_id), - ) - }) - })?; - Ok(chats) - }) -} - -pub(crate) fn with_peer_refresh( - state: &mut ClientState, - chat_id: i64, - mut f: impl FnMut(&mut ClientState) -> Result, -) -> Result { - if chat_id != 0 { - let missing = !state - .peers - .get(&chat_id) - .is_some_and(peers::has_usable_access_hash); - if missing { - crate::perf::count("peer_refresh"); - let _ = refresh_dialogs(state); - } else { - crate::perf::count("peer_cached"); - } - } - match f(state) { - Ok(value) => Ok(value), - Err(err) if is_peer_refreshable(&err) => { - refresh_dialogs(state)?; - f(state) - } - Err(err) => Err(err), - } -} - -/// Re-fetch the constructor that issued the `file_reference` (message or full peer). - -pub(crate) fn history_with_peer_refresh( - state: &mut ClientState, - chat_id: i64, - limit: i32, - offset_id: i32, - offset_date: i32, - add_offset: i32, -) -> Result, MtprotoError> { - with_peer_refresh(state, chat_id, |state| { - dialogs::get_history( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - limit, - offset_id, - offset_date, - add_offset, - state.user_id, - ) - }) -} - -pub fn get_history( - handle: u64, - chat_id: i64, - limit: i32, - offset_id: i32, - offset_date: i32, - add_offset: i32, -) -> Result, MtprotoError> { - if let Ok(client) = get_client(handle) { - client.data.lock().last_history_chat_id = chat_id; - } - with_read_lane(handle, |state| { - // Match Kotlin historyTimeoutMs=20s. Busy chats still have a mid-frame - // rpc_result after ~100KiB of msg_container (emulator last_ctor=0x73f1f8dc). - let messages = crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - history_with_peer_refresh(state, chat_id, limit, offset_id, offset_date, add_offset) - }) - })?; - Ok(messages) - }) -} - -pub fn get_replies( - handle: u64, - chat_id: i64, - msg_id: i32, - limit: i32, - offset_id: i32, - add_offset: i32, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let user_id = state.user_id; - crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - dialogs::get_replies( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - msg_id, - limit, - offset_id, - add_offset, - user_id, - ) - }) - }) - }) - }) -} - -pub fn get_forum_topics( - handle: u64, - chat_id: i64, - offset_date: i32, - offset_id: i32, - offset_topic: i32, - limit: i32, -) -> Result { - with_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - dialogs::get_forum_topics( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - offset_date, - offset_id, - offset_topic, - limit, - ) - }) - }) - }) - }) -} - -pub fn get_forum_topics_by_id( - handle: u64, - chat_id: i64, - topic_ids: Vec, -) -> Result { - with_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - dialogs::get_forum_topics_by_id( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - &topic_ids, - ) - }) - }) - }) - }) -} - -pub fn edit_forum_topic_hidden( - handle: u64, - chat_id: i64, - topic_id: i32, - hidden: bool, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - dialogs::edit_forum_topic_hidden( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - topic_id, - hidden, - ) - }) - }) - }) - }) -} - -pub fn search_messages( - handle: u64, - chat_id: i64, - query: String, - limit: i32, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let messages = call_with_migrate(state, |state| { - crate::messages::search_messages( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - &query, - limit, - ) - })?; - Ok(messages) - }) -} - -pub fn search_messages_filtered( - handle: u64, - chat_id: i64, - query: String, - filter: String, - offset_id: i32, - add_offset: i32, - limit: i32, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let messages = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::search_messages_filtered( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - &query, - &filter, - offset_id, - add_offset, - limit, - ) - }) - })?; - Ok(messages) - }) -} - -pub fn search_messages_page( - handle: u64, - chat_id: i64, - query: String, - filter: String, - offset_id: i32, - add_offset: i32, - limit: i32, -) -> Result { - with_client_mut(handle, |state| { - let page = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::search_messages_page( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - &query, - &filter, - offset_id, - add_offset, - limit, - ) - }) - })?; - Ok(page) - }) -} - -pub fn contacts_search( - handle: u64, - query: String, - limit: i32, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::search_rpc::contacts_search( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &query, - limit, - ) - })?; - Ok(dto) - }) -} - -pub fn search_global( - handle: u64, - query: String, - offset_rate: i32, - offset_peer_id: i64, - offset_id: i32, - limit: i32, - folder_id: i32, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::search_rpc::search_global( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &query, - offset_rate, - offset_peer_id, - offset_id, - limit, - folder_id, - ) - })?; - Ok(dto) - }) -} - -pub fn get_pinned_messages( - handle: u64, - chat_id: i64, - limit: i32, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let messages = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::get_pinned_messages( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - limit, - ) - }) - })?; - Ok(messages) - }) -} - -pub fn send_text_message( - handle: u64, - chat_id: i64, - text: String, - reply_to_msg_id: i32, - entities_json: Option, - top_msg_id: i32, - webpage_url: Option, - client_random_id: i64, -) -> Result { - with_interactive_client_mut(handle, |state| { - let sent = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_text( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &text, - reply_to_msg_id, - entities_json.as_deref(), - top_msg_id, - webpage_url.as_deref(), - client_random_id, - ) - }) - })?; - Ok(sent) - }) -} - -pub fn send_location_with_options( - handle: u64, - chat_id: i64, - spec: crate::LocationSendSpec, - reply: i32, - top: i32, - random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if random_id == 0 { - return Err(MtprotoError::Message( - "location send requires persisted identity".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_location_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &spec, - reply, - top, - random_id, - &options, - ) - }) - })?; - let session_id = - super::updates::stage_outgoing_result(&client, generation, random_id, result.clone())?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub fn send_contact_with_options( - handle: u64, - chat_id: i64, - spec: crate::ContactSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - client_random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if client_random_id == 0 { - return Err(MtprotoError::Message( - "contact send requires a persisted random id".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_contact_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &spec, - reply_to_msg_id, - top_msg_id, - client_random_id, - &options, - ) - }) - })?; - let session_id = super::updates::stage_outgoing_result( - &client, - generation, - client_random_id, - result.clone(), - )?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub fn send_checklist_with_options( - handle: u64, - chat_id: i64, - spec: crate::ChecklistSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - client_random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if client_random_id == 0 { - return Err(MtprotoError::Message( - "checklist send requires a persisted random id".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_checklist_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &spec, - reply_to_msg_id, - top_msg_id, - client_random_id, - &options, - ) - }) - })?; - let session_id = super::updates::stage_outgoing_result( - &client, - generation, - client_random_id, - result.clone(), - )?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub fn send_poll_with_options( - handle: u64, - chat_id: i64, - spec: crate::PollSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - client_random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if client_random_id == 0 { - return Err(MtprotoError::Message( - "poll send requires a persisted random id".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_poll_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &spec, - reply_to_msg_id, - top_msg_id, - client_random_id, - &options, - ) - }) - })?; - let session_id = super::updates::stage_outgoing_result( - &client, - generation, - client_random_id, - result.clone(), - )?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub fn send_dice_with_options( - handle: u64, - chat_id: i64, - emoticon: String, - reply_to_msg_id: i32, - top_msg_id: i32, - client_random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if client_random_id == 0 { - return Err(MtprotoError::Message( - "dice send requires a persisted random id".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_dice_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &emoticon, - reply_to_msg_id, - top_msg_id, - client_random_id, - &options, - ) - }) - })?; - let session_id = super::updates::stage_outgoing_result( - &client, - generation, - client_random_id, - result.clone(), - )?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub fn send_text_message_with_options( - handle: u64, - chat_id: i64, - text: String, - reply_to_msg_id: i32, - entities_json: Option, - top_msg_id: i32, - webpage_url: Option, - client_random_id: i64, - options: crate::SendOptions, -) -> Result { - let client = super::get_client(handle)?; - if client_random_id == 0 { - return Err(MtprotoError::Message( - "send options require a persisted random id".into(), - )); - } - let (result, session_id) = with_interactive_client_mut(handle, |state| { - let generation = state.journal.account_generation; - if !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - let result = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::send_text_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &text, - reply_to_msg_id, - entities_json.as_deref(), - top_msg_id, - webpage_url.as_deref(), - client_random_id, - &options, - ) - }) - })?; - let session_id = super::updates::stage_outgoing_result( - &client, - generation, - client_random_id, - result.clone(), - )?; - Ok((result, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(result) -} - -pub(crate) fn upload_batches( - staging: &mut UploadStaging, - width: usize, - mut accept: impl FnMut(&crate::upload::upload_rpc::UploadBatch) -> Result<(), MtprotoError>, -) -> Result<(), MtprotoError> { - while let Some(batch) = staging.next_batch(width) { - accept(&batch?)?; - } - Ok(()) -} - -#[derive(Clone, Copy)] -struct UploadOwner { - generation: i64, - session_id: i64, -} - -fn check_upload_owner(client: &Client, owner: UploadOwner) -> Result<(), MtprotoError> { - let data = client.data.lock(); - if data.journal.account_generation != owner.generation - || !session_lease_valid(&data, owner.session_id) - { - return Err(expired_session_lease()); - } - Ok(()) -} - -pub(crate) fn save_items( - client: &Arc, - api_id: i32, - items: &[crate::UploadItemDto], -) -> Result, MtprotoError> { - save_items_with(client, api_id, items, |snapshot, batch| { - crate::upload::upload_rpc::save_batch(snapshot, api_id, batch) - }) -} - -pub(crate) fn save_items_with( - client: &Arc, - api_id: i32, - items: &[crate::UploadItemDto], - accept: impl FnMut( - &mut tellers_mtproto_session::Snapshot, - &crate::upload::upload_rpc::UploadBatch, - ) -> Result<(), MtprotoError>, -) -> Result, MtprotoError> { - save_items_with_owner(client, api_id, items, None, accept) -} - -fn save_items_with_owner( - client: &Arc, - api_id: i32, - items: &[crate::UploadItemDto], - expected_owner: Option, - mut accept: impl FnMut( - &mut tellers_mtproto_session::Snapshot, - &crate::upload::upload_rpc::UploadBatch, - ) -> Result<(), MtprotoError>, -) -> Result, MtprotoError> { - let _ = api_id; - let _policy = crate::transfer_policy::bind(Arc::clone(&client.policy)); - let (home, pfs, owner) = { - let _gate = client.main_gate.acquire(scheduler::current_class())?; - let mut main = lock_request_lane(&client.main, "lane_wait.upload_home")?; - if let Some(owner) = expected_owner { - check_upload_owner(client, owner)?; - } - crate::auth::bind_temp::ensure_main_temp(client, &mut main, false)?; - if let Some(owner) = expected_owner { - check_upload_owner(client, owner)?; - } - ( - main.snapshot.clone(), - main.temp_expires_at != 0 || main.perm_key.is_some(), - client.data.lock().home_session_id, - ) - }; - crate::media::check_transfer_flood(home.dc_id, crate::media::TransferClass::Upload)?; - let mut io = super::lanes::lock_upload_lane(client); - if let Some(owner) = expected_owner { - check_upload_owner(client, owner)?; - } - crate::auth::bind_temp::ensure_main_temp(client, &mut io, pfs)?; - if io.temp_expires_at == 0 - && io.perm_key.is_none() - && sync_upload_snapshot(&mut io.snapshot, &home) - { - io.transport = None; - } - let mut transport = io.transport.take(); - let saved = scheduler::with_class(scheduler::RequestClass::InteractiveMedia, || { - with_client_transport(client, &mut transport, || { - let mut out = Vec::with_capacity(items.len()); - let mut measured = client - .policy - .upload_rtt - .lock() - .is_some_and(|(dc, at, _)| dc == home.dc_id && at.elapsed().as_secs() < 60); - for item in items { - let rtt = client - .policy - .upload_rtt - .lock() - .filter(|(dc, at, _)| *dc == home.dc_id && at.elapsed().as_secs() < 60) - .map(|(_, _, rtt)| rtt); - let mut staging = crate::upload::upload_rpc::open_staging_with_rtt(item, rtt)?; - let width = staging.in_flight(); - upload_batches(&mut staging, width, |batch| { - if let Some(owner) = expected_owner { - check_upload_owner(client, owner)?; - } - crate::media::check_transfer_flood( - io.snapshot.dc_id, - crate::media::TransferClass::Upload, - )?; - let result = accept(&mut io.snapshot, batch); - if result.is_ok() - && !measured - && batch.bytes.iter().any(|part| part.len() > 32 * 1024) - { - measured = true; - if let Ok(Some(rtt)) = crate::rpc::ping_existing_rtt(&mut io.snapshot) { - *client.policy.upload_rtt.lock() = - Some((io.snapshot.dc_id, std::time::Instant::now(), rtt)); - } - } - if let Err(error) = &result { - crate::media::note_transfer_flood( - io.snapshot.dc_id, - crate::media::TransferClass::Upload, - error, - ); - } - result - })?; - out.push(staging); - } - Ok(out) - }) - }); - let temporary = saved - .as_ref() - .err() - .is_some_and(|error| expire_temporary_lane(client, &mut io, error)); - io.transport = if saved.is_ok() { transport } else { None }; - drop(io); - if !temporary - && saved - .as_ref() - .err() - .is_some_and(|error| mark_lane_session_dead(client, owner, error)) - { - let _ = persist_updates_data(client, owner); - } - schedule_file_cleanup(client); - if temporary { - Err(temporary_key_retry_error()) - } else { - saved - } -} - -pub(crate) fn send_uploaded_metadata( - handle: u64, - chat_id: i64, - staging: &[UploadStaging], - send: impl Fn(&mut ClientState, &[UploadStaging]) -> Result, -) -> Result { - with_interactive_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(300, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - scheduler::with_class(scheduler::RequestClass::InteractiveRead, || { - send(state, staging) - }) - }) - }) - }) - }) -} - -fn replacement_source( - state: &mut ClientState, - chat_id: i64, - message_id: i32, - expected_key: Option<&str>, - item: &crate::UploadItemDto, -) -> Result<(crate::MessageDto, crate::media::MediaIndex, bool, Option, bool), MtprotoError> { - use tellers_mtproto::latest::api::{Message, MessageMedia}; - let rows = crate::dialogs::fetch_message(&mut state.snapshot, state.api_id, &state.peers, chat_id, message_id)?; - let mut rows = crate::peers::vector_boxed_items(&rows); - let Some(current @ Message::Message(message)) = rows.next() else { - return Err(MtprotoError::Message("replacement message unavailable".into())); - }; - if rows.next().is_some() || message.id != message_id || message.out.is_none() - || crate::peers::peer_chat_id(&message.peer_id) != chat_id { - return Err(MtprotoError::Message("owned replacement message required".into())); - } - let mut index = state.media.clone(); - let dto = crate::dialogs::message_to_dto(current, &mut index) - .ok_or_else(|| MtprotoError::Message("replacement message unavailable".into()))?; - if dto.media_kind.as_deref() != Some(item.kind.as_str()) || dto.media_cache_key.is_none() - || expected_key.is_some_and(|key| dto.media_cache_key.as_deref() != Some(key)) { - return Err(MtprotoError::Message("replacement media changed".into())); - } - let (spoiler, ttl) = match message.media.as_deref() { - Some(MessageMedia::MessageMediaPhoto(media)) => (media.spoiler.is_some(), media.ttl_seconds), - Some(MessageMedia::MessageMediaDocument(media)) => (media.spoiler.is_some(), media.ttl_seconds), - _ => return Err(MtprotoError::Message("unsupported replacement media".into())), - }; - Ok((dto, index, spoiler, ttl, message.invert_media.is_some())) -} - -pub fn replace_uploaded_media( - handle: u64, - chat_id: i64, - message_id: i32, - expected_key: String, - item: crate::UploadItemDto, -) -> Result { - if message_id <= 0 || expected_key.is_empty() || !matches!(item.kind.as_str(), "photo" | "video" | "document") - || !item.caption.is_empty() || item.entities_json.is_some() { - return Err(MtprotoError::Message("invalid media replacement".into())); - } - let client = get_client(handle)?; - let (owner, api_id) = { - let data = client.data.lock(); - if !session_lease_valid(&data, data.home_session_id) || !data.journal.admission_open() { - return Err(expired_session_lease()); - } - (UploadOwner { generation: data.journal.account_generation, session_id: data.home_session_id }, data.api_id) - }; - with_interactive_client_mut(handle, |state| { - check_upload_owner(&client, owner)?; - crate::rpc::with_rpc_timeout_secs(30, || call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - check_upload_owner(&client, owner)?; - replacement_source(state, chat_id, message_id, Some(&expected_key), &item).map(|_| ()) - }) - })) - })?; - let staging = save_items_with_owner(&client, api_id, std::slice::from_ref(&item), Some(owner), |snapshot, batch| { - crate::upload::upload_rpc::save_batch(snapshot, api_id, batch) - })?; - with_interactive_client_mut(handle, |state| { - check_upload_owner(&client, owner)?; - crate::rpc::with_rpc_timeout_secs(30, || call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - check_upload_owner(&client, owner)?; - let (original, _, spoiler, ttl, invert_media) = replacement_source(state, chat_id, message_id, Some(&expected_key), &item)?; - if !state.journal.admission_open() { - return Err(MtprotoError::Message("replacement journal requires acknowledgment".into())); - } - let request = crate::upload::upload_rpc::replacement_media_request(&state.peers, chat_id, message_id, - &item.kind, &item, crate::upload::upload_rpc::staged_input_file(&item, &staging[0]), spoiler, ttl, invert_media)?; - let response: tellers_mtproto::latest::api::Updates = crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - check_upload_owner(&client, owner)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response)?; - let (edited, index, _, _, _) = replacement_source(state, chat_id, message_id, None, &item)?; - check_upload_owner(&client, owner)?; - if edited.grouped_id != original.grouped_id { - return Err(MtprotoError::Message("replacement album identity changed".into())); - } - state.media = index; - Ok(edited) - }) - })) - }) -} - -pub fn send_photo_message( - handle: u64, - chat_id: i64, - path: String, - caption: String, - reply_to_msg_id: i32, - top_msg_id: i32, - entities_json: Option, -) -> Result { - let filename = std::path::Path::new(&path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("photo.jpg") - .to_string(); - let item = crate::UploadItemDto { - path, - kind: "photo".into(), - mime_type: String::new(), - file_name: filename, - caption, - entities_json: entities_json.clone(), - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let client = get_client(handle)?; - let api_id = client.data.lock().api_id; - let staging = save_items(&client, api_id, std::slice::from_ref(&item))?; - send_uploaded_metadata(handle, chat_id, &staging, |state, staging| { - crate::upload::upload_rpc::send_uploaded( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &item, - crate::upload::upload_rpc::staged_input_file(&item, &staging[0]), - reply_to_msg_id, - top_msg_id, - entities_json.as_deref(), - ) - }) -} - -pub fn send_uploaded_media( - handle: u64, - chat_id: i64, - item: crate::UploadItemDto, - reply_to_msg_id: i32, - top_msg_id: i32, - entities_json: Option, -) -> Result { - let client = get_client(handle)?; - let api_id = client.data.lock().api_id; - let staging = save_items(&client, api_id, std::slice::from_ref(&item))?; - send_uploaded_metadata(handle, chat_id, &staging, |state, staging| { - crate::upload::upload_rpc::send_uploaded( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &item, - crate::upload::upload_rpc::staged_input_file(&item, &staging[0]), - reply_to_msg_id, - top_msg_id, - entities_json.as_deref(), - ) - }) -} - -pub fn send_uploaded_album( - handle: u64, - chat_id: i64, - items: Vec, - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result, MtprotoError> { - let client = get_client(handle)?; - let api_id = client.data.lock().api_id; - let staging = save_items(&client, api_id, &items)?; - send_uploaded_metadata(handle, chat_id, &staging, |state, staging| { - let inputs: Vec<_> = items - .iter() - .zip(staging) - .map(|(item, staged)| crate::upload::upload_rpc::staged_input_file(item, staged)) - .collect(); - crate::upload::upload_rpc::send_album( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &items, - &inputs, - reply_to_msg_id, - top_msg_id, - ) - }) -} - -pub fn send_uploaded_media_with_options( - handle: u64, - chat_id: i64, - mut item: crate::UploadItemDto, - reply_to_msg_id: i32, - top_msg_id: i32, - entities_json: Option, - options: crate::SendOptions, -) -> Result { - if entities_json.is_some() { - item.entities_json = entities_json; - } - send_uploads_with_options( - handle, - chat_id, - vec![item], - reply_to_msg_id, - top_msg_id, - options, - false, - )? - .pop() - .ok_or_else(|| MtprotoError::Message("upload result missing".into())) -} - -pub fn send_uploaded_album_with_options( - handle: u64, - chat_id: i64, - items: Vec, - reply_to_msg_id: i32, - top_msg_id: i32, - options: crate::SendOptions, -) -> Result, MtprotoError> { - send_uploads_with_options( - handle, - chat_id, - items, - reply_to_msg_id, - top_msg_id, - options, - true, - ) -} - -fn send_uploads_with_options( - handle: u64, - chat_id: i64, - items: Vec, - reply_to_msg_id: i32, - top_msg_id: i32, - options: crate::SendOptions, - album: bool, -) -> Result, MtprotoError> { - let ids = items - .iter() - .map(|item| item.random_id) - .collect::>(); - if items.is_empty() - || items.len() > 10 - || ids.len() != items.len() - || ids.contains(&0) - || (!album && items.len() != 1) - { - return Err(MtprotoError::Message( - "upload options require distinct persisted random ids".into(), - )); - } - for item in &items { - options.validate(item.random_id)?; - if !matches!(item.kind.as_str(), "photo" | "video" | "document") { - return Err(MtprotoError::Message("unsupported upload kind".into())); - } - } - if album { - crate::upload::upload_rpc::validate_album( - &items - .iter() - .map(|item| item.kind.as_str()) - .collect::>(), - )?; - } - let client = get_client(handle)?; - let (owner, api_id) = { - let data = client.data.lock(); - if !session_lease_valid(&data, data.home_session_id) { - return Err(expired_session_lease()); - } - if !data.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing receipt journal requires acknowledgment".into(), - )); - } - for item in &items { - crate::messages::input_entities_from_json(item.entities_json.as_deref(), &data.peers)?; - } - ( - UploadOwner { - generation: data.journal.account_generation, - session_id: data.home_session_id, - }, - data.api_id, - ) - }; - let staging = - save_items_with_owner(&client, api_id, &items, Some(owner), |snapshot, batch| { - crate::upload::upload_rpc::save_batch(snapshot, api_id, batch) - })?; - let (results, session_id) = with_interactive_client_mut(handle, |state| { - check_upload_owner(&client, owner)?; - if state.journal.account_generation != owner.generation || !state.journal.admission_open() { - return Err(MtprotoError::Message( - "outgoing upload ownership changed".into(), - )); - } - let results = crate::rpc::with_rpc_timeout_secs(300, || { - call_with_migrate(state, |state| { - check_upload_owner(&client, owner)?; - with_peer_refresh(state, chat_id, |state| { - check_upload_owner(&client, owner)?; - scheduler::with_class(scheduler::RequestClass::InteractiveRead, || { - let inputs = items - .iter() - .zip(&staging) - .map(|(item, staged)| { - crate::upload::upload_rpc::staged_input_file(item, staged) - }) - .collect::>(); - if album { - crate::upload::upload_rpc::send_album_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &items, - &inputs, - reply_to_msg_id, - top_msg_id, - &options, - ) - } else { - crate::upload::upload_rpc::send_uploaded_with_options( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - &items[0], - inputs[0].clone(), - reply_to_msg_id, - top_msg_id, - items[0].entities_json.as_deref(), - &options, - ) - .map(|result| vec![result]) - } - }) - }) - }) - })?; - if results.len() != items.len() { - return Err(MtprotoError::Message( - "upload receipt count mismatch".into(), - )); - } - check_upload_owner(&client, owner)?; - let receipts = items - .iter() - .zip(&results) - .map(|(item, result)| (item.random_id, result.clone())) - .collect(); - let session_id = - super::updates::stage_outgoing_results(&client, owner.generation, receipts)?; - Ok((results, session_id)) - })?; - super::persist::persist_journal_sync(&client, session_id)?; - Ok(results) -} - -pub fn forward_messages( - handle: u64, - from_chat_id: i64, - message_ids: Vec, - to_chat_id: i64, - drop_author: bool, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let sent = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::forward_messages( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - from_chat_id, - message_ids.clone(), - to_chat_id, - drop_author, - ) - }) - })?; - Ok(sent) - }) -} - -pub fn edit_text_message( - handle: u64, - chat_id: i64, - message_id: i32, - text: String, - entities_json: Option, -) -> Result { - with_client_mut(handle, |state| { - let sent = crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::edit_text( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - message_id, - &text, - entities_json.as_deref(), - ) - }) - })?; - Ok(sent) - }) -} - -pub fn delete_message( - handle: u64, - chat_id: i64, - message_id: i32, - revoke: bool, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(15, || { - call_with_migrate(state, |state| { - crate::messages::delete_message( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - revoke, - ) - }) - })?; - Ok(()) - }) -} - -pub fn read_history(handle: u64, chat_id: i64, max_id: i32) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_history( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - max_id, - ) - }) - })?; - Ok(()) - }) -} - -pub fn read_message_contents( - handle: u64, - chat_id: i64, - message_ids: Vec, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_message_contents( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_ids.clone(), - ) - }) - })?; - Ok(()) - }) -} - -pub fn mark_dialog_unread(handle: u64, chat_id: i64, unread: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::mark_dialog_unread( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - unread, - ) - }) - })?; - Ok(()) - }) -} - -pub fn get_unread_mentions( - handle: u64, - chat_id: i64, - offset_id: i32, - add_offset: i32, - limit: i32, - top_msg_id: i32, -) -> Result, MtprotoError> { - with_read_lane(handle, |state| { - let user_id = state.user_id; - let messages = crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::get_unread_mentions( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - offset_id, - add_offset, - limit, - top_msg_id, - user_id, - ) - }) - }) - })?; - Ok(messages) - }) -} - -pub fn read_mentions(handle: u64, chat_id: i64, top_msg_id: i32) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_mentions( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - top_msg_id, - ) - }) - })?; - Ok(()) - }) -} - -pub fn get_unread_reactions( - handle: u64, - chat_id: i64, - offset_id: i32, - add_offset: i32, - limit: i32, - top_msg_id: i32, -) -> Result, MtprotoError> { - with_read_lane(handle, |state| { - let user_id = state.user_id; - let messages = crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::get_unread_reactions( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - offset_id, - add_offset, - limit, - top_msg_id, - user_id, - ) - }) - }) - })?; - Ok(messages) - }) -} - -pub fn read_reactions(handle: u64, chat_id: i64, top_msg_id: i32) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_reactions( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - top_msg_id, - ) - }) - })?; - Ok(()) - }) -} - -pub fn read_discussion( - handle: u64, - chat_id: i64, - msg_id: i32, - read_max_id: i32, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_discussion( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - msg_id, - read_max_id, - ) - }) - })?; - Ok(()) - }) -} - -pub fn update_status(handle: u64, offline: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::profile::update_status(&mut state.snapshot, state.api_id, offline) - }) - }) -} - -pub fn register_device( - handle: u64, - token_type: i32, - token: String, - secret: Vec, - no_muted: bool, - app_sandbox: bool, - other_uids: Vec, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::register_device( - &mut state.snapshot, - state.api_id, - token_type, - token.clone(), - secret.clone(), - no_muted, - app_sandbox, - other_uids.clone(), - ) - }) - }) -} - -pub fn unregister_device( - handle: u64, - token_type: i32, - token: String, - other_uids: Vec, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::unregister_device( - &mut state.snapshot, - state.api_id, - token_type, - token.clone(), - other_uids.clone(), - ) - }) - }) -} - -pub fn get_notify_settings( - handle: u64, - peer_kind: String, - chat_id: i64, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::get_notify_settings( - &mut state.snapshot, - state.api_id, - &state.peers, - peer_kind.clone(), - chat_id, - ) - }) - }) -} - -pub fn update_notify_settings( - handle: u64, - peer_kind: String, - chat_id: i64, - show_previews: Option, - silent: bool, - mute_until: i32, - stories_muted: bool, - sound: String, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::update_notify_settings( - &mut state.snapshot, - state.api_id, - &state.peers, - peer_kind.clone(), - chat_id, - show_previews, - silent, - mute_until, - stories_muted, - sound.clone(), - ) - }) - }) -} - -pub fn join_chat(handle: u64, chat_id: i64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| super::membership::join_chat(state, chat_id)) - }) -} - -pub fn unblock_user(handle: u64, chat_id: i64) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - super::membership::unblock_user(state, chat_id) - }) - }) -} - -pub fn reset_notify_settings(handle: u64) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::reset_notify_settings(&mut state.snapshot, state.api_id) - }) - }) -} - -pub fn set_contact_joined_silent(handle: u64, silent: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::set_contact_joined_silent( - &mut state.snapshot, - state.api_id, - silent, - ) - }) - }) -} - -pub fn get_notify_exceptions( - handle: u64, - compare_sound: bool, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::push_rpc::get_notify_exceptions( - &mut state.snapshot, - state.api_id, - compare_sound, - ) - }) - }) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_upload_options_tests.rs"] -mod upload_options_tests; - -pub fn set_typing(handle: u64, chat_id: i64, typing: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::messages::set_typing( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - typing, - ) - }) - }) -} diff --git a/native/mtproto-rs/src/client/drafts.rs b/native/mtproto-rs/src/client/drafts.rs deleted file mode 100644 index c110351f6..000000000 --- a/native/mtproto-rs/src/client/drafts.rs +++ /dev/null @@ -1,185 +0,0 @@ -#[cfg(test)] -#[path = "../../tests/unit/client_drafts_tests.rs"] -mod tests; - -use super::{call_with_migrate, with_client_mut}; -use crate::MtprotoError; -use crate::peers::{input_peer_from_cached, peer_chat_id, require_usable_peer, vector_boxed_items}; -use serde::{Deserialize, Serialize}; -use tellers_mtproto::latest::api::*; - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct CloudDraft { - pub chat_id: i64, - pub thread_id: i32, - pub text: String, - pub entities_json: Option, - pub reply_to_message_id: i32, - pub date: i32, - pub no_webpage: bool, - pub has_unsupported_content: bool, -} - -pub fn get_cloud_drafts(handle: u64) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetAllDraftsRequest {}, - )?; - super::management::cache_response_peers(state, &response); - Ok(drafts_from_updates(&response)) - }) - }) -} - -pub fn save_cloud_draft(handle: u64, draft: CloudDraft) -> Result<(), MtprotoError> { - if draft.has_unsupported_content { - return Err(MtprotoError::Message( - "this draft contains content that cannot be changed by the text composer".into(), - )); - } - if draft.thread_id < 0 || draft.reply_to_message_id < 0 { - return Err(MtprotoError::Message("invalid draft reply context".into())); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = input_peer_from_cached(require_usable_peer(&state.peers, draft.chat_id)?); - let request = save_request(peer, &draft, &state.peers)?; - let response: Bool = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - match response { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => Err(MtprotoError::Message("draft was not saved".into())), - } - }) - }) -} - -pub(crate) fn drafts_from_updates(updates: &Updates) -> Vec { - let (updates, date) = match updates { - Updates::Updates(value) => (&value.updates, value.date), - Updates::UpdatesCombined(value) => (&value.updates, value.date), - _ => return Vec::new(), - }; - vector_boxed_items(updates) - .filter_map(|update| match update { - Update::UpdateDraftMessage(value) => Some(draft_from_update(value, date)), - _ => None, - }) - .collect() -} - -pub(crate) fn draft_from_update( - update: &UpdateDraftMessageConstructor, - envelope_date: i32, -) -> CloudDraft { - let mut result = CloudDraft { - chat_id: peer_chat_id(&update.peer), - thread_id: update.top_msg_id.unwrap_or(0), - text: String::new(), - entities_json: None, - reply_to_message_id: 0, - date: envelope_date, - no_webpage: false, - has_unsupported_content: update.saved_peer_id.is_some(), - }; - match update.draft.as_ref() { - DraftMessage::DraftMessageEmpty(draft) => result.date = draft.date.unwrap_or(envelope_date), - DraftMessage::DraftMessage(draft) => { - result.text = draft.message.clone(); - result.date = draft.date; - result.no_webpage = draft.no_webpage.is_some(); - result.entities_json = draft_entities_json(draft.entities.as_deref()); - let rebuilt = crate::messages::entities_from_json(result.entities_json.as_deref()) - .ok() - .and_then(|(_, entities)| entities); - let original_count = draft - .entities - .as_ref() - .map(|items| crate::peers::vector_items(items).len()) - .unwrap_or(0); - let entities_changed = - original_count > 0 && draft.entities.as_deref() != rebuilt.as_deref(); - result.has_unsupported_content |= draft.media.is_some() - || draft.effect.is_some() - || draft.suggested_post.is_some() - || draft.rich_message.is_some() - || draft.invert_media.is_some() - || entities_changed; - if let Some(reply) = &draft.reply_to { - match reply.as_ref() { - InputReplyTo::InputReplyToMessage(reply) => { - result.thread_id = update.top_msg_id.or(reply.top_msg_id).unwrap_or(0); - result.reply_to_message_id = if reply.reply_to_msg_id == result.thread_id { - 0 - } else { - reply.reply_to_msg_id - }; - result.has_unsupported_content |= reply.reply_to_peer_id.is_some() - || reply.quote_text.is_some() - || reply.quote_entities.is_some() - || reply.quote_offset.is_some() - || reply.monoforum_peer_id.is_some() - || reply.todo_item_id.is_some() - || reply.poll_option.is_some() - || reply.quote_rich.is_some(); - } - _ => result.has_unsupported_content = true, - } - } - } - } - result -} - -pub(super) fn draft_entities_json(entities: Option<&Vector>>) -> Option { - let entities = entities?; - let raw = crate::dialogs::entities_to_json(Some(entities))?; - let mut mapped: Vec = serde_json::from_str(&raw).ok()?; - if mapped.len() != crate::peers::vector_items(entities).len() { - return None; - } - for (entity, json) in vector_boxed_items(entities).zip(&mut mapped) { - match entity { - MessageEntity::MessageEntityMentionName(mention) => { - json["kind"] = "mention_name".into(); - json["url"] = mention.user_id.to_string().into(); - } - MessageEntity::MessageEntityBlockquote(quote) if quote.collapsed.is_some() => { - json["url"] = "collapsed".into(); - } - _ => {} - } - } - serde_json::to_string(&mapped).ok() -} - -fn save_request( - peer: InputPeer, - draft: &CloudDraft, - peers: &crate::HashMap, -) -> Result { - let (_, reply_to) = - crate::messages::input_reply_to_thread(draft.reply_to_message_id, draft.thread_id); - let (_, entities) = - crate::messages::input_entities_from_json(draft.entities_json.as_deref(), peers)?; - Ok(MessagesSaveDraftRequest { - flags: (u32::from(draft.no_webpage) << 1) - | (u32::from(entities.is_some()) << 3) - | (u32::from(reply_to.is_some()) << 4), - no_webpage: draft - .no_webpage - .then(|| Box::new(True::True(TrueConstructor {}))), - invert_media: None, - reply_to, - peer: Box::new(peer), - message: draft.text.clone(), - entities, - media: None, - effect: None, - suggested_post: None, - rich_message: None, - }) -} diff --git a/native/mtproto-rs/src/client/extras.rs b/native/mtproto-rs/src/client/extras.rs deleted file mode 100644 index 3234ed83c..000000000 --- a/native/mtproto-rs/src/client/extras.rs +++ /dev/null @@ -1,671 +0,0 @@ -use crate::{MessageDto, MtprotoError}; - -use super::*; -use tellers_mtproto::latest::api::{Message, MessageMedia, Updates}; - -pub fn get_wallpapers(handle: u64, hash: i64) -> Result { - with_client_mut(handle, |state| { - let catalog = call_with_migrate(state, |state| { - crate::media::wallpaper_rpc::get_wallpapers(&mut state.snapshot, state.api_id, hash) - })?; - Ok(catalog) - }) -} - -pub fn get_web_page( - handle: u64, - url: String, - hash: i32, -) -> Result { - with_client_mut(handle, |state| { - ensure_ready(state)?; - let page = call_with_migrate(state, |state| { - crate::instant_view::get_web_page( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - url.clone(), - hash, - ) - })?; - Ok(page) - }) -} - -pub fn get_web_page_preview( - handle: u64, - message: String, -) -> Result { - with_client_mut(handle, |state| { - ensure_ready(state)?; - let page = call_with_migrate(state, |state| { - crate::instant_view::get_web_page_preview( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - message.clone(), - ) - })?; - Ok(page) - }) -} - -pub fn send_location( - handle: u64, - chat_id: i64, - latitude: f64, - longitude: f64, - live_period: i32, - heading: i32, - reply_to_msg_id: i32, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::send_location( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - latitude, - longitude, - live_period, - heading, - reply_to_msg_id, - ) - }) - }) - }) -} - -pub fn edit_live_location( - handle: u64, - chat_id: i64, - message_id: i32, - latitude: Option, - longitude: Option, - heading: Option, - stopped: bool, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let request = crate::client::extras_rpc::live_location_edit_request( - &state.peers, - chat_id, - message_id, - latitude, - longitude, - heading, - stopped, - )?; - let rows = crate::dialogs::fetch_message( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - )?; - let mut messages = crate::peers::vector_boxed_items(&rows); - let Some(Message::Message(message)) = messages.next() else { - return Err(MtprotoError::Message("live location unavailable".into())); - }; - if messages.next().is_some() - || message.id != message_id - || crate::peers::peer_chat_id(&message.peer_id) != chat_id - || message.out.is_none() - { - return Err(MtprotoError::Message("owned live location required".into())); - } - let Some(MessageMedia::MessageMediaGeoLive(media)) = message.media.as_deref() - else { - return Err(MtprotoError::Message("live location unavailable".into())); - }; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| MtprotoError::Message("clock unavailable".into()))? - .as_secs() as i64; - if !stopped - && (media.period <= 0 - || (media.period != i32::MAX - && i64::from(message.date) + i64::from(media.period) <= now)) - { - return Err(MtprotoError::Message("live location expired".into())); - } - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) - }) -} - -pub fn get_message_reactions_page( - handle: u64, - chat_id: i64, - message_id: i32, - offset: Option, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_message_reactions_page( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - offset.as_deref(), - ) - }) - }) - }) -} - -pub fn get_poll_votes_page( - handle: u64, - chat_id: i64, - message_id: i32, - offset: Option, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_poll_votes_page( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - offset.as_deref(), - ) - }) - }) - }) -} - -pub fn get_message_reactions_list( - handle: u64, - chat_id: i64, - message_id: i32, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_message_reactions_list( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - ) - }) - }) - }) -} - -pub fn get_poll_votes( - handle: u64, - chat_id: i64, - message_id: i32, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_poll_votes( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - ) - }) - }) - }) -} - -pub fn get_message( - handle: u64, - chat_id: i64, - message_id: i32, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::dialogs::get_message( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - message_id, - ) - }) - }) - }) -} - -pub fn send_poll_vote( - handle: u64, - chat_id: i64, - message_id: i32, - options: Vec>, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::send_poll_vote( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - &options, - ) - }) - }) - }) -} - -pub fn append_todo_items( - handle: u64, - chat_id: i64, - message_id: i32, - first_id: i32, - titles: Vec, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let updates = crate::client::extras_rpc::append_todo_items( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - first_id, - &titles, - )?; - super::management::cache_response_peers(state, &updates); - super::management::enqueue_response(state.snapshot.dc_id, &updates) - }) - }) - }) -} - -pub fn toggle_todo_completed( - handle: u64, - chat_id: i64, - message_id: i32, - completed: Vec, - incompleted: Vec, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let updates = crate::client::extras_rpc::toggle_todo_completed( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - &completed, - &incompleted, - )?; - super::management::cache_response_peers(state, &updates); - super::management::enqueue_response(state.snapshot.dc_id, &updates) - }) - })?; - Ok(()) - }) -} - -pub fn get_bot_callback_answer( - handle: u64, - chat_id: i64, - message_id: i32, - data_hex: String, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_bot_callback_answer( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - &data_hex, - ) - }) - })?; - Ok(dto) - }) -} - -pub fn get_saved_gifs(handle: u64) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - let (_hash, list) = call_with_migrate(state, |state| { - crate::client::extras_rpc::get_saved_gifs( - &mut state.snapshot, - state.api_id, - &mut state.media, - 0, - ) - })?; - Ok(list) - }) -} - -pub fn resolve_username( - handle: u64, - username: String, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::inline_rpc::resolve_username( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &username, - ) - })?; - Ok(dto) - }) -} - -pub fn get_inline_bot_results( - handle: u64, - chat_id: i64, - bot_id: i64, - query: String, - offset: String, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::inline_rpc::get_inline_bot_results( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - bot_id, - &query, - &offset, - ) - })?; - state.last_inline = Some(LastInlineQuery { - chat_id, - bot_id, - query: crate::CompactString::from(query), - offset: crate::CompactString::from(offset), - last_refresh: None, - }); - Ok(dto) - }) -} - -pub fn send_inline_bot_result( - handle: u64, - chat_id: i64, - query_id: i64, - result_id: String, - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::inline_rpc::send_inline_bot_result( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - query_id, - &result_id, - reply_to_msg_id, - top_msg_id, - ) - }) - })?; - Ok(dto) - }) -} - -pub fn send_saved_gif( - handle: u64, - chat_id: i64, - document_id: i64, - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::send_saved_gif( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - document_id, - reply_to_msg_id, - top_msg_id, - ) - }) - })?; - Ok(dto) - }) -} - -pub fn send_reaction( - handle: u64, - chat_id: i64, - message_id: i32, - emoticon: String, - document_id: i64, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::send_reaction( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - emoticon.clone(), - document_id, - ) - }) - }) - }) -} - -pub fn get_recent_reactions(handle: u64) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::extras_rpc::get_recent_reactions(&mut state.snapshot, state.api_id) - }) - }) -} - -pub fn animated_emoji_max(handle: u64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::extras_rpc::animated_emoji_max(&mut state.snapshot, state.api_id) - }) - }) -} - -pub fn custom_emoji_is_free(handle: u64, document_id: i64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::client::extras_rpc::custom_emoji_is_free( - &mut state.snapshot, - state.api_id, - document_id, - ) - }) - }) -} - -pub fn get_discussion_message( - handle: u64, - chat_id: i64, - message_id: i32, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::client::extras_rpc::get_discussion_message( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - chat_id, - message_id, - ) - }) - }) - }) -} - -pub fn get_sticker_pack( - handle: u64, - document_id: i64, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::sticker_rpc::get_sticker_pack( - &mut state.snapshot, - state.api_id, - &mut state.media, - document_id, - ) - })?; - // Pack documents are the only file locations for stickers that have no message id. - persist(state)?; - Ok(dto) - }) -} - -pub fn get_sticker_set( - handle: u64, - set_id: i64, - access_hash: i64, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::sticker_rpc::get_sticker_set( - &mut state.snapshot, - state.api_id, - &mut state.media, - set_id, - access_hash, - ) - })?; - persist(state)?; - Ok(dto) - }) -} - -pub fn get_all_stickers(handle: u64, hash: i64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::messages::sticker_rpc::get_all_stickers(&mut state.snapshot, state.api_id, hash) - }) - }) -} - -pub fn get_emoji_stickers( - handle: u64, - hash: i64, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::messages::sticker_rpc::get_emoji_stickers( - &mut state.snapshot, - state.api_id, - hash, - ) - }) - }) -} - -pub fn get_stickers( - handle: u64, - emoticon: String, - hash: i64, -) -> Result { - with_client_mut(handle, |state| { - let dto = call_with_migrate(state, |state| { - crate::messages::sticker_rpc::get_stickers( - &mut state.snapshot, - state.api_id, - &mut state.media, - &emoticon, - hash, - ) - })?; - Ok(dto) - }) -} - -pub fn get_message_read_participants( - handle: u64, - chat_id: i64, - msg_id: i32, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_receipts_rpc::get_message_read_participants( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - msg_id, - ) - }) - }) - }) -} - -pub fn get_outbox_read_date( - handle: u64, - chat_id: i64, - msg_id: i32, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::messages::read_receipts_rpc::get_outbox_read_date( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - msg_id, - ) - }) - }) - }) -} - -pub fn get_read_receipt_config( - handle: u64, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::messages::read_receipts_rpc::fetch_read_receipt_config( - &mut state.snapshot, - state.api_id, - ) - }) - }) -} diff --git a/native/mtproto-rs/src/client/extras_rpc.rs b/native/mtproto-rs/src/client/extras_rpc.rs deleted file mode 100644 index a316d99b5..000000000 --- a/native/mtproto-rs/src/client/extras_rpc.rs +++ /dev/null @@ -1,991 +0,0 @@ -//! Saved GIFs, reactions, and channel discussion RPCs (UniFFI). -//! https://core.telegram.org/method/messages.getSavedGifs -//! https://core.telegram.org/api/reactions -//! https://core.telegram.org/method/messages.getDiscussionMessage - -use crate::HashMap; -use tellers_mtproto::latest::api::{InputGeoPointEmptyConstructor, MessagesEditMessageRequest}; - -use serde_json::json; -use tellers_mtproto::latest::api::{ - Chat as TlChat, Document, DocumentAttribute, HelpAppConfig, HelpGetAppConfigRequest, - InputDocument, InputDocumentConstructor, InputGeoPoint, InputGeoPointConstructor, InputMedia, - InputMediaDocumentConstructor, InputMediaGeoLiveConstructor, InputMediaGeoPointConstructor, - JsonObjectValue, JsonValue, Message, MessagePeerReaction, MessagePeerVote, MessageReactions, - MessageReplies, MessagesAppendTodoListRequest, MessagesBotCallbackAnswer, - MessagesDiscussionMessage, MessagesGetBotCallbackAnswerRequest, - MessagesGetCustomEmojiDocumentsRequest, MessagesGetDiscussionMessageRequest, - MessagesGetMessageReactionsListRequest, MessagesGetPollVotesRequest, - MessagesGetRecentReactionsRequest, MessagesGetSavedGifsRequest, MessagesGetTopReactionsRequest, - MessagesMessageReactionsList, MessagesReactions, MessagesSavedGifs, MessagesSendMediaRequest, - MessagesSendReactionRequest, MessagesSendVoteRequest, MessagesToggleTodoCompletedRequest, - MessagesVotesList, Reaction, ReactionCount, ReactionCustomEmojiConstructor, - ReactionEmojiConstructor, TextWithEntities, TextWithEntitiesConstructor, TodoItem, - TodoItemConstructor, True, TrueConstructor, Updates, User, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::media::{self, MediaIndex, MediaLocation}; -use crate::messages; -use crate::peers::{ - self, CachedPeer, input_peer_from_cached, peer_chat_id, vector_boxed_items, vector_items, -}; -use crate::{ - BotCallbackAnswerDto, DiscussionDto, MessageDto, MtprotoError, ReactionChoiceDto, SavedGifDto, -}; - -pub fn reactions_to_json(reactions: Option<&MessageReactions>) -> Option { - let Some(MessageReactions::MessageReactions(body)) = reactions else { - return None; - }; - let mut rows = Vec::new(); - for count in vector_boxed_items(&body.results) { - let ReactionCount::ReactionCount(rc) = count else { - continue; - }; - let chosen = rc.chosen_order.is_some(); - match rc.reaction.as_ref() { - Reaction::ReactionEmoji(e) => rows.push(json!({ - "e": e.emoticon, - "c": rc.count, - "me": chosen, - })), - Reaction::ReactionCustomEmoji(e) => rows.push(json!({ - "d": e.document_id, - "c": rc.count, - "me": chosen, - })), - _ => {} - } - } - if rows.is_empty() { - None - } else { - serde_json::to_string(&rows).ok() - } -} - -pub fn replies_meta(replies: Option<&MessageReplies>) -> (i32, Option) { - match replies { - Some(MessageReplies::MessageReplies(r)) if r.comments.is_some() => { - let peer = r.channel_id.map(crate::peers::chat_id_for_channel); - (r.replies, peer) - } - Some(MessageReplies::MessageReplies(r)) => (r.replies, None), - _ => (0, None), - } -} - -pub fn get_saved_gifs( - snapshot: &mut Snapshot, - api_id: i32, - media_index: &mut MediaIndex, - hash: i64, -) -> Result<(i64, Vec), MtprotoError> { - let response: MessagesSavedGifs = - api_invoke::invoke_api(snapshot, api_id, MessagesGetSavedGifsRequest { hash })?; - match response { - MessagesSavedGifs::MessagesSavedGifsNotModified(_) => Ok((hash, Vec::new())), - MessagesSavedGifs::MessagesSavedGifs(body) => { - let mut out = Vec::new(); - for doc in vector_boxed_items(&body.gifs) { - let Document::Document(d) = doc else { - continue; - }; - let media = media::media_ref_from_document(d); - let dto = SavedGifDto { - document_id: d.id, - cache_key: media.cache_key.clone(), - thumb_cache_key: media.thumb_cache_key.clone(), - width: None, - height: None, - }; - media::insert_media(media_index, (d.id, 0), media); - out.push(dto); - } - Ok((body.hash, out)) - } - _ => Ok((hash, Vec::new())), - } -} - -pub fn send_saved_gif( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - document_id: i64, - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result { - let cached = peers::require_usable_peer(peers, chat_id)?; - let media = media_index - .get(&(document_id, 0)) - .cloned() - .ok_or_else(|| MtprotoError::Message("unknown saved gif".into()))?; - let MediaLocation::Document { - id, - access_hash, - file_reference, - .. - } = media.location - else { - return Err(MtprotoError::Message("saved gif is not a document".into())); - }; - let input = InputMedia::InputMediaDocument(InputMediaDocumentConstructor { - flags: 0, - spoiler: None, - id: Box::new(InputDocument::InputDocument(InputDocumentConstructor { - id, - access_hash, - file_reference, - })), - video_cover: None, - video_timestamp: None, - ttl_seconds: None, - query: None, - }); - let (reply_flag, reply_to) = messages::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let request = MessagesSendMediaRequest { - flags: reply_flag, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(cached)), - reply_to, - media: Box::new(input), - message: String::new(), - random_id: messages::random_id(), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(messages::message_from_updates( - updates, - chat_id, - "", - media_index, - )) -} - -pub fn send_reaction( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - emoticon: String, - document_id: i64, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let reaction = if !emoticon.is_empty() { - Some(Box::new(Reaction::ReactionEmoji( - ReactionEmojiConstructor { emoticon }, - ))) - } else if document_id != 0 { - Some(Box::new(Reaction::ReactionCustomEmoji( - ReactionCustomEmojiConstructor { document_id }, - ))) - } else { - None - }; - let items = reaction.into_iter().collect::>(); - let flag = if items.is_empty() { - 0 - } else { - MessagesSendReactionRequest::REACTION_FLAG | MessagesSendReactionRequest::ADD_TO_RECENT_FLAG - }; - let request = MessagesSendReactionRequest { - flags: flag, - big: None, - add_to_recent: if items.is_empty() { - None - } else { - Some(Box::new(True::True(TrueConstructor {}))) - }, - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - reaction: if items.is_empty() { - None - } else { - Some(Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - }))) - }, - }; - let _: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(()) -} - -pub fn get_discussion_message( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - message_id: i32, -) -> Result { - let cached = peers::require_usable_peer(peers, chat_id)?; - let response: MessagesDiscussionMessage = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetDiscussionMessageRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - }, - )?; - let MessagesDiscussionMessage::MessagesDiscussionMessage(body) = response else { - return Err(MtprotoError::Message("unexpected discussionMessage".into())); - }; - let users: Vec = vector_boxed_items(&body.users).cloned().collect(); - let chats: Vec = vector_boxed_items(&body.chats).cloned().collect(); - crate::dialogs::cache_from_users_chats( - peers, - media_index, - users.into_iter(), - chats.into_iter(), - ); - // Reverse chronological: last Message is the auto-forwarded channel post - // that starts the comment thread. - // https://core.telegram.org/api/discussion - let mut thread: Option<(i64, i32)> = None; - for msg in vector_boxed_items(&body.messages) { - if let Message::Message(m) = msg { - thread = Some((peer_chat_id(&m.peer_id), m.id)); - } - } - thread - .map(|(chat_id, message_id)| DiscussionDto { - chat_id, - message_id, - }) - .ok_or_else(|| MtprotoError::Message("no discussion thread".into())) -} - -fn reaction_choices(reactions: &MessagesReactions) -> Vec { - let MessagesReactions::MessagesReactions(body) = reactions else { - return Vec::new(); - }; - let mut out = Vec::new(); - for item in vector_boxed_items(&body.reactions) { - match item { - Reaction::ReactionEmoji(e) => out.push(ReactionChoiceDto { - emoticon: e.emoticon.clone(), - document_id: 0, - }), - Reaction::ReactionCustomEmoji(e) => out.push(ReactionChoiceDto { - emoticon: String::new(), - document_id: e.document_id, - }), - _ => {} - } - } - out -} - -pub fn get_recent_reactions( - snapshot: &mut Snapshot, - api_id: i32, -) -> Result, MtprotoError> { - let mut out = Vec::new(); - let recent: MessagesReactions = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetRecentReactionsRequest { limit: 32, hash: 0 }, - )?; - out.extend(reaction_choices(&recent)); - let top: MessagesReactions = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetTopReactionsRequest { limit: 16, hash: 0 }, - )?; - for choice in reaction_choices(&top) { - if !out.iter().any(|existing| { - existing.document_id == choice.document_id && existing.emoticon == choice.emoticon - }) { - out.push(choice); - } - } - Ok(out) -} - -pub fn reaction_update_json(reactions: &MessageReactions) -> String { - reactions_to_json(Some(reactions)).unwrap_or_else(|| "[]".into()) -} - -/// https://core.telegram.org/api/config#message-animated-emoji-max -pub fn animated_emoji_max(snapshot: &mut Snapshot, api_id: i32) -> Result { - let cfg: HelpAppConfig = - api_invoke::invoke_api(snapshot, api_id, HelpGetAppConfigRequest { hash: 0 })?; - let HelpAppConfig::HelpAppConfig(body) = cfg else { - return Ok(0); - }; - apply_transfer_config(body.config.as_ref()); - Ok(json_object_int(body.config.as_ref(), "message_animated_emoji_max").unwrap_or(0)) -} - -pub(crate) fn apply_transfer_config(config: &JsonValue) { - let read = |keys: &[&str], fallback| { - keys.iter() - .find_map(|key| json_object_int(config, key)) - .unwrap_or(fallback) - .clamp(1, 8) as usize - }; - let small = read( - &[ - "small_queue_active_operations_max", - "small_queue_max_active_operations_count", - ], - 5, - ); - let large = read( - &[ - "large_queue_active_operations_max", - "large_queue_max_active_operations_count", - ], - 2, - ); - let policy = crate::transfer_policy::current(); - let upload_limits = ( - json_object_int(config, "upload_max_fileparts_default") - .filter(|limit| *limit > 0) - .unwrap_or(4_000), - json_object_int(config, "upload_max_fileparts_premium") - .filter(|limit| *limit > 0) - .unwrap_or(8_000), - ); - *policy.upload_parts.lock() = upload_limits; - *crate::transfer_policy::current().queue_limits.lock() = (small, large); -} - -fn json_object_int(value: &JsonValue, key: &str) -> Option { - let JsonValue::JsonObject(obj) = value else { - return None; - }; - for item in vector_boxed_items(&obj.value) { - let JsonObjectValue::JsonObjectValue(entry) = item else { - continue; - }; - if entry.key != key { - continue; - } - return match entry.value.as_ref() { - JsonValue::JsonNumber(n) => Some(n.value as i32), - JsonValue::JsonString(s) => s.value.parse().ok(), - _ => None, - }; - } - None -} - -/// https://core.telegram.org/api/custom-emoji — the `free` flag on -/// `documentAttributeCustomEmoji`. -pub fn custom_emoji_is_free( - snapshot: &mut Snapshot, - api_id: i32, - document_id: i64, -) -> Result { - let docs: Vector> = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetCustomEmojiDocumentsRequest { - document_id: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![document_id], - })), - }, - )?; - let Some(Document::Document(doc)) = vector_boxed_items(&docs).next() else { - return Ok(false); - }; - for attr in vector_boxed_items(&doc.attributes) { - if let DocumentAttribute::DocumentAttributeCustomEmoji(emoji) = attr { - // `free` is a flags.0 conditional field in the TL schema. Read the - // wire flag directly so this remains compatible with generated - // bindings that omit optional convenience fields. - return Ok(emoji.flags & 1 != 0); - } - } - Ok(false) -} - -/// https://core.telegram.org/method/messages.getBotCallbackAnswer -/// https://core.telegram.org/api/bots/buttons -pub fn get_bot_callback_answer( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - data_hex: &str, -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let data = crate::reply_markup::from_hex(data_hex)?; - let response: MessagesBotCallbackAnswer = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetBotCallbackAnswerRequest { - flags: MessagesGetBotCallbackAnswerRequest::DATA_FLAG, - game: None, - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - data: Some(data), - password: None, - }, - )?; - let MessagesBotCallbackAnswer::MessagesBotCallbackAnswer(body) = response; - Ok(BotCallbackAnswerDto { - alert: body.alert.is_some(), - message: body.message.filter(|s| !s.is_empty()), - url: body.url.filter(|s| !s.is_empty()), - cache_time: body.cache_time, - }) -} - -/// https://core.telegram.org/method/messages.toggleTodoCompleted -/// https://core.telegram.org/api/todo -pub fn toggle_todo_completed( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - completed: &[i32], - incompleted: &[i32], -) -> Result { - let request = todo_toggle_request(peers, chat_id, message_id, completed, incompleted)?; - api_invoke::invoke_api(snapshot, api_id, request) -} - -fn todo_toggle_request( - peers: &HashMap, - chat_id: i64, - message_id: i32, - completed: &[i32], - incompleted: &[i32], -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let mut ids = crate::HashSet::default(); - if completed.is_empty() && incompleted.is_empty() - || completed - .iter() - .chain(incompleted) - .any(|id| !ids.insert(*id)) - { - return Err(MtprotoError::Message( - "checklist item selection invalid".into(), - )); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - Ok(MessagesToggleTodoCompletedRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - completed: Box::new(Vector::Vector(VectorConstructor { - field_0: completed.len() as u32, - field_1: completed.to_vec(), - })), - incompleted: Box::new(Vector::Vector(VectorConstructor { - field_0: incompleted.len() as u32, - field_1: incompleted.to_vec(), - })), - }) -} - -/// https://core.telegram.org/method/messages.sendMedia -/// https://core.telegram.org/constructor/inputMediaGeoPoint -/// https://core.telegram.org/constructor/inputMediaGeoLive -pub fn send_location( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - latitude: f64, - longitude: f64, - live_period: i32, - heading: i32, - reply_to_msg_id: i32, -) -> Result<(), MtprotoError> { - if !latitude.is_finite() || !longitude.is_finite() { - return Err(MtprotoError::Message("location is not finite".into())); - } - if !(-90.0..=90.0).contains(&latitude) || !(-180.0..=180.0).contains(&longitude) { - return Err(MtprotoError::Message("location out of range".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let point = InputGeoPoint::InputGeoPoint(InputGeoPointConstructor { - flags: 0, - lat: latitude, - long: longitude, - accuracy_radius: None, - }); - let media = if live_period > 0 { - let mut flags = InputMediaGeoLiveConstructor::PERIOD_FLAG; - if heading > 0 { - flags |= InputMediaGeoLiveConstructor::HEADING_FLAG; - } - InputMedia::InputMediaGeoLive(InputMediaGeoLiveConstructor { - flags, - stopped: None, - geo_point: Box::new(point), - heading: (heading > 0).then_some(heading), - period: Some(live_period), - proximity_notification_radius: None, - }) - } else { - InputMedia::InputMediaGeoPoint(InputMediaGeoPointConstructor { - geo_point: Box::new(point), - }) - }; - let (reply_flag, reply_to) = messages::input_reply_to_thread(reply_to_msg_id, 0); - let request = MessagesSendMediaRequest { - flags: reply_flag, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(cached)), - reply_to, - media: Box::new(media), - message: String::new(), - random_id: messages::random_id(), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - let _updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(()) -} - -pub(crate) fn live_location_edit_request( - peers: &HashMap, - chat_id: i64, - message_id: i32, - latitude: Option, - longitude: Option, - heading: Option, - stopped: bool, -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message( - "live location message required".into(), - )); - } - let point = if stopped { - if latitude.is_some() || longitude.is_some() || heading.is_some() { - return Err(MtprotoError::Message( - "stop must not include movement".into(), - )); - } - InputGeoPoint::InputGeoPointEmpty(InputGeoPointEmptyConstructor {}) - } else { - let (Some(lat), Some(long)) = (latitude, longitude) else { - return Err(MtprotoError::Message("coordinates required".into())); - }; - if !lat.is_finite() - || !long.is_finite() - || !(-90.0..=90.0).contains(&lat) - || !(-180.0..=180.0).contains(&long) - || heading.is_some_and(|h| !(1..=360).contains(&h)) - { - return Err(MtprotoError::Message( - "invalid live location movement".into(), - )); - } - InputGeoPoint::InputGeoPoint(InputGeoPointConstructor { - flags: 0, - lat, - long, - accuracy_radius: None, - }) - }; - let media = InputMediaGeoLiveConstructor { - flags: if stopped { - InputMediaGeoLiveConstructor::STOPPED_FLAG - } else { - if heading.is_some() { - InputMediaGeoLiveConstructor::HEADING_FLAG - } else { - 0 - } - }, - stopped: stopped.then(|| Box::new(True::True(TrueConstructor {}))), - geo_point: Box::new(point), - heading, - period: None, - proximity_notification_radius: None, - }; - Ok(MessagesEditMessageRequest { - flags: MessagesEditMessageRequest::MEDIA_FLAG, - no_webpage: None, - invert_media: None, - peer: Box::new(input_peer_from_cached(peers::require_usable_peer( - peers, chat_id, - )?)), - id: message_id, - message: None, - media: Some(Box::new(InputMedia::InputMediaGeoLive(media))), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - }) -} - -/// https://core.telegram.org/method/messages.sendVote -pub fn send_poll_vote( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - options: &[Vec], -) -> Result<(), MtprotoError> { - let request = poll_vote_request(peers, chat_id, message_id, options)?; - let _updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(()) -} - -fn poll_vote_request( - peers: &HashMap, - chat_id: i64, - message_id: i32, - options: &[Vec], -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - Ok(MessagesSendVoteRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - options: Box::new(Vector::Vector(VectorConstructor { - field_0: options.len() as u32, - field_1: options.to_vec(), - })), - }) -} - -/// https://core.telegram.org/method/messages.appendTodoList -/// https://core.telegram.org/api/todo -pub fn append_todo_items( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - first_id: i32, - titles: &[String], -) -> Result { - let request = todo_append_request(peers, chat_id, message_id, first_id, titles)?; - api_invoke::invoke_api(snapshot, api_id, request) -} - -fn todo_append_request( - peers: &HashMap, - chat_id: i64, - message_id: i32, - first_id: i32, - titles: &[String], -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let cleaned: Vec<&String> = titles - .iter() - .filter(|title| !title.trim().is_empty()) - .collect(); - if cleaned.is_empty() { - return Err(MtprotoError::Message("no checklist items given".into())); - } - let last_offset = i32::try_from(cleaned.len() - 1) - .map_err(|_| MtprotoError::Message("checklist item range invalid".into()))?; - first_id - .checked_add(last_offset) - .ok_or_else(|| MtprotoError::Message("checklist item range invalid".into()))?; - let cached = peers::require_usable_peer(peers, chat_id)?; - let items: Vec> = cleaned - .iter() - .enumerate() - .map(|(offset, title)| { - Box::new(TodoItem::TodoItem(TodoItemConstructor { - id: first_id + offset as i32, - title: Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: title.trim().to_owned(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - }, - )), - })) - }) - .collect(); - Ok(MessagesAppendTodoListRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id: message_id, - list: Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - })), - }) -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ReactionPeerDto { - pub peer_id: i64, - pub title: String, - pub date: i32, - pub emoticon: String, - pub document_id: i64, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ReactionPeersDto { - pub next_offset: Option, - pub peers: Vec, - pub count: i32, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct PollVoterDto { - pub peer_id: i64, - pub title: String, - pub date: i32, - pub options: Vec>, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct PollVotersDto { - pub next_offset: Option, - pub voters: Vec, - pub count: i32, -} - -fn user_title(user: &User) -> (i64, crate::CompactString) { - match user { - User::User(u) => { - let name = [ - u.first_name.as_deref().unwrap_or(""), - u.last_name.as_deref().unwrap_or(""), - ] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join(" "); - ( - u.id, - if name.is_empty() { - crate::CompactString::from(format!("User {}", u.id)) - } else { - crate::CompactString::from(name) - }, - ) - } - User::UserEmpty(u) => (u.id, crate::CompactString::from(format!("User {}", u.id))), - _ => (0, crate::CompactString::from("User")), - } -} - -fn reaction_page_request( - peer: tellers_mtproto::latest::api::InputPeer, - id: i32, - offset: Option<&str>, -) -> MessagesGetMessageReactionsListRequest { - MessagesGetMessageReactionsListRequest { - flags: if offset.is_some() { - MessagesGetMessageReactionsListRequest::OFFSET_FLAG - } else { - 0 - }, - peer: Box::new(peer), - id, - reaction: None, - offset: offset.map(str::to_owned), - limit: 50, - } -} - -fn poll_page_request( - peer: tellers_mtproto::latest::api::InputPeer, - id: i32, - offset: Option<&str>, -) -> MessagesGetPollVotesRequest { - MessagesGetPollVotesRequest { - flags: if offset.is_some() { - MessagesGetPollVotesRequest::OFFSET_FLAG - } else { - 0 - }, - peer: Box::new(peer), - id, - option: None, - offset: offset.map(str::to_owned), - limit: 50, - } -} - -pub fn get_message_reactions_list( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, -) -> Result { - get_message_reactions_page(snapshot, api_id, peers, chat_id, message_id, None) -} - -pub fn get_message_reactions_page( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - offset: Option<&str>, -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let response: MessagesMessageReactionsList = api_invoke::invoke_api( - snapshot, - api_id, - reaction_page_request(input_peer_from_cached(cached), message_id, offset), - )?; - let MessagesMessageReactionsList::MessagesMessageReactionsList(body) = response else { - return Err(MtprotoError::Message( - "unexpected messageReactionsList".into(), - )); - }; - let titles: HashMap = vector_boxed_items(&body.users) - .map(|user| user_title(user)) - .collect(); - let mut peers_out = Vec::new(); - for row in vector_boxed_items(&body.reactions) { - let MessagePeerReaction::MessagePeerReaction(item) = row else { - continue; - }; - let peer_id = peers::peer_chat_id(&item.peer_id); - let (emoticon, document_id) = match item.reaction.as_ref() { - Reaction::ReactionEmoji(e) => (e.emoticon.clone(), 0), - Reaction::ReactionCustomEmoji(e) => (String::new(), e.document_id), - _ => (String::new(), 0), - }; - peers_out.push(ReactionPeerDto { - peer_id, - title: titles - .get(&peer_id) - .map(|name| name.to_string()) - .unwrap_or_default(), - date: item.date, - emoticon, - document_id, - }); - } - Ok(ReactionPeersDto { - next_offset: body.next_offset, - peers: peers_out, - count: body.count, - }) -} - -pub(crate) fn poll_vote_options(row: &MessagePeerVote) -> Vec> { - match row { - MessagePeerVote::MessagePeerVote(v) => vec![v.option.clone()], - MessagePeerVote::MessagePeerVoteInputOption(_) => Vec::new(), - MessagePeerVote::MessagePeerVoteMultiple(v) => vector_items(v.options.as_ref()).to_vec(), - } -} - -pub fn get_poll_votes( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, -) -> Result { - get_poll_votes_page(snapshot, api_id, peers, chat_id, message_id, None) -} - -pub fn get_poll_votes_page( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - offset: Option<&str>, -) -> Result { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let response: MessagesVotesList = api_invoke::invoke_api( - snapshot, - api_id, - poll_page_request(input_peer_from_cached(cached), message_id, offset), - )?; - let MessagesVotesList::MessagesVotesList(body) = response else { - return Err(MtprotoError::Message("unexpected votesList".into())); - }; - let titles: HashMap = vector_boxed_items(&body.users) - .map(|user| user_title(user)) - .collect(); - let mut voters = Vec::new(); - for row in vector_boxed_items(&body.votes) { - let (peer, date) = match row { - MessagePeerVote::MessagePeerVote(v) => (*v.peer.clone(), v.date), - MessagePeerVote::MessagePeerVoteInputOption(v) => (*v.peer.clone(), v.date), - MessagePeerVote::MessagePeerVoteMultiple(v) => (*v.peer.clone(), v.date), - }; - let options = poll_vote_options(row); - let peer_id = peers::peer_chat_id(&peer); - voters.push(PollVoterDto { - peer_id, - title: titles - .get(&peer_id) - .map(|name| name.to_string()) - .unwrap_or_default(), - date, - options, - }); - } - Ok(PollVotersDto { - next_offset: body.next_offset, - voters, - count: body.count, - }) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_extras_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/forum_management.rs b/native/mtproto-rs/src/client/forum_management.rs deleted file mode 100644 index 810e6ee0d..000000000 --- a/native/mtproto-rs/src/client/forum_management.rs +++ /dev/null @@ -1,286 +0,0 @@ -use tellers_mtproto::codec::{Boxed, TlEncode}; -use tellers_mtproto::latest::api::{ - AccountGetNotifySettingsRequest, AccountUpdateNotifySettingsRequest, Bool, - BoolFalseConstructor, BoolTrueConstructor, InputNotifyForumTopicConstructor, InputNotifyPeer, - InputPeer, InputPeerNotifySettings, MessagesAffectedHistory, MessagesCreateForumTopicRequest, - MessagesDeleteTopicHistoryRequest, MessagesEditForumTopicRequest, - MessagesReorderPinnedForumTopicsRequest, MessagesUpdatePinnedForumTopicRequest, - PeerNotifySettings, True, TrueConstructor, Updates, UpdatesTooLongConstructor, Vector, - VectorConstructor, -}; - -use super::group_management::group_peer; -use super::management::{HistoryDeletion, enqueue_response}; -use super::{call_with_migrate, with_client_mut}; -use crate::MtprotoError; - -#[derive(Debug, Clone, Default)] -pub struct TopicChanges { - pub title: Option, - pub icon_emoji_id: Option, - pub closed: Option, - pub hidden: Option, -} - -/// Persist and reuse `random_id` when retrying the same topic creation. -pub fn create_topic( - handle: u64, - chat_id: i64, - title: &str, - random_id: i64, - icon_color: Option, - icon_emoji_id: Option, -) -> Result { - if title.trim().is_empty() || random_id == 0 { - return Err(MtprotoError::Message( - "a title and nonzero random id are required".into(), - )); - } - topic_updates(handle, chat_id, |peer| MessagesCreateForumTopicRequest { - flags: u32::from(icon_color.is_some()) | (u32::from(icon_emoji_id.is_some()) << 3), - title_missing: None, - peer: Box::new(peer), - title: title.to_owned(), - icon_color, - icon_emoji_id, - random_id, - send_as: None, - }) -} - -pub fn edit_topic( - handle: u64, - chat_id: i64, - topic_id: i32, - changes: TopicChanges, -) -> Result { - validate_topic(topic_id)?; - if changes - .title - .as_ref() - .is_some_and(|title| title.trim().is_empty()) - || (changes.title.is_none() - && changes.icon_emoji_id.is_none() - && changes.closed.is_none() - && changes.hidden.is_none()) - { - return Err(MtprotoError::Message( - "a nonempty topic change is required".into(), - )); - } - if changes.hidden.is_some() && topic_id != 1 { - return Err(MtprotoError::Message( - "only the general topic may be hidden".into(), - )); - } - topic_updates(handle, chat_id, |peer| { - edit_request(peer, topic_id, &changes) - }) -} - -pub fn pin_topic( - handle: u64, - chat_id: i64, - topic_id: i32, - pinned: bool, -) -> Result { - validate_topic(topic_id)?; - topic_updates(handle, chat_id, |peer| { - MessagesUpdatePinnedForumTopicRequest { - peer: Box::new(peer), - topic_id, - pinned: Box::new(tl_bool(pinned)), - } - }) -} - -pub fn reorder_pinned_topics( - handle: u64, - chat_id: i64, - order: &[i32], - force: bool, -) -> Result { - let mut seen = std::collections::HashSet::new(); - for id in order { - validate_topic(*id)?; - if !seen.insert(*id) { - return Err(MtprotoError::Message( - "duplicate topic in pinned order".into(), - )); - } - } - let count = order - .len() - .try_into() - .map_err(|_| MtprotoError::Message("too many topics".into()))?; - topic_updates(handle, chat_id, |peer| { - MessagesReorderPinnedForumTopicsRequest { - flags: u32::from(force), - force: force.then(|| Box::new(True::True(TrueConstructor {}))), - peer: Box::new(peer), - order: Box::new(Vector::Vector(VectorConstructor { - field_0: count, - field_1: order.to_vec(), - })), - } - }) -} - -/// Repeat while `more` is true. The response never advances a cursor past -/// unapplied updates; channel history is recovered with getChannelDifference. -pub fn delete_topic( - handle: u64, - chat_id: i64, - topic_id: i32, -) -> Result { - validate_topic(topic_id)?; - if topic_id == 1 { - return Err(MtprotoError::Message( - "the general topic cannot be deleted".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let response: MessagesAffectedHistory = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesDeleteTopicHistoryRequest { - peer: Box::new(peer), - top_msg_id: topic_id, - }, - )?; - if crate::peers::is_channel_chat_id(chat_id) { - super::prepare_channel_recovery( - &mut state.channel_recovery, - vec![chat_id], - 0, - super::recovery_now(), - ); - } else { - enqueue_response( - state.snapshot.dc_id, - &Updates::UpdatesTooLong(UpdatesTooLongConstructor {}), - )?; - } - let MessagesAffectedHistory::MessagesAffectedHistory(result) = response; - Ok(HistoryDeletion { - more: result.offset > 0, - }) - }) - }) -} - -pub fn get_topic_notifications( - handle: u64, - chat_id: i64, - topic_id: i32, -) -> Result { - validate_topic(topic_id)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountGetNotifySettingsRequest { - peer: Box::new(InputNotifyPeer::InputNotifyForumTopic( - InputNotifyForumTopicConstructor { - peer: Box::new(peer), - top_msg_id: topic_id, - }, - )), - }, - ) - }) - }) -} - -pub fn set_topic_notifications( - handle: u64, - chat_id: i64, - topic_id: i32, - settings: InputPeerNotifySettings, -) -> Result<(), MtprotoError> { - validate_topic(topic_id)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountUpdateNotifySettingsRequest { - peer: Box::new(InputNotifyPeer::InputNotifyForumTopic( - InputNotifyForumTopicConstructor { - peer: Box::new(peer), - top_msg_id: topic_id, - }, - )), - settings: Box::new(settings.clone()), - }, - )?; - match response { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => Err(MtprotoError::Message( - "topic notification change was not applied".into(), - )), - } - }) - }) -} - -fn topic_updates( - handle: u64, - chat_id: i64, - request: impl Fn(InputPeer) -> Req, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request(peer))?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -fn edit_request( - peer: InputPeer, - topic_id: i32, - changes: &TopicChanges, -) -> MessagesEditForumTopicRequest { - MessagesEditForumTopicRequest { - flags: u32::from(changes.title.is_some()) - | (u32::from(changes.icon_emoji_id.is_some()) << 1) - | (u32::from(changes.closed.is_some()) << 2) - | (u32::from(changes.hidden.is_some()) << 3), - peer: Box::new(peer), - topic_id, - title: changes.title.clone(), - icon_emoji_id: changes.icon_emoji_id, - closed: changes.closed.map(|value| Box::new(tl_bool(value))), - hidden: changes.hidden.map(|value| Box::new(tl_bool(value))), - } -} - -fn tl_bool(value: bool) -> Bool { - if value { - Bool::BoolTrue(BoolTrueConstructor {}) - } else { - Bool::BoolFalse(BoolFalseConstructor {}) - } -} - -fn validate_topic(topic_id: i32) -> Result<(), MtprotoError> { - if topic_id <= 0 { - return Err(MtprotoError::Message( - "a positive topic id is required".into(), - )); - } - Ok(()) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_forum_management_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/group_management.rs b/native/mtproto-rs/src/client/group_management.rs deleted file mode 100644 index e0012ec30..000000000 --- a/native/mtproto-rs/src/client/group_management.rs +++ /dev/null @@ -1,553 +0,0 @@ -use tellers_mtproto::latest::api::{ - Bool, BoolFalseConstructor, BoolTrueConstructor, ChannelsCreateChannelRequest, - ChannelsEditAdminRequest, ChannelsEditBannedRequest, ChannelsEditPhotoRequest, - ChannelsEditTitleRequest, ChannelsInviteToChannelRequest, ChannelsToggleForumRequest, - ChannelsUpdateUsernameRequest, ChatAdminRights, ChatBannedRights, InputChannel, - InputChannelConstructor, InputChatPhoto, InputCheckPasswordSrp, InputPeer, InputUser, - InputUserConstructor, MessagesAddChatUserRequest, MessagesCreateChatRequest, - MessagesDeleteChatUserRequest, MessagesEditChatAboutRequest, MessagesEditChatAdminRequest, - MessagesEditChatCreatorRequest, MessagesEditChatDefaultBannedRightsRequest, - MessagesEditChatPhotoRequest, MessagesEditChatTitleRequest, MessagesInvitedUsers, True, - TrueConstructor, Updates, Vector, VectorConstructor, -}; - -use super::management::enqueue_response; -use super::{call_with_migrate, with_client_mut}; -use crate::MtprotoError; -use crate::peers::{PeerKind, input_peer_from_cached, require_usable_peer}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChannelKind { - Broadcast, - Megagroup, - Forum, -} - -/// Returns missing invitees as well as updates so partial membership success is -/// visible to callers. Creation is not replayed after an uncertain disconnect. -pub fn create_group( - handle: u64, - title: &str, - member_ids: &[i64], -) -> Result { - validate_title(title)?; - if member_ids.is_empty() || member_ids.iter().any(|id| *id <= 0) { - return Err(MtprotoError::Message( - "group members must be user peers".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let users = member_ids - .iter() - .map(|id| user_peer(state, *id).map(Box::new)) - .collect::, MtprotoError>>()?; - let response: MessagesInvitedUsers = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesCreateChatRequest { - flags: 0, - users: Box::new(Vector::Vector(VectorConstructor { - field_0: users - .len() - .try_into() - .map_err(|_| MtprotoError::Message("too many group members".into()))?, - field_1: users, - })), - title: title.to_owned(), - ttl_period: None, - }, - )?; - let MessagesInvitedUsers::MessagesInvitedUsers(result) = &response; - super::management::cache_response_peers(state, &result.updates); - enqueue_response(state.snapshot.dc_id, &result.updates)?; - Ok(response) - }) - }) -} - -pub fn create_channel( - handle: u64, - title: &str, - about: &str, - kind: ChannelKind, -) -> Result { - validate_title(title)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - channel_request(title, about, kind), - )?; - super::management::cache_response_peers(state, &response); - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn invite_channel_members( - handle: u64, - chat_id: i64, - member_ids: &[i64], -) -> Result { - if member_ids.is_empty() { - return Err(MtprotoError::Message( - "at least one member is required".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let channel = channel_peer(state, chat_id)?; - let users = member_ids - .iter() - .map(|id| user_peer(state, *id).map(Box::new)) - .collect::, _>>()?; - let response: MessagesInvitedUsers = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsInviteToChannelRequest { - channel: Box::new(channel), - users: Box::new(Vector::Vector(VectorConstructor { - field_0: users - .len() - .try_into() - .map_err(|_| MtprotoError::Message("too many members".into()))?, - field_1: users, - })), - }, - )?; - let MessagesInvitedUsers::MessagesInvitedUsers(result) = &response; - enqueue_response(state.snapshot.dc_id, &result.updates)?; - Ok(response) - }) - }) -} - -pub fn add_group_member( - handle: u64, - chat_id: i64, - user_id: i64, - forward_limit: i32, -) -> Result { - if !(0..=100).contains(&forward_limit) { - return Err(MtprotoError::Message( - "forward history limit must be between 0 and 100".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let group_id = basic_group_id(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: MessagesInvitedUsers = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesAddChatUserRequest { - chat_id: group_id, - user_id: Box::new(user), - fwd_limit: forward_limit, - }, - )?; - let MessagesInvitedUsers::MessagesInvitedUsers(result) = &response; - enqueue_response(state.snapshot.dc_id, &result.updates)?; - Ok(response) - }) - }) -} - -pub fn remove_group_member( - handle: u64, - chat_id: i64, - user_id: i64, - revoke_history: bool, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let group_id = basic_group_id(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesDeleteChatUserRequest { - flags: u32::from(revoke_history), - revoke_history: flag(revoke_history), - chat_id: group_id, - user_id: Box::new(user), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_channel_admin( - handle: u64, - chat_id: i64, - user_id: i64, - rights: ChatAdminRights, - rank: Option, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let channel = channel_peer(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsEditAdminRequest { - flags: u32::from(rank.is_some()), - channel: Box::new(channel), - user_id: Box::new(user), - admin_rights: Box::new(rights.clone()), - rank: rank.clone(), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_group_admin( - handle: u64, - chat_id: i64, - user_id: i64, - is_admin: bool, -) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let group_id = basic_group_id(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatAdminRequest { - chat_id: group_id, - user_id: Box::new(user), - is_admin: Box::new(if is_admin { - Bool::BoolTrue(BoolTrueConstructor {}) - } else { - Bool::BoolFalse(BoolFalseConstructor {}) - }), - }, - )?; - confirm_change(response) - }) - }) -} - -/// The rights object controls ban/restriction/unban and its expiry. Telegram -/// remains authoritative about which restrictions the caller may grant. -pub fn set_channel_restrictions( - handle: u64, - chat_id: i64, - participant_id: i64, - rights: ChatBannedRights, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let channel = channel_peer(state, chat_id)?; - let participant = - input_peer_from_cached(require_usable_peer(&state.peers, participant_id)?); - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsEditBannedRequest { - channel: Box::new(channel), - participant: Box::new(participant), - banned_rights: Box::new(rights.clone()), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_title(handle: u64, chat_id: i64, title: &str) -> Result { - validate_title(title)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - let response: Updates = match peer.kind { - PeerKind::Chat => crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatTitleRequest { - chat_id: peer.id, - title: title.to_owned(), - }, - )?, - PeerKind::Channel => { - let channel = channel_peer(state, chat_id)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsEditTitleRequest { - channel: Box::new(channel), - title: title.to_owned(), - }, - )? - } - _ => { - return Err(MtprotoError::Message( - "a group or channel is required".into(), - )); - } - }; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_photo( - handle: u64, - chat_id: i64, - photo: InputChatPhoto, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - let response: Updates = match peer.kind { - PeerKind::Chat => crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatPhotoRequest { - chat_id: peer.id, - photo: Box::new(photo.clone()), - }, - )?, - PeerKind::Channel => { - let channel = channel_peer(state, chat_id)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsEditPhotoRequest { - channel: Box::new(channel), - photo: Box::new(photo.clone()), - }, - )? - } - _ => { - return Err(MtprotoError::Message( - "a group or channel is required".into(), - )); - } - }; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_about(handle: u64, chat_id: i64, about: &str) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatAboutRequest { - peer: Box::new(peer), - about: about.to_owned(), - }, - )?; - confirm_change(response) - }) - }) -} - -/// An empty username removes the current public username. -pub fn set_username(handle: u64, chat_id: i64, username: &str) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let channel = channel_peer(state, chat_id)?; - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsUpdateUsernameRequest { - channel: Box::new(channel), - username: username.to_owned(), - }, - )?; - confirm_change(response) - }) - }) -} - -pub fn set_default_restrictions( - handle: u64, - chat_id: i64, - rights: ChatBannedRights, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatDefaultBannedRightsRequest { - peer: Box::new(peer), - banned_rights: Box::new(rights.clone()), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -/// The caller supplies a fresh SRP proof. Eligibility and ownership-transfer -/// waiting periods are enforced by Telegram; this does not persist the proof. -pub fn transfer_ownership( - handle: u64, - chat_id: i64, - user_id: i64, - password: InputCheckPasswordSrp, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = group_peer(state, chat_id)?; - let user = user_peer(state, user_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesEditChatCreatorRequest { - peer: Box::new(peer), - user_id: Box::new(user), - password: Box::new(password.clone()), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -pub fn set_forum_enabled( - handle: u64, - chat_id: i64, - enabled: bool, - tabs: bool, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let channel = channel_peer(state, chat_id)?; - let boolean = |value| { - Box::new(if value { - Bool::BoolTrue(BoolTrueConstructor {}) - } else { - Bool::BoolFalse(BoolFalseConstructor {}) - }) - }; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsToggleForumRequest { - channel: Box::new(channel), - enabled: boolean(enabled), - tabs: boolean(tabs), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) - }) -} - -fn confirm_change(response: Bool) -> Result<(), MtprotoError> { - match response { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => Err(MtprotoError::Message("chat change was not applied".into())), - } -} - -pub(super) fn group_peer( - state: &super::ClientState, - chat_id: i64, -) -> Result { - let peer = require_usable_peer(&state.peers, chat_id)?; - if peer.kind == PeerKind::User { - return Err(MtprotoError::Message( - "a group or channel is required".into(), - )); - } - Ok(input_peer_from_cached(peer)) -} - -fn channel_peer(state: &super::ClientState, chat_id: i64) -> Result { - let peer = require_usable_peer(&state.peers, chat_id)?; - if peer.kind != PeerKind::Channel { - return Err(MtprotoError::Message( - "a channel or megagroup is required".into(), - )); - } - Ok(InputChannel::InputChannel(InputChannelConstructor { - channel_id: peer.id, - access_hash: peer.access_hash, - })) -} - -fn basic_group_id(state: &super::ClientState, chat_id: i64) -> Result { - let peer = require_usable_peer(&state.peers, chat_id)?; - if peer.kind != PeerKind::Chat { - return Err(MtprotoError::Message("a basic group is required".into())); - } - Ok(peer.id) -} - -pub(super) fn user_peer( - state: &super::ClientState, - user_id: i64, -) -> Result { - let peer = require_usable_peer(&state.peers, user_id)?; - if peer.kind != PeerKind::User { - return Err(MtprotoError::Message("a user peer is required".into())); - } - Ok(InputUser::InputUser(InputUserConstructor { - user_id: peer.id, - access_hash: peer.access_hash, - })) -} - -fn flag(enabled: bool) -> Option> { - enabled.then(|| Box::new(True::True(TrueConstructor {}))) -} - -fn validate_title(title: &str) -> Result<(), MtprotoError> { - if title.trim().is_empty() { - return Err(MtprotoError::Message("a chat title is required".into())); - } - Ok(()) -} - -fn channel_request(title: &str, about: &str, kind: ChannelKind) -> ChannelsCreateChannelRequest { - let broadcast = kind == ChannelKind::Broadcast; - let forum = kind == ChannelKind::Forum; - let flag = |enabled| { - if enabled { - Some(Box::new(True::True(TrueConstructor {}))) - } else { - None - } - }; - ChannelsCreateChannelRequest { - flags: u32::from(broadcast) | (u32::from(!broadcast) << 1) | (u32::from(forum) << 5), - broadcast: flag(broadcast), - megagroup: flag(!broadcast), - for_import: None, - forum: flag(forum), - title: title.to_owned(), - about: about.to_owned(), - geo_point: None, - address: None, - ttl_period: None, - } -} - -#[cfg(test)] -#[path = "../../tests/unit/client_group_management_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/lanes.rs b/native/mtproto-rs/src/client/lanes.rs deleted file mode 100644 index f9c0bd274..000000000 --- a/native/mtproto-rs/src/client/lanes.rs +++ /dev/null @@ -1,364 +0,0 @@ -use parking_lot::Mutex; -use tellers_mtproto_session::Snapshot; -use tellers_mtproto_transport::Connection; - -use crate::MtprotoError; -use crate::scheduler; - -use super::*; - -/// Maximum in-session file pipeline; thumbnails and background work use less. -pub(crate) const DEFAULT_PIPELINE_PARTS: usize = 4; - -pub(crate) const MAX_PIPELINE_PARTS: usize = 8; - -pub(crate) fn pipeline_parts() -> usize { - crate::transfer_policy::current().pipeline_parts() -} - -pub fn set_pipeline_parts(parts: usize) { - let parts = parts.clamp(1, MAX_PIPELINE_PARTS); - crate::transfer_policy::default_policy().assign_pipeline_parts(parts); - crate::client::visit_policies(|policy| policy.follow_pipeline_parts(parts)); -} - -/// Main RPCs and updates share one TCP and one sequence-number space. -/// Additional main sessions require explicit server permission (tmp_sessions). -pub(crate) struct SessionIo { - pub(crate) pending_push: crate::update_buffer::PushBuffer, - pub(crate) last_difference: Option, - pub(crate) snapshot: Snapshot, - pub(crate) transport: Option, - /// Unix time when this lane's temporary auth key expires. Zero means none. - pub(crate) temp_expires_at: i32, - /// Key this lane's temp key was bound from. Not the key on the wire. - pub(crate) perm_key: Option>, -} - -/// A session plus the gate that decides which request class gets it next. -pub(crate) struct Lane { - pub(crate) gate: crate::scheduler::LaneGate, - pub(crate) io: Mutex, -} - -pub(crate) struct LaneLease<'a> { - pub(crate) _gate: crate::scheduler::LaneGuard<'a>, - pub(crate) io: parking_lot::MutexGuard<'a, SessionIo>, -} - -impl std::ops::Deref for LaneLease<'_> { - type Target = SessionIo; - fn deref(&self) -> &SessionIo { - &self.io - } -} - -impl std::ops::DerefMut for LaneLease<'_> { - fn deref_mut(&mut self) -> &mut SessionIo { - &mut self.io - } -} - -impl Lane { - pub(crate) fn new(io: SessionIo) -> Self { - Self { - gate: crate::scheduler::LaneGate::new(), - io: Mutex::new(io), - } - } - - pub(crate) fn try_lease(&self, class: crate::scheduler::RequestClass) -> Option> { - let gate = self.gate.try_acquire(class)?; - Some(LaneLease { - _gate: gate, - io: self.io.lock(), - }) - } - - pub(crate) fn lease( - &self, - class: crate::scheduler::RequestClass, - ) -> Result, MtprotoError> { - let gate = self.gate.acquire(class)?; - Ok(LaneLease { - _gate: gate, - io: self.io.lock(), - }) - } -} - -pub(crate) fn lock_request_lane<'a>( - lane: &'a Mutex, - op: &'static str, -) -> Result, MtprotoError> { - let wait = crate::perf::span(op); - loop { - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - if let Some(guard) = lane.try_lock_for(std::time::Duration::from_millis(50)) { - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - drop(wait); - return Ok(guard); - } - } -} - -/// Read-lane lease; the class is resolved through `scheduler::family`. -pub(crate) fn acquire_read_lane(client: &Client) -> Result, MtprotoError> { - let class = scheduler::current_class(); - debug_assert_eq!(scheduler::family(class), scheduler::LaneFamily::Read); - match scheduler::family(class) { - scheduler::LaneFamily::Read => acquire_lane_family( - scheduler::read_lane_order(), - class, - "lane_wait.read", - "read_lane_wait", - |index| &client.rpc[index], - ), - scheduler::LaneFamily::Media => Err(MtprotoError::Message( - "media class on the read family".into(), - )), - } -} - -pub(crate) fn lock_media_lane(client: &Client) -> Result, MtprotoError> { - let class = scheduler::current_class(); - match scheduler::family(class) { - scheduler::LaneFamily::Media => { - let cap = client.policy.media_lanes(); - let waiter = client.media_gate.enqueue(class)?; - loop { - crate::request_control::check() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if !waiter.is_next() { - waiter.wait(); - continue; - } - let open = client.media_open.load(Ordering::Acquire).min(cap); - for lane in client.media.iter().take(open) { - if let Some(lease) = lane.try_lease(class) { - return Ok(lease); - } - } - if open < cap { - let _ = client.media_open.compare_exchange( - open, - open + 1, - Ordering::AcqRel, - Ordering::Acquire, - ); - continue; - } - waiter.wait(); - } - } - scheduler::LaneFamily::Read => Err(MtprotoError::Message( - "read class on the media family".into(), - )), - } -} - -pub(crate) fn close_idle_file_transports(client: &Client, now: std::time::Instant) -> bool { - let mut pending = false; - let mut close = |lane: &Mutex| { - let Some(mut io) = lane.try_lock() else { - pending = true; - return; - }; - if let Some(transport) = io.transport.as_ref() { - if now.saturating_duration_since(transport.last_io) - >= crate::media::DOWNLOAD_SESSION_IDLE - { - if let Some(mut transport) = io.transport.take() { - let _ = transport.conn.close(); - } - } else { - pending = true; - } - } - }; - for lane in &client.media { - close(&lane.io); - } - for lane in &client.upload { - close(&lane.io); - } - if !pending { - client.media_open.store(1, Ordering::Release); - } - pending -} - -/// Prefer a free temporary-key upload socket. Lane 0 is the permanent-key fallback. -pub(crate) fn lock_upload_lane(client: &Client) -> parking_lot::MutexGuard<'_, SessionIo> { - let now = crate::auth::bind_temp::unix_now(); - for lane in client.upload.iter().skip(1) { - if let Some(io) = lane.io.try_lock() { - if io.temp_expires_at > now { - return io; - } - } - } - client.upload[0].io.lock() -} - -pub(crate) fn schedule_file_cleanup(client: &Arc) { - if client.file_cleanup_running.swap(true, Ordering::AcqRel) { - return; - } - let weak = Arc::downgrade(client); - let spawned = std::thread::Builder::new() - .name("mtproto-maintenance".into()) - .spawn(move || { - let ack_due = std::time::Instant::now() + crate::rpc::ACK_DELAY; - loop { - let Some(client) = weak.upgrade() else { - break; - }; - if client - .connections - .wait(crate::media::DOWNLOAD_SESSION_IDLE) - .is_err() - { - client.file_cleanup_running.store(false, Ordering::Release); - break; - } - let now = std::time::Instant::now(); - let files_pending = close_idle_file_transports(&client, now); - if flush_idle_main_acks(&client, now, ack_due) || files_pending { - continue; - } - client.file_cleanup_running.store(false, Ordering::Release); - // A transfer may have finished while the worker cleared its flag. - if (close_idle_file_transports(&client, std::time::Instant::now()) - || flush_idle_main_acks(&client, std::time::Instant::now(), ack_due)) - && !client.file_cleanup_running.swap(true, Ordering::AcqRel) - { - continue; - } - break; - } - }); - if spawned.is_err() { - client.file_cleanup_running.store(false, Ordering::Release); - } -} - -pub(crate) fn flush_idle_main_acks( - client: &Client, - now: std::time::Instant, - due: std::time::Instant, -) -> bool { - let Some(_gate) = client - .main_gate - .try_acquire(scheduler::RequestClass::BackgroundRead) - else { - return true; - }; - let Some(mut io) = client.main.try_lock() else { - return true; - }; - if io.snapshot.pending_acknowledgements.is_empty() || io.transport.is_none() { - return false; - } - if now < due { - return true; - } - let mut transport = io.transport.take().unwrap(); - if crate::tcp::with_connection_control(&client.connections, || { - crate::rpc::flush_pending_acks(&mut io.snapshot, &mut transport) - }) - .is_ok() - { - io.transport = Some(transport); - } - false -} - -/// Second file TCP if a lane is free. The held first lease makes that gate -/// busy, so this never returns the same lane. Caller must bind a separate temp key. -pub(crate) fn try_lock_second_media_lane(client: &Client) -> Option> { - let class = scheduler::current_class(); - if scheduler::family(class) != scheduler::LaneFamily::Media { - return None; - } - for index in scheduler::media_lane_order() { - if let Some(lease) = client - .media - .get(index) - .and_then(|lane| lane.try_lease(class)) - { - return Some(lease); - } - } - None -} - -static VISIBLE_TRANSFER_WAITERS: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); - -pub(crate) fn visible_transfer_waiters() -> usize { - VISIBLE_TRANSFER_WAITERS.load(std::sync::atomic::Ordering::Acquire) -} - -#[cfg(test)] -pub(crate) fn set_visible_transfer_waiters_for_test(count: usize) { - VISIBLE_TRANSFER_WAITERS.store(count, std::sync::atomic::Ordering::Release); -} - -struct VisibleWaitGuard(bool); - -impl Drop for VisibleWaitGuard { - fn drop(&mut self) { - if self.0 { - VISIBLE_TRANSFER_WAITERS.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); - } - } -} - -pub(crate) fn acquire_lane_family<'a>( - order: impl Iterator + Clone, - class: scheduler::RequestClass, - span: &'static str, - wait_counter: &'static str, - lane_at: impl Fn(usize) -> &'a Lane, -) -> Result, MtprotoError> { - let _visible = if class == scheduler::RequestClass::InteractiveMedia { - VISIBLE_TRANSFER_WAITERS.fetch_add(1, std::sync::atomic::Ordering::AcqRel); - VisibleWaitGuard(true) - } else { - VisibleWaitGuard(false) - }; - let wait = crate::perf::span(span); - let mut waiters = Vec::new(); - for index in order.clone() { - let lane = lane_at(index); - waiters.push((lane, lane.gate.enqueue(class)?)); - } - if waiters.is_empty() { - return Err(MtprotoError::Message("no authorized request lane".into())); - } - let mut counted = false; - loop { - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - if let Some(promoted) = crate::request_control::promoted_class() { - for (_, waiter) in &waiters { - waiter.promote(promoted); - } - } - for (lane, waiter) in &waiters { - if let Some(gate) = waiter.try_acquire() { - let lease = LaneLease { - _gate: gate, - io: lane.io.lock(), - }; - drop(wait); - return Ok(lease); - } - } - if !counted { - counted = true; - crate::perf::count(wait_counter); - } - waiters[0].1.wait(); - } -} diff --git a/native/mtproto-rs/src/client/management.rs b/native/mtproto-rs/src/client/management.rs deleted file mode 100644 index d75dde095..000000000 --- a/native/mtproto-rs/src/client/management.rs +++ /dev/null @@ -1,275 +0,0 @@ -use tellers_mtproto::latest::api::{ - Bool, ChannelsDeleteHistoryRequest, ChannelsLeaveChannelRequest, FoldersEditPeerFoldersRequest, - InputChannel, InputChannelConstructor, InputDialogPeer, InputDialogPeerConstructor, - InputFolderPeer, InputFolderPeerConstructor, InputPeer, InputUser, InputUserSelfConstructor, - MessagesAffectedHistory, MessagesDeleteChatUserRequest, MessagesDeleteHistoryRequest, - MessagesToggleDialogPinRequest, MessagesUpdatePinnedMessageRequest, True, TrueConstructor, - Updates, UpdatesTooLongConstructor, Vector, VectorConstructor, -}; - -use crate::MtprotoError; -use crate::peers::{PeerKind, input_peer_from_cached, require_usable_peer}; - -use super::{call_with_migrate, with_client_mut}; - -/// Pins or unpins a message. `one_sided` applies only to private conversations. -pub fn pin_message( - handle: u64, - chat_id: i64, - message_id: i32, - pinned: bool, - silent: bool, - one_sided: bool, -) -> Result<(), MtprotoError> { - if message_id <= 0 { - return Err(MtprotoError::Message("invalid message id".into())); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - if one_sided && chat_id < 0 { - return Err(MtprotoError::Message( - "one-sided pins require a user peer".into(), - )); - } - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - pin_request( - input_peer_from_cached(peer), - message_id, - pinned, - silent, - one_sided, - ), - )?; - enqueue_response(state.snapshot.dc_id, &response) - }) - }) -} - -pub fn pin_chat(handle: u64, chat_id: i64, pinned: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesToggleDialogPinRequest { - flags: u32::from(pinned), - pinned: flag(pinned), - peer: Box::new(InputDialogPeer::InputDialogPeer( - InputDialogPeerConstructor { - peer: Box::new(input_peer_from_cached(peer)), - }, - )), - }, - )?; - match response { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => { - Err(MtprotoError::Message("dialog pin was not applied".into())) - } - } - }) - }) -} - -pub fn archive_chat(handle: u64, chat_id: i64, archived: bool) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - FoldersEditPeerFoldersRequest { - folder_peers: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(InputFolderPeer::InputFolderPeer( - InputFolderPeerConstructor { - peer: Box::new(input_peer_from_cached(peer)), - folder_id: i32::from(archived), - }, - ))], - })), - }, - )?; - enqueue_response(state.snapshot.dc_id, &response) - }) - }) -} - -/// Leaves a channel/megagroup or basic group without destroying it. -pub fn leave_chat(handle: u64, chat_id: i64) -> Result<(), MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - let response: Updates = match peer.kind { - PeerKind::Channel => crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsLeaveChannelRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: peer.id, - access_hash: peer.access_hash, - })), - }, - )?, - PeerKind::Chat => crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesDeleteChatUserRequest { - flags: 0, - revoke_history: None, - chat_id: peer.id, - user_id: Box::new(InputUser::InputUserSelf(InputUserSelfConstructor {})), - }, - )?, - _ => return Err(MtprotoError::Message("cannot leave a user dialog".into())), - }; - enqueue_response(state.snapshot.dc_id, &response) - }) - }) -} - -/// A nonzero server offset requires another call with the same history boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HistoryDeletion { - pub more: bool, -} - -pub fn clear_history( - handle: u64, - chat_id: i64, - max_id: i32, - revoke: bool, -) -> Result { - change_history(handle, chat_id, max_id, revoke, true) -} - -/// Removes a user/basic-group dialog's history; leaving membership is separate. -pub fn delete_dialog( - handle: u64, - chat_id: i64, - max_id: i32, - revoke: bool, -) -> Result { - change_history(handle, chat_id, max_id, revoke, false) -} - -fn change_history( - handle: u64, - chat_id: i64, - max_id: i32, - revoke: bool, - just_clear: bool, -) -> Result { - if max_id <= 0 { - return Err(MtprotoError::Message( - "an explicit positive history boundary is required".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = require_usable_peer(&state.peers, chat_id)?; - if peer.kind == PeerKind::Channel { - if !just_clear { - return Err(MtprotoError::Message( - "leave a channel instead of deleting its dialog history".into(), - )); - } - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsDeleteHistoryRequest { - flags: u32::from(revoke), - for_everyone: flag(revoke), - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: peer.id, - access_hash: peer.access_hash, - })), - max_id, - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - return Ok(HistoryDeletion { more: false }); - } - let response: MessagesAffectedHistory = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - history_request(input_peer_from_cached(peer), max_id, revoke, just_clear), - )?; - let MessagesAffectedHistory::MessagesAffectedHistory(affected) = response; - // Recover through getDifference rather than advancing past unseen - // updates from an affectedHistory response without journaling them. - enqueue_response( - state.snapshot.dc_id, - &Updates::UpdatesTooLong(UpdatesTooLongConstructor {}), - )?; - Ok(HistoryDeletion { - more: affected.offset > 0, - }) - }) - }) -} - -fn history_request( - peer: InputPeer, - max_id: i32, - revoke: bool, - just_clear: bool, -) -> MessagesDeleteHistoryRequest { - MessagesDeleteHistoryRequest { - flags: u32::from(just_clear) | (u32::from(revoke) << 1), - just_clear: flag(just_clear), - revoke: flag(revoke), - peer: Box::new(peer), - max_id, - min_date: None, - max_date: None, - } -} - -fn flag(enabled: bool) -> Option> { - enabled.then(|| Box::new(True::True(TrueConstructor {}))) -} - -fn pin_request( - peer: InputPeer, - message_id: i32, - pinned: bool, - silent: bool, - one_sided: bool, -) -> MessagesUpdatePinnedMessageRequest { - MessagesUpdatePinnedMessageRequest { - flags: u32::from(silent) | (u32::from(!pinned) << 1) | (u32::from(one_sided) << 2), - silent: flag(silent), - unpin: flag(!pinned), - pm_oneside: flag(one_sided), - peer: Box::new(peer), - id: message_id, - } -} - -// RPC-result updates must pass through the normal cursor/journal pipeline, not -// advance cursors here or disappear when the main transport returns to its lane. -pub(super) fn cache_response_peers(state: &mut super::ClientState, updates: &Updates) { - let (users, chats) = super::membership::updates_users_chats(updates); - crate::dialogs::cache_from_users_chats( - &mut state.peers, - &mut state.media, - users.into_iter(), - chats.into_iter(), - ); -} - -pub(crate) fn enqueue_response(dc_id: i32, updates: &Updates) -> Result<(), MtprotoError> { - let mut encoder = tellers_mtproto::codec::Encoder::new(); - tellers_mtproto::codec::TlEncode::encode(updates, &mut encoder) - .map_err(|error| MtprotoError::Message(error.to_string()))?; - crate::rpc::queue_rpc_updates(dc_id, encoder.into_bytes()) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_management_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs deleted file mode 100644 index 3456a9025..000000000 --- a/native/mtproto-rs/src/client/media_download.rs +++ /dev/null @@ -1,1088 +0,0 @@ -use std::path::Path; - -use tellers_mtproto::latest::api::{CdnConfig, CdnPublicKey, FileHash, UploadCdnFile, Vector}; -use tellers_mtproto_session::OsRandom; -use tellers_mtproto_transport::PaddedIntermediate; - -use crate::MtprotoError; -use crate::api_invoke; -use crate::auth::auth_key::create_auth_key_with_pem; -use crate::dialogs; -use crate::media; -use crate::peers::vector_boxed_items; -use crate::tcp; - -use super::*; - -#[path = "peer_avatar.rs"] -mod peer_avatar; - -use peer_avatar::reindex_peer_avatar; - -#[cfg(test)] -#[path = "../../tests/unit/cdn_transfer_tests.rs"] -mod cdn_transfer_tests; - -include!("media_lane.rs"); - -fn home_lease_ok(state: &ClientState, home_session_id: i64) -> bool { - state.snapshot.session_id == home_session_id - || state - .permanent - .as_ref() - .is_some_and(|snapshot| snapshot.session_id == home_session_id) -} - -fn download_temp_ready( - io: &super::lanes::SessionIo, - client: &Client, - dc: i32, - home_key: Option<&[u8]>, -) -> bool { - if !crate::auth::bind_temp::temp_key_valid(&io.snapshot, io.temp_expires_at) - || io.snapshot.dc_id != dc - { - return false; - } - let Some(lane_key) = io.snapshot.auth_key.as_deref() else { - return false; - }; - if Some(lane_key) == home_key { - return false; - } - let Some(perm) = io.perm_key.as_deref().filter(|key| key.len() == 256) else { - return false; - }; - let data = client.data.lock(); - crate::auth::bind_temp::dc_permanent_key(&data, dc) == Some(perm) -} - -fn stage_for(dest: &Path, offset: Option) -> Result { - if offset.is_none() { - media::StagedDownload::resume_or_create(dest) - } else { - media::StagedDownload::new(dest) - } -} - -fn finish_staged( - staged: media::StagedDownload, - result: Result, - client: &Client, - session_id: i64, - dest: &Path, -) -> Result { - match result { - Ok(_) => publish_media(client, session_id, staged, dest), - Err(err) if media::keep_partial(&err) || media::stopped_download(&err) => { - staged.keep(); - Err(err) - } - Err(err) => Err(err), - } -} - -pub(crate) struct MediaLanePreparation { - request_id: u64, - worker: std::thread::JoinHandle, -} - -impl MediaLanePreparation { - pub(crate) fn start(prepare: impl FnOnce() -> bool + Send + 'static) -> Self { - let request_id = crate::request_control::create(); - let worker = std::thread::spawn(move || { - struct Release(u64); - impl Drop for Release { - fn drop(&mut self) { - crate::request_control::release(self.0); - } - } - let _release = Release(request_id); - crate::request_control::with_shared_binding(request_id, prepare) - }); - Self { request_id, worker } - } - - pub(crate) fn finished(&self) -> bool { - self.worker.is_finished() - } -} - -impl Drop for MediaLanePreparation { - fn drop(&mut self) { - crate::request_control::cancel(self.request_id); - } -} - -pub fn download_wallpaper( - handle: u64, - id: i64, - access_hash: i64, - dest_path: String, -) -> Result { - let client = get_client(handle)?; - let (api_id, home, media) = with_client_mut(handle, |state| { - let media = call_with_migrate(state, |state| { - crate::media::wallpaper_rpc::wallpaper_media( - &mut state.snapshot, - state.api_id, - id, - access_hash, - ) - })?; - persist(state)?; - Ok((state.api_id, state.snapshot.clone(), media)) - })?; - match download_media_on_lane( - handle, - &client, - &home, - api_id, - &media, - Path::new(&dest_path), - ) { - Err(error) if media::is_file_reference_error(&error) => { - let refreshed = with_client_mut(handle, |state| { - if !home_lease_ok(state, home.session_id) { - return Err(expired_session_lease()); - } - let media = call_with_migrate(state, |state| { - crate::media::wallpaper_rpc::wallpaper_media( - &mut state.snapshot, - state.api_id, - id, - access_hash, - ) - })?; - persist(state)?; - Ok(media) - })?; - download_media_on_lane( - handle, - &client, - &home, - api_id, - &refreshed, - Path::new(&dest_path), - ) - } - result => result, - } -} - -pub(crate) fn is_peer_refreshable(err: &MtprotoError) -> bool { - matches!( - err, - MtprotoError::Message(message) - if message.contains("unknown peer") || message.contains("PEER_ID_INVALID") - ) -} - -pub(crate) fn refresh_dialogs(state: &mut ClientState) -> Result<(), MtprotoError> { - dialogs::get_dialogs( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - &mut state.channel_pts, - 0, - 0, - 0, - None, - ) - .map(|_| ()) -} - -pub(crate) fn refresh_file_source( - state: &mut ClientState, - chat_id: i64, - message_id: i32, -) -> Result<(), MtprotoError> { - if let Some(id) = media::scheduled_message_id(message_id) { - return super::scheduled::refresh_media(state, chat_id, id); - } - let before = state - .media - .get(&(chat_id, message_id)) - .map(|media| media::location_token(&media.location)); - if let Some(url) = crate::instant_view::media_refresh_url(&state.media, chat_id, message_id) { - let _ = crate::instant_view::get_web_page( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - url, - 0, - ); - return Ok(()); - } - if message_id == crate::instant_view::INSTANT_VIEW_MEDIA_MSG { - return Ok(()); - } - if message_id == 0 { - if !state.media.contains_key(&(chat_id, message_id)) { - reindex_peer_avatar(state, chat_id); - return Ok(()); - } - let peer_photo = state - .media - .get(&(chat_id, message_id)) - .is_some_and(|media| matches!(media.location, media::MediaLocation::PeerPhoto { .. })); - if peer_photo { - let _ = refresh_dialogs(state); - let _ = crate::profile::get_profile( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - state.user_id, - chat_id, - ); - media::fill_zero_peer_photo_hashes(&mut state.media, &state.peers); - } else if state - .media - .get(&(chat_id, message_id)) - .is_some_and(|media| matches!(media.location, media::MediaLocation::Document { .. })) - { - let sticker = state - .media - .get(&(chat_id, message_id)) - .and_then(sticker_set_of); - if let Some((set_id, access_hash)) = sticker { - // Pack stickers have no message id. messages.getStickerSet is the - // constructor that reissues their file_reference. - // https://core.telegram.org/api/file-references - let _ = crate::messages::sticker_rpc::get_sticker_set( - &mut state.snapshot, - state.api_id, - &mut state.media, - set_id, - access_hash, - ); - } else { - let _ = crate::client::extras_rpc::get_saved_gifs( - &mut state.snapshot, - state.api_id, - &mut state.media, - 0, - ); - } - } else if state - .media - .get(&(chat_id, message_id)) - .is_some_and(|media| matches!(media.location, media::MediaLocation::Photo { .. })) - { - refresh_last_inline(state); - } - return Ok(()); - } - dialogs::refresh_message_media( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - chat_id, - message_id, - )?; - let after = state - .media - .get(&(chat_id, message_id)) - .map(|media| media::location_token(&media.location)); - if before.is_none() || before == after { - let _ = history_with_peer_refresh(state, chat_id, 3, message_id, 0, -1); - } - Ok(()) -} - -pub(crate) fn refresh_last_inline(state: &mut ClientState) { - let Some(query) = state.last_inline.clone() else { - return; - }; - if query - .last_refresh - .is_some_and(|at| at.elapsed() < std::time::Duration::from_secs(2)) - { - return; - } - if crate::messages::inline_rpc::get_inline_bot_results( - &mut state.snapshot, - state.api_id, - &mut state.peers, - &mut state.media, - query.chat_id, - query.bot_id, - &query.query, - &query.offset, - ) - .is_ok() - { - if let Some(stored) = state.last_inline.as_mut() { - stored.last_refresh = Some(std::time::Instant::now()); - } - } -} - -pub fn download_message_media( - handle: u64, - chat_id: i64, - message_id: i32, - dest_path: String, - kind: media::MediaDownloadKind, -) -> Result { - download_message_media_range(handle, chat_id, message_id, dest_path, kind, None) -} - -/// Shared-index read of an inline stripped/cached JPEG. No RPC and no main-session lock. -pub fn peek_message_inline_thumb(handle: u64, chat_id: i64, message_id: i32) -> Option> { - let client = get_client(handle).ok()?; - let data = client.data.lock(); - let indexed = data.media.get(&(chat_id, message_id))?; - media::inline_thumb_jpeg(indexed) -} - -/// Shared-index read of an inline stripped/cached avatar JPEG for a peer (user or chat). -pub fn peek_peer_inline_thumb(handle: u64, peer_id: i64) -> Option> { - peek_message_inline_thumb(handle, peer_id, 0) -} - -pub(crate) fn indexed_media_for_download( - data: &ClientData, - chat_id: i64, - message_id: i32, - kind: media::MediaDownloadKind, -) -> Option> { - let indexed = data.media.get(&(chat_id, message_id))?; - if media::inline_payload_missing(indexed) { - return None; - } - if data.session_dead - || data.user_id.is_none() - || media::peer_photo_needs_hash(indexed) - || (message_id == 0 - && kind == media::MediaDownloadKind::Full - && data.peers.contains_key(&chat_id) - && media::needs_video_avatar_upgrade(indexed)) - { - return None; - } - let home = home_fork_source(data)?; - Some(media::media_for_download(indexed, kind).map(|media| (data.api_id, home, media))) -} - -pub fn download_message_media_range( - handle: u64, - chat_id: i64, - message_id: i32, - dest_path: String, - kind: media::MediaDownloadKind, - offset: Option, -) -> Result { - let client = get_client(handle)?; - let _policy = crate::transfer_policy::bind(std::sync::Arc::clone(&client.policy)); - schedule_transfer_config(handle); - let profile = match kind { - media::MediaDownloadKind::Thumb => media::DownloadProfile::Thumb, - media::MediaDownloadKind::Full if offset.is_some() => media::DownloadProfile::Playing, - _ if media::request_profile().is_some() => media::request_profile().unwrap(), - media::MediaDownloadKind::Display => media::DownloadProfile::Visible, - media::MediaDownloadKind::Full - if crate::scheduler::current_class() - == crate::scheduler::RequestClass::BackgroundMedia => - { - media::DownloadProfile::Background - } - media::MediaDownloadKind::Full => media::DownloadProfile::User, - }; - let _window = media::bind_download_profile(profile); - let cached = { - let _span = crate::perf::span("media_lookup_shared_index"); - let data = client.data.lock(); - indexed_media_for_download(&data, chat_id, message_id, kind) - }; - let (api_id, home, media) = if let Some(cached) = cached { - crate::perf::count("media.lookup.shared_index"); - cached? - } else { - let _span = crate::perf::span("media_lookup_main_fallback"); - crate::perf::count("media.lookup.main_fallback"); - with_client_mut(handle, |state| { - ensure_ready(state)?; - if !state.media.contains_key(&(chat_id, message_id)) { - if message_id == crate::instant_view::INSTANT_VIEW_MEDIA_MSG { - // Indexed only by messages.getWebPage as (media_id, -1). - // Never treat a photo/document id as a dialog for history. - } else if message_id == 0 { - // `(document_id, 0)` is also a sticker or GIF. peer_avatar_fetch - // skips those ids so get_profile cannot overwrite a user avatar. - reindex_peer_avatar(state, chat_id); - } else { - let _ = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - refresh_file_source(state, chat_id, message_id) - }) - }); - if !state.media.contains_key(&(chat_id, message_id)) - && media::scheduled_message_id(message_id).is_none() - { - let _ = call_with_migrate(state, |state| { - history_with_peer_refresh(state, chat_id, 3, message_id, 0, -1) - }); - } - } - } - if state - .media - .get(&(chat_id, message_id)) - .is_some_and(media::peer_photo_needs_hash) - { - let _ = call_with_migrate(state, refresh_dialogs); - media::fill_zero_peer_photo_hashes(&mut state.media, &state.peers); - } - if message_id == 0 - && kind == media::MediaDownloadKind::Full - && state.peers.contains_key(&chat_id) - && state - .media - .get(&(chat_id, 0)) - .is_some_and(media::needs_video_avatar_upgrade) - { - let _ = call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - crate::profile::get_profile( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - state.user_id, - chat_id, - ) - }) - }); - } - let mut indexed = state - .media - .get(&(chat_id, message_id)) - .filter(|media| !media::inline_payload_missing(media)) - .cloned(); - if indexed.is_none() { - // Local index gap (cold history, cleared cache, peer hash expired): - // re-fetch this message's media instead of failing the download outright. - let _ = crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - refresh_file_source(state, chat_id, message_id) - }) - }) - }); - indexed = state.media.get(&(chat_id, message_id)).cloned(); - } - let indexed = indexed.ok_or_else(|| { - MtprotoError::Message(format!("no media for chat {chat_id} message {message_id}")) - })?; - Ok(( - state.api_id, - state.snapshot.clone(), - media::media_for_download(&indexed, kind)?, - )) - })? - }; - let dest = Path::new(&dest_path); - let first = download_media_range_on_lane(handle, &client, &home, api_id, &media, dest, offset); - match first { - Ok(path) => Ok(path), - Err(err) - if kind == media::MediaDownloadKind::Display && media::is_file_id_invalid(&err) => - { - Err(MtprotoError::Message("no display size".into())) - } - Err(err) - if media::is_file_reference_error(&err) - || thumb_file_id_can_refresh(kind, &media, message_id, &err) => - { - let refreshed = with_client_mut(handle, |state| { - if !home_lease_ok(state, home.session_id) { - return Err(expired_session_lease()); - } - crate::rpc::with_rpc_timeout_secs(20, || { - call_with_migrate(state, |state| { - refresh_file_source(state, chat_id, message_id) - }) - })?; - persist(state)?; - let indexed = state.media.get(&(chat_id, message_id)).cloned().ok_or_else( - || { - MtprotoError::Message(format!( - "no media after file-ref refresh for chat {chat_id} message {message_id}" - )) - }, - )?; - media::media_for_download(&indexed, kind) - }); - let Ok(new_media) = refreshed else { - return Err(err); - }; - // Always retry getFile once after the source refetch, even if the - // token looks unchanged (server may have renewed the same bytes). - // https://core.telegram.org/api/file-references - match download_thumb_with_size_fallback( - handle, &client, &home, api_id, chat_id, message_id, kind, &new_media, dest, offset, - ) { - Err(retry_err) - if kind == media::MediaDownloadKind::Thumb - && media::is_file_id_invalid(&retry_err) => - { - Err(MtprotoError::Message("no downloadable thumb".into())) - } - other => other, - } - } - Err(err) if kind == media::MediaDownloadKind::Thumb && media::is_file_id_invalid(&err) => { - match try_alternate_thumb_sizes( - handle, &client, &home, api_id, chat_id, message_id, &media, dest, offset, - ) { - Err(retry_err) if media::is_file_id_invalid(&retry_err) => { - Err(MtprotoError::Message("no downloadable thumb".into())) - } - other => other, - } - } - Err(err) - if kind == media::MediaDownloadKind::Full - && media::is_video_avatar(&media) - && media::is_file_id_invalid(&err) => - { - try_alternate_video_avatar_sizes( - handle, &client, &home, api_id, chat_id, message_id, &media, dest, offset, - ) - } - Err(err) => Err(err), - } -} - -pub fn download_custom_emoji( - handle: u64, - document_id: i64, - dest_path: String, -) -> Result { - let client = get_client(handle)?; - let (api_id, fallback, media) = with_client_mut(handle, |state| { - ensure_ready(state)?; - if let Some(indexed) = state.media.get(&(document_id, 0)).cloned() { - return Ok((state.api_id, state.snapshot.clone(), indexed)); - } - let indexed = index_missing_custom_emoji(state, document_id)?; - Ok((state.api_id, state.snapshot.clone(), indexed)) - })?; - // A bound temp key leaves snapshot.session_id != home_session_id. Media - // leases require the permanent id, the same source message downloads use. - let home = { - let data = client.data.lock(); - home_fork_source(&data).unwrap_or(fallback) - }; - match download_media_on_lane( - handle, - &client, - &home, - api_id, - &media, - Path::new(&dest_path), - ) { - Err(error) if media::is_file_reference_error(&error) => { - let refreshed = with_client_mut(handle, |state| { - if !home_lease_ok(state, home.session_id) { - return Err(expired_session_lease()); - } - refresh_document_file_reference(state, document_id)?; - persist(state)?; - state.media.get(&(document_id, 0)).cloned().ok_or_else(|| { - MtprotoError::Message(format!( - "no media after file-ref refresh for document {document_id}" - )) - }) - })?; - download_media_on_lane( - handle, - &client, - &home, - api_id, - &refreshed, - Path::new(&dest_path), - ) - } - result => result, - } -} - -/// Re-fetch the constructor that issued this document's `file_reference`. -/// Stickers come from `messages.getStickerSet`; custom emoji and other -/// documents come from `messages.getCustomEmojiDocuments`. -/// Pack stickers carry the set that can reissue `file_reference`. Saved GIFs do not. -pub(crate) fn sticker_set_of(media: &media::MediaRef) -> Option<(i64, i64)> { - media.sticker_set_id.zip(media.sticker_set_access_hash) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum CustomEmojiDocumentSource { - StickerSet { set_id: i64, access_hash: i64 }, - CustomEmojiDocuments, -} - -pub(crate) fn custom_emoji_document_source( - index: &media::MediaIndex, - document_id: i64, -) -> CustomEmojiDocumentSource { - match sticker_set_for_document(index, document_id) { - Some((set_id, access_hash)) => CustomEmojiDocumentSource::StickerSet { - set_id, - access_hash, - }, - None => CustomEmojiDocumentSource::CustomEmojiDocuments, - } -} - -fn document_location_id(item: &media::MediaRef) -> Option { - match item.location { - media::MediaLocation::Document { id, .. } => Some(id), - _ => None, - } -} - -fn sticker_set_for_document(index: &media::MediaIndex, document_id: i64) -> Option<(i64, i64)> { - if let Some(set) = index.get(&(document_id, 0)).and_then(sticker_set_of) { - return Some(set); - } - index.iter().find_map(|(&(key_id, key_msg), item)| { - let same_document = (key_id == document_id && key_msg == 0) - || document_location_id(item) == Some(document_id); - if same_document { - sticker_set_of(item) - } else { - None - } - }) -} - -fn index_missing_custom_emoji( - state: &mut ClientState, - document_id: i64, -) -> Result { - match custom_emoji_document_source(&state.media, document_id) { - CustomEmojiDocumentSource::StickerSet { - set_id, - access_hash, - } => { - crate::messages::sticker_rpc::get_sticker_set( - &mut state.snapshot, - state.api_id, - &mut state.media, - set_id, - access_hash, - )?; - persist(state)?; - state.media.get(&(document_id, 0)).cloned().ok_or_else(|| { - MtprotoError::Message(format!( - "no sticker document {document_id} after getStickerSet" - )) - }) - } - CustomEmojiDocumentSource::CustomEmojiDocuments => { - let indexed = call_with_migrate(state, |state| { - media::fetch_custom_emoji(&mut state.snapshot, state.api_id, document_id) - })?; - media::insert_media(&mut state.media, (document_id, 0), indexed.clone()); - persist(state)?; - Ok(indexed) - } - } -} - -fn refresh_document_file_reference( - state: &mut ClientState, - document_id: i64, -) -> Result<(), MtprotoError> { - match custom_emoji_document_source(&state.media, document_id) { - CustomEmojiDocumentSource::StickerSet { - set_id, - access_hash, - } => { - crate::messages::sticker_rpc::get_sticker_set( - &mut state.snapshot, - state.api_id, - &mut state.media, - set_id, - access_hash, - )?; - Ok(()) - } - CustomEmojiDocumentSource::CustomEmojiDocuments => { - let indexed = call_with_migrate(state, |state| { - media::fetch_custom_emoji(&mut state.snapshot, state.api_id, document_id) - })?; - media::insert_media(&mut state.media, (document_id, 0), indexed); - Ok(()) - } - } -} - -pub(crate) fn download_media_on_lane( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - media: &media::MediaRef, - dest: &Path, -) -> Result { - download_media_range_on_lane(handle, client, home, api_id, media, dest, None) -} - -pub(crate) fn thumb_file_id_can_refresh( - kind: media::MediaDownloadKind, - media: &media::MediaRef, - message_id: i32, - err: &MtprotoError, -) -> bool { - kind == media::MediaDownloadKind::Thumb - && media::is_file_id_invalid(err) - && (message_id != 0 - || matches!( - media.location, - media::MediaLocation::Document { .. } | media::MediaLocation::Photo { .. } - )) -} - -pub(crate) fn download_thumb_with_size_fallback( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - chat_id: i64, - message_id: i32, - kind: media::MediaDownloadKind, - media: &media::MediaRef, - dest: &Path, - offset: Option, -) -> Result { - match download_media_range_on_lane(handle, client, home, api_id, media, dest, offset) { - Ok(path) => Ok(path), - Err(err) if kind == media::MediaDownloadKind::Thumb && media::is_file_id_invalid(&err) => { - try_alternate_thumb_sizes( - handle, client, home, api_id, chat_id, message_id, media, dest, offset, - ) - } - other => other, - } -} - -pub(crate) fn try_alternate_thumb_sizes( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - chat_id: i64, - message_id: i32, - media: &media::MediaRef, - dest: &Path, - offset: Option, -) -> Result { - let Some(current) = media::location_thumb_size(&media.location) else { - return Err(MtprotoError::Message("no downloadable thumb".into())); - }; - let mut last = MtprotoError::Message("no downloadable thumb".into()); - for ty in media::next_getfile_thumb_sizes(current) { - let Some(loc) = media::with_thumb_size(&media.location, ty) else { - break; - }; - let mut alt = media.clone(); - alt.location = loc; - match download_media_range_on_lane(handle, client, home, api_id, &alt, dest, offset) { - Ok(path) => { - let working = alt.location.clone(); - let _ = with_client_mut(handle, |state| { - if let Some(indexed) = state.media.get_mut(&(chat_id, message_id)) { - media::remember_thumb_location(indexed, working); - } - Ok(()) - }); - return Ok(path); - } - Err(err) if media::is_file_id_invalid(&err) => { - last = err; - } - Err(err) => return Err(err), - } - } - Err(last) -} - -pub(crate) fn try_alternate_video_avatar_sizes( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - chat_id: i64, - message_id: i32, - media: &media::MediaRef, - dest: &Path, - offset: Option, -) -> Result { - let Some(current) = media::location_thumb_size(&media.location) else { - return Err(MtprotoError::Message("no video avatar size".into())); - }; - let mut last = MtprotoError::Message("no video avatar size".into()); - for ty in media::next_video_avatar_sizes(current) { - let Some(loc) = media::with_thumb_size(&media.location, ty) else { - break; - }; - let mut alt = media.clone(); - alt.location = loc; - match download_media_range_on_lane(handle, client, home, api_id, &alt, dest, offset) { - Ok(path) => { - let working = alt.location.clone(); - let _ = with_client_mut(handle, |state| { - if let Some(indexed) = state.media.get_mut(&(chat_id, message_id)) { - indexed.location = working; - } - Ok(()) - }); - return Ok(path); - } - Err(err) if media::is_file_id_invalid(&err) => { - last = err; - } - Err(err) => return Err(err), - } - } - Err(last) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum MediaLanePrep { - /// Same DC + usable auth: keep the live TCP, only sync salt/time. - Reuse, - /// Home DC after a foreign-DC auth: new forked home session, drop TCP. - Replaced, - /// Foreign DC still on home auth (or wrong DC): exportAuthorization. - NeedExport, -} - -/// Home-DC downloads reuse a forked session (same auth, new session_id). -/// Other DCs need a distinct exported auth key. Never use the main RPC socket. -pub(crate) fn prepare_media_lane_snapshot( - lane: &mut Snapshot, - home: &Snapshot, - target_dc: i32, -) -> MediaLanePrep { - if target_dc == home.dc_id { - if lane.dc_id != home.dc_id || lane.auth_key != home.auth_key { - *lane = fork_session(home); - MediaLanePrep::Replaced - } else { - lane.server_salt = home.server_salt; - lane.time_offset_micros = home.time_offset_micros; - MediaLanePrep::Reuse - } - } else if lane.dc_id != target_dc || lane.auth_key == home.auth_key { - MediaLanePrep::NeedExport - } else { - lane.time_offset_micros = home.time_offset_micros; - MediaLanePrep::Reuse - } -} - -pub(crate) fn download_media_range_on_lane( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - media: &media::MediaRef, - dest: &Path, - offset: Option, -) -> Result { - let _policy = crate::transfer_policy::bind(std::sync::Arc::clone(&client.policy)); - let _ordinary = if media::bound_download_window().is_none() { - Some(media::bind_download_profile( - media::DownloadProfile::Ordinary, - )) - } else { - None - }; - if media::location_is_inline(&media.location) { - return media::download_media_range_with_fetch( - home.dc_id, - media, - dest, - dest, - offset, - |_| { - Err(MtprotoError::Message( - "inline media does not need upload.getFile".into(), - )) - }, - ); - } - let mut target_dc = media::download_dc(home.dc_id, &media.location); - let large = media.file_size.is_some_and(|size| size >= 20_000_000); - let mut migrations = 0u8; - let mut flood_resumes = 0u8; - let mut routed_media = media.clone(); - let media = &mut routed_media; - loop { - if let Err(error) = media::check_transfer_flood(target_dc, media::TransferClass::Download) { - match media::transfer_step(&error, target_dc, migrations, flood_resumes) { - media::TransferStep::ResumeFlood => { - let seconds = media::flood_wait_secs(&error).unwrap_or(1); - if !wait_flood(dest, seconds) { - return Err(MtprotoError::Message("cancelled".into())); - } - flood_resumes += 1; - continue; - } - _ => return Err(error), - } - } - let admission = client.policy.admit_download(target_dc, large)?; - let first = download_media_range_on_lane_dc( - handle, client, home, api_id, media, dest, offset, target_dc, - ); - if let Err(ref error) = first { - match media::transfer_step(error, target_dc, migrations, flood_resumes) { - media::TransferStep::Migrate(dc) => { - match &mut media.location { - media::MediaLocation::Photo { dc_id, .. } - | media::MediaLocation::Document { dc_id, .. } - | media::MediaLocation::PeerPhoto { dc_id, .. } => *dc_id = dc, - media::MediaLocation::Inline { .. } => {} - } - target_dc = dc; - migrations += 1; - drop(admission); - continue; - } - media::TransferStep::ResumeFlood => { - let seconds = media::flood_wait_secs(error).unwrap_or(1); - drop(admission); - if !wait_flood(dest, seconds) { - return Err(MtprotoError::Message("cancelled".into())); - } - flood_resumes += 1; - continue; - } - media::TransferStep::Stop => {} - } - } - return match first { - Err(err) if media::is_cdn_redirect(&err) => { - let job = media::take_cdn_redirect().ok_or(err)?; - drop(admission); - cdn_or_origin( - || { - let _cdn_admission = client.policy.admit_download(job.dc_id, large)?; - follow_cdn_redirect(handle, api_id, dest, &job) - }, - || { - let _admission = client.policy.admit_download(target_dc, large)?; - download_media_range_on_lane_dc( - handle, client, home, api_id, media, dest, offset, target_dc, - ) - }, - ) - } - other => other, - }; - } -} - -/// Flood wait is off the media lane: the admission guard is already dropped. -fn wait_flood(dest: &Path, seconds: u64) -> bool { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds.max(1)); - while std::time::Instant::now() < deadline { - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return false; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - } - !media::download_cancelled(dest) && crate::request_control::check().is_ok() -} - -/// Home-DC download on the main socket. Used only when a temp key cannot be bound, -/// so the permanent key is not opened on a second TCP. -pub(crate) fn download_media_range_on_home_session( - handle: u64, - client: &Client, - expected_session_id: i64, - api_id: i32, - media: &media::MediaRef, - dest: &Path, - offset: Option, -) -> Result { - loop { - let _span = crate::perf::span("media_file_home"); - let (session_id, dc) = { - let data = client.data.lock(); - if !session_lease_valid(&data, expected_session_id) { - return Err(expired_session_lease()); - } - (expected_session_id, data.home_dc) - }; - let staged = stage_for(dest, offset)?; - // Cap refills to one window so `with_client_mut` returns and updates can drain. - // Sliding refill still keeps the in-flight window full inside that lease. - let downloaded = media::download_media_range_batched_streaming_capped( - dc, - media, - staged.path(), - dest, - offset, - media::download_in_flight(), - Some(media::download_in_flight()), - |_init_first, requests, on_chunk| { - media::check_transfer_flood(dc, media::TransferClass::Download)?; - with_client_mut(handle, |state| { - if !home_lease_ok(state, session_id) { - return Err(expired_session_lease()); - } - ensure_ready(state)?; - crate::rpc::with_rpc_timeout_secs(45, || { - crate::api_invoke::invoke_api_batch_without_updates_streaming::< - _, - tellers_mtproto::latest::api::UploadFile, - >(&mut state.snapshot, api_id, requests, on_chunk) - }) - }) - }, - ); - if downloaded - .as_ref() - .err() - .is_some_and(media::optional_paused) - { - staged.keep(); - while media::should_pause_optional(Some(media::DownloadProfile::Background)) { - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return Err(MtprotoError::Message("cancelled".into())); - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - continue; - } - return finish_staged(staged, downloaded, client, session_id, dest); - } -} - -pub(crate) fn publish_media( - client: &Client, - session_id: i64, - staged: media::StagedDownload, - dest: &Path, -) -> Result { - if !session_lease_valid(&client.data.lock(), session_id) { - return Err(expired_session_lease()); - } - tcp::with_connection_control(&client.connections, || { - tcp::while_client_open(|| { - crate::request_control::while_active(|| staged.publish(dest)) - .map_err(|e| MtprotoError::Message(e.to_string()))? - }) - .map_err(|e| MtprotoError::Message(e.to_string()))? - }) -} diff --git a/native/mtproto-rs/src/client/media_lane.rs b/native/mtproto-rs/src/client/media_lane.rs deleted file mode 100644 index 5d2920d36..000000000 --- a/native/mtproto-rs/src/client/media_lane.rs +++ /dev/null @@ -1,548 +0,0 @@ -pub(crate) fn split_even_odd_request_indices( - offsets: &[i64], - chunk: i32, -) -> (Vec, Vec) { - let step = i64::from(chunk.max(1)); - let mut even = Vec::new(); - let mut odd = Vec::new(); - for (index, offset) in offsets.iter().enumerate() { - if (*offset / step) % 2 == 0 { - even.push(index); - } else { - odd.push(index); - } - } - (even, odd) -} - -fn invoke_lane_batch( - client: &Client, - snapshot: &mut tellers_mtproto_session::Snapshot, - slot: &mut Option, - api_id: i32, - requests: Vec, - on_chunk: &mut dyn FnMut( - usize, - Result, - ) -> Option, -) -> Result>, MtprotoError> { - with_client_transport(client, slot, || { - crate::api_invoke::invoke_api_batch_without_updates_streaming::< - _, - tellers_mtproto::latest::api::UploadFile, - >(snapshot, api_id, requests, on_chunk) - }) -} - -pub(crate) fn invoke_parallel_media_batches( - batches: [Vec<(usize, Req)>; 2], - invoke_a: impl FnOnce( - Vec, - &mut dyn FnMut(usize, Result) -> Option, - ) -> Result>, MtprotoError> - + Send, - invoke_b: impl FnOnce( - Vec, - &mut dyn FnMut(usize, Result) -> Option, - ) -> Result>, MtprotoError> - + Send, - on_chunk: &mut dyn FnMut(usize, Result) -> Option, -) -> Result>, MtprotoError> { - use std::sync::mpsc::{Sender, sync_channel}; - - fn run_lane( - batch: Vec<(usize, Req)>, - completed: Sender<( - usize, - Result, - std::sync::mpsc::SyncSender>, - )>, - invoke: impl FnOnce( - Vec, - &mut dyn FnMut(usize, Result) -> Option, - ) -> Result>, MtprotoError>, - ) -> Result<(), MtprotoError> { - let (mut indices, requests): (Vec<_>, Vec<_>) = batch.into_iter().unzip(); - invoke(requests, &mut |index, result| { - let global_index = *indices.get(index)?; - let (reply, refill) = sync_channel(0); - completed.send((global_index, result, reply)).ok()?; - let (next_index, request) = refill.recv().ok()??; - indices.push(next_index); - Some(request) - }) - .map(|_| ()) - } - - let initial_len = batches.iter().map(Vec::len).sum(); - let mut results: Vec>> = - (0..initial_len).map(|_| None).collect(); - let failed = std::sync::atomic::AtomicBool::new(false); - std::thread::scope(|scope| { - let [a, b] = batches; - let failed_ref = &failed; - let (completed, chunks) = std::sync::mpsc::channel(); - let completed_b = completed.clone(); - let even = scope.spawn(move || { - let result = run_lane(a, completed, invoke_a); - if result.is_err() { - failed_ref.store(true, Ordering::Release); - } - result - }); - let odd = scope.spawn(move || { - let result = run_lane(b, completed_b, invoke_b); - if result.is_err() { - failed_ref.store(true, Ordering::Release); - } - result - }); - for (index, result, reply) in chunks { - if result.is_err() { - failed.store(true, Ordering::Release); - } - let refill = on_chunk(index, result.clone()) - .filter(|_| !failed.load(Ordering::Acquire)) - .map(|request| { - let next_index = results.len(); - results.push(None); - (next_index, request) - }); - results[index] = Some(result); - let _ = reply.send(refill); - } - even.join() - .map_err(|_| MtprotoError::Message("even media lane".into()))??; - odd.join() - .map_err(|_| MtprotoError::Message("odd media lane".into()))??; - Ok(results - .into_iter() - .map(|result| { - result.unwrap_or_else(|| Err(MtprotoError::Message("missing media part".into()))) - }) - .collect()) - }) -} - -fn invoke_media_batch_even_odd( - client: &Client, - snap_a: &mut tellers_mtproto_session::Snapshot, - slot_a: &mut Option, - snap_b: Option<&mut tellers_mtproto_session::Snapshot>, - slot_b: Option<&mut Option>, - api_id: i32, - requests: Vec, - on_chunk: &mut dyn FnMut( - usize, - Result, - ) -> Option, -) -> Result>, MtprotoError> { - let Some(first) = requests.first() else { - return Ok(Vec::new()); - }; - let offsets: Vec = requests.iter().map(|r| r.offset).collect(); - let (even_idx, odd_idx) = split_even_odd_request_indices(&offsets, first.limit); - let even_reqs: Vec<_> = even_idx.iter().map(|i| requests[*i].clone()).collect(); - let odd_reqs: Vec<_> = odd_idx.iter().map(|i| requests[*i].clone()).collect(); - if snap_b.is_none() || slot_b.is_none() || odd_reqs.is_empty() || even_reqs.is_empty() { - return with_client_transport(client, slot_a, || { - crate::api_invoke::invoke_api_batch_without_updates_streaming( - snap_a, api_id, requests, on_chunk, - ) - }); - } - let snap_b = snap_b.unwrap(); - let slot_b = slot_b.unwrap(); - let timeout = crate::rpc::rpc_timeout_secs(); - let class = crate::scheduler::current_class(); - let request_id = crate::request_control::current_id(); - let run = |snapshot: &mut tellers_mtproto_session::Snapshot, - slot: &mut Option, - requests, - on_chunk: &mut dyn FnMut(_, _) -> _| { - let _policy = crate::transfer_policy::bind(client.policy.clone()); - crate::request_control::with_shared_binding(request_id, || { - crate::scheduler::with_class(class, || { - crate::rpc::with_rpc_timeout_secs(timeout, || { - invoke_lane_batch(client, snapshot, slot, api_id, requests, on_chunk) - }) - }) - }) - }; - let result = invoke_parallel_media_batches( - [ - even_idx.into_iter().zip(even_reqs).collect(), - odd_idx.into_iter().zip(odd_reqs).collect(), - ], - |requests, on_chunk| run(snap_a, slot_a, requests, on_chunk), - |requests, on_chunk| run(snap_b, slot_b, requests, on_chunk), - on_chunk, - ); - crate::request_control::detach_sockets(); - result -} - -pub(crate) fn download_media_range_on_lane_dc( - handle: u64, - client: &Client, - home: &tellers_mtproto_session::Snapshot, - api_id: i32, - media: &media::MediaRef, - dest: &Path, - offset: Option, - target_dc: i32, -) -> Result { - loop { - let _span = crate::perf::span("media_file"); - let mut io = lock_media_lane(client)?; - if !session_lease_valid(&client.data.lock(), home.session_id) { - return Err(expired_session_lease()); - } - let mut first_chunk_span = Some(crate::perf::span("media_first_chunk")); - let already_temp = download_temp_ready(&io, client, target_dc, home.auth_key.as_deref()); - if !already_temp { - match prepare_media_lane_snapshot(&mut io.snapshot, home, target_dc) { - MediaLanePrep::NeedExport => { - io.snapshot = with_client_mut(handle, |state| { - if !home_lease_ok(state, home.session_id) { - return Err(expired_session_lease()); - } - copy_authorization_to_dc(state, target_dc) - })?; - io.transport = None; - } - MediaLanePrep::Replaced => io.transport = None, - MediaLanePrep::Reuse => {} - } - crate::auth::bind_temp::ensure_dc_temp_with(client, &mut io, target_dc, - Some(home.session_id), &crate::rpc::SystemClock, - crate::auth::bind_temp::negotiate_bound_temp)?; - } - if target_dc == home.dc_id && io.snapshot.auth_key.as_ref() == home.auth_key.as_ref() { - drop(io); - return download_media_range_on_home_session( - handle, - client, - home.session_id, - api_id, - media, - dest, - offset, - ); - } - let staged = stage_for(dest, offset)?; - let mut io2 = if should_open_second_media_lane(target_dc, media::download_in_flight()) { - try_lock_second_media_lane(client) - } else { - None - }; - let second_ready = match io2.as_mut() { - Some(second) if download_temp_ready(second, client, target_dc, home.auth_key.as_deref()) => { - true - } - _ => false, - }; - if !second_ready { - io2 = None; - } - let mut preparation = if io2.is_none() && offset.is_none() - && should_open_second_media_lane(target_dc, media::download_in_flight()) { - get_client(handle).ok().map(|worker_client| { - let class = scheduler::current_class(); - let expected_session = home.session_id; - MediaLanePreparation::start(move || { - let _policy = crate::transfer_policy::bind(worker_client.policy.clone()); - scheduler::with_class(class, || { - let _span = crate::perf::span("media_second_lane_prepare"); - let Some(mut second) = try_lock_second_media_lane(&worker_client) else { return false; }; - crate::auth::bind_temp::ensure_dc_temp_with(&worker_client, &mut second, target_dc, - Some(expected_session), &crate::rpc::SystemClock, - crate::auth::bind_temp::negotiate_bound_temp).is_ok() - }) - }) - }) - } else { None }; - let mut media_snap = io.snapshot.clone(); - let mut slot = io.transport.take(); - let mut media_snap2 = io2.as_ref().map(|lane| lane.snapshot.clone()); - let mut slot2 = io2.as_mut().map(|lane| lane.transport.take()); - crate::rpc::clear_new_session_metadata(); - // Timeout is per window, not the whole file. Streaming idle-extends while parts arrive. - let path = { - let lane_dc = media_snap.dc_id; - media::download_media_range_batched_streaming_capped( - lane_dc, - media, - staged.path(), - dest, - offset, - media::download_in_flight(), - None, - |_init_first, requests, on_chunk| { - media::check_transfer_flood(lane_dc, media::TransferClass::Download)?; - if preparation.as_ref().is_some_and(MediaLanePreparation::finished) { - preparation = None; - if should_open_second_media_lane(lane_dc, media::download_in_flight()) { - io2 = try_lock_second_media_lane(client).filter(|lane| - download_temp_ready(lane, client, lane_dc, home.auth_key.as_deref())); - media_snap2 = io2.as_ref().map(|lane| lane.snapshot.clone()); - slot2 = io2.as_mut().map(|lane| lane.transport.take()); - } - } - let mut stream_chunk = |index, result: Result| { - if matches!(&result, Ok(tellers_mtproto::latest::api::UploadFile::UploadFile(file)) if !file.bytes.is_empty()) { - first_chunk_span.take(); - } - let refill = on_chunk(index, result); - if preparation.as_ref().is_some_and(MediaLanePreparation::finished) { None } else { refill } - }; - crate::rpc::with_rpc_timeout_secs(45, || { - invoke_media_batch_even_odd( - client, - &mut media_snap, - &mut slot, - media_snap2.as_mut(), - slot2.as_mut(), - api_id, - requests, - &mut stream_chunk, - ) - }) - }, - ) - }; - drop(preparation); - io.snapshot = media_snap.clone(); - let mut temporary = path.as_ref().err().is_some_and(|error| expire_temporary_lane(client, &mut io, error)); - // FILE_REFERENCE / transport errors must not park this TCP for the next file. - io.transport = if path.is_ok() { slot } else { None }; - if let Some(second) = io2.as_mut() { - if let Some(snap2) = media_snap2 { - second.snapshot = snap2; - } - second.transport = if path.is_ok() { slot2.flatten() } else { None }; - if let Err(error) = &path { - temporary |= expire_temporary_lane(client, second, error); - } - } - drop(io); - drop(io2); - if !temporary && path.as_ref().err().is_some_and(|error| mark_lane_session_dead(client, home.session_id, error)) { - let _ = persist_updates_data(client, home.session_id); - } - if path.as_ref().err().is_some_and(media::optional_paused) { - staged.keep(); - while media::should_pause_optional(Some(media::DownloadProfile::Background)) { - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return Err(MtprotoError::Message("cancelled".into())); - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - continue; - } - if let Ok(client) = get_client(handle) { - schedule_file_cleanup(&client); - } - let new_session = crate::rpc::take_new_session_metadata(); - if media_snap.dc_id == home.dc_id && media_snap.auth_key == home.auth_key { - let mut d = client.data.lock(); - if !session_lease_valid(&d, home.session_id) { - return Err(expired_session_lease()); - } - d.home_salt = media_snap.server_salt; - d.home_time_offset = media_snap.time_offset_micros; - if new_session.is_some() { - d.new_session = new_session.clone(); - } - } - if new_session.is_some() { - persist_updates_data(client, home.session_id)?; - } - if let Err(ref err) = path { - let foreign_key = !temporary && target_dc != home.dc_id - && !(media_snap.dc_id == home.dc_id && media_snap.auth_key == home.auth_key) - && is_foreign_auth_key_failure(err); - if foreign_key && !DC_AUTH_RETRY.with(std::cell::Cell::get) { - let _retry = DcAuthRetry::enter(); - let _ = with_client_mut(handle, |state| { - if home_lease_ok(state, home.session_id) { - state.dc_auth_keys.remove(&target_dc); - } - Ok(()) - }); - client.data.lock().dc_temp_auth.remove(&target_dc); - for lane in client.media.iter() { - if let Some(mut lane_io) = lane.io.try_lock() { - if lane_io.snapshot.dc_id == target_dc { - lane_io.temp_expires_at = 0; - lane_io.perm_key = None; - lane_io.transport = None; - } - } - } - staged.keep(); - return download_media_range_on_lane_dc( - handle, client, home, api_id, media, dest, offset, target_dc, - ); - } - } - return finish_staged(staged, if temporary { Err(temporary_key_retry_error()) } else { path }, client, home.session_id, dest); - } -} - -thread_local! { - static DC_AUTH_RETRY: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -struct DcAuthRetry(bool); - -impl DcAuthRetry { - fn enter() -> Self { - Self(DC_AUTH_RETRY.with(|flag| flag.replace(true))) - } -} - -impl Drop for DcAuthRetry { - fn drop(&mut self) { - DC_AUTH_RETRY.with(|flag| flag.set(self.0)); - } -} - -pub(crate) fn should_open_second_media_lane(dc_id: i32, in_flight: usize) -> bool { - in_flight > 1 - && crate::scheduler::active_media_lanes() > 1 - && !crate::media::download_flood_active(dc_id) -} - -fn follow_cdn_redirect( - handle: u64, - api_id: i32, - dest: &Path, - job: &media::CdnRedirect, -) -> Result { - if job.offset != 0 { - return Err(MtprotoError::Message("CDN resume requires origin download".into())); - } - let client = get_client(handle)?; - let session_id = client.data.lock().home_session_id; - let pem = with_client_mut(handle, |state| { - let cfg: CdnConfig = - api_invoke::invoke_api(&mut state.snapshot, api_id, media::get_cdn_config_request())?; - let CdnConfig::CdnConfig(cfg) = cfg; - let pem = vector_boxed_items(cfg.public_keys.as_ref()).find_map(|key| match key { - CdnPublicKey::CdnPublicKey(body) if body.dc_id == job.dc_id => { - Some(body.public_key.clone()) - } - _ => None, - }); - pem.ok_or_else(|| MtprotoError::Message("cdn public key".into())) - })?; - let endpoint = crate::dns_txt::cdn_endpoints_for(job.dc_id) - .into_iter() - .next() - .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_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 { - ping_sent: None, - updates: Vec::new(), - updates_bytes: 0, - dc_id: job.dc_id, - endpoint: endpoint.addr.clone(), - conn, - framing, - input: Vec::new(), - last_io: std::time::Instant::now(), - }); - let staged = media::StagedDownload::new(dest)?; - crate::rpc::with_live_transport(&mut slot, || { - crate::rpc::with_rpc_timeout_secs(45, || { - receive_cdn_parts(job, dest, &staged, |offset| { - let response: UploadCdnFile = api_invoke::invoke_api_without_updates( - &mut snap, api_id, - media::get_cdn_file_request(job.file_token.clone(), offset, media::CHUNK), - )?; - media::cdn_file_bytes(response) - }, |request_token| { - with_client_mut(handle, |state| { - api_invoke::invoke_api::<_, Vector>>( - &mut state.snapshot, api_id, - media::reupload_cdn_file_request(job.file_token.clone(), request_token), - ) - }).map(|_| ()) - }) - }) - })?; - publish_media(&client, session_id, staged, dest)?; - Ok(dest.display().to_string()) -} - -fn cdn_or_origin( - cdn: impl FnOnce() -> Result, - origin: impl FnOnce() -> Result, -) -> Result { - match cdn() { - Ok(result) => Ok(result), - Err(_) => media::with_cdn_supported(false, origin), - } -} - -fn receive_cdn_parts( - job: &media::CdnRedirect, - dest: &Path, - staged: &media::StagedDownload, - mut fetch: impl FnMut(i64) -> Result, Vec>, MtprotoError>, - mut reupload: impl FnMut(Vec) -> Result<(), MtprotoError>, -) -> Result<(), MtprotoError> { - use std::io::{Seek, SeekFrom, Write}; - let mut out = std::fs::File::create(staged.path()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut offset = job.offset; - loop { - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return Err(MtprotoError::Message("cancelled".into())); - } - let mut reuploads = 0; - let mut part = loop { - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return Err(MtprotoError::Message("cancelled".into())); - } - match fetch(offset)? { - Ok(bytes) => break bytes, - Err(token) => { - if reuploads >= 3 { - return Err(MtprotoError::Message("CDN reupload limit exceeded".into())); - } - reuploads += 1; - reupload(token)?; - } - } - }; - if media::download_cancelled(dest) || crate::request_control::check().is_err() { - return Err(MtprotoError::Message("cancelled".into())); - } - media::decrypt_cdn_part(&job.encryption_key, &job.encryption_iv, offset, &mut part)?; - if part.len() > media::CHUNK as usize - || !media::verify_cdn_part(&job.file_hashes, offset, &part) - { - return Err(MtprotoError::Message("CDN hash verification failed".into())); - } - out.seek(SeekFrom::Start(offset as u64)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - out.write_all(&part).map_err(|e| MtprotoError::Message(e.to_string()))?; - if part.len() < media::CHUNK as usize { break; } - offset += part.len() as i64; - media::notify_progress(&dest.display().to_string(), offset, 0); - } - out.flush().map_err(|e| MtprotoError::Message(e.to_string())) -} - diff --git a/native/mtproto-rs/src/client/membership.rs b/native/mtproto-rs/src/client/membership.rs deleted file mode 100644 index 58412211b..000000000 --- a/native/mtproto-rs/src/client/membership.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Join a channel, megagroup, or basic group, and unblock a user. -//! https://core.telegram.org/method/channels.joinChannel -//! https://core.telegram.org/method/messages.addChatUser -//! https://core.telegram.org/method/contacts.unblock - -use tellers_mtproto::latest::api::{ - Bool, ChannelsJoinChannelRequest, Chat as TlChat, ContactsUnblockRequest, InputChannel, - InputChannelConstructor, InputUser, InputUserSelfConstructor, MessagesAddChatUserRequest, - MessagesChatInviteJoinResult, MessagesInvitedUsers, MissingInvitee, Updates, User, Vector, -}; - -use crate::dialogs::{cache_from_users_chats, chat_meta_from_tl}; -use crate::peers::{self, channel_id_from_chat_id, input_peer_from_cached, vector_boxed_items}; -use crate::{JoinChatDto, MtprotoError}; - -use super::ClientState; - -pub fn join_chat(state: &mut ClientState, chat_id: i64) -> Result { - if channel_id_from_chat_id(chat_id).is_some() { - let cached = peers::require_usable_peer(&state.peers, chat_id)?; - let response: MessagesChatInviteJoinResult = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChannelsJoinChannelRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: cached.id, - access_hash: cached.access_hash, - })), - }, - )?; - return Ok(match response { - MessagesChatInviteJoinResult::MessagesChatInviteJoinResultOk(ok) => { - grant_from_updates(state, chat_id, None, ok.updates.as_ref()) - } - MessagesChatInviteJoinResult::MessagesChatInviteJoinResultWebView(_) => { - JoinChatDto::pending() - } - }); - } - if chat_id < 0 { - let response: MessagesInvitedUsers = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesAddChatUserRequest { - chat_id: -chat_id, - user_id: Box::new(InputUser::InputUserSelf(InputUserSelfConstructor {})), - fwd_limit: 0, - }, - )?; - let MessagesInvitedUsers::MessagesInvitedUsers(invited) = response; - return Ok(grant_from_updates( - state, - chat_id, - Some(invited.missing_invitees.as_ref()), - invited.updates.as_ref(), - )); - } - Err(MtprotoError::Message( - "join is only for a channel, megagroup, or basic group".into(), - )) -} - -pub fn unblock_user(state: &mut ClientState, chat_id: i64) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(&state.peers, chat_id)?; - let _: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ContactsUnblockRequest { - flags: 0, - my_stories_from: None, - id: Box::new(input_peer_from_cached(cached)), - }, - )?; - Ok(()) -} - -fn grant_from_updates( - state: &mut ClientState, - chat_id: i64, - missing: Option<&Vector>>, - updates: &Updates, -) -> JoinChatDto { - let (users, chats) = updates_users_chats(updates); - cache_from_users_chats( - &mut state.peers, - &mut state.media, - users.into_iter(), - chats.iter().cloned(), - ); - if missing.is_some_and(|items| missing_self(items, state.user_id)) { - return JoinChatDto::pending(); - } - membership_granted(chat_id, &chats) -} - -/// Membership is granted only when the returned chat for [chat_id] is present and not `left`. -pub(crate) fn membership_granted(chat_id: i64, chats: &[TlChat]) -> JoinChatDto { - let raw = channel_id_from_chat_id(chat_id).unwrap_or_else(|| -chat_id); - for chat in chats { - let Some((id, meta)) = chat_meta_from_tl(chat) else { - continue; - }; - if id != raw { - continue; - } - if meta.left { - return JoinChatDto::pending(); - } - return JoinChatDto { - joined: true, - can_send_plain: meta.can_send_plain, - can_send_photos: meta.can_send_photos, - can_send_polls: crate::dialogs::chat_poll_permission(chat), - }; - } - JoinChatDto::pending() -} - -fn missing_self(missing: &Vector>, self_id: Option) -> bool { - let Some(self_id) = self_id else { - return false; - }; - vector_boxed_items(missing).any(|item| match item { - MissingInvitee::MissingInvitee(invitee) => invitee.user_id == self_id, - }) -} - -pub(super) fn updates_users_chats(updates: &Updates) -> (Vec, Vec) { - match updates { - Updates::Updates(batch) => ( - vector_boxed_items(&batch.users).cloned().collect(), - vector_boxed_items(&batch.chats).cloned().collect(), - ), - Updates::UpdatesCombined(batch) => ( - vector_boxed_items(&batch.users).cloned().collect(), - vector_boxed_items(&batch.chats).cloned().collect(), - ), - _ => (Vec::new(), Vec::new()), - } -} - -impl JoinChatDto { - fn pending() -> Self { - Self { - joined: false, - can_send_plain: false, - can_send_photos: false, - can_send_polls: false, - } - } -} diff --git a/native/mtproto-rs/src/client/mod.rs b/native/mtproto-rs/src/client/mod.rs deleted file mode 100644 index 2bf6fff01..000000000 --- a/native/mtproto-rs/src/client/mod.rs +++ /dev/null @@ -1,1112 +0,0 @@ -//! Tellers-backed MTProto client handles. -//! https://core.telegram.org/api/invoking -//! https://core.telegram.org/api/datacenter -//! https://core.telegram.org/api/auth - -mod auth; -mod passkeys; -pub use passkeys::*; -pub mod chat_invites; -pub mod checklists; -pub mod contacts; -mod dispatch; -pub mod drafts; -mod extras; -pub(crate) mod extras_rpc; -pub mod forum_management; -pub mod group_management; -pub(crate) mod lanes; -pub mod management; -mod media_download; -mod membership; -mod persist; -pub mod polls; -pub(crate) mod push_rpc; -pub mod scheduled; -mod session; -pub mod shared_folders; -mod updates; - -pub use auth::*; -pub use dispatch::*; -pub use extras::*; -pub use lanes::*; -pub use media_download::*; -pub(crate) use persist::*; -pub(crate) use session::*; -pub use updates::*; - -#[cfg(test)] -use crate::media as media_rpc; -#[cfg(test)] -use crate::peers; - -#[cfg(test)] -#[path = "../../tests/unit/client_mgr_channel_tests.rs"] -mod channel_tests; -#[cfg(test)] -#[path = "../../tests/unit/client_mgr_lane_tests.rs"] -mod lane_tests; -#[cfg(test)] -#[path = "../../tests/unit/lazy_sync_tests.rs"] -mod lazy_sync_tests; -#[cfg(test)] -#[path = "../../tests/unit/client_mgr_logout_tests.rs"] -mod logout_tests; -#[cfg(test)] -#[path = "../../tests/unit/client_mgr_pfs_tests.rs"] -mod pfs_tests; -#[cfg(test)] -#[path = "../../tests/unit/client_mgr_session_tests.rs"] -mod session_tests; -#[cfg(test)] -#[path = "../../tests/unit/transfer_policy_tests.rs"] -mod transfer_policy_tests; - -use crate::{HashMap, HashMapExt, HashSet, HashSetExt, IndexMap}; -use std::cell::Cell; -use std::collections::VecDeque; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, LazyLock}; - -use parking_lot::Mutex; -use tellers_mtproto_session::{OsRandom, Snapshot}; - -use crate::media::MediaIndex; -use crate::peers::CachedPeer; -use crate::scheduler; -use crate::session_file::{ChannelRecovery, DcAuthKey, FileSessionStore, media_to_index}; -use crate::tcp; -use crate::{MtprotoError, UpdatesStateDto}; - -pub(crate) static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); -pub(crate) static CLIENTS: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -pub(crate) const DEFAULT_DC_ID: i32 = 2; - -pub(crate) struct ClientData { - pub(crate) api_id: i32, - pub(crate) api_hash: String, - pub(crate) session_path: PathBuf, - pub(crate) user_id: Option, - pub(crate) peers: HashMap, - pub(crate) updates: Option, - pub(crate) media: MediaIndex, - pub(crate) updates_started: bool, - pub(crate) session_dead: bool, - /// Catalog token of the error that killed the session (`AUTH_KEY_DUPLICATED`). - pub(crate) session_dead_reason: Option, - pub(crate) last_history_chat_id: i64, - pub(crate) channel_pts: HashMap, - pub(crate) channel_recovery: VecDeque, - pub(crate) seen_messages: HashSet<(i64, i32)>, - pub(crate) logout_tokens: Vec>, - pub(crate) new_session: Option, - pub(crate) home_snapshot: Snapshot, - pub(crate) home_dc: i32, - pub(crate) home_session_id: i64, - pub(crate) home_auth_key: Option>, - pub(crate) home_salt: i64, - pub(crate) home_time_offset: i64, - pub(crate) test_dc: bool, - pub(crate) last_inline: Option, - /// Imported foreign-DC master keys. Not a temporary PFS cache. - pub(crate) dc_auth_keys: HashMap, - /// Bound temporary keys for media lanes. Process-local; never persisted. - pub(crate) dc_temp_auth: HashMap, - pub(crate) persist_epoch: u64, - pub(crate) persisted_epoch: u64, - /// In-memory catch-up edge. Not written into the session file. - pub(crate) is_syncing: bool, - pub(crate) lazy_channel_updates: bool, - pub(crate) lazy_sync_exceptions: HashSet, - /// Optional metadata previews; gaps remain durable in `channel_recovery`. - pub(crate) lazy_channel_recovery: HashSet, - pub(crate) lazy_retry_at: u64, - pub(crate) journal: Arc, -} - -#[derive(Clone)] -pub(crate) struct LastInlineQuery { - pub(crate) chat_id: i64, - pub(crate) bot_id: i64, - pub(crate) query: crate::CompactString, - pub(crate) offset: crate::CompactString, - pub(crate) last_refresh: Option, -} - -pub(crate) struct Client { - pub(crate) _session_key: Option>, - pub(crate) connections: Arc, - pub(crate) policy: std::sync::Arc, - pub(crate) data: Mutex, - pub(crate) main_gate: scheduler::LaneGate, - pub(crate) main: Mutex, - /// Overlapping reads; never the updates subscriber. - pub(crate) rpc: [Lane; scheduler::READ_LANES], - /// File RPCs on separate sessions. - pub(crate) media: crate::SmallVec<[Lane; scheduler::MAX_MEDIA_LANES]>, - pub(crate) upload: [Lane; scheduler::UPLOAD_LANES], - pub(crate) file_cleanup_running: AtomicBool, - pub(crate) media_open: std::sync::atomic::AtomicUsize, - pub(crate) media_gate: scheduler::LaneGate, - pub(crate) persist_queued: AtomicBool, - pub(crate) persist_running: AtomicBool, - pub(crate) persist_exhausted: AtomicBool, - pub(crate) interactive_waiters: AtomicU64, -} - -/// Combined view for existing RPC closures. Snapshot lives on the main lane; -/// `data` is copied in/out so the data mutex is not held across TCP. -pub(crate) struct ClientState { - pub(crate) api_id: i32, - pub(crate) api_hash: String, - pub(crate) session_path: PathBuf, - pub(crate) snapshot: Snapshot, - pub(crate) user_id: Option, - pub(crate) peers: HashMap, - pub(crate) updates: Option, - pub(crate) media: MediaIndex, - pub(crate) updates_started: bool, - pub(crate) channel_pts: HashMap, - pub(crate) channel_recovery: VecDeque, - pub(crate) seen_messages: HashSet<(i64, i32)>, - pub(crate) logout_tokens: Vec>, - pub(crate) new_session: Option, - /// Home auth key is dead (401 AUTH_KEY_* / SESSION_*). Fail RPCs without more network. - pub(crate) session_dead: bool, - pub(crate) session_dead_reason: Option, - pub(crate) test_dc: bool, - pub(crate) last_inline: Option, - /// Set while the live snapshot uses a PFS temp key. Disk writes keep this key. - pub(crate) permanent: Option, - pub(crate) temp_expires_at: i32, - pub(crate) dc_auth_keys: HashMap, - pub(crate) journal: Arc, -} - -/// In-memory bound temporary key. Deliberately not `Debug`: it holds key material. -#[derive(Clone)] -pub(crate) struct DcTempAuth { - pub(crate) snapshot: Snapshot, - pub(crate) temp_expires_at: i32, - pub(crate) perm_key: Vec, -} - -pub(crate) fn prefer_newer_cursor( - current: Option, - incoming: Option, -) -> Option { - match (current, incoming) { - (None, x) | (x, None) => x, - (Some(a), Some(b)) => Some(UpdatesStateDto { - pts: a.pts.max(b.pts), - qts: a.qts.max(b.qts), - date: a.date.max(b.date), - seq: a.seq.max(b.seq), - }), - } -} - -// Commit only this lane's changes. An unchanged cloned entry must never -// overwrite data fetched by another lane while this lane was on the network. -pub(crate) trait MergeMap { - fn contains_key(&self, key: &K) -> bool; - fn get(&self, key: &K) -> Option<&V>; - fn insert(&mut self, key: K, value: V) -> Option; - fn retain(&mut self, f: &mut dyn FnMut(&K, &mut V) -> bool); - fn for_each_entry(self, f: &mut dyn FnMut(K, V)); -} - -impl MergeMap for HashMap -where - K: Eq + std::hash::Hash, -{ - fn contains_key(&self, key: &K) -> bool { - HashMap::contains_key(self, key) - } - fn get(&self, key: &K) -> Option<&V> { - HashMap::get(self, key) - } - fn insert(&mut self, key: K, value: V) -> Option { - HashMap::insert(self, key, value) - } - fn retain(&mut self, f: &mut dyn FnMut(&K, &mut V) -> bool) { - HashMap::retain(self, |key, value| f(key, value)); - } - fn for_each_entry(self, f: &mut dyn FnMut(K, V)) { - for (key, value) in self { - f(key, value); - } - } -} - -impl MergeMap for IndexMap -where - K: Eq + std::hash::Hash, -{ - fn contains_key(&self, key: &K) -> bool { - IndexMap::contains_key(self, key) - } - fn get(&self, key: &K) -> Option<&V> { - IndexMap::get(self, key) - } - fn insert(&mut self, key: K, value: V) -> Option { - IndexMap::insert(self, key, value) - } - fn retain(&mut self, f: &mut dyn FnMut(&K, &mut V) -> bool) { - IndexMap::retain(self, |key, value| f(key, value)); - } - fn for_each_entry(self, f: &mut dyn FnMut(K, V)) { - for (key, value) in self { - f(key, value); - } - } -} - -impl MergeMap for VecDeque { - fn contains_key(&self, key: &i64) -> bool { - self.iter().any(|entry| entry.chat_id == *key) - } - fn get(&self, key: &i64) -> Option<&ChannelRecovery> { - self.iter().find(|entry| entry.chat_id == *key) - } - fn insert(&mut self, key: i64, value: ChannelRecovery) -> Option { - if let Some(entry) = self.iter_mut().find(|entry| entry.chat_id == key) { - Some(std::mem::replace(entry, value)) - } else { - self.push_back(value); - None - } - } - fn retain(&mut self, f: &mut dyn FnMut(&i64, &mut ChannelRecovery) -> bool) { - self.retain_mut(|entry| { - let key = entry.chat_id; - f(&key, entry) - }); - } - fn for_each_entry(self, f: &mut dyn FnMut(i64, ChannelRecovery)) { - for entry in self { - f(entry.chat_id, entry); - } - } -} - -// Commit only this lane's changes. An unchanged cloned entry must never -// overwrite data fetched by another lane while this lane was on the network. -pub(crate) fn merge_changed_entries(current: &mut M, before: &M, incoming: M) -> bool -where - K: Eq + std::hash::Hash, - V: PartialEq, - M: MergeMap, -{ - let mut changed = false; - current.retain(&mut |key, value| { - let keep = incoming.contains_key(key) || before.get(key) != Some(value); - if !keep { - changed = true; - } - keep - }); - incoming.for_each_entry(&mut |key, value| { - if before.get(&key) != Some(&value) && current.get(&key) == before.get(&key) { - current.insert(key, value); - changed = true; - } - }); - changed -} - -#[cfg(test)] -#[path = "../../tests/unit/client_outgoing_receipts_tests.rs"] -mod outgoing_receipts_tests; - -pub(crate) fn get_client(handle: u64) -> Result, MtprotoError> { - CLIENTS - .lock() - .get(&handle) - .cloned() - .ok_or(MtprotoError::UnknownClient) -} - -pub(crate) fn visit_policies(visit: impl Fn(&crate::transfer_policy::TransferPolicy)) { - for client in CLIENTS.lock().values() { - visit(&client.policy); - } -} - -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() { - Ok(session) => (session, false), - Err(_) => (None, true), - }; - let channel_recovery = loaded - .as_ref() - .map(|s| s.channel_recovery.clone()) - .unwrap_or_default(); - let ( - snapshot, - user_id, - peers, - updates, - media, - channel_pts, - seen_messages, - logout_tokens, - new_session, - session_dead, - test_dc, - dc_auth_keys, - journal, - ) = match loaded { - Some(session) => ( - session.snapshot, - session.user_id, - session.peers, - session.updates, - media_to_index(&session.media, &session.media_order), - session.channel_pts, - session.seen_messages, - session.logout_tokens, - session.new_session, - session.session_dead, - session.test_dc, - session.dc_auth_keys, - session.journal, - ), - None => ( - Snapshot::new(DEFAULT_DC_ID, &mut OsRandom).expect("snapshot"), - None, - HashMap::new(), - None, - MediaIndex::default(), - HashMap::new(), - HashSet::new(), - Vec::new(), - None, - load_failed, - false, - HashMap::new(), - Default::default(), - ), - }; - crate::rpc::set_use_test_dc(test_dc); - let media_snapshot = fork_session(&snapshot); - let rpc_snapshot = fork_session(&snapshot); - let rpc_snapshot_b = fork_session(&snapshot); - let media_snapshots: Vec = (0..scheduler::MAX_MEDIA_LANES) - .map(|index| { - if index == 0 { - media_snapshot.clone() - } else { - fork_session(&snapshot) - } - }) - .collect(); - let upload_lanes = core::array::from_fn(|_| { - Lane::new(SessionIo { - pending_push: Default::default(), - last_difference: None, - snapshot: fork_session(&snapshot), - transport: None, - temp_expires_at: 0, - perm_key: None, - }) - }); - let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); - CLIENTS.lock().insert( - handle, - Arc::new(Client { - _session_key: crate::session_crypto::key_for(&path), - connections, - policy: crate::transfer_policy::snapshot(), - data: Mutex::new(ClientData { - api_id, - api_hash, - session_path: path, - user_id, - peers, - updates, - media, - updates_started: false, - session_dead, - session_dead_reason: None, - last_history_chat_id: 0, - channel_pts, - channel_recovery, - seen_messages, - logout_tokens, - new_session, - home_snapshot: snapshot.clone(), - home_dc: snapshot.dc_id, - home_session_id: snapshot.session_id, - home_auth_key: snapshot.auth_key.clone(), - home_salt: snapshot.server_salt, - home_time_offset: snapshot.time_offset_micros, - test_dc, - last_inline: None, - dc_auth_keys, - dc_temp_auth: HashMap::new(), - persist_epoch: 0, - persisted_epoch: 0, - is_syncing: false, - lazy_channel_updates: false, - lazy_sync_exceptions: HashSet::new(), - lazy_channel_recovery: HashSet::new(), - lazy_retry_at: 0, - journal: Arc::new(journal), - }), - main_gate: scheduler::LaneGate::new(), - main: Mutex::new(SessionIo { - pending_push: Default::default(), - last_difference: None, - snapshot, - transport: None, - temp_expires_at: 0, - perm_key: None, - }), - rpc: [ - Lane::new(SessionIo { - pending_push: Default::default(), - last_difference: None, - snapshot: rpc_snapshot, - transport: None, - temp_expires_at: 0, - perm_key: None, - }), - Lane::new(SessionIo { - pending_push: Default::default(), - last_difference: None, - snapshot: rpc_snapshot_b, - transport: None, - temp_expires_at: 0, - perm_key: None, - }), - ], - media: media_snapshots - .into_iter() - .map(|snapshot| { - Lane::new(SessionIo { - snapshot, - pending_push: Default::default(), - last_difference: None, - transport: None, - temp_expires_at: 0, - perm_key: None, - }) - }) - .collect(), - upload: upload_lanes, - file_cleanup_running: AtomicBool::new(false), - media_open: std::sync::atomic::AtomicUsize::new(1), - media_gate: scheduler::LaneGate::new(), - persist_queued: AtomicBool::new(false), - persist_running: AtomicBool::new(false), - persist_exhausted: AtomicBool::new(false), - interactive_waiters: AtomicU64::new(0), - }), - ); - handle -} - -pub(crate) fn interactive_request_pending(client: &Client) -> bool { - client.interactive_waiters.load(Ordering::Acquire) != 0 -} - -pub(crate) struct InteractiveWaiter<'a>(pub(crate) &'a AtomicU64); - -impl Drop for InteractiveWaiter<'_> { - fn drop(&mut self) { - self.0.fetch_sub(1, Ordering::AcqRel); - } -} - -pub(crate) fn with_interactive_client_mut( - handle: u64, - f: impl FnOnce(&mut ClientState) -> Result, -) -> Result { - with_client_mut(handle, f) -} - -pub fn destroy_client(handle: u64) { - let client = CLIENTS.lock().remove(&handle); - if let Some(client) = client { - client.connections.begin_shutdown(); - flush_persist(&client); - client.connections.close(); - } -} - -pub(crate) fn with_client_transport( - client: &Client, - slot: &mut Option, - body: impl FnOnce() -> T, -) -> T { - tcp::with_connection_control(&client.connections, || { - crate::rpc::with_live_transport(slot, body) - }) -} - -pub fn client_exists(handle: u64) -> bool { - CLIENTS.lock().contains_key(&handle) -} - -pub(crate) fn schedule_transfer_config(handle: u64) { - let Ok(client) = get_client(handle) else { - return; - }; - if client.data.lock().user_id.is_none() { - return; - } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let due = client.policy.config_due.load(Ordering::Acquire); - if due > now - || client - .policy - .config_due - .compare_exchange(due, now + 3600, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { - return; - } - let weak = Arc::downgrade(&client); - let spawned = std::thread::Builder::new() - .name("mtproto-transfer-config".into()) - .spawn(move || { - let result = scheduler::with_class(scheduler::RequestClass::BackgroundRead, || { - with_client_mut(handle, |state| { - crate::rpc::with_rpc_timeout_secs(8, || { - extras_rpc::animated_emoji_max(&mut state.snapshot, state.api_id) - }) - }) - }); - if result.is_err() { - if let Some(client) = weak.upgrade() { - client.policy.config_due.store(now + 60, Ordering::Release); - } - } - }); - if spawned.is_err() { - client.policy.config_due.store(now + 60, Ordering::Release); - } -} - -pub fn client_api_id(handle: u64) -> i32 { - get_client(handle) - .map(|c| c.data.lock().api_id) - .unwrap_or(0) -} - -pub fn client_uses_test_dc(handle: u64) -> bool { - get_client(handle) - .map(|c| c.data.lock().test_dc) - .unwrap_or(false) -} - -pub(crate) fn with_client_mut( - handle: u64, - f: impl FnOnce(&mut ClientState) -> Result, -) -> Result { - with_client_mut_binding(handle, f, crate::auth::bind_temp::negotiate_bound_temp) -} - -pub(crate) fn with_client_mut_binding( - handle: u64, - f: impl FnOnce(&mut ClientState) -> Result, - negotiate: impl FnOnce(&[u8], i32, i64) -> Result, -) -> Result { - let client = get_client(handle)?; - let _policy = crate::transfer_policy::bind(std::sync::Arc::clone(&client.policy)); - let class = scheduler::current_class(); - let _waiter = match class { - scheduler::RequestClass::InteractiveRead | scheduler::RequestClass::InteractiveWrite => { - client.interactive_waiters.fetch_add(1, Ordering::AcqRel); - Some(InteractiveWaiter(&client.interactive_waiters)) - } - _ => None, - }; - let _gate = { - let _wait = crate::perf::span("gate_wait.main"); - client.main_gate.acquire(class)? - }; - let mut io = lock_request_lane(&client.main, "lane_wait.main")?; - crate::auth::bind_temp::ensure_main_temp_with( - &client, - &mut io, - false, - &crate::rpc::SystemClock, - negotiate, - )?; - let mut state = { - let d = client.data.lock(); - ClientState { - api_id: d.api_id, - api_hash: d.api_hash.clone(), - session_path: d.session_path.clone(), - snapshot: io.snapshot.clone(), - user_id: d.user_id, - peers: d.peers.clone(), - updates: d.updates.clone(), - media: d.media.clone(), - updates_started: d.updates_started, - channel_pts: d.channel_pts.clone(), - channel_recovery: d.channel_recovery.clone(), - seen_messages: d.seen_messages.clone(), - logout_tokens: d.logout_tokens.clone(), - new_session: d.new_session.clone(), - session_dead: d.session_dead, - session_dead_reason: d.session_dead_reason.clone(), - test_dc: d.test_dc, - last_inline: d.last_inline.clone(), - permanent: (io.temp_expires_at != 0 || io.perm_key.is_some()) - .then(|| d.home_snapshot.clone()), - temp_expires_at: io.temp_expires_at, - dc_auth_keys: d.dc_auth_keys.clone(), - journal: Arc::clone(&d.journal), - } - }; - crate::rpc::set_use_test_dc(state.test_dc); - crate::rpc::clear_new_session_metadata(); - let before_peers = state.peers.clone(); - let before_media = state.media.clone(); - let before_channel_pts = state.channel_pts.clone(); - let before_recovery = state.channel_recovery.clone(); - let before_user_id = state.user_id; - let before_updates = state.updates.clone(); - let before_logout = state.logout_tokens.clone(); - let before_new_session = state.new_session.clone(); - let before_dead = state.session_dead; - let before_test_dc = state.test_dc; - let before_dc_auth_keys = state.dc_auth_keys.clone(); - let mut slot = io.transport.take(); - let before_snapshot = io.snapshot.clone(); - let result = with_client_transport(&client, &mut slot, || f(&mut state)); - let temp_failed = result - .as_ref() - .err() - .is_some_and(|err| temporary_key_failure(&state, err)); - if !temp_failed && !state.session_dead { - if let Err(error) = &result { - if kills_session(state.user_id, error) { - mark_session_dead(&mut state, error); - } - } - } - if temp_failed { - expire_temporary_lane(&client, &mut io, result.as_ref().err().unwrap()); - state.temp_expires_at = 1; - } - if let Some(metadata) = crate::rpc::take_new_session_metadata() { - state.new_session = Some(metadata); - } - // A session/auth-key or session_id change invalidates the old TCP lane. - // Never put a transport bound to the previous session back into the slot - // after auth restart, bad-msg recovery, DC migration, or key recreation. - let snapshot_changed = before_snapshot.dc_id != state.snapshot.dc_id - || before_snapshot.auth_key != state.snapshot.auth_key - || before_snapshot.session_id != state.snapshot.session_id; - io.transport = if snapshot_changed || state.session_dead || temp_failed { - None - } else { - slot - }; - io.snapshot = state.snapshot.clone(); - if snapshot_changed || state.session_dead { - io.pending_push = Default::default(); - io.last_difference = None; - } - let identity_changed = - before_user_id != state.user_id || before_snapshot.auth_key != state.snapshot.auth_key; - let drop_dc_auth = identity_changed || before_test_dc != state.test_dc; - if drop_dc_auth { - state.dc_auth_keys.clear(); - } - let dc_keys_changed = before_dc_auth_keys != state.dc_auth_keys; - if identity_changed { - client - .policy - .bound_extra_sessions - .store(0, Ordering::Release); - client - .policy - .main_session_allowance - .store(-1, Ordering::Release); - if state.user_id.is_none() || before_user_id.is_some() { - client.policy.premium.store(false, Ordering::Relaxed); - } - *client.policy.upload_rtt.lock() = None; - } - let updates_changed = before_updates != state.updates; - let tokens_changed = before_logout != state.logout_tokens; - let session_meta_changed = before_new_session != state.new_session - || before_dead != state.session_dead - || before_test_dc != state.test_dc; - let home = durable_snapshot(&state); - if let Some(permanent) = &state.permanent { - io.perm_key = permanent.auth_key.clone(); - io.temp_expires_at = state.temp_expires_at.max(1); - } else if state.snapshot.auth_key != before_snapshot.auth_key - || state.snapshot.dc_id != before_snapshot.dc_id - { - io.temp_expires_at = 0; - io.perm_key = None; - } - let persist_needed; - { - let mut d = client.data.lock(); - d.user_id = state.user_id; - let maps_changed = if before_user_id != state.user_id { - d.peers = state.peers; - d.media = state.media; - d.updates = state.updates; - d.channel_pts = state.channel_pts; - d.channel_recovery = state.channel_recovery; - d.seen_messages = state.seen_messages; - d.updates_started = state.updates_started; - if before_user_id.is_some() { - Arc::make_mut(&mut d.journal).reset_account(); - } - true - } else { - let peers_changed = merge_changed_entries(&mut d.peers, &before_peers, state.peers); - let media_changed = merge_changed_entries(&mut d.media, &before_media, state.media); - let channels_changed = - merge_changed_entries(&mut d.channel_pts, &before_channel_pts, state.channel_pts); - let recovery_changed = state.channel_recovery != before_recovery - && merge_changed_entries( - &mut d.channel_recovery, - &before_recovery, - state.channel_recovery, - ); - let seen_before = d.seen_messages.len(); - d.seen_messages.extend(state.seen_messages); - d.updates = prefer_newer_cursor(d.updates.clone(), state.updates); - d.updates_started |= state.updates_started; - peers_changed - || media_changed - || channels_changed - || recovery_changed - || seen_before != d.seen_messages.len() - || updates_changed - }; - d.logout_tokens = state.logout_tokens; - d.new_session = state.new_session; - d.session_dead = if before_dead { - state.session_dead - } else { - d.session_dead || state.session_dead - }; - if before_dead || state.session_dead || !d.session_dead { - d.session_dead_reason = if state.session_dead { - state.session_dead_reason.clone() - } else { - None - }; - } - if d.session_dead { - io.transport = None; - } - d.home_dc = home.dc_id; - d.home_time_offset = home.time_offset_micros; - d.home_session_id = home.session_id; - d.home_auth_key = home.auth_key.clone(); - d.home_salt = home.server_salt; - d.home_snapshot = home; - d.test_dc = state.test_dc; - d.last_inline = state.last_inline; - if dc_keys_changed { - d.dc_auth_keys = state.dc_auth_keys.clone(); - } - if drop_dc_auth { - d.dc_temp_auth.clear(); - } - persist_needed = result.is_ok() - && (identity_changed - || snapshot_changed - || maps_changed - || tokens_changed - || session_meta_changed - || dc_keys_changed); - if persist_needed { - d.persist_epoch = d.persist_epoch.saturating_add(1); - } - } - if identity_changed { - for lane in client.rpc.iter() { - if let Some(mut rpc) = lane.io.try_lock() { - rpc.temp_expires_at = 0; - rpc.transport = None; - rpc.pending_push = Default::default(); - rpc.last_difference = None; - if io.temp_expires_at == 0 && io.perm_key.is_none() { - rpc.snapshot = fork_session(&io.snapshot); - } - } - } - } - if persist_needed { - let persist_id = client.data.lock().home_session_id; - schedule_persist(&client, persist_id); - } - schedule_file_cleanup(&client); - if temp_failed { - Err(temporary_key_retry_error()) - } else { - result - } -} - -pub(crate) fn home_fork_source(data: &ClientData) -> Option { - let auth = data.home_auth_key.clone()?; - if auth.len() != 256 || data.user_id.is_none() || data.session_dead { - return None; - } - let mut snap = Snapshot::new(data.home_dc, &mut OsRandom).ok()?; - snap.auth_key = Some(auth); - snap.server_salt = data.home_salt; - snap.time_offset_micros = data.home_time_offset; - snap.session_id = data.home_session_id; - Some(snap) -} - -pub(crate) fn with_read_lane( - handle: u64, - mut f: impl FnMut(&mut ClientState) -> Result, -) -> Result { - let client = get_client(handle)?; - let _policy = crate::transfer_policy::bind(client.policy.clone()); - // A non-media DC permits a single main session. Extra read lanes are home-DC - // sessions, so they run only when the server granted them (`config.tmp_sessions`). - if scheduler::extra_main_sessions() == 0 { - return with_client_mut(handle, f); - } - let home = { - let d = client.data.lock(); - home_fork_source(&d) - }; - let Some(home) = home else { - return with_client_mut(handle, f); - }; - // Reads wait for a read lane; the `with_client_mut` paths below are authorization - // repairs, not contention fallbacks. - let mut io = acquire_read_lane(&client)?; - crate::auth::bind_temp::ensure_main_temp(&client, &mut io, false)?; - let bound_temp = crate::auth::bind_temp::temp_key_valid(&io.snapshot, io.temp_expires_at) - && io.snapshot.auth_key.as_ref() != home.auth_key.as_ref() - && io.snapshot.auth_key.as_ref().map(Vec::len) == Some(256); - if !bound_temp { - drop(io); - return with_client_mut(handle, f); - } - let home_session_id = home.session_id; - let home_dc = home.dc_id; - let home_auth = home.auth_key.clone(); - let built = { - let d = client.data.lock(); - if d.session_dead || d.home_session_id != home_session_id { - None - } else { - Some(ClientState { - api_id: d.api_id, - api_hash: d.api_hash.clone(), - session_path: d.session_path.clone(), - snapshot: io.snapshot.clone(), - user_id: d.user_id, - peers: d.peers.clone(), - updates: d.updates.clone(), - media: d.media.clone(), - updates_started: d.updates_started, - channel_pts: d.channel_pts.clone(), - channel_recovery: d.channel_recovery.clone(), - seen_messages: d.seen_messages.clone(), - logout_tokens: d.logout_tokens.clone(), - new_session: d.new_session.clone(), - session_dead: d.session_dead, - session_dead_reason: d.session_dead_reason.clone(), - test_dc: d.test_dc, - last_inline: d.last_inline.clone(), - permanent: (io.temp_expires_at != 0 || io.perm_key.is_some()) - .then(|| d.home_snapshot.clone()), - temp_expires_at: io.temp_expires_at, - dc_auth_keys: d.dc_auth_keys.clone(), - journal: Arc::clone(&d.journal), - }) - } - }; - let mut state = match built { - Some(state) => state, - None => { - drop(io); - return with_client_mut(handle, f); - } - }; - crate::rpc::set_use_test_dc(state.test_dc); - crate::rpc::clear_new_session_metadata(); - let before_peers = state.peers.clone(); - let before_media = state.media.clone(); - let before_channel_pts = state.channel_pts.clone(); - let before_recovery = state.channel_recovery.clone(); - let before_dc_auth_keys = state.dc_auth_keys.clone(); - let mut slot = io.transport.take(); - let before_snapshot = io.snapshot.clone(); - let result = EXTRA_READ_LANE.with(|cell| { - struct Restore<'a>(&'a Cell, bool); - impl Drop for Restore<'_> { - fn drop(&mut self) { - self.0.set(self.1); - } - } - let previous = cell.replace(true); - let _restore = Restore(cell, previous); - crate::api_invoke::with_invoke_without_updates(|| { - with_client_transport(&client, &mut slot, || f(&mut state)) - }) - }); - if let Some(metadata) = crate::rpc::take_new_session_metadata() { - state.new_session = Some(metadata); - } - let temp_failed = result - .as_ref() - .err() - .is_some_and(|err| temporary_key_failure(&state, err)); - if temp_failed { - expire_temporary_lane(&client, &mut io, result.as_ref().err().unwrap()); - return Err(temporary_key_retry_error()); - } - let still_temp = (io.temp_expires_at != 0 || io.perm_key.is_some()) - && state.snapshot.auth_key.as_ref() != home_auth.as_ref(); - let diverged = - state.snapshot.dc_id != home_dc || (state.snapshot.auth_key != home_auth && !still_temp); - if diverged { - io.temp_expires_at = 0; - io.perm_key = None; - io.snapshot = fork_session(&home); - io.transport = None; - drop(io); - return with_client_mut(handle, f); - } - let snapshot_changed = before_snapshot.dc_id != state.snapshot.dc_id - || before_snapshot.auth_key != state.snapshot.auth_key - || before_snapshot.session_id != state.snapshot.session_id; - io.transport = if snapshot_changed || state.session_dead { - None - } else { - slot - }; - io.snapshot = state.snapshot.clone(); - if snapshot_changed || state.session_dead { - io.pending_push = Default::default(); - io.last_difference = None; - } - { - let mut d = client.data.lock(); - if d.home_session_id == home_session_id && !d.session_dead { - let _ = merge_changed_entries(&mut d.peers, &before_peers, state.peers); - let _ = merge_changed_entries(&mut d.media, &before_media, state.media); - let _ = - merge_changed_entries(&mut d.channel_pts, &before_channel_pts, state.channel_pts); - if state.channel_recovery != before_recovery { - merge_changed_entries( - &mut d.channel_recovery, - &before_recovery, - state.channel_recovery, - ); - } - d.seen_messages.extend(state.seen_messages); - d.updates = prefer_newer_cursor(d.updates.clone(), state.updates); - d.updates_started |= state.updates_started; - d.logout_tokens = state.logout_tokens; - if state.new_session.is_some() { - d.new_session = state.new_session; - } - let terminal_changed = !d.session_dead && state.session_dead; - d.session_dead |= state.session_dead; - if state.session_dead { - d.session_dead_reason = state.session_dead_reason.clone(); - } - d.last_inline = state.last_inline; - if state.permanent.is_none() && state.snapshot.auth_key == d.home_auth_key { - d.home_salt = state.snapshot.server_salt; - d.home_snapshot.server_salt = state.snapshot.server_salt; - } - d.home_time_offset = state.snapshot.time_offset_micros; - if before_dc_auth_keys != state.dc_auth_keys { - d.dc_auth_keys = state.dc_auth_keys.clone(); - } - if result.is_ok() || terminal_changed { - d.persist_epoch = d.persist_epoch.saturating_add(1); - } - } - } - drop(io); - schedule_file_cleanup(&client); - if result - .as_ref() - .err() - .and_then(crate::api_invoke::migrate_dc) - .is_none() - { - schedule_persist(&client, home_session_id); - } - if result - .as_ref() - .err() - .and_then(crate::api_invoke::migrate_dc) - .is_some() - { - return with_client_mut(handle, f); - } - result -} - -pub(crate) fn ensure_ready(state: &mut ClientState) -> Result<(), MtprotoError> { - ensure_auth_key(state) -} - -/// Offscreen previews leave gaps durable; only the open chat and exceptions recover them. -pub(crate) fn update_lazy_sync_config(handle: u64, lazy: bool, exceptions: Vec) { - let Ok(client) = get_client(handle) else { - return; - }; - let mut d = client.data.lock(); - if lazy && !d.lazy_channel_updates { - let pending: Vec<_> = d - .channel_recovery - .iter() - .filter(|entry| { - !entry.watching - && entry.chat_id != d.last_history_chat_id - && !exceptions.contains(&entry.chat_id) - }) - .map(|entry| entry.chat_id) - .collect(); - d.lazy_channel_recovery.extend(pending); - } - d.lazy_channel_updates = lazy; - d.lazy_sync_exceptions = exceptions.into_iter().collect(); - if lazy { - return; - } - let pending = std::mem::take(&mut d.lazy_channel_recovery); - for chat_id in pending { - if d.channel_recovery - .iter() - .any(|entry| entry.chat_id == chat_id) - { - continue; - } - d.channel_recovery.push_back(ChannelRecovery { - chat_id, - due_at: 0, - watching: false, - }); - } -} diff --git a/native/mtproto-rs/src/client/passkeys.rs b/native/mtproto-rs/src/client/passkeys.rs deleted file mode 100644 index a3e25d21b..000000000 --- a/native/mtproto-rs/src/client/passkeys.rs +++ /dev/null @@ -1,177 +0,0 @@ -use super::*; -use crate::auth::auth_rpc; -use crate::{PasskeyCredentialDto, PasskeyDto, PasskeyLoginOptionsDto}; -use tellers_mtproto::latest::api::*; - -pub fn passkeys_enabled(handle: u64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let config: HelpAppConfig = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - HelpGetAppConfigRequest { hash: 0 }, - )?; - let HelpAppConfig::HelpAppConfig(config) = config else { - return Ok(false); - }; - let JsonValue::JsonObject(object) = *config.config else { - return Ok(false); - }; - let Vector::Vector(entries) = *object.value; - Ok(entries.field_1.iter().any(|entry| { - let JsonObjectValue::JsonObjectValue(entry) = entry.as_ref(); - entry.key == "settings_display_passkeys" && matches!(entry.value.as_ref(), JsonValue::JsonBool(value) if matches!(value.value.as_ref(), Bool::BoolTrue(_))) - })) - }) - }) -} - -pub fn init_passkey_login(handle: u64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let AuthPasskeyLoginOptions::AuthPasskeyLoginOptions(options) = - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AuthInitPasskeyLoginRequest { - api_id: state.api_id, - api_hash: state.api_hash.clone(), - }, - )?; - let DataJson::DataJson(json) = *options.options; - let snapshot = state.permanent.as_ref().unwrap_or(&state.snapshot); - let key = snapshot - .auth_key - .as_deref() - .ok_or_else(|| MtprotoError::Message("missing passkey login key".into()))?; - Ok(PasskeyLoginOptionsDto { - options: json.data, - dc_id: snapshot.dc_id, - auth_key_id: tellers_mtproto_crypto::auth_key_id(key), - }) - }) - }) -} - -pub fn finish_passkey_login( - handle: u64, - credential: PasskeyCredentialDto, - dc_id: i32, - auth_key_id: i64, -) -> Result { - let (destination, _) = crate::passkeys::login_identity(&credential.user_handle)?; - let credential = crate::passkeys::credential(credential, false)?; - let result = with_client_mut(handle, |state| { - reject_if_dead(state)?; - if state.user_id.is_some() || state.snapshot.dc_id != dc_id || !(1..=5).contains(&dc_id) { - return Err(MtprotoError::Message("stale passkey login".into())); - } - let key = state - .permanent - .as_ref() - .unwrap_or(&state.snapshot) - .auth_key - .as_deref() - .ok_or_else(|| MtprotoError::Message("missing passkey login key".into()))?; - if tellers_mtproto_crypto::auth_key_id(key) != auth_key_id { - return Err(MtprotoError::Message("stale passkey login".into())); - } - let migrated = destination != dc_id; - if migrated { - ensure_ready_after_migrate( - state, - MtprotoError::Message(format!("MIGRATE_{destination}")), - )?; - } - let response = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AuthFinishPasskeyLoginRequest { - flags: if migrated { 1 } else { 0 }, - credential: Box::new(credential), - from_dc_id: migrated.then_some(dc_id), - from_auth_key_id: migrated.then_some(auth_key_id), - }, - ); - match response { - Ok(authorization) => { - let signed = - auth_rpc::authorization_to_signed(authorization, state.snapshot.dc_id)?; - complete_sign_in(state, signed.user_id)?; - Ok(signed) - } - Err(MtprotoError::PasswordRequired) => { - persist(state)?; - Err(MtprotoError::PasswordRequired) - } - Err(error) => Err(error), - } - })?; - drop_updates_transport(handle); - Ok(result) -} - -pub fn init_passkey_registration(handle: u64) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let AccountPasskeyRegistrationOptions::AccountPasskeyRegistrationOptions(options) = - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountInitPasskeyRegistrationRequest {}, - )?; - let DataJson::DataJson(json) = *options.options; - Ok(json.data) - }) - }) -} - -pub fn register_passkey( - handle: u64, - credential: PasskeyCredentialDto, -) -> Result { - let credential = crate::passkeys::credential(credential, true)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountRegisterPasskeyRequest { - credential: Box::new(credential.clone()), - }, - ) - .map(crate::passkeys::to_dto) - }) - }) -} - -pub fn get_passkeys(handle: u64) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let AccountPasskeys::AccountPasskeys(keys) = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountGetPasskeysRequest {}, - )?; - let Vector::Vector(keys) = *keys.passkeys; - Ok(keys - .field_1 - .into_iter() - .map(|key| crate::passkeys::to_dto(*key)) - .collect()) - }) - }) -} - -pub fn delete_passkey(handle: u64, id: String) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let result: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AccountDeletePasskeyRequest { id: id.clone() }, - )?; - Ok(matches!(result, Bool::BoolTrue(_))) - }) - }) -} diff --git a/native/mtproto-rs/src/client/peer_avatar.rs b/native/mtproto-rs/src/client/peer_avatar.rs deleted file mode 100644 index e47036b49..000000000 --- a/native/mtproto-rs/src/client/peer_avatar.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::refresh_dialogs; -use crate::client::{ClientState, call_with_migrate}; -use crate::media; - -const BASIC_GROUP_MIN: i64 = -1_000_000_000_000; - -/// How to load a missing peer avatar. Document ids that share `(id, 0)` are `Skip`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum PeerAvatarFetch { - Skip, - Profile, - Dialogs, -} - -pub(crate) fn peer_avatar_fetch( - peers: &crate::HashMap, - user_id: Option, - chat_id: i64, -) -> PeerAvatarFetch { - if chat_id == 0 || user_id == Some(chat_id) { - return PeerAvatarFetch::Profile; - } - if BASIC_GROUP_MIN < chat_id && chat_id < 0 { - return PeerAvatarFetch::Profile; - } - let Some(peer) = peers.get(&chat_id) else { - return PeerAvatarFetch::Skip; - }; - if !matches!( - peer.kind, - crate::peers::PeerKind::User | crate::peers::PeerKind::Channel - ) { - return PeerAvatarFetch::Skip; - } - if crate::peers::has_usable_access_hash(peer) { - PeerAvatarFetch::Profile - } else { - PeerAvatarFetch::Dialogs - } -} - -fn load_avatar_profile(state: &mut ClientState, chat_id: i64) { - let _ = call_with_migrate(state, |state| { - crate::profile::get_profile( - &mut state.snapshot, - state.api_id, - &state.peers, - &mut state.media, - state.user_id, - chat_id, - ) - }); -} - -pub(crate) fn reindex_peer_avatar(state: &mut ClientState, chat_id: i64) { - media::fill_zero_peer_photo_hashes(&mut state.media, &state.peers); - if state.media.contains_key(&(chat_id, 0)) { - return; - } - match peer_avatar_fetch(&state.peers, state.user_id, chat_id) { - PeerAvatarFetch::Skip => {} - PeerAvatarFetch::Profile => load_avatar_profile(state, chat_id), - PeerAvatarFetch::Dialogs => { - let _ = call_with_migrate(state, refresh_dialogs); - media::fill_zero_peer_photo_hashes(&mut state.media, &state.peers); - if state.media.contains_key(&(chat_id, 0)) { - return; - } - if peer_avatar_fetch(&state.peers, state.user_id, chat_id) == PeerAvatarFetch::Profile { - load_avatar_profile(state, chat_id); - } - } - } - media::fill_zero_peer_photo_hashes(&mut state.media, &state.peers); -} - -#[cfg(test)] -#[path = "../../tests/unit/peer_avatar_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/persist.rs b/native/mtproto-rs/src/client/persist.rs deleted file mode 100644 index a55602ac4..000000000 --- a/native/mtproto-rs/src/client/persist.rs +++ /dev/null @@ -1,336 +0,0 @@ -use std::cell::Cell; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, LazyLock}; - -use parking_lot::Mutex; - -use crate::MtprotoError; -use crate::session_file::{ - ClientSession, FileSessionStore, media_from_index, media_order_from_index, -}; -use crate::tcp; - -use super::*; - -/// Orders session file writes without holding the home RPC lane, and remembers -/// the last committed save per session file. A process-wide counter is wrong: -/// another client's save would make this file look stale and skip its write. -static PERSIST_ORDER: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); -// Disk writes serialize independently from generation lookups. Snapshot capture -// holds the main lane and must never wait for another writer's fsync. -static PERSIST_WRITE: Mutex<()> = Mutex::new(()); - -fn persist_generation(path: &Path) -> Arc { - PERSIST_ORDER - .lock() - .entry(path.to_path_buf()) - .or_insert_with(|| Arc::new(AtomicU64::new(0))) - .clone() -} - -fn bump_persist_clock(clock: &AtomicU64) { - let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| { - Some(value.saturating_add(1)) - }); -} - -/// True when this capture must not be written. A generation change means a -/// direct `persist` committed while the copy was in flight. An epoch change -/// means a newer snapshot was committed and another persist owns it. -pub(crate) fn captured_session_is_stale( - captured_generation: u64, - current_generation: u64, - captured_epoch: u64, - current_epoch: u64, -) -> bool { - captured_generation != current_generation || captured_epoch != current_epoch -} - -thread_local! { - pub(crate) static EXTRA_READ_LANE: Cell = const { Cell::new(false) }; -} - -pub(crate) fn durable_snapshot(state: &ClientState) -> tellers_mtproto_session::Snapshot { - let mut snapshot = state.permanent.as_ref().unwrap_or(&state.snapshot).clone(); - snapshot.time_offset_micros = state.snapshot.time_offset_micros; - snapshot -} - -pub(crate) fn journal_to_save( - snapshot: crate::update_journal::UpdateJournal, - live: Option, -) -> crate::update_journal::UpdateJournal { - let Some(live) = live else { - return snapshot; - }; - if live.account_generation == snapshot.account_generation - && live.next_batch_id >= snapshot.next_batch_id - { - live - } else { - snapshot - } -} - -fn live_journal(path: &Path) -> Option { - let clients = CLIENTS.lock(); - for client in clients.values() { - let data = client.data.lock(); - if data.session_path == path { - return Some((*data.journal).clone()); - } - } - None -} - -pub(crate) fn persist(state: &mut ClientState) -> Result<(), MtprotoError> { - // Extra-lane snapshot_id must never replace the main session on disk. - if EXTRA_READ_LANE.with(Cell::get) { - return Ok(()); - } - crate::media::bound_media_index(&mut state.media); - let session = ClientSession { - snapshot: durable_snapshot(state), - user_id: state.user_id, - peers: state.peers.clone(), - updates: state.updates.clone(), - media: media_from_index(&state.media), - media_order: media_order_from_index(&state.media), - channel_pts: state.channel_pts.clone(), - channel_recovery: state.channel_recovery.clone(), - seen_messages: state.seen_messages.clone(), - logout_tokens: state.logout_tokens.clone(), - new_session: state.new_session.clone(), - session_dead: state.session_dead, - test_dc: state.test_dc, - dc_auth_keys: state.dc_auth_keys.clone(), - journal: journal_to_save((*state.journal).clone(), live_journal(&state.session_path)), - }; - let clock = persist_generation(&state.session_path); - let _write = PERSIST_WRITE.lock(); - FileSessionStore::new(&state.session_path) - .save(&session) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - bump_persist_clock(&clock); - Ok(()) -} - -pub(crate) fn session_lease_valid(data: &ClientData, session_id: i64) -> bool { - data.home_session_id == session_id && !data.session_dead -} - -pub(crate) fn expired_session_lease() -> MtprotoError { - MtprotoError::Message("request cancelled: session changed".into()) -} - -pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result { - // Copy under the home lane, then release it before the disk write. Chat - // opens, media, and channel info share that lane and were waiting out the - // whole save. - let (path, session, journal, epoch, generation) = { - let main = client.main.lock(); - let mut d = client.data.lock(); - if d.home_session_id != session_id { - return Err(expired_session_lease()); - } - crate::media::bound_media_index(&mut d.media); - let mut snapshot = if main.temp_expires_at != 0 || main.perm_key.is_some() { - if d.home_auth_key.is_none() - || d.home_snapshot.auth_key != d.home_auth_key - || d.home_snapshot.session_id != d.home_session_id - || d.home_snapshot.dc_id != d.home_dc - { - return Err(MtprotoError::Message( - "permanent session identity unavailable".into(), - )); - } - d.home_snapshot.clone() - } else { - main.snapshot.clone() - }; - snapshot.time_offset_micros = main.snapshot.time_offset_micros; - let session = ClientSession { - snapshot, - user_id: d.user_id, - peers: d.peers.clone(), - updates: d.updates.clone(), - media: media_from_index(&d.media), - media_order: media_order_from_index(&d.media), - channel_pts: d.channel_pts.clone(), - channel_recovery: d.channel_recovery.clone(), - seen_messages: d.seen_messages.clone(), - logout_tokens: d.logout_tokens.clone(), - new_session: d.new_session.clone(), - session_dead: d.session_dead, - test_dc: d.test_dc, - dc_auth_keys: d.dc_auth_keys.clone(), - journal: d.journal.header_only(), - }; - let journal = Arc::clone(&d.journal); - let path = d.session_path.clone(); - let epoch = d.persist_epoch; - drop(d); - // Read the clock while the home lane is still held, so `persist` - // cannot commit this file between the copy and the snapshot. - let generation = persist_generation(&path).load(Ordering::Acquire); - (path, session, journal, epoch, generation) - }; - let mut session = session; - session.journal = (*journal).clone(); - let clock = persist_generation(&path); - let _write = PERSIST_WRITE.lock(); - { - let d = client.data.lock(); - if d.home_session_id != session_id { - return Err(expired_session_lease()); - } - // A newer snapshot was committed while we copied. The persist loop - // writes that one; do not replace it with this older capture. - if captured_session_is_stale( - generation, - clock.load(Ordering::Acquire), - epoch, - d.persist_epoch, - ) { - return Ok(false); - } - } - #[cfg(test)] - if FORCE_STALE_SAVES - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |left| { - left.checked_sub(1) - }) - .is_ok() - { - return Ok(false); - } - let store = FileSessionStore::new(&path); - client - .connections - .while_persisting(|| store.save_open(&session)) - .map_err(|e| MtprotoError::Message(e.to_string()))? - .map_err(|e| MtprotoError::Message(e.to_string()))?; - bump_persist_clock(&clock); - let mut d = client.data.lock(); - if d.home_session_id == session_id && d.persisted_epoch < epoch { - d.persisted_epoch = epoch; - } - Ok(true) -} - -#[cfg(test)] -pub(crate) static FORCE_STALE_SAVES: AtomicU32 = AtomicU32::new(0); - -use std::sync::atomic::AtomicU32; - -pub(crate) fn persist_journal_sync(client: &Client, session_id: i64) -> Result<(), MtprotoError> { - let mut last = None; - for attempt in 0..JOURNAL_PERSIST_ATTEMPTS { - match persist_updates_data(client, session_id) { - Ok(true) => { - client.persist_exhausted.store(false, Ordering::Release); - return Ok(()); - } - Ok(false) => { - crate::perf::count("journal.persist_superseded"); - continue; - } - Err(error) if !session_lease_valid(&client.data.lock(), session_id) => { - return Err(error); - } - Err(error) => { - crate::perf::count("journal.persist_retry"); - last = Some(error); - if attempt + 1 < JOURNAL_PERSIST_ATTEMPTS { - std::thread::sleep(std::time::Duration::from_millis(25 << attempt)); - } - } - } - } - crate::perf::count("journal.persist_exhausted"); - client.persist_exhausted.store(true, Ordering::Release); - Err(last.unwrap_or_else(|| MtprotoError::Message("journal persistence failed".into()))) -} - -const JOURNAL_PERSIST_ATTEMPTS: u32 = 4; - -/// Coalesce session writes so getHistory/getChats can return before disk I/O. -/// In-memory state is already merged; this is crash recovery, not the RPC result. -pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { - client.persist_queued.store(true, Ordering::Release); - if client.persist_running.swap(true, Ordering::AcqRel) { - return; - } - let worker_client = Arc::clone(client); - let spawned = std::thread::Builder::new() - .name("mtproto-persist".into()) - .spawn(move || { - let client = worker_client; - let mut failures = 0_u32; - loop { - client.persist_queued.store(false, Ordering::Release); - let skip = { - let d = client.data.lock(); - d.persist_epoch == d.persisted_epoch - }; - if !skip { - let span = crate::perf::span("persist_session"); - let result = persist_updates_data(&client, session_id); - drop(span); - if result.as_ref().ok() == Some(&true) { - client.persist_exhausted.store(false, Ordering::Release); - } - if result.as_ref().ok() == Some(&false) { - client.persist_queued.store(true, Ordering::Release); - continue; - } - if result.is_err() { - crate::perf::count("persist_session.failed"); - client.persist_queued.store(true, Ordering::Release); - failures += 1; - if failures >= 4 || !session_lease_valid(&client.data.lock(), session_id) { - client.persist_exhausted.store(true, Ordering::Release); - client.persist_running.store(false, Ordering::Release); - break; - } - std::thread::sleep(std::time::Duration::from_millis(100 * (1 << failures))); - continue; - } - failures = 0; - } - if client.persist_queued.load(Ordering::Acquire) { - continue; - } - client.persist_running.store(false, Ordering::Release); - if client.persist_queued.load(Ordering::Acquire) - && !client.persist_running.swap(true, Ordering::AcqRel) - { - continue; - } - break; - } - }); - if spawned.is_err() { - crate::perf::count("persist_session.spawn_failed"); - client.persist_running.store(false, Ordering::Release); - } -} - -#[cfg(test)] -#[path = "../../tests/unit/client_persist_tests.rs"] -mod tests; - -pub(crate) fn flush_persist(client: &Arc) { - let session_id = client.data.lock().home_session_id; - schedule_persist(client, session_id); - let started = std::time::Instant::now(); - while (client.persist_running.load(Ordering::Acquire) - || client.persist_queued.load(Ordering::Acquire)) - && started.elapsed() < std::time::Duration::from_secs(2) - { - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} diff --git a/native/mtproto-rs/src/client/polls.rs b/native/mtproto-rs/src/client/polls.rs deleted file mode 100644 index 693f7f2ff..000000000 --- a/native/mtproto-rs/src/client/polls.rs +++ /dev/null @@ -1,116 +0,0 @@ -use super::*; -use crate::peers::input_peer_from_cached; -use tellers_mtproto::latest::api::*; - -pub fn close_poll(handle: u64, chat_id: i64, message_id: i32) -> Result<(), MtprotoError> { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - with_peer_refresh(state, chat_id, |state| { - let messages = crate::dialogs::fetch_message( - &mut state.snapshot, - state.api_id, - &state.peers, - chat_id, - message_id, - )?; - let peer = input_peer_from_cached(crate::peers::require_usable_peer( - &state.peers, - chat_id, - )?); - let Some(request) = close_request(&messages, peer, chat_id, message_id)? else { - return Ok(()); - }; - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) - }) -} - -fn close_request( - messages: &Vector>, - peer: InputPeer, - chat_id: i64, - message_id: i32, -) -> Result, MtprotoError> { - let mut rows = crate::peers::vector_boxed_items(messages); - let Some(Message::Message(message)) = rows.next() else { - return Err(MtprotoError::Message("poll unavailable".into())); - }; - if rows.next().is_some() - || message.id != message_id - || crate::peers::peer_chat_id(&message.peer_id) != chat_id - { - return Err(MtprotoError::Message("unexpected message identity".into())); - } - let Some(MessageMedia::MessageMediaPoll(media)) = message.media.as_deref() else { - return Err(MtprotoError::Message("poll unavailable".into())); - }; - let Poll::Poll(poll) = media.poll.as_ref(); - close_poll_media(poll).map(|media| { - media.map(|media| MessagesEditMessageRequest { - flags: MessagesEditMessageRequest::MEDIA_FLAG, - no_webpage: None, - invert_media: None, - peer: Box::new(peer), - id: message_id, - message: None, - media: Some(Box::new(media)), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - }) - }) -} - -fn close_poll_media(poll: &PollConstructor) -> Result, MtprotoError> { - if poll.creator.is_none() { - return Err(MtprotoError::Message("poll creator required".into())); - } - if poll.closed.is_some() { - return Ok(None); - } - let closed = PollConstructor { - id: poll.id, - flags: PollConstructor::CLOSED_FLAG, - closed: Some(Box::new(True::True(TrueConstructor {}))), - public_voters: None, - multiple_choice: None, - quiz: None, - open_answers: None, - revoting_disabled: None, - shuffle_answers: None, - hide_results_until_close: None, - creator: None, - subscribers_only: None, - question: poll.question.clone(), - answers: poll.answers.clone(), - close_period: None, - close_date: None, - countries_iso2: None, - hash: 0, - }; - Ok(Some(InputMedia::InputMediaPoll( - InputMediaPollConstructor { - flags: 0, - poll: Box::new(Poll::Poll(closed)), - correct_answers: None, - attached_media: None, - solution: None, - solution_entities: None, - solution_media: None, - }, - ))) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_poll_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/push_rpc.rs b/native/mtproto-rs/src/client/push_rpc.rs deleted file mode 100644 index 128b0124f..000000000 --- a/native/mtproto-rs/src/client/push_rpc.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! Device push registration, notify settings, and FCM payload decrypt. -//! https://core.telegram.org/api/push-updates -//! https://core.telegram.org/method/account.registerDevice - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - AccountGetNotifyExceptionsRequest, AccountGetNotifySettingsRequest, - AccountRegisterDeviceRequest, AccountResetNotifySettingsRequest, - AccountSetContactSignUpNotificationRequest, AccountUnregisterDeviceRequest, - AccountUpdateNotifySettingsRequest, Bool, BoolFalseConstructor, BoolTrueConstructor, - InputNotifyBroadcastsConstructor, InputNotifyChatsConstructor, InputNotifyPeer, - InputNotifyPeerConstructor, InputNotifyUsersConstructor, InputPeerNotifySettings, - InputPeerNotifySettingsConstructor, NotificationSound, NotificationSoundDefaultConstructor, - NotificationSoundNoneConstructor, NotifyPeer, PeerNotifySettings, True, TrueConstructor, - Update, Updates, Vector, VectorConstructor, -}; -use tellers_mtproto_crypto::{ - Direction, aes_ige_decrypt, aes_ige_encrypt, auth_key_id, constant_time_eq, message_aes_key_iv, - message_key, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::peers::{self, CachedPeer, input_peer_from_cached, peer_chat_id, vector_boxed_items}; -use crate::{MtprotoError, NotifyExceptionDto, NotifySettingsDto}; - -pub const TOKEN_TYPE_FCM: i32 = 2; -pub const TOKEN_TYPE_SIMPLE: i32 = 4; -pub const TOKEN_TYPE_WEB_PUSH: i32 = 10; - -fn tl_bool(value: bool) -> Box { - if value { - Box::new(Bool::BoolTrue(BoolTrueConstructor {})) - } else { - Box::new(Bool::BoolFalse(BoolFalseConstructor {})) - } -} - -fn tl_true() -> Box { - Box::new(True::True(TrueConstructor {})) -} - -fn as_bool(value: &Option>) -> bool { - matches!(value, Some(flag) if matches!(flag.as_ref(), Bool::BoolTrue(_))) -} - -fn long_vector(values: Vec) -> Box> { - Box::new(Vector::Vector(VectorConstructor { - field_0: values.len() as u32, - field_1: values, - })) -} - -fn input_notify( - kind: &str, - chat_id: i64, - peers: &HashMap, -) -> Result, MtprotoError> { - Ok(Box::new(match kind { - "users" => InputNotifyPeer::InputNotifyUsers(InputNotifyUsersConstructor {}), - "chats" => InputNotifyPeer::InputNotifyChats(InputNotifyChatsConstructor {}), - "broadcasts" => InputNotifyPeer::InputNotifyBroadcasts(InputNotifyBroadcastsConstructor {}), - "peer" => { - let cached = peers::require_usable_peer(peers, chat_id)?; - InputNotifyPeer::InputNotifyPeer(InputNotifyPeerConstructor { - peer: Box::new(input_peer_from_cached(cached)), - }) - } - other => { - return Err(MtprotoError::Message(format!( - "notify peer invalid: {other}" - ))); - } - })) -} - -fn sound_from_name(name: &str) -> Option> { - match name { - "" | "keep" => None, - "none" => Some(Box::new(NotificationSound::NotificationSoundNone( - NotificationSoundNoneConstructor {}, - ))), - _ => Some(Box::new(NotificationSound::NotificationSoundDefault( - NotificationSoundDefaultConstructor {}, - ))), - } -} - -fn sound_name(sound: Option<&NotificationSound>) -> String { - match sound { - Some(NotificationSound::NotificationSoundNone(_)) => "none".into(), - Some(NotificationSound::NotificationSoundDefault(_)) => "default".into(), - Some(_) => "custom".into(), - None => "default".into(), - } -} - -fn settings_dto(settings: &PeerNotifySettings) -> NotifySettingsDto { - let PeerNotifySettings::PeerNotifySettings(body) = settings else { - return NotifySettingsDto::default(); - }; - NotifySettingsDto { - show_previews: body - .show_previews - .as_ref() - .map(|flag| matches!(flag.as_ref(), Bool::BoolTrue(_))), - silent: as_bool(&body.silent), - mute_until: body.mute_until.unwrap_or(0), - stories_muted: as_bool(&body.stories_muted), - stories_hide_sender: as_bool(&body.stories_hide_sender), - sound: sound_name( - body.android_sound - .as_deref() - .or(body.other_sound.as_deref()), - ), - } -} - -/// `mute_until` omitted means the peer still inherits its type default. -pub(crate) fn update_mute_specified(update: &Update) -> bool { - let Update::UpdateNotifySettings(body) = update else { - return false; - }; - let PeerNotifySettings::PeerNotifySettings(settings) = body.notify_settings.as_ref() else { - return false; - }; - settings.mute_until.is_some() -} - -pub(crate) fn exception_from_update(update: &Update) -> Option { - let Update::UpdateNotifySettings(body) = update else { - return None; - }; - let settings = settings_dto(body.notify_settings.as_ref()); - let (peer_kind, chat_id, topic_id) = match body.peer.as_ref() { - NotifyPeer::NotifyUsers(_) => ("users", 0_i64, None), - NotifyPeer::NotifyChats(_) => ("chats", 0_i64, None), - NotifyPeer::NotifyBroadcasts(_) => ("broadcasts", 0_i64, None), - NotifyPeer::NotifyPeer(peer) => ("peer", peer_chat_id(peer.peer.as_ref()), None), - NotifyPeer::NotifyForumTopic(topic) => ( - "peer", - peer_chat_id(topic.peer.as_ref()), - Some(topic.top_msg_id), - ), - _ => return None, - }; - Some(NotifyExceptionDto { - peer_kind: peer_kind.into(), - chat_id, - topic_id, - show_previews: settings.show_previews, - silent: settings.silent, - mute_until: settings.mute_until, - stories_muted: settings.stories_muted, - stories_hide_sender: settings.stories_hide_sender, - sound: settings.sound, - }) -} - -fn collect_updates(updates: &Updates) -> Vec<&Update> { - match updates { - Updates::Updates(body) => vector_boxed_items(&body.updates).collect(), - Updates::UpdatesCombined(body) => vector_boxed_items(&body.updates).collect(), - Updates::UpdateShort(body) => vec![body.update.as_ref()], - _ => Vec::new(), - } -} - -pub fn register_device( - snapshot: &mut Snapshot, - api_id: i32, - token_type: i32, - token: String, - secret: Vec, - no_muted: bool, - app_sandbox: bool, - other_uids: Vec, -) -> Result<(), MtprotoError> { - if token.trim().is_empty() { - return Err(MtprotoError::Message("TOKEN_EMPTY".into())); - } - if !matches!( - token_type, - TOKEN_TYPE_FCM | TOKEN_TYPE_SIMPLE | TOKEN_TYPE_WEB_PUSH - ) { - return Err(MtprotoError::Message("TOKEN_TYPE_INVALID".into())); - } - if token_type == TOKEN_TYPE_FCM && !secret.is_empty() && secret.len() != 256 { - return Err(MtprotoError::Message( - "push secret must be 256 bytes".into(), - )); - } - let flags = if no_muted { - AccountRegisterDeviceRequest::NO_MUTED_FLAG - } else { - 0 - }; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - AccountRegisterDeviceRequest { - flags, - no_muted: no_muted.then(tl_true), - token_type, - token, - app_sandbox: tl_bool(app_sandbox), - secret, - other_uids: long_vector(other_uids), - }, - )?; - Ok(()) -} - -pub fn unregister_device( - snapshot: &mut Snapshot, - api_id: i32, - token_type: i32, - token: String, - other_uids: Vec, -) -> Result<(), MtprotoError> { - if token.trim().is_empty() { - return Err(MtprotoError::Message("TOKEN_EMPTY".into())); - } - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - AccountUnregisterDeviceRequest { - token_type, - token, - other_uids: long_vector(other_uids), - }, - )?; - Ok(()) -} - -pub fn get_notify_settings( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - peer_kind: String, - chat_id: i64, -) -> Result { - let response: PeerNotifySettings = api_invoke::invoke_api( - snapshot, - api_id, - AccountGetNotifySettingsRequest { - peer: input_notify(&peer_kind, chat_id, peers)?, - }, - )?; - Ok(settings_dto(&response)) -} - -pub fn update_notify_settings( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - peer_kind: String, - chat_id: i64, - show_previews: Option, - silent: bool, - mute_until: i32, - stories_muted: bool, - sound: String, -) -> Result<(), MtprotoError> { - let mut flags = InputPeerNotifySettingsConstructor::SILENT_FLAG - | InputPeerNotifySettingsConstructor::MUTE_UNTIL_FLAG - | InputPeerNotifySettingsConstructor::STORIES_MUTED_FLAG; - let sound_value = sound_from_name(&sound); - if show_previews.is_some() { - flags |= InputPeerNotifySettingsConstructor::SHOW_PREVIEWS_FLAG; - } - if sound_value.is_some() { - flags |= InputPeerNotifySettingsConstructor::SOUND_FLAG; - } - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - AccountUpdateNotifySettingsRequest { - peer: input_notify(&peer_kind, chat_id, peers)?, - settings: Box::new(InputPeerNotifySettings::InputPeerNotifySettings( - InputPeerNotifySettingsConstructor { - flags, - show_previews: show_previews.map(tl_bool), - silent: Some(tl_bool(silent)), - mute_until: Some(mute_until), - sound: sound_value, - stories_muted: Some(tl_bool(stories_muted)), - stories_hide_sender: None, - stories_sound: None, - }, - )), - }, - )?; - Ok(()) -} - -pub fn reset_notify_settings(snapshot: &mut Snapshot, api_id: i32) -> Result<(), MtprotoError> { - let _: Bool = api_invoke::invoke_api(snapshot, api_id, AccountResetNotifySettingsRequest {})?; - Ok(()) -} - -pub fn set_contact_joined_silent( - snapshot: &mut Snapshot, - api_id: i32, - silent: bool, -) -> Result<(), MtprotoError> { - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - AccountSetContactSignUpNotificationRequest { - silent: tl_bool(silent), - }, - )?; - Ok(()) -} - -pub fn get_notify_exceptions( - snapshot: &mut Snapshot, - api_id: i32, - compare_sound: bool, -) -> Result, MtprotoError> { - let mut flags = 0_u32; - let compare = if compare_sound { - flags |= AccountGetNotifyExceptionsRequest::COMPARE_SOUND_FLAG; - Some(tl_true()) - } else { - None - }; - let updates: Updates = api_invoke::invoke_api( - snapshot, - api_id, - AccountGetNotifyExceptionsRequest { - flags, - compare_sound: compare, - compare_stories: None, - peer: None, - }, - )?; - Ok(collect_updates(&updates) - .into_iter() - .filter_map(exception_from_update) - .collect()) -} - -fn b64url_decode(input: &str) -> Result, MtprotoError> { - let mut padded = input.replace('-', "+").replace('_', "/"); - while padded.len() % 4 != 0 { - padded.push('='); - } - decode_std_base64(&padded).ok_or_else(|| MtprotoError::Message("push payload invalid".into())) -} - -fn b64url_encode(bytes: &[u8]) -> String { - let mut out = encode_std_base64(bytes); - out = out.replace('+', "-").replace('/', "_"); - while out.ends_with('=') { - out.pop(); - } - out -} - -fn decode_std_base64(input: &str) -> Option> { - fn val(c: u8) -> Option { - match c { - b'A'..=b'Z' => Some(c - b'A'), - b'a'..=b'z' => Some(c - b'a' + 26), - b'0'..=b'9' => Some(c - b'0' + 52), - b'+' => Some(62), - b'/' => Some(63), - b'=' => Some(0), - _ => None, - } - } - let bytes = input.as_bytes(); - if bytes.len() % 4 != 0 { - return None; - } - let mut out = Vec::with_capacity(bytes.len() / 4 * 3); - for chunk in bytes.chunks(4) { - let a = val(chunk[0])?; - let b = val(chunk[1])?; - let c = val(chunk[2])?; - let d = val(chunk[3])?; - out.push((a << 2) | (b >> 4)); - if chunk[2] != b'=' { - out.push((b << 4) | (c >> 2)); - } - if chunk[3] != b'=' { - out.push((c << 6) | d); - } - } - Some(out) -} - -fn encode_std_base64(bytes: &[u8]) -> String { - const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::new(); - let mut i = 0; - while i < bytes.len() { - let remaining = bytes.len() - i; - let b0 = bytes[i]; - let b1 = if remaining > 1 { bytes[i + 1] } else { 0 }; - let b2 = if remaining > 2 { bytes[i + 2] } else { 0 }; - out.push(TABLE[(b0 >> 2) as usize] as char); - out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - if remaining == 1 { - out.push('='); - out.push('='); - } else { - out.push(TABLE[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char); - if remaining == 2 { - out.push('='); - } else { - out.push(TABLE[(b2 & 0x3f) as usize] as char); - } - } - i += 3; - } - out -} - -/// Decrypt Telegram FCM `p` (base64url MTProto v2) into JSON. -/// Unencrypted JSON and simple-push `version=` wake hints are accepted as-is. -pub fn decrypt_push_payload(secret: Vec, payload: String) -> Result { - let trimmed = payload.trim(); - if trimmed.is_empty() { - return Err(MtprotoError::Message("TOKEN_EMPTY".into())); - } - if trimmed.starts_with('{') { - return Ok(trimmed.to_string()); - } - if trimmed.starts_with("version=") { - return Ok("{\"loc_key\":\"WAKE\"}".into()); - } - let packet = b64url_decode(trimmed)?; - if secret.is_empty() { - if let Ok(text) = std::str::from_utf8(&packet) { - let text = text.trim(); - if text.starts_with('{') { - return Ok(text.to_string()); - } - } - return Err(MtprotoError::Message("push secret missing".into())); - } - if secret.len() != 256 { - return Err(MtprotoError::Message( - "push secret must be 256 bytes".into(), - )); - } - if packet.len() < 24 + 16 || (packet.len() - 24) % 16 != 0 { - return Err(MtprotoError::Message("push payload invalid".into())); - } - let key_id = i64::from_le_bytes(packet[0..8].try_into().expect("key id")); - if key_id != auth_key_id(&secret) { - return Err(MtprotoError::Message("push auth_key_id mismatch".into())); - } - let msg_key: [u8; 16] = packet[8..24].try_into().expect("msg key"); - let (aes_key, aes_iv) = message_aes_key_iv(&secret, &msg_key, Direction::ServerToClient) - .map_err(|_| MtprotoError::Message("push decrypt failed".into()))?; - let plaintext = aes_ige_decrypt(&packet[24..], &aes_key, &aes_iv) - .map_err(|_| MtprotoError::Message("push decrypt failed".into()))?; - let calculated = message_key(&secret, &plaintext, Direction::ServerToClient) - .map_err(|_| MtprotoError::Message("push decrypt failed".into()))?; - if !constant_time_eq(&msg_key, &calculated) { - return Err(MtprotoError::Message("push msg_key mismatch".into())); - } - if plaintext.len() < 4 { - return Err(MtprotoError::Message("push payload invalid".into())); - } - let json_len = u32::from_le_bytes(plaintext[0..4].try_into().expect("len")) as usize; - if json_len == 0 || 4 + json_len > plaintext.len() { - return Err(MtprotoError::Message("push payload invalid".into())); - } - let json = std::str::from_utf8(&plaintext[4..4 + json_len]) - .map_err(|_| MtprotoError::Message("push payload invalid".into()))?; - Ok(json.to_string()) -} - -pub fn encrypt_push_payload_for_test(secret: &[u8], json: &str) -> Result { - if secret.len() != 256 { - return Err(MtprotoError::Message( - "push secret must be 256 bytes".into(), - )); - } - let mut plaintext = Vec::new(); - plaintext.extend_from_slice(&(json.len() as u32).to_le_bytes()); - plaintext.extend_from_slice(json.as_bytes()); - let mut pad = 16 - (plaintext.len() % 16); - if pad < 12 { - pad += 16; - } - plaintext.extend(std::iter::repeat(0xAB).take(pad)); - let msg_key = message_key(secret, &plaintext, Direction::ServerToClient) - .map_err(|_| MtprotoError::Message("push encrypt failed".into()))?; - let (aes_key, aes_iv) = message_aes_key_iv(secret, &msg_key, Direction::ServerToClient) - .map_err(|_| MtprotoError::Message("push encrypt failed".into()))?; - let encrypted = aes_ige_encrypt(&plaintext, &aes_key, &aes_iv) - .map_err(|_| MtprotoError::Message("push encrypt failed".into()))?; - let mut packet = Vec::with_capacity(24 + encrypted.len()); - packet.extend_from_slice(&auth_key_id(secret).to_le_bytes()); - packet.extend_from_slice(&msg_key); - packet.extend_from_slice(&encrypted); - Ok(b64url_encode(&packet)) -} - -#[cfg(test)] -#[path = "../../tests/unit/push_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/scheduled.rs b/native/mtproto-rs/src/client/scheduled.rs deleted file mode 100644 index 771c3583d..000000000 --- a/native/mtproto-rs/src/client/scheduled.rs +++ /dev/null @@ -1,364 +0,0 @@ -use std::collections::HashSet; - -use tellers_mtproto::latest::api::*; - -use super::{call_with_migrate, with_client_mut}; -use crate::peers::{input_peer_from_cached, require_usable_peer}; -use crate::{MessageDto, MtprotoError}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct ScheduledMessageDto { - pub message: MessageDto, - #[serde(default = "unsupported_by_default")] - pub has_unsupported_content: bool, -} - -fn unsupported_by_default() -> bool { - true -} - -pub const SCHEDULE_WHEN_ONLINE: i32 = 0x7fff_fffe; - -pub fn get_scheduled_history( - handle: u64, - chat_id: i64, -) -> Result, MtprotoError> { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = input_peer_from_cached(require_usable_peer(&state.peers, chat_id)?); - let response: MessagesMessages = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetScheduledHistoryRequest { - peer: Box::new(peer), - hash: 0, - }, - )?; - map_response(response, &mut state.peers, &mut state.media) - }) - }) -} - -pub fn get_scheduled_messages( - handle: u64, - chat_id: i64, - ids: Vec, -) -> Result, MtprotoError> { - validate_ids(&ids)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = input_peer_from_cached(require_usable_peer(&state.peers, chat_id)?); - let response: MessagesMessages = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetScheduledMessagesRequest { - peer: Box::new(peer), - id: vector(ids.clone()), - }, - )?; - map_response(response, &mut state.peers, &mut state.media) - }) - }) -} - -pub fn send_scheduled_messages( - handle: u64, - chat_id: i64, - ids: Vec, -) -> Result<(), MtprotoError> { - change_queue(handle, chat_id, ids, true) -} - -pub fn delete_scheduled_messages( - handle: u64, - chat_id: i64, - ids: Vec, -) -> Result<(), MtprotoError> { - change_queue(handle, chat_id, ids, false) -} - -fn change_queue(handle: u64, chat_id: i64, ids: Vec, send: bool) -> Result<(), MtprotoError> { - validate_ids(&ids)?; - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = Box::new(input_peer_from_cached(require_usable_peer( - &state.peers, - chat_id, - )?)); - let response: Updates = if send { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesSendScheduledMessagesRequest { - peer, - id: vector(ids.clone()), - }, - )? - } else { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesDeleteScheduledMessagesRequest { - peer, - id: vector(ids.clone()), - }, - )? - }; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) -} - -pub fn edit_scheduled_message( - handle: u64, - chat_id: i64, - message_id: i32, - schedule_date: i32, - text: Option, - entities_json: Option, - no_webpage: bool, -) -> Result<(), MtprotoError> { - validate_ids(&[message_id])?; - validate_date(schedule_date)?; - if text.is_none() && (entities_json.is_some() || no_webpage) { - return Err(MtprotoError::Message( - "scheduled formatting requires replacement text".into(), - )); - } - with_client_mut(handle, |state| { - call_with_migrate(state, |state| { - let peer = input_peer_from_cached(require_usable_peer(&state.peers, chat_id)?); - if text.is_some() { - let response: MessagesMessages = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetScheduledMessagesRequest { - peer: Box::new(peer.clone()), - id: vector(vec![message_id]), - }, - )?; - let current = map_response(response, &mut state.peers, &mut state.media)?; - require_editable(¤t, chat_id, message_id)?; - if current[0].message.date != i64::from(schedule_date) { - return Err(MtprotoError::Message( - "scheduled date changed; refresh before editing".into(), - )); - } - } - let request = edit_request( - peer, - message_id, - schedule_date, - text.clone(), - entities_json.as_deref(), - no_webpage, - &state.peers, - )?; - let response: Updates = - crate::api_invoke::invoke_api(&mut state.snapshot, state.api_id, request)?; - super::management::cache_response_peers(state, &response); - super::management::enqueue_response(state.snapshot.dc_id, &response) - }) - }) -} - -fn require_editable( - messages: &[ScheduledMessageDto], - chat_id: i64, - id: i32, -) -> Result<(), MtprotoError> { - if matches!(messages, [value] if value.message.chat_id == chat_id && value.message.id == id && !value.has_unsupported_content - && matches!(value.message.media_kind.as_deref(), None | Some("photo" | "video" | "gif" | "document" | "audio"))) - { - Ok(()) - } else { - Err(MtprotoError::Message( - "scheduled content cannot be safely edited".into(), - )) - } -} - -fn edit_request( - peer: InputPeer, - id: i32, - date: i32, - text: Option, - entities_json: Option<&str>, - no_webpage: bool, - peers: &crate::HashMap, -) -> Result { - let mut flags = MessagesEditMessageRequest::SCHEDULE_DATE_FLAG; - let entities = if text.is_some() { - flags |= - MessagesEditMessageRequest::MESSAGE_FLAG | MessagesEditMessageRequest::ENTITIES_FLAG; - Some( - crate::messages::input_entities_from_json(entities_json, peers)? - .1 - .unwrap_or_else(|| vector(Vec::new())), - ) - } else { - None - }; - if no_webpage { - flags |= MessagesEditMessageRequest::NO_WEBPAGE_FLAG; - } - Ok(MessagesEditMessageRequest { - flags, - no_webpage: no_webpage.then(|| Box::new(True::True(TrueConstructor {}))), - invert_media: None, - peer: Box::new(peer), - id, - message: text, - media: None, - reply_markup: None, - entities, - schedule_date: Some(date), - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - }) -} - -pub(crate) fn validate_date(date: i32) -> Result<(), MtprotoError> { - if date <= 0 || date == i32::MAX { - return Err(MtprotoError::Message("invalid scheduled date".into())); - } - Ok(()) -} - -fn validate_ids(ids: &[i32]) -> Result<(), MtprotoError> { - if ids.is_empty() - || ids.len() > 100 - || ids.iter().any(|id| *id <= 0) - || ids.iter().collect::>().len() != ids.len() - { - return Err(MtprotoError::Message( - "invalid scheduled message selection".into(), - )); - } - Ok(()) -} - -fn vector(items: Vec) -> Box> { - Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - })) -} - -pub(super) fn refresh_media( - state: &mut super::ClientState, - chat_id: i64, - id: i32, -) -> Result<(), MtprotoError> { - let peer = input_peer_from_cached(require_usable_peer(&state.peers, chat_id)?); - let response: MessagesMessages = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetScheduledMessagesRequest { - peer: Box::new(peer), - id: vector(vec![id]), - }, - )?; - state - .media - .shift_remove(&(chat_id, crate::media::scheduled_media_id(id)?)); - map_response(response, &mut state.peers, &mut state.media)?; - Ok(()) -} - -pub(crate) fn map_update( - message: &Message, - media: &mut crate::media::MediaIndex, -) -> Result { - if let Message::Message(value) = message { - media.shift_remove(&( - crate::peers::peer_chat_id(&value.peer_id), - crate::media::scheduled_media_id(value.id)?, - )); - } - let response = MessagesMessages::MessagesMessages(MessagesMessagesConstructor { - messages: vector(vec![Box::new(message.clone())]), - topics: vector(Vec::new()), - users: vector(Vec::new()), - chats: vector(Vec::new()), - }); - map_response(response, &mut crate::HashMap::default(), media)? - .into_iter() - .next() - .ok_or_else(|| MtprotoError::Message("scheduled update has no message".into())) -} - -pub(crate) fn map_response( - mut response: MessagesMessages, - peers: &mut crate::HashMap, - media: &mut crate::media::MediaIndex, -) -> Result, MtprotoError> { - let messages = match &mut response { - MessagesMessages::MessagesMessages(value) => &mut value.messages, - MessagesMessages::MessagesMessagesSlice(value) => &mut value.messages, - MessagesMessages::MessagesChannelMessages(value) => &mut value.messages, - _ => { - return Err(MtprotoError::Message( - "unexpected scheduled history response".into(), - )); - } - }; - let mut entities = crate::peers::vector_boxed_items(messages) - .filter_map(|message| match message { - Message::Message(value) => { - let json = super::drafts::draft_entities_json(value.entities.as_deref()); - let unsupported = (value - .entities - .as_deref() - .is_some_and(|items| !crate::peers::vector_items(items).is_empty()) - && json.is_none()) - || value.rich_message.is_some() - || value.invert_media.is_some() - || value.media.as_deref().is_some_and(|media| { - !matches!( - media, - MessageMedia::MessageMediaEmpty(_) - | MessageMedia::MessageMediaPhoto(_) - | MessageMedia::MessageMediaDocument(_) - ) - }); - Some((value.id, (json, unsupported))) - } - _ => None, - }) - .collect::>(); - if let Vector::Vector(items) = messages.as_mut() { - for message in &mut items.field_1 { - let id = match message.as_mut() { - Message::Message(value) => &mut value.id, - Message::MessageService(value) => &mut value.id, - Message::MessageEmpty(value) => &mut value.id, - _ => return Err(MtprotoError::Message("unexpected scheduled message".into())), - }; - *id = crate::media::scheduled_media_id(*id)?; - } - } - Ok(crate::dialogs::dtos_from_messages(response, peers, media)? - .into_iter() - .map(|mut message| { - message.id = crate::media::scheduled_message_id(message.id).expect("mapped queue ID"); - let has_unsupported_content = - if let Some((value, unsupported)) = entities.remove(&message.id) { - message.entities_json = value; - unsupported - } else { - true - }; - ScheduledMessageDto { - message, - has_unsupported_content, - } - }) - .collect()) -} - -#[cfg(test)] -#[path = "../../tests/unit/client_scheduled_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/client/session.rs b/native/mtproto-rs/src/client/session.rs deleted file mode 100644 index cd6f39b3f..000000000 --- a/native/mtproto-rs/src/client/session.rs +++ /dev/null @@ -1,453 +0,0 @@ -use tellers_mtproto_session::{OsRandom, Snapshot}; -use tellers_mtproto_transport::PaddedIntermediate; - -use crate::MtprotoError; -use crate::api_invoke; -use crate::auth::auth_key::create_auth_key; -use crate::rpc::dc_endpoints; -use crate::tcp; - -use super::*; - -/// File parts use one extra session on the same datacenter authorization. -/// It keeps its own `session_id` so it is not a second main session. -/// https://core.telegram.org/api/optimisation -/// https://core.telegram.org/api/datacenter#parallel-sessions -pub(crate) fn sync_upload_snapshot(upload: &mut Snapshot, home: &Snapshot) -> bool { - let replaced = upload.dc_id != home.dc_id - || upload.auth_key != home.auth_key - || upload.session_id == home.session_id; - if replaced { - let previous = upload.session_id; - *upload = fork_session(home); - if previous != upload.session_id { - crate::rpc::salts::forget_session(previous); - } - return true; - } - upload.server_salt = home.server_salt; - upload.time_offset_micros = home.time_offset_micros; - false -} - -pub(crate) fn fork_session(home: &Snapshot) -> Snapshot { - let mut forked = Snapshot::new(home.dc_id, &mut OsRandom).expect("updates session"); - forked.auth_key = home.auth_key.clone(); - forked.server_salt = home.server_salt; - forked.time_offset_micros = home.time_offset_micros; - forked -} - -pub(crate) fn apply_home_auth( - target: &mut Snapshot, - dc: i32, - auth: Option>, - salt: i64, - time_offset: i64, -) { - let changed = target.dc_id != dc || target.auth_key != auth; - target.dc_id = dc; - target.clear_auth_key(); - target.auth_key = auth; - target.server_salt = salt; - target.time_offset_micros = time_offset; - if changed { - let previous = target.session_id; - let fresh = Snapshot::new(dc, &mut OsRandom).expect("resession"); - target.session_id = fresh.session_id; - if previous != target.session_id { - crate::rpc::salts::forget_session(previous); - } - target.last_message_id = 0; - target.content_sequence = 0; - target.pending_acknowledgements.clear(); - target.received_message_ids.clear(); - target.reconnect = Default::default(); - } -} - -pub(crate) fn ensure_auth_key_on(snapshot: &mut Snapshot) -> Result<(), MtprotoError> { - if snapshot.auth_key.as_ref().map(|k| k.len()) == Some(256) { - return Ok(()); - } - // TCP connect + DH auth-key exchange, timed apart from the lane wait. - let span = crate::perf::span("handshake"); - let mut conn = tcp::connect_obfuscated_dc(dc_endpoints(snapshot.dc_id)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut framing = PaddedIntermediate::default(); - create_auth_key(&mut conn, &mut framing, snapshot)?; - drop(span); - Ok(()) -} - -pub(crate) fn ensure_auth_key(state: &mut ClientState) -> Result<(), MtprotoError> { - if state.snapshot.auth_key.as_ref().map(Vec::len) == Some(256) { - return Ok(()); - } - ensure_auth_key_on(&mut state.snapshot)?; - persist(state) -} - -pub(crate) fn is_foreign_auth_key_failure(err: &MtprotoError) -> bool { - let MtprotoError::Message(message) = err else { - return false; - }; - let upper = message.to_ascii_uppercase(); - upper.contains("AUTH_KEY_UNREGISTERED") - || upper.contains("AUTH_KEY_INVALID") - || upper.contains("AUTH_KEY_DUPLICATED") -} - -pub(crate) fn copy_authorization_to_dc( - state: &mut ClientState, - target_dc: i32, -) -> Result { - use crate::session_file::DcAuthKey; - use tellers_mtproto::latest::api::{ - AuthAuthorization, AuthExportAuthorizationRequest, AuthExportedAuthorization, - AuthImportAuthorizationRequest, - }; - if target_dc != state.snapshot.dc_id { - if let Some(cached) = state.dc_auth_keys.get(&target_dc) { - if cached.key.len() == 256 { - let mut dest = fork_session(&state.snapshot); - dest.dc_id = target_dc; - dest.clear_auth_key(); - dest.auth_key = Some(cached.key.clone()); - dest.server_salt = cached.salt; - return Ok(dest); - } - } - } - let exported: AuthExportedAuthorization = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - AuthExportAuthorizationRequest { dc_id: target_dc }, - )?; - let AuthExportedAuthorization::AuthExportedAuthorization(exp) = exported; - let mut dest = fork_session(&state.snapshot); - dest.dc_id = target_dc; - dest.clear_auth_key(); - dest.server_salt = 0; - crate::rpc::drop_live_transport(); - ensure_auth_key_on(&mut dest)?; - let _: AuthAuthorization = crate::api_invoke::invoke_api( - &mut dest, - state.api_id, - AuthImportAuthorizationRequest { - id: exp.id, - bytes: exp.bytes, - }, - )?; - // The destination snapshot leaves this lane; never retain its TCP here. - crate::rpc::drop_live_transport(); - if target_dc != state.snapshot.dc_id { - if let Some(key) = dest.auth_key.clone() { - if key.len() == 256 { - state.dc_auth_keys.insert( - target_dc, - DcAuthKey { - key, - salt: dest.server_salt, - }, - ); - } - } - } - Ok(dest) -} - -pub(crate) fn is_unrecoverable_session(err: &MtprotoError) -> bool { - let MtprotoError::Message(msg) = err else { - return false; - }; - let upper = msg.to_ascii_uppercase(); - upper.contains("AUTH_KEY_UNREGISTERED") - || upper.contains("AUTH_KEY_INVALID") - || upper.contains("AUTH_KEY_DUPLICATED") - || upper.contains("SESSION_REVOKED") - || upper.contains("SESSION_EXPIRED") - || upper.contains("USER_DEACTIVATED") -} - -/// A pre-login auth key is unregistered until `auth.signIn`. That 401 must not -/// persist `session_dead`: the next `auth.sendCode` would throw the key away. -pub(crate) fn kills_session(user_id: Option, err: &MtprotoError) -> bool { - if !is_unrecoverable_session(err) { - return false; - } - let MtprotoError::Message(msg) = err else { - return true; - }; - !(user_id.is_none() && msg.to_ascii_uppercase().contains("AUTH_KEY_UNREGISTERED")) -} - -pub(crate) fn mark_session_dead(state: &mut ClientState, err: &MtprotoError) { - state.session_dead = true; - state.session_dead_reason = Some(session_dead_token(err)); - crate::rpc::drop_live_transport(); - let _ = persist(state); -} - -/// `RPC 406: AUTH_KEY_DUPLICATED` → `AUTH_KEY_DUPLICATED`. Only fixed uppercase -/// tokens travel to the bridge; anything else is `UNKNOWN`, so no payload can leak. -pub(crate) fn session_dead_token(err: &MtprotoError) -> String { - let MtprotoError::Message(msg) = err else { - return "UNKNOWN".into(); - }; - let body = msg - .split_once(": ") - .map(|(_, rest)| rest) - .unwrap_or(msg.as_str()); - let token: String = body - .trim_start() - .chars() - .take_while(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || *c == '_') - .collect(); - if token.len() >= 3 { - token - } else { - "UNKNOWN".into() - } -} - -/// Bridge-visible form of a locally known dead session. -/// -/// Deliberately *not* shaped like an `rpc_error`: the authorization was already -/// invalidated server-side (406), and a synthetic `401 AUTH_KEY_UNREGISTERED` both -/// hides that cause from the logs and makes the bridge treat a local gate as a -/// fresh server rejection. -pub(crate) fn session_invalidated_error(state: &ClientState) -> MtprotoError { - let reason = state.session_dead_reason.as_deref().unwrap_or("UNKNOWN"); - MtprotoError::Message(format!("session invalidated: {reason}")) -} - -pub(crate) fn reject_if_dead(state: &ClientState) -> Result<(), MtprotoError> { - if state.session_dead { - return Err(session_invalidated_error(state)); - } - Ok(()) -} - -pub(crate) fn ensure_ready_after_migrate( - state: &mut ClientState, - err: MtprotoError, -) -> Result<(), MtprotoError> { - ensure_ready_after_migrate_with_binding( - state, - err, - crate::auth::bind_temp::negotiate_bound_temp, - ) -} - -pub(crate) fn ensure_ready_after_migrate_with_binding( - state: &mut ClientState, - err: MtprotoError, - negotiate: impl FnOnce(&[u8], i32, i64) -> Result, -) -> Result<(), MtprotoError> { - if let MtprotoError::Message(msg) = &err { - if let Some(rest) = msg.strip_prefix("MIGRATE_") { - if let Ok(dc) = rest.parse::() { - if state.user_id.is_some() - && state.snapshot.auth_key.as_ref().map(|key| key.len()) == Some(256) - { - // An authorized account must move its authorization with - // exportAuthorization/importAuthorization. Dropping the - // home auth key here turns a normal USER/NETWORK_MIGRATE - // into an unnecessary logout and breaks all update lanes. - let destination = copy_authorization_to_dc(state, dc)?; - if state.permanent.is_some() { - let key = destination - .auth_key - .as_deref() - .ok_or_else(temporary_key_retry_error)?; - let bound = match negotiate(key, dc, destination.time_offset_micros) { - Ok(bound) => bound, - Err(error) => { - if is_foreign_auth_key_failure(&error) { - state.dc_auth_keys.remove(&dc); - persist(state)?; - } - return Err(temporary_key_retry_error()); - } - }; - bound - .snapshot - .validate() - .map_err(|_| temporary_key_retry_error())?; - if bound.snapshot.dc_id != dc - || bound.snapshot.session_id == destination.session_id - || !bound.snapshot.auth_key.as_ref().is_some_and(|temporary| { - temporary.len() == 256 && temporary.as_slice() != key - }) - || !crate::auth::bind_temp::temp_key_valid( - &bound.snapshot, - bound.expires_at, - ) - { - return Err(temporary_key_retry_error()); - } - state.permanent = Some(destination); - state.snapshot = bound.snapshot; - state.temp_expires_at = bound.expires_at; - } else { - state.snapshot = destination; - state.permanent = None; - state.temp_expires_at = 0; - } - // Authorization moved, but these are still the same - // account's applied update sequences. - state.session_dead = false; - return Ok(()); - } - state.snapshot.dc_id = dc; - state.permanent = None; - state.snapshot.clear_auth_key(); - state.snapshot.server_salt = 0; - ensure_auth_key(state)?; - return Ok(()); - } - } - } - Err(err) -} - -pub(crate) fn temporary_key_failure(state: &ClientState, err: &MtprotoError) -> bool { - state.permanent.is_some() && temporary_key_error(err) -} - -pub(crate) fn temporary_key_error(err: &MtprotoError) -> bool { - let MtprotoError::Message(message) = err else { - return false; - }; - if message == "MTProto transport error -404" { - return true; - } - let Some((prefix, token)) = message.split_once(": ") else { - return false; - }; - prefix.starts_with("RPC ") - && matches!( - token, - "AUTH_KEY_UNREGISTERED" - | "AUTH_KEY_INVALID" - | "AUTH_KEY_DUPLICATED" - | "AUTH_KEY_PERM_EMPTY" - ) -} - -pub(crate) fn temporary_key_retry_error() -> MtprotoError { - MtprotoError::Message("connection closed: temporary authorization renewal required".into()) -} - -pub(crate) fn expire_temporary_lane( - client: &Client, - io: &mut super::lanes::SessionIo, - err: &MtprotoError, -) -> bool { - if io.perm_key.is_none() || !temporary_key_error(err) { - return false; - } - io.temp_expires_at = 1; - io.transport = None; - let mut data = client.data.lock(); - if data - .dc_temp_auth - .get(&io.snapshot.dc_id) - .is_some_and(|cached| cached.snapshot.auth_key == io.snapshot.auth_key) - { - data.dc_temp_auth.remove(&io.snapshot.dc_id); - } - true -} - -pub(crate) fn mark_lane_session_dead(client: &Client, owner: i64, err: &MtprotoError) -> bool { - let mut data = client.data.lock(); - if !session_lease_valid(&data, owner) || !kills_session(data.user_id, err) { - return false; - } - data.session_dead = true; - data.session_dead_reason = Some(session_dead_token(err)); - data.persist_epoch = data.persist_epoch.saturating_add(1); - true -} - -pub(crate) fn call_with_migrate( - state: &mut ClientState, - mut op: impl FnMut(&mut ClientState) -> Result, -) -> Result { - reject_if_dead(state)?; - ensure_ready(state)?; - let before = state.snapshot.clone(); - match op(state) { - Ok(v) => Ok(v), - Err(err) if temporary_key_failure(state, &err) => Err(err), - Err(err) if kills_session(state.user_id, &err) => { - mark_session_dead(state, &err); - Err(err) - } - Err(err) => { - // api_invoke normalizes all non-file MIGRATE errors to - // `MIGRATE_`. Restore the home snapshot before exporting - // authorization in case the failed RPC changed session metadata. - if api_invoke::migrate_dc(&err).is_some() { - state.snapshot = before; - } - if !kills_session(state.user_id, &err) && is_unrecoverable_session(&err) { - return Err(err); - } - ensure_ready_after_migrate(state, err)?; - match op(state) { - Ok(v) => Ok(v), - Err(err) if temporary_key_failure(state, &err) => Err(err), - Err(err) if kills_session(state.user_id, &err) => { - mark_session_dead(state, &err); - Err(err) - } - Err(err) => Err(err), - } - } - } -} - -pub(crate) fn drop_updates_transport(handle: u64) { - crate::rpc::drop_live_transport(); - if let Ok(client) = get_client(handle) { - let mut main = client.main.lock(); - main.transport = None; - main.last_difference = None; - main.pending_push = Default::default(); - } -} - -pub(crate) fn drop_all_transports(handle: u64) { - crate::rpc::drop_live_transport(); - if let Ok(client) = get_client(handle) { - client.main.lock().transport = None; - for lane in client.rpc.iter() { - lane.io.lock().transport = None; - } - for lane in client.media.iter() { - lane.io.lock().transport = None; - } - for lane in &client.upload { - lane.io.lock().transport = None; - } - } -} - -pub(crate) fn recreate_mtproto_session(state: &mut ClientState) -> Result<(), MtprotoError> { - let dc = state.snapshot.dc_id; - state.permanent = None; - state.user_id = None; - state.updates = None; - state.updates_started = false; - state.session_dead = false; - crate::rpc::drop_live_transport(); - state.media.clear(); - state.dc_auth_keys.clear(); - // Keep peer cache; drop auth key so the next RPC builds a fresh session. - state.snapshot = - Snapshot::new(dc, &mut OsRandom).map_err(|e| MtprotoError::Message(e.to_string()))?; - persist(state) -} diff --git a/native/mtproto-rs/src/client/shared_folders.rs b/native/mtproto-rs/src/client/shared_folders.rs deleted file mode 100644 index 17721e732..000000000 --- a/native/mtproto-rs/src/client/shared_folders.rs +++ /dev/null @@ -1,365 +0,0 @@ -use super::management::enqueue_response; -use super::{ClientState, call_with_migrate, with_client_mut}; -use crate::MtprotoError; -use crate::peers::{input_peer_from_cached, require_usable_peer, vector_boxed_items}; -#[cfg(test)] -#[path = "../../tests/unit/shared_folders_tests.rs"] -mod tests; - -use tellers_mtproto::latest::api::*; - -pub fn export_invite( - handle: u64, - filter_id: i32, - title: &str, - chat_ids: &[i64], -) -> Result { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let peers = peer_list(state, chat_ids)?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsExportChatlistInviteRequest { - chatlist: Box::new(chatlist.clone()), - title: title.to_owned(), - peers, - }, - ) - }) -} - -/// None leaves a field unchanged; an empty title explicitly removes the label. -pub fn edit_invite( - handle: u64, - filter_id: i32, - slug: &str, - title: Option, - chat_ids: Option>, -) -> Result { - let chatlist = chatlist(filter_id)?; - validate_slug(slug)?; - if title.is_none() && chat_ids.is_none() { - return Err(MtprotoError::Message("an invite change is required".into())); - } - call(handle, |state| { - let peers = chat_ids - .as_ref() - .map(|ids| peer_list(state, ids)) - .transpose()?; - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsEditExportedInviteRequest { - flags: (u32::from(title.is_some()) << 1) | (u32::from(peers.is_some()) << 2), - chatlist: Box::new(chatlist.clone()), - slug: slug.to_owned(), - title: title.clone(), - peers, - }, - ) - }) -} - -pub fn delete_invite(handle: u64, filter_id: i32, slug: &str) -> Result<(), MtprotoError> { - let chatlist = chatlist(filter_id)?; - validate_slug(slug)?; - call(handle, |state| { - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsDeleteExportedInviteRequest { - chatlist: Box::new(chatlist.clone()), - slug: slug.to_owned(), - }, - )?; - confirm(response) - }) -} - -pub fn get_invites(handle: u64, filter_id: i32) -> Result { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let response: ChatlistsExportedInvites = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsGetExportedInvitesRequest { - chatlist: Box::new(chatlist.clone()), - }, - )?; - let ChatlistsExportedInvites::ChatlistsExportedInvites(result) = &response; - cache_peers(state, &result.users, &result.chats); - Ok(response) - }) -} - -/// Preview also caches full access hashes needed to join previously unknown chats. -pub fn check_invite(handle: u64, slug: &str) -> Result { - validate_slug(slug)?; - call(handle, |state| { - let response: ChatlistsChatlistInvite = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsCheckChatlistInviteRequest { - slug: slug.to_owned(), - }, - )?; - match &response { - ChatlistsChatlistInvite::ChatlistsChatlistInvite(result) => { - cache_peers(state, &result.users, &result.chats) - } - ChatlistsChatlistInvite::ChatlistsChatlistInviteAlready(result) => { - cache_peers(state, &result.users, &result.chats) - } - } - Ok(response) - }) -} - -pub fn join_invite( - handle: u64, - slug: &str, - selected_chat_ids: &[i64], -) -> Result { - validate_slug(slug)?; - call(handle, |state| { - let peers = peer_list(state, selected_chat_ids)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsJoinChatlistInviteRequest { - slug: slug.to_owned(), - peers, - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -pub fn get_updates(handle: u64, filter_id: i32) -> Result { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let response: ChatlistsChatlistUpdates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsGetChatlistUpdatesRequest { - chatlist: Box::new(chatlist.clone()), - }, - )?; - let ChatlistsChatlistUpdates::ChatlistsChatlistUpdates(result) = &response; - cache_peers(state, &result.users, &result.chats); - Ok(response) - }) -} - -pub fn join_updates( - handle: u64, - filter_id: i32, - selected_chat_ids: &[i64], -) -> Result { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let peers = peer_list(state, selected_chat_ids)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsJoinChatlistUpdatesRequest { - chatlist: Box::new(chatlist.clone()), - peers, - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -pub fn hide_updates(handle: u64, filter_id: i32) -> Result<(), MtprotoError> { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsHideChatlistUpdatesRequest { - chatlist: Box::new(chatlist.clone()), - }, - )?; - confirm(response) - }) -} - -pub fn leave_suggestions(handle: u64, filter_id: i32) -> Result>, MtprotoError> { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsGetLeaveChatlistSuggestionsRequest { - chatlist: Box::new(chatlist.clone()), - }, - ) - }) -} - -/// Only explicitly selected chats are left. An empty selection keeps membership -/// in every chat while removing the shared folder. -pub fn leave(handle: u64, filter_id: i32, chats_to_leave: &[i64]) -> Result { - let chatlist = chatlist(filter_id)?; - call(handle, |state| { - let peers = peer_list(state, chats_to_leave)?; - let response: Updates = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - ChatlistsLeaveChatlistRequest { - chatlist: Box::new(chatlist.clone()), - peers, - }, - )?; - enqueue_response(state.snapshot.dc_id, &response)?; - Ok(response) - }) -} - -pub fn get_filters(handle: u64) -> Result { - call(handle, |state| { - crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesGetDialogFiltersRequest {}, - ) - }) -} - -/// Keeps the caller's regular/shared filter variant and all of its fields. -/// None deletes the filter; peer memberships are unaffected. -pub fn set_filter( - handle: u64, - filter_id: i32, - filter: Option, -) -> Result<(), MtprotoError> { - chatlist(filter_id)?; - if let Some(filter) = &filter { - let id = match filter { - DialogFilter::DialogFilter(value) => value.id, - DialogFilter::DialogFilterChatlist(value) => value.id, - _ => { - return Err(MtprotoError::Message( - "a custom dialog filter is required".into(), - )); - } - }; - if id != filter_id { - return Err(MtprotoError::Message( - "dialog filter id does not match".into(), - )); - } - } - call(handle, |state| { - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesUpdateDialogFilterRequest { - flags: u32::from(filter.is_some()), - id: filter_id, - filter: filter.clone().map(Box::new), - }, - )?; - confirm(response) - }) -} - -pub fn reorder_filters(handle: u64, order: &[i32]) -> Result<(), MtprotoError> { - let mut seen = std::collections::HashSet::new(); - if order - .iter() - .any(|id| *id < 0 || *id == 1 || !seen.insert(*id)) - { - return Err(MtprotoError::Message("invalid dialog filter order".into())); - } - let count = order - .len() - .try_into() - .map_err(|_| MtprotoError::Message("too many filters".into()))?; - call(handle, |state| { - let response: Bool = crate::api_invoke::invoke_api( - &mut state.snapshot, - state.api_id, - MessagesUpdateDialogFiltersOrderRequest { - order: Box::new(Vector::Vector(VectorConstructor { - field_0: count, - field_1: order.to_vec(), - })), - }, - )?; - confirm(response) - }) -} - -fn call( - handle: u64, - operation: impl Fn(&mut ClientState) -> Result, -) -> Result { - with_client_mut(handle, |state| { - call_with_migrate(state, |state| operation(state)) - }) -} - -fn chatlist(filter_id: i32) -> Result { - if filter_id <= 1 { - return Err(MtprotoError::Message( - "a custom folder id is required".into(), - )); - } - Ok(InputChatlist::InputChatlistDialogFilter( - InputChatlistDialogFilterConstructor { filter_id }, - )) -} - -fn validate_slug(slug: &str) -> Result<(), MtprotoError> { - if slug.trim().is_empty() { - return Err(MtprotoError::Message("an invite slug is required".into())); - } - Ok(()) -} - -fn peer_list( - state: &ClientState, - chat_ids: &[i64], -) -> Result>>, MtprotoError> { - let mut seen = std::collections::HashSet::new(); - let mut peers = Vec::with_capacity(chat_ids.len()); - for id in chat_ids { - if !seen.insert(*id) { - return Err(MtprotoError::Message("duplicate chat selection".into())); - } - peers.push(Box::new(input_peer_from_cached(require_usable_peer( - &state.peers, - *id, - )?))); - } - Ok(Box::new(Vector::Vector(VectorConstructor { - field_0: peers - .len() - .try_into() - .map_err(|_| MtprotoError::Message("too many chats".into()))?, - field_1: peers, - }))) -} - -fn cache_peers(state: &mut ClientState, users: &Vector>, chats: &Vector>) { - crate::dialogs::cache_from_users_chats( - &mut state.peers, - &mut state.media, - vector_boxed_items(users).cloned(), - vector_boxed_items(chats).cloned(), - ); -} - -fn confirm(response: Bool) -> Result<(), MtprotoError> { - match response { - Bool::BoolTrue(_) => Ok(()), - Bool::BoolFalse(_) => Err(MtprotoError::Message( - "shared folder change was not applied".into(), - )), - } -} diff --git a/native/mtproto-rs/src/client/updates.rs b/native/mtproto-rs/src/client/updates.rs deleted file mode 100644 index 280feb4a7..000000000 --- a/native/mtproto-rs/src/client/updates.rs +++ /dev/null @@ -1,1048 +0,0 @@ -use std::collections::VecDeque; - -use crate::peers::channel_id_from_chat_id; -use crate::session_file::ChannelRecovery; -use crate::tcp; -use crate::update_journal::{DurableBatch, is_durable}; -use crate::updates::updates_rpc; -use crate::{ - ChannelCursorDto, MtprotoError, UpdateBatchDto, UpdateDrainDto, UpdateEventDto, UpdatesStateDto, -}; - -use super::*; - -#[cfg(test)] -std::thread_local! { - static UPDATE_CACHE_CLONES: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -pub(crate) fn take_update_cache_clones() -> u64 { - UPDATE_CACHE_CLONES.with(|cell| cell.replace(0)) -} - -fn note_update_cache_clone() { - crate::perf::count("updates.cache_clone"); - #[cfg(test)] - UPDATE_CACHE_CLONES.with(|cell| cell.set(cell.get() + 1)); -} - -struct AppliedDrain { - peers: crate::HashMap, - before_peers: crate::HashMap, - media: crate::media::MediaIndex, - before_media: crate::media::MediaIndex, - channel_pts: crate::HashMap, - recovery: VecDeque, - seen_messages: crate::HashSet<(i64, i32)>, - lazy_queue: crate::HashSet, - lazy_retry_at: u64, - stopped_channel: Option, - cursor: Option, -} - -pub fn start_updates(handle: u64) -> Result<(), MtprotoError> { - let client = get_client(handle)?; - { - let mut data = client.data.lock(); - if data.session_dead { - return Err(MtprotoError::Message(format!( - "session invalidated: {}", - data.session_dead_reason.as_deref().unwrap_or("UNKNOWN"), - ))); - } - data.updates_started = true; - } - Ok(()) -} - -/// A cursor becomes durable only after `getDifference` applies it. Fetching -/// `updates.getState` is used solely as the in-memory bootstrap cursor in -/// `drain_updates`; persisting that server head could skip a crash-window gap. -pub(crate) fn begin_updates(state: &mut ClientState) { - state.updates_started = true; -} - -pub fn clear_active_dialog(handle: u64) -> Result<(), MtprotoError> { - let client = get_client(handle)?; - let session_id = { - let mut data = client.data.lock(); - data.last_history_chat_id = 0; - // Keep server-driven recovery hints, but discard the periodic watcher - // that existed only while this dialog was open. - data.channel_recovery.retain(|entry| !entry.watching); - data.home_session_id - }; - persist_updates_data(&client, session_id).map(|_| ()) -} - -pub(crate) fn lock_updates_lane( - client: &Client, -) -> Result< - Option<( - scheduler::LaneGuard<'_>, - parking_lot::MutexGuard<'_, SessionIo>, - )>, - MtprotoError, -> { - // Updates share the home transport with main RPCs. Taking the same gate as - // request batches prevents a just-freed media batch from barging ahead. - let gate = client - .main_gate - .acquire(scheduler::RequestClass::BackgroundRead)?; - Ok(client.main.try_lock().map(|io| (gate, io))) -} - -fn batch_dto(generation: i64, batch: &DurableBatch) -> UpdateBatchDto { - UpdateBatchDto { - account_generation: generation, - batch_id: batch.batch_id, - events: batch.events.clone(), - cursor_after: batch.cursor_after.clone(), - channel_pts_after: batch - .channel_pts_after - .iter() - .map(|(chat_id, pts)| ChannelCursorDto { - chat_id: *chat_id, - pts: *pts, - }) - .collect(), - } -} - -pub(super) fn stage_outgoing_result( - client: &Client, - expected_generation: i64, - random_id: i64, - result: crate::OutgoingMessageResult, -) -> Result { - stage_outgoing_results(client, expected_generation, vec![(random_id, result)]) -} - -pub(super) fn stage_outgoing_results( - client: &Client, - expected_generation: i64, - results: Vec<(i64, crate::OutgoingMessageResult)>, -) -> Result { - let mut data = client.data.lock(); - let session_id = data.home_session_id; - if data.journal.account_generation != expected_generation - || !session_lease_valid(&data, session_id) - { - return Err(expired_session_lease()); - } - let ids = results - .iter() - .map(|(id, _)| *id) - .collect::>(); - if results.is_empty() || results.len() > 10 || ids.len() != results.len() || ids.contains(&0) { - return Err(MtprotoError::Message( - "outgoing receipts require distinct persisted random ids".into(), - )); - } - let events = results - .into_iter() - .map( - |(random_id, result)| UpdateEventDto::OutgoingMessageAcknowledged { random_id, result }, - ) - .collect(); - std::sync::Arc::make_mut(&mut data.journal).push(events, None, Vec::new()); - data.persist_epoch = data.persist_epoch.saturating_add(1); - Ok(session_id) -} - -pub fn pending_update_batches(handle: u64) -> Result, MtprotoError> { - let client = get_client(handle)?; - let (session_id, dirty) = { - let d = client.data.lock(); - if d.journal.batches.is_empty() { - return Ok(Vec::new()); - } - (d.home_session_id, d.persist_epoch != d.persisted_epoch) - }; - if dirty { - persist_journal_sync(&client, session_id)?; - } - let mut d = client.data.lock(); - if !session_lease_valid(&d, session_id) { - return Err(expired_session_lease()); - } - let generation = d.journal.account_generation; - let batches = d - .journal - .pending() - .iter() - .map(|batch| batch_dto(generation, batch)) - .collect(); - std::sync::Arc::make_mut(&mut d.journal).mark_all_delivered(); - Ok(batches) -} - -pub fn acknowledge_update_batch( - handle: u64, - account_generation: i64, - batch_id: i64, -) -> Result<(), MtprotoError> { - let client = get_client(handle)?; - let (removed, session_id) = { - let mut d = client.data.lock(); - let removed = - std::sync::Arc::make_mut(&mut d.journal).acknowledge(account_generation, batch_id); - if removed { - d.persist_epoch = d.persist_epoch.saturating_add(1); - } - (removed, d.home_session_id) - }; - if removed { - schedule_persist(&client, session_id); - } - Ok(()) -} - -pub fn drain_updates(handle: u64) -> Result { - let client = get_client(handle)?; - if interactive_request_pending(&client) { - return Ok(UpdateDrainDto::default()); - } - { - let d = client.data.lock(); - if d.journal.has_undelivered() { - return Ok(UpdateDrainDto { - replay_required: true, - ..Default::default() - }); - } - if !d.journal.admission_open() { - crate::perf::count("journal.admission_closed"); - return Ok(UpdateDrainDto::default()); - } - } - if client - .persist_exhausted - .load(std::sync::atomic::Ordering::Acquire) - { - let session_id = client.data.lock().home_session_id; - schedule_persist(&client, session_id); - } - let Some((gate, mut io)) = lock_updates_lane(&client)? else { - return Ok(UpdateDrainDto::default()); - }; - let now = recovery_now(); - let ( - api_id, - previous, - home_dc, - home_auth, - home_salt, - home_off, - open_chat, - session_id, - due_recovery, - lazy_pending, - ) = { - let d = client.data.lock(); - if d.session_dead { - return Err(MtprotoError::Message(format!( - "session invalidated: {}", - d.session_dead_reason.as_deref().unwrap_or("UNKNOWN") - ))); - } - if d.user_id.is_none() || !d.updates_started { - return Ok(UpdateDrainDto::default()); - } - ( - d.api_id, - d.updates.clone(), - d.home_dc, - d.home_auth_key.clone(), - d.home_salt, - d.home_time_offset, - d.last_history_chat_id, - d.home_session_id, - next_channel_recovery( - &d.channel_recovery, - d.last_history_chat_id, - &d.lazy_sync_exceptions, - d.lazy_channel_updates, - now, - ) - .is_some() - || (channel_id_from_chat_id(d.last_history_chat_id).is_some() - && !d - .channel_recovery - .iter() - .any(|entry| entry.chat_id == d.last_history_chat_id)), - d.lazy_channel_updates && !d.lazy_channel_recovery.is_empty() && d.lazy_retry_at <= now, - ) - }; - crate::auth::bind_temp::ensure_main_temp(&client, &mut io, false)?; - if io.temp_expires_at == 0 && io.perm_key.is_none() { - apply_home_auth( - &mut io.snapshot, - home_dc, - home_auth.clone(), - home_salt, - home_off, - ); - } - let mut needs_difference = io.transport.is_none() - || io - .last_difference - .map(|last| last.elapsed() >= std::time::Duration::from_secs(60)) - .unwrap_or(true); - let mut slot = io.transport.take(); - crate::rpc::clear_new_session_metadata(); - let drained = with_client_transport(&client, &mut slot, || { - // Receive stays on the idle budget; recovery RPCs use their own 8s scope - // so a waiting interactive caller can take the lane between pages. - ensure_auth_key_on(&mut io.snapshot)?; - let mut cursor = previous.clone(); - if cursor.is_none() { - cursor = Some(updates_rpc::get_updates_state(&mut io.snapshot, api_id)?); - } - if !needs_difference { - let _span = crate::perf::span("updates.receive"); - let pushes = crate::rpc::receive_updates(&mut io.snapshot)?; - io.pending_push.append(pushes); - } - let has_work = - needs_difference || !io.pending_push.is_empty() || due_recovery || lazy_pending; - if !has_work { - crate::perf::count("updates.empty_fast_path"); - return Ok((Vec::new(), None)); - } - note_update_cache_clone(); - let mut recovery; - let mut peers; - let mut media; - let mut channel_pts; - let mut seen_messages; - let mut lazy_queue; - let mut lazy_retry_at; - let lazy_enabled; - let lazy_exceptions; - { - let d = client.data.lock(); - if d.session_dead { - return Err(MtprotoError::Message(format!( - "session invalidated: {}", - d.session_dead_reason.as_deref().unwrap_or("UNKNOWN") - ))); - } - if d.home_dc != home_dc - || d.home_auth_key != home_auth - || !session_lease_valid(&d, session_id) - { - return Err(expired_session_lease()); - } - recovery = d.channel_recovery.clone(); - peers = d.peers.clone(); - media = d.media.clone(); - channel_pts = d.channel_pts.clone(); - seen_messages = d.seen_messages.clone(); - lazy_queue = d.lazy_channel_recovery.clone(); - lazy_retry_at = d.lazy_retry_at; - lazy_enabled = d.lazy_channel_updates; - lazy_exceptions = d.lazy_sync_exceptions.clone(); - } - let before_peers = peers.clone(); - let before_media = media.clone(); - let mut live = cursor.clone().unwrap(); - let mut pending_channels = Vec::new(); - let mut stopped_channel = None; - let mut events = Vec::new(); - let sync_started = !client.data.lock().is_syncing - && catch_up_active( - !needs_difference, - visible_gap_recovery_pending(&recovery, open_chat, &lazy_exceptions, lazy_enabled), - false, - ); - if sync_started { - events.push(UpdateEventDto::SyncState { is_syncing: true }); - } - if !needs_difference { - let _span = crate::perf::span("updates.apply"); - let resolve = io.pending_push.resolve(std::time::Instant::now(), |push| { - match updates_rpc::apply_push( - push, - &mut peers, - &mut media, - &mut live, - &mut channel_pts, - &mut pending_channels, - ) { - Ok(applied) => { - events.extend(applied); - Ok(true) - } - Err(MtprotoError::Message(message)) if message == "updates gap" => Ok(false), - Err(error) => Err(error), - } - }); - match resolve { - Ok(recovery_required) => { - needs_difference = recovery_required; - } - Err(error) => { - // An invalid constructor or malformed packet cannot be - // retried from the reordering queue. Drop it and recover - // from the last committed cursor on a fresh connection. - io.pending_push = Default::default(); - crate::rpc::drop_live_transport(); - needs_difference = true; - tcp::wait_reconnect(std::time::Duration::from_millis(50)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = error; - } - } - } - let defer_recovery = events - .iter() - .any(|event| !matches!(event, UpdateEventDto::SyncState { .. })); - if needs_difference { - // Let a waiting interactive RPC take the home lane between - // update recovery requests. Keep the cursor unchanged so the - // next drain resumes recovery safely. Deliver already-applied - // pushes this poll instead of holding the lane for getDifference. - if interactive_request_pending(&client) || defer_recovery { - crate::perf::count("updates.defer_difference"); - needs_difference = false; - } - } - if needs_difference { - if !client.data.lock().is_syncing && !sync_started { - events.push(UpdateEventDto::SyncState { is_syncing: true }); - } - let _span = crate::perf::span("updates.difference"); - let (page, has_more) = crate::rpc::with_rpc_timeout_secs(8, || { - updates_rpc::drain_difference( - &mut io.snapshot, - api_id, - &mut peers, - &mut media, - &mut live, - &mut pending_channels, - ) - })?; - // The difference is authoritative for the cursor. Discard - // packets retained behind the gap before using the new state. - io.pending_push = Default::default(); - events.extend(page); - io.last_difference = (!has_more).then(std::time::Instant::now); - } - let now = recovery_now(); - let pending_channels = route_lazy_channels( - pending_channels, - open_chat, - &lazy_exceptions, - lazy_enabled, - &mut lazy_queue, - ); - pull_priority_channels( - &mut lazy_queue, - &mut recovery, - open_chat, - &lazy_exceptions, - now, - ); - prepare_channel_recovery(&mut recovery, pending_channels, open_chat, now); - // Round robin pages, bounded per poll; a failed channel cannot - // prevent the common cursor or another channel from progressing. - let recovery_budget = std::time::Instant::now() + std::time::Duration::from_secs(8); - for _ in 0..1 { - if interactive_request_pending(&client) - || defer_recovery - || std::time::Instant::now() >= recovery_budget - { - break; - } - let Some(index) = - next_channel_recovery(&recovery, open_chat, &lazy_exceptions, lazy_enabled, now) - else { - break; - }; - let entry = recovery.remove(index).unwrap(); - if !entry.watching - && !client.data.lock().is_syncing - && !events - .iter() - .any(|event| matches!(event, UpdateEventDto::SyncState { is_syncing: true })) - { - events.push(UpdateEventDto::SyncState { is_syncing: true }); - } - let chat_id = entry.chat_id; - let pts = channel_pts.get(&chat_id).copied().unwrap_or(1); - let _span = crate::perf::span("updates.channel_diff"); - match crate::rpc::with_rpc_timeout_secs(8, || { - updates_rpc::drain_channel_difference( - &mut io.snapshot, - api_id, - &mut peers, - &mut media, - chat_id, - pts, - ) - }) { - Ok(page) => { - channel_pts.insert(chat_id, page.pts.max(pts)); - events.extend(page.events); - finish_channel_recovery( - &mut recovery, - entry, - page.final_page, - page.timeout, - open_chat, - now, - ); - let page_pending = route_lazy_channels( - page.pending_channels, - open_chat, - &lazy_exceptions, - lazy_enabled, - &mut lazy_queue, - ); - prepare_channel_recovery(&mut recovery, page_pending, open_chat, now); - } - Err(err) => { - if is_unrecoverable_session(&err) { - return Err(err); - } - if !terminal_channel_error(&err) { - defer_channel_recovery(&mut recovery, entry, &err, recovery_now()); - } else { - lazy_queue.remove(&chat_id); - if chat_id == open_chat { - stopped_channel = Some(chat_id); - } - } - crate::rpc::drop_live_transport(); - break; - } - } - } - pull_priority_channels( - &mut lazy_queue, - &mut recovery, - if stopped_channel.is_some() { - 0 - } else { - open_chat - }, - &lazy_exceptions, - recovery_now(), - ); - let lazy_batch = if lazy_retry_at <= recovery_now() - && !interactive_request_pending(&client) - && !defer_recovery - && !needs_difference - { - take_lazy_batch( - &mut lazy_queue, - |chat_id| crate::peers::usable_cached_peer(&peers, chat_id).is_some(), - LAZY_CHANNEL_BATCH, - ) - } else { - Vec::new() - }; - if !lazy_batch.is_empty() { - let _span = crate::perf::span("updates.lazy_channels"); - match crate::rpc::with_rpc_timeout_secs(8, || { - updates_rpc::drain_lazy_channels( - &mut io.snapshot, - api_id, - &mut peers, - &mut media, - &mut channel_pts, - &lazy_batch, - ) - }) { - Ok(page) => { - lazy_retry_at = recovery_now().saturating_add(5); - restore_lazy_batch(&mut lazy_queue, &page.unresolved); - events.extend(lazy_dialog_events(page.chats)); - } - Err(err) => { - let plan = plan_lazy_failure(is_unrecoverable_session(&err)); - if !plan.restore { - return Err(err); - } - if !terminal_channel_error(&err) { - restore_lazy_batch(&mut lazy_queue, &lazy_batch); - } - lazy_retry_at = recovery_now().saturating_add(recovery_wait_secs(&err)); - if plan.drop_transport { - crate::rpc::drop_live_transport(); - } - } - } - } - let mut saw_chats = false; - if seen_messages.len() > 4_000 { - seen_messages.clear(); - } - events.retain(|event| match event { - UpdateEventDto::ChatsChanged if saw_chats => false, - UpdateEventDto::ChatsChanged => { - saw_chats = true; - true - } - UpdateEventDto::NewMessage { message } => { - seen_messages.insert((message.chat_id, message.id)) - } - UpdateEventDto::MessageEdited { message } => { - seen_messages.insert((message.chat_id, message.id)); - true - } - UpdateEventDto::MessagesDeleted { - chat_id, - message_ids, - } => { - let inferred_chat = *chat_id; - for message_id in message_ids { - if let Some(chat) = inferred_chat { - seen_messages.remove(&(chat, *message_id)); - } else { - seen_messages.retain(|(_, id)| id != message_id); - } - } - true - } - _ => true, - }); - Ok(( - events, - Some(AppliedDrain { - peers, - before_peers, - media, - before_media, - channel_pts, - recovery, - seen_messages, - lazy_queue, - lazy_retry_at, - stopped_channel, - cursor: Some(live), - }), - )) - }); - let (mut events, applied) = match drained { - Ok(pair) => { - io.transport = slot; - pair - } - Err(err) => { - let temporary = expire_temporary_lane(&client, &mut io, &err); - io.transport = None; - drop(io); - drop(gate); - if temporary { - return Err(temporary_key_retry_error()); - } - if mark_lane_session_dead(&client, session_id, &err) { - let _ = persist_updates_data(&client, session_id); - } - return Err(err); - } - }; - let new_session = crate::rpc::take_new_session_metadata(); - let Some(applied) = applied else { - drop(io); - drop(gate); - if let Some(metadata) = new_session { - { - let mut d = client.data.lock(); - if d.home_dc == home_dc - && d.home_auth_key == home_auth - && session_lease_valid(&d, session_id) - { - d.new_session = Some(metadata); - d.persist_epoch = d.persist_epoch.saturating_add(1); - } - } - schedule_persist(&client, session_id); - } - return Ok(UpdateDrainDto { - live_events: events, - ..Default::default() - }); - }; - let persist_needed; - let mut journal_batch = None; - let mut journal_generation = 0; - let is_syncing_now; - let should_emit_sync_state; - { - let mut d = client.data.lock(); - // A completed old-account poll must not repopulate state after logout - // or authorization migration on the main lane. - if d.home_dc != home_dc - || d.home_auth_key != home_auth - || !session_lease_valid(&d, session_id) - { - io.transport = None; - return Err(expired_session_lease()); - } - is_syncing_now = catch_up_active( - io.last_difference.is_some(), - visible_gap_recovery_pending( - &applied.recovery, - d.last_history_chat_id, - &d.lazy_sync_exceptions, - d.lazy_channel_updates, - ), - false, - ); - let peers_changed = - merge_changed_entries(&mut d.peers, &applied.before_peers, applied.peers); - let media_changed = - merge_changed_entries(&mut d.media, &applied.before_media, applied.media); - let next_cursor = prefer_newer_cursor(d.updates.clone(), applied.cursor); - should_emit_sync_state = sync_edge( - d.is_syncing - || events - .iter() - .any(|event| matches!(event, UpdateEventDto::SyncState { is_syncing: true })), - is_syncing_now, - ); - d.is_syncing = is_syncing_now; - let durable_events: Vec; - (durable_events, events) = std::mem::take(&mut events) - .into_iter() - .partition(is_durable); - if !durable_events.is_empty() { - let channel_pts_after: Vec<(i64, i32)> = applied - .channel_pts - .iter() - .filter(|(chat, pts)| d.channel_pts.get(*chat) != Some(*pts)) - .map(|(chat, pts)| (*chat, *pts)) - .collect(); - let journal = std::sync::Arc::make_mut(&mut d.journal); - journal_generation = journal.account_generation; - journal_batch = - Some(journal.push(durable_events, next_cursor.clone(), channel_pts_after)); - } - persist_needed = journal_batch.is_some() - || new_session.is_some() - || peers_changed - || media_changed - || d.channel_pts != applied.channel_pts - || d.channel_recovery != applied.recovery - || d.seen_messages != applied.seen_messages - || next_cursor != d.updates; - if persist_needed { - d.persist_epoch = d.persist_epoch.saturating_add(1); - } - d.updates = next_cursor; - d.channel_pts = applied.channel_pts; - d.channel_recovery = applied.recovery; - d.seen_messages = applied.seen_messages; - d.lazy_channel_recovery = applied.lazy_queue; - d.lazy_retry_at = applied.lazy_retry_at; - if applied.stopped_channel == Some(d.last_history_chat_id) { - d.last_history_chat_id = 0; - } - if new_session.is_some() { - d.new_session = new_session.clone(); - } - d.updates_started = true; - // The updates lane can receive a fresher server salt/time correction. - // Feed it back to the home lane on the next RPC instead of repeatedly - // restoring stale values from the previous main-lane snapshot. - if io.snapshot.dc_id == d.home_dc && io.snapshot.auth_key == d.home_auth_key { - d.home_salt = io.snapshot.server_salt; - d.home_time_offset = io.snapshot.time_offset_micros; - } - } - drop(io); - drop(gate); - schedule_file_cleanup(&client); - if should_emit_sync_state { - events.push(UpdateEventDto::SyncState { - is_syncing: is_syncing_now, - }); - } - let Some(batch) = journal_batch else { - if persist_needed { - crate::perf::count("updates.persist"); - schedule_persist(&client, session_id); - } - return Ok(UpdateDrainDto { - live_events: events, - ..Default::default() - }); - }; - crate::perf::count("updates.persist"); - persist_journal_sync(&client, session_id)?; - std::sync::Arc::make_mut(&mut client.data.lock().journal).mark_delivered(batch.batch_id); - Ok(UpdateDrainDto { - batches: vec![batch_dto(journal_generation, &batch)], - live_events: events, - replay_required: false, - }) -} - -pub fn get_updates_state(handle: u64) -> Result { - with_read_lane(handle, |state| { - let cursor = call_with_migrate(state, |state| { - updates_rpc::get_updates_state(&mut state.snapshot, state.api_id) - })?; - // Reading the server head is not applying its updates. Advancing the - // recovery cursor here would skip messages on the next getDifference. - Ok(cursor) - }) -} - -pub(crate) fn recovery_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -pub(crate) fn defer_channel_recovery( - queue: &mut VecDeque, - mut entry: ChannelRecovery, - error: &MtprotoError, - now: u64, -) { - entry.due_at = now.saturating_add(recovery_wait_secs(error)); - queue.push_back(entry); -} - -pub(crate) fn terminal_channel_error(error: &MtprotoError) -> bool { - matches!(error, MtprotoError::Message(message) if - message.contains("CHANNEL_PRIVATE") || message.contains("CHANNEL_INVALID") - || message.contains("PEER_ID_INVALID")) -} - -pub(crate) fn recovery_wait_secs(error: &MtprotoError) -> u64 { - let wait = match error { - MtprotoError::Message(message) => ["FLOOD_WAIT_", "FLOOD_PREMIUM_WAIT_"] - .iter() - .filter_map(|prefix| message.split_once(prefix)) - .filter_map(|(_, suffix)| { - suffix - .split(|c: char| !c.is_ascii_digit()) - .next()? - .parse::() - .ok() - }) - .max() - .unwrap_or(5), - _ => 5, - }; - wait.max(5) -} - -pub(crate) fn prepare_channel_recovery( - queue: &mut VecDeque, - mut pending: Vec, - open_chat: i64, - now: u64, -) { - queue.retain(|entry| !entry.watching || entry.chat_id == open_chat); - for chat_id in pending - .drain(..) - .filter(|id| channel_id_from_chat_id(*id).is_some()) - { - if let Some(entry) = queue.iter_mut().find(|entry| entry.chat_id == chat_id) { - if entry.watching { - entry.watching = false; - entry.due_at = now; - } - } else { - queue.push_back(ChannelRecovery { - chat_id, - due_at: now, - watching: false, - }); - } - } - if channel_id_from_chat_id(open_chat).is_some() - && !queue.iter().any(|entry| entry.chat_id == open_chat) - { - queue.push_back(ChannelRecovery { - chat_id: open_chat, - due_at: now, - watching: true, - }); - } -} - -pub(crate) fn finish_channel_recovery( - queue: &mut VecDeque, - mut entry: ChannelRecovery, - final_page: bool, - timeout: Option, - open_chat: i64, - now: u64, -) { - if !final_page || entry.chat_id == open_chat { - entry.watching = final_page; - entry.due_at = now.saturating_add(if final_page { - timeout.unwrap_or(1).max(1) as u64 - } else { - 0 - }); - queue.push_back(entry); - } -} - -pub(crate) const LAZY_CHANNEL_BATCH: usize = 100; - -pub(crate) fn sync_edge(previous: bool, now: bool) -> bool { - previous != now -} - -/// Difference still running or a channel gap still queued. -/// A `watching` entry is the open channel's short poll, not catch-up. -pub(crate) fn catch_up_active( - difference_settled: bool, - recovery_pending: bool, - lazy_pending: bool, -) -> bool { - !difference_settled || recovery_pending || lazy_pending -} - -#[cfg(test)] -pub(crate) fn gap_recovery_pending(recovery: &VecDeque) -> bool { - recovery.iter().any(|entry| !entry.watching) -} - -pub(crate) fn visible_gap_recovery_pending( - recovery: &VecDeque, - open_chat: i64, - exceptions: &crate::HashSet, - lazy: bool, -) -> bool { - recovery.iter().any(|entry| { - !entry.watching - && (!lazy || entry.chat_id == open_chat || exceptions.contains(&entry.chat_id)) - }) -} - -pub(crate) fn next_channel_recovery( - recovery: &VecDeque, - open_chat: i64, - exceptions: &crate::HashSet, - lazy: bool, - now: u64, -) -> Option { - recovery - .iter() - .position(|entry| entry.due_at <= now && entry.chat_id == open_chat) - .or_else(|| { - recovery.iter().position(|entry| { - entry.due_at <= now && (!lazy || exceptions.contains(&entry.chat_id)) - }) - }) -} - -/// Chats the user opened, or listed as exceptions, must not stay on the lazy -/// batch. They go to `getChannelDifference` even if an earlier poll queued them. -pub(crate) fn pull_priority_channels( - lazy_queue: &mut crate::HashSet, - recovery: &mut VecDeque, - open_chat: i64, - exceptions: &crate::HashSet, - now: u64, -) { - let promoted: Vec = lazy_queue - .iter() - .copied() - .filter(|id| *id == open_chat || exceptions.contains(id)) - .collect(); - for chat_id in promoted { - lazy_queue.remove(&chat_id); - if let Some(entry) = recovery.iter_mut().find(|entry| entry.chat_id == chat_id) { - if entry.watching { - entry.watching = false; - entry.due_at = now; - } - } else { - recovery.push_back(ChannelRecovery { - chat_id, - due_at: now, - watching: false, - }); - } - } -} - -pub(crate) struct LazyFailurePlan { - pub restore: bool, - pub drop_transport: bool, -} - -/// A lazy `getPeerDialogs` failure restores the batch. It does not drop the -/// update transport. An unrecoverable session still fails the drain. -pub(crate) fn plan_lazy_failure(unrecoverable: bool) -> LazyFailurePlan { - if unrecoverable { - LazyFailurePlan { - restore: false, - drop_transport: false, - } - } else { - LazyFailurePlan { - restore: true, - drop_transport: false, - } - } -} - -/// Retain offscreen gaps durably until the channel is opened or lazy mode is disabled. -pub(crate) fn route_lazy_channels( - pending: Vec, - open_chat: i64, - exceptions: &crate::HashSet, - lazy_enabled: bool, - lazy_queue: &mut crate::HashSet, -) -> Vec { - if !lazy_enabled { - return pending; - } - let mut full = Vec::new(); - for chat_id in pending { - if chat_id == open_chat || exceptions.contains(&chat_id) { - full.push(chat_id); - } else if channel_id_from_chat_id(chat_id).is_some() { - lazy_queue.insert(chat_id); - full.push(chat_id); - } - } - full -} - -/// Takes at most `limit` peers that can be sent. Ids without a usable peer stay queued. -pub(crate) fn take_lazy_batch( - queue: &mut crate::HashSet, - mut usable: impl FnMut(i64) -> bool, - limit: usize, -) -> Vec { - let mut ids: Vec = queue.iter().copied().collect(); - ids.sort_unstable(); - let mut ready = Vec::new(); - for id in ids { - if ready.len() == limit { - break; - } - if usable(id) { - queue.remove(&id); - ready.push(id); - } - } - ready -} - -pub(crate) fn restore_lazy_batch(queue: &mut crate::HashSet, batch: &[i64]) { - queue.extend(batch.iter().copied()); -} - -pub(crate) fn lazy_dialog_events(chats: Vec) -> Vec { - if chats.is_empty() { - Vec::new() - } else { - vec![UpdateEventDto::DialogsPatched { chats }] - } -} diff --git a/native/mtproto-rs/src/collections.rs b/native/mtproto-rs/src/collections.rs deleted file mode 100644 index 14cc5cdac..000000000 --- a/native/mtproto-rs/src/collections.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub use compact_str::CompactString; -pub use rapidhash::fast::{ - HashMapExt, HashSetExt, RapidHashMap as HashMap, RapidHashSet as HashSet, -}; -pub use smallvec::SmallVec; - -pub type IndexMap = indexmap::IndexMap; diff --git a/native/mtproto-rs/src/dialogs/folders.rs b/native/mtproto-rs/src/dialogs/folders.rs deleted file mode 100644 index 9673a5e4a..000000000 --- a/native/mtproto-rs/src/dialogs/folders.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! https://core.telegram.org/api/folders -//! https://core.telegram.org/method/messages.getDialogFilters -//! https://core.telegram.org/method/messages.updateDialogFilter - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - Bool, DialogFilter, DialogFilterConstructor, InputPeer, MessagesDialogFilters, - MessagesGetDialogFiltersRequest, MessagesUpdateDialogFilterRequest, - MessagesUpdateDialogFiltersOrderRequest, TextWithEntities, TextWithEntitiesConstructor, True, - TrueConstructor, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::peers::{self, CachedPeer, input_peer_from_cached, vector_boxed_items}; -use crate::{FolderDto, MtprotoError}; - -pub(crate) fn folder_title(title: &TextWithEntities) -> String { - match title { - TextWithEntities::TextWithEntities(t) => t.text.clone(), - _ => "Folder".into(), - } -} - -pub(crate) fn collect_input_peer_ids(peers: impl Iterator) -> Vec { - peers - .filter_map(|peer| peers::chat_id_from_input_peer(&peer)) - .collect() -} - -pub(crate) fn flag(value: &Option>) -> bool { - value.is_some() -} - -pub fn get_folders( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, -) -> Result, MtprotoError> { - let response: MessagesDialogFilters = - api_invoke::invoke_api(snapshot, api_id, MessagesGetDialogFiltersRequest {})?; - let MessagesDialogFilters::MessagesDialogFilters(filters) = response else { - return Err(MtprotoError::Message( - "unexpected messages.dialogFilters".into(), - )); - }; - let mut out = Vec::new(); - for filter in vector_boxed_items(&filters.filters) { - match filter { - DialogFilter::DialogFilter(f) => { - let _ = peers; - let pinned_chat_ids = - collect_input_peer_ids(vector_boxed_items(&f.pinned_peers).cloned()); - let include = collect_input_peer_ids(vector_boxed_items(&f.include_peers).cloned()); - let exclude_chat_ids = - collect_input_peer_ids(vector_boxed_items(&f.exclude_peers).cloned()); - out.push(FolderDto { - id: f.id, - title: folder_title(&f.title), - chat_ids: merge_folder_chat_ids(&pinned_chat_ids, &include), - exclude_chat_ids, - include_contacts: flag(&f.contacts), - include_non_contacts: flag(&f.non_contacts), - include_groups: flag(&f.groups), - include_channels: flag(&f.broadcasts), - include_bots: flag(&f.bots), - exclude_muted: flag(&f.exclude_muted), - exclude_read: flag(&f.exclude_read), - exclude_archived: flag(&f.exclude_archived), - emoticon: f.emoticon.clone().unwrap_or_default(), - pinned_chat_ids, - }); - } - DialogFilter::DialogFilterDefault(_) => { - out.push(FolderDto { - id: 0, - title: "All chats".into(), - chat_ids: Vec::new(), - exclude_chat_ids: Vec::new(), - include_contacts: true, - include_non_contacts: true, - include_groups: true, - include_channels: true, - include_bots: true, - exclude_muted: false, - exclude_read: false, - exclude_archived: true, - emoticon: String::new(), - pinned_chat_ids: Vec::new(), - }); - } - DialogFilter::DialogFilterChatlist(f) => { - let pinned_chat_ids = - collect_input_peer_ids(vector_boxed_items(&f.pinned_peers).cloned()); - let include = collect_input_peer_ids(vector_boxed_items(&f.include_peers).cloned()); - out.push(FolderDto { - id: f.id, - title: folder_title(&f.title), - chat_ids: merge_folder_chat_ids(&pinned_chat_ids, &include), - exclude_chat_ids: Vec::new(), - include_contacts: false, - include_non_contacts: false, - include_groups: false, - include_channels: false, - include_bots: false, - exclude_muted: false, - exclude_read: false, - exclude_archived: false, - emoticon: f.emoticon.clone().unwrap_or_default(), - pinned_chat_ids, - }); - } - _ => {} - } - } - Ok(out) -} - -pub(crate) fn merge_folder_chat_ids(pinned: &[i64], include: &[i64]) -> Vec { - let mut chat_ids = pinned.to_vec(); - for id in include { - if !chat_ids.contains(id) { - chat_ids.push(*id); - } - } - chat_ids -} - -pub(crate) fn folder_flag(on: bool) -> Option> { - on.then(|| Box::new(True::True(TrueConstructor {}))) -} - -pub(crate) fn folder_input_peers( - peers: &HashMap, - ids: &[i64], -) -> Vector> { - let items: Vec> = ids - .iter() - .filter_map(|id| { - peers - .get(id) - .map(|cached| Box::new(input_peer_from_cached(cached))) - }) - .collect(); - Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - }) -} - -pub fn update_folder( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - folder: &FolderDto, -) -> Result<(), MtprotoError> { - if folder.id <= 1 { - return Err(MtprotoError::Message("folder id required".into())); - } - let title = folder.title.trim(); - if title.is_empty() { - return Err(MtprotoError::Message("folder title required".into())); - } - let mut flags = 0u32; - if folder.include_contacts { - flags |= DialogFilterConstructor::CONTACTS_FLAG; - } - if folder.include_non_contacts { - flags |= DialogFilterConstructor::NON_CONTACTS_FLAG; - } - if folder.include_groups { - flags |= DialogFilterConstructor::GROUPS_FLAG; - } - if folder.include_channels { - flags |= DialogFilterConstructor::BROADCASTS_FLAG; - } - if folder.include_bots { - flags |= DialogFilterConstructor::BOTS_FLAG; - } - if folder.exclude_muted { - flags |= DialogFilterConstructor::EXCLUDE_MUTED_FLAG; - } - if folder.exclude_read { - flags |= DialogFilterConstructor::EXCLUDE_READ_FLAG; - } - if folder.exclude_archived { - flags |= DialogFilterConstructor::EXCLUDE_ARCHIVED_FLAG; - } - let emoticon = folder.emoticon.trim(); - if !emoticon.is_empty() { - flags |= DialogFilterConstructor::EMOTICON_FLAG; - } - let include_ids: Vec = folder - .chat_ids - .iter() - .copied() - .filter(|id| !folder.pinned_chat_ids.contains(id)) - .collect(); - let filter = DialogFilter::DialogFilter(DialogFilterConstructor { - flags, - contacts: folder_flag(folder.include_contacts), - non_contacts: folder_flag(folder.include_non_contacts), - groups: folder_flag(folder.include_groups), - broadcasts: folder_flag(folder.include_channels), - bots: folder_flag(folder.include_bots), - exclude_muted: folder_flag(folder.exclude_muted), - exclude_read: folder_flag(folder.exclude_read), - exclude_archived: folder_flag(folder.exclude_archived), - title_noanimate: None, - id: folder.id, - title: Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: title.to_string(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - }, - )), - emoticon: (!emoticon.is_empty()).then(|| emoticon.to_string()), - color: None, - pinned_peers: Box::new(folder_input_peers(peers, &folder.pinned_chat_ids)), - include_peers: Box::new(folder_input_peers(peers, &include_ids)), - exclude_peers: Box::new(folder_input_peers(peers, &folder.exclude_chat_ids)), - }); - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesUpdateDialogFilterRequest { - flags: MessagesUpdateDialogFilterRequest::FILTER_FLAG, - id: folder.id, - filter: Some(Box::new(filter)), - }, - )?; - Ok(()) -} - -pub fn delete_folder(snapshot: &mut Snapshot, api_id: i32, id: i32) -> Result<(), MtprotoError> { - if id <= 1 { - return Err(MtprotoError::Message("folder id required".into())); - } - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesUpdateDialogFilterRequest { - flags: 0, - id, - filter: None, - }, - )?; - Ok(()) -} - -pub fn update_folder_order( - snapshot: &mut Snapshot, - api_id: i32, - order: &[i32], -) -> Result<(), MtprotoError> { - let items = order.to_vec(); - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesUpdateDialogFiltersOrderRequest { - order: Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - })), - }, - )?; - Ok(()) -} diff --git a/native/mtproto-rs/src/dialogs/forum.rs b/native/mtproto-rs/src/dialogs/forum.rs deleted file mode 100644 index 83b2c2e31..000000000 --- a/native/mtproto-rs/src/dialogs/forum.rs +++ /dev/null @@ -1,206 +0,0 @@ -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::latest::api::{ - Bool, BoolFalseConstructor, BoolTrueConstructor, ForumTopic, Message, - MessagesEditForumTopicRequest, MessagesForumTopics, MessagesGetForumTopicsByIdRequest, - MessagesGetForumTopicsRequest, Updates, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use super::peers::cache_from_users_chats; -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::peers::{self, CachedPeer, input_peer_from_cached, vector_boxed_items}; -use crate::{ForumTopicDto, ForumTopicsPageDto, MtprotoError}; - -pub fn get_forum_topics( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - offset_date: i32, - offset_id: i32, - offset_topic: i32, - limit: i32, -) -> Result { - let peer = Box::new(input_peer_from_cached(peers::require_usable_peer( - peers, chat_id, - )?)); - let request = MessagesGetForumTopicsRequest { - flags: 0, - peer, - q: None, - offset_date, - offset_id, - offset_topic, - limit: limit.clamp(1, 100), - }; - let response: MessagesForumTopics = api_invoke::invoke_api(snapshot, api_id, request)?; - page_from_forum_topics(response, peers, media_index) -} - -/// https://core.telegram.org/method/messages.getForumTopicsByID -pub fn get_forum_topics_by_id( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - topic_ids: &[i32], -) -> Result { - if topic_ids.is_empty() { - return Ok(ForumTopicsPageDto { - count: 0, - topics: Vec::new(), - }); - } - let peer = Box::new(input_peer_from_cached(peers::require_usable_peer( - peers, chat_id, - )?)); - let request = MessagesGetForumTopicsByIdRequest { - peer, - topics: Box::new(Vector::Vector(VectorConstructor { - field_0: topic_ids.len() as u32, - field_1: topic_ids.to_vec(), - })), - }; - let response: MessagesForumTopics = api_invoke::invoke_api(snapshot, api_id, request)?; - page_from_forum_topics(response, peers, media_index) -} - -pub(crate) fn page_from_forum_topics( - response: MessagesForumTopics, - peers: &mut HashMap, - media_index: &mut MediaIndex, -) -> Result { - let MessagesForumTopics::MessagesForumTopics(page) = response else { - return Err(MtprotoError::Message( - "unexpected messages.forumTopics".into(), - )); - }; - cache_from_users_chats( - peers, - media_index, - vector_boxed_items(&page.users).cloned(), - vector_boxed_items(&page.chats).cloned(), - ); - let mut previews = HashMap::new(); - for msg in vector_boxed_items(&page.messages) { - if let Message::Message(m) = msg { - if !m.message.is_empty() { - previews.insert(m.id, m.message.clone()); - } - } - } - let topics = vector_boxed_items(&page.topics) - .map(|topic| { - let preview = topic_top_message_id(topic).and_then(|id| previews.get(&id).cloned()); - map_forum_topic(topic, preview) - }) - .collect(); - Ok(ForumTopicsPageDto { - count: page.count, - topics, - }) -} - -pub(crate) fn topic_top_message_id(topic: &ForumTopic) -> Option { - match topic { - ForumTopic::ForumTopic(t) => Some(t.top_message).filter(|&id| id > 0), - _ => None, - } -} - -pub(crate) fn map_forum_topic(topic: &ForumTopic, preview: Option) -> ForumTopicDto { - match topic { - ForumTopic::ForumTopicDeleted(deleted) => ForumTopicDto { - id: deleted.id, - title: String::new(), - icon_color: 0, - icon_emoji_id: None, - top_message: 0, - date: 0, - unread_count: 0, - unread_mentions_count: 0, - unread_reactions_count: 0, - read_inbox_max_id: 0, - pinned: false, - closed: false, - hidden: false, - short: false, - deleted: true, - last_message_preview: None, - }, - ForumTopic::ForumTopic(topic) => ForumTopicDto { - id: topic.id, - title: topic.title.clone(), - icon_color: topic.icon_color, - icon_emoji_id: topic.icon_emoji_id, - top_message: topic.top_message, - date: topic.date, - unread_count: topic.unread_count, - unread_mentions_count: topic.unread_mentions_count, - unread_reactions_count: topic.unread_reactions_count, - read_inbox_max_id: topic.read_inbox_max_id, - pinned: topic.pinned.is_some(), - closed: topic.closed.is_some(), - hidden: topic.hidden.is_some(), - short: topic.short.is_some(), - deleted: false, - last_message_preview: preview, - }, - _ => ForumTopicDto { - id: 0, - title: String::new(), - icon_color: 0, - icon_emoji_id: None, - top_message: 0, - date: 0, - unread_count: 0, - unread_mentions_count: 0, - unread_reactions_count: 0, - read_inbox_max_id: 0, - pinned: false, - closed: false, - hidden: false, - short: false, - deleted: true, - last_message_preview: None, - }, - } -} - -/// Hide or unhide General (`messages.editForumTopic` hidden flag). -/// https://core.telegram.org/method/messages.editForumTopic -pub fn edit_forum_topic_hidden( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - topic_id: i32, - hidden: bool, -) -> Result<(), MtprotoError> { - let peer = Box::new(input_peer_from_cached(peers::require_usable_peer( - peers, chat_id, - )?)); - let request = MessagesEditForumTopicRequest { - flags: MessagesEditForumTopicRequest::HIDDEN_FLAG, - peer, - topic_id, - title: None, - icon_emoji_id: None, - closed: None, - hidden: Some(tl_bool(hidden)), - }; - let _: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(()) -} - -fn tl_bool(value: bool) -> Box { - if value { - Box::new(Bool::BoolTrue(BoolTrueConstructor {})) - } else { - Box::new(Bool::BoolFalse(BoolFalseConstructor {})) - } -} diff --git a/native/mtproto-rs/src/dialogs/get_dialogs.rs b/native/mtproto-rs/src/dialogs/get_dialogs.rs deleted file mode 100644 index e93fc1a03..000000000 --- a/native/mtproto-rs/src/dialogs/get_dialogs.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! https://core.telegram.org/method/messages.getDialogs -//! https://core.telegram.org/api/folders - -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, -}; -use tellers_mtproto_session::Snapshot; - -use super::peers::{ - cache_from_users_chats, index_dialog_avatar, index_users_chats, title_for_peer, -}; -use super::preview::message_preview; -use crate::api_invoke; -use crate::emoji_status::emoji_status_document_id; -use crate::media::{self, MediaIndex}; -use crate::peers::{ - self, CachedPeer, PeerKind, chat_id_for_channel, chat_id_for_chat, chat_id_for_user, - input_peer_from_cached, peer_chat_id, vector_boxed_items, -}; -use crate::presence::user_status_parts; -use crate::{ChatDto, MtprotoError}; - -pub(crate) fn is_muted(settings: &PeerNotifySettings) -> bool { - let PeerNotifySettings::PeerNotifySettings(s) = settings else { - return false; - }; - let until = s.mute_until.unwrap_or(0); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i32) - .unwrap_or(0); - until > now -} - -/// True when the dialog carries its own mute setting instead of inheriting the peer type default -/// (`account.getNotifySettings` for users/chats/broadcasts). Telegram clients treat a missing -/// `mute_until` flag as "inherited", and an explicitly past `mute_until` as "not muted". -pub(crate) fn is_mute_override(settings: &PeerNotifySettings) -> bool { - let PeerNotifySettings::PeerNotifySettings(s) = settings else { - return false; - }; - s.mute_until.is_some() -} - -pub fn get_dialogs( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - channel_pts: &mut HashMap, - offset_date: i32, - offset_id: i32, - offset_peer_id: i64, - folder_id: Option, -) -> Result, MtprotoError> { - let offset_peer = if offset_peer_id == 0 { - InputPeer::InputPeerEmpty(InputPeerEmptyConstructor {}) - } else { - let cached = peers::require_usable_peer(peers, offset_peer_id)?; - input_peer_from_cached(cached) - }; - let folder_present = folder_id.is_some(); - let request = MessagesGetDialogsRequest { - // flags.0 exclude_pinned, flags.1 folder_id. Both reference clients always send - // exclude_pinned together with folder_id, so mirror that shape exactly. - flags: if folder_present { 3 } else { 0 }, - exclude_pinned: folder_present.then(|| Box::new(True::True(TrueConstructor {}))), - folder_id, - offset_date, - offset_id, - offset_peer: Box::new(offset_peer), - limit: 40, - hash: 0, - }; - let response: MessagesDialogs = api_invoke::invoke_api(snapshot, api_id, request)?; - let (dialogs, messages, chats, users) = match response { - MessagesDialogs::MessagesDialogs(d) => (d.dialogs, d.messages, d.chats, d.users), - MessagesDialogs::MessagesDialogsSlice(d) => (d.dialogs, d.messages, d.chats, d.users), - MessagesDialogs::MessagesDialogsNotModified(_) => { - return Ok(Vec::new()); - } - _ => return Err(MtprotoError::Message("unexpected messages.dialogs".into())), - }; - 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, - api_id, - peers, - media_index, - channel_pts, - folder_id.unwrap_or(0), - ) { - out = merge_pinned_prefix(pinned, out); - } - } - Ok(out) -} - -fn fetch_pinned_dialogs( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - channel_pts: &mut HashMap, - folder_id: i32, -) -> Result, MtprotoError> { - let response: MessagesPeerDialogs = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetPinnedDialogsRequest { folder_id }, - )?; - let MessagesPeerDialogs::MessagesPeerDialogs(d) = response; - Ok(map_peer_dialogs( - peers, - media_index, - channel_pts, - d.dialogs, - d.messages, - d.chats, - d.users, - )) -} - -pub(crate) fn map_peer_dialogs( - peers: &mut HashMap, - media_index: &mut MediaIndex, - channel_pts: &mut HashMap, - dialogs: Box>>, - messages: Box>>, - chats: Box>>, - users: Box>>, -) -> Vec { - let user_list: Vec = vector_boxed_items(&users).cloned().collect(); - let chat_list: Vec = vector_boxed_items(&chats).cloned().collect(); - cache_from_users_chats( - peers, - media_index, - user_list.iter().cloned(), - chat_list.iter().cloned(), - ); - let mut extras: HashMap< - i64, - ( - bool, - bool, - Option, - Option, - Option, - Option, - ), - > = HashMap::new(); - let mut poll_peers: HashSet = HashSet::new(); - let mut verified_ids: HashSet = HashSet::new(); - for user in &user_list { - if let User::User(u) = user { - let chat_id = chat_id_for_user(u.id); - if u.bot.is_some() || u.self_.is_some() { - poll_peers.insert(chat_id); - } - if u.verified.is_some() { - verified_ids.insert(chat_id); - } - let photo_key = u.photo.as_ref().and_then(|photo| { - let UserProfilePhoto::UserProfilePhoto(p) = photo.as_ref() else { - return None; - }; - let hash = u.access_hash.filter(|&h| h != 0)?; - Some(index_dialog_avatar( - media_index, - chat_id, - PeerKind::User, - u.id, - hash, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - )) - }); - let (peer_status, peer_status_at) = - user_status_parts(u.status.as_ref().map(|s| s.as_ref())); - extras.insert( - chat_id, - ( - u.contact.is_some(), - u.bot.is_some(), - photo_key, - peer_status, - peer_status_at, - emoji_status_document_id(u.emoji_status.as_ref().map(|s| s.as_ref())), - ), - ); - } - } - for chat in &chat_list { - if super::peers::chat_poll_permission(chat) { - if let Some((id, meta)) = super::peers::chat_meta_from_tl(chat) { - poll_peers.insert(if meta.is_channel || matches!(chat, TlChat::Channel(_)) { - chat_id_for_channel(id) - } else { - chat_id_for_chat(id) - }); - } - } - match chat { - TlChat::Chat(c) => { - let chat_id = chat_id_for_chat(c.id); - let photo_key = match &*c.photo { - ChatPhoto::ChatPhoto(p) => Some(index_dialog_avatar( - media_index, - chat_id, - PeerKind::Chat, - c.id, - 0, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - )), - _ => None, - }; - extras.insert(chat_id, (false, false, photo_key, None, None, None)); - } - TlChat::Channel(c) => { - let chat_id = chat_id_for_channel(c.id); - if c.verified.is_some() { - verified_ids.insert(chat_id); - } - let photo_key = match &*c.photo { - ChatPhoto::ChatPhoto(p) => c.access_hash.filter(|&h| h != 0).map(|hash| { - index_dialog_avatar( - media_index, - chat_id, - PeerKind::Channel, - c.id, - hash, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - ) - }), - _ => None, - }; - extras.insert( - chat_id, - ( - false, - false, - photo_key, - None, - None, - emoji_status_document_id(c.emoji_status.as_ref().map(|s| s.as_ref())), - ), - ); - } - _ => {} - } - } - let (user_names, chat_meta) = index_users_chats(user_list.into_iter(), chat_list.into_iter()); - - let mut last_by_peer: crate::IndexMap< - i64, - (Option, Option, Option, i32, bool), - > = crate::IndexMap::default(); - for msg in vector_boxed_items(&messages) { - let indexed = media::index_message_media(msg, media_index); - if let Some((id, date, text, outgoing)) = - message_preview(msg, &user_names, &chat_meta, &indexed) - { - let chat_id = match msg { - Message::Message(m) => peer_chat_id(&m.peer_id), - Message::MessageService(m) => peer_chat_id(&m.peer_id), - _ => continue, - }; - let thumb = indexed.thumb_cache_key; - last_by_peer - .entry(chat_id) - .and_modify(|e| { - if e.1.map(|d| date >= d).unwrap_or(true) { - *e = (text.clone(), Some(date), thumb.clone(), id, outgoing); - } - }) - .or_insert((text, Some(date), thumb, id, outgoing)); - } - } - - let mut out = Vec::new(); - for dialog in vector_boxed_items(&dialogs) { - let Dialog::Dialog(d) = dialog else { - continue; - }; - let chat_id = peer_chat_id(&d.peer); - let meta = title_for_peer(&d.peer, &user_names, &chat_meta); - let known_peer = match d.peer.as_ref() { - Peer::PeerChat(c) => chat_meta.contains_key(&c.chat_id), - Peer::PeerChannel(c) => chat_meta.contains_key(&c.channel_id), - _ => true, - }; - // Chats Telegram omitted from the payload stay out. Left/kicked dialogs - // are returned with `left = true` so Room can hide them. - if !known_peer { - continue; - } - let title = meta.title.clone(); - let is_channel = meta.is_channel; - let is_group = meta.is_group; - if is_channel { - merge_channel_pts(channel_pts, chat_id, d.pts); - } - let (preview, date, last_thumb, last_id, last_outgoing) = last_by_peer - .shift_remove(&chat_id) - .unwrap_or((None, None, None, 0, false)); - let ( - is_contact, - is_bot, - photo_cache_key, - peer_status, - peer_status_at, - emoji_status_document_id, - ) = extras - .get(&chat_id) - .cloned() - .unwrap_or((false, false, None, None, None, None)); - out.push(ChatDto { - id: chat_id, - title: title.into(), - is_channel, - is_group, - is_forum: meta.is_forum, - left: meta.left, - unread_count: d.unread_count, - last_message_preview: preview, - last_message_date: date, - archived: d.folder_id == Some(1), - muted: is_muted(&d.notify_settings), - mute_override: is_mute_override(&d.notify_settings), - unread_mark: d.unread_mark.is_some(), - unread_mentions_count: d.unread_mentions_count, - unread_reactions_count: d.unread_reactions_count, - is_contact, - is_bot, - is_verified: verified_ids.contains(&chat_id), - photo_cache_key, - pinned: d.pinned.is_some(), - read_inbox_max_id: d.read_inbox_max_id, - read_outbox_max_id: d.read_outbox_max_id, - peer_status, - peer_status_at, - last_media_thumb_cache_key: last_thumb, - last_message_id: last_id, - can_view: meta.can_view, - can_send_plain: meta.can_send_plain, - can_send_photos: meta.can_send_photos, - can_send_polls: poll_peers.contains(&chat_id), - can_forward: meta.can_forward, - can_delete_others: meta.can_delete_others, - can_manage_topics: meta.can_manage_topics, - emoji_status_document_id, - last_message_outgoing: last_outgoing, - }); - } - out -} - -pub(crate) fn merge_pinned_prefix(pinned: Vec, rest: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::with_capacity(pinned.len() + rest.len()); - for mut chat in pinned { - if seen.insert(chat.id) { - chat.pinned = true; - out.push(chat); - } - } - for chat in rest { - if seen.insert(chat.id) { - out.push(chat); - } - } - out -} - -/// Dialog `pts` is the last channel cursor supplied with the dialog list. -/// Keep only positive, monotonic values so an older page cannot rewind a -/// recovered channel before `updates.getChannelDifference` runs. -pub(crate) fn merge_channel_pts( - channel_pts: &mut HashMap, - chat_id: i64, - dialog_pts: Option, -) { - let Some(dialog_pts) = dialog_pts.filter(|pts| *pts > 0) else { - return; - }; - channel_pts - .entry(chat_id) - .and_modify(|current| *current = (*current).max(dialog_pts)) - .or_insert(dialog_pts); -} diff --git a/native/mtproto-rs/src/dialogs/history.rs b/native/mtproto-rs/src/dialogs/history.rs deleted file mode 100644 index 1cb253d4b..000000000 --- a/native/mtproto-rs/src/dialogs/history.rs +++ /dev/null @@ -1,203 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - ChannelsGetMessagesRequest, InputChannel, InputChannelConstructor, InputMessage, - InputMessageIdConstructor, Message, MessagesGetHistoryRequest, MessagesGetMessagesRequest, - MessagesGetRepliesRequest, MessagesMessages, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use super::message_map::{dtos_from_messages, message_to_dto}; -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::peers::{ - self, CachedPeer, PeerKind, channel_id_from_chat_id, input_peer_from_cached, vector_boxed_items, -}; -use crate::{MessageDto, MtprotoError}; - -pub fn get_history( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - limit: i32, - offset_id: i32, - offset_date: i32, - add_offset: i32, - user_id: Option, -) -> Result, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let request = MessagesGetHistoryRequest { - peer: Box::new(input_peer_from_cached(cached)), - offset_id, - offset_date, - add_offset, - limit: limit.clamp(1, 100), - max_id: 0, - min_id: 0, - hash: 0, - }; - let response: MessagesMessages = api_invoke::invoke_api(snapshot, api_id, request)?; - let mut dtos = dtos_from_messages(response, peers, media_index)?; - crate::dialogs::rich_rpc::fill_unsupported_rich_text( - snapshot, api_id, peers, user_id, &mut dtos, - ); - Ok(dtos) -} - -/// https://core.telegram.org/method/messages.getReplies -/// https://core.telegram.org/api/discussion -pub fn get_replies( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - msg_id: i32, - limit: i32, - offset_id: i32, - add_offset: i32, - user_id: Option, -) -> Result, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let request = MessagesGetRepliesRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id, - offset_id, - offset_date: 0, - add_offset, - limit: limit.clamp(1, 100), - max_id: 0, - min_id: 0, - hash: 0, - }; - let response: MessagesMessages = api_invoke::invoke_api(snapshot, api_id, request)?; - let mut dtos = dtos_from_messages(response, peers, media_index)?; - crate::dialogs::rich_rpc::fill_unsupported_rich_text( - snapshot, api_id, peers, user_id, &mut dtos, - ); - Ok(dtos) -} - -/// https://core.telegram.org/method/messages.getForumTopics - -pub(crate) fn input_message_ids(message_id: i32) -> Box>> { - Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(InputMessage::InputMessageId( - InputMessageIdConstructor { id: message_id }, - ))], - })) -} - -pub(crate) fn messages_from_response( - response: MessagesMessages, -) -> Result>, MtprotoError> { - match response { - MessagesMessages::MessagesMessages(m) => Ok(*m.messages), - MessagesMessages::MessagesMessagesSlice(m) => Ok(*m.messages), - MessagesMessages::MessagesChannelMessages(m) => Ok(*m.messages), - MessagesMessages::MessagesMessagesNotModified(_) => { - Err(MtprotoError::Message("messages not modified".into())) - } - _ => Err(MtprotoError::Message("unexpected messages.messages".into())), - } -} - -/// Refetch one dialog message and re-index its `file_reference`. -/// https://core.telegram.org/api/file-references (`fileSourceMessage` / getMessageOp) -pub fn refresh_message_media( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - message_id: i32, -) -> Result<(), MtprotoError> { - let messages = fetch_message(snapshot, api_id, peers, chat_id, message_id)?; - let _ = vector_boxed_items(&messages) - .filter_map(|msg| message_to_dto(msg, media_index)) - .count(); - Ok(()) -} - -pub fn get_message( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - message_id: i32, -) -> Result, MtprotoError> { - if message_id <= 0 { - return Err(MtprotoError::Message("message id required".into())); - } - let messages = fetch_message(snapshot, api_id, peers, chat_id, message_id)?; - select_message(&messages, media_index, chat_id, message_id) -} - -fn select_message( - messages: &Vector>, - media_index: &mut MediaIndex, - chat_id: i64, - message_id: i32, -) -> Result, MtprotoError> { - let mut rows = vector_boxed_items(messages); - let Some(message) = rows.next() else { - return Ok(None); - }; - if rows.next().is_some() { - return Err(MtprotoError::Message("unexpected message identity".into())); - } - if let Message::MessageEmpty(empty) = message { - if empty.id != message_id - || empty - .peer_id - .as_ref() - .is_some_and(|peer| crate::peers::peer_chat_id(peer) != chat_id) - { - return Err(MtprotoError::Message("unexpected message identity".into())); - } - return Ok(None); - } - let mut staged = media_index.clone(); - let message = message_to_dto(message, &mut staged) - .filter(|message| message.id == message_id && message.chat_id == chat_id) - .ok_or_else(|| MtprotoError::Message("unexpected message identity".into()))?; - *media_index = staged; - Ok(Some(message)) -} - -pub(crate) fn fetch_message( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, -) -> Result>, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let ids = input_message_ids(message_id); - let response: MessagesMessages = if cached.kind == PeerKind::Channel { - let channel_id = channel_id_from_chat_id(chat_id) - .ok_or_else(|| MtprotoError::Message("not a channel".into()))?; - api_invoke::invoke_api( - snapshot, - api_id, - ChannelsGetMessagesRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id, - access_hash: cached.access_hash, - })), - id: ids, - }, - )? - } else { - api_invoke::invoke_api(snapshot, api_id, MessagesGetMessagesRequest { id: ids })? - }; - messages_from_response(response) -} - -#[cfg(test)] -#[path = "../../tests/unit/dialogs_message_lookup_tests.rs"] -mod message_lookup_tests; diff --git a/native/mtproto-rs/src/dialogs/message_map.rs b/native/mtproto-rs/src/dialogs/message_map.rs deleted file mode 100644 index 5d9c4b812..000000000 --- a/native/mtproto-rs/src/dialogs/message_map.rs +++ /dev/null @@ -1,554 +0,0 @@ -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::latest::api::{ - Chat as TlChat, Message, MessageEntity, MessageFwdHeader, MessageMedia, MessageReplyHeader, - MessageReplyHeaderConstructor, MessagesMessages, RichMessage, RichText, User, Vector, -}; - -use super::peers::{cache_from_users_chats, display_name}; -use crate::emoji_status::emoji_status_document_id; -use crate::media::{self, MediaIndex}; -use crate::peers::{ - CachedPeer, chat_id_for_channel, chat_id_for_chat, chat_id_for_user, peer_chat_id, - vector_boxed_items, -}; -use crate::service_messages::service_action_text; -use crate::{MessageDto, MtprotoError}; - -pub(crate) fn message_to_dto(msg: &Message, media_index: &mut MediaIndex) -> Option { - message_to_dto_named( - msg, - media_index, - &HashMap::new(), - &HashMap::new(), - &HashMap::new(), - ) -} - -pub(crate) fn message_to_dto_named( - msg: &Message, - media_index: &mut MediaIndex, - titles: &HashMap, - usernames: &HashMap, - emoji_status: &HashMap, -) -> Option { - match msg { - Message::Message(m) => { - let chat_id = peer_chat_id(&m.peer_id); - let indexed = media::index_message_media(msg, media_index); - let fallback = m - .media - .as_ref() - .and_then(|media| media::media_fallback_text(media)); - let rich = m - .rich_message - .as_ref() - .and_then(|rich| match rich.as_ref() { - RichMessage::RichMessage(body) => { - let photos: Vec<_> = vector_boxed_items(&body.photos).cloned().collect(); - let formatted = media::page_blocks_formatted_media( - vector_boxed_items(&body.blocks), - &photos, - ); - (!formatted.text.is_empty()).then_some(formatted) - } - }); - let text = if !m.message.is_empty() { - Some(m.message.clone()) - } else if let Some(rich) = rich.as_ref() { - Some(rich.text.clone()) - } else { - match indexed.kind.as_deref() { - Some( - "sticker" | "sticker_animated" | "sticker_video" | "photo" | "video" - | "gif" | "voice", - ) => None, - _ => fallback, - } - }; - if text.is_none() - && indexed.kind.is_none() - && m.fwd_from.is_none() - && m.via_bot_id.is_none() - && m.rich_message.is_none() - { - return None; - } - let media_kind = match (indexed.kind.as_deref(), text.as_ref()) { - (Some("unsupported"), Some(_)) => None, - _ => indexed.kind.clone(), - }; - let ( - reply_to_msg_id, - reply_to_top_id, - reply_quote, - reply_quote_entities_json, - forum_topic, - ) = reply_meta(m.reply_to.as_deref()); - Some(MessageDto { - media_preview_restricted: media_preview_restricted(m.media.as_deref()).map( - |restricted| restricted || m.noforwards.is_some() || m.ttl_period.is_some(), - ), - chat_id, - id: m.id, - sender_id: m.from_id.as_ref().map(|p| peer_chat_id(p)), - text, - date: i64::from(m.date), - edit_date: if m.edit_hide.is_some() { - None - } else { - m.edit_date.map(i64::from) - }, - outgoing: m.out.is_some(), - media_kind, - media_cache_key: indexed.cache_key, - thumb_cache_key: indexed.thumb_cache_key, - media_duration: indexed.duration, - media_width: indexed.width, - media_height: indexed.height, - reply_quote, - reply_quote_entities_json, - entities_json: rich - .as_ref() - .and_then(|formatted| { - (!formatted.entities.is_empty()) - .then(|| serde_json::to_string(&formatted.entities.as_slice()).ok()) - .flatten() - }) - .or_else(|| entities_to_json(m.entities.as_deref())), - noforwards: m.noforwards.is_some(), - reply_to_msg_id, - reply_to_top_id, - fwd_from: fwd_from_label(m.fwd_from.as_deref(), titles), - fwd_from_id: fwd_origin(m.fwd_from.as_deref()).0, - fwd_date: fwd_origin(m.fwd_from.as_deref()).1, - via_bot: via_bot_label(m.via_bot_id, usernames, titles), - sender_name: m - .from_id - .as_ref() - .and_then(|peer| titles.get(&peer_chat_id(peer)).map(|name| name.to_string())), - sender_emoji_status_document_id: m - .from_id - .as_ref() - .and_then(|peer| emoji_status.get(&peer_chat_id(peer)).copied()), - grouped_id: m.grouped_id, - file_name: indexed.file_name, - file_size: indexed.file_size, - supports_streaming: indexed.supports_streaming, - reactions_json: crate::client::extras_rpc::reactions_to_json( - m.reactions.as_ref().map(|r| r.as_ref()), - ), - replies_count: crate::client::extras_rpc::replies_meta( - m.replies.as_ref().map(|r| r.as_ref()), - ) - .0, - discussion_peer_id: crate::client::extras_rpc::replies_meta( - m.replies.as_ref().map(|r| r.as_ref()), - ) - .1, - reply_markup_json: crate::reply_markup::to_json(m.reply_markup.as_deref()), - forum_topic, - sender_photo_cache_key: sender_photo_cache_key( - media_index, - m.from_id.as_ref().map(|peer| peer_chat_id(peer)), - ), - }) - } - Message::MessageService(m) => { - let sender_id = m.from_id.as_ref().map(|p| peer_chat_id(p)); - let sender_name = sender_id.and_then(|id| titles.get(&id).map(|name| name.to_string())); - let (reply_to_msg_id, reply_to_top_id, _, _, forum_topic) = - reply_meta(m.reply_to.as_deref()); - Some(MessageDto { - media_preview_restricted: None, - chat_id: peer_chat_id(&m.peer_id), - id: m.id, - sender_id, - text: Some(service_action_text( - m.action.as_ref(), - sender_id, - sender_name.as_deref().unwrap_or(""), - titles, - )), - date: i64::from(m.date), - edit_date: None, - outgoing: m.out.is_some(), - media_kind: Some("service".into()), - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: None, - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id, - reply_to_top_id, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name, - sender_emoji_status_document_id: sender_id - .and_then(|id| emoji_status.get(&id).copied()), - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic, - sender_photo_cache_key: sender_photo_cache_key(media_index, sender_id), - }) - } - _ => None, - } -} - -fn media_preview_restricted(media: Option<&MessageMedia>) -> Option { - match media { - Some(MessageMedia::MessageMediaPhoto(photo)) => { - Some(photo.spoiler.is_some() || photo.ttl_seconds.is_some()) - } - Some(MessageMedia::MessageMediaDocument(document)) => { - Some(document.spoiler.is_some() || document.ttl_seconds.is_some()) - } - _ => None, - } -} - -/// `(id, 0)` also stores stickers, GIFs, and documents. Only `avatar:` keys are sender photos. -fn sender_photo_cache_key(media_index: &MediaIndex, sender_id: Option) -> Option { - let sender_id = sender_id?; - let key = media_index.get(&(sender_id, 0))?.cache_key.as_str(); - key.starts_with("avatar:").then(|| key.to_string()) -} - -pub(crate) fn reply_meta( - header: Option<&MessageReplyHeader>, -) -> ( - Option, - Option, - Option, - Option, - bool, -) { - let Some(header) = header else { - return (None, None, None, None, false); - }; - let MessageReplyHeader::MessageReplyHeader(h) = header else { - return (None, None, None, None, false); - }; - let (quote, entities_json) = quote_meta(h); - ( - h.reply_to_msg_id, - h.reply_to_top_id, - quote, - entities_json, - h.forum_topic.is_some(), - ) -} - -fn quote_meta(h: &MessageReplyHeaderConstructor) -> (Option, Option) { - if let Some(text) = h.quote_text.clone().filter(|s| !s.is_empty()) { - return (Some(text), entities_to_json(h.quote_entities.as_deref())); - } - let Some(rich) = h.quote_rich.as_deref() else { - return (None, None); - }; - let mut formatted = crate::media::FormattedText::default(); - for item in vector_boxed_items(rich) { - let part = crate::media::rich_formatted(item); - let offset = formatted.text.encode_utf16().count() as i32; - formatted.text.push_str(&part.text); - formatted - .entities - .extend(part.entities.into_iter().map(|mut entity| { - entity.offset += offset; - entity - })); - } - let text = (!formatted.text.is_empty()).then_some(formatted.text); - let entities = (!formatted.entities.is_empty()) - .then(|| serde_json::to_string(&formatted.entities.as_slice()).ok()) - .flatten(); - (text, entities) -} - -pub(crate) fn fwd_origin(header: Option<&MessageFwdHeader>) -> (Option, Option) { - match header { - Some(MessageFwdHeader::MessageFwdHeader(h)) => ( - h.from_id.as_ref().map(|peer| peer_chat_id(peer)), - Some(i64::from(h.date)), - ), - _ => (None, None), - } -} - -pub(crate) fn fwd_from_label( - header: Option<&MessageFwdHeader>, - titles: &HashMap, -) -> Option { - let MessageFwdHeader::MessageFwdHeader(h) = header? else { - return None; - }; - if let Some(name) = h.from_name.as_ref().filter(|s| !s.is_empty()) { - return Some(name.clone()); - } - if let Some(author) = h.post_author.as_ref().filter(|s| !s.is_empty()) { - return Some(author.clone()); - } - h.from_id - .as_ref() - .and_then(|peer| titles.get(&peer_chat_id(peer)).map(|name| name.to_string())) -} - -pub(crate) fn via_bot_label( - via_bot_id: Option, - usernames: &HashMap, - titles: &HashMap, -) -> Option { - let id = via_bot_id?; - usernames - .get(&id) - .or_else(|| titles.get(&id)) - .map(|name| name.to_string()) -} - -pub(crate) fn collect_peer_labels( - users: impl Iterator, - chats: impl Iterator, -) -> ( - HashMap, - HashMap, - HashMap, -) { - let mut titles = HashMap::new(); - let mut usernames = HashMap::new(); - let mut emoji_status = HashMap::new(); - for user in users { - match &user { - User::User(u) => { - let chat_id = chat_id_for_user(u.id); - titles.insert(chat_id, display_name(&user)); - if let Some(name) = u.username.as_ref().filter(|s| !s.is_empty()) { - usernames.insert(u.id, crate::CompactString::from(name.as_str())); - } - if let Some(document_id) = - emoji_status_document_id(u.emoji_status.as_ref().map(|s| s.as_ref())) - { - emoji_status.insert(chat_id, document_id); - } - } - User::UserEmpty(u) => { - titles.insert( - chat_id_for_user(u.id), - crate::CompactString::from(format!("User {}", u.id)), - ); - } - _ => {} - } - } - for chat in chats { - match &chat { - TlChat::Chat(c) => { - titles.insert( - chat_id_for_chat(c.id), - crate::CompactString::from(c.title.clone()), - ); - } - TlChat::Channel(c) => { - let chat_id = chat_id_for_channel(c.id); - titles.insert(chat_id, crate::CompactString::from(c.title.clone())); - if let Some(document_id) = - emoji_status_document_id(c.emoji_status.as_ref().map(|s| s.as_ref())) - { - emoji_status.insert(chat_id, document_id); - } - } - _ => {} - } - } - (titles, usernames, emoji_status) -} - -pub(crate) fn fill_missing_reply_quotes(dtos: &mut [MessageDto]) { - let texts: HashMap = dtos - .iter() - .filter_map(|message| { - message - .text - .as_ref() - .filter(|text| !text.is_empty()) - .map(|text| (message.id, text.clone())) - }) - .collect(); - for dto in dtos.iter_mut() { - if dto - .reply_quote - .as_ref() - .is_some_and(|quote| !quote.is_empty()) - { - continue; - } - if let Some(id) = dto.reply_to_msg_id { - if let Some(text) = texts.get(&id) { - dto.reply_quote = Some(text.clone()); - } - } - } -} - -pub(crate) fn dtos_from_messages( - response: MessagesMessages, - peers: &mut HashMap, - media_index: &mut MediaIndex, -) -> Result, MtprotoError> { - Ok(dtos_from_messages_page(response, peers, media_index)?.0) -} - -/// Maps `messages.Messages` and returns `next_rate` from `messages.messagesSlice` (else 0). -/// https://core.telegram.org/api/offsets -pub(crate) fn dtos_from_messages_page( - response: MessagesMessages, - peers: &mut HashMap, - media_index: &mut MediaIndex, -) -> Result<(Vec, i32, i32), MtprotoError> { - let (messages, users, chats, next_rate, total_count) = match response { - MessagesMessages::MessagesMessages(m) => { - let count = vector_boxed_items(&m.messages).count() as i32; - (m.messages, m.users, m.chats, 0, count) - } - MessagesMessages::MessagesMessagesSlice(m) => ( - m.messages, - m.users, - m.chats, - m.next_rate.unwrap_or(0), - m.count, - ), - MessagesMessages::MessagesChannelMessages(m) => (m.messages, m.users, m.chats, 0, m.count), - MessagesMessages::MessagesMessagesNotModified(_) => return Ok((Vec::new(), 0, 0)), - _ => return Err(MtprotoError::Message("unexpected messages.messages".into())), - }; - let user_list = vector_boxed_items(&users).cloned().collect::>(); - let chat_list = vector_boxed_items(&chats).cloned().collect::>(); - cache_from_users_chats( - peers, - media_index, - user_list.iter().cloned(), - chat_list.iter().cloned(), - ); - let (titles, usernames, emoji_status) = - collect_peer_labels(user_list.into_iter(), chat_list.into_iter()); - let mut dtos: Vec = vector_boxed_items(&messages) - .filter_map(|msg| { - message_to_dto_named(msg, media_index, &titles, &usernames, &emoji_status) - }) - .collect(); - fill_missing_reply_quotes(&mut dtos); - Ok((dtos, next_rate, total_count)) -} - -#[derive(serde::Serialize)] -pub(crate) struct EntityJson { - kind: String, - offset: i32, - length: i32, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, -} - -pub(crate) fn entity_json(kind: &str, offset: i32, length: i32, url: Option) -> EntityJson { - EntityJson { - kind: kind.to_string(), - offset, - length, - url, - } -} - -pub(crate) fn entities_to_json(entities: Option<&Vector>>) -> Option { - let items = entities?; - let mapped: Vec = vector_boxed_items(items) - .filter_map(|entity| match entity { - MessageEntity::MessageEntityBold(e) => { - Some(entity_json("bold", e.offset, e.length, None)) - } - MessageEntity::MessageEntityItalic(e) => { - Some(entity_json("italic", e.offset, e.length, None)) - } - MessageEntity::MessageEntityUnderline(e) => { - Some(entity_json("underline", e.offset, e.length, None)) - } - MessageEntity::MessageEntityStrike(e) => { - Some(entity_json("strike", e.offset, e.length, None)) - } - MessageEntity::MessageEntityCode(e) => { - Some(entity_json("code", e.offset, e.length, None)) - } - MessageEntity::MessageEntityPre(e) => Some(entity_json( - "pre", - e.offset, - e.length, - Some(e.language.clone()).filter(|s| !s.is_empty()), - )), - MessageEntity::MessageEntitySpoiler(e) => { - Some(entity_json("spoiler", e.offset, e.length, None)) - } - MessageEntity::MessageEntityBlockquote(e) => { - Some(entity_json("blockquote", e.offset, e.length, None)) - } - MessageEntity::MessageEntityUrl(e) => { - Some(entity_json("url", e.offset, e.length, None)) - } - MessageEntity::MessageEntityTextUrl(e) => Some(entity_json( - "text_url", - e.offset, - e.length, - Some(e.url.clone()), - )), - MessageEntity::MessageEntityMention(e) => { - Some(entity_json("mention", e.offset, e.length, None)) - } - MessageEntity::MessageEntityMentionName(e) => { - Some(entity_json("mention", e.offset, e.length, None)) - } - MessageEntity::MessageEntityHashtag(e) => { - Some(entity_json("hashtag", e.offset, e.length, None)) - } - MessageEntity::MessageEntityBotCommand(e) => { - Some(entity_json("bot_command", e.offset, e.length, None)) - } - MessageEntity::MessageEntityEmail(e) => { - Some(entity_json("email", e.offset, e.length, None)) - } - MessageEntity::MessageEntityPhone(e) => { - Some(entity_json("phone", e.offset, e.length, None)) - } - MessageEntity::MessageEntityCashtag(e) => { - Some(entity_json("cashtag", e.offset, e.length, None)) - } - MessageEntity::MessageEntityBankCard(e) => { - Some(entity_json("bank_card", e.offset, e.length, None)) - } - MessageEntity::MessageEntityCustomEmoji(e) => Some(entity_json( - "custom_emoji", - e.offset, - e.length, - Some(e.document_id.to_string()), - )), - MessageEntity::MessageEntityFormattedDate(e) => { - Some(entity_json("date", e.offset, e.length, None)) - } - _ => None, - }) - .collect(); - if mapped.is_empty() { - None - } else { - serde_json::to_string(&mapped).ok() - } -} diff --git a/native/mtproto-rs/src/dialogs/mod.rs b/native/mtproto-rs/src/dialogs/mod.rs deleted file mode 100644 index 2ca35697e..000000000 --- a/native/mtproto-rs/src/dialogs/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Dialog list, folders, and history offsets. -//! https://core.telegram.org/api/folders -//! https://core.telegram.org/api/offsets -//! https://core.telegram.org/method/messages.getDialogs - -mod folders; -mod forum; -mod get_dialogs; -mod history; -mod message_map; -mod peers; -mod permissions; -mod preview; -pub(crate) mod rich_rpc; - -pub use folders::*; -pub use forum::*; -pub use get_dialogs::*; -pub use history::*; -pub(crate) use message_map::*; -pub(crate) use peers::*; -pub(crate) use permissions::*; -pub(crate) use preview::*; - -#[cfg(test)] -use tellers_mtproto::latest::api::{ - Chat as TlChat, InputMessageIdConstructor, InputPeer, MessagesGetDialogsRequest, - MessagesGetMessagesRequest, -}; - -#[cfg(test)] -#[path = "../../tests/unit/dialogs_forum_tests.rs"] -mod forum_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_header_tests.rs"] -mod header_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_history_tests.rs"] -mod history_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_membership_tests.rs"] -mod membership_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_mute_tests.rs"] -mod mute_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_preview_tests.rs"] -mod preview_tests; -#[cfg(test)] -#[path = "../../tests/unit/dialogs_rights_tests.rs"] -mod rights_tests; diff --git a/native/mtproto-rs/src/dialogs/peers.rs b/native/mtproto-rs/src/dialogs/peers.rs deleted file mode 100644 index 56a1dc6f5..000000000 --- a/native/mtproto-rs/src/dialogs/peers.rs +++ /dev/null @@ -1,400 +0,0 @@ -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::latest::api::{Chat as TlChat, ChatPhoto, Peer, User, UserProfilePhoto}; - -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, -}; -use crate::media::{self, MediaIndex}; -use crate::peers::{ - self, CachedPeer, PeerKind, chat_id_for_channel, chat_id_for_chat, chat_id_for_user, -}; - -pub(crate) fn display_name(user: &User) -> crate::CompactString { - match user { - User::User(u) => { - let first = u.first_name.as_deref().unwrap_or(""); - let last = u.last_name.as_deref().unwrap_or(""); - let full = format!("{first} {last}"); - let full = full.trim(); - if !full.is_empty() { - crate::CompactString::from(full) - } else { - u.username - .as_deref() - .map(crate::CompactString::from) - .unwrap_or_else(|| crate::CompactString::from(format!("User {}", u.id))) - } - } - User::UserEmpty(u) => crate::CompactString::from(format!("User {}", u.id)), - _ => crate::CompactString::from("User"), - } -} - -pub(crate) fn cache_from_users_chats( - peers: &mut HashMap, - media_index: &mut MediaIndex, - users: impl Iterator, - chats: impl Iterator, -) { - for user in users { - if let User::User(u) = user { - let chat_id = chat_id_for_user(u.id); - peers::upsert_cached_peer( - peers, - chat_id, - PeerKind::User, - u.id, - u.access_hash.unwrap_or(0), - u.min.is_some(), - ); - if let Some(photo) = u.photo.as_ref() { - if let UserProfilePhoto::UserProfilePhoto(p) = photo.as_ref() { - if let Some(hash) = u.access_hash.filter(|&h| h != 0) { - if u.min.is_none() || !media_index.contains_key(&(chat_id, 0)) { - index_dialog_avatar( - media_index, - chat_id, - PeerKind::User, - u.id, - hash, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - ); - } - } - } - } - } - } - for chat in chats { - match chat { - TlChat::Chat(c) => { - let chat_id = chat_id_for_chat(c.id); - peers::upsert_cached_peer(peers, chat_id, PeerKind::Chat, c.id, 0, false); - if let ChatPhoto::ChatPhoto(p) = &*c.photo { - index_dialog_avatar( - media_index, - chat_id, - PeerKind::Chat, - c.id, - 0, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - ); - } - } - TlChat::Channel(c) => { - let chat_id = chat_id_for_channel(c.id); - peers::upsert_cached_peer( - peers, - chat_id, - PeerKind::Channel, - c.id, - c.access_hash.unwrap_or(0), - c.min.is_some(), - ); - if let ChatPhoto::ChatPhoto(p) = &*c.photo { - if let Some(hash) = c.access_hash.filter(|&h| h != 0) { - if c.min.is_none() || !media_index.contains_key(&(chat_id, 0)) { - index_dialog_avatar( - media_index, - chat_id, - PeerKind::Channel, - c.id, - hash, - p.photo_id, - p.dc_id, - p.has_video.is_some(), - p.stripped_thumb.as_deref(), - ); - } - } - } - } - _ => {} - } - } - media::fill_zero_peer_photo_hashes(media_index, peers); -} - -pub(crate) fn index_dialog_avatar( - media_index: &mut MediaIndex, - chat_id: i64, - kind: PeerKind, - raw_id: i64, - access_hash: i64, - photo_id: i64, - dc_id: i32, - has_video: bool, - stripped_thumb: Option<&[u8]>, -) -> String { - let cache_key = media::avatar_cache_key(chat_id, has_video); - let media = media::media_ref_peer_photo( - kind, - raw_id, - access_hash, - photo_id, - dc_id, - cache_key, - has_video, - stripped_thumb, - ); - media::index_avatar(media_index, chat_id, media) -} - -#[derive(Clone)] -pub(crate) struct ChatMeta { - pub(crate) title: crate::CompactString, - pub(crate) is_channel: bool, - pub(crate) is_group: bool, - pub(crate) is_forum: bool, - pub(crate) left: bool, - pub(crate) can_view: bool, - pub(crate) can_send_plain: bool, - pub(crate) can_send_photos: bool, - pub(crate) can_forward: bool, - pub(crate) can_delete_others: bool, - pub(crate) can_manage_topics: bool, -} - -pub(crate) fn user_chat_meta(title: impl Into) -> ChatMeta { - ChatMeta { - title: title.into(), - is_channel: false, - is_group: false, - is_forum: false, - left: false, - can_view: true, - can_send_plain: true, - can_send_photos: true, - can_forward: true, - can_delete_others: false, - can_manage_topics: false, - } -} - -pub(crate) fn title_for_peer( - peer: &Peer, - users: &HashMap, - chats: &HashMap, -) -> ChatMeta { - match peer { - Peer::PeerUser(u) => user_chat_meta( - users - .get(&u.user_id) - .cloned() - .unwrap_or_else(|| crate::CompactString::from(format!("User {}", u.user_id))), - ), - Peer::PeerChat(c) => chats.get(&c.chat_id).cloned().unwrap_or_else(|| ChatMeta { - title: crate::CompactString::from(format!("Chat {}", c.chat_id)), - is_channel: false, - is_group: true, - is_forum: false, - // Unknown membership: keep the dialog rather than hiding it by guess. - left: false, - can_view: true, - can_send_plain: true, - can_send_photos: true, - can_forward: true, - can_delete_others: false, - can_manage_topics: false, - }), - Peer::PeerChannel(c) => chats - .get(&c.channel_id) - .cloned() - .unwrap_or_else(|| ChatMeta { - title: crate::CompactString::from(format!("Channel {}", c.channel_id)), - is_channel: true, - is_group: false, - is_forum: false, - left: false, - can_view: true, - can_send_plain: false, - can_send_photos: false, - can_forward: true, - can_delete_others: false, - can_manage_topics: false, - }), - _ => user_chat_meta(crate::CompactString::from("Chat")), - } -} - -pub(crate) fn index_users_chats( - users: impl Iterator, - chats: impl Iterator, -) -> (HashMap, HashMap) { - let mut user_names = HashMap::new(); - for user in users { - match &user { - User::User(u) => { - user_names.insert(u.id, display_name(&user)); - } - User::UserEmpty(u) => { - user_names.insert(u.id, crate::CompactString::from(format!("User {}", u.id))); - } - _ => {} - } - } - let mut chat_meta = HashMap::new(); - for chat in chats { - if let Some((id, meta)) = chat_meta_from_tl(&chat) { - chat_meta.insert(id, meta); - } - } - (user_names, chat_meta) -} - -pub(crate) fn chat_poll_permission(chat: &TlChat) -> bool { - use super::permissions::resolve_poll_permission; - match chat { - TlChat::Chat(c) => resolve_poll_permission( - c.left.is_some() || c.deactivated.is_some(), - false, - c.creator.is_some(), - c.admin_rights.is_some(), - false, - None, - c.default_banned_rights.as_deref(), - ), - TlChat::Channel(c) => { - c.monoforum.is_none() - && resolve_poll_permission( - c.left.is_some(), - c.broadcast.is_some() && c.megagroup.is_none(), - c.creator.is_some(), - c.admin_rights.is_some(), - admin_can_post(c.admin_rights.as_deref()), - c.banned_rights.as_deref(), - c.default_banned_rights.as_deref(), - ) - } - _ => false, - } -} - -pub(crate) fn chat_meta_from_tl(chat: &TlChat) -> Option<(i64, ChatMeta)> { - match chat { - TlChat::Chat(c) => { - let (can_view, can_send_plain, can_send_photos, can_forward, can_delete_others) = - resolve_permissions( - false, - c.left.is_some(), - false, - c.creator.is_some(), - false, - admin_can_delete(c.admin_rights.as_deref()), - c.admin_rights.is_some() || c.creator.is_some(), - c.noforwards.is_some(), - false, - false, - false, - false, - false, - banned_send(c.default_banned_rights.as_deref()), - banned_plain(c.default_banned_rights.as_deref()), - banned_media(c.default_banned_rights.as_deref()), - banned_photos(c.default_banned_rights.as_deref()), - ); - Some(( - c.id, - ChatMeta { - title: crate::CompactString::from(c.title.clone()), - is_channel: false, - is_group: true, - is_forum: false, - left: c.left.is_some() || c.deactivated.is_some(), - can_view, - can_send_plain, - can_send_photos, - can_forward, - can_delete_others, - can_manage_topics: c.creator.is_some() - || admin_can_manage_topics(c.admin_rights.as_deref()), - }, - )) - } - TlChat::Channel(c) => { - let is_channel = c.broadcast.is_some() && c.megagroup.is_none(); - let is_group = !is_channel; - let (can_view, can_send_plain, can_send_photos, can_forward, can_delete_others) = - resolve_permissions( - false, - c.left.is_some() || c.join_to_send.is_some() && c.left.is_some(), - is_channel, - c.creator.is_some(), - admin_can_post(c.admin_rights.as_deref()), - admin_can_delete(c.admin_rights.as_deref()), - c.admin_rights.is_some() || c.creator.is_some(), - c.noforwards.is_some(), - banned_view(c.banned_rights.as_deref()), - banned_send(c.banned_rights.as_deref()), - banned_plain(c.banned_rights.as_deref()), - banned_media(c.banned_rights.as_deref()), - banned_photos(c.banned_rights.as_deref()), - banned_send(c.default_banned_rights.as_deref()), - banned_plain(c.default_banned_rights.as_deref()), - banned_media(c.default_banned_rights.as_deref()), - banned_photos(c.default_banned_rights.as_deref()), - ); - let is_forum = (c.forum.is_some() || c.forum_tabs.is_some()) - && c.monoforum.is_none() - && !is_channel; - Some(( - c.id, - ChatMeta { - title: crate::CompactString::from(c.title.clone()), - is_channel, - is_group, - is_forum, - left: c.left.is_some(), - can_view, - can_send_plain, - can_send_photos, - can_forward, - can_delete_others, - can_manage_topics: c.creator.is_some() - || admin_can_manage_topics(c.admin_rights.as_deref()), - }, - )) - } - TlChat::ChatForbidden(c) => Some(( - c.id, - ChatMeta { - title: crate::CompactString::from(c.title.clone()), - is_channel: false, - is_group: true, - is_forum: false, - left: true, - can_view: false, - can_send_plain: false, - can_send_photos: false, - can_forward: false, - can_delete_others: false, - can_manage_topics: false, - }, - )), - TlChat::ChannelForbidden(c) => Some(( - c.id, - ChatMeta { - title: crate::CompactString::from(c.title.clone()), - is_channel: true, - is_group: false, - is_forum: false, - left: true, - can_view: false, - can_send_plain: false, - can_send_photos: false, - can_forward: false, - can_delete_others: false, - can_manage_topics: false, - }, - )), - _ => None, - } -} diff --git a/native/mtproto-rs/src/dialogs/permissions.rs b/native/mtproto-rs/src/dialogs/permissions.rs deleted file mode 100644 index 2e9296c76..000000000 --- a/native/mtproto-rs/src/dialogs/permissions.rs +++ /dev/null @@ -1,116 +0,0 @@ -use tellers_mtproto::latest::api::{ChatAdminRights, ChatBannedRights}; - -pub(crate) fn banned_rights( - rights: Option<&ChatBannedRights>, -) -> Option<&tellers_mtproto::latest::api::ChatBannedRightsConstructor> { - match rights? { - ChatBannedRights::ChatBannedRights(c) => Some(c), - _ => None, - } -} - -pub(crate) fn admin_rights( - rights: Option<&ChatAdminRights>, -) -> Option<&tellers_mtproto::latest::api::ChatAdminRightsConstructor> { - match rights? { - ChatAdminRights::ChatAdminRights(c) => Some(c), - _ => None, - } -} - -pub(crate) fn banned_view(rights: Option<&ChatBannedRights>) -> bool { - banned_rights(rights).is_some_and(|r| r.view_messages.is_some()) -} -pub(crate) fn banned_send(rights: Option<&ChatBannedRights>) -> bool { - banned_rights(rights).is_some_and(|r| r.send_messages.is_some()) -} -pub(crate) fn banned_plain(rights: Option<&ChatBannedRights>) -> bool { - banned_rights(rights).is_some_and(|r| r.send_plain.is_some()) -} -pub(crate) fn banned_media(rights: Option<&ChatBannedRights>) -> bool { - banned_rights(rights).is_some_and(|r| r.send_media.is_some()) -} -pub(crate) fn banned_photos(rights: Option<&ChatBannedRights>) -> bool { - banned_rights(rights).is_some_and(|r| r.send_photos.is_some()) -} -pub(crate) fn resolve_poll_permission( - left: bool, - broadcast: bool, - creator: bool, - admin_present: bool, - admin_post: bool, - rights: Option<&ChatBannedRights>, - defaults: Option<&ChatBannedRights>, -) -> bool { - if left || banned_view(rights) { - return false; - } - if broadcast { - return creator || admin_post; - } - if banned_send(rights) || banned_rights(rights).is_some_and(|r| r.send_polls.is_some()) { - return false; - } - creator - || admin_present - || !(banned_send(defaults) - || banned_rights(defaults).is_some_and(|r| r.send_polls.is_some())) -} - -pub(crate) fn admin_can_post(rights: Option<&ChatAdminRights>) -> bool { - admin_rights(rights).is_some_and(|r| r.post_messages.is_some()) -} -pub(crate) fn admin_can_delete(rights: Option<&ChatAdminRights>) -> bool { - admin_rights(rights).is_some_and(|r| r.delete_messages.is_some()) -} -pub(crate) fn admin_can_manage_topics(rights: Option<&ChatAdminRights>) -> bool { - admin_rights(rights).is_some_and(|r| r.manage_topics.is_some()) -} - -/// Resolves effective permissions inverted from -/// chatBannedRights flags (https://core.telegram.org/constructor/chatBannedRights). -pub(crate) fn resolve_permissions( - forbidden: bool, - left: bool, - broadcast: bool, - creator: bool, - admin_post: bool, - admin_delete: bool, - admin_present: bool, - noforwards: bool, - ban_view: bool, - ban_send: bool, - ban_plain: bool, - ban_media: bool, - ban_photos: bool, - def_send: bool, - def_plain: bool, - def_media: bool, - def_photos: bool, -) -> (bool, bool, bool, bool, bool) { - let can_view = !forbidden && !ban_view; - if !can_view { - return (false, false, false, false, false); - } - let can_forward = !noforwards; - let can_delete_others = creator || admin_delete; - if broadcast { - let can_send = creator || admin_post; - return (true, can_send, can_send, can_forward, can_delete_others); - } - if left { - return (true, false, false, can_forward, can_delete_others); - } - let override_default = creator || admin_present; - let send_blocked = ban_send || (!override_default && def_send); - let plain_blocked = ban_plain || (!override_default && def_plain); - let media_blocked = ban_media || (!override_default && def_media); - let photos_blocked = ban_photos || (!override_default && def_photos); - ( - true, - !send_blocked && !plain_blocked, - !send_blocked && !media_blocked && !photos_blocked, - can_forward, - can_delete_others, - ) -} diff --git a/native/mtproto-rs/src/dialogs/preview.rs b/native/mtproto-rs/src/dialogs/preview.rs deleted file mode 100644 index b6a79427e..000000000 --- a/native/mtproto-rs/src/dialogs/preview.rs +++ /dev/null @@ -1,189 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{Message, Peer, RichMessage}; - -use super::peers::ChatMeta; -use crate::media; -use crate::peers::{peer_chat_id, vector_boxed_items}; -use crate::service_messages::service_action_text; - -pub(crate) fn compose_chat_list_preview( - is_group: bool, - outgoing: bool, - sender_name: Option<&str>, - media_label: Option<&str>, - text: &str, -) -> Option { - let text = text.trim(); - let body = match ( - media_label.filter(|s| !s.is_empty()), - (!text.is_empty()).then_some(text), - ) { - (Some(kind), Some(t)) => format!("{kind}, {t}"), - (Some(kind), None) => kind.to_string(), - (None, Some(t)) => t.to_string(), - (None, None) => String::new(), - }; - let preview = if is_group { - let sender = if outgoing { - Some("You") - } else { - sender_name.filter(|s| !s.is_empty()) - }; - match (sender, body.is_empty()) { - (Some(name), false) => format!("{name}: {body}"), - (Some(name), true) => format!("{name}:"), - (None, false) => body, - (None, true) => String::new(), - } - } else { - body - }; - if preview.is_empty() { - None - } else { - Some(preview) - } -} - -pub(crate) fn chat_list_media_word(kind: &str) -> Option<&'static str> { - match kind { - "photo" => Some("Photo"), - "video" => Some("Video"), - "gif" => Some("GIF"), - "sticker" | "sticker_animated" | "sticker_video" => Some("Sticker"), - "document" => Some("Document"), - "audio" => Some("Audio"), - "voice" => Some("Voice message"), - "video_note" => Some("Video message"), - "todo" => Some("Checklist"), - "webpage" => Some("Link"), - "poll" => Some("Poll"), - "geo" => Some("Location"), - "venue" => Some("Venue"), - "contact" => Some("Contact"), - "dice" => Some("Dice"), - _ => None, - } -} - -/// Sticker and animation names Telegram generates; they never carry user text. -pub(crate) fn is_sticker_file_name(name: &str) -> bool { - let lower = name.trim().to_ascii_lowercase(); - lower.ends_with(".webp") || lower.ends_with(".webm") || lower.ends_with(".tgs") -} - -pub(crate) fn preview_hides_file_name(kind: Option<&str>, file_name: &str) -> bool { - if is_sticker_file_name(file_name) { - return true; - } - matches!( - kind, - Some( - "photo" - | "video" - | "gif" - | "sticker" - | "sticker_animated" - | "sticker_video" - | "voice" - | "video_note" - | "webpage" - | "todo" - | "poll" - | "geo" - | "venue" - | "contact" - | "dice" - ) - ) -} - -pub(crate) fn dialog_list_caption( - m: &tellers_mtproto::latest::api::MessageConstructor, - indexed: &media::IndexedMessageMedia, -) -> String { - let plain = m.message.trim(); - if !plain.is_empty() { - return m.message.clone(); - } - if let Some(RichMessage::RichMessage(body)) = m.rich_message.as_ref().map(|rich| rich.as_ref()) - { - let photos: Vec<_> = vector_boxed_items(&body.photos).cloned().collect(); - let formatted = - media::page_blocks_formatted_media(vector_boxed_items(&body.blocks), &photos); - if !formatted.text.is_empty() { - return formatted.text; - } - } - indexed - .file_name - .clone() - .filter(|name| !name.is_empty()) - .filter(|name| !preview_hides_file_name(indexed.kind.as_deref(), name)) - .or_else(|| { - m.media - .as_ref() - .and_then(|media| media::media_fallback_text(media)) - }) - .unwrap_or_default() -} - -pub(crate) fn message_preview( - msg: &Message, - user_names: &HashMap, - chat_meta: &HashMap, - indexed: &media::IndexedMessageMedia, -) -> Option<(i32, i64, Option, bool)> { - match msg { - Message::Message(m) => { - let is_group = match &*m.peer_id { - Peer::PeerChat(_) => true, - Peer::PeerChannel(c) => chat_meta - .get(&c.channel_id) - .map(|meta| meta.is_group) - .unwrap_or(true), - _ => false, - }; - let outgoing = m.out.is_some(); - let sender = m - .from_id - .as_ref() - .and_then(|p| { - let id = peer_chat_id(p); - user_names.get(&id).or_else(|| match p.as_ref() { - Peer::PeerUser(u) => user_names.get(&u.user_id), - _ => None, - }) - }) - .map(|name| name.as_str()); - let media = indexed.kind.as_deref().and_then(chat_list_media_word); - let caption = dialog_list_caption(m, indexed); - Some(( - m.id, - i64::from(m.date), - compose_chat_list_preview(is_group, outgoing, sender, media, &caption), - outgoing, - )) - } - Message::MessageService(m) => { - let actor = m.from_id.as_ref().and_then(|peer| { - user_names - .get(&peer_chat_id(peer)) - .map(|name| name.as_str()) - }); - Some(( - m.id, - i64::from(m.date), - Some(service_action_text( - m.action.as_ref(), - m.from_id.as_ref().map(|peer| peer_chat_id(peer)), - actor.unwrap_or(""), - user_names, - )), - m.out.is_some(), - )) - } - _ => None, - } -} diff --git a/native/mtproto-rs/src/dialogs/rich_rpc.rs b/native/mtproto-rs/src/dialogs/rich_rpc.rs deleted file mode 100644 index ab80c19ac..000000000 --- a/native/mtproto-rs/src/dialogs/rich_rpc.rs +++ /dev/null @@ -1,109 +0,0 @@ -use tellers_mtproto::codec::{Boxed, Decoder, Encoder, Limits, TlDecode, TlEncode}; -use tellers_mtproto::latest::api::{InputPeer, InputPeerSelfConstructor, PageBlock, Vector}; -use tellers_mtproto_session::Snapshot; - -use crate::MtprotoError; -use crate::api_invoke; -use crate::media; -use crate::peers::{self, CachedPeer, vector_boxed_items}; -use crate::rpc; - -/// `messages.getRichMessage#501569cf peer:InputPeer id:int = messages.Messages` -const GET_RICH_MESSAGE_ID: u32 = 0x5015_69cf; -/// `richMessage#baf39d8b` -const RICH_MESSAGE_ID: u32 = 0xbaf3_9d8b; -const RICH_LAYER: i32 = 229; - -struct MessagesGetRichMessageRequest { - peer: Box, - id: i32, -} - -impl TlEncode for MessagesGetRichMessageRequest { - fn encode(&self, encoder: &mut Encoder) -> Result<(), tellers_mtproto::codec::Error> { - TlEncode::encode(&self.peer, encoder)?; - TlEncode::encode(&self.id, encoder)?; - Ok(()) - } -} - -impl Boxed for MessagesGetRichMessageRequest { - const CONSTRUCTOR_ID: u32 = GET_RICH_MESSAGE_ID; -} - -pub fn fill_unsupported_rich_text( - snapshot: &mut Snapshot, - api_id: i32, - peers: &crate::HashMap, - user_id: Option, - dtos: &mut [crate::MessageDto], -) { - let mut remaining = 3_u8; - for dto in dtos.iter_mut() { - if remaining == 0 { - break; - } - if dto.media_kind.as_deref() != Some("unsupported") { - continue; - } - if dto.text.as_ref().is_some_and(|t| !t.is_empty()) { - continue; - } - remaining -= 1; - match fetch_rich_plain(snapshot, api_id, peers, user_id, dto.chat_id, dto.id) { - Ok(Some(text)) => { - dto.text = Some(text); - dto.media_kind = None; - } - Ok(None) | Err(_) => {} - } - } -} - -fn fetch_rich_plain( - snapshot: &mut Snapshot, - api_id: i32, - peers: &crate::HashMap, - user_id: Option, - chat_id: i64, - message_id: i32, -) -> Result, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let peer = if user_id == Some(chat_id) { - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}) - } else { - peers::input_peer_from_cached(cached) - }; - let request = MessagesGetRichMessageRequest { - peer: Box::new(peer), - id: message_id, - }; - let wrapped = api_invoke::wrap_init_connection_at_layer(api_id, RICH_LAYER, request); - let body = rpc::encode_boxed_bytes(&wrapped)?; - let raw = match rpc::invoke_raw(snapshot, body) { - Ok(bytes) => bytes, - Err(err) => { - eprintln!("getRichMessage id={message_id} err={err}"); - return Err(err); - } - }; - let plain = extract_rich_plain(&raw); - eprintln!( - "getRichMessage id={message_id} bytes={} rich={}", - raw.len(), - plain.as_ref().map(|t| t.len()).unwrap_or(0), - ); - Ok(plain) -} - -fn extract_rich_plain(bytes: &[u8]) -> Option { - let unpacked = rpc::ungzip_if_needed(bytes).ok()?; - let marker = RICH_MESSAGE_ID.to_le_bytes(); - let pos = unpacked.windows(4).position(|w| w == marker)?; - let mut decoder = Decoder::new(&unpacked[pos..], Limits::default()).ok()?; - let _ctor = decoder.read_u32().ok()?; - let _flags: u32 = TlDecode::decode(&mut decoder).ok()?; - let blocks: Vector> = TlDecode::decode(&mut decoder).ok()?; - let text = media::page_blocks_plain(vector_boxed_items(&blocks)); - if text.is_empty() { None } else { Some(text) } -} diff --git a/native/mtproto-rs/src/dns_txt.rs b/native/mtproto-rs/src/dns_txt.rs deleted file mode 100644 index 1e0b0a31c..000000000 --- a/native/mtproto-rs/src/dns_txt.rs +++ /dev/null @@ -1,725 +0,0 @@ -//! `help.configSimple` from `dc_txt_domain_name` (apv3.stel.com) TXT via DoH. -//! https://core.telegram.org/api/config#updating-the-dc-list -//! -//! Concatenate two TXT parts, RSA public-op, AES-CBC, SHA-256 check, then -//! parse `help.configSimple`. Extra IPs are reconnect fallbacks after same-IP -//! :443; `AUTH_KEY_DUPLICATED` still stops further IP hops. - -use std::path::Path; -use std::sync::{LazyLock, Mutex}; - -use aes::Aes256; -use cipher::{Block, BlockCipherDecrypt, KeyInit}; -use sha2::{Digest, Sha256}; -use tellers_mtproto::latest::api::{Config, DcOption, HelpGetConfigRequest}; -use tellers_mtproto_crypto::{parse_rsa_public_key, rsa_encrypt_raw}; -use tellers_mtproto_session::{OsRandom, Snapshot}; -use tellers_mtproto_transport::PaddedIntermediate; - -use crate::MtprotoError; -use crate::peers::vector_boxed_items; -use crate::rpc::LiveTransport; - -/// Public key used to unwrap the signed simple-config blob. -const SIMPLE_CONFIG_RSA_PEM: &str = "-----BEGIN RSA PUBLIC KEY----- -MIIBCgKCAQEAyr+18Rex2ohtVy8sroGPBwXD3DOoKCSpjDqYoXgCqB7ioln4eDCF -fOBUlfXUEvM/fnKCpF46VkAftlb4VuPDeQSS/ZxZYEGqHaywlroVnXHIjgqoxiAd -192xRGreuXIaUKmkwlM9JID9WS2jUsTpzQ91L8MEPLJ/4zrBwZua8W5fECwCCh2c -9G5IzzBm+otMS/YKwmR1olzRCyEkyAEjXWqBI9Ftv5eG8m0VkBzOG655WIYdyV0H -fDK/NWcvGqa0w/nriMD6mDjKOryamw0OP9QuYgMN0C9xMW9y8SmP4h92OAWodTYg -Y1hZCxdv6cs5UnW9+PWvS+WIbkh+GaWYxwIDAQAB ------END RSA PUBLIC KEY----- -"; - -const HELP_CONFIG_SIMPLE: u32 = 0x5a59_2a6c; -const ACCESS_POINT_RULE: u32 = 0x4679_b65f; -const IP_PORT: u32 = 0xd433_ad73; -const IP_PORT_SECRET: u32 = 0x3798_2646; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TxtEndpoint { - pub dc_id: i32, - pub addr: String, - pub secret: Option<[u8; 16]>, -} - -static TXT_ENDPOINTS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -static CDN_ENDPOINTS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -pub fn endpoints_for(dc_id: i32) -> Vec { - TXT_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .filter(|e| e.dc_id == dc_id) - .cloned() - .collect() -} - -pub fn cdn_endpoints_for(dc_id: i32) -> Vec { - CDN_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .filter(|e| e.dc_id == dc_id) - .cloned() - .collect() -} - -/// Obfuscation secret from sidecar / `help.getConfig` for this exact DC address. -/// First hop uses this; other IPs stay unused with the home auth key (`AUTH_KEY_DUPLICATED`). -pub fn secret_for(dc_id: i32, addr: &str) -> Option<[u8; 16]> { - TXT_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .find(|e| e.dc_id == dc_id && e.addr == addr) - .and_then(|e| e.secret) -} - -/// Fresh temp-key handshake target when live `help.getConfig` fails. -/// Prefer a different address so we do not retry the dead home socket. -pub(crate) fn pick_backup_endpoint( - home_dc: i32, - home_addr: &str, - endpoints: &[TxtEndpoint], -) -> Option { - endpoints - .iter() - .find(|e| e.dc_id == home_dc && e.addr != home_addr) - .or_else(|| endpoints.iter().find(|e| e.addr != home_addr)) - .or_else(|| endpoints.iter().find(|e| e.secret.is_some())) - .cloned() -} - -const CONFIG_TTL_MAX_SECS: i64 = 24 * 3600; - -pub fn load_sidecar(session_path: &Path) { - let path = sidecar_path(session_path); - let Ok(text) = std::fs::read_to_string(&path) else { - return; - }; - let parts: Vec = text - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect(); - match decode_txt_parts(&parts) { - Ok(found) => { - eprintln!("monogram.api dns txt decoded endpoints={}", found.len()); - *TXT_ENDPOINTS.lock().unwrap_or_else(|e| e.into_inner()) = found; - } - Err(err) => eprintln!("monogram.api dns txt decode failed {err}"), - } -} - -/// Apply a still-fresh `dc_txt.config`. Returns true when the backup handshake -/// / live `help.getConfig` can be skipped. -pub fn apply_fresh_cache(session_path: &Path, home_dc: i32) -> bool { - let now = unix_now(); - let Some(cached) = load_config_cache(session_path) else { - return false; - }; - if cached.expires <= now { - merge_home(home_dc, cached.endpoints); - return false; - } - merge_home(home_dc, cached.endpoints.clone()); - if let Some(tmp_sessions) = cached.tmp_sessions { - crate::scheduler::set_main_session_allowance(Some(tmp_sessions)); - } - write_status( - session_path, - &format!( - "getConfig source=cache home_dc={home_dc} options={} ttl={}s", - cached.endpoints.len(), - cached.expires - now - ), - ); - true -} - -/// `help.getConfig` on the home live socket (existing auth_key). Never puts -/// the home key on a TXT backup DC. Temp-key fallback only if live is down. -pub fn refresh_home_from_backup( - session_path: &Path, - home_dc: i32, - api_id: i32, - snapshot: &mut Snapshot, -) { - let now = unix_now(); - let cached = load_config_cache(session_path); - if let Some(cached) = cached.as_ref() { - if cached.expires > now { - merge_home(home_dc, cached.endpoints.clone()); - write_status( - session_path, - &format!( - "getConfig source=cache home_dc={home_dc} options={} ttl={}s", - cached.endpoints.len(), - cached.expires - now - ), - ); - return; - } - } - match fetch_home_options_live(home_dc, api_id, snapshot) { - Ok((home, server_expires)) => { - let expires = clamp_expires(server_expires, now); - save_config_cache(session_path, expires, &home); - merge_home(home_dc, home.clone()); - write_status( - session_path, - &format!( - "getConfig source=rpc home_dc={home_dc} options={} ttl={}s", - home.len(), - expires - now - ), - ); - } - Err(err) => { - let home_addr = crate::rpc::dc_endpoints(home_dc) - .first() - .copied() - .unwrap_or(""); - let known = TXT_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - if let Some(backup) = pick_backup_endpoint(home_dc, home_addr, &known) { - match fetch_home_options(home_dc, api_id, &backup) { - Ok((home, server_expires)) => { - let expires = clamp_expires(server_expires, now); - save_config_cache(session_path, expires, &home); - merge_home(home_dc, home.clone()); - write_status( - session_path, - &format!( - "getConfig source=backup home_dc={home_dc} via={} options={} ttl={}s", - backup.addr, - home.len(), - expires - now - ), - ); - return; - } - Err(backup_err) => { - write_status( - session_path, - &format!( - "getConfig source=backup failed live={err} backup={backup_err}" - ), - ); - } - } - } - if let Some(cached) = cached { - merge_home(home_dc, cached.endpoints); - write_status( - session_path, - &format!("getConfig source=stale failed {err}"), - ); - } else { - write_status(session_path, &format!("getConfig source=rpc failed {err}")); - } - } - } -} - -/// `help.getConfig` on the home session purely to learn `config.tmp_sessions`. -/// -/// Best effort: a failure leaves the client on the safe single-session policy and -/// must never mark the session dead (the probe is not an authorization test). -pub fn probe_main_session_allowance(home_dc: i32, api_id: i32, snapshot: &mut Snapshot) { - match fetch_home_options_live(home_dc, api_id, snapshot) { - Ok(_) => eprintln!( - "monogram.api main sessions allowance={} home_dc={home_dc}", - crate::scheduler::main_session_allowance() - ), - Err(err) => eprintln!("monogram.api main sessions probe failed {err}"), - } -} - -fn merge_home(home_dc: i32, home: Vec) { - if home.is_empty() { - return; - } - let mut guard = TXT_ENDPOINTS.lock().unwrap_or_else(|e| e.into_inner()); - guard.retain(|e| e.dc_id != home_dc); - guard.extend(home); -} - -fn fetch_home_options_live( - home_dc: i32, - api_id: i32, - snapshot: &mut Snapshot, -) -> Result<(Vec, i32), MtprotoError> { - let cfg = crate::rpc::with_rpc_timeout_secs(15, || { - crate::api_invoke::invoke_api::<_, Config>(snapshot, api_id, HelpGetConfigRequest {}) - })?; - config_home_options(home_dc, cfg) -} - -fn fetch_home_options( - home_dc: i32, - api_id: i32, - backup: &TxtEndpoint, -) -> Result<(Vec, i32), MtprotoError> { - let mut last_err = None; - for _ in 0..3 { - match fetch_home_options_once(home_dc, api_id, backup) { - Ok(ok) => return Ok(ok), - Err(err) => last_err = Some(err), - } - } - Err(last_err.unwrap_or_else(|| MtprotoError::Message("backup getConfig failed".into()))) -} - -fn fetch_home_options_once( - home_dc: i32, - api_id: i32, - backup: &TxtEndpoint, -) -> Result<(Vec, i32), MtprotoError> { - let mut conn = crate::tcp::connect_obfuscated_timeout_obf( - &backup.addr, - 15, - Some(backup.dc_id as i16), - backup.secret.as_ref().map(|s| s.as_slice()), - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - conn.set_io_timeout(15); - let mut framing = PaddedIntermediate::default(); - let mut snap = Snapshot::new(backup.dc_id, &mut OsRandom) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - crate::auth::auth_key::create_auth_key(&mut conn, &mut framing, &mut snap)?; - let mut slot = Some(LiveTransport { - ping_sent: None, - updates: Vec::new(), - updates_bytes: 0, - dc_id: backup.dc_id, - endpoint: backup.addr.clone(), - conn, - framing, - input: Vec::new(), - last_io: std::time::Instant::now(), - }); - let cfg = crate::rpc::with_live_transport(&mut slot, || { - crate::rpc::with_rpc_timeout_secs(15, || { - crate::api_invoke::invoke_api::<_, Config>(&mut snap, api_id, HelpGetConfigRequest {}) - }) - }); - drop(slot); - config_home_options(home_dc, cfg?) -} - -fn config_home_options(home_dc: i32, cfg: Config) -> Result<(Vec, i32), MtprotoError> { - let Config::Config(cfg) = cfg; - // `tmp_sessions` is the server's permission for parallel main sessions on a - // non-media DC; opening more than it allows kills the authorization (406). - crate::scheduler::set_main_session_allowance(cfg.tmp_sessions); - let expires = cfg.expires; - let mut out = Vec::new(); - CDN_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clear(); - for opt in vector_boxed_items(cfg.dc_options.as_ref()) { - let DcOption::DcOption(o) = opt else { - continue; - }; - if o.ipv6.is_some() { - continue; - } - let endpoint = TxtEndpoint { - dc_id: o.id, - addr: format!("{}:{}", o.ip_address, o.port), - secret: o.secret.as_deref().and_then(normalize_secret), - }; - if o.cdn.is_some() { - CDN_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push(endpoint); - continue; - } - if o.id != home_dc || o.media_only.is_some() { - continue; - } - out.push(endpoint); - } - Ok((out, expires)) -} - -pub fn sidecar_path(session_path: &Path) -> std::path::PathBuf { - session_dir(session_path).join("dc_txt.parts") -} - -fn config_cache_path(session_path: &Path) -> std::path::PathBuf { - session_dir(session_path).join("dc_txt.config") -} - -fn status_path(session_path: &Path) -> std::path::PathBuf { - session_dir(session_path).join("dc_txt.status") -} - -fn session_dir(session_path: &Path) -> std::path::PathBuf { - session_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf() -} - -fn unix_now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -pub(crate) fn clamp_expires(server_expires: i32, now: i64) -> i64 { - // No valid server deadline means no reusable permission. - let ttl = if server_expires <= 0 { - 0 - } else { - // Cached session permission must never outlive the server config. - (i64::from(server_expires) - now).clamp(0, CONFIG_TTL_MAX_SECS) - }; - now + ttl -} - -pub(crate) struct CachedConfig { - expires: i64, - tmp_sessions: Option, - endpoints: Vec, -} - -fn load_config_cache(session_path: &Path) -> Option { - let text = std::fs::read_to_string(config_cache_path(session_path)).ok()?; - parse_config_cache(&text) -} - -pub(crate) fn parse_config_cache(text: &str) -> Option { - let mut expires = 0_i64; - let mut tmp_sessions = None; - let mut endpoints = Vec::new(); - for raw in text.lines() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some(rest) = line.strip_prefix("expires ") { - expires = rest.trim().parse().ok()?; - continue; - } - if let Some(rest) = line.strip_prefix("tmp_sessions ") { - tmp_sessions = Some(rest.trim().parse().ok()?); - continue; - } - let mut bits = line.split_whitespace(); - let dc_id: i32 = bits.next()?.parse().ok()?; - let addr = bits.next()?.to_string(); - if addr.is_empty() || !addr.contains(':') { - return None; - } - let secret = bits.next().and_then(parse_secret_hex); - endpoints.push(TxtEndpoint { - dc_id, - addr, - secret, - }); - } - if expires <= 0 || endpoints.is_empty() { - return None; - } - Some(CachedConfig { - expires, - tmp_sessions, - endpoints, - }) -} - -fn save_config_cache(session_path: &Path, expires: i64, endpoints: &[TxtEndpoint]) { - if endpoints.is_empty() { - return; - } - let tmp_sessions = crate::scheduler::main_session_allowance(); - let mut body = format!("# dc_txt.config v2\nexpires {expires}\ntmp_sessions {tmp_sessions}\n"); - for e in endpoints { - match e.secret { - Some(secret) => { - body.push_str(&format!("{} {} {}\n", e.dc_id, e.addr, hex_encode(&secret))); - } - None => body.push_str(&format!("{} {}\n", e.dc_id, e.addr)), - } - } - let path = config_cache_path(session_path); - let tmp = path.with_extension("config.tmp"); - if std::fs::write(&tmp, body).is_ok() { - let _ = std::fs::rename(&tmp, path); - } -} - -fn write_status(session_path: &Path, line: &str) { - let _ = std::fs::write(status_path(session_path), line); -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - out.push(HEX[(b >> 4) as usize] as char); - out.push(HEX[(b & 0xf) as usize] as char); - } - out -} - -fn parse_secret_hex(text: &str) -> Option<[u8; 16]> { - if text.len() != 32 { - return None; - } - let mut out = [0_u8; 16]; - for i in 0..16 { - out[i] = u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()?; - } - Some(out) -} - -/// Official DoH client concatenates the two TXT `data` strings, longer-first -/// only when the first part is not already the longer one. -pub fn concat_txt_parts(parts: &[String]) -> String { - match parts { - [] => String::new(), - [only] => only.clone(), - [a, b, ..] if a.len() < b.len() => format!("{b}{a}"), - [a, b, ..] => format!("{a}{b}"), - } -} - -fn base64_filter(input: &str) -> String { - input - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '+' || *c == '/' || *c == '=') - .collect() -} - -fn aes_cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result, MtprotoError> { - if key.len() != 32 || iv.len() != 16 || data.len() % 16 != 0 { - return Err(MtprotoError::Message("simple config AES length".into())); - } - let cipher = Aes256::new_from_slice(key) - .map_err(|_| MtprotoError::Message("simple config AES key".into()))?; - let mut prev = [0_u8; 16]; - prev.copy_from_slice(iv); - let mut out = Vec::with_capacity(data.len()); - for chunk in data.chunks_exact(16) { - let mut raw = [0_u8; 16]; - raw.copy_from_slice(chunk); - let mut block = Block::::from(raw); - cipher.decrypt_block(&mut block); - for i in 0..16 { - block[i] ^= prev[i]; - } - out.extend_from_slice(block.as_slice()); - prev.copy_from_slice(chunk); - } - Ok(out) -} - -pub fn decode_txt_parts(parts: &[String]) -> Result, MtprotoError> { - let concat = concat_txt_parts(parts); - let filtered = base64_filter(&concat); - if filtered.len() != 344 { - return Err(MtprotoError::Message(format!( - "simple config b64 len {}", - filtered.len() - ))); - } - let rsa_bytes = base64_decode(&filtered)?; - if rsa_bytes.len() != 256 { - return Err(MtprotoError::Message("simple config rsa len".into())); - } - let key = parse_rsa_public_key(SIMPLE_CONFIG_RSA_PEM) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let decrypted = - rsa_encrypt_raw(&key, &rsa_bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; - if decrypted.len() != 256 { - return Err(MtprotoError::Message("simple config rsa out".into())); - } - let aes_key = &decrypted[..32]; - let iv = &decrypted[16..32]; - let cbc = aes_cbc_decrypt(aes_key, iv, &decrypted[32..])?; - if cbc.len() != 224 { - return Err(MtprotoError::Message("simple config cbc len".into())); - } - let hash = Sha256::digest(&cbc[..208]); - if cbc[208..] != hash[..16] { - return Err(MtprotoError::Message("simple config sha256".into())); - } - let len = i32::from_le_bytes(cbc[0..4].try_into().expect("len")) as usize; - if !(8..=208).contains(&len) { - return Err(MtprotoError::Message("simple config inner len".into())); - } - let ctor = u32::from_le_bytes(cbc[4..8].try_into().expect("ctor")); - if ctor != HELP_CONFIG_SIMPLE { - return Err(MtprotoError::Message(format!( - "simple config ctor {ctor:#x}" - ))); - } - parse_config_simple(&cbc[8..len]) -} - -fn base64_decode(input: &str) -> Result, MtprotoError> { - fn val(c: u8) -> Option { - match c { - b'A'..=b'Z' => Some(c - b'A'), - b'a'..=b'z' => Some(c - b'a' + 26), - b'0'..=b'9' => Some(c - b'0' + 52), - b'+' => Some(62), - b'/' => Some(63), - _ => None, - } - } - let bytes: Vec = input.bytes().filter(|b| *b != b'=').collect(); - if bytes.len() % 4 == 1 { - return Err(MtprotoError::Message("simple config b64".into())); - } - let mut out = Vec::with_capacity(bytes.len() * 3 / 4); - for chunk in bytes.chunks(4) { - let a = val(chunk[0]).ok_or_else(|| MtprotoError::Message("simple config b64".into()))?; - let b = val(chunk[1]).ok_or_else(|| MtprotoError::Message("simple config b64".into()))?; - out.push((a << 2) | (b >> 4)); - if chunk.len() > 2 { - let c = - val(chunk[2]).ok_or_else(|| MtprotoError::Message("simple config b64".into()))?; - out.push((b << 4) | (c >> 2)); - if chunk.len() > 3 { - let d = val(chunk[3]) - .ok_or_else(|| MtprotoError::Message("simple config b64".into()))?; - out.push((c << 6) | d); - } - } - } - Ok(out) -} - -fn parse_config_simple(body: &[u8]) -> Result, MtprotoError> { - let mut off = 0usize; - let _date = read_i32(body, &mut off)?; - let _expires = read_i32(body, &mut off)?; - // Bare `vector` is count + items (no Vector constructor). - let count = read_i32(body, &mut off)?; - let mut out = Vec::new(); - for _ in 0..count.max(0) { - let ctor = read_u32(body, &mut off)?; - if ctor != ACCESS_POINT_RULE { - return Err(MtprotoError::Message("simple config rule ctor".into())); - } - let _phone_rules = read_tl_string(body, &mut off)?; - let dc_id = read_i32(body, &mut off)?; - let ip_count = read_i32(body, &mut off)?; - for _ in 0..ip_count.max(0) { - out.extend(parse_ip_port(body, &mut off, dc_id)?); - } - } - Ok(out) -} - -fn parse_ip_port( - buf: &[u8], - off: &mut usize, - dc_id: i32, -) -> Result, MtprotoError> { - let ctor = read_u32(buf, off)?; - let ipv4 = read_u32(buf, off)?; - let port = read_i32(buf, off)?; - let ip = format!( - "{}.{}.{}.{}", - (ipv4 >> 24) & 0xff, - (ipv4 >> 16) & 0xff, - (ipv4 >> 8) & 0xff, - ipv4 & 0xff - ); - let addr = format!("{ip}:{port}"); - match ctor { - IP_PORT => Ok(vec![TxtEndpoint { - dc_id, - addr, - secret: None, - }]), - IP_PORT_SECRET => { - let raw = read_tl_bytes(buf, off)?; - let secret = normalize_secret(&raw); - Ok(vec![TxtEndpoint { - dc_id, - addr, - secret, - }]) - } - _ => Err(MtprotoError::Message("simple config ip ctor".into())), - } -} - -fn normalize_secret(raw: &[u8]) -> Option<[u8; 16]> { - let slice = if raw.len() == 17 { - &raw[1..] - } else if raw.len() == 16 { - raw - } else { - return None; - }; - let mut out = [0_u8; 16]; - out.copy_from_slice(slice); - Some(out) -} - -fn read_i32(buf: &[u8], off: &mut usize) -> Result { - Ok(read_u32(buf, off)? as i32) -} - -fn read_u32(buf: &[u8], off: &mut usize) -> Result { - if *off + 4 > buf.len() { - return Err(MtprotoError::Message("simple config truncated".into())); - } - let v = u32::from_le_bytes(buf[*off..*off + 4].try_into().expect("u32")); - *off += 4; - Ok(v) -} - -fn read_tl_string(buf: &[u8], off: &mut usize) -> Result { - let bytes = read_tl_bytes(buf, off)?; - Ok(String::from_utf8_lossy(&bytes).into_owned()) -} - -fn read_tl_bytes(buf: &[u8], off: &mut usize) -> Result, MtprotoError> { - if *off >= buf.len() { - return Err(MtprotoError::Message("simple config truncated".into())); - } - let first = buf[*off]; - *off += 1; - let (len, header) = if first == 0xfe { - if *off + 3 > buf.len() { - return Err(MtprotoError::Message("simple config truncated".into())); - } - let len = u32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], 0]) as usize; - *off += 3; - (len, 4usize) - } else { - (usize::from(first), 1usize) - }; - if *off + len > buf.len() { - return Err(MtprotoError::Message("simple config truncated".into())); - } - let data = buf[*off..*off + len].to_vec(); - *off += len; - let total = header + len; - let pad = (4 - (total % 4)) % 4; - if *off + pad > buf.len() { - return Err(MtprotoError::Message("simple config truncated".into())); - } - *off += pad; - Ok(data) -} - -#[cfg(test)] -#[path = "../tests/unit/dns_txt_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/dto.rs b/native/mtproto-rs/src/dto.rs index 17c265bc0..1b6673dcb 100644 --- a/native/mtproto-rs/src/dto.rs +++ b/native/mtproto-rs/src/dto.rs @@ -1,4 +1,51 @@ -#[derive(Debug, Clone, uniffi::Record)] +pub type InstantViewDto = mono_proto_messages::instant_view::InstantViewDto; + +pub type WallpaperDto = mono_proto_media::wallpaper::WallpaperDto; + +#[uniffi::remote(Record)] +pub struct WallpaperDto { + pub id: i64, + pub access_hash: i64, + pub slug: String, + pub pattern: bool, + pub dark: bool, + pub mime_type: String, + pub document_id: Option, + pub colors: Vec, + pub intensity: Option, + pub rotation: i32, + pub blur: bool, + pub motion: bool, +} + +pub type WallpaperCatalogDto = mono_proto_media::wallpaper::WallpaperCatalogDto; + +#[uniffi::remote(Record)] +pub struct WallpaperCatalogDto { + pub hash: i64, + pub not_modified: bool, + pub wallpapers: Vec, +} +#[uniffi::remote(Record)] +pub struct InstantViewDto { + pub url: String, + pub display_url: String, + pub title: Option, + pub site_name: Option, + pub description: Option, + pub webpage_type: Option, + pub hash: i32, + pub has_instant_view: bool, + pub part: bool, + pub rtl: bool, + pub v2: bool, + pub not_modified: bool, + pub blocks_json: String, +} + +pub type AuthCodeSent = mono_proto_types::auth::AuthCodeSent; + +#[uniffi::remote(Record)] pub struct AuthCodeSent { pub phone: String, pub phone_code_hash: String, @@ -6,13 +53,17 @@ pub struct AuthCodeSent { pub code_length: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type AuthSignedIn = mono_proto_types::auth::AuthSignedIn; + +#[uniffi::remote(Record)] pub struct AuthSignedIn { pub user_id: i64, pub dc_id: i32, } -#[derive(Clone, uniffi::Record)] +pub type PasskeyCredentialDto = mono_proto_types::auth::PasskeyCredentialDto; + +#[uniffi::remote(Record)] pub struct PasskeyCredentialDto { pub id: String, pub raw_id: String, @@ -23,14 +74,18 @@ pub struct PasskeyCredentialDto { pub attestation_data: Vec, } -#[derive(Clone, uniffi::Record)] +pub type PasskeyLoginOptionsDto = mono_proto_types::auth::PasskeyLoginOptionsDto; + +#[uniffi::remote(Record)] pub struct PasskeyLoginOptionsDto { pub options: String, pub dc_id: i32, pub auth_key_id: i64, } -#[derive(Clone, uniffi::Record)] +pub type PasskeyDto = mono_proto_types::auth::PasskeyDto; + +#[uniffi::remote(Record)] pub struct PasskeyDto { pub id: String, pub name: String, @@ -39,13 +94,17 @@ pub struct PasskeyDto { pub last_usage_date: Option, } -#[derive(Clone, uniffi::Enum)] +pub type QrLoginDto = mono_proto_types::auth::QrLoginDto; + +#[uniffi::remote(Enum)] pub enum QrLoginDto { Token { token: Vec, expires: i32 }, Authorized { session: AuthSignedIn }, } -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub type DeviceSessionDto = mono_proto_types::auth::DeviceSessionDto; + +#[uniffi::remote(Record)] pub struct DeviceSessionDto { pub hash: i64, pub current: bool, @@ -61,9 +120,11 @@ pub struct DeviceSessionDto { pub region: String, } +pub type InitConnectionInfo = mono_proto_types::init_connection::InitConnectionInfo; + /// Values sent in `initConnection` on a fresh transport. /// https://core.telegram.org/api/invoking -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +#[uniffi::remote(Record)] pub struct InitConnectionInfo { pub device_model: String, pub system_version: String, @@ -73,43 +134,9 @@ pub struct InitConnectionInfo { pub lang_code: String, } -impl Default for InitConnectionInfo { - fn default() -> Self { - Self { - device_model: "Android".into(), - system_version: "14".into(), - app_version: env!("CARGO_PKG_VERSION").into(), - system_lang_code: "en".into(), - lang_pack: "android".into(), - lang_code: "en".into(), - } - } -} - -impl InitConnectionInfo { - pub(crate) fn sanitized(self) -> Self { - let fallback = Self::default(); - Self { - device_model: nonempty(self.device_model, &fallback.device_model), - system_version: nonempty(self.system_version, &fallback.system_version), - app_version: nonempty(self.app_version, &fallback.app_version), - system_lang_code: nonempty(self.system_lang_code, &fallback.system_lang_code), - lang_pack: nonempty(self.lang_pack, &fallback.lang_pack), - lang_code: nonempty(self.lang_code, &fallback.lang_code), - } - } -} - -fn nonempty(value: String, fallback: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - fallback.to_string() - } else { - trimmed.to_string() - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub type ChatDto = mono_proto_types::message::ChatDto; + +#[uniffi::remote(Record)] pub struct ChatDto { pub id: i64, pub title: String, @@ -137,7 +164,6 @@ pub struct ChatDto { pub can_view: bool, pub can_send_plain: bool, pub can_send_photos: bool, - #[serde(default)] pub can_send_polls: bool, pub can_forward: bool, pub can_delete_others: bool, @@ -157,7 +183,9 @@ pub struct ChatDto { pub can_manage_topics: bool, } -#[derive(Debug, Clone, uniffi::Record)] +pub type FolderDto = mono_proto_types::dto::FolderDto; + +#[uniffi::remote(Record)] pub struct FolderDto { pub id: i32, pub title: String, @@ -176,7 +204,9 @@ pub struct FolderDto { pub pinned_chat_ids: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ProfileDto = mono_proto_types::dto::ProfileDto; + +#[uniffi::remote(Record)] pub struct ProfileDto { pub id: i64, pub kind: String, @@ -191,7 +221,9 @@ pub struct ProfileDto { pub extra_json: Option, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub type MessageDto = mono_proto_types::message::MessageDto; + +#[uniffi::remote(Record)] pub struct MessageDto { pub chat_id: i64, pub id: i32, @@ -231,21 +263,23 @@ pub struct MessageDto { pub reply_markup_json: Option, pub forum_topic: bool, /// Avatar already indexed at `(sender_id, 0)` when its key starts with `avatar:`. - #[serde(default)] pub sender_photo_cache_key: Option, /// `None` means the cached record predates media privacy metadata. - #[serde(default)] #[uniffi(default = None)] pub media_preview_restricted: Option, } -#[derive(Debug, Clone, uniffi::Record)] +pub type MessagesPageDto = mono_proto_types::dto::MessagesPageDto; + +#[uniffi::remote(Record)] pub struct MessagesPageDto { pub messages: Vec, pub total_count: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type SavedGifDto = mono_proto_types::dto::SavedGifDto; + +#[uniffi::remote(Record)] pub struct SavedGifDto { pub document_id: i64, pub cache_key: String, @@ -254,7 +288,9 @@ pub struct SavedGifDto { pub height: Option, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ForumTopicDto = mono_proto_types::dto::ForumTopicDto; + +#[uniffi::remote(Record)] pub struct ForumTopicDto { pub id: i32, pub title: String, @@ -274,25 +310,97 @@ pub struct ForumTopicDto { pub last_message_preview: Option, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ForumTopicsPageDto = mono_proto_types::dto::ForumTopicsPageDto; + +#[uniffi::remote(Record)] pub struct ForumTopicsPageDto { pub count: i32, pub topics: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type DiscussionDto = mono_proto_types::dto::DiscussionDto; + +#[uniffi::remote(Record)] pub struct DiscussionDto { pub chat_id: i64, pub message_id: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ReactionChoiceDto = mono_proto_types::dto::ReactionChoiceDto; + +#[uniffi::remote(Record)] pub struct ReactionChoiceDto { pub emoticon: String, pub document_id: i64, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Enum)] +pub type ScheduledMessageDto = mono_proto_types::events::ScheduledMessageDto; + +#[uniffi::remote(Record)] +pub struct ScheduledMessageDto { + pub message: MessageDto, + pub has_unsupported_content: bool, +} + +pub type ContactImportSpec = mono_proto_messages::contacts::ContactImportSpec; +#[uniffi::remote(Record)] +pub struct ContactImportSpec { + pub client_id: i64, + pub phone: String, + pub first_name: String, + pub last_name: String, +} + +pub type ContactImportMatch = mono_proto_messages::contacts::ContactImportMatch; +#[uniffi::remote(Record)] +pub struct ContactImportMatch { + pub client_id: i64, + pub user_id: i64, +} + +pub type ContactImportPopularity = mono_proto_messages::contacts::ContactImportPopularity; +#[uniffi::remote(Record)] +pub struct ContactImportPopularity { + pub client_id: i64, + pub importers: i32, +} + +pub type ContactImportResult = mono_proto_messages::contacts::ContactImportResult; +#[uniffi::remote(Record)] +pub struct ContactImportResult { + pub imported: Vec, + pub retry_client_ids: Vec, + pub popular: Vec, +} + +pub type CloudDraft = mono_proto_types::events::CloudDraft; + +#[uniffi::remote(Record)] +pub struct CloudDraft { + pub chat_id: i64, + pub thread_id: i32, + pub text: String, + pub entities_json: Option, + pub reply_to_message_id: i32, + pub date: i32, + pub no_webpage: bool, + pub has_unsupported_content: bool, +} + +pub type ChecklistTextEditDto = mono_proto_client::checklists::ChecklistTextEditDto; + +#[uniffi::remote(Record)] +pub struct ChecklistTextEditDto { + pub item_id: Option, + pub text: String, + pub entities_json: Option, + pub expected_text: String, + pub expected_entities_json: Option, +} + +pub type UpdateEventDto = mono_proto_types::events::UpdateEventDto; + +#[uniffi::remote(Enum)] pub enum UpdateEventDto { PollChanged { poll_id: i64, @@ -300,14 +408,14 @@ pub enum UpdateEventDto { ChatsChanged, FoldersChanged, DraftChanged { - draft: crate::client::drafts::CloudDraft, + draft: crate::dto::CloudDraft, }, OutgoingMessageAcknowledged { random_id: i64, result: crate::OutgoingMessageResult, }, ScheduledMessageChanged { - message: crate::client::scheduled::ScheduledMessageDto, + message: crate::dto::ScheduledMessageDto, }, ScheduledMessagesDeleted { chat_id: i64, @@ -372,13 +480,17 @@ pub enum UpdateEventDto { }, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ChannelCursorDto = mono_proto_types::dto::ChannelCursorDto; + +#[uniffi::remote(Record)] pub struct ChannelCursorDto { pub chat_id: i64, pub pts: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type UpdateBatchDto = mono_proto_types::dto::UpdateBatchDto; + +#[uniffi::remote(Record)] pub struct UpdateBatchDto { pub account_generation: i64, pub batch_id: i64, @@ -387,14 +499,18 @@ pub struct UpdateBatchDto { pub channel_pts_after: Vec, } -#[derive(Debug, Clone, Default, uniffi::Record)] +pub type UpdateDrainDto = mono_proto_types::dto::UpdateDrainDto; + +#[uniffi::remote(Record)] pub struct UpdateDrainDto { pub batches: Vec, pub live_events: Vec, pub replay_required: bool, } -#[derive(Debug, Clone, Default, uniffi::Record)] +pub type NotifySettingsDto = mono_proto_types::dto::NotifySettingsDto; + +#[uniffi::remote(Record)] pub struct NotifySettingsDto { pub show_previews: Option, pub silent: bool, @@ -404,7 +520,9 @@ pub struct NotifySettingsDto { pub sound: String, } -#[derive(Debug, Clone, uniffi::Record)] +pub type NotifyExceptionDto = mono_proto_types::dto::NotifyExceptionDto; + +#[uniffi::remote(Record)] pub struct NotifyExceptionDto { pub peer_kind: String, pub chat_id: i64, @@ -417,7 +535,9 @@ pub struct NotifyExceptionDto { pub sound: String, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, uniffi::Record)] +pub type UpdatesStateDto = mono_proto_types::updates_state::UpdatesStateDto; + +#[uniffi::remote(Record)] pub struct UpdatesStateDto { pub pts: i32, pub qts: i32, @@ -425,7 +545,9 @@ pub struct UpdatesStateDto { pub seq: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type StickerPackDto = mono_proto_types::dto::StickerPackDto; + +#[uniffi::remote(Record)] pub struct StickerPackDto { pub id: i64, pub access_hash: i64, @@ -436,21 +558,27 @@ pub struct StickerPackDto { pub preview_document_ids: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type StickerCatalogDto = mono_proto_types::dto::StickerCatalogDto; + +#[uniffi::remote(Record)] pub struct StickerCatalogDto { pub hash: i64, pub not_modified: bool, pub sets: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type StickerListDto = mono_proto_types::dto::StickerListDto; + +#[uniffi::remote(Record)] pub struct StickerListDto { pub hash: i64, pub not_modified: bool, pub document_ids: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ResolvedPeerDto = mono_proto_types::dto::ResolvedPeerDto; + +#[uniffi::remote(Record)] pub struct ResolvedPeerDto { pub peer_id: i64, pub username: Option, @@ -458,7 +586,9 @@ pub struct ResolvedPeerDto { pub is_bot: bool, } -#[derive(Debug, Clone, uniffi::Record)] +pub type InlineBotResultDto = mono_proto_types::dto::InlineBotResultDto; + +#[uniffi::remote(Record)] pub struct InlineBotResultDto { pub id: String, pub kind: String, @@ -469,7 +599,9 @@ pub struct InlineBotResultDto { pub thumb_cache_key: Option, } -#[derive(Debug, Clone, uniffi::Record)] +pub type InlineBotResultsDto = mono_proto_types::dto::InlineBotResultsDto; + +#[uniffi::remote(Record)] pub struct InlineBotResultsDto { pub query_id: i64, pub gallery: bool, @@ -478,7 +610,9 @@ pub struct InlineBotResultsDto { pub results: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type SearchPeerDto = mono_proto_types::dto::SearchPeerDto; + +#[uniffi::remote(Record)] pub struct SearchPeerDto { pub peer_id: i64, pub title: String, @@ -489,13 +623,17 @@ pub struct SearchPeerDto { pub is_channel: bool, } -#[derive(Debug, Clone, uniffi::Record)] +pub type ContactsSearchDto = mono_proto_types::dto::ContactsSearchDto; + +#[uniffi::remote(Record)] pub struct ContactsSearchDto { pub people: Vec, pub chats: Vec, } -#[derive(Debug, Clone, uniffi::Record)] +pub type GlobalMessageSearchDto = mono_proto_types::dto::GlobalMessageSearchDto; + +#[uniffi::remote(Record)] pub struct GlobalMessageSearchDto { pub messages: Vec, pub next_rate: i32, @@ -503,7 +641,9 @@ pub struct GlobalMessageSearchDto { pub next_offset_id: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type BotCallbackAnswerDto = mono_proto_types::dto::BotCallbackAnswerDto; + +#[uniffi::remote(Record)] pub struct BotCallbackAnswerDto { pub alert: bool, pub message: Option, @@ -511,7 +651,9 @@ pub struct BotCallbackAnswerDto { pub cache_time: i32, } -#[derive(Debug, Clone, uniffi::Record)] +pub type UploadItemDto = mono_proto_types::dto::UploadItemDto; + +#[uniffi::remote(Record)] pub struct UploadItemDto { pub path: String, pub kind: String, @@ -525,7 +667,9 @@ pub struct UploadItemDto { pub random_id: i64, } -#[derive(Debug, Clone, uniffi::Record)] +pub type JoinChatDto = mono_proto_types::dto::JoinChatDto; + +#[uniffi::remote(Record)] pub struct JoinChatDto { pub joined: bool, pub can_send_plain: bool, @@ -533,6 +677,131 @@ pub struct JoinChatDto { pub can_send_polls: bool, } +pub type ReactionPeerDto = mono_proto_messages::extras_rpc::ReactionPeerDto; + +#[uniffi::remote(Record)] +pub struct ReactionPeerDto { + pub peer_id: i64, + pub title: String, + pub date: i32, + pub emoticon: String, + pub document_id: i64, +} + +pub type ReactionPeersDto = mono_proto_messages::extras_rpc::ReactionPeersDto; + +#[uniffi::remote(Record)] +pub struct ReactionPeersDto { + pub next_offset: Option, + pub peers: Vec, + pub count: i32, +} + +pub type PollVoterDto = mono_proto_messages::extras_rpc::PollVoterDto; + +#[uniffi::remote(Record)] +pub struct PollVoterDto { + pub peer_id: i64, + pub title: String, + pub date: i32, + pub options: Vec>, +} + +pub type PollVotersDto = mono_proto_messages::extras_rpc::PollVotersDto; + +#[uniffi::remote(Record)] +pub struct PollVotersDto { + pub next_offset: Option, + pub voters: Vec, + pub count: i32, +} + +pub type ChecklistSendSpec = mono_proto_client::ChecklistSendSpec; +#[uniffi::remote(Record)] +pub struct ChecklistSendSpec { + pub title: String, + pub items: Vec, + pub others_can_append: bool, + pub others_can_complete: bool, +} + +pub type ContactSendSpec = mono_proto_client::ContactSendSpec; +#[uniffi::remote(Record)] +pub struct ContactSendSpec { + pub phone: String, + pub first_name: String, + pub last_name: String, + pub vcard: String, +} + +pub type LocationSendSpec = mono_proto_client::LocationSendSpec; +#[uniffi::remote(Record)] +pub struct LocationSendSpec { + pub latitude: f64, + pub longitude: f64, + pub live_period: i32, + pub heading: Option, +} + +pub type PollSendSpec = mono_proto_client::PollSendSpec; +#[uniffi::remote(Record)] +pub struct PollSendSpec { + pub question: String, + pub answers: Vec, + pub anonymous: bool, + pub multiple_choice: bool, + pub quiz: bool, + pub correct_answer: Option, + pub solution: Option, +} + +pub type SendOptions = mono_proto_runtime::send_options::SendOptions; +#[uniffi::remote(Record)] +pub struct SendOptions { + pub silent: bool, + pub no_webpage: bool, + pub schedule_date: Option, +} + +pub type OutgoingMessageResult = mono_proto_types::events::OutgoingMessageResult; +#[uniffi::remote(Enum)] +pub enum OutgoingMessageResult { + Sent { message: MessageDto }, + Scheduled { message: ScheduledMessageDto }, +} + +pub type ReadParticipantDto = mono_proto_messages::messages::read_receipts_rpc::ReadParticipantDto; +#[uniffi::remote(Record)] +pub struct ReadParticipantDto { + pub peer_id: i64, + pub date: i32, +} + +pub type ReadParticipantsDto = + mono_proto_messages::messages::read_receipts_rpc::ReadParticipantsDto; +#[uniffi::remote(Record)] +pub struct ReadParticipantsDto { + pub participants: Vec, + pub error: Option, +} + +pub type OutboxReadDto = mono_proto_messages::messages::read_receipts_rpc::OutboxReadDto; +#[uniffi::remote(Record)] +pub struct OutboxReadDto { + pub date: i32, + pub error: Option, +} + +pub type ReadReceiptConfigDto = + mono_proto_messages::messages::read_receipts_rpc::ReadReceiptConfigDto; +#[uniffi::remote(Record)] +pub struct ReadReceiptConfigDto { + pub chat_read_mark_size_threshold: i32, + pub chat_read_mark_expire_period: i32, + pub pm_read_date_expire_period: i32, + pub from_server: bool, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct LottieSize { pub width: u32, diff --git a/native/mtproto-rs/src/emoji_status.rs b/native/mtproto-rs/src/emoji_status.rs deleted file mode 100644 index 693662544..000000000 --- a/native/mtproto-rs/src/emoji_status.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! https://core.telegram.org/constructor/emojiStatus -//! https://core.telegram.org/constructor/emojiStatusCollectible - -use tellers_mtproto::latest::api::EmojiStatus; - -pub fn emoji_status_document_id(status: Option<&EmojiStatus>) -> Option { - let status = status?; - let now = unix_now(); - match status { - EmojiStatus::EmojiStatusEmpty(_) => None, - EmojiStatus::EmojiStatus(value) => { - if expired(value.until, now) { - None - } else { - Some(value.document_id) - } - } - EmojiStatus::EmojiStatusCollectible(value) => { - if expired(value.until, now) { - None - } else { - Some(value.document_id) - } - } - EmojiStatus::InputEmojiStatusCollectible(_) => None, - _ => None, - } -} - -fn expired(until: Option, now: i64) -> bool { - until.is_some_and(|until| i64::from(until) <= now) -} - -fn unix_now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -#[cfg(test)] -#[path = "../tests/unit/emoji_status_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/error.rs b/native/mtproto-rs/src/error.rs index d4b4dcbbe..f63e99c66 100644 --- a/native/mtproto-rs/src/error.rs +++ b/native/mtproto-rs/src/error.rs @@ -1,11 +1,9 @@ -#[derive(Clone, Debug, thiserror::Error, uniffi::Error)] +pub type MtprotoError = mono_proto_types::error::MtprotoError; + +#[uniffi::remote(Error)] pub enum MtprotoError { - #[error("unknown client handle")] UnknownClient, - #[error("registration is not supported")] RegistrationRequired, - #[error("two-factor authentication password required")] PasswordRequired, - #[error("{0}")] Message(String), } diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index 40787a3db..a4e7fe036 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -1,4 +1,3 @@ -use crate::client; use crate::{UpdateBatchDto, UpdateDrainDto}; #[cfg(target_os = "android")] @@ -14,35 +13,33 @@ pub extern "system" fn init_platform_verifier<'caller>( .resolve::(); } -use crate::client::extras_rpc; -use crate::client::push_rpc; use crate::lottie; -use crate::messages::read_receipts_rpc; -use crate::perf; use crate::qr; -use crate::request_control; -use crate::scheduler; -use crate::session_crypto; -use crate::session_file; use crate::vpx; use crate::waveform; use crate::{ AuthCodeSent, AuthSignedIn, BotCallbackAnswerDto, ChatDto, ContactsSearchDto, DeviceSessionDto, DiscussionDto, FolderDto, ForumTopicsPageDto, GlobalMessageSearchDto, InlineBotResultsDto, InstantViewDto, JoinChatDto, LottieSize, MessageDto, MessagesPageDto, MtprotoError, - NotifyExceptionDto, NotifySettingsDto, ProfileDto, QrLoginDto, ReactionChoiceDto, + NotifyExceptionDto, NotifySettingsDto, OutboxReadDto, PollVotersDto, ProfileDto, QrLoginDto, + ReactionChoiceDto, ReactionPeersDto, ReadParticipantsDto, ReadReceiptConfigDto, ResolvedPeerDto, SavedGifDto, StickerCatalogDto, StickerListDto, StickerPackDto, UpdateEventDto, UpdatesStateDto, UploadItemDto, VpxAlphaFrame, VpxFrame, WallpaperCatalogDto, }; +use mono_proto_media as proto_media; +use mono_proto_messages as proto_messages; +use mono_proto_observability as proto_observability; +use mono_proto_runtime as proto_runtime; +use mono_proto_socket as proto_socket; #[uniffi::export] pub fn passkeys_enabled(handle: u64) -> Result { - client::passkeys_enabled(handle) + mono_proto_client::passkeys::passkeys_enabled(handle) } #[uniffi::export] pub fn init_passkey_login(handle: u64) -> Result { - client::init_passkey_login(handle) + mono_proto_client::passkeys::init_passkey_login(handle) } #[uniffi::export] @@ -52,12 +49,12 @@ pub fn finish_passkey_login( dc_id: i32, auth_key_id: i64, ) -> Result { - client::finish_passkey_login(handle, credential, dc_id, auth_key_id) + mono_proto_client::passkeys::finish_passkey_login(handle, credential, dc_id, auth_key_id) } #[uniffi::export] pub fn init_passkey_registration(handle: u64) -> Result { - client::init_passkey_registration(handle) + mono_proto_client::passkeys::init_passkey_registration(handle) } #[uniffi::export] @@ -65,57 +62,57 @@ pub fn register_passkey( handle: u64, credential: crate::PasskeyCredentialDto, ) -> Result { - client::register_passkey(handle, credential) + mono_proto_client::passkeys::register_passkey(handle, credential) } #[uniffi::export] pub fn get_passkeys(handle: u64) -> Result, MtprotoError> { - client::get_passkeys(handle) + mono_proto_client::passkeys::get_passkeys(handle) } #[uniffi::export] pub fn delete_passkey(handle: u64, id: String) -> Result { - client::delete_passkey(handle, id) + mono_proto_client::passkeys::delete_passkey(handle, id) } #[uniffi::export] pub fn export_login_token(handle: u64) -> Result { - client::export_login_token(handle) + mono_proto_client::auth::export_login_token(handle) } #[uniffi::export] pub fn poll_login_token(handle: u64) -> Result { - client::poll_login_token(handle) + mono_proto_client::auth::poll_login_token(handle) } #[uniffi::export] pub fn get_device_sessions(handle: u64) -> Result, MtprotoError> { - client::get_device_sessions(handle) + mono_proto_client::auth::get_device_sessions(handle) } #[uniffi::export] pub fn accept_login_token(handle: u64, token: Vec) -> Result { - client::accept_login_token(handle, token) + mono_proto_client::auth::accept_login_token(handle, token) } #[uniffi::export] pub fn revoke_device_session(handle: u64, hash: i64) -> Result<(), MtprotoError> { - client::revoke_device_session(handle, hash) + mono_proto_client::auth::revoke_device_session(handle, hash) } #[uniffi::export] pub fn get_wallpapers(handle: u64, hash: i64) -> Result { - client::get_wallpapers(handle, hash) + mono_proto_client::extras::get_wallpapers(handle, hash) } #[uniffi::export] pub fn get_web_page(handle: u64, url: String, hash: i32) -> Result { - client::get_web_page(handle, url, hash) + mono_proto_client::extras::get_web_page(handle, url, hash) } #[uniffi::export] pub fn get_web_page_preview(handle: u64, message: String) -> Result { - client::get_web_page_preview(handle, message) + mono_proto_client::extras::get_web_page_preview(handle, message) } #[uniffi::export] @@ -125,76 +122,85 @@ pub fn download_wallpaper( access_hash: i64, dest_path: String, ) -> Result { - client::download_wallpaper(handle, id, access_hash, dest_path) + mono_proto_client::media_download::download_wallpaper(handle, id, access_hash, dest_path) } #[uniffi::export] pub fn create_request_control() -> u64 { - request_control::create() + proto_runtime::request_control::create() } #[uniffi::export] pub fn bind_request_control(id: u64) -> u64 { - request_control::bind(id) + proto_runtime::request_control::bind(id) } #[uniffi::export] pub fn cancel_request_control(id: u64) { - request_control::cancel(id); + proto_runtime::request_control::cancel(id); } #[uniffi::export] pub fn release_request_control(id: u64) { - request_control::release(id); + proto_runtime::request_control::release(id); } #[uniffi::export] pub fn promote_request_control(id: u64, class: i32) { let class = match class { - 0 => scheduler::RequestClass::InteractiveRead, - 1 => scheduler::RequestClass::BackgroundRead, - 2 | 5 | 6 | 7 => scheduler::RequestClass::InteractiveMedia, - 3 => scheduler::RequestClass::BackgroundMedia, - 4 => scheduler::RequestClass::InteractiveWrite, + 0 => proto_runtime::scheduler::RequestClass::InteractiveRead, + 1 => proto_runtime::scheduler::RequestClass::BackgroundRead, + 2 | 5 | 6 | 7 => proto_runtime::scheduler::RequestClass::InteractiveMedia, + 3 => proto_runtime::scheduler::RequestClass::BackgroundMedia, + 4 => proto_runtime::scheduler::RequestClass::InteractiveWrite, _ => return, }; - request_control::promote(id, class); + proto_runtime::request_control::promote(id, class); } /// 0 interactive read, 1 background read, 2 interactive media, 3 background media, /// 4 interactive write, 5 ordinary media, 6 visible media, 7 user media. #[uniffi::export] pub fn set_dispatch_class(class: i32) { - crate::media::set_request_profile(match class { - 3 => Some(crate::media::DownloadProfile::Background), - 5 => Some(crate::media::DownloadProfile::Ordinary), - 6 => Some(crate::media::DownloadProfile::Visible), - 7 => Some(crate::media::DownloadProfile::User), + proto_media::profile::set_request_profile(match class { + 3 => Some(proto_runtime::transfer_window::DownloadProfile::Background), + 5 => Some(proto_runtime::transfer_window::DownloadProfile::Ordinary), + 6 => Some(proto_runtime::transfer_window::DownloadProfile::Visible), + 7 => Some(proto_runtime::transfer_window::DownloadProfile::User), _ => None, }); let class = match class { - 0 => scheduler::RequestClass::InteractiveRead, - 1 => scheduler::RequestClass::BackgroundRead, - 2 | 5 | 6 | 7 => scheduler::RequestClass::InteractiveMedia, - 3 => scheduler::RequestClass::BackgroundMedia, - 4 => scheduler::RequestClass::InteractiveWrite, - _ => scheduler::RequestClass::InteractiveRead, + 0 => proto_runtime::scheduler::RequestClass::InteractiveRead, + 1 => proto_runtime::scheduler::RequestClass::BackgroundRead, + 2 | 5 | 6 | 7 => proto_runtime::scheduler::RequestClass::InteractiveMedia, + 3 => proto_runtime::scheduler::RequestClass::BackgroundMedia, + 4 => proto_runtime::scheduler::RequestClass::InteractiveWrite, + _ => proto_runtime::scheduler::RequestClass::InteractiveRead, }; - scheduler::set_current_class(class); + proto_runtime::scheduler::set_current_class(class); } /// "Faster downloads" setting: media lanes (1..=8) and parts in flight (1..=16). #[uniffi::export] pub fn set_download_concurrency(lanes: i32, parts: i32) { - scheduler::set_active_media_lanes(lanes.max(1) as usize); - client::set_pipeline_parts(parts.max(1) as usize); + proto_runtime::scheduler::set_active_media_lanes(lanes.max(1) as usize); + mono_proto_client::lanes::set_pipeline_parts(parts.max(1) as usize); } #[uniffi::export] pub fn set_file_part_kib(kib: i32) { - crate::upload::upload_rpc::set_file_part_kib(kib); + let bytes = proto_runtime::transfer_policy::normalize_file_part_kib(kib); + let kib = if bytes == proto_messages::upload_rpc::FILE_PART_FAST { + 512 + } else { + 32 + }; + proto_runtime::transfer_policy::default_policy().assign_file_part_kib(kib); + mono_proto_client::registry::visit_policies(|policy| policy.follow_file_part_kib(kib)); } #[uniffi::export] pub fn set_download_chunk_kib(kib: i32) { - crate::media::set_chunk_size(kib.checked_mul(1024).unwrap_or(0)); + let size = proto_runtime::transfer_policy::normalize_chunk(kib.checked_mul(1024).unwrap_or(0)); + proto_runtime::transfer_policy::default_policy().assign_chunk_size(size); + mono_proto_client::registry::visit_policies(|policy| policy.follow_chunk_size(size)); } #[uniffi::export(callback_interface)] @@ -205,49 +211,51 @@ pub trait DownloadProgressListener: Send + Sync { #[uniffi::export] pub fn set_download_progress_listener(listener: Box) { let listener: std::sync::Arc = std::sync::Arc::from(listener); - crate::media::set_progress_callback(Some(std::sync::Arc::new( - move |path: &str, downloaded: i64, total: i64| { + let callback: proto_runtime::transfer_policy::ProgressCallback = + std::sync::Arc::new(move |path: &str, downloaded: i64, total: i64| { listener.on_progress(path.to_string(), downloaded, total); - }, - ))); + }); + let progress = Some(callback); + proto_runtime::transfer_policy::default_policy().assign_progress(progress.clone()); + mono_proto_client::registry::visit_policies(|policy| policy.follow_progress(&progress)); } #[uniffi::export] pub fn download_chunk_kib() -> i32 { - crate::media::chunk_size() / 1024 + proto_runtime::transfer_policy::current().chunk_size() / 1024 } #[uniffi::export] pub fn download_concurrency() -> Vec { vec![ - scheduler::active_media_lanes() as i32, - client::pipeline_parts() as i32, + proto_runtime::scheduler::active_media_lanes() as i32, + mono_proto_client::lanes::pipeline_parts() as i32, ] } /// Netcode timing spans on/off; off by default. #[uniffi::export] pub fn perf_set_enabled(enabled: bool) { - perf::set_enabled(enabled); + proto_observability::perf::set_enabled(enabled); } /// JSON timing snapshot; `reset` clears the window. #[uniffi::export] pub fn perf_snapshot(reset: bool) -> String { - perf::snapshot_json(reset) + proto_observability::perf::snapshot_json(reset) } #[uniffi::export] pub fn library_version() -> String { format!( - "monogram-mtproto/{}; tellers-mtproto-impl; layer {}", + "monogram-mtproto/{}; mono-proto-impl; layer {}", env!("CARGO_PKG_VERSION"), - tellers_mtproto::LATEST_API_LAYER + mono_proto::LATEST_API_LAYER ) } #[uniffi::export] pub fn api_layer() -> u32 { - tellers_mtproto::LATEST_API_LAYER + mono_proto::LATEST_API_LAYER } #[uniffi::export] @@ -257,7 +265,8 @@ pub fn set_init_connection_info(info: crate::InitConnectionInfo) { #[uniffi::export] pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 { - client::create_client(api_id, api_hash, session_path) + crate::api_invoke::initialize(); + mono_proto_client::construct::create_client(api_id, api_hash, session_path) } #[uniffi::export] @@ -267,15 +276,8 @@ pub fn create_encrypted_client( session_path: String, key: Vec, ) -> Result { - let key = zeroize::Zeroizing::new(key); - let guard = session_crypto::register(std::path::Path::new(&session_path), &key) - .map_err(|_| MtprotoError::Message("session key unavailable".into()))?; - session_file::FileSessionStore::new(&session_path) - .load() - .map_err(|_| MtprotoError::Message("session restore failed".into()))?; - let handle = client::create_client(api_id, api_hash, session_path); - drop(guard); - Ok(handle) + crate::api_invoke::initialize(); + mono_proto_client::construct::create_encrypted_client(api_id, api_hash, session_path, key) } #[uniffi::export] @@ -288,19 +290,19 @@ pub fn set_proxy( 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, + "socks5" => mono_proto_socket::ProxyKind::Socks5, + "http" => mono_proto_socket::ProxyKind::Http, + "https" => mono_proto_socket::ProxyKind::Https, + "mtproto" => mono_proto_socket::ProxyKind::Mtproto, _ => return Err(MtprotoError::Message("unsupported proxy type".into())), }; - let parsed = monogram_mtproto_transport::ProxyConfig::decode_mtproto_secret(&secret) + let parsed = mono_proto_socket::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 { + proto_runtime::connection::tcp::set_proxy(Some(proto_socket::ProxyConfig { kind, host, port, @@ -322,19 +324,19 @@ pub fn ping_proxy( 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, + "socks5" => mono_proto_socket::ProxyKind::Socks5, + "http" => mono_proto_socket::ProxyKind::Http, + "https" => mono_proto_socket::ProxyKind::Https, + "mtproto" => mono_proto_socket::ProxyKind::Mtproto, _ => return Err(MtprotoError::Message("unsupported proxy type".into())), }; - let parsed = monogram_mtproto_transport::ProxyConfig::decode_mtproto_secret(&secret) + let parsed = mono_proto_socket::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 { + proto_runtime::connection::tcp::probe_proxy(proto_socket::ProxyConfig { kind, host, port, @@ -348,64 +350,65 @@ pub fn ping_proxy( #[uniffi::export] pub fn clear_proxy() -> Result<(), MtprotoError> { - crate::tcp::set_proxy(None).map_err(|e| MtprotoError::Message(e.to_string())) + proto_runtime::connection::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 + mono_proto_socket::TransportMode::PaddedIntermediate } - "http" => monogram_mtproto_transport::TransportMode::Http, + "http" => mono_proto_socket::TransportMode::Http, _ => return Err(MtprotoError::Message("unsupported transport mode".into())), }; - crate::tcp::set_transport_mode(mode); + proto_runtime::connection::tcp::set_transport_mode(mode); Ok(()) } #[uniffi::export] pub fn connect(handle: u64) -> Result<(), MtprotoError> { - perf::span("connect").with(|| client::connect(handle)) + proto_observability::perf::span("connect").with(|| mono_proto_client::auth::connect(handle)) } #[uniffi::export] pub fn is_authorized(handle: u64) -> Result { - client::is_authorized(handle) + mono_proto_client::auth::is_authorized(handle) } #[uniffi::export] pub fn destroy_client(handle: u64) { - client::destroy_client(handle) + mono_proto_client::registry::destroy_client(handle) } #[uniffi::export] pub fn update_lazy_sync_config(handle: u64, lazy: bool, exceptions: Vec) { - client::update_lazy_sync_config(handle, lazy, exceptions); + mono_proto_client::config::update_lazy_sync_config(handle, lazy, exceptions); } #[uniffi::export] pub fn client_exists(handle: u64) -> bool { - client::client_exists(handle) + mono_proto_client::registry::client_exists(handle) } #[uniffi::export] pub fn client_api_id(handle: u64) -> i32 { - client::client_api_id(handle) + mono_proto_client::access::client_api_id(handle) } #[uniffi::export] pub fn set_client_test_dc(handle: u64, test: bool) -> Result<(), MtprotoError> { - client::set_client_test_dc(handle, test) + mono_proto_client::auth::set_client_test_dc(handle, test) } #[uniffi::export] pub fn client_uses_test_dc(handle: u64) -> bool { - client::client_uses_test_dc(handle) + mono_proto_client::access::client_uses_test_dc(handle) } #[uniffi::export] pub fn send_auth_code(handle: u64, phone: String) -> Result { - client::send_auth_code(handle, phone) + mono_proto_client::auth::send_auth_code(handle, phone) } #[uniffi::export] @@ -414,7 +417,7 @@ pub fn resend_auth_code( phone: String, phone_code_hash: String, ) -> Result { - client::resend_auth_code(handle, phone, phone_code_hash) + mono_proto_client::auth::resend_auth_code(handle, phone, phone_code_hash) } #[uniffi::export] @@ -424,47 +427,47 @@ pub fn sign_in( phone_code_hash: String, phone_code: String, ) -> Result { - client::sign_in(handle, phone, phone_code_hash, phone_code) + mono_proto_client::auth::sign_in(handle, phone, phone_code_hash, phone_code) } #[uniffi::export] pub fn check_password(handle: u64, password: String) -> Result { - client::check_password(handle, password) + mono_proto_client::auth::check_password(handle, password) } #[uniffi::export] pub fn logout(handle: u64) -> Result<(), MtprotoError> { - client::logout(handle) + mono_proto_client::auth::logout(handle) } #[uniffi::export] pub fn get_chats(handle: u64) -> Result, MtprotoError> { - client::get_chats(handle) + mono_proto_client::dispatch::get_chats(handle) } #[uniffi::export] pub fn get_folders(handle: u64) -> Result, MtprotoError> { - client::get_folders(handle) + mono_proto_client::dispatch::get_folders(handle) } #[uniffi::export] pub fn update_folder(handle: u64, folder: FolderDto) -> Result<(), MtprotoError> { - client::update_folder(handle, folder) + mono_proto_client::dispatch::update_folder(handle, folder) } #[uniffi::export] pub fn delete_folder(handle: u64, id: i32) -> Result<(), MtprotoError> { - client::delete_folder(handle, id) + mono_proto_client::dispatch::delete_folder(handle, id) } #[uniffi::export] pub fn update_folder_order(handle: u64, order: Vec) -> Result<(), MtprotoError> { - client::update_folder_order(handle, order) + mono_proto_client::dispatch::update_folder_order(handle, order) } #[uniffi::export] pub fn get_history(handle: u64, chat_id: i64, limit: i32) -> Result, MtprotoError> { - client::get_history(handle, chat_id, limit, 0, 0, 0) + mono_proto_client::dispatch::get_history(handle, chat_id, limit, 0, 0, 0) } #[uniffi::export] @@ -473,7 +476,7 @@ pub fn get_message( chat_id: i64, message_id: i32, ) -> Result, MtprotoError> { - client::get_message(handle, chat_id, message_id) + mono_proto_client::extras::get_message(handle, chat_id, message_id) } #[uniffi::export] @@ -485,7 +488,14 @@ pub fn get_history_page( offset_date: i32, add_offset: i32, ) -> Result, MtprotoError> { - client::get_history(handle, chat_id, limit, offset_id, offset_date, add_offset) + mono_proto_client::dispatch::get_history( + handle, + chat_id, + limit, + offset_id, + offset_date, + add_offset, + ) } #[uniffi::export] @@ -497,7 +507,7 @@ pub fn get_replies( offset_id: i32, add_offset: i32, ) -> Result, MtprotoError> { - client::get_replies(handle, chat_id, msg_id, limit, offset_id, add_offset) + mono_proto_client::dispatch::get_replies(handle, chat_id, msg_id, limit, offset_id, add_offset) } #[uniffi::export] @@ -509,7 +519,14 @@ pub fn get_forum_topics( offset_topic: i32, limit: i32, ) -> Result { - client::get_forum_topics(handle, chat_id, offset_date, offset_id, offset_topic, limit) + mono_proto_client::dispatch::get_forum_topics( + handle, + chat_id, + offset_date, + offset_id, + offset_topic, + limit, + ) } #[uniffi::export] @@ -518,7 +535,7 @@ pub fn get_forum_topics_by_id( chat_id: i64, topic_ids: Vec, ) -> Result { - client::get_forum_topics_by_id(handle, chat_id, topic_ids) + mono_proto_client::dispatch::get_forum_topics_by_id(handle, chat_id, topic_ids) } #[uniffi::export] @@ -528,7 +545,7 @@ pub fn edit_forum_topic_hidden( topic_id: i32, hidden: bool, ) -> Result<(), MtprotoError> { - client::edit_forum_topic_hidden(handle, chat_id, topic_id, hidden) + mono_proto_client::dispatch::edit_forum_topic_hidden(handle, chat_id, topic_id, hidden) } #[uniffi::export] @@ -539,7 +556,13 @@ pub fn load_more_chats( offset_peer_id: i64, folder_id: i32, ) -> Result, MtprotoError> { - client::load_more_chats(handle, offset_date, offset_id, offset_peer_id, folder_id) + mono_proto_client::dispatch::load_more_chats( + handle, + offset_date, + offset_id, + offset_peer_id, + folder_id, + ) } #[uniffi::export] @@ -549,7 +572,7 @@ pub fn search_messages( query: String, limit: i32, ) -> Result, MtprotoError> { - client::search_messages(handle, chat_id, query, limit) + mono_proto_client::dispatch::search_messages(handle, chat_id, query, limit) } #[uniffi::export] @@ -562,7 +585,9 @@ pub fn search_messages_filtered( add_offset: i32, limit: i32, ) -> Result, MtprotoError> { - client::search_messages_filtered(handle, chat_id, query, filter, offset_id, add_offset, limit) + mono_proto_client::dispatch::search_messages_filtered( + handle, chat_id, query, filter, offset_id, add_offset, limit, + ) } #[uniffi::export] @@ -575,7 +600,9 @@ pub fn search_messages_page( add_offset: i32, limit: i32, ) -> Result { - client::search_messages_page(handle, chat_id, query, filter, offset_id, add_offset, limit) + mono_proto_client::dispatch::search_messages_page( + handle, chat_id, query, filter, offset_id, add_offset, limit, + ) } #[uniffi::export] @@ -584,7 +611,7 @@ pub fn contacts_search( query: String, limit: i32, ) -> Result { - client::contacts_search(handle, query, limit) + mono_proto_client::dispatch::contacts_search(handle, query, limit) } #[uniffi::export] @@ -597,7 +624,7 @@ pub fn search_global( limit: i32, folder_id: i32, ) -> Result { - client::search_global( + mono_proto_client::dispatch::search_global( handle, query, offset_rate, @@ -614,20 +641,17 @@ pub fn get_pinned_messages( chat_id: i64, limit: i32, ) -> Result, MtprotoError> { - client::get_pinned_messages(handle, chat_id, limit) + mono_proto_client::dispatch::get_pinned_messages(handle, chat_id, limit) } #[uniffi::export] -pub fn get_cloud_drafts(handle: u64) -> Result, MtprotoError> { - client::drafts::get_cloud_drafts(handle) +pub fn get_cloud_drafts(handle: u64) -> Result, MtprotoError> { + mono_proto_client::drafts::get_cloud_drafts(handle) } #[uniffi::export] -pub fn save_cloud_draft( - handle: u64, - draft: client::drafts::CloudDraft, -) -> Result<(), MtprotoError> { - client::drafts::save_cloud_draft(handle, draft) +pub fn save_cloud_draft(handle: u64, draft: crate::CloudDraft) -> Result<(), MtprotoError> { + mono_proto_client::drafts::save_cloud_draft(handle, draft) } #[uniffi::export] @@ -635,7 +659,7 @@ pub fn get_scheduled_history( handle: u64, chat_id: i64, ) -> Result, MtprotoError> { - client::scheduled::get_scheduled_history(handle, chat_id) + mono_proto_client::scheduled::get_scheduled_history(handle, chat_id) } #[uniffi::export] @@ -644,7 +668,7 @@ pub fn get_scheduled_messages( chat_id: i64, ids: Vec, ) -> Result, MtprotoError> { - client::scheduled::get_scheduled_messages(handle, chat_id, ids) + mono_proto_client::scheduled::get_scheduled_messages(handle, chat_id, ids) } #[uniffi::export] @@ -653,7 +677,7 @@ pub fn send_scheduled_messages( chat_id: i64, ids: Vec, ) -> Result<(), MtprotoError> { - client::scheduled::send_scheduled_messages(handle, chat_id, ids) + mono_proto_client::scheduled::send_scheduled_messages(handle, chat_id, ids) } #[uniffi::export] @@ -662,7 +686,7 @@ pub fn delete_scheduled_messages( chat_id: i64, ids: Vec, ) -> Result<(), MtprotoError> { - client::scheduled::delete_scheduled_messages(handle, chat_id, ids) + mono_proto_client::scheduled::delete_scheduled_messages(handle, chat_id, ids) } #[uniffi::export] @@ -675,7 +699,7 @@ pub fn edit_scheduled_message( entities_json: Option, no_webpage: bool, ) -> Result<(), MtprotoError> { - client::scheduled::edit_scheduled_message( + mono_proto_client::scheduled::edit_scheduled_message( handle, chat_id, message_id, @@ -691,7 +715,7 @@ pub fn import_contacts( handle: u64, contacts: Vec, ) -> Result { - client::contacts::import_contacts(handle, contacts) + mono_proto_client::contacts::import_contacts(handle, contacts) } #[uniffi::export] @@ -704,7 +728,7 @@ pub fn send_location_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_location_with_options( + mono_proto_client::dispatch::send_location_with_options( handle, chat_id, spec, @@ -725,7 +749,7 @@ pub fn send_contact_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_contact_with_options( + mono_proto_client::dispatch::send_contact_with_options( handle, chat_id, spec, @@ -746,7 +770,7 @@ pub fn send_checklist_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_checklist_with_options( + mono_proto_client::dispatch::send_checklist_with_options( handle, chat_id, spec, @@ -767,7 +791,7 @@ pub fn send_poll_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_poll_with_options( + mono_proto_client::dispatch::send_poll_with_options( handle, chat_id, spec, @@ -788,7 +812,7 @@ pub fn send_dice_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_dice_with_options( + mono_proto_client::dispatch::send_dice_with_options( handle, chat_id, emoticon, @@ -811,7 +835,7 @@ pub fn send_text_message_with_options( client_random_id: i64, options: crate::SendOptions, ) -> Result { - client::send_text_message_with_options( + mono_proto_client::dispatch::send_text_message_with_options( handle, chat_id, text, @@ -835,7 +859,7 @@ pub fn send_text_message( webpage_url: Option, client_random_id: i64, ) -> Result { - client::send_text_message( + mono_proto_client::dispatch::send_text_message( handle, chat_id, text, @@ -857,7 +881,7 @@ pub fn send_photo_message( top_msg_id: i32, entities_json: Option, ) -> Result { - client::send_photo_message( + mono_proto_client::dispatch::send_photo_message( handle, chat_id, path, @@ -870,9 +894,19 @@ pub fn send_photo_message( #[uniffi::export] pub fn replace_uploaded_media( - handle: u64, chat_id: i64, message_id: i32, expected_key: String, item: UploadItemDto, + handle: u64, + chat_id: i64, + message_id: i32, + expected_key: String, + item: UploadItemDto, ) -> Result { - client::replace_uploaded_media(handle, chat_id, message_id, expected_key, item) + mono_proto_client::dispatch::replace_uploaded_media( + handle, + chat_id, + message_id, + expected_key, + item, + ) } #[uniffi::export] @@ -884,7 +918,7 @@ pub fn send_uploaded_media( top_msg_id: i32, entities_json: Option, ) -> Result { - client::send_uploaded_media( + mono_proto_client::dispatch::send_uploaded_media( handle, chat_id, item, @@ -902,7 +936,13 @@ pub fn send_uploaded_album( reply_to_msg_id: i32, top_msg_id: i32, ) -> Result, MtprotoError> { - client::send_uploaded_album(handle, chat_id, items, reply_to_msg_id, top_msg_id) + mono_proto_client::dispatch::send_uploaded_album( + handle, + chat_id, + items, + reply_to_msg_id, + top_msg_id, + ) } #[uniffi::export] @@ -915,7 +955,7 @@ pub fn send_uploaded_media_with_options( entities_json: Option, options: crate::SendOptions, ) -> Result { - client::send_uploaded_media_with_options( + mono_proto_client::dispatch::send_uploaded_media_with_options( handle, chat_id, item, @@ -935,7 +975,7 @@ pub fn send_uploaded_album_with_options( top_msg_id: i32, options: crate::SendOptions, ) -> Result, MtprotoError> { - client::send_uploaded_album_with_options( + mono_proto_client::dispatch::send_uploaded_album_with_options( handle, chat_id, items, @@ -953,7 +993,7 @@ pub fn edit_text_message( text: String, entities_json: Option, ) -> Result { - client::edit_text_message(handle, chat_id, message_id, text, entities_json) + mono_proto_client::dispatch::edit_text_message(handle, chat_id, message_id, text, entities_json) } #[uniffi::export] @@ -963,7 +1003,7 @@ pub fn delete_message( message_id: i32, revoke: bool, ) -> Result<(), MtprotoError> { - client::delete_message(handle, chat_id, message_id, revoke) + mono_proto_client::dispatch::delete_message(handle, chat_id, message_id, revoke) } #[uniffi::export] @@ -974,12 +1014,18 @@ pub fn forward_messages( to_chat_id: i64, drop_author: bool, ) -> Result, MtprotoError> { - client::forward_messages(handle, from_chat_id, message_ids, to_chat_id, drop_author) + mono_proto_client::dispatch::forward_messages( + handle, + from_chat_id, + message_ids, + to_chat_id, + drop_author, + ) } #[uniffi::export] pub fn read_history(handle: u64, chat_id: i64, max_id: i32) -> Result<(), MtprotoError> { - client::read_history(handle, chat_id, max_id) + mono_proto_client::dispatch::read_history(handle, chat_id, max_id) } /// https://core.telegram.org/method/messages.readMessageContents @@ -990,13 +1036,13 @@ pub fn read_message_contents( chat_id: i64, message_ids: Vec, ) -> Result<(), MtprotoError> { - client::read_message_contents(handle, chat_id, message_ids) + mono_proto_client::dispatch::read_message_contents(handle, chat_id, message_ids) } /// https://core.telegram.org/method/messages.markDialogUnread #[uniffi::export] pub fn mark_dialog_unread(handle: u64, chat_id: i64, unread: bool) -> Result<(), MtprotoError> { - client::mark_dialog_unread(handle, chat_id, unread) + mono_proto_client::dispatch::mark_dialog_unread(handle, chat_id, unread) } /// https://core.telegram.org/method/messages.getUnreadMentions @@ -1009,13 +1055,15 @@ pub fn get_unread_mentions( limit: i32, top_msg_id: i32, ) -> Result, MtprotoError> { - client::get_unread_mentions(handle, chat_id, offset_id, add_offset, limit, top_msg_id) + mono_proto_client::dispatch::get_unread_mentions( + handle, chat_id, offset_id, add_offset, limit, top_msg_id, + ) } /// https://core.telegram.org/method/messages.readMentions #[uniffi::export] pub fn read_mentions(handle: u64, chat_id: i64, top_msg_id: i32) -> Result<(), MtprotoError> { - client::read_mentions(handle, chat_id, top_msg_id) + mono_proto_client::dispatch::read_mentions(handle, chat_id, top_msg_id) } /// https://core.telegram.org/method/messages.getUnreadReactions @@ -1028,13 +1076,15 @@ pub fn get_unread_reactions( limit: i32, top_msg_id: i32, ) -> Result, MtprotoError> { - client::get_unread_reactions(handle, chat_id, offset_id, add_offset, limit, top_msg_id) + mono_proto_client::dispatch::get_unread_reactions( + handle, chat_id, offset_id, add_offset, limit, top_msg_id, + ) } /// https://core.telegram.org/method/messages.readReactions #[uniffi::export] pub fn read_reactions(handle: u64, chat_id: i64, top_msg_id: i32) -> Result<(), MtprotoError> { - client::read_reactions(handle, chat_id, top_msg_id) + mono_proto_client::dispatch::read_reactions(handle, chat_id, top_msg_id) } #[uniffi::export] @@ -1044,17 +1094,17 @@ pub fn read_discussion( msg_id: i32, read_max_id: i32, ) -> Result<(), MtprotoError> { - client::read_discussion(handle, chat_id, msg_id, read_max_id) + mono_proto_client::dispatch::read_discussion(handle, chat_id, msg_id, read_max_id) } #[uniffi::export] pub fn set_typing(handle: u64, chat_id: i64, typing: bool) -> Result<(), MtprotoError> { - client::set_typing(handle, chat_id, typing) + mono_proto_client::dispatch::set_typing(handle, chat_id, typing) } #[uniffi::export] pub fn update_status(handle: u64, offline: bool) -> Result<(), MtprotoError> { - client::update_status(handle, offline) + mono_proto_client::dispatch::update_status(handle, offline) } #[uniffi::export] @@ -1067,7 +1117,7 @@ pub fn register_device( app_sandbox: bool, other_uids: Vec, ) -> Result<(), MtprotoError> { - client::register_device( + mono_proto_client::dispatch::register_device( handle, token_type, token, @@ -1085,7 +1135,7 @@ pub fn unregister_device( token: String, other_uids: Vec, ) -> Result<(), MtprotoError> { - client::unregister_device(handle, token_type, token, other_uids) + mono_proto_client::dispatch::unregister_device(handle, token_type, token, other_uids) } #[uniffi::export] @@ -1094,7 +1144,7 @@ pub fn get_notify_settings( peer_kind: String, chat_id: i64, ) -> Result { - client::get_notify_settings(handle, peer_kind, chat_id) + mono_proto_client::dispatch::get_notify_settings(handle, peer_kind, chat_id) } #[uniffi::export] @@ -1108,7 +1158,7 @@ pub fn update_notify_settings( stories_muted: bool, sound: String, ) -> Result<(), MtprotoError> { - client::update_notify_settings( + mono_proto_client::dispatch::update_notify_settings( handle, peer_kind, chat_id, @@ -1122,22 +1172,22 @@ pub fn update_notify_settings( #[uniffi::export] pub fn reset_notify_settings(handle: u64) -> Result<(), MtprotoError> { - client::reset_notify_settings(handle) + mono_proto_client::dispatch::reset_notify_settings(handle) } #[uniffi::export] pub fn join_chat(handle: u64, chat_id: i64) -> Result { - client::join_chat(handle, chat_id) + mono_proto_client::dispatch::join_chat(handle, chat_id) } #[uniffi::export] pub fn unblock_user(handle: u64, chat_id: i64) -> Result<(), MtprotoError> { - client::unblock_user(handle, chat_id) + mono_proto_client::dispatch::unblock_user(handle, chat_id) } #[uniffi::export] pub fn set_contact_joined_silent(handle: u64, silent: bool) -> Result<(), MtprotoError> { - client::set_contact_joined_silent(handle, silent) + mono_proto_client::dispatch::set_contact_joined_silent(handle, silent) } #[uniffi::export] @@ -1145,22 +1195,22 @@ pub fn get_notify_exceptions( handle: u64, compare_sound: bool, ) -> Result, MtprotoError> { - client::get_notify_exceptions(handle, compare_sound) + mono_proto_client::dispatch::get_notify_exceptions(handle, compare_sound) } #[uniffi::export] pub fn decrypt_push_payload(secret: Vec, payload: String) -> Result { - push_rpc::decrypt_push_payload(secret, payload) + mono_proto_messages::notifications::decrypt_push_payload(secret, payload) } #[uniffi::export] pub fn get_profile(handle: u64, peer_id: i64) -> Result { - client::get_profile(handle, peer_id) + mono_proto_client::dispatch::get_profile(handle, peer_id) } #[uniffi::export] pub fn get_group_admin_tags(handle: u64, chat_id: i64) -> Result { - client::get_group_admin_tags(handle, chat_id) + mono_proto_client::dispatch::get_group_admin_tags(handle, chat_id) } /// `messages.getSearchCounters` counts per filter (photo_video/document/url/gif/voice/music/...). @@ -1170,7 +1220,7 @@ pub fn get_search_counters( chat_id: i64, filters: Vec, ) -> Result { - client::get_search_counters(handle, chat_id, filters) + mono_proto_client::dispatch::get_search_counters(handle, chat_id, filters) } /// Participant page with roles for groups and channels, as compact JSON. @@ -1183,7 +1233,7 @@ pub fn get_participants( offset: i32, limit: i32, ) -> Result { - client::get_participants(handle, chat_id, filter, query, offset, limit) + mono_proto_client::dispatch::get_participants(handle, chat_id, filter, query, offset, limit) } /// Groups and channels shared with a user, as compact JSON. @@ -1194,27 +1244,27 @@ pub fn get_common_chats( max_id: i64, limit: i32, ) -> Result { - client::get_common_chats(handle, user_id, max_id, limit) + mono_proto_client::dispatch::get_common_chats(handle, user_id, max_id, limit) } #[uniffi::export] pub fn start_updates(handle: u64) -> Result<(), MtprotoError> { - client::start_updates(handle) + mono_proto_client::updates::start_updates(handle) } #[uniffi::export] pub fn clear_active_dialog(handle: u64) -> Result<(), MtprotoError> { - client::clear_active_dialog(handle) + mono_proto_client::updates::clear_active_dialog(handle) } #[uniffi::export] pub fn drain_updates(handle: u64) -> Result { - client::drain_updates(handle) + mono_proto_client::updates::drain_updates(handle) } #[uniffi::export] pub fn pending_update_batches(handle: u64) -> Result, MtprotoError> { - client::pending_update_batches(handle) + mono_proto_client::updates::pending_update_batches(handle) } #[uniffi::export] @@ -1223,22 +1273,22 @@ pub fn acknowledge_update_batch( account_generation: i64, batch_id: i64, ) -> Result<(), MtprotoError> { - client::acknowledge_update_batch(handle, account_generation, batch_id) + mono_proto_client::updates::acknowledge_update_batch(handle, account_generation, batch_id) } #[uniffi::export] pub fn animated_emoji_max(handle: u64) -> Result { - client::animated_emoji_max(handle) + mono_proto_client::extras::animated_emoji_max(handle) } #[uniffi::export] pub fn custom_emoji_is_free(handle: u64, document_id: i64) -> Result { - client::custom_emoji_is_free(handle, document_id) + mono_proto_client::extras::custom_emoji_is_free(handle, document_id) } #[uniffi::export] pub fn get_updates_state(handle: u64) -> Result { - client::get_updates_state(handle) + mono_proto_client::updates::get_updates_state(handle) } #[uniffi::export] @@ -1248,12 +1298,12 @@ pub fn download_message_media( message_id: i32, dest_path: String, ) -> Result { - client::download_message_media( + mono_proto_client::media_download::download_message_media( handle, chat_id, message_id, dest_path, - crate::media::MediaDownloadKind::Full, + proto_media::location::MediaDownloadKind::Full, ) } @@ -1265,12 +1315,12 @@ pub fn download_message_media_chunk( dest_path: String, offset: i64, ) -> Result { - client::download_message_media_range( + mono_proto_client::media_download::download_message_media_range( handle, chat_id, message_id, dest_path, - crate::media::MediaDownloadKind::Full, + proto_media::location::MediaDownloadKind::Full, Some(offset), ) } @@ -1282,23 +1332,23 @@ pub fn download_message_thumb( message_id: i32, dest_path: String, ) -> Result { - client::download_message_media( + mono_proto_client::media_download::download_message_media( handle, chat_id, message_id, dest_path, - crate::media::MediaDownloadKind::Thumb, + proto_media::location::MediaDownloadKind::Thumb, ) } #[uniffi::export] pub fn peek_message_inline_thumb(handle: u64, chat_id: i64, message_id: i32) -> Option> { - client::peek_message_inline_thumb(handle, chat_id, message_id) + mono_proto_client::media_download::peek_message_inline_thumb(handle, chat_id, message_id) } #[uniffi::export] pub fn peek_peer_inline_thumb(handle: u64, peer_id: i64) -> Option> { - client::peek_peer_inline_thumb(handle, peer_id) + mono_proto_client::media_download::peek_peer_inline_thumb(handle, peer_id) } #[uniffi::export] @@ -1308,12 +1358,12 @@ pub fn download_message_display( message_id: i32, dest_path: String, ) -> Result { - client::download_message_media( + mono_proto_client::media_download::download_message_media( handle, chat_id, message_id, dest_path, - crate::media::MediaDownloadKind::Display, + proto_media::location::MediaDownloadKind::Display, ) } @@ -1323,12 +1373,12 @@ pub fn download_custom_emoji( document_id: i64, dest_path: String, ) -> Result { - client::download_custom_emoji(handle, document_id, dest_path) + mono_proto_client::media_download::download_custom_emoji(handle, document_id, dest_path) } #[uniffi::export] pub fn get_sticker_pack(handle: u64, document_id: i64) -> Result { - client::get_sticker_pack(handle, document_id) + mono_proto_client::extras::get_sticker_pack(handle, document_id) } #[uniffi::export] @@ -1337,17 +1387,17 @@ pub fn get_sticker_set( set_id: i64, access_hash: i64, ) -> Result { - client::get_sticker_set(handle, set_id, access_hash) + mono_proto_client::extras::get_sticker_set(handle, set_id, access_hash) } #[uniffi::export] pub fn get_all_stickers(handle: u64, hash: i64) -> Result { - client::get_all_stickers(handle, hash) + mono_proto_client::extras::get_all_stickers(handle, hash) } #[uniffi::export] pub fn get_emoji_stickers(handle: u64, hash: i64) -> Result { - client::get_emoji_stickers(handle, hash) + mono_proto_client::extras::get_emoji_stickers(handle, hash) } #[uniffi::export] @@ -1356,12 +1406,12 @@ pub fn get_stickers( emoticon: String, hash: i64, ) -> Result { - client::get_stickers(handle, emoticon, hash) + mono_proto_client::extras::get_stickers(handle, emoticon, hash) } #[uniffi::export] pub fn resolve_username(handle: u64, username: String) -> Result { - client::resolve_username(handle, username) + mono_proto_client::extras::resolve_username(handle, username) } #[uniffi::export] @@ -1372,7 +1422,7 @@ pub fn get_inline_bot_results( query: String, offset: String, ) -> Result { - client::get_inline_bot_results(handle, chat_id, bot_id, query, offset) + mono_proto_client::extras::get_inline_bot_results(handle, chat_id, bot_id, query, offset) } #[uniffi::export] @@ -1384,7 +1434,7 @@ pub fn send_inline_bot_result( reply_to_msg_id: i32, top_msg_id: i32, ) -> Result { - client::send_inline_bot_result( + mono_proto_client::extras::send_inline_bot_result( handle, chat_id, query_id, @@ -1396,7 +1446,7 @@ pub fn send_inline_bot_result( #[uniffi::export] pub fn get_saved_gifs(handle: u64) -> Result, MtprotoError> { - client::get_saved_gifs(handle) + mono_proto_client::extras::get_saved_gifs(handle) } #[uniffi::export] @@ -1409,7 +1459,7 @@ pub fn edit_live_location( heading: Option, stopped: bool, ) -> Result<(), MtprotoError> { - client::edit_live_location( + mono_proto_client::extras::edit_live_location( handle, chat_id, message_id, latitude, longitude, heading, stopped, ) } @@ -1424,7 +1474,7 @@ pub fn send_location( heading: i32, reply_to_msg_id: i32, ) -> Result<(), MtprotoError> { - client::send_location( + mono_proto_client::extras::send_location( handle, chat_id, latitude, @@ -1441,8 +1491,8 @@ pub fn get_message_reactions_page( chat_id: i64, message_id: i32, offset: Option, -) -> Result { - client::get_message_reactions_page(handle, chat_id, message_id, offset) +) -> Result { + mono_proto_client::extras::get_message_reactions_page(handle, chat_id, message_id, offset) } #[uniffi::export] @@ -1451,8 +1501,8 @@ pub fn get_poll_votes_page( chat_id: i64, message_id: i32, offset: Option, -) -> Result { - client::get_poll_votes_page(handle, chat_id, message_id, offset) +) -> Result { + mono_proto_client::extras::get_poll_votes_page(handle, chat_id, message_id, offset) } #[uniffi::export] @@ -1460,8 +1510,8 @@ pub fn get_message_reactions_list( handle: u64, chat_id: i64, message_id: i32, -) -> Result { - client::get_message_reactions_list(handle, chat_id, message_id) +) -> Result { + mono_proto_client::extras::get_message_reactions_list(handle, chat_id, message_id) } #[uniffi::export] @@ -1469,8 +1519,8 @@ pub fn get_poll_votes( handle: u64, chat_id: i64, message_id: i32, -) -> Result { - client::get_poll_votes(handle, chat_id, message_id) +) -> Result { + mono_proto_client::extras::get_poll_votes(handle, chat_id, message_id) } #[uniffi::export] @@ -1478,9 +1528,9 @@ pub fn remove_checklist_item( handle: u64, chat_id: i64, message_id: i32, - edit: client::checklists::ChecklistTextEditDto, + edit: crate::ChecklistTextEditDto, ) -> Result<(), MtprotoError> { - client::checklists::remove_checklist_item(handle, chat_id, message_id, edit) + mono_proto_client::checklists::remove_checklist_item(handle, chat_id, message_id, edit) } #[uniffi::export] @@ -1488,9 +1538,9 @@ pub fn edit_checklist_text( handle: u64, chat_id: i64, message_id: i32, - edit: client::checklists::ChecklistTextEditDto, + edit: crate::ChecklistTextEditDto, ) -> Result<(), MtprotoError> { - client::checklists::edit_checklist_text(handle, chat_id, message_id, edit) + mono_proto_client::checklists::edit_checklist_text(handle, chat_id, message_id, edit) } #[uniffi::export] @@ -1500,12 +1550,12 @@ pub fn edit_checklist_title( message_id: i32, title: String, ) -> Result<(), MtprotoError> { - client::checklists::edit_checklist_title(handle, chat_id, message_id, title) + mono_proto_client::checklists::edit_checklist_title(handle, chat_id, message_id, title) } #[uniffi::export] pub fn close_poll(handle: u64, chat_id: i64, message_id: i32) -> Result<(), MtprotoError> { - client::polls::close_poll(handle, chat_id, message_id) + mono_proto_client::polls::close_poll(handle, chat_id, message_id) } #[uniffi::export] @@ -1515,7 +1565,7 @@ pub fn send_poll_vote( message_id: i32, options: Vec>, ) -> Result<(), MtprotoError> { - client::send_poll_vote(handle, chat_id, message_id, options) + mono_proto_client::extras::send_poll_vote(handle, chat_id, message_id, options) } #[uniffi::export] @@ -1526,7 +1576,7 @@ pub fn append_todo_items( first_id: i32, titles: Vec, ) -> Result<(), MtprotoError> { - client::append_todo_items(handle, chat_id, message_id, first_id, titles) + mono_proto_client::extras::append_todo_items(handle, chat_id, message_id, first_id, titles) } #[uniffi::export] @@ -1537,7 +1587,13 @@ pub fn toggle_todo_completed( completed: Vec, incompleted: Vec, ) -> Result<(), MtprotoError> { - client::toggle_todo_completed(handle, chat_id, message_id, completed, incompleted) + mono_proto_client::extras::toggle_todo_completed( + handle, + chat_id, + message_id, + completed, + incompleted, + ) } #[uniffi::export] @@ -1547,7 +1603,7 @@ pub fn get_bot_callback_answer( message_id: i32, data_hex: String, ) -> Result { - client::get_bot_callback_answer(handle, chat_id, message_id, data_hex) + mono_proto_client::extras::get_bot_callback_answer(handle, chat_id, message_id, data_hex) } #[uniffi::export] @@ -1558,7 +1614,13 @@ pub fn send_saved_gif( reply_to_msg_id: i32, top_msg_id: i32, ) -> Result { - client::send_saved_gif(handle, chat_id, document_id, reply_to_msg_id, top_msg_id) + mono_proto_client::extras::send_saved_gif( + handle, + chat_id, + document_id, + reply_to_msg_id, + top_msg_id, + ) } #[uniffi::export] @@ -1569,7 +1631,7 @@ pub fn send_reaction( emoticon: String, document_id: i64, ) -> Result<(), MtprotoError> { - client::send_reaction(handle, chat_id, message_id, emoticon, document_id) + mono_proto_client::extras::send_reaction(handle, chat_id, message_id, emoticon, document_id) } #[uniffi::export] @@ -1578,12 +1640,12 @@ pub fn get_discussion_message( chat_id: i64, message_id: i32, ) -> Result { - client::get_discussion_message(handle, chat_id, message_id) + mono_proto_client::extras::get_discussion_message(handle, chat_id, message_id) } #[uniffi::export] pub fn get_recent_reactions(handle: u64) -> Result, MtprotoError> { - client::get_recent_reactions(handle) + mono_proto_client::extras::get_recent_reactions(handle) } #[uniffi::export] @@ -1642,8 +1704,8 @@ pub fn get_message_read_participants( handle: u64, chat_id: i64, msg_id: i32, -) -> Result { - client::get_message_read_participants(handle, chat_id, msg_id) +) -> Result { + mono_proto_client::extras::get_message_read_participants(handle, chat_id, msg_id) } #[uniffi::export] @@ -1651,15 +1713,13 @@ pub fn get_outbox_read_date( handle: u64, chat_id: i64, msg_id: i32, -) -> Result { - client::get_outbox_read_date(handle, chat_id, msg_id) +) -> Result { + mono_proto_client::extras::get_outbox_read_date(handle, chat_id, msg_id) } #[uniffi::export] -pub fn get_read_receipt_config( - handle: u64, -) -> Result { - client::get_read_receipt_config(handle) +pub fn get_read_receipt_config(handle: u64) -> Result { + mono_proto_client::extras::get_read_receipt_config(handle) } #[uniffi::export] diff --git a/native/mtproto-rs/src/instant_view/blocks.rs b/native/mtproto-rs/src/instant_view/blocks.rs deleted file mode 100644 index 25bb3a834..000000000 --- a/native/mtproto-rs/src/instant_view/blocks.rs +++ /dev/null @@ -1,496 +0,0 @@ -use serde_json::{Value, json}; -use tellers_mtproto::latest::api::{ - Chat, ChatPhoto, Document, GeoPoint, PageBlock, PageButton, PageCaption, PageListItem, - PageListOrderedItem, PageRelatedArticle, PageTableRow, Photo, PhotoSize, RichText, -}; - -use super::index::PageContext; -use super::rich::{formatted_json, inline_button_href, rich_json, rich_unbounded}; -use crate::media; -use crate::peers::{chat_id_for_channel, chat_id_for_chat, vector_boxed_items}; - -pub(crate) fn map_block(block: &PageBlock, ctx: &PageContext<'_>) -> Value { - match block { - PageBlock::PageBlockUnsupported(_) => json!({"k": "unsupported"}), - PageBlock::PageBlockTitle(b) => text_block("title", &b.text, 0), - PageBlock::PageBlockSubtitle(b) => text_block("subtitle", &b.text, 0), - PageBlock::PageBlockKicker(b) => text_block("kicker", &b.text, 0), - PageBlock::PageBlockAuthorDate(b) => { - let mut value = text_block("authorDate", &b.author, 0); - value["d"] = json!(b.published_date); - value - } - PageBlock::PageBlockHeader(b) => text_block("heading", &b.text, 1), - PageBlock::PageBlockSubheader(b) => text_block("heading", &b.text, 2), - PageBlock::PageBlockHeading1(b) => text_block("heading", &b.text, 1), - PageBlock::PageBlockHeading2(b) => text_block("heading", &b.text, 2), - PageBlock::PageBlockHeading3(b) => text_block("heading", &b.text, 3), - PageBlock::PageBlockHeading4(b) => text_block("heading", &b.text, 4), - PageBlock::PageBlockHeading5(b) => text_block("heading", &b.text, 5), - PageBlock::PageBlockHeading6(b) => text_block("heading", &b.text, 6), - PageBlock::PageBlockParagraph(b) => text_block("paragraph", &b.text, 0), - PageBlock::PageBlockFooter(b) => text_block("footer", &b.text, 0), - PageBlock::PageBlockThinking(b) => text_block("thinking", &b.text, 0), - PageBlock::PageBlockPreformatted(b) => { - let mut value = text_block("pre", &b.text, 0); - if !b.language.is_empty() { - value["lang"] = json!(b.language); - } - value - } - PageBlock::PageBlockDivider(_) => json!({"k": "divider"}), - PageBlock::PageBlockAnchor(b) => json!({"k": "anchor", "n": b.name}), - PageBlock::PageBlockMath(b) => json!({"k": "math", "src": b.source}), - PageBlock::PageBlockBlockquote(b) => quote_block(&b.text, &b.caption, false), - PageBlock::PageBlockPullquote(b) => quote_block(&b.text, &b.caption, true), - PageBlock::PageBlockBlockquoteBlocks(b) => { - json!({ - "k": "quote", - "pull": false, - "blocks": map_blocks(vector_boxed_items(&b.blocks), ctx), - "caption": rich_json(&b.caption), - }) - } - PageBlock::PageBlockList(b) => json!({ - "k": "list", - "ordered": false, - "items": vector_boxed_items(&b.items).map(|item| list_item(item, ctx)).collect::>(), - }), - PageBlock::PageBlockOrderedList(b) => json!({ - "k": "list", - "ordered": true, - "items": vector_boxed_items(&b.items).map(|item| ordered_item(item, ctx)).collect::>(), - }), - PageBlock::PageBlockTable(b) => json!({ - "k": "table", - "bordered": b.bordered.is_some(), - "striped": b.striped.is_some(), - "title": rich_json(&b.title), - "rows": vector_boxed_items(&b.rows).map(table_row).collect::>(), - }), - PageBlock::PageBlockDetails(b) => json!({ - "k": "details", - "open": b.open.is_some(), - "title": rich_json(&b.title), - "blocks": map_blocks(vector_boxed_items(&b.blocks), ctx), - }), - PageBlock::PageBlockPhoto(b) => { - media_photo(b.photo_id, Some(b.caption.as_ref()), b.url.clone(), ctx) - } - PageBlock::PageBlockVideo(b) => media_document( - "video", - b.video_id, - Some(b.caption.as_ref()), - json!({ - "autoplay": b.autoplay.is_some(), - "loop": b.loop_.is_some(), - }), - ctx, - ), - PageBlock::PageBlockAudio(b) => media_document( - "audio", - b.audio_id, - Some(b.caption.as_ref()), - json!({}), - ctx, - ), - PageBlock::PageBlockDocument(b) => media_document( - "document", - b.document_id, - Some(b.caption.as_ref()), - json!({}), - ctx, - ), - PageBlock::PageBlockCover(b) => json!({ - "k": "cover", - "block": map_block(b.cover.as_ref(), ctx), - }), - PageBlock::PageBlockEmbed(b) => json!({ - "k": "embed", - "url": b.url, - "html": b.html, - "w": b.w, - "h": b.h, - "full": b.full_width.is_some(), - "scroll": b.allow_scrolling.is_some(), - "poster": b.poster_photo_id.map(|id| format!("photo:{id}")), - "caption": caption_json(b.caption.as_ref()), - }), - PageBlock::PageBlockEmbedPost(b) => json!({ - "k": "embedPost", - "url": b.url, - "author": b.author, - "date": b.date, - "photo": photo_cache(b.author_photo_id, ctx), - "blocks": map_blocks(vector_boxed_items(&b.blocks), ctx), - "caption": caption_json(b.caption.as_ref()), - }), - PageBlock::PageBlockCollage(b) => json!({ - "k": "collage", - "items": map_blocks(vector_boxed_items(&b.items), ctx), - "caption": caption_json(b.caption.as_ref()), - }), - PageBlock::PageBlockSlideshow(b) => json!({ - "k": "slideshow", - "items": map_blocks(vector_boxed_items(&b.items), ctx), - "caption": caption_json(b.caption.as_ref()), - }), - PageBlock::PageBlockChannel(b) => channel_json(b.channel.as_ref()), - PageBlock::PageBlockRelatedArticles(b) => json!({ - "k": "related", - "title": rich_json(&b.title), - "articles": vector_boxed_items(&b.articles).map(|a| related_article(a, ctx)).collect::>(), - }), - PageBlock::PageBlockMap(b) => map_geo(b.geo.as_ref(), b.zoom, b.w, b.h, b.caption.as_ref()), - PageBlock::InputPageBlockMap(b) => json!({ - "k": "map", - "zoom": b.zoom, - "w": b.w, - "h": b.h, - "caption": caption_json(b.caption.as_ref()), - }), - PageBlock::PageBlockButtonRow(b) => json!({ - "k": "buttons", - "items": vector_boxed_items(&b.buttons).filter_map(page_button).collect::>(), - }), - _ => json!({"k": "unsupported"}), - } -} - -pub(crate) fn map_blocks<'a>( - blocks: impl Iterator, - ctx: &PageContext<'_>, -) -> Vec { - blocks.map(|block| map_block(block, ctx)).collect() -} - -pub(crate) fn text_block(kind: &str, text: &RichText, level: i32) -> Value { - let mut value = rich_json(text); - value["k"] = json!(kind); - if level > 0 { - value["l"] = json!(level); - } - value -} - -pub(crate) fn quote_block(text: &RichText, caption: &RichText, pull: bool) -> Value { - json!({ - "k": "quote", - "pull": pull, - "t": rich_json(text)["t"], - "e": rich_json(text)["e"], - "caption": rich_json(caption), - }) -} - -pub(crate) fn list_item(item: &PageListItem, ctx: &PageContext<'_>) -> Value { - match item { - PageListItem::PageListItemText(t) => json!({ - "checkbox": t.checkbox.is_some(), - "checked": t.checked.is_some(), - "t": rich_json(&t.text)["t"], - "e": rich_json(&t.text)["e"], - }), - PageListItem::PageListItemBlocks(t) => json!({ - "checkbox": t.checkbox.is_some(), - "checked": t.checked.is_some(), - "blocks": map_blocks(vector_boxed_items(&t.blocks), ctx), - }), - _ => json!({"t": ""}), - } -} - -pub(crate) fn ordered_item(item: &PageListOrderedItem, ctx: &PageContext<'_>) -> Value { - match item { - PageListOrderedItem::PageListOrderedItemText(t) => json!({ - "checkbox": t.checkbox.is_some(), - "checked": t.checked.is_some(), - "num": t.num, - "t": rich_json(&t.text)["t"], - "e": rich_json(&t.text)["e"], - }), - PageListOrderedItem::PageListOrderedItemBlocks(t) => json!({ - "checkbox": t.checkbox.is_some(), - "checked": t.checked.is_some(), - "num": t.num, - "blocks": map_blocks(vector_boxed_items(&t.blocks), ctx), - }), - _ => json!({"t": ""}), - } -} - -pub(crate) fn table_row(row: &PageTableRow) -> Value { - let PageTableRow::PageTableRow(row) = row; - Value::Array( - vector_boxed_items(&row.cells) - .map(|cell| { - let tellers_mtproto::latest::api::PageTableCell::PageTableCell(cell) = cell; - json!({ - "h": cell.header.is_some(), - "t": cell.text.as_ref().map(|t| rich_json(t)["t"].clone()).unwrap_or(json!("")), - "e": cell.text.as_ref().map(|t| rich_json(t)["e"].clone()).unwrap_or(json!([])), - "cs": cell.colspan.unwrap_or(1), - "rs": cell.rowspan.unwrap_or(1), - }) - }) - .collect(), - ) -} - -pub(crate) fn media_photo( - photo_id: i64, - caption: Option<&PageCaption>, - url: Option, - ctx: &PageContext<'_>, -) -> Value { - let (w, h) = ctx - .photos - .get(&photo_id) - .map(|photo| photo_wh(photo)) - .unwrap_or((0, 0)); - json!({ - "k": "photo", - "id": photo_id, - "cache": format!("photo:{photo_id}"), - "w": w, - "h": h, - "url": url, - "caption": caption.map(caption_json), - }) -} - -pub(crate) fn media_document( - kind: &str, - document_id: i64, - caption: Option<&PageCaption>, - extra: Value, - ctx: &PageContext<'_>, -) -> Value { - let meta = ctx - .documents - .get(&document_id) - .and_then(|doc| match doc { - Document::Document(d) => { - let indexed = media::media_ref_from_document(d); - let mut name = None; - let mut title = None; - let mut performer = None; - let mut duration = None; - let mut width = None; - let mut height = None; - for attr in vector_boxed_items(&d.attributes) { - match attr { - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeFilename(f) => { - name = Some(f.file_name.clone()); - } - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeAudio(a) => { - title = a.title.clone(); - performer = a.performer.clone(); - duration = Some(a.duration); - } - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeVideo(v) => { - duration = Some(v.duration as i32); - width = Some(v.w); - height = Some(v.h); - } - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeImageSize(s) => { - width = Some(s.w); - height = Some(s.h); - } - _ => {} - } - } - Some(( - indexed.cache_key, - name, - Some(d.mime_type.clone()), - Some(d.size), - title, - performer, - duration, - width, - height, - )) - } - _ => None, - }) - .unwrap_or_else(|| { - ( - format!("doc:{document_id}"), - None, - None, - None, - None, - None, - None, - None, - None, - ) - }); - let (cache, name, mime, size, title, performer, duration, width, height) = meta; - let mut value = extra; - if let Value::Object(map) = &mut value { - map.insert("k".into(), json!(kind)); - map.insert("id".into(), json!(document_id)); - map.insert("cache".into(), json!(cache)); - if let Some(name) = name { - map.insert("name".into(), json!(name)); - } - if let Some(mime) = mime { - map.insert("mime".into(), json!(mime)); - } - if let Some(size) = size { - map.insert("size".into(), json!(size)); - } - if let Some(title) = title { - map.insert("title".into(), json!(title)); - } - if let Some(performer) = performer { - map.insert("performer".into(), json!(performer)); - } - if let Some(duration) = duration { - map.insert("duration".into(), json!(duration)); - } - if let (Some(w), Some(h)) = (width, height) { - if w > 0 && h > 0 { - map.insert("w".into(), json!(w)); - map.insert("h".into(), json!(h)); - } - } - if let Some(caption) = caption { - map.insert("caption".into(), caption_json(caption)); - } - } - value -} - -pub(crate) fn photo_cache(photo_id: i64, ctx: &PageContext<'_>) -> Option { - if ctx.photos.contains_key(&photo_id) { - Some(format!("photo:{photo_id}")) - } else if photo_id != 0 { - Some(format!("photo:{photo_id}")) - } else { - None - } -} - -pub(crate) fn photo_wh(photo: &Photo) -> (i32, i32) { - let Photo::Photo(ph) = photo else { - return (0, 0); - }; - let mut best = (0, 0, 0); - for size in vector_boxed_items(&ph.sizes) { - let PhotoSize::PhotoSize(s) = size else { - continue; - }; - let area = s.w.saturating_mul(s.h); - if area >= best.0 { - best = (area, s.w, s.h); - } - } - (best.1, best.2) -} - -pub(crate) fn caption_json(caption: &PageCaption) -> Value { - let PageCaption::PageCaption(cap) = caption; - json!({ - "t": rich_json(&cap.text)["t"], - "e": rich_json(&cap.text)["e"], - "credit": rich_json(&cap.credit), - }) -} - -pub(crate) fn related_article(article: &PageRelatedArticle, ctx: &PageContext<'_>) -> Value { - let PageRelatedArticle::PageRelatedArticle(a) = article; - json!({ - "url": a.url, - "title": a.title, - "description": a.description, - "author": a.author, - "date": a.published_date, - "photo": a.photo_id.and_then(|id| photo_cache(id, ctx)), - }) -} - -pub(crate) fn map_geo(geo: &GeoPoint, zoom: i32, w: i32, h: i32, caption: &PageCaption) -> Value { - let (lat, lng, access_hash) = match geo { - GeoPoint::GeoPoint(g) => (g.lat, g.long, g.access_hash), - _ => (0.0, 0.0, 0), - }; - json!({ - "k": "map", - "lat": lat, - "lng": lng, - "zoom": zoom, - "w": w, - "h": h, - "cache": format!("geo:{access_hash}:{zoom}"), - "hash": access_hash, - "caption": caption_json(caption), - }) -} - -pub(crate) fn channel_json(chat: &Chat) -> Value { - match chat { - Chat::Channel(c) => { - let id = chat_id_for_channel(c.id); - json!({ - "k": "channel", - "id": id, - "title": c.title, - "username": c.username, - "photo": channel_photo_key(id, c.photo.as_ref()), - }) - } - Chat::Chat(c) => { - let id = chat_id_for_chat(c.id); - json!({ - "k": "channel", - "id": id, - "title": c.title, - "photo": channel_photo_key(id, c.photo.as_ref()), - }) - } - Chat::ChannelForbidden(c) => json!({ - "k": "channel", - "id": chat_id_for_channel(c.id), - "title": c.title, - }), - Chat::ChatForbidden(c) => json!({ - "k": "channel", - "id": chat_id_for_chat(c.id), - "title": c.title, - }), - _ => json!({"k": "channel", "id": 0, "title": ""}), - } -} - -pub(crate) fn channel_photo_key(chat_id: i64, photo: &ChatPhoto) -> Option { - match photo { - ChatPhoto::ChatPhoto(_) => Some(format!("avatar:{chat_id}")), - _ => None, - } -} - -pub(crate) fn page_button(button: &PageButton) -> Option { - let PageButton::PageButton(b) = button; - let mut formatted = rich_unbounded(&b.text); - if let Some(url) = inline_button_href(b.type_.as_ref()) { - let has_link = formatted - .entities - .iter() - .any(|entity| entity.kind == "text_url" || entity.kind == "url"); - if !has_link { - let length = formatted.text.encode_utf16().count() as i32; - if length > 0 { - formatted.entities.push(media::FormatEntity { - kind: "text_url".into(), - offset: 0, - length, - url: Some(url), - }); - } - } - } - Some(formatted_json(&formatted)) -} diff --git a/native/mtproto-rs/src/instant_view/index.rs b/native/mtproto-rs/src/instant_view/index.rs deleted file mode 100644 index d8ce2efe2..000000000 --- a/native/mtproto-rs/src/instant_view/index.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - Document, GeoPoint, InputGeoPoint, InputGeoPointConstructor, - InputWebFileGeoPointLocationConstructor, InputWebFileLocation, PageBlock, Photo, - UploadGetWebFileRequest, UploadWebFile, -}; -use tellers_mtproto_session::Snapshot; - -use crate::MtprotoError; -use crate::media::{self, MediaIndex, MediaLocation, MediaRef}; -use crate::peers::vector_boxed_items; - -/// Indexed as `(media_id, INSTANT_VIEW_MEDIA_MSG)` so avatars `(peer, 0)` stay distinct. -pub const INSTANT_VIEW_MEDIA_MSG: i32 = -1; - -pub(crate) struct PageContext<'a> { - pub(crate) photos: HashMap, - pub(crate) documents: HashMap, -} - -pub(crate) fn index_page_media<'a>( - media: &mut MediaIndex, - page_url: &str, - photos: impl Iterator, - documents: impl Iterator, -) { - for photo in photos { - let Some(id) = photo_id(photo) else { continue }; - let cache_key = format!("photo:{id}"); - if let Some(mut indexed) = media::media_ref_from_photo_with_thumbs(photo, cache_key) { - indexed.source_url = Some(page_url.to_string()); - media::insert_media(media, (id, INSTANT_VIEW_MEDIA_MSG), indexed); - } - } - for document in documents { - let Document::Document(d) = document else { - continue; - }; - let mut indexed = media::media_ref_from_document(d); - indexed.source_url = Some(page_url.to_string()); - media::insert_media(media, (d.id, INSTANT_VIEW_MEDIA_MSG), indexed); - } -} - -pub(crate) fn index_map_previews<'a>( - snapshot: &mut Snapshot, - api_id: i32, - media: &mut MediaIndex, - page_url: &str, - blocks: impl Iterator, -) { - for block in blocks { - match block { - PageBlock::PageBlockMap(m) => { - let _ = index_one_map(snapshot, api_id, media, page_url, m); - } - PageBlock::PageBlockCover(c) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - std::iter::once(c.cover.as_ref()), - ); - } - PageBlock::PageBlockDetails(d) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - vector_boxed_items(&d.blocks), - ); - } - PageBlock::PageBlockCollage(c) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - vector_boxed_items(&c.items), - ); - } - PageBlock::PageBlockSlideshow(c) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - vector_boxed_items(&c.items), - ); - } - PageBlock::PageBlockEmbedPost(c) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - vector_boxed_items(&c.blocks), - ); - } - PageBlock::PageBlockBlockquoteBlocks(c) => { - index_map_previews( - snapshot, - api_id, - media, - page_url, - vector_boxed_items(&c.blocks), - ); - } - _ => {} - } - } -} - -pub(crate) fn index_one_map( - snapshot: &mut Snapshot, - api_id: i32, - media: &mut MediaIndex, - page_url: &str, - block: &tellers_mtproto::latest::api::PageBlockMapConstructor, -) -> Result<(), MtprotoError> { - let GeoPoint::GeoPoint(geo) = block.geo.as_ref() else { - return Ok(()); - }; - let cache_key = format!("geo:{}:{}", geo.access_hash, block.zoom); - let location = InputWebFileLocation::InputWebFileGeoPointLocation( - InputWebFileGeoPointLocationConstructor { - geo_point: Box::new(InputGeoPoint::InputGeoPoint(InputGeoPointConstructor { - flags: 0, - lat: geo.lat, - long: geo.long, - accuracy_radius: None, - })), - access_hash: geo.access_hash, - w: block.w.clamp(64, 1024), - h: block.h.clamp(64, 1024), - zoom: block.zoom, - scale: 1, - }, - ); - let response: UploadWebFile = crate::api_invoke::invoke_api_without_updates( - snapshot, - api_id, - UploadGetWebFileRequest { - location: Box::new(location), - offset: 0, - limit: 128 * 1024, - }, - )?; - let UploadWebFile::UploadWebFile(file) = response; - media::insert_media( - media, - (geo.access_hash, INSTANT_VIEW_MEDIA_MSG), - MediaRef { - file_size: None, - kind: "photo".into(), - cache_key, - location: MediaLocation::Inline { - bytes: file.bytes, - extension: "jpg".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: if page_url.is_empty() { - None - } else { - Some(page_url.to_string()) - }, - }, - ); - Ok(()) -} - -/// IV media is keyed `(id, INSTANT_VIEW_MEDIA_MSG)`. FILE_REFERENCE refresh -/// re-runs `messages.getWebPage` for this URL instead of a chat message. -pub(crate) fn media_refresh_url( - media: &MediaIndex, - chat_id: i64, - message_id: i32, -) -> Option { - if message_id != INSTANT_VIEW_MEDIA_MSG { - return None; - } - media.get(&(chat_id, message_id))?.source_url.clone() -} - -pub(crate) fn photo_id(photo: &Photo) -> Option { - match photo { - Photo::Photo(p) => Some(p.id), - _ => None, - } -} - -pub(crate) fn document_id(document: &Document) -> Option { - match document { - Document::Document(d) => Some(d.id), - _ => None, - } -} diff --git a/native/mtproto-rs/src/instant_view/mod.rs b/native/mtproto-rs/src/instant_view/mod.rs deleted file mode 100644 index 9f687f49b..000000000 --- a/native/mtproto-rs/src/instant_view/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Instant View page mapping. -//! https://core.telegram.org/method/messages.getWebPage -//! https://core.telegram.org/constructor/page -//! https://core.telegram.org/type/PageBlock - -mod blocks; -mod index; -mod rich; -mod rpc; - -pub(crate) use blocks::*; -pub use index::INSTANT_VIEW_MEDIA_MSG; -pub(crate) use index::*; -pub(crate) use rich::*; -pub use rpc::*; - -#[cfg(test)] -#[path = "../../tests/unit/instant_view_block_tests.rs"] -mod block_tests; -#[cfg(test)] -#[path = "../../tests/unit/instant_view_page_tests.rs"] -mod page_tests; diff --git a/native/mtproto-rs/src/instant_view/rich.rs b/native/mtproto-rs/src/instant_view/rich.rs deleted file mode 100644 index 7d3c01ecf..000000000 --- a/native/mtproto-rs/src/instant_view/rich.rs +++ /dev/null @@ -1,170 +0,0 @@ -use serde_json::{Map, Value, json}; -use tellers_mtproto::latest::api::{InlineButtonType, RichText}; - -use crate::media; -use crate::peers::vector_boxed_items; - -pub(crate) fn inline_button_href(kind: &InlineButtonType) -> Option { - match kind { - InlineButtonType::InlineButtonTypeUrl(url) => Some(url.url.clone()), - InlineButtonType::InlineButtonTypeUrlAuth(url) => Some(url.url.clone()), - InlineButtonType::InlineButtonTypeWebView(web) => Some(web.url.clone()), - InlineButtonType::InlineButtonTypeUserProfile(user) => { - Some(format!("tg://user?id={}", user.user_id)) - } - _ => None, - } -} - -pub(crate) fn rich_json(text: &RichText) -> Value { - formatted_json(&rich_unbounded(text)) -} - -pub(crate) fn formatted_json(formatted: &media::FormattedText) -> Value { - json!({ - "t": formatted.text, - "e": formatted.entities.iter().map(|entity| { - let mut row = Map::new(); - row.insert("k".into(), json!(entity.kind)); - row.insert("o".into(), json!(entity.offset)); - row.insert("l".into(), json!(entity.length)); - if let Some(url) = &entity.url { - row.insert("u".into(), json!(url)); - } - Value::Object(row) - }).collect::>(), - }) -} - -pub(crate) fn rich_unbounded(text: &RichText) -> media::FormattedText { - let mut out = media::FormattedText::default(); - append_rich(text, &mut out); - out -} - -pub(crate) fn append_rich(text: &RichText, out: &mut media::FormattedText) { - let start = out.text.encode_utf16().count() as i32; - match text { - RichText::TextEmpty(_) | RichText::TextImage(_) => {} - RichText::TextPlain(t) => out.text.push_str(&t.text), - RichText::TextConcat(t) => { - for child in vector_boxed_items(&t.texts) { - append_rich(child, out); - } - } - RichText::TextBold(t) => { - append_rich(&t.text, out); - mark(out, start, "bold", None); - } - RichText::TextItalic(t) => { - append_rich(&t.text, out); - mark(out, start, "italic", None); - } - RichText::TextUnderline(t) => { - append_rich(&t.text, out); - mark(out, start, "underline", None); - } - RichText::TextStrike(t) => { - append_rich(&t.text, out); - mark(out, start, "strike", None); - } - RichText::TextFixed(t) => { - append_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextTonAddress(t) => { - append_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextBankCard(t) => { - append_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextSpoiler(t) => { - append_rich(&t.text, out); - mark(out, start, "spoiler", None); - } - RichText::TextMarked(t) => { - append_rich(&t.text, out); - mark(out, start, "spoiler", None); - } - RichText::TextUrl(t) => { - append_rich(&t.text, out); - mark(out, start, "text_url", Some(t.url.clone())); - } - RichText::TextEmail(t) => { - append_rich(&t.text, out); - mark(out, start, "email", None); - } - RichText::TextAutoEmail(t) => { - append_rich(&t.text, out); - mark(out, start, "email", None); - } - RichText::TextPhone(t) => { - append_rich(&t.text, out); - mark(out, start, "phone", None); - } - RichText::TextAutoPhone(t) => { - append_rich(&t.text, out); - mark(out, start, "phone", None); - } - RichText::TextHashtag(t) => { - append_rich(&t.text, out); - mark(out, start, "hashtag", None); - } - RichText::TextMention(t) => { - append_rich(&t.text, out); - mark(out, start, "mention", None); - } - RichText::TextMentionName(t) => { - append_rich(&t.text, out); - mark(out, start, "mention", None); - } - RichText::TextBotCommand(t) => { - append_rich(&t.text, out); - mark(out, start, "bot_command", None); - } - RichText::TextCashtag(t) => { - append_rich(&t.text, out); - mark(out, start, "cashtag", None); - } - RichText::TextAutoUrl(t) => { - append_rich(&t.text, out); - mark(out, start, "url", None); - } - RichText::TextCustomEmoji(t) => out.text.push_str(&t.alt), - RichText::TextSubscript(t) => { - append_rich(&t.text, out); - mark(out, start, "subscript", None); - } - RichText::TextSuperscript(t) => { - append_rich(&t.text, out); - mark(out, start, "superscript", None); - } - RichText::TextAnchor(t) => { - append_rich(&t.text, out); - mark(out, start, "anchor", Some(format!("#{}", t.name))); - } - RichText::TextMath(t) => out.text.push_str(&t.source), - RichText::TextDate(t) => append_rich(&t.text, out), - RichText::TextDiff(t) => append_rich(&t.text, out), - RichText::TextButton(t) => { - append_rich(&t.text, out); - if let Some(url) = inline_button_href(t.type_.as_ref()) { - mark(out, start, "text_url", Some(url)); - } - } - } -} - -pub(crate) fn mark(out: &mut media::FormattedText, start: i32, kind: &str, url: Option) { - let length = out.text.encode_utf16().count() as i32 - start; - if length > 0 { - out.entities.push(media::FormatEntity { - kind: kind.to_string(), - offset: start, - length, - url, - }); - } -} diff --git a/native/mtproto-rs/src/instant_view/rpc.rs b/native/mtproto-rs/src/instant_view/rpc.rs deleted file mode 100644 index d676ec414..000000000 --- a/native/mtproto-rs/src/instant_view/rpc.rs +++ /dev/null @@ -1,246 +0,0 @@ -use crate::HashMap; - -use serde_json::Value; -use tellers_mtproto::latest::api::{ - MessageMedia, MessagesGetWebPagePreviewRequest, MessagesGetWebPageRequest, MessagesWebPage, - MessagesWebPagePreview, Page, WebPage, -}; -use tellers_mtproto_session::Snapshot; - -use super::blocks::map_block; -use super::index::{PageContext, document_id, index_map_previews, index_page_media, photo_id}; -use crate::MtprotoError; -use crate::api_invoke::invoke_api; -use crate::dialogs; -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, vector_boxed_items}; - -#[derive(Clone, Debug, uniffi::Record)] -pub struct InstantViewDto { - pub url: String, - pub display_url: String, - pub title: Option, - pub site_name: Option, - pub description: Option, - pub webpage_type: Option, - pub hash: i32, - pub has_instant_view: bool, - pub part: bool, - pub rtl: bool, - pub v2: bool, - pub not_modified: bool, - pub blocks_json: String, -} - -pub fn get_web_page( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - url: String, - hash: i32, -) -> Result { - let response: MessagesWebPage = - invoke_api(snapshot, api_id, MessagesGetWebPageRequest { url, hash })?; - let MessagesWebPage::MessagesWebPage(body) = response; - dialogs::cache_from_users_chats( - peers, - media, - vector_boxed_items(&body.users).cloned(), - vector_boxed_items(&body.chats).cloned(), - ); - let dto = map_messages_web_page(body.webpage.as_ref(), media, hash); - if let WebPage::WebPage(page) = body.webpage.as_ref() { - if let Some(Page::Page(cached)) = page.cached_page.as_ref().map(|p| p.as_ref()) { - index_map_previews( - snapshot, - api_id, - media, - &page.url, - vector_boxed_items(&cached.blocks), - ); - } - } - Ok(dto) -} - -pub fn get_web_page_preview( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - message: String, -) -> Result { - let response: MessagesWebPagePreview = invoke_api( - snapshot, - api_id, - MessagesGetWebPagePreviewRequest { - flags: 0, - message, - entities: None, - }, - )?; - let MessagesWebPagePreview::MessagesWebPagePreview(body) = response; - dialogs::cache_from_users_chats( - peers, - media, - vector_boxed_items(&body.users).cloned(), - vector_boxed_items(&body.chats).cloned(), - ); - let webpage = match body.media.as_ref() { - MessageMedia::MessageMediaWebPage(w) => w.webpage.as_ref(), - _ => { - return Ok(InstantViewDto { - url: String::new(), - display_url: String::new(), - title: None, - site_name: None, - description: None, - webpage_type: None, - hash: 0, - has_instant_view: false, - part: false, - rtl: false, - v2: false, - not_modified: false, - blocks_json: "[]".into(), - }); - } - }; - Ok(map_messages_web_page(webpage, media, 0)) -} - -pub fn map_messages_web_page( - webpage: &WebPage, - media: &mut MediaIndex, - requested_hash: i32, -) -> InstantViewDto { - match webpage { - WebPage::WebPageNotModified(_) => InstantViewDto { - url: String::new(), - display_url: String::new(), - title: None, - site_name: None, - description: None, - webpage_type: None, - hash: requested_hash, - has_instant_view: false, - part: false, - rtl: false, - v2: false, - not_modified: true, - blocks_json: "[]".into(), - }, - WebPage::WebPageEmpty(_) | WebPage::WebPagePending(_) => InstantViewDto { - url: String::new(), - display_url: String::new(), - title: None, - site_name: None, - description: None, - webpage_type: None, - hash: 0, - has_instant_view: false, - part: false, - rtl: false, - v2: false, - not_modified: false, - blocks_json: "[]".into(), - }, - WebPage::WebPage(page) => map_web_page(page, media), - _ => InstantViewDto { - url: String::new(), - display_url: String::new(), - title: None, - site_name: None, - description: None, - webpage_type: None, - hash: 0, - has_instant_view: false, - part: false, - rtl: false, - v2: false, - not_modified: false, - blocks_json: "[]".into(), - }, - } -} - -pub(crate) fn map_web_page( - page: &tellers_mtproto::latest::api::WebPageConstructor, - media: &mut MediaIndex, -) -> InstantViewDto { - let cached = page.cached_page.as_ref().map(|p| p.as_ref()); - let (part, rtl, v2, blocks, mut photos, mut documents) = match cached { - Some(Page::Page(p)) => { - index_page_media( - media, - &page.url, - vector_boxed_items(&p.photos), - vector_boxed_items(&p.documents), - ); - ( - p.part.is_some(), - p.rtl.is_some(), - p.v2.is_some(), - vector_boxed_items(&p.blocks).cloned().collect::>(), - vector_boxed_items(&p.photos).cloned().collect::>(), - vector_boxed_items(&p.documents) - .cloned() - .collect::>(), - ) - } - _ => (false, false, false, Vec::new(), Vec::new(), Vec::new()), - }; - if let Some(photo) = page.photo.as_deref() { - index_page_media(media, &page.url, std::iter::once(photo), std::iter::empty()); - } - if let Some(document) = page.document.as_deref() { - index_page_media( - media, - &page.url, - std::iter::empty(), - std::iter::once(document), - ); - } - if let Some(cover) = page.photo.as_deref() { - let cover_id = photo_id(cover); - if !photos.iter().any(|existing| photo_id(existing) == cover_id) { - photos.push(cover.clone()); - } - } - if let Some(cover) = page.document.as_deref() { - let cover_id = document_id(cover); - if !documents - .iter() - .any(|existing| document_id(existing) == cover_id) - { - documents.push(cover.clone()); - } - } - let ctx = PageContext { - photos: photos - .iter() - .filter_map(|p| photo_id(p).map(|id| (id, p))) - .collect(), - documents: documents - .iter() - .filter_map(|d| document_id(d).map(|id| (id, d))) - .collect(), - }; - let blocks_json = Value::Array(blocks.iter().map(|b| map_block(b, &ctx)).collect()).to_string(); - InstantViewDto { - url: page.url.clone(), - display_url: page.display_url.clone(), - title: page.title.clone(), - site_name: page.site_name.clone(), - description: page.description.clone(), - webpage_type: page.type_.clone(), - hash: page.hash, - has_instant_view: cached.is_some(), - part, - rtl, - v2, - not_modified: false, - blocks_json, - } -} diff --git a/native/mtproto-rs/src/lib.rs b/native/mtproto-rs/src/lib.rs index 6fca35155..8bd393806 100644 --- a/native/mtproto-rs/src/lib.rs +++ b/native/mtproto-rs/src/lib.rs @@ -1,4 +1,4 @@ -//! Android UniFFI surface for Monogram (Tellers MTProto runtime). +//! Android UniFFI surface for Monogram (Mono MTProto runtime). //! https://core.telegram.org/mtproto //! https://core.telegram.org/api/invoking //! https://core.telegram.org/api/layers @@ -6,72 +6,28 @@ #![deny(unsafe_code)] mod api_invoke; -mod auth; -mod client; -mod collections; -mod dialogs; -mod dns_txt; mod dto; -mod emoji_status; mod error; mod ffi; -mod instant_view; mod lottie; -pub mod media; -mod messages; -mod passkeys; -mod peers; -mod perf; -mod presence; -mod profile; mod qr; -mod reply_markup; -mod request_control; -mod rpc; -mod scheduler; -mod service_messages; -mod session_crypto; -mod session_file; -mod stripped_jpeg; -mod tcp; -mod transfer_policy; -mod update_buffer; -mod update_journal; -mod updates; -mod upload; #[allow(unsafe_code)] mod vpx; mod waveform; +#[cfg(test)] +#[path = "../tests/unit/adapter_boundary_tests.rs"] +mod adapter_boundary_tests; + +use mono_proto_types::collections; + pub(crate) use collections::{ CompactString, HashMap, HashMapExt, HashSet, HashSetExt, IndexMap, SmallVec, }; -pub use client::chat_invites; -pub use client::contacts::{ - ContactImportMatch, ContactImportPopularity, ContactImportResult, ContactImportSpec, -}; -pub use client::drafts; -pub use client::forum_management; -pub use client::group_management; -pub use client::management; -pub use client::scheduled; -pub use client::scheduled::ScheduledMessageDto; -pub use client::shared_folders; pub use dto::*; pub use error::*; pub use ffi::*; -pub use instant_view::InstantViewDto; -pub use media::wallpaper_rpc::{WallpaperCatalogDto, WallpaperDto}; -pub use media::{ProgressCallback, notify_progress, set_progress_callback}; -pub use messages::{ - ChecklistSendSpec, ContactSendSpec, LocationSendSpec, OutgoingMessageResult, PollSendSpec, - SendOptions, -}; pub use qr::QrCodeMatrix; uniffi::setup_scaffolding!(); - -#[cfg(test)] -#[path = "../tests/unit/lib_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/media/cdn.rs b/native/mtproto-rs/src/media/cdn.rs deleted file mode 100644 index 2b624d18e..000000000 --- a/native/mtproto-rs/src/media/cdn.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! https://core.telegram.org/cdn -//! https://core.telegram.org/api/files -//! https://core.telegram.org/method/upload.getCdnFile - -use aes::Aes256; -use cipher::{KeyIvInit, StreamCipher}; -use ctr::Ctr128BE; -use sha2::{Digest, Sha256}; -use std::cell::{Cell, RefCell}; -use tellers_mtproto::latest::api::{ - FileHash, HelpGetCdnConfigRequest, True, TrueConstructor, UploadCdnFile, - UploadFileCdnRedirectConstructor, UploadGetCdnFileRequest, UploadReuploadCdnFileRequest, -}; - -use crate::MtprotoError; - -type Aes256Ctr = Ctr128BE; - -thread_local! { - static CDN_SUPPORTED: Cell = const { Cell::new(true) }; - static CDN_REDIRECT: RefCell> = const { RefCell::new(None) }; -} - -#[derive(Clone, Debug)] -pub struct CdnRedirect { - pub dc_id: i32, - pub file_token: Vec, - pub encryption_key: Vec, - pub encryption_iv: Vec, - pub offset: i64, - pub file_hashes: Vec, -} - -pub fn cdn_supported() -> bool { - CDN_SUPPORTED.with(Cell::get) -} - -pub fn with_cdn_supported(on: bool, body: impl FnOnce() -> T) -> T { - CDN_SUPPORTED.with(|flag| { - let previous = flag.replace(on); - let result = body(); - flag.set(previous); - result - }) -} - -pub fn getfile_cdn_fields() -> (u32, Option>) { - if cdn_supported() { - (2, Some(Box::new(True::True(TrueConstructor {})))) - } else { - (0, None) - } -} - -pub fn store_cdn_redirect(redirect: &UploadFileCdnRedirectConstructor, offset: i64) { - CDN_REDIRECT.with(|slot| { - *slot.borrow_mut() = Some(CdnRedirect { - dc_id: redirect.dc_id, - file_token: redirect.file_token.clone(), - encryption_key: redirect.encryption_key.clone(), - encryption_iv: redirect.encryption_iv.clone(), - offset, - file_hashes: crate::peers::vector_boxed_items(redirect.file_hashes.as_ref()) - .cloned() - .collect(), - }); - }); -} - -pub fn take_cdn_redirect() -> Option { - CDN_REDIRECT.with(|slot| slot.borrow_mut().take()) -} - -pub fn is_cdn_redirect(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if message == "CDN_REDIRECT") -} - -pub fn cdn_redirect_error() -> MtprotoError { - MtprotoError::Message("CDN_REDIRECT".into()) -} - -/// AES-256-CTR used by `FileLoadOperation` for CDN parts. -pub fn decrypt_cdn_part( - key: &[u8], - encryption_iv: &[u8], - offset: i64, - data: &mut [u8], -) -> Result<(), MtprotoError> { - if key.len() != 32 { - return Err(MtprotoError::Message("cdn encryption_key".into())); - } - if encryption_iv.len() < 16 { - return Err(MtprotoError::Message("cdn encryption_iv".into())); - } - if offset < 0 || offset % 16 != 0 { - return Err(MtprotoError::Message("cdn offset".into())); - } - let key: [u8; 32] = key - .try_into() - .map_err(|_| MtprotoError::Message("cdn encryption_key".into()))?; - let mut iv = [0u8; 16]; - iv.copy_from_slice(&encryption_iv[..16]); - let block = (offset / 16) as u32; - iv[12] = (block >> 24) as u8; - iv[13] = (block >> 16) as u8; - iv[14] = (block >> 8) as u8; - iv[15] = block as u8; - let mut cipher = Aes256Ctr::new(&key.into(), &iv.into()); - cipher.apply_keystream(data); - Ok(()) -} - -pub fn verify_cdn_hash(data: &[u8], expected: &[u8]) -> bool { - let digest = Sha256::digest(data); - expected == digest.as_slice() -} - -pub fn get_cdn_file_request( - file_token: Vec, - offset: i64, - limit: i32, -) -> UploadGetCdnFileRequest { - UploadGetCdnFileRequest { - file_token, - offset, - limit, - } -} - -pub fn reupload_cdn_file_request( - file_token: Vec, - request_token: Vec, -) -> UploadReuploadCdnFileRequest { - UploadReuploadCdnFileRequest { - file_token, - request_token, - } -} - -pub fn get_cdn_config_request() -> HelpGetCdnConfigRequest { - HelpGetCdnConfigRequest {} -} - -pub fn cdn_file_bytes(file: UploadCdnFile) -> Result, Vec>, MtprotoError> { - match file { - UploadCdnFile::UploadCdnFile(body) => Ok(Ok(body.bytes)), - UploadCdnFile::UploadCdnFileReuploadNeeded(body) => Ok(Err(body.request_token)), - _ => Err(MtprotoError::Message("unexpected upload.cdnFile".into())), - } -} - -pub fn hash_matches(file_hash: &FileHash, offset: i64, data: &[u8]) -> bool { - let FileHash::FileHash(hash) = file_hash else { - return false; - }; - if hash.offset != offset { - return false; - } - let Ok(take) = usize::try_from(hash.limit) else { - return false; - }; - take > 0 && data.len() >= take && verify_cdn_hash(&data[..take], &hash.hash) -} - -pub(crate) fn verify_cdn_part(hashes: &[FileHash], offset: i64, data: &[u8]) -> bool { - if offset < 0 || data.is_empty() { - return false; - } - let mut consumed = 0; - while consumed < data.len() { - let Some(at) = offset.checked_add(consumed as i64) else { - return false; - }; - let Some(hash) = hashes.iter().find(|entry| { - matches!(entry, - FileHash::FileHash(hash) if hash.offset == at - ) - }) else { - return false; - }; - if !hash_matches(hash, at, &data[consumed..]) { - return false; - } - let FileHash::FileHash(hash) = hash else { - return false; - }; - consumed += hash.limit as usize; - } - true -} - -#[cfg(test)] -#[path = "../../tests/unit/media_cdn_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/media/download.rs b/native/mtproto-rs/src/media/download.rs deleted file mode 100644 index 1484e1704..000000000 --- a/native/mtproto-rs/src/media/download.rs +++ /dev/null @@ -1,1968 +0,0 @@ -//! https://core.telegram.org/method/upload.getFile -//! https://core.telegram.org/api/files - -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Condvar, Mutex}; - -use crate::{HashMapExt, HashSet, HashSetExt}; - -use tellers_mtproto::latest::api::{UploadFile, UploadGetFileRequest}; -use tellers_mtproto_session::Snapshot; - -use super::location::{MediaLocation, MediaRef, input_location, media_dc}; -use crate::MtprotoError; -use crate::api_invoke; - -pub(crate) const DEFAULT_CHUNK: i32 = 128 * 1024; -pub(crate) const CHUNK: i32 = DEFAULT_CHUNK; -pub(crate) const STREAM_WINDOW_CHUNKS: usize = 4; -pub(crate) const FAST_CHUNK: i32 = 512 * 1024; -/// Telegram keeps foreground file sockets up and pings about every 3 minutes. -pub const DOWNLOAD_SESSION_IDLE: std::time::Duration = std::time::Duration::from_secs(180); - -/// https://core.telegram.org/api/files -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DownloadProfile { - Thumb, - Ordinary, - Background, - Visible, - Playing, - User, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DownloadWindow { - pub chunk: i32, - pub in_flight: usize, -} - -/// Visible, playing, and user files use 512 KiB and 8 parts. Other files stay -/// at 128 KiB, and a thumb is one request. -/// https://core.telegram.org/api/files -pub(crate) fn window_candidate( - chunk: i32, - in_flight: usize, - alignment_proven: bool, -) -> Option { - let aligned = chunk > 0 && chunk % 4_096 == 0; - match (chunk, in_flight, alignment_proven && aligned) { - (131_072, 4, _) | (524_288, 8, _) => Some(DownloadWindow { chunk, in_flight }), - (1_048_576, 12, true) => Some(DownloadWindow { chunk, in_flight }), - _ => None, - } -} - -pub fn download_window(profile: DownloadProfile) -> DownloadWindow { - match profile { - DownloadProfile::Thumb => DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight: 1, - }, - DownloadProfile::Ordinary | DownloadProfile::Background => DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight: 4, - }, - DownloadProfile::Visible | DownloadProfile::Playing | DownloadProfile::User => { - DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 8, - } - } - } -} - -/// `LIMIT_INVALID` retries a smaller part and fewer of them in flight. -/// https://core.telegram.org/api/files -pub fn limit_invalid_window(window: DownloadWindow) -> Option { - if window.chunk <= DEFAULT_CHUNK { - return None; - } - let in_flight = (window.in_flight / 2).clamp(1, 4); - let in_flight = if in_flight < window.in_flight { - in_flight - } else { - 1 - }; - Some(DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight, - }) -} - -std::thread_local! { - static BOUND_WINDOW: std::cell::Cell = const { std::cell::Cell::new(0) }; - static FORCED_CHUNK: std::cell::Cell = const { std::cell::Cell::new(0) }; - static REQUEST_PROFILE: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -pub(crate) fn set_request_profile(profile: Option) { - REQUEST_PROFILE.with(|cell| cell.set(profile.map(profile_tag).unwrap_or(0))); -} - -pub(crate) fn request_profile() -> Option { - REQUEST_PROFILE.with(|cell| profile_from_tag(cell.get())) -} - -fn profile_tag(profile: DownloadProfile) -> u8 { - match profile { - DownloadProfile::Thumb => 1, - DownloadProfile::Ordinary => 2, - DownloadProfile::Background => 3, - DownloadProfile::Visible => 4, - DownloadProfile::Playing => 5, - DownloadProfile::User => 6, - } -} - -fn profile_from_tag(tag: u8) -> Option { - Some(match tag { - 1 => DownloadProfile::Thumb, - 2 => DownloadProfile::Ordinary, - 3 => DownloadProfile::Background, - 4 => DownloadProfile::Visible, - 5 => DownloadProfile::Playing, - 6 => DownloadProfile::User, - _ => return None, - }) -} - -pub struct DownloadWindowGuard { - previous_profile: u8, - previous_chunk: i32, -} - -impl Drop for DownloadWindowGuard { - fn drop(&mut self) { - BOUND_WINDOW.with(|cell| cell.set(self.previous_profile)); - FORCED_CHUNK.with(|cell| cell.set(self.previous_chunk)); - } -} - -pub fn bind_download_profile(profile: DownloadProfile) -> DownloadWindowGuard { - let window = configured_download_window(profile); - let previous_profile = BOUND_WINDOW.with(|cell| cell.replace(profile_tag(profile))); - let previous_chunk = FORCED_CHUNK.with(|cell| cell.replace(window.chunk)); - DownloadWindowGuard { - previous_profile, - previous_chunk, - } -} - -pub(crate) fn bound_download_window() -> Option { - BOUND_WINDOW - .with(|cell| profile_from_tag(cell.get())) - .map(configured_download_window) -} - -pub(crate) fn configured_download_window(profile: DownloadProfile) -> DownloadWindow { - let maximum = download_window(profile); - let policy = crate::transfer_policy::current(); - DownloadWindow { - chunk: if policy.is_chunk_owned() { - maximum.chunk.min(policy.chunk_size()) - } else if maximum.chunk >= FAST_CHUNK { - maximum.chunk - } else { - maximum.chunk.min(policy.chunk_size()) - }, - in_flight: maximum.in_flight.min(policy.pipeline_parts()), - } -} - -pub(crate) fn download_in_flight() -> usize { - bound_download_window() - .map(|window| window.in_flight) - .unwrap_or_else(crate::client::pipeline_parts) -} - -/// Parts before the aligned seek, or past the in-flight window, are not requested. -/// A non-zero seek stays on the origin DC. -/// https://core.telegram.org/api/files -/// https://core.telegram.org/cdn -pub(crate) fn cancel_outside_seek_window( - seek: i64, - chunk: i32, - window_chunks: usize, - inflight: &[i64], -) -> Vec { - let step = i64::from(chunk.max(1)); - let start = seek - seek.rem_euclid(step); - let end = start.saturating_add(step.saturating_mul(window_chunks.max(1) as i64)); - inflight - .iter() - .copied() - .filter(|off| *off < start || *off >= end) - .collect() -} - -fn cdn_fields_for_seek(seek: i64) -> (u32, Option>) { - if seek == 0 { - super::cdn::getfile_cdn_fields() - } else { - (0, None) - } -} - -pub use crate::transfer_policy::ProgressCallback; - -pub fn set_progress_callback(cb: Option) { - crate::transfer_policy::default_policy().assign_progress(cb.clone()); - crate::client::visit_policies(|policy| policy.follow_progress(&cb)); -} - -pub fn notify_progress(path: &str, downloaded: i64, total: i64) { - crate::transfer_policy::current().notify(path, downloaded, total); -} - -pub fn set_chunk_size(size: i32) { - let size = crate::transfer_policy::normalize_chunk(size); - crate::transfer_policy::default_policy().assign_chunk_size(size); - crate::client::visit_policies(|policy| policy.follow_chunk_size(size)); -} - -pub(crate) fn chunk_size() -> i32 { - crate::transfer_policy::current().chunk_size() -} - -fn resolve_chunk(offset: Option) -> i32 { - let forced = FORCED_CHUNK.with(|cell| cell.get()); - let desired = if forced > 0 { forced } else { chunk_size() }; - if let Some(off) = offset { - if off > 0 && off % i64::from(desired) != 0 && off % i64::from(DEFAULT_CHUNK) == 0 { - return DEFAULT_CHUNK; - } - } - desired -} - -fn is_limit_invalid(err: &MtprotoError) -> bool { - match err { - MtprotoError::Message(message) => { - message.contains("LIMIT_INVALID") || message.contains("limit_invalid") - } - _ => false, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TransferStep { - /// `FILE_MIGRATE_X` only. Home-DC errors such as `USER_MIGRATE` stay put. - Migrate(i32), - /// Same file, after the lane has been released. - ResumeFlood, - Stop, -} - -pub(crate) fn file_migrate_dc(error: &MtprotoError, target_dc: i32, migrations: u8) -> Option { - let MtprotoError::Message(message) = error else { - return None; - }; - if !message.contains("FILE_MIGRATE_") || migrations >= 3 { - return None; - } - let dc = crate::api_invoke::migrate_dc(error)?; - if dc == 0 || dc == target_dc { - None - } else { - Some(dc) - } -} - -pub(crate) fn transfer_step( - error: &MtprotoError, - target_dc: i32, - migrations: u8, - flood_resumes: u8, -) -> TransferStep { - if let Some(dc) = file_migrate_dc(error, target_dc, migrations) { - return TransferStep::Migrate(dc); - } - if flood_resumes < 3 && flood_wait_secs(error).is_some() { - return TransferStep::ResumeFlood; - } - TransferStep::Stop -} - -pub(crate) fn flood_wait_secs(err: &MtprotoError) -> Option { - let MtprotoError::Message(message) = err else { - return None; - }; - ["FLOOD_WAIT_", "FLOOD_PREMIUM_WAIT_"] - .iter() - .filter_map(|prefix| message.split_once(prefix)) - .filter_map(|(_, suffix)| { - suffix - .split(|c: char| !c.is_ascii_digit()) - .next()? - .parse::() - .ok() - }) - .max() -} - -/// Server `FLOOD_WAIT` seconds, not a 15 second cap. -/// https://core.telegram.org/api/errors -pub(crate) fn flood_wait_duration(secs: u64) -> std::time::Duration { - std::time::Duration::from_secs(secs.max(1)) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum TransferClass { - Download, - Upload, -} - -#[derive(Clone, Debug)] -pub struct FloodScope { - slow_until: crate::HashMap<(i32, TransferClass), FloodBackoff>, -} - -#[derive(Clone, Debug)] -struct FloodBackoff { - window: DownloadWindow, - until: std::time::Instant, - premium: bool, - recover_after: std::time::Instant, - successes: usize, -} - -impl FloodScope { - pub fn new() -> Self { - Self { - slow_until: crate::HashMap::new(), - } - } - - /// Record the server deadline. The caller unwinds its lease on the error. - pub fn park( - &mut self, - dc_id: i32, - class: TransferClass, - seconds: u64, - premium: bool, - now: std::time::Instant, - current: DownloadWindow, - ) { - let key = (dc_id, class); - let previous = self.slow_until.get(&key); - let slowed = if class == TransferClass::Download { - DownloadWindow { - chunk: current.chunk, - in_flight: if premium { - 1 - } else { - (previous.map_or(current.in_flight, |state| state.window.in_flight) / 2) - .clamp(1, 2) - }, - } - } else if premium { - DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight: 1, - } - } else { - DownloadWindow { - chunk: current.chunk.min(DEFAULT_CHUNK), - in_flight: current.in_flight.min(4), - } - }; - let seconds = seconds.max(1); - let deadline = - (now + flood_wait_duration(seconds)).max(previous.map_or(now, |state| state.until)); - let premium = previous - .filter(|state| state.until > now) - .map_or(premium, |state| state.premium || premium); - self.slow_until.insert( - key, - FloodBackoff { - window: slowed, - until: deadline, - premium, - recover_after: deadline + std::time::Duration::from_secs(30), - successes: 0, - }, - ); - } - - pub fn window( - &mut self, - dc_id: i32, - class: TransferClass, - now: std::time::Instant, - fallback: DownloadWindow, - ) -> DownloadWindow { - let expired = self - .slow_until - .get(&(dc_id, class)) - .is_some_and(|state| now >= state.until); - if expired && class != TransferClass::Download { - self.slow_until.remove(&(dc_id, class)); - return fallback; - } - self.slow_until - .get(&(dc_id, class)) - .map(|state| DownloadWindow { - chunk: state.window.chunk.min(fallback.chunk), - in_flight: state.window.in_flight.min(fallback.in_flight), - }) - .unwrap_or(fallback) - } - - pub(crate) fn recovering_download(&self, dc_id: i32) -> bool { - self.slow_until - .contains_key(&(dc_id, TransferClass::Download)) - } - - pub(crate) fn download_succeeded(&mut self, dc_id: i32, now: std::time::Instant) { - let Some(state) = self.slow_until.get_mut(&(dc_id, TransferClass::Download)) else { - return; - }; - if now < state.until { - return; - } - state.successes = state.successes.saturating_add(1); - if state.successes >= 32 && now >= state.recover_after && state.window.in_flight < 4 { - state.window.in_flight += 1; - state.successes = 0; - state.recover_after = now + std::time::Duration::from_secs(30); - } - } - - pub(crate) fn pending_error( - &self, - dc_id: i32, - class: TransferClass, - now: std::time::Instant, - ) -> Option { - let state = self.slow_until.get(&(dc_id, class))?; - let remaining = state.until.checked_duration_since(now)?; - if remaining.is_zero() { - return None; - } - let seconds = remaining - .as_secs() - .saturating_add(u64::from(remaining.subsec_nanos() > 0)); - Some(MtprotoError::Message(format!( - "RPC 420: {}_{seconds}", - if state.premium { - "FLOOD_PREMIUM_WAIT" - } else { - "FLOOD_WAIT" - } - ))) - } -} - -pub(crate) fn download_flood_active(dc_id: i32) -> bool { - crate::transfer_policy::current() - .floods - .lock() - .recovering_download(dc_id) -} - -pub(crate) fn note_transfer_flood(dc_id: i32, class: TransferClass, err: &MtprotoError) { - let Some(seconds) = flood_wait_secs(err) else { - return; - }; - let premium = - matches!(err, MtprotoError::Message(message) if message.contains("FLOOD_PREMIUM_WAIT_")); - crate::transfer_policy::current().floods.lock().park( - dc_id, - class, - seconds, - premium, - std::time::Instant::now(), - bound_download_window().unwrap_or_else(|| download_window(DownloadProfile::Ordinary)), - ); -} - -pub(crate) fn check_transfer_flood(dc_id: i32, class: TransferClass) -> Result<(), MtprotoError> { - if let Some(error) = crate::transfer_policy::current() - .floods - .lock() - .pending_error(dc_id, class, std::time::Instant::now()) - { - return Err(error); - } - Ok(()) -} - -fn is_rpc_timeout(err: &MtprotoError) -> bool { - match err { - MtprotoError::Message(message) => message.contains("RPC timeout"), - _ => false, - } -} - -/// Keep a partial staging file for another attempt. -pub(crate) fn keep_partial(err: &MtprotoError) -> bool { - let MtprotoError::Message(message) = err else { - return false; - }; - let upper = message.to_ascii_uppercase(); - if upper.contains("CANCEL") { - return false; - } - flood_wait_secs(err).is_some() - || upper.contains("TIMEOUT") - || upper.contains("FILE_REFERENCE") - || upper.contains("DOWNLOAD INCOMPLETE") - || upper.contains("OPTIONAL_PAUSED") - || upper.contains("AUTH_KEY_UNREGISTERED") - || upper.contains("AUTH_KEY_INVALID") - || upper.contains("AUTH_KEY_DUPLICATED") - || upper.contains("NETWORK") - || upper.contains("CONNECTION") - || upper.contains("TRANSPORT") -} - -/// Android `cancel(false)`: stop the transfer and leave the partial bytes. -pub(crate) fn stopped_download(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if message.to_ascii_uppercase().contains("CANCEL")) -} - -fn first_missing_offset(counted: &HashSet, start: i64, end: i64, chunk: i32) -> i64 { - let step = i64::from(chunk.max(1)); - let mut off = start; - while off < end { - if !counted.contains(&off) { - return off; - } - off = off.saturating_add(step); - } - end -} - -pub fn download_media( - snapshot: &mut Snapshot, - api_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, -) -> Result { - download_media_with_fetch( - snapshot.dc_id, - media, - dest_path, - cancellation_path, - |request| api_invoke::invoke_api_without_updates(snapshot, api_id, request), - ) -} - -pub(crate) fn download_media_with_fetch( - dc_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, - fetch: impl FnMut(UploadGetFileRequest) -> Result, -) -> Result { - download_media_range_with_fetch(dc_id, media, dest_path, cancellation_path, None, fetch) -} - -pub(crate) fn download_media_range_with_fetch( - dc_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, - offset: Option, - mut fetch: impl FnMut(UploadGetFileRequest) -> Result, -) -> Result { - let _chunk_guard = DownloadWindowGuard { - previous_profile: BOUND_WINDOW.with(|cell| cell.get()), - previous_chunk: FORCED_CHUNK.with(|cell| cell.get()), - }; - let chunk = resolve_chunk(offset); - if offset.is_some_and(|value| value < 0 || value % i64::from(chunk) != 0) { - return Err(MtprotoError::Message("invalid media range offset".into())); - } - if download_cancelled(cancellation_path) { - return Err(MtprotoError::Message("cancelled".into())); - } - if let MediaLocation::Inline { bytes, .. } = &media.location { - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).map_err(|e| MtprotoError::Message(e.to_string()))?; - } - let bytes = if let Some(offset) = offset { - let start = usize::try_from(offset) - .unwrap_or(usize::MAX) - .min(bytes.len()); - &bytes[start..start.saturating_add(chunk as usize).min(bytes.len())] - } else { - bytes.as_slice() - }; - fs::write(dest_path, bytes).map_err(|e| MtprotoError::Message(e.to_string()))?; - return Ok(dest_path.display().to_string()); - } - - if let Some(dc) = media_dc(&media.location) { - if dc != 0 && dc != dc_id { - // File DC only — caller copies authorization onto a media lane. - // Do not wipe the snapshot auth key here. - return Err(MtprotoError::Message(format!("FILE_MIGRATE_{dc}"))); - } - } - - let location = input_location(&media.location)?; - write_file_or_cleanup(dest_path, |out| { - let range = offset.is_some(); - let mut offset = offset.unwrap_or(0); - loop { - if download_cancelled(cancellation_path) { - return Err(MtprotoError::Message("cancelled".into())); - } - let (flags, cdn_supported) = cdn_fields_for_seek(if range { offset } else { 0 }); - let request = UploadGetFileRequest { - flags, - precise: None, - cdn_supported, - location: Box::new(location.clone()), - offset, - limit: chunk, - }; - let response = crate::perf::span("getfile_part").with(|| fetch(request))?; - match response { - UploadFile::UploadFile(file) => { - let n = file.bytes.len(); - if n > chunk as usize { - return Err(MtprotoError::Message("oversized media part".into())); - } - out.write_all(&file.bytes) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - offset += n as i64; - notify_progress(&cancellation_path.display().to_string(), offset, 0); - if range || n < chunk as usize { - break; - } - } - UploadFile::UploadFileCdnRedirect(redirect) => { - super::cdn::store_cdn_redirect(&redirect, offset); - return Err(super::cdn::cdn_redirect_error()); - } - _ => { - return Err(MtprotoError::Message("unexpected upload.file".into())); - } - } - } - Ok(()) - }) -} - -pub(crate) fn cancel_marker(dest_path: &Path) -> PathBuf { - let mut raw = dest_path.as_os_str().to_os_string(); - raw.push(".cancel"); - PathBuf::from(raw) -} - -pub(crate) fn download_cancelled(dest_path: &Path) -> bool { - cancel_marker(dest_path).exists() -} - -/// Byte length that is a whole number of download chunks. A short tail is not resumable. -pub(crate) fn aligned_prefix(len: i64, chunk: i32) -> i64 { - if len <= 0 || chunk <= 0 { - return 0; - } - let step = i64::from(chunk); - len - len % step -} - -/// `cancel()` calls `cancel(false)`, so the partial and this range file stay. -/// The sidecar is `.ranges.part`: it ends in `.part`, so -/// `FileCache` treats it as staging, and the `..part` matcher skips it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct MissingRanges { - ranges: Vec<(i64, i64)>, -} - -impl MissingRanges { - pub(crate) fn span(start: i64, end: i64) -> Self { - if end > start && start >= 0 { - Self { - ranges: vec![(start, end)], - } - } else { - Self { ranges: Vec::new() } - } - } - - pub(crate) fn first_start(&self) -> Option { - self.ranges.iter().map(|(start, _)| *start).min() - } - - pub(crate) fn overlaps(&self, start: i64, end: i64) -> bool { - end > start - && self - .ranges - .iter() - .any(|(range_start, range_end)| *range_end > start && *range_start < end) - } - - /// The file ended at `end`. Drop holes at or past that point and clip the rest. - pub(crate) fn clip_to_end(&mut self, end: i64) { - if end < 0 { - self.ranges.clear(); - return; - } - let mut next = Vec::new(); - for (start, range_end) in self.ranges.drain(..) { - if start >= end || range_end <= start { - continue; - } - next.push((start, range_end.min(end))); - } - self.ranges = next; - } - - /// Drop bytes that have been written. `end` is exclusive. - pub(crate) fn cover(&mut self, start: i64, end: i64) { - if end <= start { - return; - } - let mut next = Vec::new(); - for (range_start, range_end) in self.ranges.drain(..) { - if end <= range_start || start >= range_end { - next.push((range_start, range_end)); - continue; - } - if start > range_start { - next.push((range_start, start)); - } - if end < range_end { - next.push((end, range_end)); - } - } - self.ranges = next; - } - - pub(crate) fn load(bytes: &[u8]) -> Option { - if bytes.len() < 4 { - return None; - } - let count = i32::from_be_bytes(bytes[0..4].try_into().ok()?); - if count < 0 { - return None; - } - let count = count as usize; - let body = count.checked_mul(16)?; - let total = body.checked_add(4)?; - if bytes.len() != total { - return None; - } - let mut ranges = Vec::with_capacity(count); - for index in 0..count { - let offset = 4 + index * 16; - let start = i64::from_be_bytes(bytes[offset..offset + 8].try_into().ok()?); - let end = i64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().ok()?); - if start < 0 || end < start { - return None; - } - if end > start { - ranges.push((start, end)); - } - } - Some(Self { ranges }) - } - - pub(crate) fn to_bytes(&self) -> Option> { - let count = i32::try_from(self.ranges.len()).ok()?; - let mut bytes = Vec::with_capacity(4 + self.ranges.len() * 16); - bytes.extend_from_slice(&count.to_be_bytes()); - for (start, end) in &self.ranges { - bytes.extend_from_slice(&start.to_be_bytes()); - bytes.extend_from_slice(&end.to_be_bytes()); - } - Some(bytes) - } -} - -pub(crate) fn ranges_path(staging: &Path) -> PathBuf { - let text = staging.to_string_lossy(); - if let Some(stripped) = text.strip_suffix(".part") { - PathBuf::from(format!("{stripped}.ranges.part")) - } else { - PathBuf::from(format!("{text}.ranges.part")) - } -} - -fn load_ranges(staging: &Path) -> Option { - let bytes = fs::read(ranges_path(staging)).ok()?; - MissingRanges::load(&bytes) -} - -fn store_ranges(staging: &Path, ranges: &MissingRanges) { - let Some(bytes) = ranges.to_bytes() else { - return; - }; - let _ = fs::write(ranges_path(staging), bytes); -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct RangeIdentity { - pub file_id: i64, - pub access_hash: i64, - pub dc_id: i32, - pub size: i64, - pub version: i32, -} - -impl RangeIdentity { - fn to_bytes(&self) -> [u8; 32] { - let mut bytes = [0u8; 32]; - bytes[0..8].copy_from_slice(&self.file_id.to_le_bytes()); - bytes[8..16].copy_from_slice(&self.access_hash.to_le_bytes()); - bytes[16..20].copy_from_slice(&self.dc_id.to_le_bytes()); - bytes[20..28].copy_from_slice(&self.size.to_le_bytes()); - bytes[28..32].copy_from_slice(&self.version.to_le_bytes()); - bytes - } - - fn from_bytes(bytes: &[u8]) -> Option { - if bytes.len() != 32 { - return None; - } - Some(Self { - file_id: i64::from_le_bytes(bytes[0..8].try_into().ok()?), - access_hash: i64::from_le_bytes(bytes[8..16].try_into().ok()?), - dc_id: i32::from_le_bytes(bytes[16..20].try_into().ok()?), - size: i64::from_le_bytes(bytes[20..28].try_into().ok()?), - version: i32::from_le_bytes(bytes[28..32].try_into().ok()?), - }) - } -} - -pub(crate) fn location_identity( - location: &crate::media::MediaLocation, - size: i64, - dc_fallback: i32, -) -> Option { - let (file_id, access_hash, dc_id) = match location { - crate::media::MediaLocation::Document { - id, - access_hash, - dc_id, - .. - } - | crate::media::MediaLocation::Photo { - id, - access_hash, - dc_id, - .. - } => (*id, *access_hash, *dc_id), - crate::media::MediaLocation::PeerPhoto { - photo_id, - access_hash, - dc_id, - .. - } => (*photo_id, *access_hash, *dc_id), - crate::media::MediaLocation::Inline { .. } => return None, - }; - Some(RangeIdentity { - file_id, - access_hash, - dc_id: if dc_id == 0 { dc_fallback } else { dc_id }, - size, - version: 0, - }) -} - -pub(crate) fn resume_allowed(stored: Option<&RangeIdentity>, current: &RangeIdentity) -> bool { - stored.is_some_and(|stored| stored == current) -} - -fn identity_path(staging: &Path) -> PathBuf { - let text = staging.to_string_lossy(); - if let Some(stripped) = text.strip_suffix(".part") { - PathBuf::from(format!("{stripped}.identity.part")) - } else { - PathBuf::from(format!("{text}.identity.part")) - } -} - -fn load_identity(staging: &Path) -> Option { - RangeIdentity::from_bytes(&fs::read(identity_path(staging)).ok()?) -} - -fn store_identity(staging: &Path, identity: &RangeIdentity) { - let _ = fs::write(identity_path(staging), identity.to_bytes()); -} - -pub(crate) fn dedup_offsets(offsets: &[i64], chunk: i64) -> Vec { - if chunk <= 0 { - return Vec::new(); - } - let mut ordered: Vec = offsets.iter().copied().filter(|off| *off >= 0).collect(); - ordered.sort_unstable(); - ordered.dedup(); - let mut kept = Vec::new(); - for offset in ordered { - if kept - .iter() - .any(|previous: &i64| offset < previous + chunk && offset >= *previous) - { - continue; - } - kept.push(offset); - } - kept -} - -static INFLIGHT_RANGES: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); - -pub(crate) fn claim_offsets(file_id: i64, offsets: &[i64]) -> Vec { - let mut held = INFLIGHT_RANGES - .lock() - .unwrap_or_else(|error| error.into_inner()); - offsets - .iter() - .copied() - .filter(|offset| held.insert((file_id, *offset))) - .collect() -} - -pub(crate) fn release_offsets(file_id: i64, offsets: &[i64]) { - let mut held = INFLIGHT_RANGES - .lock() - .unwrap_or_else(|error| error.into_inner()); - for offset in offsets { - held.remove(&(file_id, *offset)); - } -} - -struct OffsetClaim(Option<(i64, Vec)>); - -impl Drop for OffsetClaim { - fn drop(&mut self) { - if let Some((file_id, offsets)) = self.0.take() { - release_offsets(file_id, &offsets); - } - } -} - -pub(crate) fn should_pause_optional(profile: Option) -> bool { - matches!(profile, Some(DownloadProfile::Background)) - && crate::client::lanes::visible_transfer_waiters() > 0 -} - -pub(crate) fn optional_paused(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if message.contains("OPTIONAL_PAUSED")) -} - -/// Where a full-file retry should request next. A range file wins. A missing or -/// corrupt file falls back to the chunk-aligned length. Neither path panics. -pub(crate) fn resume_start(ranges: Option<&MissingRanges>, file_len: i64, chunk: i32) -> i64 { - if let Some(start) = ranges.and_then(MissingRanges::first_start) { - return aligned_prefix(start, chunk); - } - aligned_prefix(file_len.max(0), chunk) -} - -/// Final length after a full-file attempt. A remaining hole is `Err` so the -/// partial stays. Success uses the known size, never the first-hole cursor, -/// so a tail past the hole is not truncated. -pub(crate) fn preserved_end( - file_len: i64, - written_end: i64, - expected: Option, - missing: Option<&MissingRanges>, -) -> Result { - let known = file_len.max(written_end).max(0); - if let Some(expected) = expected.filter(|size| *size > 0) { - if missing.is_some_and(|ranges| ranges.overlaps(0, expected)) { - return Err(()); - } - return Ok(expected); - } - if missing.is_some_and(|ranges| ranges.overlaps(0, known.max(1))) { - return Err(()); - } - Ok(known) -} - -fn request_offsets(ranges: &MissingRanges, chunk: i64, limit: usize) -> Vec { - if chunk <= 0 || limit == 0 { - return Vec::new(); - } - let mut offsets = Vec::new(); - let mut ordered = ranges.ranges.clone(); - ordered.sort_by_key(|(start, _)| *start); - for (start, end) in ordered { - if end <= start { - continue; - } - let mut offset = start - start % chunk; - if offset < 0 { - offset = 0; - } - while offset < end && offsets.len() < limit { - if offset + chunk > start && !offsets.contains(&offset) { - offsets.push(offset); - } - offset = offset.saturating_add(chunk); - } - if offsets.len() >= limit { - break; - } - } - offsets -} - -pub(crate) fn is_native_staging_name(name: &str, destination_name: &str) -> bool { - let Some(body) = name - .strip_prefix(destination_name) - .and_then(|rest| rest.strip_prefix('.')) - .and_then(|rest| rest.strip_suffix(".part")) - else { - return false; - }; - let Some((pid, sequence)) = body.split_once('.') else { - return false; - }; - !pid.is_empty() - && !sequence.is_empty() - && pid.bytes().all(|byte| byte.is_ascii_digit()) - && sequence.bytes().all(|byte| byte.is_ascii_digit()) -} - -const STAGING_DIR_BYTE_CAP: u64 = 32 * 1024 * 1024; -const STAGING_STALE_SECS: u64 = 120; - -fn remove_staging_sidecars(staging: &Path) { - let _ = fs::remove_file(ranges_path(staging)); - let _ = fs::remove_file(identity_path(staging)); -} - -pub(crate) fn sweep_orphan_sidecars(dir: &Path, protect: &Path) { - let protect_identity = identity_path(protect); - let protect_ranges = ranges_path(protect); - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path == protect || path == protect_identity || path == protect_ranges { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - let stem = name - .strip_suffix(".identity.part") - .or_else(|| name.strip_suffix(".ranges.part")); - let Some(stem) = stem else { - continue; - }; - if !dir.join(format!("{stem}.part")).exists() { - let _ = fs::remove_file(&path); - } - } -} - -pub(crate) fn sweep_stale_parts(dir: &Path, protect: &Path) { - sweep_orphan_sidecars(dir, protect); - let Some(cutoff) = std::time::SystemTime::now() - .checked_sub(std::time::Duration::from_secs(STAGING_STALE_SECS)) - else { - return; - }; - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - let mut stale = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path == protect { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !name.ends_with(".part") { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - let Ok(modified) = meta.modified() else { - continue; - }; - if modified > cutoff { - continue; - } - stale.push((modified, meta.len(), path)); - } - let mut total: u64 = stale.iter().map(|(_, len, _)| *len).sum(); - if total <= STAGING_DIR_BYTE_CAP { - return; - } - stale.sort_by_key(|(modified, _, _)| *modified); - for (_, len, path) in stale { - if total <= STAGING_DIR_BYTE_CAP { - break; - } - if fs::remove_file(&path).is_ok() { - total = total.saturating_sub(len); - remove_staging_sidecars(&path); - } - } -} - -fn native_staging_files(destination: &Path) -> Vec { - let Some(name) = destination.file_name().and_then(|name| name.to_str()) else { - return Vec::new(); - }; - let parent = destination - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let Ok(entries) = fs::read_dir(parent) else { - return Vec::new(); - }; - entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|file_name| file_name.to_str()) - .is_some_and(|file_name| is_native_staging_name(file_name, name)) - }) - .collect() -} - -fn take_resume_file(destination: &Path) -> Option { - let mut files = native_staging_files(destination); - if files.is_empty() { - return None; - } - files.sort_by_key(|path| fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)); - let best = files.pop()?; - for extra in files { - let _ = fs::remove_file(extra); - } - if fs::metadata(&best).map(|meta| meta.len()).unwrap_or(0) == 0 { - let _ = fs::remove_file(&best); - return None; - } - Some(best) -} - -struct DestOwner { - thread: Option, - depth: usize, -} - -struct DestEntry { - state: Mutex, - cv: Condvar, -} - -/// One writer per destination. The same thread may lock again when a home-DC -/// download takes over a lane that already staged the same path. -fn destination_locks() -> &'static Mutex>> { - static LOCKS: std::sync::OnceLock>>> = - std::sync::OnceLock::new(); - LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) -} - -pub(crate) struct DestinationLock { - path: PathBuf, - entry: Arc, -} - -impl DestinationLock { - pub(crate) fn acquire(path: &Path) -> Self { - let entry = { - let mut map = destination_locks() - .lock() - .unwrap_or_else(|error| error.into_inner()); - map.entry(path.to_path_buf()) - .or_insert_with(|| { - Arc::new(DestEntry { - state: Mutex::new(DestOwner { - thread: None, - depth: 0, - }), - cv: Condvar::new(), - }) - }) - .clone() - }; - let id = std::thread::current().id(); - let mut owner = entry - .state - .lock() - .unwrap_or_else(|error| error.into_inner()); - loop { - if owner.thread == Some(id) { - owner.depth += 1; - break; - } - if owner.thread.is_none() { - owner.thread = Some(id); - owner.depth = 1; - break; - } - owner = entry - .cv - .wait(owner) - .unwrap_or_else(|error| error.into_inner()); - } - drop(owner); - Self { - path: path.to_path_buf(), - entry, - } - } -} - -impl Drop for DestinationLock { - fn drop(&mut self) { - let mut owner = self - .entry - .state - .lock() - .unwrap_or_else(|error| error.into_inner()); - owner.depth = owner.depth.saturating_sub(1); - if owner.depth == 0 { - owner.thread = None; - self.entry.cv.notify_one(); - } - let idle = owner.depth == 0; - drop(owner); - if !idle { - return; - } - let mut map = destination_locks() - .lock() - .unwrap_or_else(|error| error.into_inner()); - if Arc::strong_count(&self.entry) == 2 - && map - .get(&self.path) - .is_some_and(|existing| Arc::ptr_eq(existing, &self.entry)) - { - map.remove(&self.path); - } - } -} - -#[cfg(test)] -pub(crate) fn destination_lock_entries() -> usize { - destination_locks().lock().map(|map| map.len()).unwrap_or(0) -} - -#[cfg(test)] -#[path = "../../tests/unit/destination_lock_tests.rs"] -mod destination_lock_tests; - -#[cfg(test)] -#[path = "../../tests/unit/staging_sweep_tests.rs"] -mod staging_sweep_tests; - -/// The caller publishes under its session lease. A dropped download removes its -/// staging file unless [StagedDownload::keep] preserved a retryable partial. -pub(crate) struct StagedDownload { - path: PathBuf, - preserve: bool, - _lock: DestinationLock, -} - -impl StagedDownload { - pub(crate) fn new(destination: &Path) -> Result { - Self::open(destination, false) - } - - /// Reopen `...part` when a full-file download is retried. - pub(crate) fn resume_or_create(destination: &Path) -> Result { - Self::open(destination, true) - } - - fn open(destination: &Path, resume: bool) -> Result { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT: AtomicU64 = AtomicU64::new(1); - let lock = DestinationLock::acquire(destination); - if let Some(parent) = destination - .parent() - .filter(|path| !path.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|e| MtprotoError::Message(e.to_string()))?; - } - if download_cancelled(destination) { - return Err(MtprotoError::Message("cancelled".into())); - } - if resume { - if let Some(path) = take_resume_file(destination) { - return Ok(Self { - path, - preserve: false, - _lock: lock, - }); - } - } - loop { - let mut name = destination.as_os_str().to_os_string(); - name.push(format!( - ".{}.{}.part", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - let path = PathBuf::from(name); - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(_) => { - return Ok(Self { - path, - preserve: false, - _lock: lock, - }); - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - } - } - - pub(crate) fn path(&self) -> &Path { - &self.path - } - - /// Leave the staging file on disk. Used for retryable failures and process death. - pub(crate) fn keep(mut self) { - self.preserve = true; - if let Some(parent) = self.path.parent() { - sweep_stale_parts(parent, &self.path); - } - } - - pub(crate) fn publish(mut self, destination: &Path) -> Result { - if download_cancelled(destination) { - self.preserve = true; - return Err(MtprotoError::Message("cancelled".into())); - } - remove_staging_sidecars(&self.path); - fs::rename(&self.path, destination).map_err(|e| MtprotoError::Message(e.to_string()))?; - if let Some(parent) = destination.parent() { - sweep_orphan_sidecars(parent, destination); - } - Ok(destination.display().to_string()) - } -} - -impl Drop for StagedDownload { - fn drop(&mut self) { - if !self.preserve { - let _ = fs::remove_file(&self.path); - remove_staging_sidecars(&self.path); - } - } -} - -/// Requests `parts_in_flight` parts on one session and refills the window as each -/// part returns. EOF is a short or empty part. -pub(crate) fn download_media_range_batched( - dc_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, - offset: Option, - parts_in_flight: usize, - mut fetch_batch: impl FnMut( - bool, - Vec, - ) -> Result>, MtprotoError>, -) -> Result { - download_media_range_batched_streaming( - dc_id, - media, - dest_path, - cancellation_path, - offset, - parts_in_flight, - |first, requests, on_chunk| { - let results = fetch_batch(first, requests)?; - for (index, result) in results.iter().enumerate() { - match result { - Ok(file) => { - let _ = on_chunk(index, Ok(file.clone())); - } - Err(err) => { - let _ = on_chunk(index, Err(err.clone())); - } - } - } - Ok(results) - }, - ) -} - -pub(crate) fn download_media_range_batched_streaming( - dc_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, - offset: Option, - parts_in_flight: usize, - fetch_batch: impl FnMut( - bool, - Vec, - &mut dyn FnMut(usize, Result) -> Option, - ) -> Result>, MtprotoError>, -) -> Result { - download_media_range_batched_streaming_capped( - dc_id, - media, - dest_path, - cancellation_path, - offset, - parts_in_flight, - None, - fetch_batch, - ) -} - -/// `refill_limit` caps sliding refills per `fetch_batch` so the caller can drop -/// a home-DC main-session lock between windows. `None` keeps the window full -/// until EOF (media-DC file sessions). -pub(crate) fn download_media_range_batched_streaming_capped( - dc_id: i32, - media: &MediaRef, - dest_path: &Path, - cancellation_path: &Path, - offset: Option, - parts_in_flight: usize, - refill_limit: Option, - mut fetch_batch: impl FnMut( - bool, - Vec, - &mut dyn FnMut(usize, Result) -> Option, - ) -> Result>, MtprotoError>, -) -> Result { - let _chunk_guard = DownloadWindowGuard { - previous_profile: BOUND_WINDOW.with(|cell| cell.get()), - previous_chunk: FORCED_CHUNK.with(|cell| cell.get()), - }; - let chunk = resolve_chunk(offset); - if offset.is_some_and(|value| value < 0 || value % i64::from(chunk) != 0) { - return Err(MtprotoError::Message("invalid media range offset".into())); - } - if download_cancelled(cancellation_path) { - return Err(MtprotoError::Message("cancelled".into())); - } - if let MediaLocation::Inline { .. } = &media.location { - return download_media_range_with_fetch( - dc_id, - media, - dest_path, - cancellation_path, - offset, - |request| { - let mut results = fetch_batch(false, vec![request], &mut |_, _| None)?; - results - .pop() - .unwrap_or_else(|| Err(MtprotoError::Message("missing media part".into()))) - }, - ); - } - if let Some(dc) = media_dc(&media.location) { - if dc != 0 && dc != dc_id { - return Err(MtprotoError::Message(format!("FILE_MIGRATE_{dc}"))); - } - } - - let location = input_location(&media.location)?; - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).map_err(|e| MtprotoError::Message(e.to_string()))?; - } - let result = (|| -> Result<(), MtprotoError> { - let stream = offset.is_some(); - let mut file_len = fs::metadata(dest_path) - .map(|meta| meta.len() as i64) - .unwrap_or(0); - let identity = location_identity(&media.location, media.file_size.unwrap_or(0), dc_id); - let mut loaded_ranges = if stream { None } else { load_ranges(dest_path) }; - if !stream { - if let Some(identity) = identity.clone() { - if file_len > 0 && !resume_allowed(load_identity(dest_path).as_ref(), &identity) { - loaded_ranges = None; - file_len = 0; - let _ = fs::remove_file(ranges_path(dest_path)); - } - store_identity(dest_path, &identity); - } - } - let chunk_now = resolve_chunk(offset); - let origin = if stream { - offset.unwrap_or(0) - } else { - resume_start(loaded_ranges.as_ref(), file_len, chunk_now) - }; - let mut missing = if stream { - None - } else if let Some(ranges) = loaded_ranges.clone() { - Some(ranges) - } else { - let end = media.file_size.filter(|size| *size > 0).unwrap_or(i64::MAX); - Some(MissingRanges::span(origin, end)) - }; - if !stream { - if let Some(ranges) = missing.as_ref() { - store_ranges(dest_path, ranges); - } - } - let mut out = if !stream && file_len > 0 { - use std::io::{Seek, SeekFrom}; - let mut file = fs::OpenOptions::new() - .read(true) - .write(true) - .open(dest_path) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if loaded_ranges.is_none() && origin < file_len { - file.set_len(origin as u64) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - file.seek(SeekFrom::Start(origin as u64)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - file - } else { - fs::File::create(dest_path).map_err(|e| MtprotoError::Message(e.to_string()))? - }; - let window = crate::transfer_policy::current().floods.lock().window( - dc_id, - TransferClass::Download, - std::time::Instant::now(), - DownloadWindow { - chunk: resolve_chunk(if origin > 0 { Some(origin) } else { offset }), - in_flight: parts_in_flight.max(1), - }, - ); - FORCED_CHUNK.with(|cell| cell.set(window.chunk)); - let mut width = window.in_flight; - let mut next = origin; - let mut retried_limit = false; - let mut timeout_retries = 0u8; - let mut written_end = origin; - let mut downloaded_bytes = origin; - let already_complete = !stream - && media.file_size.is_some_and(|expected| { - expected > 0 - && missing - .as_ref() - .is_none_or(|ranges| !ranges.overlaps(0, expected)) - }); - if already_complete { - let expected = media.file_size.unwrap_or(origin); - out.set_len(expected as u64) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - written_end = expected; - downloaded_bytes = expected; - } else if origin > 0 { - notify_progress( - &cancellation_path.display().to_string(), - origin, - media.file_size.unwrap_or(0), - ); - } - let mut counted_offsets = HashSet::new(); - let mut first_batch = true; - let target_str = cancellation_path.display().to_string(); - 'download: loop { - if already_complete { - break 'download; - } - width = crate::transfer_policy::current() - .floods - .lock() - .window( - dc_id, - TransferClass::Download, - std::time::Instant::now(), - DownloadWindow { - chunk: resolve_chunk(offset), - in_flight: parts_in_flight.max(1), - }, - ) - .in_flight - .min(if retried_limit { - width - } else { - parts_in_flight.max(1) - }); - let chunk = resolve_chunk(Some(next).filter(|_| next > 0).or(offset)); - let batch = if stream { - bound_download_window() - .map(|window| window.in_flight) - .unwrap_or(STREAM_WINDOW_CHUNKS) - .min(width.max(1)) - .min((FAST_CHUNK / chunk).max(1) as usize) - } else { - width - }; - loop { - if download_cancelled(cancellation_path) { - return Err(MtprotoError::Message("cancelled".into())); - } - if should_pause_optional(request_profile()) { - return Err(MtprotoError::Message("OPTIONAL_PAUSED".into())); - } - let mut offsets = if let Some(ranges) = missing.as_ref().filter(|_| !stream) { - let planned = request_offsets(ranges, i64::from(chunk.max(1)), batch); - if planned.is_empty() { - break 'download; - } - next = planned[0]; - planned - } else { - let mut sequential = Vec::with_capacity(batch); - for step in 0..batch { - sequential.push(next + (step as i64) * i64::from(chunk)); - } - sequential - }; - if stream { - let seek = offset.unwrap_or(next); - let cancelled = cancel_outside_seek_window(seek, chunk, batch, &offsets); - if !cancelled.is_empty() { - offsets.retain(|off| !cancelled.contains(off)); - } - } - offsets = dedup_offsets(&offsets, i64::from(chunk.max(1))); - if let Some(identity) = identity.clone() { - offsets = claim_offsets(identity.file_id, &offsets); - } - if offsets.is_empty() { - std::thread::sleep(std::time::Duration::from_millis(5)); - continue; - } - let _release_claim = OffsetClaim( - identity - .clone() - .map(|identity| (identity.file_id, offsets.clone())), - ); - let (flags, cdn_supported) = cdn_fields_for_seek(origin); - let requests: Vec = offsets - .iter() - .map(|offset| UploadGetFileRequest { - flags, - precise: None, - cdn_supported: cdn_supported.clone(), - location: Box::new(location.clone()), - offset: *offset, - limit: chunk, - }) - .collect(); - let mut written_parts = vec![false; offsets.len()]; - let mut short_or_empty = vec![false; offsets.len()]; - let mut last_part = false; - let mut stop_refill = false; - - let mut offset_by_index = offsets.clone(); - let mut next_to_request = next + (batch as i64) * i64::from(chunk); - let mut refills_used = 0usize; - let responses = { - let mut chunk_cb = |index: usize, - res: Result| - -> Option { - if download_cancelled(cancellation_path) { - return None; - } - let part_offset = *offset_by_index.get(index)?; - if !matches!(&res, Ok(UploadFile::UploadFile(_))) { - stop_refill = true; - } - let mut hit_short = false; - if let Ok(UploadFile::UploadFile(file)) = &res { - let bytes = &file.bytes; - if !bytes.is_empty() && bytes.len() <= chunk as usize { - use std::io::{Seek, SeekFrom, Write}; - if out - .seek(SeekFrom::Start( - (if stream { - part_offset - origin - } else { - part_offset - }) as u64, - )) - .is_ok() - && out.write_all(bytes).is_ok() - { - written_end = written_end.max(part_offset + bytes.len() as i64); - if let Some(ranges) = missing.as_mut() { - ranges.cover(part_offset, part_offset + bytes.len() as i64); - store_ranges(dest_path, ranges); - } - if counted_offsets.insert(part_offset) { - downloaded_bytes += bytes.len() as i64; - crate::transfer_policy::current() - .floods - .lock() - .download_succeeded(dc_id, std::time::Instant::now()); - if crate::transfer_policy::current() - .floods - .lock() - .window( - dc_id, - TransferClass::Download, - std::time::Instant::now(), - DownloadWindow { - chunk, - in_flight: parts_in_flight.max(1), - }, - ) - .in_flight - != width - { - stop_refill = true; - } - notify_progress(&target_str, downloaded_bytes, 0); - } - if index < written_parts.len() { - written_parts[index] = true; - } - } - } - if bytes.len() < chunk as usize { - hit_short = true; - last_part = true; - if index < short_or_empty.len() { - short_or_empty[index] = true; - } - } - } - if stream || hit_short || last_part || stop_refill { - return None; - } - if refill_limit.is_some_and(|limit| refills_used >= limit) { - return None; - } - while counted_offsets.contains(&next_to_request) { - next_to_request += i64::from(chunk); - } - refills_used += 1; - let off = next_to_request; - next_to_request += i64::from(chunk); - offset_by_index.push(off); - crate::perf::count("media.pipeline.refill"); - Some(UploadGetFileRequest { - flags, - precise: None, - cdn_supported: cdn_supported.clone(), - location: Box::new(location.clone()), - offset: off, - limit: chunk, - }) - }; - match fetch_batch(first_batch, requests, &mut chunk_cb) { - Err(err) - if !retried_limit - && is_limit_invalid(&err) - && limit_invalid_window(DownloadWindow { - chunk, - in_flight: width, - }) - .is_some() => - { - let next = limit_invalid_window(DownloadWindow { - chunk, - in_flight: width, - }) - .expect("window"); - FORCED_CHUNK.with(|cell| cell.set(next.chunk)); - width = next.in_flight.max(1); - retried_limit = true; - continue 'download; - } - Err(err) if flood_wait_secs(&err).is_some() => { - note_transfer_flood(dc_id, TransferClass::Download, &err); - return Err(err); - } - Err(err) if timeout_retries < 3 && is_rpc_timeout(&err) => { - timeout_retries += 1; - next = first_missing_offset( - &counted_offsets, - origin, - next_to_request, - chunk, - ); - continue 'download; - } - other => other?, - } - }; - - first_batch = false; - timeout_retries = 0; - for (index, response) in responses.into_iter().enumerate() { - if download_cancelled(cancellation_path) { - return Err(MtprotoError::Message("cancelled".into())); - } - if last_part { - // Reached EOF at an earlier part; ignore any trailing parts/errors past EOF. - break; - } - if index >= written_parts.len() { - continue; - } - if written_parts[index] { - if short_or_empty[index] { - last_part = true; - } - continue; - } - let part_offset = match offsets.get(index) { - Some(value) => *value, - None => continue, - }; - match response { - Ok(UploadFile::UploadFile(file)) => { - let bytes = file.bytes; - if bytes.len() > chunk as usize { - return Err(MtprotoError::Message("oversized media part".into())); - } - if bytes.is_empty() { - last_part = true; - break; - } - use std::io::{Seek, SeekFrom, Write}; - out.seek(SeekFrom::Start( - (if stream { - part_offset - origin - } else { - part_offset - }) as u64, - )) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - out.write_all(&bytes) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - written_end = written_end.max(part_offset + bytes.len() as i64); - if counted_offsets.insert(part_offset) { - downloaded_bytes += bytes.len() as i64; - notify_progress(&target_str, downloaded_bytes, 0); - } - if bytes.len() < chunk as usize { - last_part = true; - } - } - Ok(UploadFile::UploadFileCdnRedirect(redirect)) => { - super::cdn::store_cdn_redirect(&redirect, part_offset); - return Err(super::cdn::cdn_redirect_error()); - } - Ok(_) => { - return Err(MtprotoError::Message("unexpected media response".into())); - } - Err(err) => { - if !retried_limit && is_limit_invalid(&err) { - if let Some(next) = limit_invalid_window(DownloadWindow { - chunk, - in_flight: width, - }) { - FORCED_CHUNK.with(|cell| cell.set(next.chunk)); - width = next.in_flight.max(1); - retried_limit = true; - continue 'download; - } - } - if flood_wait_secs(&err).is_some() { - note_transfer_flood(dc_id, TransferClass::Download, &err); - return Err(err); - } - if timeout_retries < 3 && is_rpc_timeout(&err) { - timeout_retries += 1; - next = first_missing_offset( - &counted_offsets, - origin, - next_to_request, - chunk, - ); - continue 'download; - } - return Err(err); - } - } - } - if stream { - break 'download; - } - if last_part { - if let Some(ranges) = missing.as_mut() { - ranges.clip_to_end(written_end); - store_ranges(dest_path, ranges); - } - let still_open = missing - .as_ref() - .is_some_and(|ranges| ranges.overlaps(0, written_end.max(1))); - if !still_open { - break 'download; - } - } - // A caller may ignore the refill request returned by `on_chunk`. - // Continue at the first hole instead of the cursor those refills advanced. - next = first_missing_offset(&counted_offsets, origin, next_to_request, chunk); - } - } // 'download - if !stream { - match preserved_end(file_len, written_end, media.file_size, missing.as_ref()) { - Err(()) => { - let expected = media.file_size.unwrap_or(0); - return Err(MtprotoError::Message(format!( - "download incomplete: received {written_end} of {expected} bytes" - ))); - } - Ok(end) => { - out.set_len(end as u64) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - } - } else { - out.set_len((written_end - origin).max(0) as u64) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - out.flush() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(()) - })(); - match result { - Ok(()) => Ok(dest_path.display().to_string()), - Err(err) => { - if !keep_partial(&err) && !stopped_download(&err) { - let _ = fs::remove_file(dest_path); - let _ = fs::remove_file(ranges_path(dest_path)); - } - Err(err) - } - } -} - -pub(crate) fn write_file_or_cleanup(dest_path: &Path, write: F) -> Result -where - F: FnOnce(&mut fs::File) -> Result<(), MtprotoError>, -{ - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).map_err(|e| MtprotoError::Message(e.to_string()))?; - } - let mut file = fs::File::create(dest_path).map_err(|e| MtprotoError::Message(e.to_string()))?; - let written = write(&mut file); - if written.is_ok() { - if let Err(e) = file.flush() { - drop(file); - let _ = fs::remove_file(dest_path); - return Err(MtprotoError::Message(e.to_string())); - } - } - drop(file); - match written { - Ok(()) => Ok(dest_path.display().to_string()), - Err(err) => { - let _ = fs::remove_file(dest_path); - Err(err) - } - } -} - -#[cfg(test)] -#[path = "../../tests/unit/media_download_profile_tests.rs"] -mod profile_tests; diff --git a/native/mtproto-rs/src/media/index.rs b/native/mtproto-rs/src/media/index.rs deleted file mode 100644 index a4356c378..000000000 --- a/native/mtproto-rs/src/media/index.rs +++ /dev/null @@ -1,1060 +0,0 @@ -use tellers_mtproto::latest::api::{ - Document, DocumentAttribute, InputStickerSet, Message, MessageMedia, - MessagesGetCustomEmojiDocumentsRequest, PageBlock, Photo, PhotoSize, RichMessage, Vector, - VectorConstructor, VideoSize, WebPage, -}; -use tellers_mtproto_session::Snapshot; - -use super::location::{MediaIndex, MediaLocation, MediaRef}; -use super::page_plain::photo_id_of; -use super::structured::{ - contact_to_json, dice_to_json, game_to_json, geo_live_to_json, geo_to_json, - giveaway_results_to_json, giveaway_to_json, invoice_to_json, paid_media_to_json, poll_to_json, - story_to_json, todo_title, todo_to_json, venue_to_json, video_stream_to_json, -}; -use super::thumbs::{ - collect_photo_sizes, distinct_thumb_key, pick_display_size, pick_document_thumb_size, - pick_getfile_preview, pick_photo_size, pick_thumb_size, -}; -use crate::MtprotoError; -use crate::api_invoke; -use crate::peers::{peer_chat_id, vector_boxed_items}; - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct MediaMetrics { - pub duration: Option, - pub width: Option, - pub height: Option, - pub file_name: Option, - pub file_size: Option, - pub supports_streaming: bool, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct IndexedMessageMedia { - pub kind: Option, - pub cache_key: Option, - pub thumb_cache_key: Option, - pub duration: Option, - pub width: Option, - pub height: Option, - pub file_name: Option, - pub file_size: Option, - pub supports_streaming: bool, -} - -pub(crate) fn classify_document( - mime_type: &str, - is_sticker: bool, - is_animated: bool, - is_video: bool, -) -> &'static str { - if is_sticker && is_animated { - "sticker_animated" - } else if is_sticker && (is_video || mime_type.contains("webm")) { - "sticker_video" - } else if is_sticker { - "sticker" - } else if !is_sticker && is_animated && mime_type == "video/mp4" { - // GIF documents are stored as silent mp4 with DocumentAttributeAnimated - "gif" - } else if is_video || mime_type.starts_with("video/") { - "video" - } else if mime_type == "image/gif" { - "gif" - } else { - // Image MIME without photo/sticker/GIF/video attrs is a file, not a compressed photo. - "document" - } -} - -pub(crate) fn duration_secs(duration: f64) -> i32 { - duration.max(0.0).round() as i32 -} - -pub(crate) fn document_kind(doc: &tellers_mtproto::latest::api::DocumentConstructor) -> String { - let attrs: Vec<&DocumentAttribute> = vector_boxed_items(&doc.attributes).collect(); - let is_voice = attrs.iter().any( - |a| matches!(a, DocumentAttribute::DocumentAttributeAudio(audio) if audio.voice.is_some()), - ); - if is_voice { - return "voice".into(); - } - 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(); - } - let is_audio = attrs - .iter() - .any(|a| matches!(a, DocumentAttribute::DocumentAttributeAudio(_))); - if is_audio { - return "audio".into(); - } - let is_sticker = attrs - .iter() - .any(|a| matches!(a, DocumentAttribute::DocumentAttributeSticker(_))); - let is_animated = attrs - .iter() - .any(|a| matches!(a, DocumentAttribute::DocumentAttributeAnimated(_))) - || doc.mime_type.contains("tgsticker") - || doc.mime_type.contains("tgs"); - let is_video = attrs - .iter() - .any(|a| matches!(a, DocumentAttribute::DocumentAttributeVideo(_))); - classify_document(&doc.mime_type, is_sticker, is_animated, is_video).into() -} - -pub(crate) fn document_metrics( - doc: &tellers_mtproto::latest::api::DocumentConstructor, -) -> MediaMetrics { - let mut metrics = MediaMetrics::default(); - for attr in vector_boxed_items(&doc.attributes) { - match attr { - DocumentAttribute::DocumentAttributeVideo(v) => { - metrics.duration = Some(duration_secs(v.duration)); - metrics.width = Some(v.w); - metrics.height = Some(v.h); - metrics.supports_streaming = v.supports_streaming.is_some(); - } - DocumentAttribute::DocumentAttributeImageSize(s) if metrics.width.is_none() => { - metrics.width = Some(s.w); - metrics.height = Some(s.h); - } - DocumentAttribute::DocumentAttributeAudio(a) if metrics.duration.is_none() => { - metrics.duration = Some(a.duration); - } - _ => {} - } - } - metrics -} - -pub(crate) fn photo_metrics(sizes: &[PhotoSize]) -> MediaMetrics { - let mut best: Option<(i32, i32, i32)> = None; - for size in sizes { - let Some((_, area, _, w, h)) = super::thumbs::photo_size_type(size) else { - continue; - }; - match best { - None => best = Some((area, w, h)), - Some((best_area, _, _)) if area >= best_area => best = Some((area, w, h)), - _ => {} - } - } - MediaMetrics { - duration: None, - width: best.map(|(_, w, _)| w), - height: best.map(|(_, _, h)| h), - file_name: None, - file_size: None, - supports_streaming: false, - } -} - -pub fn media_ref_from_photo(photo: &Photo, cache_key: String) -> Option { - let Photo::Photo(photo) = photo else { - return None; - }; - if let Some(videos) = photo.video_sizes.as_ref() { - let picked = vector_boxed_items(videos).find_map(|size| match size { - VideoSize::VideoSize(v) if v.type_ == "u" || v.type_ == "v" => Some(v.type_.clone()), - VideoSize::VideoSize(v) => Some(v.type_.clone()), - _ => None, - }); - if let Some(thumb_size) = picked { - let cache_key = if cache_key.ends_with(":video") { - cache_key - } else { - format!("{cache_key}:video") - }; - return Some(MediaRef { - file_size: None, - kind: "video_avatar".into(), - cache_key, - location: MediaLocation::Photo { - id: photo.id, - access_hash: photo.access_hash, - file_reference: photo.file_reference.clone(), - thumb_size, - dc_id: photo.dc_id, - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }); - } - } - let sizes: Vec = vector_boxed_items(&photo.sizes).cloned().collect(); - let (thumb_size, inline) = pick_photo_size(&sizes)?; - let location = if let Some(bytes) = inline { - MediaLocation::Inline { - bytes, - extension: "jpg".into(), - } - } else { - MediaLocation::Photo { - id: photo.id, - access_hash: photo.access_hash, - file_reference: photo.file_reference.clone(), - thumb_size, - dc_id: photo.dc_id, - } - }; - Some(MediaRef { - file_size: None, - kind: "photo".into(), - cache_key, - location, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }) -} - -/// Still-image photo for inline galleries: skip `video_sizes` (those are `u`/`v` -/// streaming letters that fail `upload.getFile`) and attach a small getFile thumb. -pub(crate) fn media_ref_from_photo_with_thumbs( - photo: &Photo, - cache_key: String, -) -> Option { - let Photo::Photo(ph) = photo else { - return None; - }; - let sizes: Vec = vector_boxed_items(&ph.sizes).cloned().collect(); - let (full_ty, full_inline) = pick_photo_size(&sizes)?; - let thumb = pick_document_thumb_size(&sizes); - let location = photo_file_location(ph, full_ty.clone(), full_inline); - let (thumb_cache_key, thumb_location) = match thumb { - Some((ty, inline)) if ty != full_ty => ( - Some(distinct_thumb_key(&cache_key, &full_ty, &ty)), - Some(photo_file_location(ph, ty, inline)), - ), - Some(_) => (Some(cache_key.clone()), None), - None => (None, None), - }; - Some(MediaRef { - file_size: None, - kind: "photo".into(), - cache_key, - location, - thumb_cache_key, - thumb_location, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }) -} - -pub(crate) fn attach_document_thumbs( - doc: &tellers_mtproto::latest::api::DocumentConstructor, - media: &mut MediaRef, -) { - if media.thumb_location.is_some() { - return; - } - let thumbs: Vec = doc - .thumbs - .as_ref() - .map(|t| vector_boxed_items(t).cloned().collect()) - .unwrap_or_default(); - let Some((ty, inline)) = pick_document_thumb_size(&thumbs) else { - return; - }; - media.thumb_cache_key = Some(format!("{}:thumb", media.cache_key)); - media.thumb_location = Some(document_file_location(doc, ty, inline)); -} - -pub fn avatar_cache_key(chat_id: i64, has_video: bool) -> String { - if has_video { - format!("avatar:{chat_id}:video") - } else { - format!("avatar:{chat_id}") - } -} - -pub fn is_video_avatar(media: &MediaRef) -> bool { - media.kind == "video_avatar" -} - -pub fn is_resolved_video_avatar(media: &MediaRef) -> bool { - is_video_avatar(media) && matches!(media.location, MediaLocation::Photo { .. }) -} - -pub(crate) const MESSAGE_MEDIA_CAP: usize = 500; -pub(crate) const ZERO_ID_DOCUMENT_CAP: usize = 200; -pub(crate) const INSTANT_VIEW_CAP: usize = 32; -pub(crate) const PERSISTED_INLINE_BYTE_CAP: usize = 4_096; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum PersistClass { - Avatar, - Message, - ZeroDocument, - Instant, -} - -fn persist_class(key: &(i64, i32), media: &MediaRef) -> PersistClass { - // `(id, 0)` is also stickers, GIFs, inline files, and custom emoji. - if matches!(media.location, MediaLocation::PeerPhoto { .. }) || is_resolved_video_avatar(media) - { - return PersistClass::Avatar; - } - if key.1 == crate::instant_view::INSTANT_VIEW_MEDIA_MSG { - return PersistClass::Instant; - } - if key.1 == 0 { - return PersistClass::ZeroDocument; - } - PersistClass::Message -} - -fn class_cap(class: PersistClass) -> Option { - match class { - PersistClass::Avatar => None, - PersistClass::Message => Some(MESSAGE_MEDIA_CAP), - PersistClass::ZeroDocument => Some(ZERO_ID_DOCUMENT_CAP), - PersistClass::Instant => Some(INSTANT_VIEW_CAP), - } -} - -fn evict_overflow(index: &mut MediaIndex, class: PersistClass) { - let Some(cap) = class_cap(class) else { - return; - }; - let count = index - .iter() - .filter(|(key, media)| persist_class(key, media) == class) - .count(); - if count <= cap { - return; - } - let Some(oldest) = index - .iter() - .find(|(key, media)| persist_class(key, media) == class) - .map(|(key, _)| *key) - else { - return; - }; - index.shift_remove(&oldest); -} - -pub(crate) fn insert_media(index: &mut MediaIndex, key: (i64, i32), media: MediaRef) { - let previous = index - .get(&key) - .map(|existing| persist_class(&key, existing)); - index.insert(key, media); - let Some(stored) = index.get(&key) else { - return; - }; - let class = persist_class(&key, stored); - if let Some(previous) = previous.filter(|previous| *previous != class) { - evict_overflow(index, previous); - } - evict_overflow(index, class); -} - -pub(crate) fn bound_media_index(index: &mut MediaIndex) { - for class in [ - PersistClass::Message, - PersistClass::ZeroDocument, - PersistClass::Instant, - ] { - let Some(cap) = class_cap(class) else { - continue; - }; - let keys: Vec<_> = index - .iter() - .filter(|(key, media)| persist_class(key, media) == class) - .map(|(key, _)| *key) - .collect(); - let overflow = keys.len().saturating_sub(cap); - if overflow == 0 { - continue; - } - let drop_keys: crate::HashSet<(i64, i32)> = keys.into_iter().take(overflow).collect(); - let mut kept = - MediaIndex::with_capacity_and_hasher(index.len() - drop_keys.len(), Default::default()); - for (key, value) in std::mem::take(index) { - if !drop_keys.contains(&key) { - kept.insert(key, value); - } - } - *index = kept; - } -} - -pub(crate) fn session_media_kept(message_id: i32, media: &MediaRef) -> bool { - message_id != crate::instant_view::INSTANT_VIEW_MEDIA_MSG || media.source_url.is_some() -} - -/// Keep instant-view `source_url` without storing large inline preview bytes. -pub(crate) fn persisted_media_entry(message_id: i32, media: &MediaRef) -> Option { - if !session_media_kept(message_id, media) { - return None; - } - let mut copy = media.clone(); - if message_id == crate::instant_view::INSTANT_VIEW_MEDIA_MSG { - strip_large_inline(&mut copy.location); - if let Some(location) = copy.thumb_location.as_mut() { - strip_large_inline(location); - } - if let Some(location) = copy.display_location.as_mut() { - strip_large_inline(location); - } - } - Some(copy) -} - -fn strip_large_inline(location: &mut MediaLocation) { - if let MediaLocation::Inline { bytes, .. } = location { - if bytes.len() > PERSISTED_INLINE_BYTE_CAP { - bytes.clear(); - } - } -} - -pub fn needs_video_avatar_upgrade(media: &MediaRef) -> bool { - is_video_avatar(media) && matches!(media.location, MediaLocation::PeerPhoto { .. }) -} - -fn avatar_photo_id(media: &MediaRef) -> Option { - match &media.location { - MediaLocation::PeerPhoto { photo_id, .. } => Some(*photo_id), - MediaLocation::Photo { id, .. } => Some(*id), - _ => None, - } -} - -/// Keep a resolved profile video (`inputPhotoFileLocation` + `u`/`v`) over a still -/// `InputPeerPhotoFileLocation` for the same photo id. -pub fn index_avatar(media: &mut MediaIndex, chat_id: i64, incoming: MediaRef) -> String { - let key = (chat_id, 0); - if let Some(existing) = media.get(&key) { - if is_resolved_video_avatar(existing) - && !is_resolved_video_avatar(&incoming) - && avatar_photo_id(existing) == avatar_photo_id(&incoming) - { - return existing.cache_key.clone(); - } - } - let cache_key = incoming.cache_key.clone(); - insert_media(media, key, incoming); - cache_key -} - -pub fn media_ref_peer_photo( - peer_kind: crate::peers::PeerKind, - peer_id: i64, - access_hash: i64, - photo_id: i64, - dc_id: i32, - cache_key: String, - has_video: bool, - stripped_thumb: Option<&[u8]>, -) -> MediaRef { - let (thumb_cache_key, thumb_location) = - match stripped_thumb.and_then(crate::stripped_jpeg::expand_stripped_jpeg) { - Some(bytes) => ( - Some(format!("{}:thumb", cache_key)), - Some(MediaLocation::Inline { - bytes, - extension: "jpg".into(), - }), - ), - None => (None, None), - }; - let small_location = MediaLocation::PeerPhoto { - peer_kind, - peer_id, - access_hash, - photo_id, - big: false, - dc_id, - }; - MediaRef { - file_size: None, - kind: if has_video { - "video_avatar".into() - } else { - "photo".into() - }, - cache_key: cache_key.clone(), - thumb_cache_key, - thumb_location, - display_cache_key: Some(format!("{}:small", cache_key)), - display_location: Some(small_location), - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - location: MediaLocation::PeerPhoto { - peer_kind, - peer_id, - access_hash, - photo_id, - big: true, - dc_id, - }, - } -} - -pub(crate) fn photo_file_location( - photo: &tellers_mtproto::latest::api::PhotoConstructor, - thumb_size: String, - inline: Option>, -) -> MediaLocation { - if let Some(bytes) = inline { - MediaLocation::Inline { - bytes, - extension: "jpg".into(), - } - } else { - MediaLocation::Photo { - id: photo.id, - access_hash: photo.access_hash, - file_reference: photo.file_reference.clone(), - thumb_size, - dc_id: photo.dc_id, - } - } -} - -pub(crate) fn document_file_location( - doc: &tellers_mtproto::latest::api::DocumentConstructor, - thumb_size: String, - inline: Option>, -) -> MediaLocation { - if let Some(bytes) = inline { - MediaLocation::Inline { - bytes, - extension: "jpg".into(), - } - } else { - MediaLocation::Document { - id: doc.id, - access_hash: doc.access_hash, - file_reference: doc.file_reference.clone(), - thumb_size, - dc_id: doc.dc_id, - mime_type: doc.mime_type.clone(), - } - } -} - -pub(crate) fn webpage_media( - media: &tellers_mtproto::latest::api::MessageMediaWebPageConstructor, -) -> Option<(MediaRef, MediaMetrics)> { - let WebPage::WebPage(page) = media.webpage.as_ref() else { - return None; - }; - let meta = serde_json::json!({ - "u": page.url, - "t": page.title, - "s": page.site_name, - "d": page.description, - "e": page.embed_url, - "y": page.type_, - "iv": page.cached_page.is_some(), - "h": page.hash, - }) - .to_string(); - let metrics = MediaMetrics { - duration: page.duration, - width: page.embed_width, - height: page.embed_height, - file_name: Some(meta), - file_size: None, - supports_streaming: false, - }; - if let Some(photo) = page.photo.as_ref() { - if let Photo::Photo(ph) = photo.as_ref() { - let cache_key = format!("photo:{}", ph.id); - if let Some(mut indexed) = media_ref_from_photo_with_thumbs(photo.as_ref(), cache_key) { - indexed.kind = "webpage".into(); - let sizes: Vec = vector_boxed_items(&ph.sizes).cloned().collect(); - let full_ty = match &indexed.location { - MediaLocation::Photo { thumb_size, .. } => thumb_size.clone(), - _ => String::new(), - }; - let thumb_ty = match &indexed.thumb_location { - Some(MediaLocation::Photo { thumb_size, .. }) => Some(thumb_size.clone()), - _ => None, - }; - if let Some((ty, inline)) = pick_display_size(&sizes, thumb_ty.as_deref(), &full_ty) - { - indexed.display_cache_key = Some(format!("{}:display", indexed.cache_key)); - indexed.display_location = Some(photo_file_location(ph, ty, inline)); - } - return Some((indexed, metrics)); - } - } - } - Some(( - MediaRef { - file_size: None, - kind: "webpage".into(), - cache_key: format!("web:{}", page.id), - location: MediaLocation::Inline { - bytes: Vec::new(), - extension: "jpg".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }, - metrics, - )) -} - -pub(crate) fn extract_from_media(media: &MessageMedia) -> Option<(MediaRef, MediaMetrics)> { - match media { - MessageMedia::MessageMediaPhoto(p) => { - let Photo::Photo(ph) = p.photo.as_ref()?.as_ref() else { - return None; - }; - let sizes: Vec = vector_boxed_items(&ph.sizes).cloned().collect(); - let cache_key = format!("photo:{}", ph.id); - let metrics = photo_metrics(&sizes); - let (full_ty, full_inline) = pick_photo_size(&sizes)?; - let thumb = pick_thumb_size(&sizes); - let location = photo_file_location(ph, full_ty.clone(), full_inline); - let thumb_ty = thumb.as_ref().map(|(ty, _)| ty.as_str()); - let (display_cache_key, display_location) = - match pick_display_size(&sizes, thumb_ty, &full_ty) { - Some((ty, inline)) => ( - Some(format!("{cache_key}:display")), - Some(photo_file_location(ph, ty, inline)), - ), - None => (None, None), - }; - let (thumb_cache_key, thumb_location) = match thumb { - Some((ty, inline)) if ty != full_ty => ( - Some(distinct_thumb_key(&cache_key, &full_ty, &ty)), - Some(photo_file_location(ph, ty, inline)), - ), - Some(_) => (Some(cache_key.clone()), None), - None => (None, None), - }; - Some(( - MediaRef { - file_size: None, - kind: "photo".into(), - cache_key, - location, - thumb_cache_key, - thumb_location, - display_cache_key, - display_location, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }, - metrics, - )) - } - MessageMedia::MessageMediaDocument(d) => { - let Document::Document(doc) = d.document.as_ref()?.as_ref() else { - return None; - }; - let kind = document_kind(doc); - let cache_key = format!("doc:{}", doc.id); - let mut metrics = document_metrics(doc); - metrics.file_size = Some(doc.size); - let mut filename = None; - let mut audio_label = None; - for attr in vector_boxed_items(&doc.attributes) { - match attr { - DocumentAttribute::DocumentAttributeFilename(f) => { - filename = Some(f.file_name.clone()); - } - DocumentAttribute::DocumentAttributeAudio(a) => { - audio_label = match (a.title.as_ref(), a.performer.as_ref()) { - (Some(title), Some(performer)) => { - Some(format!("{title} — {performer}")) - } - (Some(title), None) => Some(title.clone()), - (None, Some(performer)) => Some(performer.clone()), - _ => None, - }; - } - _ => {} - } - } - metrics.file_name = audio_label.or(filename); - let thumbs: Vec = doc - .thumbs - .as_ref() - .map(|t| vector_boxed_items(t).cloned().collect()) - .unwrap_or_default(); - let collected = collect_photo_sizes(&thumbs); - let thumb = pick_thumb_size(&thumbs); - let (thumb_cache_key, thumb_location) = match thumb { - Some((ty, inline)) => ( - Some(format!("{cache_key}:thumb")), - Some(document_file_location(doc, ty, inline)), - ), - None => (None, None), - }; - let thumb_inline = matches!( - &thumb_location, - Some(MediaLocation::Inline { bytes, .. }) if !bytes.is_empty() - ); - let (display_cache_key, display_location) = if thumb_inline { - match pick_getfile_preview(&collected) { - Some((ty, inline)) => ( - Some(format!("{cache_key}:display")), - Some(document_file_location(doc, ty, inline)), - ), - None => (None, None), - } - } else { - (None, None) - }; - let (sticker_set_id, sticker_set_access_hash) = sticker_set_ids(doc); - Some(( - MediaRef { - file_size: Some(doc.size), - kind, - cache_key, - location: document_file_location(doc, String::new(), None), - thumb_cache_key, - thumb_location, - display_cache_key, - display_location, - sticker_set_id, - sticker_set_access_hash, - source_url: None, - }, - metrics, - )) - } - MessageMedia::MessageMediaWebPage(w) => webpage_media(w), - _ => None, - } -} - -/// Caption/body when `message` is empty but media still carries readable text. -pub(crate) fn media_fallback_text(media: &MessageMedia) -> Option { - match media { - MessageMedia::MessageMediaToDo(todo) => todo_title(todo), - MessageMedia::MessageMediaWebPage(_) => None, - MessageMedia::MessageMediaGame(game) => { - let tellers_mtproto::latest::api::Game::Game(game) = game.game.as_ref(); - (!game.title.is_empty()).then(|| game.title.clone()) - } - MessageMedia::MessageMediaInvoice(invoice) => { - (!invoice.title.is_empty()).then(|| invoice.title.clone()) - } - MessageMedia::MessageMediaGiveaway(giveaway) => { - giveaway.prize_description.clone().filter(|s| !s.is_empty()) - } - MessageMedia::MessageMediaStory(_) => Some("Story".into()), - MessageMedia::MessageMediaDocument(d) => { - let Document::Document(doc) = d.document.as_ref()?.as_ref() else { - return None; - }; - let kind = document_kind(doc); - if matches!( - kind.as_str(), - "sticker" - | "sticker_animated" - | "sticker_video" - | "voice" - | "audio" - | "gif" - | "video" - | "video_note" - | "photo" - ) { - return None; - } - vector_boxed_items(&doc.attributes).find_map(|attr| match attr { - DocumentAttribute::DocumentAttributeFilename(f) => Some(f.file_name.clone()), - _ => None, - }) - } - _ => None, - } -} - -pub fn index_message_media(msg: &Message, index: &mut MediaIndex) -> IndexedMessageMedia { - let Message::Message(m) = msg else { - return IndexedMessageMedia::default(); - }; - let chat_id = peer_chat_id(&m.peer_id); - let Some(media) = m.media.as_ref() else { - return match m.rich_message.as_ref().map(|rich| rich.as_ref()) { - Some(RichMessage::RichMessage(body)) => { - index_first_rich_photo(chat_id, m.id, body, index) - } - _ => IndexedMessageMedia::default(), - }; - }; - if matches!(media.as_ref(), MessageMedia::MessageMediaUnsupported(_)) { - return IndexedMessageMedia { - kind: Some("unsupported".into()), - ..IndexedMessageMedia::default() - }; - } - if let MessageMedia::MessageMediaToDo(todo) = media.as_ref() { - return IndexedMessageMedia { - kind: Some("todo".into()), - file_name: Some(todo_to_json(todo)), - ..IndexedMessageMedia::default() - }; - } - match media.as_ref() { - MessageMedia::MessageMediaPoll(poll) => { - return IndexedMessageMedia { - kind: Some("poll".into()), - file_name: Some(poll_to_json(poll)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaGeo(geo) => { - return IndexedMessageMedia { - kind: Some("geo".into()), - file_name: geo_to_json(geo), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaGeoLive(live) => { - return IndexedMessageMedia { - kind: Some("geo".into()), - file_name: geo_live_to_json(live), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaVenue(venue) => { - return IndexedMessageMedia { - kind: Some("venue".into()), - file_name: venue_to_json(venue), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaContact(contact) => { - return IndexedMessageMedia { - kind: Some("contact".into()), - file_name: Some(contact_to_json(contact)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaDice(dice) => { - return IndexedMessageMedia { - kind: Some("dice".into()), - file_name: Some(dice_to_json(dice)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaGiveaway(giveaway) => { - return IndexedMessageMedia { - kind: Some("giveaway".into()), - file_name: Some(giveaway_to_json(giveaway)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaGiveawayResults(results) => { - return IndexedMessageMedia { - kind: Some("giveaway_results".into()), - file_name: Some(giveaway_results_to_json(results)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaPaidMedia(paid) => { - return IndexedMessageMedia { - kind: Some("paid_media".into()), - file_name: Some(paid_media_to_json(paid)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaGame(game) => { - return IndexedMessageMedia { - kind: Some("game".into()), - file_name: Some(game_to_json(game)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaInvoice(invoice) => { - return IndexedMessageMedia { - kind: Some("invoice".into()), - file_name: Some(invoice_to_json(invoice)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaStory(story) => { - return IndexedMessageMedia { - kind: Some("story".into()), - file_name: Some(story_to_json(story)), - ..IndexedMessageMedia::default() - }; - } - MessageMedia::MessageMediaVideoStream(stream) => { - return IndexedMessageMedia { - kind: Some("video_stream".into()), - file_name: Some(video_stream_to_json(stream)), - ..IndexedMessageMedia::default() - }; - } - _ => {} - } - let Some((media_ref, metrics)) = extract_from_media(media) else { - return match m.rich_message.as_ref().map(|rich| rich.as_ref()) { - Some(RichMessage::RichMessage(body)) => { - index_first_rich_photo(chat_id, m.id, body, index) - } - _ => IndexedMessageMedia::default(), - }; - }; - let indexed = IndexedMessageMedia { - kind: Some(media_ref.kind.clone()), - cache_key: Some(media_ref.cache_key.clone()), - thumb_cache_key: media_ref.thumb_cache_key.clone(), - duration: metrics.duration, - width: metrics.width, - height: metrics.height, - file_name: metrics.file_name, - file_size: metrics.file_size, - supports_streaming: metrics.supports_streaming, - }; - insert_media(index, (chat_id, m.id), media_ref); - indexed -} - -pub(crate) fn index_first_rich_photo( - chat_id: i64, - message_id: i32, - rich: &tellers_mtproto::latest::api::RichMessageConstructor, - index: &mut MediaIndex, -) -> IndexedMessageMedia { - let photos: Vec = vector_boxed_items(&rich.photos).cloned().collect(); - let Some(want) = first_block_photo_id(vector_boxed_items(&rich.blocks)) else { - return IndexedMessageMedia::default(); - }; - for photo in &photos { - if photo_id_of(photo) != Some(want) { - continue; - } - let cache_key = format!("photo:{want}"); - let Some(media_ref) = media_ref_from_photo_with_thumbs(photo, cache_key) else { - continue; - }; - let Photo::Photo(ph) = photo else { - continue; - }; - let sizes: Vec = vector_boxed_items(&ph.sizes).cloned().collect(); - let metrics = photo_metrics(&sizes); - let indexed = IndexedMessageMedia { - kind: None, - cache_key: Some(media_ref.cache_key.clone()), - thumb_cache_key: media_ref.thumb_cache_key.clone(), - duration: None, - width: metrics.width, - height: metrics.height, - file_name: None, - file_size: metrics.file_size, - supports_streaming: false, - }; - insert_media(index, (chat_id, message_id), media_ref); - return indexed; - } - IndexedMessageMedia::default() -} - -fn first_block_photo_id<'a>(blocks: impl Iterator) -> Option { - for block in blocks { - match block { - PageBlock::PageBlockPhoto(p) => return Some(p.photo_id), - PageBlock::PageBlockBlockquoteBlocks(p) => { - if let Some(id) = first_block_photo_id(vector_boxed_items(&p.blocks)) { - return Some(id); - } - } - PageBlock::PageBlockDetails(p) => { - if let Some(id) = first_block_photo_id(vector_boxed_items(&p.blocks)) { - return Some(id); - } - } - _ => {} - } - } - None -} - -pub fn fetch_custom_emoji( - snapshot: &mut Snapshot, - api_id: i32, - document_id: i64, -) -> Result { - let request = MessagesGetCustomEmojiDocumentsRequest { - document_id: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![document_id], - })), - }; - let docs: Vector> = api_invoke::invoke_api(snapshot, api_id, request)?; - let first = vector_boxed_items(&docs) - .next() - .ok_or_else(|| MtprotoError::Message("empty custom emoji documents".into()))?; - let Document::Document(doc) = first else { - return Err(MtprotoError::Message("custom emoji document empty".into())); - }; - Ok(media_ref_from_document(doc)) -} - -pub(crate) fn media_ref_from_document( - doc: &tellers_mtproto::latest::api::DocumentConstructor, -) -> MediaRef { - let kind = document_kind(doc); - let (sticker_set_id, sticker_set_access_hash) = sticker_set_ids(doc); - let cache_key = if kind.starts_with("sticker") || sticker_set_id.is_some() { - format!("emoji:{}", doc.id) - } else { - format!("doc:{}", doc.id) - }; - let mut media = MediaRef { - file_size: Some(doc.size), - kind: kind.into(), - cache_key, - location: document_file_location(doc, String::new(), None), - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id, - sticker_set_access_hash, - source_url: None, - }; - attach_document_thumbs(doc, &mut media); - media -} - -pub(crate) fn sticker_set_ids( - doc: &tellers_mtproto::latest::api::DocumentConstructor, -) -> (Option, Option) { - for attr in vector_boxed_items(&doc.attributes) { - let set = match attr { - DocumentAttribute::DocumentAttributeSticker(s) => s.stickerset.as_ref(), - DocumentAttribute::DocumentAttributeCustomEmoji(s) => s.stickerset.as_ref(), - _ => continue, - }; - if let InputStickerSet::InputStickerSetId(id) = set { - return (Some(id.id), Some(id.access_hash)); - } - } - (None, None) -} diff --git a/native/mtproto-rs/src/media/location.rs b/native/mtproto-rs/src/media/location.rs deleted file mode 100644 index 868c91137..000000000 --- a/native/mtproto-rs/src/media/location.rs +++ /dev/null @@ -1,396 +0,0 @@ -use crate::{HashMap, IndexMap}; - -use serde::{Deserialize, Serialize}; -use tellers_mtproto::latest::api::{ - InputDocumentFileLocationConstructor, InputFileLocation, InputPeer, - InputPeerChannelConstructor, InputPeerChatConstructor, InputPeerPhotoFileLocationConstructor, - InputPeerUserConstructor, InputPhotoFileLocationConstructor, True, TrueConstructor, -}; - -use super::thumbs::is_getfile_thumb_size; -use crate::MtprotoError; - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum MediaLocation { - Photo { - id: i64, - access_hash: i64, - file_reference: Vec, - thumb_size: String, - dc_id: i32, - }, - Document { - id: i64, - access_hash: i64, - file_reference: Vec, - thumb_size: String, - dc_id: i32, - mime_type: String, - }, - PeerPhoto { - peer_kind: crate::peers::PeerKind, - peer_id: i64, - access_hash: i64, - photo_id: i64, - big: bool, - dc_id: i32, - }, - Inline { - bytes: Vec, - extension: String, - }, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MediaRef { - #[serde(default)] - pub file_size: Option, - pub kind: String, - pub cache_key: String, - pub location: MediaLocation, - #[serde(default)] - pub thumb_cache_key: Option, - #[serde(default)] - pub thumb_location: Option, - #[serde(default)] - pub display_cache_key: Option, - #[serde(default)] - pub display_location: Option, - #[serde(default)] - pub sticker_set_id: Option, - #[serde(default)] - pub sticker_set_access_hash: Option, - #[serde(default)] - pub source_url: Option, -} - -// Zero and -1 are reserved for avatars and Instant View; queue media uses the remaining negatives. -pub(crate) fn scheduled_media_id(id: i32) -> Result { - if id <= 0 { - return Err(MtprotoError::Message("invalid scheduled message id".into())); - } - Ok(-1 - id) -} - -pub(crate) fn scheduled_message_id(media_id: i32) -> Option { - (media_id < -1).then(|| -1 - media_id) -} - -pub type MediaIndex = IndexMap<(i64, i32), MediaRef>; - -/// Avatar `InputPeerPhotoFileLocation` cannot use hash 0 / min. -/// https://core.telegram.org/api/min -pub fn fill_zero_peer_photo_hashes( - media: &mut MediaIndex, - peers: &HashMap, -) { - for ((chat_id, _), item) in media.iter_mut() { - if let Some(peer) = crate::peers::usable_cached_peer(peers, *chat_id) { - if let MediaLocation::PeerPhoto { access_hash, .. } = &mut item.location { - if *access_hash == 0 { - *access_hash = peer.access_hash; - } - } - if let Some(MediaLocation::PeerPhoto { access_hash, .. }) = &mut item.display_location { - if *access_hash == 0 { - *access_hash = peer.access_hash; - } - } - } - } -} - -pub fn inline_payload_missing(media: &MediaRef) -> bool { - matches!(media.location, MediaLocation::Inline { ref bytes, .. } if bytes.is_empty()) -} - -pub fn peer_photo_needs_hash(media: &MediaRef) -> bool { - matches!( - media.location, - MediaLocation::PeerPhoto { access_hash: 0, peer_kind, .. } - if peer_kind != crate::peers::PeerKind::Chat - ) -} - -pub(crate) fn input_location(loc: &MediaLocation) -> Result { - match loc { - MediaLocation::Photo { - id, - access_hash, - file_reference, - thumb_size, - .. - } => Ok(InputFileLocation::InputPhotoFileLocation( - InputPhotoFileLocationConstructor { - id: *id, - access_hash: *access_hash, - file_reference: file_reference.clone(), - thumb_size: thumb_size.clone(), - }, - )), - MediaLocation::Document { - id, - access_hash, - file_reference, - thumb_size, - .. - } => Ok(InputFileLocation::InputDocumentFileLocation( - InputDocumentFileLocationConstructor { - id: *id, - access_hash: *access_hash, - file_reference: file_reference.clone(), - thumb_size: thumb_size.clone(), - }, - )), - MediaLocation::PeerPhoto { - peer_kind, - peer_id, - access_hash, - photo_id, - big, - .. - } => { - let peer = match peer_kind { - crate::peers::PeerKind::User => { - InputPeer::InputPeerUser(InputPeerUserConstructor { - user_id: *peer_id, - access_hash: *access_hash, - }) - } - crate::peers::PeerKind::Chat => { - InputPeer::InputPeerChat(InputPeerChatConstructor { chat_id: *peer_id }) - } - crate::peers::PeerKind::Channel => { - InputPeer::InputPeerChannel(InputPeerChannelConstructor { - channel_id: *peer_id, - access_hash: *access_hash, - }) - } - }; - let big = if *big { - Some(Box::new(True::True(TrueConstructor {}))) - } else { - None - }; - let flags = if big.is_some() { 1 } else { 0 }; - Ok(InputFileLocation::InputPeerPhotoFileLocation( - InputPeerPhotoFileLocationConstructor { - flags, - big, - peer: Box::new(peer), - photo_id: *photo_id, - }, - )) - } - MediaLocation::Inline { .. } => Err(MtprotoError::Message( - "inline media does not need upload.getFile".into(), - )), - } -} - -pub fn location_is_inline(loc: &MediaLocation) -> bool { - matches!(loc, MediaLocation::Inline { .. }) -} - -pub(crate) fn media_dc(loc: &MediaLocation) -> Option { - match loc { - MediaLocation::Photo { dc_id, .. } - | MediaLocation::Document { dc_id, .. } - | MediaLocation::PeerPhoto { dc_id, .. } => Some(*dc_id), - MediaLocation::Inline { .. } => None, - } -} - -/// DC that should own `upload.getFile` for this location. 0 / missing → home DC. -pub(crate) fn download_dc(home_dc: i32, loc: &MediaLocation) -> i32 { - media_dc(loc).filter(|dc| *dc != 0).unwrap_or(home_dc) -} - -pub fn is_file_reference_error(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if { - let upper = message.to_ascii_uppercase(); - upper.contains("FILE_REFERENCE") - }) -} - -/// Compare locations after a file-ref refresh. Peer photos have no `file_reference`. -/// https://core.telegram.org/api/file-references -pub fn location_token(loc: &MediaLocation) -> Vec { - match loc { - MediaLocation::Photo { - id, - file_reference, - thumb_size, - .. - } => token_parts(*id, file_reference, thumb_size), - MediaLocation::Document { - id, - file_reference, - thumb_size, - .. - } => token_parts(*id, file_reference, thumb_size), - MediaLocation::PeerPhoto { - photo_id, - access_hash, - .. - } => { - let mut out = photo_id.to_le_bytes().to_vec(); - out.extend_from_slice(&access_hash.to_le_bytes()); - out - } - MediaLocation::Inline { bytes, .. } => bytes.clone(), - } -} - -thread_local! { - static TOKEN_SCRATCH: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; -} - -pub(crate) fn token_parts(id: i64, file_reference: &[u8], thumb_size: impl AsRef) -> Vec { - TOKEN_SCRATCH.with(|cell| { - let mut out = cell.borrow_mut(); - out.clear(); - out.extend_from_slice(&id.to_le_bytes()); - out.extend_from_slice(file_reference); - out.extend_from_slice(thumb_size.as_ref().as_bytes()); - out.clone() - }) -} - -pub(crate) fn location_is_getfile_thumb(loc: &MediaLocation) -> bool { - match loc { - MediaLocation::Inline { bytes, .. } => !bytes.is_empty(), - MediaLocation::Photo { thumb_size, .. } | MediaLocation::Document { thumb_size, .. } => { - is_getfile_thumb_size(thumb_size) - } - MediaLocation::PeerPhoto { big, .. } => !*big, - } -} - -pub fn is_file_id_invalid(err: &MtprotoError) -> bool { - matches!(err, MtprotoError::Message(message) if { - message.to_ascii_uppercase().contains("FILE_ID_INVALID") - }) -} - -/// Other getFile letters to try after `FILE_ID_INVALID` on the picked thumb. - -pub fn location_thumb_size(loc: &MediaLocation) -> Option<&str> { - match loc { - MediaLocation::Photo { thumb_size, .. } | MediaLocation::Document { thumb_size, .. } => { - Some(thumb_size.as_str()) - } - _ => None, - } -} - -pub fn with_thumb_size(loc: &MediaLocation, thumb_size: &str) -> Option { - match loc { - MediaLocation::Photo { - id, - access_hash, - file_reference, - dc_id, - .. - } => Some(MediaLocation::Photo { - id: *id, - access_hash: *access_hash, - file_reference: file_reference.clone(), - thumb_size: thumb_size.to_string(), - dc_id: *dc_id, - }), - MediaLocation::Document { - id, - access_hash, - file_reference, - dc_id, - mime_type, - .. - } => Some(MediaLocation::Document { - id: *id, - access_hash: *access_hash, - file_reference: file_reference.clone(), - thumb_size: thumb_size.to_string(), - dc_id: *dc_id, - mime_type: mime_type.clone(), - }), - _ => None, - } -} - -/// Persist a working getFile letter so later thumb fetches skip the invalid size. -pub fn remember_thumb_location(media: &mut MediaRef, loc: MediaLocation) { - if media.thumb_location.is_some() { - media.thumb_location = Some(loc); - } else { - media.location = loc; - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MediaDownloadKind { - Full, - Thumb, - Display, -} - -/// Thumb downloads must use an inline blob or a real getFile size. -/// Falling back to the full document/photo is how thumbs hit `FILE_ID_INVALID`. -pub fn media_for_download( - media: &MediaRef, - kind: MediaDownloadKind, -) -> Result { - match kind { - MediaDownloadKind::Full => Ok(media.clone()), - MediaDownloadKind::Display => { - if let Some(loc) = &media.display_location { - if location_is_getfile_thumb(loc) { - let mut copy = media.clone(); - copy.location = loc.clone(); - copy.file_size = None; - copy.cache_key = media - .display_cache_key - .clone() - .unwrap_or_else(|| format!("{}:display", media.cache_key)); - return Ok(copy); - } - } - Err(MtprotoError::Message("no display size".into())) - } - MediaDownloadKind::Thumb => { - if let Some(loc) = &media.thumb_location { - if location_is_getfile_thumb(loc) { - let mut copy = media.clone(); - copy.location = loc.clone(); - copy.file_size = None; - copy.cache_key = media - .thumb_cache_key - .clone() - .unwrap_or_else(|| media.cache_key.clone()); - return Ok(copy); - } - } - // Only size is already a getFile photo thumb (`m`/`x`/…); not a full document. - if location_is_getfile_thumb(&media.location) { - return Ok(media.clone()); - } - if matches!( - &media.location, - MediaLocation::Inline { bytes, .. } if !bytes.is_empty() - ) { - return Ok(media.clone()); - } - Err(MtprotoError::Message("no downloadable thumb".into())) - } - } -} - -/// Expanded JPEG for `photoStrippedSize` / cached thumbs. No `upload.getFile`. -pub fn inline_thumb_jpeg(media: &MediaRef) -> Option> { - let download = media_for_download(media, MediaDownloadKind::Thumb).ok()?; - match download.location { - MediaLocation::Inline { bytes, .. } if !bytes.is_empty() => Some(bytes), - _ => None, - } -} diff --git a/native/mtproto-rs/src/media/mod.rs b/native/mtproto-rs/src/media/mod.rs deleted file mode 100644 index b2367d4dd..000000000 --- a/native/mtproto-rs/src/media/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Media locations, thumbs, and `upload.getFile`. -//! https://core.telegram.org/api/files -//! https://core.telegram.org/api/file-references - -mod cdn; -mod download; -mod index; -mod location; -mod page_plain; -mod structured; -mod thumbs; -pub(crate) mod wallpaper_rpc; - -pub(crate) use cdn::*; -pub(crate) use download::*; -pub use download::{ProgressCallback, notify_progress, set_progress_callback}; -pub use index::*; -pub(crate) use index::{ - INSTANT_VIEW_CAP, MESSAGE_MEDIA_CAP, PERSISTED_INLINE_BYTE_CAP, ZERO_ID_DOCUMENT_CAP, - bound_media_index, insert_media, persisted_media_entry, session_media_kept, -}; -pub use location::*; -pub(crate) use page_plain::*; -pub(crate) use structured::*; -pub use thumbs::*; - -#[cfg(test)] -use crate::MtprotoError; -#[cfg(test)] -use crate::{HashMap, HashMapExt}; -#[cfg(test)] -use std::fs; -#[cfg(test)] -use std::io::Write; -#[cfg(test)] -use tellers_mtproto::latest::api::{ - GeoPoint, PageBlock, PageCaption, Photo, PhotoSize, Poll, PollAnswer, PollAnswerVoters, - PollResults, RichText, TextWithEntities, True, TrueConstructor, UploadFile, Vector, - VectorConstructor, -}; - -#[cfg(test)] -#[path = "../../tests/unit/media_download_tests.rs"] -mod download_tests; -#[cfg(test)] -#[path = "../../tests/unit/media_index_tests.rs"] -mod index_tests; -#[cfg(test)] -#[path = "../../tests/unit/media_location_tests.rs"] -mod location_tests; -#[cfg(test)] -#[path = "../../tests/unit/media_page_tests.rs"] -mod page_tests; -#[cfg(test)] -#[path = "../../tests/unit/media_structured_tests.rs"] -mod structured_tests; -#[cfg(test)] -#[path = "../../tests/unit/media_thumbs_tests.rs"] -mod thumbs_tests; diff --git a/native/mtproto-rs/src/media/page_plain.rs b/native/mtproto-rs/src/media/page_plain.rs deleted file mode 100644 index bf327266f..000000000 --- a/native/mtproto-rs/src/media/page_plain.rs +++ /dev/null @@ -1,754 +0,0 @@ -use tellers_mtproto::latest::api::{ - Chat, Page, PageBlock, PageButton, PageCaption, PageRelatedArticle, Photo, PhotoSize, RichText, - WebPage, -}; - -use super::thumbs::photo_size_type; -use crate::peers::vector_boxed_items; - -pub(crate) fn webpage_plain(page: &WebPage) -> Option { - let WebPage::WebPage(p) = page else { - return None; - }; - let mut out = String::new(); - if let Some(title) = p.title.as_ref().filter(|s| !s.is_empty()) { - out.push_str(title); - } - if let Some(description) = p.description.as_ref().filter(|s| !s.is_empty()) { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(description); - } - if let Some(cached) = p.cached_page.as_ref() { - let body = page_plain(cached); - if !body.is_empty() { - if !out.is_empty() { - out.push_str("\n\n"); - } - out.push_str(&body); - } - } - if out.is_empty() { None } else { Some(out) } -} - -#[allow(dead_code)] -pub(crate) fn page_plain(page: &Page) -> String { - let Page::Page(p) = page else { - return String::new(); - }; - page_blocks_formatted(vector_boxed_items(&p.blocks)).text -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct FormattedText { - pub text: String, - /// Format entities on page blocks are few and small per block. Stated inline: 4. - pub entities: crate::SmallVec<[FormatEntity; 4]>, -} - -#[derive(Clone, Debug, serde::Serialize)] -pub(crate) struct FormatEntity { - pub kind: String, - pub offset: i32, - pub length: i32, - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -pub(crate) fn page_blocks_plain<'a>(blocks: impl Iterator) -> String { - page_blocks_formatted(blocks).text -} - -pub(crate) fn page_blocks_formatted<'a>( - blocks: impl Iterator, -) -> FormattedText { - page_blocks_formatted_media(blocks, &[]) -} - -pub(crate) fn page_blocks_formatted_media<'a>( - blocks: impl Iterator, - photos: &'a [Photo], -) -> FormattedText { - let mut out = FormattedText::default(); - for block in blocks { - if out.text.len() >= 12_000 { - break; - } - let start = out.text.len(); - push_block(block, &mut out, photos); - if out.text.len() > start && !out.text.ends_with('\n') { - out.text.push('\n'); - } - } - while out.text.ends_with('\n') { - out.text.pop(); - } - out -} - -pub(crate) fn utf16_len(text: &str) -> i32 { - text.encode_utf16().count() as i32 -} - -fn mark(out: &mut FormattedText, start_utf16: i32, kind: &str, url: Option) { - let length = utf16_len(&out.text) - start_utf16; - if length > 0 { - out.entities.push(FormatEntity { - kind: kind.to_string(), - offset: start_utf16, - length, - url, - }); - } -} - -fn push_block(block: &PageBlock, out: &mut FormattedText, photos: &[Photo]) { - match block { - PageBlock::PageBlockTitle(b) => push_heading(&b.text, out, 1), - PageBlock::PageBlockHeading1(b) => push_heading(&b.text, out, 1), - PageBlock::PageBlockHeader(b) => push_heading(&b.text, out, 1), - PageBlock::PageBlockHeading2(b) => push_heading(&b.text, out, 2), - PageBlock::PageBlockSubheader(b) => push_heading(&b.text, out, 2), - PageBlock::PageBlockHeading3(b) => push_heading(&b.text, out, 3), - PageBlock::PageBlockHeading4(b) => push_heading(&b.text, out, 4), - PageBlock::PageBlockHeading5(b) => push_heading(&b.text, out, 5), - PageBlock::PageBlockHeading6(b) => push_heading(&b.text, out, 6), - PageBlock::PageBlockKicker(b) => push_heading(&b.text, out, 2), - PageBlock::PageBlockSubtitle(b) => push_rich(&b.text, out), - PageBlock::PageBlockParagraph(b) => push_rich(&b.text, out), - PageBlock::PageBlockFooter(b) => push_rich(&b.text, out), - PageBlock::PageBlockPreformatted(b) => { - let start = utf16_len(&out.text); - push_rich(&b.text, out); - let lang = b.language.trim(); - mark( - out, - start, - "pre", - (!lang.is_empty()).then(|| lang.to_string()), - ); - } - PageBlock::PageBlockBlockquote(b) => { - let start = utf16_len(&out.text); - push_rich(&b.text, out); - mark(out, start, "blockquote", None); - } - PageBlock::PageBlockPullquote(b) => { - let start = utf16_len(&out.text); - push_rich(&b.text, out); - mark(out, start, "blockquote", None); - } - PageBlock::PageBlockBlockquoteBlocks(b) => { - let start = utf16_len(&out.text); - push_nested_blocks(vector_boxed_items(&b.blocks), out, photos); - if !matches!(b.caption.as_ref(), RichText::TextEmpty(_)) { - ensure_nl(out); - push_rich(&b.caption, out); - } - mark(out, start, "blockquote", None); - } - PageBlock::PageBlockList(b) => push_unordered_list(b, out, 0, photos), - PageBlock::PageBlockOrderedList(b) => push_ordered_list(b, out, 0, photos), - PageBlock::PageBlockTable(b) => push_table(b, out), - PageBlock::PageBlockDivider(_) => { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str("---"); - mark(out, start, "rule", None); - } - PageBlock::PageBlockDetails(b) => { - let start = utf16_len(&out.text); - push_rich(&b.title, out); - ensure_nl(out); - push_nested_blocks(vector_boxed_items(&b.blocks), out, photos); - mark(out, start, "details", None); - } - PageBlock::PageBlockPhoto(b) => push_page_photo(b, out, photos), - PageBlock::PageBlockMath(b) => { - let start = utf16_len(&out.text); - out.text.push_str(&b.source); - mark(out, start, "math", Some("block".into())); - } - PageBlock::PageBlockAuthorDate(b) => { - push_rich(&b.author, out); - if b.published_date > 0 { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(&b.published_date.to_string()); - mark(out, start, "date", Some(b.published_date.to_string())); - } - } - PageBlock::PageBlockAnchor(_) => {} - PageBlock::PageBlockThinking(b) => { - ensure_nl(out); - let start = utf16_len(&out.text); - push_rich(&b.text, out); - mark(out, start, "thinking", None); - } - PageBlock::PageBlockVideo(b) => { - push_placeholder(out, "video", b.video_id, "Video"); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockAudio(b) => { - push_placeholder(out, "audio", b.audio_id, "Audio"); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockDocument(b) => { - push_placeholder(out, "document", b.document_id, "Document"); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockCover(b) => push_block(b.cover.as_ref(), out, photos), - PageBlock::PageBlockEmbed(b) => { - ensure_nl(out); - let start = utf16_len(&out.text); - if let Some(url) = b.url.as_ref().filter(|s| !s.is_empty()) { - out.text.push_str(url); - } else if let Some(html) = b.html.as_ref().filter(|s| !s.is_empty()) { - out.text.push_str(html); - } - mark(out, start, "embed", None); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockEmbedPost(b) => { - ensure_nl(out); - if !b.author.is_empty() { - out.text.push_str(&b.author); - ensure_nl(out); - } - push_nested_blocks(vector_boxed_items(&b.blocks), out, photos); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockCollage(b) => { - push_nested_blocks(vector_boxed_items(&b.items), out, photos); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockSlideshow(b) => { - push_nested_blocks(vector_boxed_items(&b.items), out, photos); - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockChannel(b) => { - let title = match b.channel.as_ref() { - Chat::Chat(c) => c.title.as_str(), - Chat::ChatForbidden(c) => c.title.as_str(), - _ => "", - }; - if !title.is_empty() { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(title); - mark(out, start, "channel", None); - } - } - PageBlock::PageBlockRelatedArticles(b) => { - ensure_nl(out); - push_rich(&b.title, out); - for article in vector_boxed_items(&b.articles) { - let PageRelatedArticle::PageRelatedArticle(a) = article; - if let Some(title) = a.title.as_ref().filter(|s| !s.is_empty()) { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(title); - mark(out, start, "related", Some(a.url.clone())); - } - } - } - PageBlock::PageBlockMap(b) => { - if let Some(point) = geo_coordinates(b.geo.as_ref()) { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(&point); - mark(out, start, "map", Some(b.zoom.to_string())); - } - push_page_caption(&b.caption, out); - } - PageBlock::InputPageBlockMap(b) => { - push_page_caption(&b.caption, out); - } - PageBlock::PageBlockButtonRow(b) => { - for button in vector_boxed_items(&b.buttons) { - let PageButton::PageButton(button) = button; - let label = crate::media::rich_text_plain(&button.text); - if !label.is_empty() { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(&label); - mark(out, start, "button", None); - } - } - } - PageBlock::PageBlockUnsupported(_) => {} - _ => {} - } -} - -fn push_placeholder(out: &mut FormattedText, kind: &str, id: i64, label: &str) { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push_str(label); - mark(out, start, kind, Some(id.to_string())); -} - -fn geo_coordinates(geo: &tellers_mtproto::latest::api::GeoPoint) -> Option { - let tellers_mtproto::latest::api::GeoPoint::GeoPoint(point) = geo else { - return None; - }; - Some(format!("{:.4}, {:.4}", point.lat, point.long)) -} - -fn ensure_nl(out: &mut FormattedText) { - if !out.text.is_empty() && !out.text.ends_with('\n') { - out.text.push('\n'); - } -} - -fn push_nested_blocks<'a>( - blocks: impl Iterator, - out: &mut FormattedText, - photos: &[Photo], -) { - for nested in blocks { - ensure_nl(out); - push_block(nested, out, photos); - } -} - -fn push_page_caption(caption: &PageCaption, out: &mut FormattedText) { - let PageCaption::PageCaption(cap) = caption; - if !matches!(cap.text.as_ref(), RichText::TextEmpty(_)) { - push_rich(&cap.text, out); - } - if !matches!(cap.credit.as_ref(), RichText::TextEmpty(_)) { - ensure_nl(out); - push_rich(&cap.credit, out); - } -} - -pub(crate) fn photo_id_of(photo: &Photo) -> Option { - match photo { - Photo::Photo(p) => Some(p.id), - _ => None, - } -} - -pub(crate) fn photo_wh(photo: &Photo) -> (i32, i32) { - let Photo::Photo(ph) = photo else { - return (0, 0); - }; - let sizes: Vec = vector_boxed_items(&ph.sizes).cloned().collect(); - let mut best: Option<(i32, i32, i32)> = None; - for size in &sizes { - let Some((_, area, _, w, h)) = photo_size_type(size) else { - continue; - }; - match best { - None => best = Some((area, w, h)), - Some((best_area, _, _)) if area >= best_area => best = Some((area, w, h)), - _ => {} - } - } - ( - best.map(|(_, w, _)| w).unwrap_or(0), - best.map(|(_, _, h)| h).unwrap_or(0), - ) -} - -fn push_page_photo( - block: &tellers_mtproto::latest::api::PageBlockPhotoConstructor, - out: &mut FormattedText, - photos: &[Photo], -) { - if let Some(photo) = photos - .iter() - .find(|p| photo_id_of(p) == Some(block.photo_id)) - { - ensure_nl(out); - let start = utf16_len(&out.text); - out.text.push('\u{FFFC}'); - let (w, h) = photo_wh(photo); - mark( - out, - start, - "photo", - Some(format!("photo:{}:{}x{}", block.photo_id, w, h)), - ); - ensure_nl(out); - } - push_page_caption(&block.caption, out); -} - -fn checkbox_prefix(checkbox: bool, checked: bool) -> &'static str { - if !checkbox { - return ""; - } - if checked { "☑ " } else { "☐ " } -} - -fn is_list_block(block: &PageBlock) -> bool { - matches!( - block, - PageBlock::PageBlockList(_) | PageBlock::PageBlockOrderedList(_) - ) -} - -fn push_unordered_list( - list: &tellers_mtproto::latest::api::PageBlockListConstructor, - out: &mut FormattedText, - indent: usize, - photos: &[Photo], -) { - for item in vector_boxed_items(&list.items) { - ensure_nl(out); - for _ in 0..indent { - out.text.push_str(" "); - } - match item { - tellers_mtproto::latest::api::PageListItem::PageListItemText(t) => { - let prefix = checkbox_prefix(t.checkbox.is_some(), t.checked.is_some()); - out.text - .push_str(if prefix.is_empty() { "• " } else { prefix }); - push_rich(&t.text, out); - } - tellers_mtproto::latest::api::PageListItem::PageListItemBlocks(t) => { - let prefix = checkbox_prefix(t.checkbox.is_some(), t.checked.is_some()); - out.text - .push_str(if prefix.is_empty() { "• " } else { prefix }); - for nested in vector_boxed_items(&t.blocks) { - if is_list_block(nested) { - match nested { - PageBlock::PageBlockList(inner) => { - push_unordered_list(inner, out, indent + 1, photos) - } - PageBlock::PageBlockOrderedList(inner) => { - push_ordered_list(inner, out, indent + 1, photos) - } - _ => push_block(nested, out, photos), - } - } else { - push_block(nested, out, photos); - } - } - } - } - } -} - -fn push_ordered_list( - list: &tellers_mtproto::latest::api::PageBlockOrderedListConstructor, - out: &mut FormattedText, - indent: usize, - photos: &[Photo], -) { - for (i, item) in vector_boxed_items(&list.items).enumerate() { - ensure_nl(out); - for _ in 0..indent { - out.text.push_str(" "); - } - match item { - tellers_mtproto::latest::api::PageListOrderedItem::PageListOrderedItemText(t) => { - let prefix = checkbox_prefix(t.checkbox.is_some(), t.checked.is_some()); - if prefix.is_empty() { - let n = t.num.clone().unwrap_or_else(|| format!("{}.", i + 1)); - out.text.push_str(&n); - if !n.ends_with(' ') { - out.text.push(' '); - } - } else { - out.text.push_str(prefix); - } - push_rich(&t.text, out); - } - tellers_mtproto::latest::api::PageListOrderedItem::PageListOrderedItemBlocks(t) => { - let prefix = checkbox_prefix(t.checkbox.is_some(), t.checked.is_some()); - if prefix.is_empty() { - let n = t.num.clone().unwrap_or_else(|| format!("{}.", i + 1)); - out.text.push_str(&n); - if !n.ends_with(' ') { - out.text.push(' '); - } - } else { - out.text.push_str(prefix); - } - for nested in vector_boxed_items(&t.blocks) { - if is_list_block(nested) { - match nested { - PageBlock::PageBlockList(inner) => { - push_unordered_list(inner, out, indent + 1, photos) - } - PageBlock::PageBlockOrderedList(inner) => { - push_ordered_list(inner, out, indent + 1, photos) - } - _ => push_block(nested, out, photos), - } - } else { - push_block(nested, out, photos); - } - } - } - } - } -} - -fn push_heading(text: &RichText, out: &mut FormattedText, level: u8) { - let start = utf16_len(&out.text); - push_rich(text, out); - mark(out, start, "heading", Some(level.to_string())); -} - -fn push_table( - table: &tellers_mtproto::latest::api::PageBlockTableConstructor, - out: &mut FormattedText, -) { - if !matches!(table.title.as_ref(), RichText::TextEmpty(_)) { - push_rich(&table.title, out); - ensure_nl(out); - } - let mut grid = Vec::new(); - for row in vector_boxed_items(&table.rows) { - let tellers_mtproto::latest::api::PageTableRow::PageTableRow(row) = row else { - continue; - }; - let mut cells = Vec::new(); - for cell in vector_boxed_items(&row.cells) { - let tellers_mtproto::latest::api::PageTableCell::PageTableCell(cell) = cell else { - continue; - }; - cells.push(cell.text.as_deref().map(rich_plain).unwrap_or_default()); - } - if !cells.is_empty() { - grid.push(cells); - } - } - if grid.is_empty() { - return; - } - let headers = grid[0].clone(); - let body = grid[1..].to_vec(); - let start = utf16_len(&out.text); - for (index, row) in grid.iter().enumerate() { - if index > 0 { - out.text.push('\n'); - } - out.text.push('|'); - for cell in row { - out.text.push(' '); - out.text.push_str(&cell.replace(['\n', '\r'], " ")); - out.text.push_str(" |"); - } - } - mark( - out, - start, - "table", - Some(encode_table_payload(&headers, &body)), - ); -} - -fn rich_plain(text: &RichText) -> String { - let mut plain = FormattedText::default(); - push_rich(text, &mut plain); - plain.text -} - -/// Server cells travel in `url`. `\u{1e}` separates rows and `\u{1f}` separates cells. -fn encode_table_payload(headers: &[String], rows: &[Vec]) -> String { - let mut records = Vec::with_capacity(1 + rows.len()); - records.push(join_cells(headers)); - for row in rows { - records.push(join_cells(row)); - } - records.join("\u{1e}") -} - -fn join_cells(cells: &[String]) -> String { - cells - .iter() - .map(|cell| escape_table_cell(cell)) - .collect::>() - .join("\u{1f}") -} - -fn escape_table_cell(text: &str) -> String { - let mut out = String::new(); - for ch in text.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '\u{1e}' => out.push_str("\\R"), - '\u{1f}' => out.push_str("\\C"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - other => out.push(other), - } - } - out -} - -fn date_flag_bits(date: &tellers_mtproto::latest::api::TextDateConstructor) -> u32 { - let mut bits = date.flags; - if date.relative.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::RELATIVE_FLAG; - } - if date.short_time.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::SHORT_TIME_FLAG; - } - if date.long_time.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::LONG_TIME_FLAG; - } - if date.short_date.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::SHORT_DATE_FLAG; - } - if date.long_date.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::LONG_DATE_FLAG; - } - if date.day_of_week.is_some() { - bits |= tellers_mtproto::latest::api::TextDateConstructor::DAY_OF_WEEK_FLAG; - } - bits -} - -pub(crate) fn rich_text_plain(text: &RichText) -> String { - let mut out = FormattedText::default(); - push_rich(text, &mut out); - out.text -} - -pub(crate) fn rich_formatted(text: &RichText) -> FormattedText { - let mut out = FormattedText::default(); - push_rich(text, &mut out); - out -} - -fn push_rich(text: &RichText, out: &mut FormattedText) { - if out.text.len() >= 12_000 { - return; - } - let start = utf16_len(&out.text); - match text { - RichText::TextEmpty(_) | RichText::TextImage(_) => {} - RichText::TextPlain(t) => out.text.push_str(&t.text), - RichText::TextConcat(t) => { - for child in vector_boxed_items(&t.texts) { - push_rich(child, out); - } - } - RichText::TextBold(t) => { - push_rich(&t.text, out); - mark(out, start, "bold", None); - } - RichText::TextItalic(t) => { - push_rich(&t.text, out); - mark(out, start, "italic", None); - } - RichText::TextUnderline(t) => { - push_rich(&t.text, out); - mark(out, start, "underline", None); - } - RichText::TextStrike(t) => { - push_rich(&t.text, out); - mark(out, start, "strike", None); - } - RichText::TextFixed(t) => { - push_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextTonAddress(t) => { - push_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextBankCard(t) => { - push_rich(&t.text, out); - mark(out, start, "code", None); - } - RichText::TextSpoiler(t) => { - push_rich(&t.text, out); - mark(out, start, "spoiler", None); - } - RichText::TextMarked(t) => { - push_rich(&t.text, out); - mark(out, start, "marked", None); - } - RichText::TextUrl(t) => { - push_rich(&t.text, out); - mark(out, start, "text_url", Some(t.url.clone())); - } - RichText::TextEmail(t) => { - push_rich(&t.text, out); - let email = (!t.email.is_empty()).then(|| t.email.clone()); - mark(out, start, "email", email); - } - RichText::TextAutoEmail(t) => { - push_rich(&t.text, out); - mark(out, start, "email", None); - } - RichText::TextPhone(t) => { - push_rich(&t.text, out); - let phone = (!t.phone.is_empty()).then(|| t.phone.clone()); - mark(out, start, "phone", phone); - } - RichText::TextAutoPhone(t) => { - push_rich(&t.text, out); - mark(out, start, "phone", None); - } - RichText::TextHashtag(t) => { - push_rich(&t.text, out); - mark(out, start, "hashtag", None); - } - RichText::TextMention(t) => { - push_rich(&t.text, out); - mark(out, start, "mention", None); - } - RichText::TextMentionName(t) => { - push_rich(&t.text, out); - mark(out, start, "mention_name", Some(t.user_id.to_string())); - } - RichText::TextBotCommand(t) => { - push_rich(&t.text, out); - mark(out, start, "bot_command", None); - } - RichText::TextCashtag(t) => { - push_rich(&t.text, out); - mark(out, start, "cashtag", None); - } - RichText::TextAutoUrl(t) => { - push_rich(&t.text, out); - mark(out, start, "url", None); - } - RichText::TextCustomEmoji(t) => { - let alt = if t.alt.is_empty() { - "\u{FFFC}" - } else { - t.alt.as_str() - }; - out.text.push_str(alt); - mark(out, start, "custom_emoji", Some(t.document_id.to_string())); - } - RichText::TextSubscript(t) => { - push_rich(&t.text, out); - mark(out, start, "subscript", None); - } - RichText::TextSuperscript(t) => { - push_rich(&t.text, out); - mark(out, start, "superscript", None); - } - RichText::TextAnchor(t) => { - push_rich(&t.text, out); - let name = (!t.name.is_empty()).then(|| t.name.clone()); - mark(out, start, "anchor", name); - } - RichText::TextMath(t) => { - out.text.push_str(&t.source); - mark(out, start, "math", Some("inline".into())); - } - RichText::TextDate(t) => { - push_rich(&t.text, out); - mark( - out, - start, - "date", - Some(format!("{}|{}", t.date, date_flag_bits(t))), - ); - } - RichText::TextDiff(t) => push_rich(&t.text, out), - RichText::TextButton(t) => push_rich(&t.text, out), - } -} diff --git a/native/mtproto-rs/src/media/structured.rs b/native/mtproto-rs/src/media/structured.rs deleted file mode 100644 index f020d7ee3..000000000 --- a/native/mtproto-rs/src/media/structured.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - GeoPoint, Poll, PollAnswer, PollAnswerVoters, PollResults, TextWithEntities, TodoCompletion, - TodoItem, TodoList, -}; - -use crate::peers::{peer_chat_id, vector_boxed_items}; - -pub(crate) fn todo_title( - media: &tellers_mtproto::latest::api::MessageMediaToDoConstructor, -) -> Option { - let TodoList::TodoList(list) = media.todo.as_ref(); - let TextWithEntities::TextWithEntities(title) = list.title.as_ref(); - (!title.text.is_empty()).then(|| title.text.clone()) -} - -pub(crate) fn todo_to_json( - media: &tellers_mtproto::latest::api::MessageMediaToDoConstructor, -) -> String { - let TodoList::TodoList(list) = media.todo.as_ref(); - let TextWithEntities::TextWithEntities(title) = list.title.as_ref(); - let completions: HashMap = media - .completions - .as_ref() - .map(|vector| { - vector_boxed_items(vector) - .filter_map(|item| { - let TodoCompletion::TodoCompletion(body) = item; - Some((body.id, (peer_chat_id(&body.completed_by), body.date))) - }) - .collect() - }) - .unwrap_or_default(); - let items: Vec = vector_boxed_items(&list.list) - .filter_map(|item| { - let TodoItem::TodoItem(body) = item; - let TextWithEntities::TextWithEntities(title) = body.title.as_ref(); - let (by, at) = completions.get(&body.id).copied().unwrap_or((0, 0)); - Some(serde_json::json!({ - "id": body.id, - "t": title.text, - "e": crate::dialogs::entities_to_json(Some(&title.entities)), - "d": if completions.contains_key(&body.id) { 1 } else { 0 }, - "by": by, - "at": at, - })) - }) - .collect(); - serde_json::json!({ - "t": title.text, - "e": crate::dialogs::entities_to_json(Some(&title.entities)), - "a": list.others_can_append.is_some(), - "c": list.others_can_complete.is_some(), - "i": items, - }) - .to_string() -} - -/// Poll/quiz payload for the chat bubble. -pub(crate) fn poll_to_json( - media: &tellers_mtproto::latest::api::MessageMediaPollConstructor, -) -> String { - let Poll::Poll(poll) = media.poll.as_ref(); - let TextWithEntities::TextWithEntities(question) = poll.question.as_ref(); - let PollResults::PollResults(results) = media.results.as_ref(); - let by_option: HashMap<&[u8], (bool, bool, i32)> = results - .results - .as_ref() - .map(|vector| { - vector_boxed_items(vector) - .map(|item| { - let PollAnswerVoters::PollAnswerVoters(voters) = item; - ( - voters.option.as_slice(), - ( - voters.chosen.is_some(), - voters.correct.is_some(), - voters.voters.unwrap_or(0), - ), - ) - }) - .collect() - }) - .unwrap_or_default(); - let answers: Vec = vector_boxed_items(&poll.answers) - .filter_map(|item| { - let PollAnswer::PollAnswer(answer) = item else { - return None; - }; - let TextWithEntities::TextWithEntities(text) = answer.text.as_ref(); - let (chosen, correct, voters) = by_option - .get(answer.option.as_slice()) - .copied() - .unwrap_or((false, false, 0)); - Some(serde_json::json!({ - "t": text.text, - "c": if chosen { 1 } else { 0 }, - "k": if correct { 1 } else { 0 }, - "v": voters, - "o": hex_bytes(&answer.option), - })) - }) - .collect(); - serde_json::json!({ - "id": poll.id.to_string(), - "creator": if poll.creator.is_some() { 1 } else { 0 }, - "r": if poll.revoting_disabled.is_some() { 1 } else { 0 }, - "q": question.text, - "a": answers, - "n": results.total_voters.unwrap_or(0), - "z": if poll.quiz.is_some() { 1 } else { 0 }, - "m": if poll.multiple_choice.is_some() { 1 } else { 0 }, - "x": if poll.closed.is_some() { 1 } else { 0 }, - "o": if poll.public_voters.is_some() { 1 } else { 0 }, - "s": results.solution.clone().unwrap_or_default(), - }) - .to_string() -} - -/// Lowercase hex, the form the Kotlin parser turns back into vote bytes. -pub(crate) fn hex_bytes(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -/// Static geo point, or `None` for the empty variant. -pub(crate) fn geo_point_json(geo: &GeoPoint) -> Option { - let GeoPoint::GeoPoint(point) = geo else { - return None; - }; - Some(serde_json::json!({ "lat": point.lat, "long": point.long })) -} - -pub(crate) fn geo_to_json( - media: &tellers_mtproto::latest::api::MessageMediaGeoConstructor, -) -> Option { - geo_point_json(media.geo.as_ref()).map(|value| value.to_string()) -} - -/// Live location payload. -pub(crate) fn geo_live_to_json( - media: &tellers_mtproto::latest::api::MessageMediaGeoLiveConstructor, -) -> Option { - let mut value = geo_point_json(media.geo.as_ref())?; - value["live"] = serde_json::json!(1); - value["p"] = serde_json::json!(media.period); - if let Some(heading) = media.heading { - value["h"] = serde_json::json!(heading); - } - Some(value.to_string()) -} - -/// Venue payload. -pub(crate) fn venue_to_json( - media: &tellers_mtproto::latest::api::MessageMediaVenueConstructor, -) -> Option { - let mut value = geo_point_json(media.geo.as_ref())?; - value["t"] = serde_json::json!(media.title); - value["a"] = serde_json::json!(media.address); - value["p"] = serde_json::json!(media.provider); - value["i"] = serde_json::json!(media.venue_id); - value["y"] = serde_json::json!(media.venue_type); - Some(value.to_string()) -} - -/// Contact payload. -pub(crate) fn contact_to_json( - media: &tellers_mtproto::latest::api::MessageMediaContactConstructor, -) -> String { - serde_json::json!({ - "p": media.phone_number, - "f": media.first_name, - "l": media.last_name, - "c": media.vcard, - "u": media.user_id, - }) - .to_string() -} - -/// Dice/emoji game payload. -pub(crate) fn dice_to_json( - media: &tellers_mtproto::latest::api::MessageMediaDiceConstructor, -) -> String { - serde_json::json!({ "v": media.value, "e": media.emoticon }).to_string() -} - -/// Compact announcement payload shared by media without a downloadable file. -/// Keys are single letters: `t` title, `s` description, `d` detail, -/// `q` quantity, `m` months, `x` stars, `w` winners, `u` unclaimed, -/// `i` item count, `n` story id, `r` rtmp flag. -fn announcement(value: serde_json::Value) -> String { - value.to_string() -} - -pub(crate) fn giveaway_to_json( - media: &tellers_mtproto::latest::api::MessageMediaGiveawayConstructor, -) -> String { - announcement(serde_json::json!({ - "d": media.prize_description.clone().unwrap_or_default(), - "q": media.quantity, - "m": media.months, - "x": media.stars, - "e": media.until_date, - })) -} - -pub(crate) fn giveaway_results_to_json( - media: &tellers_mtproto::latest::api::MessageMediaGiveawayResultsConstructor, -) -> String { - announcement(serde_json::json!({ - "d": media.prize_description.clone().unwrap_or_default(), - "w": media.winners_count, - "u": media.unclaimed_count, - "x": media.stars, - })) -} - -pub(crate) fn paid_media_to_json( - media: &tellers_mtproto::latest::api::MessageMediaPaidMediaConstructor, -) -> String { - announcement(serde_json::json!({ - "x": media.stars_amount, - "i": vector_boxed_items(&media.extended_media).count(), - })) -} - -pub(crate) fn game_to_json( - media: &tellers_mtproto::latest::api::MessageMediaGameConstructor, -) -> String { - let tellers_mtproto::latest::api::Game::Game(game) = media.game.as_ref(); - announcement(serde_json::json!({ - "t": game.title, - "s": game.description, - "d": game.short_name, - })) -} - -pub(crate) fn invoice_to_json( - media: &tellers_mtproto::latest::api::MessageMediaInvoiceConstructor, -) -> String { - announcement(serde_json::json!({ - "t": media.title, - "s": media.description, - "d": format!("{} {}", media.total_amount, media.currency), - })) -} - -pub(crate) fn story_to_json( - media: &tellers_mtproto::latest::api::MessageMediaStoryConstructor, -) -> String { - let caption = media - .story - .as_ref() - .and_then(|story| { - if let tellers_mtproto::latest::api::StoryItem::StoryItem(item) = story.as_ref() { - item.caption.clone() - } else { - None - } - }) - .unwrap_or_default(); - announcement(serde_json::json!({ "n": media.id, "d": caption })) -} - -pub(crate) fn video_stream_to_json( - media: &tellers_mtproto::latest::api::MessageMediaVideoStreamConstructor, -) -> String { - announcement(serde_json::json!({ "r": media.rtmp_stream.is_some() })) -} diff --git a/native/mtproto-rs/src/media/thumbs.rs b/native/mtproto-rs/src/media/thumbs.rs deleted file mode 100644 index 19d47a49f..000000000 --- a/native/mtproto-rs/src/media/thumbs.rs +++ /dev/null @@ -1,213 +0,0 @@ -use tellers_mtproto::latest::api::PhotoSize; - -pub(crate) fn photo_size_type( - size: &PhotoSize, -) -> Option<(String, i32, Option>, i32, i32)> { - match size { - PhotoSize::PhotoSize(s) => Some((s.type_.clone(), s.w.saturating_mul(s.h), None, s.w, s.h)), - PhotoSize::PhotoSizeProgressive(s) => { - Some((s.type_.clone(), s.w.saturating_mul(s.h), None, s.w, s.h)) - } - PhotoSize::PhotoCachedSize(s) => { - if s.bytes.is_empty() { - return None; - } - Some(( - s.type_.clone(), - s.w.saturating_mul(s.h), - Some(s.bytes.clone()), - s.w, - s.h, - )) - } - PhotoSize::PhotoStrippedSize(s) => { - let jpeg = crate::stripped_jpeg::expand_stripped_jpeg(&s.bytes)?; - let h = i32::from(*s.bytes.get(1).unwrap_or(&40)); - let w = i32::from(*s.bytes.get(2).unwrap_or(&40)); - Some(( - s.type_.clone(), - w.saturating_mul(h).max(1), - Some(jpeg), - w, - h, - )) - } - PhotoSize::PhotoPathSize(_) | PhotoSize::PhotoSizeEmpty(_) => None, - _ => None, - } -} - -pub(crate) fn pick_photo_size(sizes: &[PhotoSize]) -> Option<(String, Option>)> { - let mut best: Option<(String, i32, Option>)> = None; - for size in sizes { - let Some((ty, area, bytes, _, _)) = photo_size_type(size) else { - continue; - }; - match &best { - None => best = Some((ty, area, bytes)), - Some((_, best_area, _)) if area >= *best_area => best = Some((ty, area, bytes)), - _ => {} - } - } - best.map(|(ty, _, bytes)| (ty, bytes)) -} - -/// Sizes `upload.getFile` can fetch. Skip stripped/path (`i`/`j`) and video -/// streaming letters (`u`/`v`) — those return `FILE_ID_INVALID`. -pub(crate) fn is_getfile_thumb_size(ty: &str) -> bool { - matches!(ty, "s" | "m" | "x" | "y" | "w" | "a" | "b" | "c") -} - -pub(crate) fn thumb_has_inline_bytes(bytes: &Option>) -> bool { - bytes.as_ref().is_some_and(|b| !b.is_empty()) -} - -pub(crate) fn is_usable_thumb(ty: &str, bytes: &Option>) -> bool { - thumb_has_inline_bytes(bytes) || is_getfile_thumb_size(ty) -} - -/// Prefer inline stripped/cached JPEG so chat thumbs paint without getFile. -/// Fall back to Telegram's `m`/`x`/`s` network thumbs. -pub(crate) fn pick_thumb_candidate( - sizes: &[(String, i32, Option>)], -) -> Option<(String, Option>)> { - let usable: Vec<&(String, i32, Option>)> = sizes - .iter() - .filter(|(ty, _, bytes)| is_usable_thumb(ty, bytes)) - .collect(); - if let Some((ty, _, bytes)) = usable - .iter() - .filter(|(_, _, bytes)| thumb_has_inline_bytes(bytes)) - .max_by_key(|(_, area, _)| *area) - { - return Some((ty.clone(), bytes.clone())); - } - for wanted in ["m", "x", "s"] { - if let Some((ty, _, bytes)) = usable.iter().find(|(ty, _, _)| ty == wanted) { - return Some((ty.clone(), bytes.clone())); - } - } - usable - .iter() - .min_by_key(|(_, area, _)| *area) - .map(|(ty, _, bytes)| (ty.clone(), bytes.clone())) -} - -/// Network preview used to sharpen a cell after an inline stripped thumb. -pub(crate) fn pick_getfile_preview( - sizes: &[(String, i32, Option>)], -) -> Option<(String, Option>)> { - let usable: Vec<&(String, i32, Option>)> = sizes - .iter() - .filter(|(ty, _, bytes)| is_getfile_thumb_size(ty) && !thumb_has_inline_bytes(bytes)) - .collect(); - for wanted in ["m", "s", "x", "a"] { - if let Some((ty, _, bytes)) = usable.iter().find(|(ty, _, _)| ty == wanted) { - return Some((ty.clone(), bytes.clone())); - } - } - usable - .iter() - .min_by_key(|(_, area, _)| *area) - .map(|(ty, _, bytes)| (ty.clone(), bytes.clone())) -} - -/// GIF/sticker/inline pickers: use cached/stripped JPEG immediately, else the -/// smallest getFile size (`s`/`a` before `m`). `m` often returns FILE_ID_INVALID -/// on inline photos. -pub(crate) fn pick_inline_or_thumb_candidate( - sizes: &[(String, i32, Option>)], -) -> Option<(String, Option>)> { - let usable: Vec<&(String, i32, Option>)> = sizes - .iter() - .filter(|(ty, _, bytes)| is_usable_thumb(ty, bytes)) - .collect(); - if let Some((ty, _, bytes)) = usable - .iter() - .filter(|(_, _, bytes)| thumb_has_inline_bytes(bytes)) - .max_by_key(|(_, area, _)| *area) - { - return Some((ty.clone(), bytes.clone())); - } - for wanted in ["s", "a", "b", "m", "x"] { - if let Some((ty, _, bytes)) = usable.iter().find(|(ty, _, _)| ty == wanted) { - return Some((ty.clone(), bytes.clone())); - } - } - pick_thumb_candidate(sizes) -} - -pub(crate) fn collect_photo_sizes(sizes: &[PhotoSize]) -> Vec<(String, i32, Option>)> { - sizes - .iter() - .filter_map(|size| photo_size_type(size).map(|(ty, area, bytes, _, _)| (ty, area, bytes))) - .collect() -} - -pub(crate) fn pick_thumb_size(sizes: &[PhotoSize]) -> Option<(String, Option>)> { - pick_thumb_candidate(&collect_photo_sizes(sizes)) -} - -pub(crate) fn pick_document_thumb_size(sizes: &[PhotoSize]) -> Option<(String, Option>)> { - pick_inline_or_thumb_candidate(&collect_photo_sizes(sizes)) -} - -/// Chat-cell JPEG: `m` (~320). `x` only if `m` is already the thumb. -/// https://core.telegram.org/api/files -pub(crate) fn pick_display_candidate( - sizes: &[(String, i32, Option>)], - thumb_ty: Option<&str>, - full_ty: &str, -) -> Option<(String, Option>)> { - let usable: Vec<&(String, i32, Option>)> = sizes - .iter() - .filter(|(ty, _, bytes)| is_usable_thumb(ty, bytes)) - .collect(); - for wanted in ["m", "s", "x"] { - if let Some((ty, _, bytes)) = usable.iter().find(|(ty, _, _)| ty == wanted) { - if Some(ty.as_str()) != thumb_ty && ty != full_ty { - return Some((ty.clone(), bytes.clone())); - } - } - } - None -} - -pub(crate) fn pick_display_size( - sizes: &[PhotoSize], - thumb_ty: Option<&str>, - full_ty: &str, -) -> Option<(String, Option>)> { - let collected: Vec<(String, i32, Option>)> = sizes - .iter() - .filter_map(|size| photo_size_type(size).map(|(ty, area, bytes, _, _)| (ty, area, bytes))) - .collect(); - pick_display_candidate(&collected, thumb_ty, full_ty) -} - -pub(crate) fn distinct_thumb_key(full_key: &str, full_ty: &str, thumb_ty: &str) -> String { - if full_ty == thumb_ty { - full_key.to_string() - } else { - format!("{full_key}:thumb") - } -} - -pub fn next_getfile_thumb_sizes(current: &str) -> &'static [&'static str] { - match current { - "s" => &["a", "m", "x"], - "a" | "b" => &["s", "m", "x"], - "m" => &["s", "x", "a"], - "x" => &["s", "m", "a"], - _ => &["s", "m", "x"], - } -} - -/// Profile video sizes. `u` is the main MPEG4 avatar; `v` is the smaller one. -pub fn next_video_avatar_sizes(current: &str) -> &'static [&'static str] { - match current { - "u" => &["v"], - "v" => &["u"], - _ => &["u", "v"], - } -} diff --git a/native/mtproto-rs/src/media/wallpaper_rpc.rs b/native/mtproto-rs/src/media/wallpaper_rpc.rs deleted file mode 100644 index 4af31a53d..000000000 --- a/native/mtproto-rs/src/media/wallpaper_rpc.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Server wallpaper catalog and document source refresh. -//! https://core.telegram.org/api/wallpapers - -use tellers_mtproto::latest::api::{ - AccountGetWallPaperRequest, AccountGetWallPapersRequest, AccountWallPapers, Document, - InputWallPaper, InputWallPaperConstructor, WallPaper, WallPaperSettings, -}; -use tellers_mtproto_session::Snapshot; - -use crate::MtprotoError; -use crate::api_invoke::invoke_api; -use crate::media::{self, MediaRef}; -use crate::peers::vector_boxed_items; - -#[derive(Clone, Debug, uniffi::Record)] -pub struct WallpaperDto { - pub id: i64, - pub access_hash: i64, - pub slug: String, - pub pattern: bool, - pub dark: bool, - pub mime_type: String, - pub document_id: Option, - pub colors: Vec, - pub intensity: Option, - pub rotation: i32, - pub blur: bool, - pub motion: bool, -} - -#[derive(Clone, Debug, uniffi::Record)] -pub struct WallpaperCatalogDto { - pub hash: i64, - pub not_modified: bool, - pub wallpapers: Vec, -} - -pub fn get_wallpapers( - snapshot: &mut Snapshot, - api_id: i32, - hash: i64, -) -> Result { - let response: AccountWallPapers = - invoke_api(snapshot, api_id, AccountGetWallPapersRequest { hash })?; - Ok(map_catalog(response, hash)) -} - -fn map_catalog(response: AccountWallPapers, previous_hash: i64) -> WallpaperCatalogDto { - match response { - AccountWallPapers::AccountWallPapers(result) => WallpaperCatalogDto { - hash: result.hash, - not_modified: false, - wallpapers: vector_boxed_items(&result.wallpapers) - .filter_map(map_wallpaper) - .collect(), - }, - AccountWallPapers::AccountWallPapersNotModified(_) => WallpaperCatalogDto { - hash: previous_hash, - not_modified: true, - wallpapers: Vec::new(), - }, - } -} - -fn map_wallpaper(wallpaper: &WallPaper) -> Option { - let (id, access_hash, slug, pattern, dark, document, settings) = match wallpaper { - WallPaper::WallPaper(w) => ( - w.id, - w.access_hash, - w.slug.clone(), - w.pattern.is_some(), - w.dark.is_some(), - Some(w.document.as_ref()), - w.settings.as_deref(), - ), - WallPaper::WallPaperNoFile(w) => ( - w.id, - 0, - String::new(), - false, - w.dark.is_some(), - None, - w.settings.as_deref(), - ), - }; - let (document_id, mime_type) = match document { - Some(Document::Document(d)) => (Some(d.id), d.mime_type.clone()), - Some(Document::DocumentEmpty(_)) => return None, - None => (None, String::new()), - }; - let mut result = WallpaperDto { - id, - access_hash, - slug, - pattern, - dark, - mime_type, - document_id, - colors: Vec::new(), - intensity: None, - rotation: 0, - blur: false, - motion: false, - }; - if let Some(WallPaperSettings::WallPaperSettings(s)) = settings { - result.colors = [ - s.background_color, - s.second_background_color, - s.third_background_color, - s.fourth_background_color, - ] - .into_iter() - .flatten() - .collect(); - result.intensity = s.intensity; - result.rotation = s.rotation.unwrap_or(0); - result.blur = s.blur.is_some(); - result.motion = s.motion.is_some(); - } - Some(result) -} - -pub fn wallpaper_media( - snapshot: &mut Snapshot, - api_id: i32, - id: i64, - access_hash: i64, -) -> Result { - let wallpaper: WallPaper = invoke_api( - snapshot, - api_id, - AccountGetWallPaperRequest { - wallpaper: Box::new(InputWallPaper::InputWallPaper(InputWallPaperConstructor { - id, - access_hash, - })), - }, - )?; - let WallPaper::WallPaper(wallpaper) = wallpaper else { - return Err(MtprotoError::Message("wallpaper has no document".into())); - }; - let Document::Document(document) = wallpaper.document.as_ref() else { - return Err(MtprotoError::Message( - "wallpaper document unavailable".into(), - )); - }; - // Bound wallpaper transfers separately from message attachments. - if document.size <= 0 || document.size > 20 * 1024 * 1024 { - return Err(MtprotoError::Message("wallpaper exceeds size limit".into())); - } - let mut media = media::media_ref_from_document(document); - media.cache_key = format!("wallpaper:{}:{}", wallpaper.id, document.id); - Ok(media) -} - -#[cfg(test)] -#[path = "../../tests/unit/media_wallpaper_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/checklist_creation.rs b/native/mtproto-rs/src/messages/checklist_creation.rs deleted file mode 100644 index 98ef4546f..000000000 --- a/native/mtproto-rs/src/messages/checklist_creation.rs +++ /dev/null @@ -1,141 +0,0 @@ -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, input_peer_from_cached, require_usable_peer}; -use crate::{HashMap, MtprotoError, OutgoingMessageResult, SendOptions}; -use tellers_mtproto::latest::api::*; -use tellers_mtproto_session::Snapshot; - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct ChecklistSendSpec { - pub title: String, - pub items: Vec, - pub others_can_append: bool, - pub others_can_complete: bool, -} - -fn text(value: &str) -> Box { - Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: value.to_owned(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }, - )) -} - -fn checklist_media(spec: &ChecklistSendSpec) -> Result { - if spec.title.trim().is_empty() - || spec.title.encode_utf16().count() > 32 - || !(1..=30).contains(&spec.items.len()) - || spec - .items - .iter() - .any(|v| v.trim().is_empty() || v.encode_utf16().count() > 64) - { - return Err(MtprotoError::Message( - "checklist configuration invalid".into(), - )); - } - let todo = TodoListConstructor { - flags: if spec.others_can_append { - TodoListConstructor::OTHERS_CAN_APPEND_FLAG - } else { - 0 - } | if spec.others_can_complete { - TodoListConstructor::OTHERS_CAN_COMPLETE_FLAG - } else { - 0 - }, - others_can_append: spec - .others_can_append - .then(|| Box::new(True::True(TrueConstructor {}))), - others_can_complete: spec - .others_can_complete - .then(|| Box::new(True::True(TrueConstructor {}))), - title: text(&spec.title), - list: Box::new(Vector::Vector(VectorConstructor { - field_0: spec.items.len() as u32, - field_1: spec - .items - .iter() - .enumerate() - .map(|(i, value)| { - Box::new(TodoItem::TodoItem(TodoItemConstructor { - id: i as i32 + 1, - title: text(value), - })) - }) - .collect(), - })), - }; - Ok(InputMedia::InputMediaTodo(InputMediaTodoConstructor { - todo: Box::new(TodoList::TodoList(todo)), - })) -} - -pub fn send_checklist_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - spec: &ChecklistSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - options.validate(random_id)?; - if random_id == 0 || reply_to_msg_id < 0 || top_msg_id < 0 { - return Err(MtprotoError::Message( - "checklist send identity invalid".into(), - )); - } - let checklist = checklist_media(spec)?; - let peer = require_usable_peer(peers, chat_id)?; - let (flags, reply_to) = super::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let mut request = MessagesSendMediaRequest { - flags, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(peer)), - reply_to, - media: Box::new(checklist), - message: String::new(), - random_id, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - let updates: Updates = crate::api_invoke::invoke_api(snapshot, api_id, request)?; - let mut staged = media.clone(); - let result = super::send_result(updates, chat_id, "", random_id, peers, &mut staged)?; - let message = match &result { - OutgoingMessageResult::Sent { message } => message, - OutgoingMessageResult::Scheduled { message } => &message.message, - }; - if message.media_kind.as_deref() != Some("todo") { - return Err(MtprotoError::Message( - "checklist response missing authoritative checklist".into(), - )); - } - *media = staged; - Ok(result) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_checklist_creation_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/contact_send.rs b/native/mtproto-rs/src/messages/contact_send.rs deleted file mode 100644 index 30684d36e..000000000 --- a/native/mtproto-rs/src/messages/contact_send.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, input_peer_from_cached, require_usable_peer}; -use crate::{HashMap, MtprotoError, OutgoingMessageResult, SendOptions}; -use tellers_mtproto::latest::api::*; -use tellers_mtproto_session::Snapshot; - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct ContactSendSpec { - pub phone: String, - pub first_name: String, - pub last_name: String, - pub vcard: String, -} - -pub(crate) fn validate_contact( - phone: &str, - first_name: &str, - last_name: &str, -) -> Result<(), MtprotoError> { - let digits = phone.strip_prefix('+').unwrap_or(phone); - if !(3..=15).contains(&digits.len()) - || !digits.bytes().all(|v| v.is_ascii_digit()) - || first_name.trim().is_empty() - || first_name.encode_utf16().count() > 256 - || last_name.encode_utf16().count() > 256 - { - return Err(MtprotoError::Message( - "contact configuration invalid".into(), - )); - } - Ok(()) -} - -fn contact_media(spec: &ContactSendSpec) -> Result { - validate_contact(&spec.phone, &spec.first_name, &spec.last_name)?; - if spec.vcard.len() > 65536 { - return Err(MtprotoError::Message("contact vcard too large".into())); - } - Ok(InputMedia::InputMediaContact( - InputMediaContactConstructor { - phone_number: spec.phone.clone(), - first_name: spec.first_name.clone(), - last_name: spec.last_name.clone(), - vcard: spec.vcard.clone(), - }, - )) -} - -pub fn send_contact_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - spec: &ContactSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - options.validate(random_id)?; - if random_id == 0 || reply_to_msg_id < 0 || top_msg_id < 0 { - return Err(MtprotoError::Message( - "contact send identity invalid".into(), - )); - } - let contact = contact_media(spec)?; - let peer = require_usable_peer(peers, chat_id)?; - let (flags, reply_to) = super::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let mut request = MessagesSendMediaRequest { - flags, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(peer)), - reply_to, - media: Box::new(contact), - message: String::new(), - random_id, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - let updates: Updates = crate::api_invoke::invoke_api(snapshot, api_id, request)?; - let mut staged = media.clone(); - let result = super::send_result(updates, chat_id, "", random_id, peers, &mut staged)?; - let message = match &result { - OutgoingMessageResult::Sent { message } => message, - OutgoingMessageResult::Scheduled { message } => &message.message, - }; - if message.media_kind.as_deref() != Some("contact") { - return Err(MtprotoError::Message( - "contact response missing authoritative contact".into(), - )); - } - *media = staged; - Ok(result) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_contact_send_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/dice.rs b/native/mtproto-rs/src/messages/dice.rs deleted file mode 100644 index c8244d243..000000000 --- a/native/mtproto-rs/src/messages/dice.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, input_peer_from_cached, require_usable_peer}; -use crate::{HashMap, MtprotoError, OutgoingMessageResult, SendOptions}; -use tellers_mtproto::latest::api::*; -use tellers_mtproto_session::Snapshot; - -fn dice_request( - peers: &HashMap, - chat_id: i64, - emoticon: &str, - reply_to_msg_id: i32, - top_msg_id: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - if random_id == 0 || reply_to_msg_id < 0 || top_msg_id < 0 { - return Err(MtprotoError::Message("dice send identity invalid".into())); - } - if !matches!(emoticon, "🎲" | "🎯" | "🏀" | "⚽" | "🎳" | "🎰") { - return Err(MtprotoError::Message("dice emoticon invalid".into())); - } - options.validate(random_id)?; - let peer = require_usable_peer(peers, chat_id)?; - let (flags, reply_to) = super::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let mut request = MessagesSendMediaRequest { - flags, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(peer)), - reply_to, - media: Box::new(InputMedia::InputMediaDice(InputMediaDiceConstructor { - emoticon: emoticon.to_owned(), - })), - message: String::new(), - random_id, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - Ok(request) -} - -pub fn send_dice_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - emoticon: &str, - reply_to_msg_id: i32, - top_msg_id: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - let request = dice_request( - peers, - chat_id, - emoticon, - reply_to_msg_id, - top_msg_id, - random_id, - options, - )?; - let updates: Updates = crate::api_invoke::invoke_api(snapshot, api_id, request)?; - let mut staged = media.clone(); - let result = super::send_result(updates, chat_id, "", random_id, peers, &mut staged)?; - let (message, scheduled) = match &result { - OutgoingMessageResult::Sent { message } => (message, false), - OutgoingMessageResult::Scheduled { message } => (&message.message, true), - }; - if !valid_dice_payload( - message.media_kind.as_deref(), - message.file_name.as_deref(), - emoticon, - scheduled, - ) { - return Err(MtprotoError::Message( - "dice response lacks authoritative result".into(), - )); - } - *media = staged; - Ok(result) -} - -fn valid_dice_payload( - kind: Option<&str>, - payload: Option<&str>, - emoticon: &str, - scheduled: bool, -) -> bool { - let Some(payload) = - payload.and_then(|value| serde_json::from_str::(value).ok()) - else { - return false; - }; - kind == Some("dice") - && payload["e"].as_str() == Some(emoticon) - && payload["v"] - .as_i64() - .is_some_and(|value| value > 0 || scheduled && value == 0) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_dice_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/entities.rs b/native/mtproto-rs/src/messages/entities.rs deleted file mode 100644 index 1aad654f8..000000000 --- a/native/mtproto-rs/src/messages/entities.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! https://core.telegram.org/type/MessageEntity - -use tellers_mtproto::latest::api::{ - MessageEntity, MessageEntityBlockquoteConstructor, MessageEntityBoldConstructor, - MessageEntityCodeConstructor, MessageEntityCustomEmojiConstructor, - MessageEntityItalicConstructor, MessageEntityPreConstructor, MessageEntitySpoilerConstructor, - MessageEntityStrikeConstructor, MessageEntityTextUrlConstructor, - MessageEntityUnderlineConstructor, MessagesSendMessageRequest, True, TrueConstructor, Vector, - VectorConstructor, -}; - -use crate::MtprotoError; -use tellers_mtproto::latest::api as tl; - -#[derive(serde::Deserialize)] -struct WireEntity { - kind: String, - offset: i32, - length: i32, - url: Option, -} - -pub(crate) fn input_entities_from_json( - json: Option<&str>, - peers: &crate::HashMap, -) -> Result<(u32, Option>>>), MtprotoError> { - let (flag, mut entities) = entities_from_json(json)?; - if let Some(vector) = entities.as_deref_mut() { - if let Vector::Vector(items) = vector { - for entity in &mut items.field_1 { - if let MessageEntity::MessageEntityMentionName(mention) = entity.as_ref() { - let peer = crate::peers::require_usable_peer(peers, mention.user_id)?; - if peer.kind != crate::peers::PeerKind::User - || peer.id != mention.user_id - || peer.id <= 0 - { - return Err(MtprotoError::Message( - "named mention requires a full user peer".into(), - )); - } - **entity = MessageEntity::InputMessageEntityMentionName( - tl::InputMessageEntityMentionNameConstructor { - offset: mention.offset, - length: mention.length, - user_id: Box::new(tl::InputUser::InputUser(tl::InputUserConstructor { - user_id: peer.id, - access_hash: peer.access_hash, - })), - }, - ); - } - } - } - } - Ok((flag, entities)) -} - -pub(crate) fn entities_from_json( - json: Option<&str>, -) -> Result<(u32, Option>>>), MtprotoError> { - let Some(raw) = json.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok((0, None)); - }; - let parsed: Vec = serde_json::from_str(raw) - .map_err(|e| MtprotoError::Message(format!("entities json: {e}")))?; - let items: Vec> = parsed - .into_iter() - .filter_map(wire_entity) - .map(Box::new) - .collect(); - if items.is_empty() { - return Ok((0, None)); - } - Ok(( - MessagesSendMessageRequest::ENTITIES_FLAG, - Some(Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - }))), - )) -} - -fn wire_entity(entity: WireEntity) -> Option { - if entity.length <= 0 || entity.offset < 0 { - return None; - } - let offset = entity.offset; - let length = entity.length; - Some(match entity.kind.as_str() { - "bold" | "heading" => { - MessageEntity::MessageEntityBold(MessageEntityBoldConstructor { offset, length }) - } - "italic" => { - MessageEntity::MessageEntityItalic(MessageEntityItalicConstructor { offset, length }) - } - "underline" => MessageEntity::MessageEntityUnderline(MessageEntityUnderlineConstructor { - offset, - length, - }), - "strike" => { - MessageEntity::MessageEntityStrike(MessageEntityStrikeConstructor { offset, length }) - } - "code" => MessageEntity::MessageEntityCode(MessageEntityCodeConstructor { offset, length }), - "pre" => MessageEntity::MessageEntityPre(MessageEntityPreConstructor { - offset, - length, - language: entity.url.unwrap_or_default(), - }), - "spoiler" => { - MessageEntity::MessageEntitySpoiler(MessageEntitySpoilerConstructor { offset, length }) - } - "blockquote" => { - let collapsed = entity.url.as_deref() == Some("collapsed"); - MessageEntity::MessageEntityBlockquote(MessageEntityBlockquoteConstructor { - flags: if collapsed { - MessageEntityBlockquoteConstructor::COLLAPSED_FLAG - } else { - 0 - }, - collapsed: collapsed.then(|| Box::new(True::True(TrueConstructor {}))), - offset, - length, - }) - } - "url" => { - MessageEntity::MessageEntityUrl(tl::MessageEntityUrlConstructor { offset, length }) - } - "mention" => MessageEntity::MessageEntityMention(tl::MessageEntityMentionConstructor { - offset, - length, - }), - "hashtag" => MessageEntity::MessageEntityHashtag(tl::MessageEntityHashtagConstructor { - offset, - length, - }), - "bot_command" => { - MessageEntity::MessageEntityBotCommand(tl::MessageEntityBotCommandConstructor { - offset, - length, - }) - } - "email" => { - MessageEntity::MessageEntityEmail(tl::MessageEntityEmailConstructor { offset, length }) - } - "phone" => { - MessageEntity::MessageEntityPhone(tl::MessageEntityPhoneConstructor { offset, length }) - } - "cashtag" => MessageEntity::MessageEntityCashtag(tl::MessageEntityCashtagConstructor { - offset, - length, - }), - "bank_card" => MessageEntity::MessageEntityBankCard(tl::MessageEntityBankCardConstructor { - offset, - length, - }), - "text_url" => { - let url = entity.url.filter(|s| !s.is_empty())?; - MessageEntity::MessageEntityTextUrl(MessageEntityTextUrlConstructor { - offset, - length, - url, - }) - } - "custom_emoji" => { - let document_id = entity.url.as_deref()?.parse().ok()?; - MessageEntity::MessageEntityCustomEmoji(MessageEntityCustomEmojiConstructor { - offset, - length, - document_id, - }) - } - "mention_name" => { - let user_id = entity.url.as_deref()?.parse().ok()?; - MessageEntity::MessageEntityMentionName( - tellers_mtproto::latest::api::MessageEntityMentionNameConstructor { - offset, - length, - user_id, - }, - ) - } - _ => return None, - }) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_entities_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/inline_rpc.rs b/native/mtproto-rs/src/messages/inline_rpc.rs deleted file mode 100644 index e383782cc..000000000 --- a/native/mtproto-rs/src/messages/inline_rpc.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Inline bot queries and send. -//! https://core.telegram.org/api/bots/inline -//! https://core.telegram.org/method/contacts.resolveUsername -//! https://core.telegram.org/method/messages.getInlineBotResults -//! https://core.telegram.org/method/messages.sendInlineBotResult - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - BotInlineResult, Chat as TlChat, ContactsResolveUsernameRequest, ContactsResolvedPeer, - Document, InputPeer, InputPeerEmptyConstructor, InputUser, InputUserConstructor, - MessagesBotResults, MessagesGetInlineBotResultsRequest, MessagesSendInlineBotResultRequest, - Photo, True, TrueConstructor, Updates, User, WebDocument, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::dialogs; -use crate::media::{self, MediaIndex}; -use crate::messages; -use crate::peers::{ - self, CachedPeer, PeerKind, input_peer_from_cached, peer_chat_id, vector_boxed_items, -}; -use crate::{InlineBotResultDto, InlineBotResultsDto, MessageDto, MtprotoError, ResolvedPeerDto}; - -pub fn strip_username(raw: &str) -> String { - raw.trim().trim_start_matches('@').trim().to_string() -} - -pub fn resolve_username( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - username: &str, -) -> Result { - let username = strip_username(username); - if username.is_empty() { - return Err(MtprotoError::Message("empty username".into())); - } - let response: ContactsResolvedPeer = api_invoke::invoke_api( - snapshot, - api_id, - ContactsResolveUsernameRequest { - flags: 0, - username, - referer: None, - }, - )?; - let ContactsResolvedPeer::ContactsResolvedPeer(body) = response; - dialogs::cache_from_users_chats( - peers, - media, - vector_boxed_items(&body.users).cloned(), - vector_boxed_items(&body.chats).cloned(), - ); - Ok(resolved_from_peer( - &body.peer, - vector_boxed_items(&body.users), - vector_boxed_items(&body.chats), - )) -} - -fn inline_peer_rejected(err: &MtprotoError) -> bool { - let MtprotoError::Message(message) = err else { - return false; - }; - let text = message.to_ascii_uppercase(); - text.contains("PEER_ID_INVALID") - || text.contains("CHANNEL_PRIVATE") - || text.contains("CHANNEL_INVALID") - || text.contains("CHAT_ID_INVALID") - || text.contains("RPC 406") -} - -pub fn get_inline_bot_results( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - chat_id: i64, - bot_id: i64, - query: &str, - offset: &str, -) -> Result { - let bot = input_user(peers, bot_id)?; - let request = |peer: InputPeer| MessagesGetInlineBotResultsRequest { - flags: 0, - bot: Box::new(bot.clone()), - peer: Box::new(peer), - geo_point: None, - query: query.to_string(), - offset: offset.to_string(), - }; - let peer = if chat_id == 0 { - InputPeer::InputPeerEmpty(InputPeerEmptyConstructor {}) - } else { - input_peer_from_cached(peers::require_usable_peer(peers, chat_id)?) - }; - let response: MessagesBotResults = match api_invoke::invoke_api(snapshot, api_id, request(peer)) - { - Ok(value) => value, - Err(err) if chat_id != 0 && inline_peer_rejected(&err) => api_invoke::invoke_api( - snapshot, - api_id, - request(InputPeer::InputPeerEmpty(InputPeerEmptyConstructor {})), - )?, - Err(err) => return Err(err), - }; - let MessagesBotResults::MessagesBotResults(body) = response; - dialogs::cache_from_users_chats( - peers, - media, - vector_boxed_items(&body.users).cloned(), - std::iter::empty::(), - ); - Ok(results_from_body(&body, media)) -} - -pub fn send_inline_bot_result( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - query_id: i64, - result_id: &str, - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (reply_flag, reply_to) = messages::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let flags = reply_flag | MessagesSendInlineBotResultRequest::CLEAR_DRAFT_FLAG; - let request = MessagesSendInlineBotResultRequest { - flags, - silent: None, - background: None, - clear_draft: Some(Box::new(True::True(TrueConstructor {}))), - hide_via: None, - peer: Box::new(input_peer_from_cached(cached)), - reply_to, - random_id: messages::random_id(), - query_id, - id: result_id.to_string(), - schedule_date: None, - send_as: None, - quick_reply_shortcut: None, - allow_paid_stars: None, - }; - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(messages::message_from_updates(updates, chat_id, "", media)) -} - -fn input_user(peers: &HashMap, bot_id: i64) -> Result { - let cached = peers::require_usable_peer(peers, bot_id)?; - if cached.kind != PeerKind::User { - return Err(MtprotoError::Message("inline bot is not a user".into())); - } - Ok(InputUser::InputUser(InputUserConstructor { - user_id: cached.id, - access_hash: cached.access_hash, - })) -} - -fn resolved_from_peer<'a>( - peer: &tellers_mtproto::latest::api::Peer, - users: impl Iterator, - chats: impl Iterator, -) -> ResolvedPeerDto { - let peer_id = peer_chat_id(peer); - let mut title = String::new(); - let mut username = None; - let mut is_bot = false; - for user in users { - if let User::User(u) = user { - if peers::chat_id_for_user(u.id) == peer_id { - title = display_name( - u.first_name.as_deref(), - u.last_name.as_deref(), - u.username.as_deref(), - ); - username = u.username.clone(); - is_bot = u.bot.is_some(); - } - } - } - if title.is_empty() { - for chat in chats { - match chat { - TlChat::Chat(c) if peers::chat_id_for_chat(c.id) == peer_id => { - title = c.title.clone(); - } - TlChat::Channel(c) if peers::chat_id_for_channel(c.id) == peer_id => { - title = c.title.clone(); - username = c.username.clone(); - } - _ => {} - } - } - } - if title.is_empty() { - title = peer_id.to_string(); - } - ResolvedPeerDto { - peer_id, - username, - title, - is_bot, - } -} - -fn display_name(first: Option<&str>, last: Option<&str>, username: Option<&str>) -> String { - let joined = [first.unwrap_or(""), last.unwrap_or("")] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join(" "); - if !joined.is_empty() { - joined - } else { - username.unwrap_or("").to_string() - } -} - -fn results_from_body( - body: &tellers_mtproto::latest::api::MessagesBotResultsConstructor, - media: &mut MediaIndex, -) -> InlineBotResultsDto { - let mut results = Vec::new(); - for item in vector_boxed_items(&body.results) { - results.push(map_inline_result(item, media)); - } - InlineBotResultsDto { - query_id: body.query_id, - gallery: body.gallery.is_some(), - next_offset: body.next_offset.clone().filter(|s| !s.is_empty()), - cache_time: body.cache_time, - results, - } -} - -fn web_thumb_url(doc: Option<&WebDocument>) -> Option { - match doc? { - WebDocument::WebDocument(body) => Some(body.url.clone()), - WebDocument::WebDocumentNoProxy(body) => Some(body.url.clone()), - _ => None, - } - .filter(|url| url.starts_with("https://") || url.starts_with("http://")) -} - -fn map_inline_result(item: &BotInlineResult, media: &mut MediaIndex) -> InlineBotResultDto { - match item { - BotInlineResult::BotInlineResult(body) => InlineBotResultDto { - id: body.id.clone(), - kind: body.type_.clone(), - title: body.title.clone(), - description: body.description.clone(), - url: body.url.clone(), - document_id: None, - thumb_cache_key: web_thumb_url(body.thumb.as_deref()).or_else(|| { - if body.type_ == "photo" { - web_thumb_url(body.content.as_deref()) - } else { - None - } - }), - }, - BotInlineResult::BotInlineMediaResult(body) => { - let mut document_id = None; - let mut thumb_cache_key = None; - if let Some(doc) = body.document.as_ref() { - if let Document::Document(d) = doc.as_ref() { - document_id = Some(d.id); - let mut indexed = media::media_ref_from_document(d); - indexed.cache_key = format!("doc:{}", d.id); - media::attach_document_thumbs(d, &mut indexed); - thumb_cache_key = indexed - .thumb_cache_key - .clone() - .or_else(|| Some(indexed.cache_key.clone())); - media::insert_media(media, (d.id, 0), indexed); - } - } else if let Some(photo) = body.photo.as_ref() { - if let Photo::Photo(p) = photo.as_ref() { - let cache_key = format!("photo:{}", p.id); - if let Some(indexed) = - media::media_ref_from_photo_with_thumbs(photo.as_ref(), cache_key) - { - thumb_cache_key = indexed - .thumb_cache_key - .clone() - .or_else(|| Some(indexed.cache_key.clone())); - media::insert_media(media, (p.id, 0), indexed); - } - } - } - InlineBotResultDto { - id: body.id.clone(), - kind: body.type_.clone(), - title: body.title.clone(), - description: body.description.clone(), - url: None, - document_id, - thumb_cache_key, - } - } - } -} - -#[cfg(test)] -#[path = "../../tests/unit/inline_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/location_send.rs b/native/mtproto-rs/src/messages/location_send.rs deleted file mode 100644 index 42c0b544c..000000000 --- a/native/mtproto-rs/src/messages/location_send.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, input_peer_from_cached, require_usable_peer}; -use crate::{HashMap, MtprotoError, OutgoingMessageResult, SendOptions}; -use tellers_mtproto::latest::api::*; -use tellers_mtproto_session::Snapshot; - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct LocationSendSpec { - pub latitude: f64, - pub longitude: f64, - pub live_period: i32, - pub heading: Option, -} - -pub(crate) fn location_send_request( - peers: &HashMap, - chat_id: i64, - spec: &LocationSendSpec, - reply: i32, - top: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - options.validate(random_id)?; - if random_id == 0 - || reply < 0 - || top < 0 - || !spec.latitude.is_finite() - || !spec.longitude.is_finite() - || !(-90.0..=90.0).contains(&spec.latitude) - || !(-180.0..=180.0).contains(&spec.longitude) - || spec.live_period < 0 - || (spec.live_period > 0 - && spec.live_period != i32::MAX - && !(60..=86400).contains(&spec.live_period)) - || spec.heading.is_some_and(|h| !(1..=360).contains(&h)) - || (spec.live_period == 0 && spec.heading.is_some()) - || (spec.live_period > 0 && options.schedule_date.is_some()) - { - return Err(MtprotoError::Message( - "location send configuration invalid".into(), - )); - } - let point = Box::new(InputGeoPoint::InputGeoPoint(InputGeoPointConstructor { - flags: 0, - lat: spec.latitude, - long: spec.longitude, - accuracy_radius: None, - })); - let media = if spec.live_period == 0 { - InputMedia::InputMediaGeoPoint(InputMediaGeoPointConstructor { geo_point: point }) - } else { - InputMedia::InputMediaGeoLive(InputMediaGeoLiveConstructor { - flags: InputMediaGeoLiveConstructor::PERIOD_FLAG - | if spec.heading.is_some() { - InputMediaGeoLiveConstructor::HEADING_FLAG - } else { - 0 - }, - stopped: None, - geo_point: point, - heading: spec.heading, - period: Some(spec.live_period), - proximity_notification_radius: None, - }) - }; - let (flags, reply_to) = super::input_reply_to_thread(reply, top); - let mut request = MessagesSendMediaRequest { - flags, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(require_usable_peer(peers, chat_id)?)), - reply_to, - media: Box::new(media), - message: String::new(), - random_id, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - Ok(request) -} - -pub fn send_location_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - spec: &LocationSendSpec, - reply: i32, - top: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - let request = location_send_request(peers, chat_id, spec, reply, top, random_id, options)?; - let updates: Updates = crate::api_invoke::invoke_api(snapshot, api_id, request)?; - let mut staged = media.clone(); - let result = super::send_result(updates, chat_id, "", random_id, peers, &mut staged)?; - let message = match &result { - OutgoingMessageResult::Sent { message } => message, - OutgoingMessageResult::Scheduled { message } => &message.message, - }; - if message.media_kind.as_deref() != Some("geo") { - return Err(MtprotoError::Message( - "location response missing authoritative media".into(), - )); - } - *media = staged; - Ok(result) -} diff --git a/native/mtproto-rs/src/messages/mod.rs b/native/mtproto-rs/src/messages/mod.rs deleted file mode 100644 index 0c8ab09d8..000000000 --- a/native/mtproto-rs/src/messages/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Send, search, and read-history RPCs. -//! https://core.telegram.org/api/sending -//! https://core.telegram.org/method/messages.sendMessage -//! https://core.telegram.org/api/offsets - -pub(crate) mod location_send; -pub use location_send::LocationSendSpec; -pub(crate) use location_send::send_location_with_options; -mod contact_send; -mod entities; -pub(crate) mod inline_rpc; -mod read; -pub(crate) mod read_receipts_rpc; -mod search; -pub(crate) mod search_rpc; -mod send; -pub use contact_send::ContactSendSpec; -pub(crate) use contact_send::{send_contact_with_options, validate_contact}; -mod checklist_creation; -pub use checklist_creation::ChecklistSendSpec; -pub(crate) use checklist_creation::send_checklist_with_options; -mod poll_creation; -pub use poll_creation::PollSendSpec; -pub(crate) use poll_creation::send_poll_with_options; -mod dice; -pub(crate) use dice::send_dice_with_options; -mod send_options; -pub use send_options::{OutgoingMessageResult, SendOptions}; -pub(crate) use send_options::{album_send_results, implicit_ack, send_result}; -pub(crate) mod sticker_rpc; - -pub(crate) use entities::*; -pub use read::*; -pub use search::*; -pub use send::*; -pub(crate) use send::{ - PHOTO_MAX, PHOTO_PART, all_new_messages, input_reply_to_thread, message_from_updates, random_id, -}; - -#[cfg(test)] -#[path = "../../tests/unit/messages_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/poll_creation.rs b/native/mtproto-rs/src/messages/poll_creation.rs deleted file mode 100644 index 67e27fd4b..000000000 --- a/native/mtproto-rs/src/messages/poll_creation.rs +++ /dev/null @@ -1,191 +0,0 @@ -use crate::media::MediaIndex; -use crate::peers::{CachedPeer, input_peer_from_cached, require_usable_peer}; -use crate::{HashMap, MtprotoError, OutgoingMessageResult, SendOptions}; -use tellers_mtproto::latest::api::*; -use tellers_mtproto_session::Snapshot; - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, uniffi::Record)] -pub struct PollSendSpec { - pub question: String, - pub answers: Vec, - pub anonymous: bool, - pub multiple_choice: bool, - pub quiz: bool, - pub correct_answer: Option, - pub solution: Option, -} - -fn text(value: &str) -> Box { - Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: value.to_owned(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }, - )) -} - -fn poll_media(spec: &PollSendSpec) -> Result { - if spec.question.trim().is_empty() - || spec.question.encode_utf16().count() > 255 - || !(2..=10).contains(&spec.answers.len()) - || spec - .answers - .iter() - .any(|v| v.trim().is_empty() || v.encode_utf16().count() > 100) - || spec.quiz && (spec.multiple_choice || spec.correct_answer.is_none()) - || !spec.quiz && (spec.correct_answer.is_some() || spec.solution.is_some()) - || spec - .correct_answer - .is_some_and(|v| v as usize >= spec.answers.len()) - || spec - .solution - .as_ref() - .is_some_and(|v| v.trim().is_empty() || v.encode_utf16().count() > 200) - { - return Err(MtprotoError::Message("poll configuration invalid".into())); - } - let yes = || Some(Box::new(True::True(TrueConstructor {}))); - let poll = PollConstructor { - id: 0, - flags: if spec.anonymous { - 0 - } else { - PollConstructor::PUBLIC_VOTERS_FLAG - } | if spec.multiple_choice { - PollConstructor::MULTIPLE_CHOICE_FLAG - } else { - 0 - } | if spec.quiz { - PollConstructor::QUIZ_FLAG - } else { - 0 - }, - closed: None, - public_voters: if spec.anonymous { None } else { yes() }, - multiple_choice: if spec.multiple_choice { yes() } else { None }, - quiz: if spec.quiz { yes() } else { None }, - open_answers: None, - revoting_disabled: None, - shuffle_answers: None, - hide_results_until_close: None, - creator: None, - subscribers_only: None, - question: text(&spec.question), - answers: Box::new(Vector::Vector(VectorConstructor { - field_0: spec.answers.len() as u32, - field_1: spec - .answers - .iter() - .enumerate() - .map(|(i, v)| { - Box::new(PollAnswer::PollAnswer(PollAnswerConstructor { - flags: 0, - text: text(v), - option: vec![b'0' + i as u8], - media: None, - added_by: None, - date: None, - })) - }) - .collect(), - })), - close_period: None, - close_date: None, - countries_iso2: None, - hash: 0, - }; - Ok(InputMedia::InputMediaPoll(InputMediaPollConstructor { - flags: if spec.correct_answer.is_some() { - InputMediaPollConstructor::CORRECT_ANSWERS_FLAG - } else { - 0 - } | if spec.solution.is_some() { - InputMediaPollConstructor::SOLUTION_FLAG - } else { - 0 - }, - poll: Box::new(Poll::Poll(poll)), - correct_answers: spec.correct_answer.map(|i| { - Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![i as i32], - })) - }), - attached_media: None, - solution: spec.solution.clone(), - solution_entities: spec.solution.as_ref().map(|_| { - Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })) - }), - solution_media: None, - })) -} - -pub fn send_poll_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media: &mut MediaIndex, - chat_id: i64, - spec: &PollSendSpec, - reply_to_msg_id: i32, - top_msg_id: i32, - random_id: i64, - options: &SendOptions, -) -> Result { - options.validate(random_id)?; - if random_id == 0 || reply_to_msg_id < 0 || top_msg_id < 0 { - return Err(MtprotoError::Message("poll send identity invalid".into())); - } - let poll = poll_media(spec)?; - let peer = require_usable_peer(peers, chat_id)?; - let (flags, reply_to) = super::input_reply_to_thread(reply_to_msg_id, top_msg_id); - let mut request = MessagesSendMediaRequest { - flags, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(peer)), - reply_to, - media: Box::new(poll), - message: String::new(), - random_id, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - let updates: Updates = crate::api_invoke::invoke_api(snapshot, api_id, request)?; - let mut staged = media.clone(); - let result = super::send_result(updates, chat_id, "", random_id, peers, &mut staged)?; - let message = match &result { - OutgoingMessageResult::Sent { message } => message, - OutgoingMessageResult::Scheduled { message } => &message.message, - }; - if message.media_kind.as_deref() != Some("poll") { - return Err(MtprotoError::Message( - "poll response missing authoritative poll".into(), - )); - } - *media = staged; - Ok(result) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_poll_creation_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/read.rs b/native/mtproto-rs/src/messages/read.rs deleted file mode 100644 index 3e85ecd77..000000000 --- a/native/mtproto-rs/src/messages/read.rs +++ /dev/null @@ -1,297 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - Bool, ChannelsReadHistoryRequest, ChannelsReadMessageContentsRequest, InputChannel, - InputChannelConstructor, InputDialogPeer, InputDialogPeerConstructor, MessagesAffectedHistory, - MessagesAffectedMessages, MessagesGetUnreadMentionsRequest, MessagesGetUnreadReactionsRequest, - MessagesMarkDialogUnreadRequest, MessagesMessages, MessagesReadDiscussionRequest, - MessagesReadHistoryRequest, MessagesReadMentionsRequest, MessagesReadMessageContentsRequest, - MessagesReadReactionsRequest, MessagesSetTypingRequest, SendMessageAction, - SendMessageCancelActionConstructor, SendMessageTypingActionConstructor, True, TrueConstructor, - Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::dialogs::dtos_from_messages; -use crate::dialogs::rich_rpc; -use crate::media::MediaIndex; -use crate::peers::{self, CachedPeer, PeerKind, channel_id_from_chat_id, input_peer_from_cached}; -use crate::{MessageDto, MtprotoError}; - -pub fn read_history( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - max_id: i32, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - if cached.kind == PeerKind::Channel { - // https://core.telegram.org/method/channels.readHistory - let channel_id = channel_id_from_chat_id(chat_id) - .ok_or_else(|| MtprotoError::Message("not a channel".into()))?; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsReadHistoryRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id, - access_hash: cached.access_hash, - })), - max_id, - }, - )?; - return Ok(()); - } - // https://core.telegram.org/method/messages.readHistory - let _: MessagesAffectedMessages = api_invoke::invoke_api( - snapshot, - api_id, - MessagesReadHistoryRequest { - peer: Box::new(input_peer_from_cached(cached)), - max_id, - }, - )?; - Ok(()) -} - -/// https://core.telegram.org/method/messages.readMessageContents -/// https://core.telegram.org/method/channels.readMessageContents -pub fn read_message_contents( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_ids: Vec, -) -> Result<(), MtprotoError> { - let message_ids: Vec = message_ids.into_iter().filter(|id| *id > 0).collect(); - if message_ids.is_empty() { - return Ok(()); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let ids = Box::new(Vector::Vector(VectorConstructor { - field_0: message_ids.len() as u32, - field_1: message_ids, - })); - if cached.kind == PeerKind::Channel { - let channel_id = channel_id_from_chat_id(chat_id) - .ok_or_else(|| MtprotoError::Message("not a channel".into()))?; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsReadMessageContentsRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id, - access_hash: cached.access_hash, - })), - id: ids, - }, - )?; - } else { - let _: MessagesAffectedMessages = api_invoke::invoke_api( - snapshot, - api_id, - MessagesReadMessageContentsRequest { id: ids }, - )?; - } - Ok(()) -} - -/// https://core.telegram.org/method/messages.markDialogUnread -pub fn mark_dialog_unread( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - unread: bool, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesMarkDialogUnreadRequest { - flags: if unread { 1 } else { 0 }, - unread: unread.then(|| Box::new(True::True(TrueConstructor {}))), - parent_peer: None, - peer: Box::new(InputDialogPeer::InputDialogPeer( - InputDialogPeerConstructor { - peer: Box::new(input_peer_from_cached(cached)), - }, - )), - }, - )?; - Ok(()) -} - -/// https://core.telegram.org/method/messages.readDiscussion -pub fn read_discussion( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - msg_id: i32, - read_max_id: i32, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesReadDiscussionRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id, - read_max_id, - }, - )?; - Ok(()) -} - -pub fn set_typing( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - typing: bool, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let action = if typing { - SendMessageAction::SendMessageTypingAction(SendMessageTypingActionConstructor {}) - } else { - SendMessageAction::SendMessageCancelAction(SendMessageCancelActionConstructor {}) - }; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - MessagesSetTypingRequest { - flags: 0, - peer: Box::new(input_peer_from_cached(cached)), - top_msg_id: None, - action: Box::new(action), - }, - )?; - Ok(()) -} - -fn top_msg_flag(value: i32) -> (u32, Option) { - if value > 0 { - (1_u32 << 0, Some(value)) - } else { - (0, None) - } -} - -/// https://core.telegram.org/method/messages.getUnreadMentions -/// https://core.telegram.org/api/offsets -pub fn get_unread_mentions( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - offset_id: i32, - add_offset: i32, - limit: i32, - top_msg_id: i32, - user_id: Option, -) -> Result, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (flags, top) = top_msg_flag(top_msg_id); - let response: MessagesMessages = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetUnreadMentionsRequest { - flags, - peer: Box::new(input_peer_from_cached(cached)), - top_msg_id: top, - offset_id, - add_offset, - limit: limit.clamp(1, 100), - max_id: 0, - min_id: 0, - }, - )?; - let mut dtos = dtos_from_messages(response, peers, media_index)?; - rich_rpc::fill_unsupported_rich_text(snapshot, api_id, peers, user_id, &mut dtos); - Ok(dtos) -} - -/// https://core.telegram.org/method/messages.readMentions -pub fn read_mentions( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - top_msg_id: i32, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (flags, top) = top_msg_flag(top_msg_id); - let _: MessagesAffectedHistory = api_invoke::invoke_api( - snapshot, - api_id, - MessagesReadMentionsRequest { - flags, - peer: Box::new(input_peer_from_cached(cached)), - top_msg_id: top, - }, - )?; - Ok(()) -} - -/// https://core.telegram.org/method/messages.getUnreadReactions -/// https://core.telegram.org/api/offsets -pub fn get_unread_reactions( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - offset_id: i32, - add_offset: i32, - limit: i32, - top_msg_id: i32, - user_id: Option, -) -> Result, MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (flags, top) = top_msg_flag(top_msg_id); - let response: MessagesMessages = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetUnreadReactionsRequest { - flags, - peer: Box::new(input_peer_from_cached(cached)), - top_msg_id: top, - saved_peer_id: None, - offset_id, - add_offset, - limit: limit.clamp(1, 100), - max_id: 0, - min_id: 0, - }, - )?; - let mut dtos = dtos_from_messages(response, peers, media_index)?; - rich_rpc::fill_unsupported_rich_text(snapshot, api_id, peers, user_id, &mut dtos); - Ok(dtos) -} - -/// https://core.telegram.org/method/messages.readReactions -pub fn read_reactions( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - top_msg_id: i32, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (flags, top) = top_msg_flag(top_msg_id); - let _: MessagesAffectedHistory = api_invoke::invoke_api( - snapshot, - api_id, - MessagesReadReactionsRequest { - flags, - peer: Box::new(input_peer_from_cached(cached)), - top_msg_id: top, - saved_peer_id: None, - }, - )?; - Ok(()) -} diff --git a/native/mtproto-rs/src/messages/read_receipts_rpc.rs b/native/mtproto-rs/src/messages/read_receipts_rpc.rs deleted file mode 100644 index 1d38e2c21..000000000 --- a/native/mtproto-rs/src/messages/read_receipts_rpc.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! https://core.telegram.org/method/messages.getMessageReadParticipants -//! https://core.telegram.org/method/messages.getOutboxReadDate -//! https://core.telegram.org/api/config - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - HelpAppConfig, HelpGetAppConfigRequest, JsonObjectValue, JsonValue, - MessagesGetMessageReadParticipantsRequest, MessagesGetOutboxReadDateRequest, OutboxReadDate, - ReadParticipantDate, Vector, -}; -use tellers_mtproto_session::Snapshot; - -use crate::MtprotoError; -use crate::api_invoke; -use crate::peers::{self, CachedPeer, input_peer_from_cached}; - -pub const DEFAULT_CHAT_READ_MARK_SIZE_THRESHOLD: i32 = 100; -pub const DEFAULT_CHAT_READ_MARK_EXPIRE_PERIOD: i32 = 604_800; -pub const DEFAULT_PM_READ_DATE_EXPIRE_PERIOD: i32 = 604_800; - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ReadParticipantDto { - pub peer_id: i64, - pub date: i32, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ReadParticipantsDto { - pub participants: Vec, - pub error: Option, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct OutboxReadDto { - pub date: i32, - pub error: Option, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ReadReceiptConfigDto { - pub chat_read_mark_size_threshold: i32, - pub chat_read_mark_expire_period: i32, - pub pm_read_date_expire_period: i32, - pub from_server: bool, -} - -fn known_error(err: &MtprotoError) -> Option { - let MtprotoError::Message(text) = err else { - return None; - }; - const KNOWN: [&str; 8] = [ - "CHAT_TOO_BIG", - "MSG_TOO_OLD", - "MSG_ID_INVALID", - "PEER_ID_INVALID", - "MESSAGE_TOO_OLD", - "MESSAGE_NOT_READ_YET", - "MESSAGE_ID_INVALID", - "PRIVACY_RESTRICTED", - ]; - KNOWN - .iter() - .find(|name| text.contains(**name)) - .map(|name| (*name).to_string()) -} - -pub fn get_message_read_participants( - snapshot: &mut Snapshot, - api_id: i32, - peers_map: &HashMap, - chat_id: i64, - msg_id: i32, -) -> Result { - let cached = peers::require_usable_peer(peers_map, chat_id)?; - let request = MessagesGetMessageReadParticipantsRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id, - }; - let response: Vector> = - match api_invoke::invoke_api(snapshot, api_id, request) { - Ok(value) => value, - Err(err) => { - return match known_error(&err) { - Some(name) => Ok(ReadParticipantsDto { - participants: Vec::new(), - error: Some(name), - }), - None => Err(err), - }; - } - }; - let mut participants = Vec::new(); - for entry in crate::peers::vector_boxed_items(&response) { - if let ReadParticipantDate::ReadParticipantDate(value) = entry { - participants.push(ReadParticipantDto { - peer_id: value.user_id, - date: value.date, - }); - } - } - Ok(ReadParticipantsDto { - participants, - error: None, - }) -} - -pub fn get_outbox_read_date( - snapshot: &mut Snapshot, - api_id: i32, - peers_map: &HashMap, - chat_id: i64, - msg_id: i32, -) -> Result { - let cached = peers::require_usable_peer(peers_map, chat_id)?; - let request = MessagesGetOutboxReadDateRequest { - peer: Box::new(input_peer_from_cached(cached)), - msg_id, - }; - let response: OutboxReadDate = match api_invoke::invoke_api(snapshot, api_id, request) { - Ok(value) => value, - Err(err) => { - return match known_error(&err) { - Some(name) => Ok(OutboxReadDto { - date: -1, - error: Some(name), - }), - None => Err(err), - }; - } - }; - let OutboxReadDate::OutboxReadDate(value) = response; - Ok(OutboxReadDto { - date: value.date, - error: None, - }) -} - -pub fn fetch_read_receipt_config( - snapshot: &mut Snapshot, - api_id: i32, -) -> Result { - let response: HelpAppConfig = - api_invoke::invoke_api(snapshot, api_id, HelpGetAppConfigRequest { hash: 0 })?; - let HelpAppConfig::HelpAppConfig(config) = response else { - return Ok(default_config(false)); - }; - let mut found = ReadReceiptConfigDto { - chat_read_mark_size_threshold: DEFAULT_CHAT_READ_MARK_SIZE_THRESHOLD, - chat_read_mark_expire_period: DEFAULT_CHAT_READ_MARK_EXPIRE_PERIOD, - pm_read_date_expire_period: DEFAULT_PM_READ_DATE_EXPIRE_PERIOD, - from_server: false, - }; - collect_read_receipt_config(&config.config, &mut found); - Ok(found) -} - -fn default_config(from_server: bool) -> ReadReceiptConfigDto { - ReadReceiptConfigDto { - chat_read_mark_size_threshold: DEFAULT_CHAT_READ_MARK_SIZE_THRESHOLD, - chat_read_mark_expire_period: DEFAULT_CHAT_READ_MARK_EXPIRE_PERIOD, - pm_read_date_expire_period: DEFAULT_PM_READ_DATE_EXPIRE_PERIOD, - from_server, - } -} - -fn collect_read_receipt_config(value: &JsonValue, out: &mut ReadReceiptConfigDto) { - let JsonValue::JsonObject(object) = value else { - return; - }; - for entry in crate::peers::vector_boxed_items(&object.value) { - let JsonObjectValue::JsonObjectValue(entry) = entry else { - continue; - }; - let number = match &*entry.value { - JsonValue::JsonNumber(number) => number.value, - _ => continue, - }; - match entry.key.as_str() { - "chat_read_mark_size_threshold" => { - out.chat_read_mark_size_threshold = number as i32; - out.from_server = true; - } - "chat_read_mark_expire_period" => { - out.chat_read_mark_expire_period = number as i32; - out.from_server = true; - } - "pm_read_date_expire_period" => { - out.pm_read_date_expire_period = number as i32; - out.from_server = true; - } - _ => {} - } - } -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_read_receipts_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/search.rs b/native/mtproto-rs/src/messages/search.rs deleted file mode 100644 index 2312c4d89..000000000 --- a/native/mtproto-rs/src/messages/search.rs +++ /dev/null @@ -1,284 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - InputMessagesFilterChatPhotosConstructor, InputMessagesFilterDocumentConstructor, - InputMessagesFilterEmptyConstructor, InputMessagesFilterGifConstructor, - InputMessagesFilterMusicConstructor, InputMessagesFilterPhotoVideoConstructor, - InputMessagesFilterPhotosConstructor, InputMessagesFilterPinnedConstructor, - InputMessagesFilterUrlConstructor, InputMessagesFilterVideoConstructor, - InputMessagesFilterVoiceConstructor, MessagesFilter, MessagesGetSearchCountersRequest, - MessagesSearchCounter, MessagesSearchRequest, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::peers::{self, CachedPeer, input_peer_from_cached}; -use crate::{MessageDto, MtprotoError}; - -pub fn clamp_search_limit(limit: i32) -> i32 { - limit.clamp(1, 100) -} - -/// `messages.search` filter names used by UniFFI. Unknown → empty. -/// https://core.telegram.org/type/MessagesFilter -pub fn messages_filter(kind: &str) -> MessagesFilter { - match kind { - "photo_video" => MessagesFilter::InputMessagesFilterPhotoVideo( - InputMessagesFilterPhotoVideoConstructor {}, - ), - "document" => { - MessagesFilter::InputMessagesFilterDocument(InputMessagesFilterDocumentConstructor {}) - } - "url" => MessagesFilter::InputMessagesFilterUrl(InputMessagesFilterUrlConstructor {}), - "gif" => MessagesFilter::InputMessagesFilterGif(InputMessagesFilterGifConstructor {}), - "voice" => MessagesFilter::InputMessagesFilterVoice(InputMessagesFilterVoiceConstructor {}), - "music" => MessagesFilter::InputMessagesFilterMusic(InputMessagesFilterMusicConstructor {}), - "photos" => { - MessagesFilter::InputMessagesFilterPhotos(InputMessagesFilterPhotosConstructor {}) - } - "videos" => { - MessagesFilter::InputMessagesFilterVideo(InputMessagesFilterVideoConstructor {}) - } - "chat_photos" => MessagesFilter::InputMessagesFilterChatPhotos( - InputMessagesFilterChatPhotosConstructor {}, - ), - "pinned" => { - MessagesFilter::InputMessagesFilterPinned(InputMessagesFilterPinnedConstructor {}) - } - _ => MessagesFilter::InputMessagesFilterEmpty(InputMessagesFilterEmptyConstructor {}), - } -} - -/// Stable filter key mirroring [`messages_filter`]; unusable filters map to `None`. -pub fn messages_filter_key(filter: &MessagesFilter) -> Option<&'static str> { - Some(match filter { - MessagesFilter::InputMessagesFilterPhotoVideo(_) => "photo_video", - MessagesFilter::InputMessagesFilterDocument(_) => "document", - MessagesFilter::InputMessagesFilterUrl(_) => "url", - MessagesFilter::InputMessagesFilterGif(_) => "gif", - MessagesFilter::InputMessagesFilterVoice(_) => "voice", - MessagesFilter::InputMessagesFilterMusic(_) => "music", - MessagesFilter::InputMessagesFilterPhotos(_) => "photos", - MessagesFilter::InputMessagesFilterVideo(_) => "videos", - MessagesFilter::InputMessagesFilterChatPhotos(_) => "chat_photos", - MessagesFilter::InputMessagesFilterPinned(_) => "pinned", - _ => return None, - }) -} - -/// `messages.getSearchCounters` — result counts per filter, as compact JSON -/// `{"photo_video":{"count":12,"inexact":false}, ...}`. -/// https://core.telegram.org/method/messages.getSearchCounters -pub fn get_search_counters( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - filters: &[String], -) -> Result { - let cached = peers::require_usable_peer(peers, chat_id)?; - let items: Vec> = filters - .iter() - .map(|name| Box::new(messages_filter(name))) - .collect(); - if items.is_empty() { - return Ok("{}".to_string()); - } - let request = MessagesGetSearchCountersRequest { - flags: 0, - peer: Box::new(input_peer_from_cached(cached)), - saved_peer_id: None, - top_msg_id: None, - filters: Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - })), - }; - let response: Vector = - api_invoke::invoke_api(snapshot, api_id, request)?; - let Vector::Vector(list) = response; - let mut out = serde_json::Map::new(); - for counter in list.field_1 { - let MessagesSearchCounter::MessagesSearchCounter(value) = counter; - let Some(key) = messages_filter_key(&value.filter) else { - continue; - }; - out.insert( - key.to_string(), - serde_json::json!({ "count": value.count, "inexact": value.inexact.is_some() }), - ); - } - Ok(serde_json::Value::Object(out).to_string()) -} - -pub fn search_messages( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - query: &str, - limit: i32, -) -> Result, MtprotoError> { - search_messages_with_filter( - snapshot, - api_id, - peers, - media_index, - chat_id, - query, - 0, - 0, - limit, - messages_filter(""), - ) -} - -/// `messages.search` with a named filter and history offsets. -/// https://core.telegram.org/method/messages.search -/// https://core.telegram.org/api/offsets -pub fn search_messages_filtered( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - query: &str, - filter: &str, - offset_id: i32, - add_offset: i32, - limit: i32, -) -> Result, MtprotoError> { - search_messages_with_filter( - snapshot, - api_id, - peers, - media_index, - chat_id, - query, - offset_id, - add_offset, - limit, - messages_filter(filter), - ) -} - -/// `messages.search` with `inputMessagesFilterPinned`. -/// https://core.telegram.org/api/pin -pub fn get_pinned_messages( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - limit: i32, -) -> Result, MtprotoError> { - search_messages_with_filter( - snapshot, - api_id, - peers, - media_index, - chat_id, - "", - 0, - 0, - limit, - messages_filter("pinned"), - ) -} - -pub fn search_messages_page( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - query: &str, - filter: &str, - offset_id: i32, - add_offset: i32, - limit: i32, -) -> Result { - let (messages, total_count) = search_messages_with_filter_and_count( - snapshot, - api_id, - peers, - media_index, - chat_id, - query, - offset_id, - add_offset, - limit, - messages_filter(filter), - )?; - Ok(crate::dto::MessagesPageDto { - messages, - total_count, - }) -} - -pub(crate) fn search_messages_with_filter( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - query: &str, - offset_id: i32, - add_offset: i32, - limit: i32, - filter: MessagesFilter, -) -> Result, MtprotoError> { - Ok(search_messages_with_filter_and_count( - snapshot, - api_id, - peers, - media_index, - chat_id, - query, - offset_id, - add_offset, - limit, - filter, - )? - .0) -} - -pub(crate) fn search_messages_with_filter_and_count( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - query: &str, - offset_id: i32, - add_offset: i32, - limit: i32, - filter: MessagesFilter, -) -> Result<(Vec, i32), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let request = MessagesSearchRequest { - flags: 0, - peer: Box::new(input_peer_from_cached(cached)), - q: query.to_string(), - from_id: None, - saved_peer_id: None, - saved_reaction: None, - top_msg_id: None, - filter: Box::new(filter), - min_date: 0, - max_date: 0, - offset_id, - add_offset, - limit: clamp_search_limit(limit), - max_id: 0, - min_id: 0, - hash: 0, - }; - let response: tellers_mtproto::latest::api::MessagesMessages = - api_invoke::invoke_api(snapshot, api_id, request)?; - let (dtos, _, total_count) = - crate::dialogs::dtos_from_messages_page(response, peers, media_index)?; - Ok((dtos, total_count)) -} diff --git a/native/mtproto-rs/src/messages/search_rpc.rs b/native/mtproto-rs/src/messages/search_rpc.rs deleted file mode 100644 index 4c4e4967d..000000000 --- a/native/mtproto-rs/src/messages/search_rpc.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Global and people search. -//! https://core.telegram.org/api/search -//! https://core.telegram.org/api/offsets -//! https://core.telegram.org/method/contacts.search -//! https://core.telegram.org/method/messages.searchGlobal - -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::latest::api::{ - Chat as TlChat, ContactsFound, ContactsSearchRequest, InputMessagesFilterEmptyConstructor, - InputPeer, InputPeerEmptyConstructor, MessagesFilter, MessagesSearchGlobalRequest, Peer, User, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::dialogs; -use crate::media::MediaIndex; -use crate::messages::clamp_search_limit; -use crate::peers::{self, CachedPeer, input_peer_from_cached, peer_chat_id, vector_boxed_items}; -use crate::{ContactsSearchDto, GlobalMessageSearchDto, MessageDto, MtprotoError, SearchPeerDto}; - -pub fn is_people_peer(peer: &Peer) -> bool { - matches!(peer, Peer::PeerUser(_)) -} - -pub fn next_global_page(messages: &[MessageDto], next_rate: i32) -> (i32, i64, i32) { - match messages.last() { - Some(last) => (next_rate, last.chat_id, last.id), - None => (0, 0, 0), - } -} - -pub fn contacts_search_query(query: &str) -> Result { - let q = query.trim().to_string(); - if q.is_empty() { - Err(MtprotoError::Message("empty search query".into())) - } else { - Ok(q) - } -} - -/// `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)) -} - -pub fn contacts_search( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - query: &str, - limit: i32, -) -> Result { - let q = contacts_search_query(query)?; - let response: ContactsFound = api_invoke::invoke_api( - snapshot, - api_id, - ContactsSearchRequest { - flags: 0, - broadcasts: None, - bots: None, - q, - limit: clamp_search_limit(limit), - }, - )?; - let ContactsFound::ContactsFound(body) = response; - dialogs::cache_from_users_chats( - peers, - media, - vector_boxed_items(&body.users).cloned(), - vector_boxed_items(&body.chats).cloned(), - ); - let users: Vec<&User> = vector_boxed_items(&body.users).collect(); - let chats: Vec<&TlChat> = vector_boxed_items(&body.chats).collect(); - let mut people = Vec::new(); - let mut found_chats = Vec::new(); - let mut seen = HashMap::::new(); - for peer in vector_boxed_items(&body.my_results).chain(vector_boxed_items(&body.results)) { - let dto = search_peer_from_peer(peer, users.iter().copied(), chats.iter().copied()); - if seen.insert(dto.peer_id, ()).is_some() { - continue; - } - if is_people_peer(peer) { - people.push(dto); - } else { - found_chats.push(dto); - } - } - Ok(ContactsSearchDto { - people, - chats: found_chats, - }) -} - -pub fn search_global( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media: &mut MediaIndex, - query: &str, - offset_rate: i32, - offset_peer_id: i64, - offset_id: i32, - limit: i32, - folder_id: i32, -) -> Result { - let offset_peer = offset_input_peer(peers, offset_peer_id)?; - let (flags, folder_id) = search_global_folder(folder_id); - let request = MessagesSearchGlobalRequest { - flags, - broadcasts_only: None, - groups_only: None, - users_only: None, - folder_id, - community: None, - q: query.to_string(), - filter: Box::new(MessagesFilter::InputMessagesFilterEmpty( - InputMessagesFilterEmptyConstructor {}, - )), - min_date: 0, - max_date: 0, - offset_rate, - offset_peer: Box::new(offset_peer), - offset_id, - limit: clamp_search_limit(limit), - }; - let response: tellers_mtproto::latest::api::MessagesMessages = - api_invoke::invoke_api(snapshot, api_id, request)?; - let (messages, next_rate, _) = dialogs::dtos_from_messages_page(response, peers, media)?; - let (next_rate, next_peer_id, next_offset_id) = next_global_page(&messages, next_rate); - Ok(GlobalMessageSearchDto { - messages, - next_rate, - next_peer_id, - next_offset_id, - }) -} - -fn offset_input_peer( - peers: &HashMap, - offset_peer_id: i64, -) -> Result { - if offset_peer_id == 0 { - Ok(InputPeer::InputPeerEmpty(InputPeerEmptyConstructor {})) - } else { - Ok(input_peer_from_cached(peers::require_usable_peer( - peers, - offset_peer_id, - )?)) - } -} - -fn search_peer_from_peer<'a>( - peer: &Peer, - users: impl Iterator, - chats: impl Iterator, -) -> SearchPeerDto { - let peer_id = peer_chat_id(peer); - let mut title = String::new(); - let mut username = None; - let mut is_bot = false; - let mut is_group = matches!(peer, Peer::PeerChat(_)); - let mut is_channel = matches!(peer, Peer::PeerChannel(_)); - for user in users { - if let User::User(u) = user { - if peers::chat_id_for_user(u.id) == peer_id { - title = display_name( - u.first_name.as_deref(), - u.last_name.as_deref(), - u.username.as_deref(), - ); - username = u.username.clone(); - is_bot = u.bot.is_some(); - is_group = false; - is_channel = false; - } - } - } - if title.is_empty() { - for chat in chats { - match chat { - TlChat::Chat(c) if peers::chat_id_for_chat(c.id) == peer_id => { - title = c.title.clone(); - is_group = true; - is_channel = false; - } - TlChat::Channel(c) if peers::chat_id_for_channel(c.id) == peer_id => { - title = c.title.clone(); - username = c.username.clone(); - is_group = c.megagroup.is_some(); - is_channel = c.broadcast.is_some() || c.megagroup.is_none(); - } - _ => {} - } - } - } - if title.is_empty() { - title = peer_id.to_string(); - } - let kind = if is_bot { - "bot" - } else if is_group { - "group" - } else if is_channel { - "channel" - } else { - "user" - }; - SearchPeerDto { - peer_id, - title, - username, - kind: kind.to_string(), - is_bot, - is_group, - is_channel, - } -} - -fn display_name(first: Option<&str>, last: Option<&str>, username: Option<&str>) -> String { - let joined = [first.unwrap_or(""), last.unwrap_or("")] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join(" "); - if !joined.is_empty() { - joined - } else { - username.unwrap_or("").to_string() - } -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_search_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/send.rs b/native/mtproto-rs/src/messages/send.rs deleted file mode 100644 index 007f9376d..000000000 --- a/native/mtproto-rs/src/messages/send.rs +++ /dev/null @@ -1,722 +0,0 @@ -//! https://core.telegram.org/method/messages.sendMessage -//! https://core.telegram.org/method/messages.editMessage -//! https://core.telegram.org/method/messages.deleteMessages -//! https://core.telegram.org/method/messages.forwardMessages - -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::latest::api::{ - ChannelsDeleteMessagesRequest, InputChannel, InputChannelConstructor, InputMedia, - InputMediaWebPageConstructor, InputPeer, InputReplyTo, InputReplyToMessageConstructor, - MessageEntity, MessagesAffectedMessages, MessagesDeleteMessagesRequest, - MessagesEditMessageRequest, MessagesForwardMessagesRequest, MessagesSendMediaRequest, - MessagesSendMessageRequest, True, TrueConstructor, Updates, Vector, VectorConstructor, -}; -use tellers_mtproto_crypto::fill_random; -use tellers_mtproto_session::Snapshot; - -use super::entities::input_entities_from_json; -use crate::api_invoke; -use crate::dialogs::message_to_dto; -use crate::media::MediaIndex; -use crate::peers::{ - self, CachedPeer, PeerKind, channel_id_from_chat_id, input_peer_from_cached, vector_boxed_items, -}; -use crate::{MessageDto, MtprotoError}; - -pub(crate) fn random_id() -> i64 { - let mut bytes = [0_u8; 8]; - let _ = fill_random(&mut bytes); - let value = i64::from_le_bytes(bytes); - if value == 0 { 1 } else { value } -} - -/// `0` means the caller has no id yet. A non-zero id is the pending UI id. -pub(crate) fn outgoing_random_id(requested: i64) -> i64 { - if requested == 0 { - random_id() - } else { - requested - } -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_send_random_id_tests.rs"] -mod random_id_tests; - -pub(crate) fn message_from_updates( - updates: Updates, - chat_id: i64, - text: &str, - media_index: &mut MediaIndex, -) -> MessageDto { - match updates { - Updates::Updates(u) => first_new_message(&u.updates, media_index), - Updates::UpdatesCombined(u) => first_new_message(&u.updates, media_index), - Updates::UpdateShortSentMessage(s) => Some(MessageDto { - media_preview_restricted: None, - chat_id, - id: s.id, - sender_id: None, - text: Some(text.to_string()), - date: i64::from(s.date), - edit_date: None, - outgoing: true, - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: None, - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id: None, - reply_to_top_id: None, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }), - Updates::UpdateShortMessage(m) => { - let (reply_to_msg_id, reply_to_top_id, reply_quote, reply_quote_entities_json, _) = - crate::dialogs::reply_meta(m.reply_to.as_deref()); - Some(MessageDto { - media_preview_restricted: None, - chat_id, - id: m.id, - sender_id: Some(m.user_id), - text: Some(m.message.clone()), - date: i64::from(m.date), - edit_date: None, - outgoing: m.out.is_some(), - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote, - reply_quote_entities_json, - entities_json: None, - noforwards: false, - reply_to_msg_id, - reply_to_top_id, - fwd_from: crate::dialogs::fwd_from_label( - m.fwd_from.as_deref(), - &crate::HashMap::new(), - ), - fwd_from_id: crate::dialogs::fwd_origin(m.fwd_from.as_deref()).0, - fwd_date: crate::dialogs::fwd_origin(m.fwd_from.as_deref()).1, - via_bot: crate::dialogs::via_bot_label( - m.via_bot_id, - &crate::HashMap::new(), - &crate::HashMap::new(), - ), - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }) - } - Updates::UpdateShortChatMessage(m) => { - let (reply_to_msg_id, reply_to_top_id, reply_quote, reply_quote_entities_json, _) = - crate::dialogs::reply_meta(m.reply_to.as_deref()); - Some(MessageDto { - media_preview_restricted: None, - chat_id, - id: m.id, - sender_id: Some(m.from_id), - text: Some(m.message.clone()), - date: i64::from(m.date), - edit_date: None, - outgoing: m.out.is_some(), - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote, - reply_quote_entities_json, - entities_json: None, - noforwards: false, - reply_to_msg_id, - reply_to_top_id, - fwd_from: crate::dialogs::fwd_from_label( - m.fwd_from.as_deref(), - &crate::HashMap::new(), - ), - fwd_from_id: crate::dialogs::fwd_origin(m.fwd_from.as_deref()).0, - fwd_date: crate::dialogs::fwd_origin(m.fwd_from.as_deref()).1, - via_bot: crate::dialogs::via_bot_label( - m.via_bot_id, - &crate::HashMap::new(), - &crate::HashMap::new(), - ), - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }) - } - _ => None, - } - .unwrap_or(MessageDto { - media_preview_restricted: None, - chat_id, - id: 0, - sender_id: None, - text: Some(text.to_string()), - date: 0, - edit_date: None, - outgoing: true, - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: None, - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id: None, - reply_to_top_id: None, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }) -} - -pub(crate) fn first_new_message( - updates: &tellers_mtproto::latest::api::Vector>, - media_index: &mut MediaIndex, -) -> Option { - use tellers_mtproto::latest::api::Update; - for update in vector_boxed_items(updates) { - match update { - Update::UpdateNewMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - return Some(dto); - } - } - Update::UpdateNewChannelMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - return Some(dto); - } - } - Update::UpdateEditMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - return Some(dto); - } - } - Update::UpdateEditChannelMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - return Some(dto); - } - } - _ => {} - } - } - None -} - -pub(crate) fn all_new_messages( - updates: &tellers_mtproto::latest::api::Vector>, - media_index: &mut MediaIndex, -) -> Vec { - use tellers_mtproto::latest::api::Update; - let mut out = Vec::new(); - for update in vector_boxed_items(updates) { - match update { - Update::UpdateNewMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - out.push(dto); - } - } - Update::UpdateNewChannelMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - out.push(dto); - } - } - _ => {} - } - } - out -} - -pub(crate) fn input_reply_to(reply_to_msg_id: i32) -> (u32, Option>) { - input_reply_to_thread(reply_to_msg_id, 0) -} - -pub(crate) fn input_reply_to_thread( - reply_to_msg_id: i32, - top_msg_id: i32, -) -> (u32, Option>) { - if reply_to_msg_id <= 0 && top_msg_id <= 0 { - return (0, None); - } - let reply_id = if reply_to_msg_id > 0 { - reply_to_msg_id - } else { - top_msg_id - }; - let (top_flag, top) = if top_msg_id > 0 { - ( - InputReplyToMessageConstructor::TOP_MSG_ID_FLAG, - Some(top_msg_id), - ) - } else { - (0, None) - }; - ( - MessagesSendMessageRequest::REPLY_TO_FLAG, - Some(Box::new(InputReplyTo::InputReplyToMessage( - InputReplyToMessageConstructor { - flags: top_flag, - reply_to_msg_id: reply_id, - top_msg_id: top, - reply_to_peer_id: None, - quote_text: None, - quote_entities: None, - quote_offset: None, - monoforum_peer_id: None, - todo_item_id: None, - poll_option: None, - quote_rich: None, - }, - ))), - ) -} - -pub(crate) fn send_message_request( - flags: u32, - peer: Box, - reply_to: Option>, - message: String, - client_random_id: i64, - entities: Option>>>, -) -> MessagesSendMessageRequest { - MessagesSendMessageRequest { - flags, - no_webpage: None, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer, - reply_to, - message, - random_id: outgoing_random_id(client_random_id), - reply_markup: None, - entities, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - rich_message: None, - } -} - -pub fn send_text( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - text: &str, - reply_to_msg_id: i32, - entities_json: Option<&str>, - top_msg_id: i32, - webpage_url: Option<&str>, - client_random_id: i64, -) -> Result { - match send_text_with_options( - snapshot, - api_id, - peers, - media_index, - chat_id, - text, - reply_to_msg_id, - entities_json, - top_msg_id, - webpage_url, - client_random_id, - &super::SendOptions::default(), - )? { - super::OutgoingMessageResult::Sent { message } => Ok(message), - super::OutgoingMessageResult::Scheduled { .. } => Err(MtprotoError::Message( - "unexpected scheduled result for immediate send".into(), - )), - } -} - -pub fn send_text_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - text: &str, - reply_to_msg_id: i32, - entities_json: Option<&str>, - top_msg_id: i32, - webpage_url: Option<&str>, - client_random_id: i64, - options: &super::SendOptions, -) -> Result { - options.validate(client_random_id)?; - let outgoing_id = outgoing_random_id(client_random_id); - let cached = peers::require_usable_peer(peers, chat_id)?; - let (reply_flag, reply_to) = input_reply_to_thread(reply_to_msg_id, top_msg_id); - let (entities_flag, entities) = input_entities_from_json(entities_json, peers)?; - let peer = Box::new(input_peer_from_cached(cached)); - let updates: Updates = if let Some(url) = webpage_url - .filter(|_| !options.no_webpage) - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let mut request = MessagesSendMediaRequest { - flags: reply_flag | entities_flag, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - 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(), - }, - )), - message: text.to_string(), - random_id: outgoing_id, - reply_markup: None, - entities, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - api_invoke::invoke_api(snapshot, api_id, request)? - } else { - let mut request = send_message_request( - reply_flag | entities_flag, - peer, - reply_to, - text.to_string(), - outgoing_id, - entities, - ); - options.apply_text(&mut request); - api_invoke::invoke_api(snapshot, api_id, request)? - }; - let mapped = super::send_options::send_result( - updates.clone(), - chat_id, - text, - outgoing_id, - peers, - media_index, - ); - if let Ok(ref result) = mapped { - if let Some(ack) = super::send_options::implicit_ack(&updates, result, outgoing_id) { - crate::client::management::enqueue_response(snapshot.dc_id, &ack)?; - } - } - crate::client::management::enqueue_response(snapshot.dc_id, &updates)?; - let mut result = mapped?; - let dto = match &mut result { - super::OutgoingMessageResult::Sent { message } => message, - super::OutgoingMessageResult::Scheduled { message } => &mut message.message, - }; - if dto.entities_json.is_none() { - dto.entities_json = entities_json - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string); - } - Ok(result) -} - -pub(crate) const PHOTO_PART: usize = crate::upload::upload_rpc::FILE_PART; -pub(crate) const PHOTO_MAX: u64 = crate::upload::upload_rpc::PHOTO_MAX; - -pub fn edit_text( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - message_id: i32, - text: &str, - entities_json: Option<&str>, -) -> Result { - let cached = peers::require_usable_peer(peers, chat_id)?; - let (entities_flag, entities) = input_entities_from_json(entities_json, peers)?; - let request = MessagesEditMessageRequest { - flags: MessagesEditMessageRequest::MESSAGE_FLAG | entities_flag, - no_webpage: None, - invert_media: None, - peer: Box::new(input_peer_from_cached(cached)), - id: message_id, - message: Some(text.to_string()), - media: None, - reply_markup: None, - entities, - schedule_date: None, - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - }; - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(message_from_updates(updates, chat_id, text, media_index)) -} - -pub fn delete_message( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, - message_id: i32, - revoke: bool, -) -> Result<(), MtprotoError> { - let cached = peers::require_usable_peer(peers, chat_id)?; - let ids = Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![message_id], - })); - if cached.kind == PeerKind::Channel { - let channel_id = channel_id_from_chat_id(chat_id) - .ok_or_else(|| MtprotoError::Message("not a channel".into()))?; - let _: MessagesAffectedMessages = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsDeleteMessagesRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id, - access_hash: cached.access_hash, - })), - id: ids, - }, - )?; - return Ok(()); - } - let revoke_flag = if revoke { - Some(Box::new(True::True(TrueConstructor {}))) - } else { - None - }; - let _: MessagesAffectedMessages = api_invoke::invoke_api( - snapshot, - api_id, - MessagesDeleteMessagesRequest { - flags: if revoke { - MessagesDeleteMessagesRequest::REVOKE_FLAG - } else { - 0 - }, - revoke: revoke_flag, - id: ids, - }, - )?; - Ok(()) -} - -/// https://core.telegram.org/method/messages.forwardMessages -pub fn forward_messages( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - from_chat_id: i64, - message_ids: Vec, - to_chat_id: i64, - drop_author: bool, -) -> Result, MtprotoError> { - if message_ids.is_empty() || message_ids.iter().any(|&message_id| message_id <= 0) { - return Err(MtprotoError::Message("valid message ids required".into())); - } - let from = peers::require_usable_peer(peers, from_chat_id)?; - let to = peers::require_usable_peer(peers, to_chat_id)?; - let message_count = message_ids.len() as u32; - let ids = Box::new(Vector::Vector(VectorConstructor { - field_0: message_count, - field_1: message_ids, - })); - let random_ids = Box::new(Vector::Vector(VectorConstructor { - field_0: message_count, - field_1: (0..message_count).map(|_| random_id()).collect(), - })); - let request = MessagesForwardMessagesRequest { - flags: if drop_author { - MessagesForwardMessagesRequest::DROP_AUTHOR_FLAG - } else { - 0 - }, - silent: None, - background: None, - with_my_score: None, - drop_author: drop_author.then(|| Box::new(True::True(TrueConstructor {}))), - drop_media_captions: None, - noforwards: None, - allow_paid_floodskip: None, - from_peer: Box::new(input_peer_from_cached(from)), - id: ids, - random_id: random_ids, - to_peer: Box::new(input_peer_from_cached(to)), - top_msg_id: None, - reply_to: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - video_timestamp: None, - allow_paid_stars: None, - suggested_post: None, - from_ephemeral: None, - }; - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - Ok(messages_from_forward_updates( - updates, - to_chat_id, - media_index, - )) -} - -pub(crate) fn messages_from_forward_updates( - updates: Updates, - chat_id: i64, - media_index: &mut MediaIndex, -) -> Vec { - match updates { - Updates::Updates(u) => collect_new_messages(&u.updates, media_index), - Updates::UpdatesCombined(u) => collect_new_messages(&u.updates, media_index), - other => first_new_from_short(other, chat_id).into_iter().collect(), - } -} - -pub(crate) fn first_new_from_short(updates: Updates, chat_id: i64) -> Option { - match updates { - Updates::UpdateShortSentMessage(s) => Some(MessageDto { - media_preview_restricted: None, - chat_id, - id: s.id, - sender_id: None, - text: None, - date: i64::from(s.date), - edit_date: None, - outgoing: true, - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: None, - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id: None, - reply_to_top_id: None, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }), - _ => None, - } -} - -pub(crate) fn collect_new_messages( - updates: &tellers_mtproto::latest::api::Vector>, - media_index: &mut MediaIndex, -) -> Vec { - use tellers_mtproto::latest::api::Update; - let mut out = Vec::new(); - for update in vector_boxed_items(updates) { - let message = match update { - Update::UpdateNewMessage(u) => message_to_dto(&u.message, media_index), - Update::UpdateNewChannelMessage(u) => message_to_dto(&u.message, media_index), - _ => None, - }; - if let Some(dto) = message { - out.push(dto); - } - } - out -} diff --git a/native/mtproto-rs/src/messages/send_options.rs b/native/mtproto-rs/src/messages/send_options.rs deleted file mode 100644 index 3e1e4f819..000000000 --- a/native/mtproto-rs/src/messages/send_options.rs +++ /dev/null @@ -1,283 +0,0 @@ -use tellers_mtproto::latest::api::*; - -use crate::{MessageDto, MtprotoError}; - -#[derive(Debug, Clone, Default, uniffi::Record)] -pub struct SendOptions { - pub silent: bool, - pub no_webpage: bool, - pub schedule_date: Option, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Enum)] -pub enum OutgoingMessageResult { - Sent { message: MessageDto }, - Scheduled { message: crate::ScheduledMessageDto }, -} - -impl SendOptions { - pub(crate) fn validate(&self, random_id: i64) -> Result<(), MtprotoError> { - if let Some(date) = self.schedule_date { - crate::client::scheduled::validate_date(date)?; - if random_id == 0 { - return Err(MtprotoError::Message( - "scheduled sends require a persisted random id".into(), - )); - } - } - Ok(()) - } - - pub(crate) fn apply_text(&self, request: &mut MessagesSendMessageRequest) { - if self.no_webpage { - request.flags |= MessagesSendMessageRequest::NO_WEBPAGE_FLAG; - } - if self.silent { - request.flags |= MessagesSendMessageRequest::SILENT_FLAG; - } - if self.schedule_date.is_some() { - request.flags |= MessagesSendMessageRequest::SCHEDULE_DATE_FLAG; - } - request.no_webpage = self - .no_webpage - .then(|| Box::new(True::True(TrueConstructor {}))); - request.silent = self - .silent - .then(|| Box::new(True::True(TrueConstructor {}))); - request.schedule_date = self.schedule_date; - } - - pub(crate) fn apply_media(&self, request: &mut MessagesSendMediaRequest) { - if self.silent { - request.flags |= MessagesSendMediaRequest::SILENT_FLAG; - } - if self.schedule_date.is_some() { - request.flags |= MessagesSendMediaRequest::SCHEDULE_DATE_FLAG; - } - request.silent = self - .silent - .then(|| Box::new(True::True(TrueConstructor {}))); - request.schedule_date = self.schedule_date; - } - - pub(crate) fn apply_album(&self, request: &mut MessagesSendMultiMediaRequest) { - if self.silent { - request.flags |= MessagesSendMultiMediaRequest::SILENT_FLAG; - } - if self.schedule_date.is_some() { - request.flags |= MessagesSendMultiMediaRequest::SCHEDULE_DATE_FLAG; - } - request.silent = self - .silent - .then(|| Box::new(True::True(TrueConstructor {}))); - request.schedule_date = self.schedule_date; - } -} - -pub(crate) fn send_result( - updates: Updates, - chat_id: i64, - text: &str, - random_id: i64, - peers: &crate::HashMap, - media: &mut crate::media::MediaIndex, -) -> Result { - let parts = match &updates { - Updates::Updates(value) => Some((&value.updates, &value.users, &value.chats)), - Updates::UpdatesCombined(value) => Some((&value.updates, &value.users, &value.chats)), - _ => None, - }; - if let Some((items, users, chats)) = parts { - let acknowledgments = crate::peers::vector_boxed_items(items) - .filter_map(|update| match update { - Update::UpdateMessageId(value) if value.random_id == random_id => Some(value.id), - _ => None, - }) - .collect::>(); - let scheduled = crate::peers::vector_boxed_items(items) - .filter_map(|update| match update { - Update::UpdateNewScheduledMessage(value) => Some(value.message.clone()), - _ => None, - }) - .filter(|message| match message.as_ref() { - Message::Message(value) => { - acknowledgments.contains(&value.id) - && crate::peers::peer_chat_id(&value.peer_id) == chat_id - } - _ => false, - }) - .collect::>(); - let normal = crate::peers::vector_boxed_items(items) - .filter_map(|update| match update { - Update::UpdateNewMessage(value) => Some(&value.message), - Update::UpdateNewChannelMessage(value) => Some(&value.message), - _ => None, - }) - .filter(|message| match message.as_ref() { - Message::Message(value) => { - crate::peers::peer_chat_id(&value.peer_id) == chat_id - && (acknowledgments.is_empty() || acknowledgments.contains(&value.id)) - } - _ => false, - }) - .collect::>(); - if scheduled.len() + normal.len() > 1 { - return Err(MtprotoError::Message( - "ambiguous acknowledged send response".into(), - )); - } - if !scheduled.is_empty() { - let response = MessagesMessages::MessagesMessages(MessagesMessagesConstructor { - messages: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: scheduled, - })), - topics: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - users: users.clone(), - chats: chats.clone(), - }); - let message = - crate::client::scheduled::map_response(response, &mut peers.clone(), media)? - .into_iter() - .next() - .ok_or_else(|| { - MtprotoError::Message("scheduled response has no message".into()) - })?; - return Ok(OutgoingMessageResult::Scheduled { message }); - } - if let Some(message) = normal.first() { - let message = crate::dialogs::message_to_dto(message, media) - .ok_or_else(|| MtprotoError::Message("send response has no message".into()))?; - return Ok(OutgoingMessageResult::Sent { message }); - } - return Err(MtprotoError::Message( - "send response missing a unique acknowledged message".into(), - )); - } - let message = super::send::message_from_updates(updates, chat_id, text, media); - if message.id <= 0 { - return Err(MtprotoError::Message( - "send response has no confirmed message id".into(), - )); - } - Ok(OutgoingMessageResult::Sent { message }) -} - -pub(crate) fn album_send_results( - updates: Updates, - chat_id: i64, - requests: &[(i64, &str)], - peers: &crate::HashMap, - media: &mut crate::media::MediaIndex, -) -> Result, MtprotoError> { - let ids = requests - .iter() - .map(|(id, _)| *id) - .collect::>(); - if requests.is_empty() || requests.len() > 10 || ids.len() != requests.len() || ids.contains(&0) - { - return Err(MtprotoError::Message( - "album requires distinct persisted random ids".into(), - )); - } - if requests.len() > 1 { - let items = match &updates { - Updates::Updates(value) => &value.updates, - Updates::UpdatesCombined(value) => &value.updates, - _ => { - return Err(MtprotoError::Message( - "album response missing acknowledgments".into(), - )); - } - }; - let mut acknowledged = std::collections::HashMap::new(); - let mut message_ids = std::collections::HashSet::new(); - for update in crate::peers::vector_boxed_items(items) { - if let Update::UpdateMessageId(value) = update { - if ids.contains(&value.random_id) - && (value.id <= 0 - || acknowledged.insert(value.random_id, value.id).is_some() - || !message_ids.insert(value.id)) - { - return Err(MtprotoError::Message( - "ambiguous album acknowledgment".into(), - )); - } - } - } - if acknowledged.len() != requests.len() { - return Err(MtprotoError::Message( - "album response missing acknowledgments".into(), - )); - } - } - let mut mapped_media = media.clone(); - let results = requests - .iter() - .map(|(id, text)| { - send_result( - updates.clone(), - chat_id, - text, - *id, - peers, - &mut mapped_media, - ) - }) - .collect::, _>>()?; - *media = mapped_media; - Ok(results) -} - -// Short RPC results bind the confirmed message ID to the request's random ID implicitly. -pub(crate) fn implicit_ack( - updates: &Updates, - result: &OutgoingMessageResult, - random_id: i64, -) -> Option { - if random_id == 0 { - return None; - } - let date = match updates { - Updates::UpdateShortSentMessage(value) => value.date, - Updates::UpdateShortMessage(value) => value.date, - Updates::UpdateShortChatMessage(value) => value.date, - Updates::Updates(value) => { - if crate::peers::vector_boxed_items(&value.updates).any(|item| { - matches!(item, - Update::UpdateMessageId(ack) if ack.random_id == random_id) - }) { - return None; - } - value.date - } - Updates::UpdatesCombined(value) => { - if crate::peers::vector_boxed_items(&value.updates).any(|item| { - matches!(item, - Update::UpdateMessageId(ack) if ack.random_id == random_id) - }) { - return None; - } - value.date - } - _ => return None, - }; - let id = match result { - OutgoingMessageResult::Sent { message } => message.id, - OutgoingMessageResult::Scheduled { message } => message.message.id, - }; - Some(Updates::UpdateShort(UpdateShortConstructor { - date, - update: Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id, - random_id, - })), - })) -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_send_options_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/messages/sticker_rpc.rs b/native/mtproto-rs/src/messages/sticker_rpc.rs deleted file mode 100644 index 51c5ec7e1..000000000 --- a/native/mtproto-rs/src/messages/sticker_rpc.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Sticker / custom-emoji pack lookup. -//! https://core.telegram.org/method/messages.getStickerSet -//! https://core.telegram.org/method/messages.getAllStickers -//! https://core.telegram.org/method/messages.getEmojiStickers -//! https://core.telegram.org/method/messages.getStickers -//! https://core.telegram.org/api/stickers -//! https://core.telegram.org/api/custom-emoji - -use tellers_mtproto::latest::api::{ - Document, InputStickerSet, InputStickerSetIdConstructor, MessagesAllStickers, - MessagesGetAllStickersRequest, MessagesGetEmojiStickersRequest, MessagesGetStickerSetRequest, - MessagesGetStickersRequest, MessagesStickerSet, MessagesStickers, StickerPack as TlStickerPack, - StickerSet, StickerSetConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::media::{self, MediaIndex, MediaLocation}; -use crate::peers::{vector_boxed_items, vector_items}; -use crate::{MtprotoError, StickerCatalogDto, StickerListDto, StickerPackDto}; - -pub fn get_sticker_pack( - snapshot: &mut Snapshot, - api_id: i32, - media: &mut MediaIndex, - document_id: i64, -) -> Result { - let (set_id, access_hash) = sticker_set_for_document(snapshot, api_id, media, document_id)?; - get_sticker_set(snapshot, api_id, media, set_id, access_hash) -} - -pub fn get_sticker_set( - snapshot: &mut Snapshot, - api_id: i32, - media: &mut MediaIndex, - set_id: i64, - access_hash: i64, -) -> Result { - let response: MessagesStickerSet = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetStickerSetRequest { - stickerset: Box::new(InputStickerSet::InputStickerSetId( - InputStickerSetIdConstructor { - id: set_id, - access_hash, - }, - )), - hash: 0, - }, - )?; - let MessagesStickerSet::MessagesStickerSet(pack) = response else { - return Err(MtprotoError::Message("sticker set not modified".into())); - }; - let StickerSet::StickerSet(set) = *pack.set; - let mut previews = Vec::new(); - for boxed in vector_items(pack.documents.as_ref()) { - let Document::Document(d) = boxed.as_ref() else { - continue; - }; - if !previews.contains(&d.id) { - previews.push(d.id); - } - media::insert_media(media, (d.id, 0), media::media_ref_from_document(d)); - } - if previews.is_empty() { - if let Some(id) = set.thumb_document_id { - previews.push(id); - } - for pack_row in vector_boxed_items(pack.packs.as_ref()) { - let TlStickerPack::StickerPack(row) = pack_row else { - continue; - }; - for id in vector_items(row.documents.as_ref()).iter().copied() { - if !previews.contains(&id) { - previews.push(id); - } - } - } - } - Ok(pack_from_set(&set, previews)) -} - -pub fn get_all_stickers( - snapshot: &mut Snapshot, - api_id: i32, - hash: i64, -) -> Result { - catalog_from_all( - api_invoke::invoke_api(snapshot, api_id, MessagesGetAllStickersRequest { hash })?, - hash, - ) -} - -pub fn get_emoji_stickers( - snapshot: &mut Snapshot, - api_id: i32, - hash: i64, -) -> Result { - catalog_from_all( - api_invoke::invoke_api(snapshot, api_id, MessagesGetEmojiStickersRequest { hash })?, - hash, - ) -} - -pub fn get_stickers( - snapshot: &mut Snapshot, - api_id: i32, - media: &mut MediaIndex, - emoticon: &str, - hash: i64, -) -> Result { - let response: MessagesStickers = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetStickersRequest { - emoticon: emoticon.to_string(), - hash, - }, - )?; - match response { - MessagesStickers::MessagesStickersNotModified(_) => Ok(StickerListDto { - hash, - not_modified: true, - document_ids: Vec::new(), - }), - MessagesStickers::MessagesStickers(body) => { - let mut document_ids = Vec::new(); - for boxed in vector_items(body.stickers.as_ref()) { - let Document::Document(d) = boxed.as_ref() else { - continue; - }; - if !document_ids.contains(&d.id) { - document_ids.push(d.id); - } - media::insert_media(media, (d.id, 0), media::media_ref_from_document(d)); - } - Ok(StickerListDto { - hash: body.hash, - not_modified: false, - document_ids, - }) - } - } -} - -fn catalog_from_all( - response: MessagesAllStickers, - hash: i64, -) -> Result { - match response { - MessagesAllStickers::MessagesAllStickersNotModified(_) => Ok(StickerCatalogDto { - hash, - not_modified: true, - sets: Vec::new(), - }), - MessagesAllStickers::MessagesAllStickers(body) => { - let mut sets = Vec::new(); - for set in vector_boxed_items(body.sets.as_ref()) { - let StickerSet::StickerSet(set) = set else { - continue; - }; - let preview = set.thumb_document_id.into_iter().collect(); - sets.push(pack_from_set(set, preview)); - } - Ok(StickerCatalogDto { - hash: body.hash, - not_modified: false, - sets, - }) - } - } -} - -pub(crate) fn pack_from_set(set: &StickerSetConstructor, previews: Vec) -> StickerPackDto { - StickerPackDto { - id: set.id, - access_hash: set.access_hash, - title: set.title.clone(), - short_name: set.short_name.clone(), - count: set.count, - is_emoji: set.emojis.is_some(), - preview_document_ids: previews, - } -} - -fn sticker_set_for_document( - snapshot: &mut Snapshot, - api_id: i32, - media: &MediaIndex, - document_id: i64, -) -> Result<(i64, i64), MtprotoError> { - if let Some(indexed) = media.values().find(|item| match item.location { - MediaLocation::Document { id, .. } => id == document_id, - _ => false, - }) { - if let (Some(id), Some(hash)) = (indexed.sticker_set_id, indexed.sticker_set_access_hash) { - return Ok((id, hash)); - } - } - let fetched = media::fetch_custom_emoji(snapshot, api_id, document_id)?; - match (fetched.sticker_set_id, fetched.sticker_set_access_hash) { - (Some(id), Some(hash)) => Ok((id, hash)), - _ => Err(MtprotoError::Message("document has no sticker set".into())), - } -} - -#[cfg(test)] -#[path = "../../tests/unit/messages_sticker_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/passkeys.rs b/native/mtproto-rs/src/passkeys.rs deleted file mode 100644 index 77ad2ed68..000000000 --- a/native/mtproto-rs/src/passkeys.rs +++ /dev/null @@ -1,84 +0,0 @@ -use tellers_mtproto::latest::api::*; - -use crate::{MtprotoError, PasskeyCredentialDto, PasskeyDto}; - -pub(crate) fn credential( - value: PasskeyCredentialDto, - registration: bool, -) -> Result { - let invalid = || MtprotoError::Message("invalid passkey credential".into()); - if value.id.is_empty() || value.raw_id.is_empty() || value.client_data.len() > 65536 { - return Err(invalid()); - } - let data: serde_json::Value = - serde_json::from_str(&value.client_data).map_err(|_| invalid())?; - let expected = if registration { - "webauthn.create" - } else { - "webauthn.get" - }; - if data["type"].as_str() != Some(expected) - || data["origin"].as_str() != Some("https://telegram.org") - || data["challenge"].as_str().is_none_or(str::is_empty) - { - return Err(invalid()); - } - let client_data = Box::new(DataJson::DataJson(DataJsonConstructor { - data: value.client_data, - })); - let response = if registration { - if value.attestation_data.is_empty() { - return Err(invalid()); - } - InputPasskeyResponse::InputPasskeyResponseRegister( - InputPasskeyResponseRegisterConstructor { - client_data, - attestation_data: value.attestation_data, - }, - ) - } else { - login_identity(&value.user_handle)?; - if value.authenticator_data.len() < 37 || value.signature.is_empty() { - return Err(invalid()); - } - InputPasskeyResponse::InputPasskeyResponseLogin(InputPasskeyResponseLoginConstructor { - client_data, - authenticator_data: value.authenticator_data, - signature: value.signature, - user_handle: value.user_handle, - }) - }; - Ok(InputPasskeyCredential::InputPasskeyCredentialPublicKey( - InputPasskeyCredentialPublicKeyConstructor { - id: value.id, - raw_id: value.raw_id, - response: Box::new(response), - }, - )) -} - -pub(crate) fn login_identity(handle: &str) -> Result<(i32, i64), MtprotoError> { - let invalid = || MtprotoError::Message("invalid passkey user handle".into()); - let (dc, user) = handle.split_once(':').ok_or_else(invalid)?; - let dc = dc.parse::().map_err(|_| invalid())?; - let user = user.parse::().map_err(|_| invalid())?; - if !(1..=5).contains(&dc) || user <= 0 { - return Err(invalid()); - } - Ok((dc, user)) -} - -pub(crate) fn to_dto(value: Passkey) -> PasskeyDto { - let Passkey::Passkey(value) = value; - PasskeyDto { - id: value.id, - name: value.name, - date: value.date, - software_emoji_id: value.software_emoji_id, - last_usage_date: value.last_usage_date, - } -} - -#[cfg(test)] -#[path = "../tests/unit/passkeys_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/peers.rs b/native/mtproto-rs/src/peers.rs deleted file mode 100644 index f18888e5c..000000000 --- a/native/mtproto-rs/src/peers.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Peer id encoding and access-hash cache for InputPeer construction. - -use serde::{Deserialize, Serialize}; -use tellers_mtproto::latest::api::{ - InputPeer, InputPeerChannelConstructor, InputPeerChatConstructor, InputPeerUserConstructor, - Peer, Vector, -}; - -const CHANNEL_OFFSET: i64 = 1_000_000_000_000; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum PeerKind { - User, - Chat, - Channel, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub struct CachedPeer { - pub kind: PeerKind, - pub id: i64, - pub access_hash: i64, - /// Min hashes must not be used as `InputPeer` (https://core.telegram.org/api/min). - #[serde(default)] - pub min_hash: bool, -} - -pub fn peer_chat_id(peer: &Peer) -> i64 { - match peer { - Peer::PeerUser(u) => u.user_id, - Peer::PeerChat(c) => -c.chat_id, - Peer::PeerChannel(c) => -(CHANNEL_OFFSET + c.channel_id), - _ => 0, - } -} - -pub fn chat_id_for_user(user_id: i64) -> i64 { - user_id -} - -pub fn chat_id_for_chat(chat_id: i64) -> i64 { - -chat_id -} - -pub fn chat_id_for_channel(channel_id: i64) -> i64 { - -(CHANNEL_OFFSET + channel_id) -} - -pub fn is_channel_chat_id(chat_id: i64) -> bool { - chat_id <= -CHANNEL_OFFSET -} - -pub fn channel_id_from_chat_id(chat_id: i64) -> Option { - if is_channel_chat_id(chat_id) { - Some(-chat_id - CHANNEL_OFFSET) - } else { - None - } -} - -/// Apply a peer from TL. Never replace a full hash with a `*Min` hash. -/// https://core.telegram.org/api/min -pub fn upsert_cached_peer( - peers: &mut crate::HashMap, - chat_id: i64, - kind: PeerKind, - raw_id: i64, - access_hash: i64, - min: bool, -) { - if kind != PeerKind::Chat && (min || access_hash == 0) { - if let Some(existing) = peers.get(&chat_id) { - if existing.access_hash != 0 && !existing.min_hash { - return; - } - } else { - return; - } - if min { - return; - } - } - peers.insert( - chat_id, - CachedPeer { - kind, - id: raw_id, - access_hash, - min_hash: min, - }, - ); -} - -/// User/channel `InputPeer` needs a full access hash. Basic chats do not. -/// https://core.telegram.org/api/min -pub fn has_usable_access_hash(peer: &CachedPeer) -> bool { - match peer.kind { - PeerKind::Chat => true, - PeerKind::User | PeerKind::Channel => !peer.min_hash && peer.access_hash != 0, - } -} - -pub fn usable_cached_peer( - peers: &crate::HashMap, - chat_id: i64, -) -> Option<&CachedPeer> { - peers - .get(&chat_id) - .filter(|peer| has_usable_access_hash(peer)) -} - -pub fn require_usable_peer( - peers: &crate::HashMap, - chat_id: i64, -) -> Result<&CachedPeer, crate::MtprotoError> { - usable_cached_peer(peers, chat_id).ok_or_else(|| { - crate::MtprotoError::Message(format!("unknown peer {chat_id}; refresh chats first")) - }) -} - -pub fn input_peer_from_cached(peer: &CachedPeer) -> InputPeer { - match peer.kind { - PeerKind::User => InputPeer::InputPeerUser(InputPeerUserConstructor { - user_id: peer.id, - access_hash: peer.access_hash, - }), - PeerKind::Chat => InputPeer::InputPeerChat(InputPeerChatConstructor { chat_id: peer.id }), - PeerKind::Channel => InputPeer::InputPeerChannel(InputPeerChannelConstructor { - channel_id: peer.id, - access_hash: peer.access_hash, - }), - } -} - -pub fn chat_id_from_input_peer(peer: &InputPeer) -> Option { - match peer { - InputPeer::InputPeerUser(u) => Some(chat_id_for_user(u.user_id)), - InputPeer::InputPeerChat(c) => Some(chat_id_for_chat(c.chat_id)), - InputPeer::InputPeerChannel(c) => Some(chat_id_for_channel(c.channel_id)), - InputPeer::InputPeerSelf(_) => Some(0), // saved messages placeholder - _ => None, - } -} - -pub fn vector_items(vector: &Vector) -> &[T] { - match vector { - Vector::Vector(v) => &v.field_1, - } -} - -pub fn vector_boxed_items(vector: &Vector>) -> impl Iterator { - vector_items(vector).iter().map(|b| b.as_ref()) -} - -#[cfg(test)] -#[path = "../tests/unit/peers_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/perf.rs b/native/mtproto-rs/src/perf.rs deleted file mode 100644 index 0d7f16501..000000000 --- a/native/mtproto-rs/src/perf.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Opt-in netcode timing spans: op names and durations only, never payloads. - -use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Instant; - -use parking_lot::Mutex; -use serde::Serialize; - -/// Ring size per op. -const MAX_SAMPLES: usize = 64; - -static ENABLED: AtomicBool = AtomicBool::new(false); - -fn epoch() -> &'static Instant { - static EPOCH: OnceLock = OnceLock::new(); - EPOCH.get_or_init(Instant::now) -} - -#[derive(Serialize, Clone, Copy)] -pub struct PerfSample { - pub at_us: u64, - pub us: u64, -} - -#[derive(Serialize, Clone)] -pub struct PerfOp { - pub op: String, - pub count: u64, - pub sum_us: u64, - pub max_us: u64, - pub samples: Vec, -} - -static OPS: Mutex> = Mutex::new(Vec::new()); -static COUNTERS: Mutex> = Mutex::new(Vec::new()); - -pub fn set_enabled(enabled: bool) { - ENABLED.store(enabled, Ordering::Relaxed); -} - -pub fn enabled() -> bool { - ENABLED.load(Ordering::Relaxed) -} - -pub fn span(op: &'static str) -> Span { - if !enabled() { - return Span { - op: None, - start: None, - }; - } - Span { - op: Some(op.to_owned()), - start: Some(Instant::now()), - } -} - -pub fn span_dyn(op: impl FnOnce() -> String) -> Span { - if !enabled() { - return Span { - op: None, - start: None, - }; - } - Span { - op: Some(op()), - start: Some(Instant::now()), - } -} - -pub fn count(op: &'static str) { - if !enabled() { - return; - } - bump(op); -} - -fn bump(op: &str) { - let mut counters = COUNTERS.lock(); - match counters.iter_mut().find(|(name, _)| name == op) { - Some((_, value)) => *value += 1, - None => counters.push((op.to_owned(), 1)), - } -} - -pub fn record(op: &str, micros: u64) { - let at_us = epoch().elapsed().as_micros() as u64; - let mut ops = OPS.lock(); - let entry = match ops.iter_mut().find(|entry| entry.op == op) { - Some(entry) => entry, - None => { - ops.push(PerfOp { - op: op.to_owned(), - count: 0, - sum_us: 0, - max_us: 0, - samples: Vec::new(), - }); - ops.last_mut().expect("just pushed") - } - }; - entry.count += 1; - entry.sum_us += micros; - if micros > entry.max_us { - entry.max_us = micros; - } - if entry.samples.len() == MAX_SAMPLES { - entry.samples.remove(0); - } - entry.samples.push(PerfSample { at_us, us: micros }); -} - -#[derive(Serialize)] -struct Snapshot<'a> { - at_us: u64, - ops: &'a [PerfOp], - counters: &'a [(String, u64)], -} - -/// `reset` clears the table so one dump describes one measurement window. -pub fn snapshot_json(reset: bool) -> String { - let now_us = epoch().elapsed().as_micros() as u64; - let ops = std::mem::take(&mut *OPS.lock()); - let counters = std::mem::take(&mut *COUNTERS.lock()); - let snapshot = Snapshot { - at_us: now_us, - ops: &ops, - counters: &counters, - }; - let json = serde_json::to_string(&snapshot).unwrap_or_else(|_| "{}".into()); - if !reset { - let mut guard = OPS.lock(); - for op in ops { - match guard.iter_mut().find(|entry| entry.op == op.op) { - Some(entry) => { - entry.count += op.count; - entry.sum_us += op.sum_us; - if op.max_us > entry.max_us { - entry.max_us = op.max_us; - } - for sample in op.samples { - if entry.samples.len() == MAX_SAMPLES { - entry.samples.remove(0); - } - entry.samples.push(sample); - } - } - None => guard.push(op), - } - } - drop(guard); - let mut counter_guard = COUNTERS.lock(); - for (name, value) in counters { - match counter_guard.iter_mut().find(|(name_, _)| *name_ == name) { - Some((_, existing)) => *existing += value, - None => counter_guard.push((name, value)), - } - } - } - json -} - -#[cfg(test)] -#[path = "../tests/unit/perf_tests.rs"] -mod tests; - -pub struct Span { - op: Option, - start: Option, -} - -impl Span { - pub fn with(self, body: impl FnOnce() -> T) -> T { - let value = body(); - drop(self); - value - } -} - -impl Drop for Span { - fn drop(&mut self) { - let (Some(op), Some(start)) = (self.op.as_deref(), self.start) else { - return; - }; - record(op, start.elapsed().as_micros() as u64); - } -} diff --git a/native/mtproto-rs/src/presence.rs b/native/mtproto-rs/src/presence.rs deleted file mode 100644 index ff5ee9fb5..000000000 --- a/native/mtproto-rs/src/presence.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Map Telegram user status / typing actions into compact DTO strings. -//! https://core.telegram.org/type/UserStatus -//! https://core.telegram.org/type/SendMessageAction - -use tellers_mtproto::latest::api::{SendMessageAction, UserStatus}; - -pub fn user_status_parts(status: Option<&UserStatus>) -> (Option, Option) { - match status { - Some(UserStatus::UserStatusOnline(s)) => { - ("online".to_string().into(), Some(i64::from(s.expires))) - } - Some(UserStatus::UserStatusOffline(s)) => { - ("offline".to_string().into(), Some(i64::from(s.was_online))) - } - Some(UserStatus::UserStatusRecently(_)) => ("recently".to_string().into(), None), - Some(UserStatus::UserStatusLastWeek(_)) => ("last_week".to_string().into(), None), - Some(UserStatus::UserStatusLastMonth(_)) => ("last_month".to_string().into(), None), - _ => (None, None), - } -} - -/// Compact wire kind for a live send-message action. `None` means cancel / not shown. -pub fn action_kind(action: &SendMessageAction) -> Option<&'static str> { - match action { - SendMessageAction::SendMessageTypingAction(_) => Some("typing"), - SendMessageAction::SendMessageRecordAudioAction(_) => Some("record_audio"), - SendMessageAction::SendMessageUploadAudioAction(_) => Some("upload_audio"), - SendMessageAction::SendMessageRecordVideoAction(_) => Some("record_video"), - SendMessageAction::SendMessageUploadVideoAction(_) => Some("upload_video"), - SendMessageAction::SendMessageUploadPhotoAction(_) => Some("upload_photo"), - SendMessageAction::SendMessageUploadDocumentAction(_) => Some("upload_document"), - SendMessageAction::SendMessageGeoLocationAction(_) => Some("geo"), - SendMessageAction::SendMessageChooseContactAction(_) => Some("contact"), - SendMessageAction::SendMessageGamePlayAction(_) => Some("game"), - SendMessageAction::SendMessageRecordRoundAction(_) => Some("record_round"), - SendMessageAction::SendMessageUploadRoundAction(_) => Some("upload_round"), - SendMessageAction::SpeakingInGroupCallAction(_) => Some("speaking"), - SendMessageAction::SendMessageChooseStickerAction(_) => Some("choose_sticker"), - SendMessageAction::SendMessageEmojiInteractionSeen(_) => Some("watching_emoji"), - SendMessageAction::SendMessageCancelAction(_) - | SendMessageAction::SendMessageHistoryImportAction(_) - | SendMessageAction::SendMessageEmojiInteraction(_) - | SendMessageAction::SendMessageTextDraftAction(_) - | SendMessageAction::InputSendMessageRichMessageDraftAction(_) - | SendMessageAction::SendMessageRichMessageDraftAction(_) - | SendMessageAction::SendMessageStopDraftAction(_) => None, - } -} - -pub fn action_is_active(action: &SendMessageAction) -> bool { - action_kind(action).is_some() -} - -#[cfg(test)] -#[path = "../tests/unit/presence_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/profile/common.rs b/native/mtproto-rs/src/profile/common.rs deleted file mode 100644 index edb00220d..000000000 --- a/native/mtproto-rs/src/profile/common.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - Chat as TlChat, InputUser, InputUserConstructor, MessagesChats, MessagesGetCommonChatsRequest, -}; -use tellers_mtproto_session::Snapshot; - -use super::full::{first_username, index_chat_photo}; -use crate::MtprotoError; -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::peers::{ - self, CachedPeer, PeerKind, chat_id_for_channel, chat_id_for_chat, vector_boxed_items, -}; - -/// `messages.getCommonChats` — groups/channels shared with a user, as compact JSON -/// `{"chats":[{id,title,kind,username,avatar}]}`. -/// https://core.telegram.org/method/messages.getCommonChats -pub fn get_common_chats( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - user_id: i64, - max_id: i64, - limit: i32, -) -> Result { - let cached = peers::require_usable_peer(peers, user_id)?; - let response: MessagesChats = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetCommonChatsRequest { - user_id: Box::new(InputUser::InputUser(InputUserConstructor { - user_id: cached.id, - access_hash: cached.access_hash, - })), - max_id, - limit: limit.clamp(1, 100), - }, - )?; - let chats = match response { - MessagesChats::MessagesChats(value) => value.chats, - MessagesChats::MessagesChatsSlice(value) => value.chats, - }; - let mut out = Vec::new(); - for chat in vector_boxed_items(&chats) { - let (raw_id, title, kind, username, photo, is_channel) = match chat { - TlChat::Chat(value) => ( - value.id, - value.title.clone(), - "group", - None, - Some(&value.photo), - false, - ), - TlChat::Channel(value) => ( - value.id, - value.title.clone(), - if value.megagroup.is_some() { - "group" - } else { - "channel" - }, - first_username(&value.username, &value.usernames), - Some(&value.photo), - true, - ), - _ => continue, - }; - let peer_chat_id = if is_channel { - chat_id_for_channel(raw_id) - } else { - chat_id_for_chat(raw_id) - }; - let avatar_cache_key = match photo { - Some(photo) => index_chat_photo( - media_index, - peer_chat_id, - if is_channel { - PeerKind::Channel - } else { - PeerKind::Chat - }, - raw_id, - 0, - photo, - ), - None => None, - }; - let mut entry = serde_json::Map::new(); - entry.insert("id".into(), serde_json::Value::from(peer_chat_id)); - entry.insert("title".into(), serde_json::Value::from(title)); - entry.insert("kind".into(), serde_json::Value::from(kind)); - if let Some(username) = username { - entry.insert("username".into(), serde_json::Value::from(username)); - } - if let Some(key) = avatar_cache_key { - entry.insert("avatar".into(), serde_json::Value::from(key)); - } - out.push(serde_json::Value::Object(entry)); - } - Ok(serde_json::json!({ "chats": out }).to_string()) -} diff --git a/native/mtproto-rs/src/profile/full.rs b/native/mtproto-rs/src/profile/full.rs deleted file mode 100644 index 40eab8e77..000000000 --- a/native/mtproto-rs/src/profile/full.rs +++ /dev/null @@ -1,484 +0,0 @@ -//! https://core.telegram.org/method/users.getFullUser -//! https://core.telegram.org/method/messages.getFullChat -//! https://core.telegram.org/method/channels.getFullChannel - -use crate::HashMap; - -use serde_json::json; -use tellers_mtproto::latest::api::{ - ChannelsGetFullChannelRequest, Chat as TlChat, ChatFull, ChatPhoto, InputChannel, - InputChannelConstructor, InputUser, InputUserConstructor, InputUserSelfConstructor, - MessagesChatFull, MessagesGetFullChatRequest, Photo, User, UserFull, UserProfilePhoto, - Username, UsersGetFullUserRequest, UsersUserFull, Vector, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::emoji_status::emoji_status_document_id; -use crate::media::{self, MediaIndex}; -use crate::peers::{ - self, CachedPeer, PeerKind, chat_id_for_channel, chat_id_for_chat, chat_id_for_user, - vector_boxed_items, -}; -use crate::presence::user_status_parts; -use crate::{MtprotoError, ProfileDto}; - -pub(crate) const CHANNEL_OFFSET: i64 = 1_000_000_000_000; - -pub(crate) fn first_username( - username: &Option, - usernames: &Option>>>, -) -> Option { - username.clone().filter(|s| !s.is_empty()).or_else(|| { - let names = usernames.as_ref()?; - let mut fallback = None; - for item in vector_boxed_items(names) { - let Username::Username(n) = item else { - continue; - }; - if n.username.is_empty() { - continue; - } - if n.active.is_some() { - return Some(n.username.clone()); - } - if fallback.is_none() { - fallback = Some(n.username.clone()); - } - } - fallback - }) -} - -pub(crate) fn display_user(user: &User) -> (String, Option) { - match user { - User::User(u) => { - let username = first_username(&u.username, &u.usernames); - let first = u.first_name.clone().unwrap_or_default(); - let last = u.last_name.clone().unwrap_or_default(); - let title = format!("{first} {last}").trim().to_string(); - let title = if title.is_empty() { - username.clone().unwrap_or_else(|| format!("User {}", u.id)) - } else { - title - }; - (title, username) - } - User::UserEmpty(u) => (format!("User {}", u.id), None), - _ => ("User".into(), None), - } -} - -pub(crate) fn user_from_list(users: impl Iterator, id: i64) -> Option { - users.into_iter().find(|u| match u { - User::User(x) => x.id == id, - User::UserEmpty(x) => x.id == id, - _ => false, - }) -} - -pub(crate) fn index_user_avatar( - media_index: &mut MediaIndex, - peer_chat_id: i64, - user: &User, - profile_photo: Option<&Photo>, - personal_photo: Option<&Photo>, -) -> Option { - let cache_key = format!("avatar:{peer_chat_id}"); - if let Some(photo) = profile_photo.or(personal_photo) { - if let Some(media) = media::media_ref_from_photo(photo, cache_key.clone()) { - return Some(media::index_avatar(media_index, peer_chat_id, media)); - } - } - if let User::User(u) = user { - if let Some(photo) = u.photo.as_ref() { - if let UserProfilePhoto::UserProfilePhoto(p) = photo.as_ref() { - let access_hash = u.access_hash.unwrap_or(0); - let has_video = p.has_video.is_some(); - let media = media::media_ref_peer_photo( - PeerKind::User, - u.id, - access_hash, - p.photo_id, - p.dc_id, - media::avatar_cache_key(peer_chat_id, has_video), - has_video, - p.stripped_thumb.as_deref(), - ); - return Some(media::index_avatar(media_index, peer_chat_id, media)); - } - } - } - None -} - -pub(crate) fn index_chat_photo( - media_index: &mut MediaIndex, - peer_chat_id: i64, - kind: PeerKind, - raw_id: i64, - access_hash: i64, - photo: &ChatPhoto, -) -> Option { - let ChatPhoto::ChatPhoto(p) = photo else { - return None; - }; - let has_video = p.has_video.is_some(); - let media = media::media_ref_peer_photo( - kind, - raw_id, - access_hash, - p.photo_id, - p.dc_id, - media::avatar_cache_key(peer_chat_id, has_video), - has_video, - p.stripped_thumb.as_deref(), - ); - Some(media::index_avatar(media_index, peer_chat_id, media)) -} - -pub(crate) fn fetch_user_full( - snapshot: &mut Snapshot, - api_id: i32, - media_index: &mut MediaIndex, - input: InputUser, - is_self: bool, - expected_id: Option, -) -> Result { - let response: UsersUserFull = api_invoke::invoke_api( - snapshot, - api_id, - UsersGetFullUserRequest { - id: Box::new(input), - }, - )?; - let UsersUserFull::UsersUserFull(full) = response else { - return Err(MtprotoError::Message("unexpected users.userFull".into())); - }; - let UserFull::UserFull(info) = *full.full_user else { - return Err(MtprotoError::Message("unexpected userFull".into())); - }; - let users: Vec = vector_boxed_items(&full.users).cloned().collect(); - let user = expected_id - .and_then(|id| user_from_list(users.iter().cloned(), id)) - .or_else(|| users.into_iter().next()) - .ok_or_else(|| MtprotoError::Message("userFull missing user".into()))?; - let id = match &user { - User::User(u) => u.id, - User::UserEmpty(u) => u.id, - _ => info.id, - }; - let peer_id = chat_id_for_user(id); - let (title, username) = display_user(&user); - let (status, status_at) = match &user { - User::User(u) => user_status_parts(u.status.as_ref().map(|s| s.as_ref())), - _ => (None, None), - }; - let avatar_cache_key = index_user_avatar( - media_index, - peer_id, - &user, - info.profile_photo.as_ref().map(|p| p.as_ref()), - info.personal_photo.as_ref().map(|p| p.as_ref()), - ); - let (phone, is_bot, is_verified, is_scam, is_premium) = match &user { - User::User(u) => ( - u.phone.clone().filter(|s| !s.is_empty()), - u.bot.is_some(), - u.verified.is_some(), - u.scam.is_some() || u.fake.is_some(), - u.premium.is_some(), - ), - _ => (None, false, false, false, false), - }; - if is_self { - crate::transfer_policy::current() - .premium - .store(is_premium, std::sync::atomic::Ordering::Relaxed); - } - Ok(ProfileDto { - id: peer_id, - kind: if is_bot { "bot".into() } else { "user".into() }, - title, - username, - about: info.about, - avatar_cache_key, - is_self, - status, - status_at, - extra_json: profile_extra_json( - None, - None, - Some(info.common_chats_count).filter(|n| *n > 0), - phone.as_deref(), - is_bot, - is_verified, - is_scam, - match &user { - User::User(u) => { - emoji_status_document_id(u.emoji_status.as_ref().map(|s| s.as_ref())) - } - _ => None, - }, - is_premium, - None, - info.blocked.is_some(), - ), - }) -} - -pub(crate) fn fetch_basic_chat( - snapshot: &mut Snapshot, - api_id: i32, - media_index: &mut MediaIndex, - chat_id: i64, -) -> Result { - let response: MessagesChatFull = - api_invoke::invoke_api(snapshot, api_id, MessagesGetFullChatRequest { chat_id })?; - let MessagesChatFull::MessagesChatFull(full) = response else { - return Err(MtprotoError::Message("unexpected messages.chatFull".into())); - }; - let about = match *full.full_chat { - ChatFull::ChatFull(c) => Some(c.about).filter(|s| !s.is_empty()), - ChatFull::ChannelFull(c) => Some(c.about).filter(|s| !s.is_empty()), - _ => None, - }; - let peer_id = chat_id_for_chat(chat_id); - let mut title = format!("Chat {chat_id}"); - let mut avatar_cache_key = None; - let mut members_count = None; - for chat in vector_boxed_items(&full.chats) { - match chat { - TlChat::Chat(c) if c.id == chat_id => { - title = c.title.clone(); - members_count = Some(c.participants_count); - avatar_cache_key = - index_chat_photo(media_index, peer_id, PeerKind::Chat, c.id, 0, &c.photo); - break; - } - TlChat::ChatForbidden(c) if c.id == chat_id => { - title = c.title.clone(); - break; - } - _ => {} - } - } - Ok(ProfileDto { - id: peer_id, - kind: "chat".into(), - title, - username: None, - about, - avatar_cache_key, - is_self: false, - status: None, - status_at: None, - extra_json: profile_extra_json( - members_count, - None, - None, - None, - false, - false, - false, - None, - false, - None, - false, - ), - }) -} - -pub(crate) fn fetch_channel( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - peer_id: i64, -) -> Result { - let cached = peers::require_usable_peer(peers, peer_id)?; - if cached.kind != PeerKind::Channel { - return Err(MtprotoError::Message("peer is not a channel".into())); - } - let response: MessagesChatFull = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsGetFullChannelRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: cached.id, - access_hash: cached.access_hash, - })), - }, - )?; - let MessagesChatFull::MessagesChatFull(full) = response else { - return Err(MtprotoError::Message( - "unexpected channels.getFullChannel result".into(), - )); - }; - let (about, members_count, online_count, can_view_participants) = match *full.full_chat { - ChatFull::ChannelFull(c) => ( - Some(c.about.clone()).filter(|s| !s.is_empty()), - c.participants_count, - c.online_count, - Some(c.can_view_participants.is_some()), - ), - ChatFull::ChatFull(c) => ( - Some(c.about.clone()).filter(|s| !s.is_empty()), - None, - None, - None, - ), - _ => (None, None, None, None), - }; - let mut title = format!("Channel {}", cached.id); - let mut username = None; - let mut avatar_cache_key = None; - let mut kind = "channel".to_string(); - let mut verified = false; - let mut scam = false; - let mut emoji_status = None; - for chat in vector_boxed_items(&full.chats) { - match chat { - TlChat::Channel(c) if c.id == cached.id => { - title = c.title.clone(); - username = first_username(&c.username, &c.usernames); - emoji_status = - emoji_status_document_id(c.emoji_status.as_ref().map(|s| s.as_ref())); - if c.megagroup.is_some() { - kind = "group".into(); - } - verified = c.verified.is_some(); - scam = c.scam.is_some() || c.fake.is_some(); - avatar_cache_key = index_chat_photo( - media_index, - peer_id, - PeerKind::Channel, - c.id, - c.access_hash.unwrap_or(cached.access_hash), - &c.photo, - ); - break; - } - TlChat::ChannelForbidden(c) if c.id == cached.id => { - title = c.title.clone(); - break; - } - _ => {} - } - } - // Broadcast channels expose the subscriber list only when the server allows it; - // megagroups are attempted anyway and hidden on failure. - let participants_visible = if kind == "channel" { - can_view_participants - } else { - None - }; - Ok(ProfileDto { - id: chat_id_for_channel(cached.id), - kind, - title, - username, - about, - avatar_cache_key, - is_self: false, - status: None, - status_at: None, - extra_json: profile_extra_json( - members_count, - online_count, - None, - None, - false, - verified, - scam, - emoji_status, - false, - participants_visible, - false, - ), - }) -} - -pub fn get_profile( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - self_user_id: Option, - peer_id: i64, -) -> Result { - let is_self = peer_id == 0 || self_user_id == Some(peer_id); - if is_self { - return fetch_user_full( - snapshot, - api_id, - media_index, - InputUser::InputUserSelf(InputUserSelfConstructor {}), - true, - self_user_id, - ); - } - if peer_id > 0 { - let cached = peers::require_usable_peer(peers, peer_id)?; - return fetch_user_full( - snapshot, - api_id, - media_index, - InputUser::InputUser(InputUserConstructor { - user_id: peer_id, - access_hash: cached.access_hash, - }), - false, - Some(peer_id), - ); - } - if peer_id > -CHANNEL_OFFSET { - return fetch_basic_chat(snapshot, api_id, media_index, -peer_id); - } - fetch_channel(snapshot, api_id, peers, media_index, peer_id) -} - -pub(crate) fn profile_extra_json( - members: Option, - online: Option, - common: Option, - phone: Option<&str>, - bot: bool, - verified: bool, - scam: bool, - emoji: Option, - premium: bool, - participants: Option, - blocked: bool, -) -> Option { - if members.is_none() - && online.is_none() - && common.is_none() - && phone.is_none() - && !bot - && !verified - && !scam - && emoji.is_none() - && !premium - && participants.is_none() - && !blocked - { - return None; - } - Some( - json!({ - "members": members, - "online": online, - "common": common, - "phone": phone, - "bot": bot, - "verified": verified, - "scam": scam, - "emoji": emoji, - "premium": premium, - "participants": participants, - "blocked": blocked, - }) - .to_string(), - ) -} diff --git a/native/mtproto-rs/src/profile/mod.rs b/native/mtproto-rs/src/profile/mod.rs deleted file mode 100644 index a072f3764..000000000 --- a/native/mtproto-rs/src/profile/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Full user/chat/channel profiles and member lists. -//! https://core.telegram.org/method/users.getFullUser -//! https://core.telegram.org/method/messages.getFullChat -//! https://core.telegram.org/method/channels.getFullChannel - -mod common; -mod full; -mod participants; -mod status; - -pub use common::*; -pub(crate) use full::profile_extra_json; -pub use full::*; -pub(crate) use participants::custom_or_role; -pub use participants::*; -pub use status::*; - -#[cfg(test)] -#[path = "../../tests/unit/profile_mod_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/profile/participants.rs b/native/mtproto-rs/src/profile/participants.rs deleted file mode 100644 index 8d88cd86f..000000000 --- a/native/mtproto-rs/src/profile/participants.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - ChannelParticipant, ChannelParticipantsAdminsConstructor, ChannelParticipantsBotsConstructor, - ChannelParticipantsFilter, ChannelParticipantsRecentConstructor, ChannelsChannelParticipants, - ChannelsGetParticipantsRequest, ChatFull, ChatParticipant, ChatParticipants, InputChannel, - InputChannelConstructor, MessagesChatFull, MessagesGetFullChatRequest, User, -}; -use tellers_mtproto_session::Snapshot; - -use super::full::{display_user, index_user_avatar, user_from_list}; -use crate::MtprotoError; -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::peers::{self, CachedPeer, PeerKind, chat_id_for_user, vector_boxed_items}; -use crate::presence::user_status_parts; - -pub fn get_group_admin_tags( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - chat_id: i64, -) -> Result { - let chat = peers::require_usable_peer(peers, chat_id)?; - let mut tags = serde_json::Map::new(); - if chat.kind == PeerKind::Channel { - let response: ChannelsChannelParticipants = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsGetParticipantsRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: chat.id, - access_hash: chat.access_hash, - })), - filter: Box::new(ChannelParticipantsFilter::ChannelParticipantsAdmins( - ChannelParticipantsAdminsConstructor {}, - )), - offset: 0, - limit: 200, - hash: 0, - }, - )?; - if let ChannelsChannelParticipants::ChannelsChannelParticipants(result) = response { - for participant in vector_boxed_items(&result.participants) { - if let Some((user_id, tag)) = channel_participant_tag(participant) { - tags.insert(user_id.to_string(), serde_json::Value::String(tag)); - } - } - } - } else if chat.kind == PeerKind::Chat { - let response: MessagesChatFull = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetFullChatRequest { chat_id: chat.id }, - )?; - let MessagesChatFull::MessagesChatFull(full) = response; - if let ChatFull::ChatFull(chat_full) = full.full_chat.as_ref() { - if let ChatParticipants::ChatParticipants(participants) = - chat_full.participants.as_ref() - { - for participant in vector_boxed_items(&participants.participants) { - if let Some((user_id, tag)) = basic_participant_tag(participant) { - tags.insert(user_id.to_string(), serde_json::Value::String(tag)); - } - } - } - } - } - Ok(serde_json::Value::Object(tags).to_string()) -} - -pub(crate) fn custom_or_role(rank: &Option, role: &str) -> String { - rank.as_ref() - .filter(|value| !value.is_empty()) - .map(|value| format!("rank:{value}")) - .unwrap_or_else(|| format!("role:{role}")) -} - -pub(crate) fn channel_participant_tag(participant: &ChannelParticipant) -> Option<(i64, String)> { - match participant { - ChannelParticipant::ChannelParticipantCreator(value) => { - Some((value.user_id, custom_or_role(&value.rank, "owner"))) - } - ChannelParticipant::ChannelParticipantAdmin(value) => { - Some((value.user_id, custom_or_role(&value.rank, "admin"))) - } - _ => None, - } -} - -pub(crate) fn basic_participant_tag(participant: &ChatParticipant) -> Option<(i64, String)> { - match participant { - ChatParticipant::ChatParticipantCreator(value) => { - Some((value.user_id, custom_or_role(&value.rank, "owner"))) - } - ChatParticipant::ChatParticipantAdmin(value) => { - Some((value.user_id, custom_or_role(&value.rank, "admin"))) - } - _ => None, - } -} - -pub(crate) fn channel_participant_role(participant: &ChannelParticipant) -> &'static str { - match participant { - ChannelParticipant::ChannelParticipantCreator(_) => "owner", - ChannelParticipant::ChannelParticipantAdmin(_) => "admin", - _ => "member", - } -} - -pub(crate) fn channel_participant_rank(participant: &ChannelParticipant) -> Option { - match participant { - ChannelParticipant::ChannelParticipantCreator(value) => value.rank.clone(), - ChannelParticipant::ChannelParticipantAdmin(value) => value.rank.clone(), - _ => None, - } -} - -pub(crate) fn participant_user_id(participant: &ChannelParticipant) -> Option { - match participant { - ChannelParticipant::ChannelParticipant(value) => Some(value.user_id), - ChannelParticipant::ChannelParticipantSelf(value) => Some(value.user_id), - ChannelParticipant::ChannelParticipantCreator(value) => Some(value.user_id), - ChannelParticipant::ChannelParticipantAdmin(value) => Some(value.user_id), - _ => None, - } -} - -pub(crate) fn member_json( - media_index: &mut MediaIndex, - users: &[User], - user_id: i64, - role: &str, - rank: Option, -) -> Option { - let user = user_from_list(users.iter().cloned(), user_id)?; - let (title, username) = display_user(&user); - let peer_id = chat_id_for_user(user_id); - let avatar_cache_key = index_user_avatar(media_index, peer_id, &user, None, None); - let (status, status_at) = match &user { - User::User(u) => user_status_parts(u.status.as_ref().map(|s| s.as_ref())), - _ => (None, None), - }; - let mut entry = serde_json::Map::new(); - entry.insert("id".into(), serde_json::Value::from(user_id)); - entry.insert("peer".into(), serde_json::Value::from(peer_id)); - entry.insert("title".into(), serde_json::Value::from(title)); - if let Some(username) = username { - entry.insert("username".into(), serde_json::Value::from(username)); - } - if let Some(key) = avatar_cache_key { - entry.insert("avatar".into(), serde_json::Value::from(key)); - } - if let Some(status) = status { - entry.insert("status".into(), serde_json::Value::from(status)); - } - if let Some(status_at) = status_at { - entry.insert("status_at".into(), serde_json::Value::from(status_at)); - } - entry.insert("role".into(), serde_json::Value::from(role)); - if let Some(rank) = rank.filter(|value| !value.is_empty()) { - entry.insert("rank".into(), serde_json::Value::from(rank)); - } - Some(serde_json::Value::Object(entry)) -} - -/// `channels.getParticipants` / `messages.getFullChat` participant page as compact JSON -/// `{"count":N,"members":[{id,peer,title,username,avatar,status,role,rank}]}`. -/// Basic groups always carry the full participant list in `chatFull`. -/// https://core.telegram.org/method/channels.getParticipants -/// https://core.telegram.org/method/messages.getFullChat -pub fn get_participants( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - filter: &str, - query: &str, - offset: i32, - limit: i32, -) -> Result { - let chat = peers::require_usable_peer(peers, chat_id)?; - let limit = limit.clamp(1, 200); - let mut members = Vec::new(); - let mut count = 0i32; - if chat.kind == PeerKind::Channel { - let channel_filter: Box = match filter { - "admins" => Box::new(ChannelParticipantsFilter::ChannelParticipantsAdmins( - ChannelParticipantsAdminsConstructor {}, - )), - "bots" => Box::new(ChannelParticipantsFilter::ChannelParticipantsBots( - ChannelParticipantsBotsConstructor {}, - )), - _ => Box::new(ChannelParticipantsFilter::ChannelParticipantsRecent( - ChannelParticipantsRecentConstructor {}, - )), - }; - let response: ChannelsChannelParticipants = api_invoke::invoke_api( - snapshot, - api_id, - ChannelsGetParticipantsRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: chat.id, - access_hash: chat.access_hash, - })), - filter: channel_filter, - offset: offset.max(0), - limit, - hash: 0, - }, - )?; - if let ChannelsChannelParticipants::ChannelsChannelParticipants(result) = response { - count = result.count; - let users: Vec = vector_boxed_items(&result.users).cloned().collect(); - for participant in vector_boxed_items(&result.participants) { - let Some(user_id) = participant_user_id(participant) else { - continue; - }; - if let Some(entry) = member_json( - media_index, - &users, - user_id, - channel_participant_role(participant), - channel_participant_rank(participant), - ) { - members.push(entry); - } - } - } - } else if chat.kind == PeerKind::Chat { - let response: MessagesChatFull = api_invoke::invoke_api( - snapshot, - api_id, - MessagesGetFullChatRequest { chat_id: chat.id }, - )?; - let MessagesChatFull::MessagesChatFull(full) = response; - let users: Vec = vector_boxed_items(&full.users).cloned().collect(); - if let ChatFull::ChatFull(chat_full) = full.full_chat.as_ref() { - if let ChatParticipants::ChatParticipants(participants) = - chat_full.participants.as_ref() - { - let all: Vec<&ChatParticipant> = - vector_boxed_items(&participants.participants).collect(); - count = all.len() as i32; - let skip = offset.max(0) as usize; - for participant in all.into_iter().skip(skip).take(limit as usize) { - let (user_id, role, rank) = match participant { - ChatParticipant::ChatParticipantCreator(value) => { - (value.user_id, "owner", value.rank.clone()) - } - ChatParticipant::ChatParticipantAdmin(value) => { - (value.user_id, "admin", value.rank.clone()) - } - ChatParticipant::ChatParticipant(value) => (value.user_id, "member", None), - _ => continue, - }; - if let Some(entry) = member_json(media_index, &users, user_id, role, rank) { - members.push(entry); - } - } - } - } - } else { - return Err(MtprotoError::Message("peer has no participants".into())); - } - let _ = query; - Ok(serde_json::json!({ "count": count, "members": members }).to_string()) -} diff --git a/native/mtproto-rs/src/profile/status.rs b/native/mtproto-rs/src/profile/status.rs deleted file mode 100644 index 2c1da8f19..000000000 --- a/native/mtproto-rs/src/profile/status.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! https://core.telegram.org/method/account.updateStatus - -use tellers_mtproto::latest::api::{ - AccountUpdateStatusRequest, Bool, BoolFalseConstructor, BoolTrueConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::MtprotoError; -use crate::api_invoke; - -pub fn update_status( - snapshot: &mut Snapshot, - api_id: i32, - offline: bool, -) -> Result<(), MtprotoError> { - let flag = if offline { - Bool::BoolTrue(BoolTrueConstructor {}) - } else { - Bool::BoolFalse(BoolFalseConstructor {}) - }; - let _: Bool = api_invoke::invoke_api( - snapshot, - api_id, - AccountUpdateStatusRequest { - offline: Box::new(flag), - }, - )?; - Ok(()) -} diff --git a/native/mtproto-rs/src/reply_markup.rs b/native/mtproto-rs/src/reply_markup.rs deleted file mode 100644 index 825e38d9f..000000000 --- a/native/mtproto-rs/src/reply_markup.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Compact JSON for message `reply_markup`. -//! https://core.telegram.org/api/bots/buttons - -use serde_json::{Value, json}; -use tellers_mtproto::latest::api::{ - ButtonType, InlineButtonType, KeyboardButton, KeyboardButtonRow, KeyboardInlineButton, - KeyboardInlineButtonRow, ReplyMarkup, -}; - -use crate::peers::vector_boxed_items; - -pub fn to_json(markup: Option<&ReplyMarkup>) -> Option { - let markup = markup?; - let value = match markup { - ReplyMarkup::ReplyKeyboardHide(_) => json!({"k":"hide"}), - ReplyMarkup::ReplyKeyboardForceReply(body) => { - let mut obj = serde_json::Map::new(); - obj.insert("k".into(), json!("force")); - if body.single_use.is_some() { - obj.insert("single".into(), json!(true)); - } - if body.selective.is_some() { - obj.insert("selective".into(), json!(true)); - } - if let Some(placeholder) = &body.placeholder { - if !placeholder.is_empty() { - obj.insert("placeholder".into(), json!(placeholder)); - } - } - Value::Object(obj) - } - ReplyMarkup::ReplyKeyboardMarkup(body) => { - let mut obj = serde_json::Map::new(); - obj.insert("k".into(), json!("keyboard")); - if body.resize.is_some() { - obj.insert("resize".into(), json!(true)); - } - if body.single_use.is_some() { - obj.insert("single".into(), json!(true)); - } - if body.selective.is_some() { - obj.insert("selective".into(), json!(true)); - } - if body.persistent.is_some() { - obj.insert("persistent".into(), json!(true)); - } - if body.force_reply.is_some() { - obj.insert("force".into(), json!(true)); - } - if let Some(placeholder) = &body.placeholder { - if !placeholder.is_empty() { - obj.insert("placeholder".into(), json!(placeholder)); - } - } - obj.insert("rows".into(), Value::Array(reply_rows(&body.rows))); - Value::Object(obj) - } - ReplyMarkup::ReplyInlineMarkup(body) => { - json!({ - "k": "inline", - "rows": inline_rows(&body.rows), - }) - } - }; - serde_json::to_string(&value).ok() -} - -fn reply_rows(rows: &tellers_mtproto::latest::api::Vector>) -> Vec { - vector_boxed_items(rows) - .filter_map(|row| match row { - KeyboardButtonRow::KeyboardButtonRow(body) => Some(Value::Array( - vector_boxed_items(&body.buttons) - .map(reply_button) - .collect(), - )), - }) - .collect() -} - -fn inline_rows( - rows: &tellers_mtproto::latest::api::Vector>, -) -> Vec { - vector_boxed_items(rows) - .filter_map(|row| match row { - KeyboardInlineButtonRow::KeyboardInlineButtonRow(body) => Some(Value::Array( - vector_boxed_items(&body.buttons) - .map(inline_button) - .collect(), - )), - }) - .collect() -} - -fn reply_button(button: &KeyboardButton) -> Value { - let KeyboardButton::KeyboardButton(body) = button; - let mut obj = serde_json::Map::new(); - obj.insert("x".into(), json!(body.text)); - match body.type_.as_ref() { - ButtonType::ButtonTypeDefault(_) => { - obj.insert("t".into(), json!("text")); - } - ButtonType::ButtonTypeRequestPhone(_) => { - obj.insert("t".into(), json!("contact")); - } - ButtonType::ButtonTypeRequestGeoLocation(_) => { - obj.insert("t".into(), json!("geo")); - } - ButtonType::ButtonTypeRequestPoll(_) => { - obj.insert("t".into(), json!("poll")); - } - ButtonType::ButtonTypeRequestPeer(_) | ButtonType::InputButtonTypeRequestPeer(_) => { - obj.insert("t".into(), json!("peer")); - } - ButtonType::ButtonTypeSimpleWebView(web) => { - obj.insert("t".into(), json!("web")); - obj.insert("u".into(), json!(web.url)); - } - _ => { - obj.insert("t".into(), json!("other")); - } - } - Value::Object(obj) -} - -fn inline_button(button: &KeyboardInlineButton) -> Value { - let KeyboardInlineButton::KeyboardInlineButton(body) = button; - let mut obj = serde_json::Map::new(); - obj.insert("x".into(), json!(body.text)); - match body.type_.as_ref() { - InlineButtonType::InlineButtonTypeUrl(url) => { - obj.insert("t".into(), json!("url")); - obj.insert("u".into(), json!(url.url)); - } - InlineButtonType::InlineButtonTypeCallback(cb) => { - obj.insert("t".into(), json!("cb")); - obj.insert("d".into(), json!(hex(&cb.data))); - if cb.requires_password.is_some() { - obj.insert("pw".into(), json!(true)); - } - } - InlineButtonType::InlineButtonTypeSwitchInline(sw) => { - obj.insert("t".into(), json!("sw")); - obj.insert("q".into(), json!(sw.query)); - if sw.same_peer.is_some() { - obj.insert("same".into(), json!(true)); - } - } - InlineButtonType::InlineButtonTypeCopy(copy) => { - obj.insert("t".into(), json!("copy")); - obj.insert("c".into(), json!(copy.copy_text)); - } - InlineButtonType::InlineButtonTypeWebView(web) => { - obj.insert("t".into(), json!("web")); - obj.insert("u".into(), json!(web.url)); - } - InlineButtonType::InlineButtonTypeUrlAuth(auth) => { - obj.insert("t".into(), json!("login")); - obj.insert("u".into(), json!(auth.url)); - } - InlineButtonType::InlineButtonTypeGame(_) => { - obj.insert("t".into(), json!("game")); - } - InlineButtonType::InlineButtonTypeBuy(_) => { - obj.insert("t".into(), json!("pay")); - } - InlineButtonType::InlineButtonTypeUserProfile(user) => { - obj.insert("t".into(), json!("user")); - obj.insert("u".into(), json!(user.user_id.to_string())); - } - InlineButtonType::InlineButtonTypeDisabled(_) => { - obj.insert("t".into(), json!("off")); - } - _ => { - obj.insert("t".into(), json!("other")); - } - } - Value::Object(obj) -} - -fn hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len().saturating_mul(2)); - for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); - } - out -} - -pub(crate) fn from_hex(src: &str) -> Result, crate::MtprotoError> { - if src.len() % 2 != 0 { - return Err(crate::MtprotoError::Message("invalid callback data".into())); - } - let mut out = Vec::with_capacity(src.len() / 2); - let bytes = src.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let hi = hex_nibble(bytes[i])?; - let lo = hex_nibble(bytes[i + 1])?; - out.push((hi << 4) | lo); - i += 2; - } - Ok(out) -} - -fn hex_nibble(ch: u8) -> Result { - match ch { - b'0'..=b'9' => Ok(ch - b'0'), - b'a'..=b'f' => Ok(ch - b'a' + 10), - b'A'..=b'F' => Ok(ch - b'A' + 10), - _ => Err(crate::MtprotoError::Message("invalid callback data".into())), - } -} - -#[cfg(test)] -#[path = "../tests/unit/reply_markup_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/request_control.rs b/native/mtproto-rs/src/request_control.rs deleted file mode 100644 index 3e5b71afd..000000000 --- a/native/mtproto-rs/src/request_control.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Request cancellation independent of the lifetime of a client or socket lane. -use crate::tcp::ConnectionControl; -use crate::{HashMap, HashMapExt}; -use parking_lot::Mutex; -use std::cell::RefCell; -use std::net::TcpStream; -use std::sync::{ - Arc, LazyLock, - atomic::{AtomicU64, Ordering}, -}; -use tellers_mtproto_transport::Error; - -static NEXT: AtomicU64 = AtomicU64::new(1); -static REQUESTS: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); -static PROMOTIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -thread_local! { - static CURRENT: RefCell<(u64, Option>)> = const { RefCell::new((0, None)) }; - static SHARED: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -pub fn create() -> u64 { - let id = NEXT.fetch_add(1, Ordering::Relaxed); - REQUESTS - .lock() - .insert(id, Arc::new(ConnectionControl::default())); - id -} - -/// Returns the previous binding for coroutine ThreadContextElement restoration. -pub fn bind(id: u64) -> u64 { - let control = if id == 0 { - None - } else { - Some(REQUESTS.lock().get(&id).cloned().unwrap_or_else(|| { - let closed = Arc::new(ConnectionControl::default()); - closed.close(); - closed - })) - }; - CURRENT.with(|cell| cell.replace((id, control)).0) -} - -pub(crate) fn current_id() -> u64 { - CURRENT.with(|cell| cell.borrow().0) -} - -pub(crate) fn with_shared_binding(id: u64, body: impl FnOnce() -> T) -> T { - struct Restore(u64, bool); - impl Drop for Restore { - fn drop(&mut self) { - bind(self.0); - SHARED.with(|cell| cell.set(self.1)); - } - } - let _restore = Restore(bind(id), SHARED.with(|cell| cell.replace(true))); - body() -} - -pub fn cancel(id: u64) { - let control = REQUESTS.lock().get(&id).cloned(); - if let Some(control) = control { - control.close(); - } -} - -pub fn release(id: u64) { - REQUESTS.lock().remove(&id); - PROMOTIONS.lock().remove(&id); -} - -pub fn promote(id: u64, class: crate::scheduler::RequestClass) { - if !REQUESTS.lock().contains_key(&id) { - return; - } - let mut promotions = PROMOTIONS.lock(); - let entry = promotions.entry(id).or_insert(class.bits()); - let current = crate::scheduler::RequestClass::from_bits(*entry); - if current.is_none_or(|c| class.priority() < c.priority()) { - *entry = class.bits(); - } -} - -pub(crate) fn promoted_class() -> Option { - let id = current_id(); - if id == 0 { - return None; - } - PROMOTIONS - .lock() - .get(&id) - .and_then(|bits| crate::scheduler::RequestClass::from_bits(*bits)) -} - -pub(crate) fn detach_sockets() { - // A finished parallel lane must not unregister the other lane's socket. - if SHARED.with(|cell| cell.get()) { - return; - } - CURRENT.with(|cell| { - if let Some(control) = &cell.borrow().1 { - control.detach_sockets(); - } - }); -} - -pub(crate) fn check() -> Result<(), Error> { - while_active(|| ()) -} - -pub(crate) fn while_active(operation: impl FnOnce() -> T) -> Result { - CURRENT - .with(|cell| match &cell.borrow().1 { - Some(control) => control.while_open(operation), - None => Ok(operation()), - }) - .map_err(|_| Error::Connection("request cancelled".into())) -} - -pub(crate) fn register(stream: &Arc) -> Result<(), Error> { - CURRENT - .with(|cell| match &cell.borrow().1 { - Some(control) => control.register(stream), - None => Ok(()), - }) - .map_err(|_| Error::Connection("request cancelled".into())) -} - -#[cfg(test)] -#[path = "../tests/unit/request_control_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/rpc.rs b/native/mtproto-rs/src/rpc.rs deleted file mode 100644 index 47a5d14ff..000000000 --- a/native/mtproto-rs/src/rpc.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Encrypted MTProto RPC over padded-intermediate framing using Tellers Engine. -//! https://core.telegram.org/mtproto/description -//! https://core.telegram.org/mtproto/mtproto-transports -//! https://core.telegram.org/mtproto/service_messages - -mod dc; -mod framing; -mod inbound; -mod invoke; -mod live; -mod main_policy; -pub(crate) mod salts; -mod supervisor; -mod timeout; - -pub use dc::{dc_endpoints, rotated_endpoints, same_ip_endpoints, set_use_test_dc, use_test_dc}; -pub use framing::SystemClock; -pub use inbound::{ - BoxedQuery, NewSessionMetadata, clear_new_session_metadata, encode_boxed_bytes, - take_new_session_metadata, -}; -pub(crate) use inbound::{gzip_if_smaller, ungzip_if_needed}; -pub use invoke::invoke_raw; -pub(crate) use live::queue_rpc_updates; -pub use live::{LiveTransport, drop_live_transport, with_live_transport}; -pub(crate) use timeout::rpc_timeout_secs; -pub use timeout::with_rpc_timeout_secs; - -pub(crate) use invoke::{ - flush_pending_acks, invoke_batch_raw_with_retry, invoke_batch_raw_with_retry_streaming, - invoke_raw_with_retry_factory, ping_existing_rtt, receive_updates, -}; -pub(crate) use main_policy::ACK_DELAY; -pub(crate) use timeout::trim_padded_mtproto_packet; - -#[cfg(test)] -use crate::MtprotoError; -#[cfg(test)] -use crate::tcp; -#[cfg(test)] -use std::io::Read; -#[cfg(test)] -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; -#[cfg(test)] -use tellers_mtproto::transport::{ - GzipPackedConstructor, MsgResendReqConstructor, NewSessionCreatedConstructor, PingRequest, - PongConstructor, RpcErrorConstructor, Vector, -}; -#[cfg(test)] -use tellers_mtproto_engine::{Engine, ExponentialBackoff}; -#[cfg(test)] -use tellers_mtproto_session::{Clock, OsRandom, ReceivedMessageResult, Snapshot}; -#[cfg(test)] -use tellers_mtproto_transport::{Framing, PaddedIntermediate}; - -#[cfg(test)] -pub(crate) use dc::{ - extra_reconnect_same_host, reconnect_backoff, reconnect_delay, rotated_same_ip, -}; -#[cfg(test)] -pub(crate) use framing::{ - bad_msg_should_reconnect, clock_delta_micros, detailed_answer_request, open_live_addr, - queue_update, recreate_session_after_bad_message, -}; -#[cfg(test)] -pub(crate) use inbound::{ - BAD_MSG_NOTIFICATION, BAD_SERVER_SALT, InboundEvent, MAX_UNPACKED_BYTES, MAX_WRAPPER_DEPTH, - MSG_CONTAINER, MSG_COPY, MSGS_ACK, NEW_SESSION_CREATED, PONG, RPC_RESULT, - apply_new_session_salt, is_updates_type, parse_authenticated, parse_service_or_result, - should_process_inbound, -}; -#[cfg(test)] -pub(crate) use invoke::{RawMethod, RpcRetryPolicy, may_reconnect_request}; -#[cfg(test)] -pub(crate) use live::{live_stack_depth, live_top_occupied}; -#[cfg(test)] -pub(crate) use supervisor::{ - ConnectionState, ConnectionSupervisor, FailureClass, failure_class, is_transport_error, - is_waitable_io, -}; -#[cfg(test)] -pub(crate) use timeout::{ - KEEPALIVE_PING_SECS, fail_fast_idle, idle_after_complete_frames, idle_empty_first_byte, - idle_needs_liveness_probe, idle_reused_socket, is_mid_frame, keepalive_probe_failed, - leftover_frame_grace, live_transport_stale, note_inbound_liveness, recv_wait_deadline, - rpc_attempt_budget, rpc_timeout_message, subscribed_read_deadline, timeout_idle_needs_probe, -}; - -#[cfg(test)] -#[path = "../tests/unit/rpc_container_tests.rs"] -mod container_tests; -#[cfg(test)] -#[path = "../tests/unit/rpc_dc_tests.rs"] -mod dc_tests; -#[cfg(test)] -#[path = "../tests/unit/rpc_gzip_tests.rs"] -mod gzip_tests; -#[cfg(test)] -#[path = "../tests/unit/rpc_push_tests.rs"] -mod push_tests; -#[cfg(test)] -#[path = "../tests/unit/rpc_wire_tests.rs"] -mod wire_tests; - -#[cfg(test)] -#[path = "../tests/unit/rpc_session_tests.rs"] -mod session_tests; -#[cfg(test)] -#[path = "../tests/unit/rpc_timeout_tests.rs"] -mod timeout_tests; diff --git a/native/mtproto-rs/src/rpc/dc.rs b/native/mtproto-rs/src/rpc/dc.rs deleted file mode 100644 index 41ece065e..000000000 --- a/native/mtproto-rs/src/rpc/dc.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! https://core.telegram.org/api/datacenter -//! https://core.telegram.org/api/obtaining_api_id#test-accounts - -use std::sync::atomic::{AtomicBool, Ordering}; - -use tellers_mtproto_crypto::fill_random; - -static USE_TEST_DC: AtomicBool = AtomicBool::new(false); - -/// Test DCs (99966XYYYY). Separate IPs from production; same dc_id numbers. -/// https://core.telegram.org/api/obtaining_api_id#test-accounts -pub fn set_use_test_dc(test: bool) { - USE_TEST_DC.store(test, Ordering::SeqCst); -} - -pub fn use_test_dc() -> bool { - USE_TEST_DC.load(Ordering::SeqCst) -} - -/// Direct-DC endpoints. 443 first: 5222/80 often accept TCP then stay silent (DPI). -pub fn dc_endpoints(dc_id: i32) -> &'static [&'static str] { - if use_test_dc() { - return test_dc_endpoints(dc_id); - } - match dc_id { - 1 => &[ - "149.154.175.53:443", - "149.154.175.53:5222", - "149.154.175.53:80", - ], - 2 => &[ - "149.154.167.51:443", - "149.154.167.50:443", - "149.154.167.51:5222", - "149.154.167.50:5222", - "149.154.167.51:80", - "149.154.167.50:80", - ], - 3 => &[ - "149.154.175.100:443", - "149.154.175.100:5222", - "149.154.175.100:80", - ], - 4 => &[ - "149.154.167.91:443", - "149.154.167.91:5222", - "149.154.167.91:80", - ], - 5 => &[ - "91.108.56.165:443", - "91.108.56.165:5222", - "91.108.56.165:80", - ], - _ => dc_endpoints(2), - } -} - -pub(crate) fn test_dc_endpoints(dc_id: i32) -> &'static [&'static str] { - match dc_id { - 1 => &["149.154.175.10:443", "149.154.175.10:80"], - 2 => &["149.154.167.40:443", "149.154.167.40:80"], - 3 => &["149.154.175.117:443", "149.154.175.117:80"], - _ => test_dc_endpoints(2), - } -} - -pub(crate) fn endpoint_host(addr: &str) -> &str { - addr.rsplit_once(':').map(|(host, _)| host).unwrap_or(addr) -} - -/// Ports on the first IP only, for stable endpoint retry ordering. -pub fn same_ip_endpoints(dc_id: i32) -> Vec<&'static str> { - let all = dc_endpoints(dc_id); - let Some(first) = all.first() else { - return Vec::new(); - }; - let host = endpoint_host(first); - all.iter() - .copied() - .filter(|addr| endpoint_host(addr) == host) - .collect() -} - -pub(crate) fn reconnect_backoff(attempt: usize) -> std::time::Duration { - let mut random = [0_u8; 8]; - let _ = fill_random(&mut random); - reconnect_delay(attempt, u64::from_le_bytes(random)) -} - -pub(crate) fn reconnect_delay(attempt: usize, random: u64) -> std::time::Duration { - if attempt == 0 { - return std::time::Duration::ZERO; - } - let shift = (attempt - 1).min(3) as u32; - let ceiling = (100_u64 << shift).min(800); - std::time::Duration::from_millis(ceiling / 2 + random % (ceiling / 2 + 1)) -} - -/// Rotate so a silent 5222 (TCP up, no MTProto) does not starve 80/443 retries. -pub fn rotated_endpoints(dc_id: i32, skip: usize) -> Vec<&'static str> { - let all = dc_endpoints(dc_id); - let n = all.len(); - if n == 0 { - return Vec::new(); - } - (0..n).map(|i| all[(i + skip) % n]).collect() -} - -pub(crate) fn rotated_same_ip(dc_id: i32, skip: usize) -> Vec<&'static str> { - let all = same_ip_endpoints(dc_id); - let n = all.len(); - if n == 0 { - return Vec::new(); - } - (0..n).map(|i| all[(i + skip) % n]).collect() -} - -pub(crate) fn extra_reconnect_same_host(pinned: &[&str], extra_addr: &str) -> bool { - let home_host = pinned - .first() - .and_then(|addr| addr.rsplit_once(':').map(|(host, _)| host)); - let host = extra_addr.rsplit_once(':').map(|(h, _)| h); - extra_addr.ends_with(":443") && host == home_host && !pinned.iter().any(|p| *p == extra_addr) -} diff --git a/native/mtproto-rs/src/rpc/framing.rs b/native/mtproto-rs/src/rpc/framing.rs deleted file mode 100644 index def65ee7c..000000000 --- a/native/mtproto-rs/src/rpc/framing.rs +++ /dev/null @@ -1,611 +0,0 @@ -use tellers_mtproto::transport::{ - GetFutureSaltsRequest, MsgResendReqConstructor, MsgsAckConstructor, MsgsStateReqConstructor, - PingRequest, Vector, VectorConstructor, -}; -use tellers_mtproto_crypto::fill_random; -use tellers_mtproto_engine::{Engine, OutboundMessage}; -use tellers_mtproto_session::{Clock, OsRandom, Snapshot}; -use tellers_mtproto_transport::{Connection, Framing, PaddedIntermediate}; - -use crate::MtprotoError; -use crate::tcp::{self, ObfuscatedTcp}; - -use super::dc::{rotated_endpoints, rotated_same_ip}; -use super::inbound::{MAX_UNPACKED_BYTES, encode_boxed_bytes}; -use super::live::LiveTransport; -use super::supervisor::is_waitable_io; -use super::timeout::{ - LAST_INBOUND_CTOR, fail_fast_idle, idle_empty_first_byte, idle_needs_liveness_probe, - is_mid_frame, recv_wait_deadline, rpc_timeout_message, -}; - -#[cfg(test)] -#[path = "../../tests/unit/rpc_control_tests.rs"] -mod control_tests; - -pub struct SystemClock; -impl Clock for SystemClock { - fn unix_micros(&self) -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_micros() as i64) - .unwrap_or(0) - } -} - -pub(crate) fn make_padding(body_len: usize) -> Result, MtprotoError> { - // 32 + body + padding must be % 16 == 0, padding in 12..=1024. - let mut pad_len = 12; - while (32 + body_len + pad_len) % 16 != 0 { - pad_len += 1; - } - let mut padding = vec![0_u8; pad_len]; - fill_random(&mut padding).map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(padding) -} - -pub(crate) fn send_framed( - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - payload: &[u8], -) -> Result<(), MtprotoError> { - let packet = framing - .encode(payload) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - conn.send(&packet) - .map_err(|e| MtprotoError::Message(e.to_string())) -} - -fn consume_control(payload: &[u8]) -> Result { - match tellers_mtproto_transport::payload_control(payload) { - Some(tellers_mtproto_transport::Control::QuickAck(_)) => Ok(true), - Some(tellers_mtproto_transport::Control::TransportError(code)) => Err( - MtprotoError::Message(format!("MTProto transport error {code}")), - ), - None => Ok(false), - } -} - -pub(crate) fn try_decode_complete( - framing: &mut PaddedIntermediate, - input: &mut Vec, -) -> Result>, MtprotoError> { - for _ in 0..256 { - match framing.decode(input) { - Ok(packet) => { - let consumed = packet.consumed; - let payload = packet.payload; - input.drain(..consumed); - if !consume_control(&payload)? { - return Ok(Some(payload)); - } - } - Err(tellers_mtproto_transport::Error::Incomplete { .. }) => return Ok(None), - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - } - Err(MtprotoError::Message("MTProto control frame limit".into())) -} - -pub(crate) fn recv_framed( - conn: &mut crate::tcp::ObfuscatedTcp, - framing: &mut PaddedIntermediate, - input: &mut Vec, - received: &mut usize, - attempt_deadline: std::time::Instant, - overall_deadline: std::time::Instant, - reused: bool, - idle_wait_ms: u64, -) -> Result, MtprotoError> { - let mut buf = vec![0_u8; 64 * 1024]; - let mut controls = 0; - loop { - // Decode before the deadline check. A blocking recv can return a complete - // padded-intermediate frame after the attempt clock; timing out first is - // `recv=2880 needed=4 prefix=488 last_ctor=0x0` (never decrypted). - match framing.decode(input) { - Ok(packet) => { - let consumed = packet.consumed; - let payload = packet.payload; - input.drain(..consumed); - if consume_control(&payload)? { - controls += 1; - if controls >= 256 || std::time::Instant::now() >= overall_deadline { - return Err(MtprotoError::Message("MTProto control frame limit".into())); - } - continue; - } - return Ok(payload); - } - Err(tellers_mtproto_transport::Error::Incomplete { - needed: n, - available: a, - }) => { - let needed = n; - let available = a; - if std::time::Instant::now() - > recv_wait_deadline(attempt_deadline, overall_deadline, needed, available) - { - return Err(MtprotoError::Message(rpc_timeout_message( - *received, input, needed, available, - ))); - } - let now = std::time::Instant::now(); - let last_ctor = LAST_INBOUND_CTOR.with(|c| c.get()); - let wait_ms = if fail_fast_idle(reused, *received, needed, available, last_ctor) { - 500 - } else if idle_empty_first_byte(*received, needed, available) - || idle_needs_liveness_probe(*received, needed, available, last_ctor) - { - idle_wait_ms - } else if is_mid_frame(needed, available) { - overall_deadline.saturating_duration_since(now).as_millis() as u64 - } else { - attempt_deadline.saturating_duration_since(now).as_millis() as u64 - }; - conn.set_io_timeout_ms(wait_ms.max(50)); - let n = match conn.receive(&mut buf) { - Ok(n) => n, - Err(e) if is_waitable_io(&e.to_string()) => { - let last_ctor = LAST_INBOUND_CTOR.with(|c| c.get()); - if fail_fast_idle(reused, *received, needed, available, last_ctor) - || idle_empty_first_byte(*received, needed, available) - || idle_needs_liveness_probe(*received, needed, available, last_ctor) - { - return Err(MtprotoError::Message(rpc_timeout_message( - *received, input, needed, available, - ))); - } - continue; - } - Err(e) => return Err(MtprotoError::Message(e.to_string())), - }; - if n == 0 { - return Err(MtprotoError::Message(format!( - "connection closed recv={received}" - ))); - } - *received = received.saturating_add(n); - input.extend_from_slice(&buf[..n]); - } - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - } -} - -pub(crate) fn open_transport_skip( - snapshot: &Snapshot, - skip: usize, - connect_secs: u64, -) -> Result<(ObfuscatedTcp, PaddedIntermediate, &'static str), MtprotoError> { - let addrs = if snapshot.auth_key.as_ref().map(|k| k.len()) == Some(256) { - rotated_same_ip(snapshot.dc_id, skip) - } else { - rotated_endpoints(snapshot.dc_id, skip) - }; - let addr = addrs - .first() - .copied() - .ok_or_else(|| MtprotoError::Message("no DC endpoints".into()))?; - let secret = crate::dns_txt::secret_for(snapshot.dc_id, addr); - let conn = if let Some(secret) = secret { - tcp::connect_obfuscated_timeout_obf( - addr, - connect_secs, - Some(snapshot.dc_id as i16), - Some(&secret), - ) - } else { - 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)) -} - -pub(crate) fn open_live( - dc_id: i32, - snapshot: &Snapshot, - timeout_secs: u64, - skip: usize, -) -> Result { - let (mut conn, framing, endpoint) = open_transport_skip(snapshot, skip, timeout_secs)?; - conn.set_io_timeout(timeout_secs); - Ok(LiveTransport { - ping_sent: None, - updates: Vec::new(), - updates_bytes: 0, - dc_id, - endpoint: endpoint.to_owned(), - conn, - framing, - input: Vec::new(), - last_io: std::time::Instant::now(), - }) -} - -pub(crate) fn queue_update( - transport: &mut LiveTransport, - body: Vec, -) -> Result<(), MtprotoError> { - if transport.updates.len() >= 256 - || transport.updates_bytes.saturating_add(body.len()) > MAX_UNPACKED_BYTES - { - return Err(MtprotoError::Message( - "updates queue overflow; recovery required".into(), - )); - } - transport.updates_bytes += body.len(); - transport.updates.push(body); - Ok(()) -} - -/// Read a subscribed socket without issuing another getDifference. It does not -/// use RPC's fail-fast reused-socket policy: a quiet subscribed socket is -/// expected, so it can wait for a bounded idle window before yielding. The - -pub(crate) fn open_live_addr( - dc_id: i32, - addr: &str, - secret: Option<&[u8]>, - timeout_secs: u64, -) -> Result { - let mut conn = - tcp::connect_obfuscated_timeout_obf(addr, timeout_secs, Some(dc_id as i16), secret) - .map_err(|e| MtprotoError::Message(format!("{e} via {addr}")))?; - conn.set_io_timeout(timeout_secs); - Ok(LiveTransport { - ping_sent: None, - updates: Vec::new(), - updates_bytes: 0, - dc_id, - endpoint: addr.to_owned(), - conn, - framing: PaddedIntermediate::default(), - input: Vec::new(), - last_io: std::time::Instant::now(), - }) -} - -/// Content-unrelated ping. Does not start the server disconnect timer. -/// `ping_delay_disconnect` is omitted: without a background pinger it would -/// close a parked TCP after disconnect_delay (docs: 75s if pinging every 60s). -/// https://core.telegram.org/mtproto/service_messages#ping-messages-ping-pong -/// https://core.telegram.org/mtproto/service_messages_about_messages -/// A status notification does not deliver its answer. Never acknowledge unseen data. -pub(crate) fn detailed_answer_request( - snapshot: &mut Snapshot, - answer_msg_id: i64, -) -> Result>, MtprotoError> { - if snapshot.received_message_ids.contains_key(&answer_msg_id) { - snapshot - .acknowledge(answer_msg_id) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - return Ok(None); - } - let request = MsgResendReqConstructor { - msg_ids: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![answer_msg_id], - })), - }; - encode_boxed_bytes(&request).map(Some) -} - -pub(crate) fn recover_detailed_answer( - engine: &mut Engine

, - transport: &mut LiveTransport, - answer_msg_id: i64, - clock: &SystemClock, -) -> Result<(), MtprotoError> { - let Some(body) = detailed_answer_request(&mut engine.session, answer_msg_id)? else { - return Ok(()); - }; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let sequence = engine - .session - .next_sequence(false) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let outbound = OutboundMessage { - message_id, - sequence, - body, - }; - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(&mut transport.conn, &mut transport.framing, &sealed)?; - transport.last_io = std::time::Instant::now(); - Ok(()) -} - -pub(crate) fn send_ping( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, -) -> Result { - let mut raw = [0_u8; 8]; - fill_random(&mut raw).map_err(|e| MtprotoError::Message(e.to_string()))?; - let ping_id = i64::from_le_bytes(raw); - let ping = PingRequest { ping_id }; - let body = encode_boxed_bytes(&ping)?; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - remember_plaintext(engine.session.session_id, message_id, false, body.clone()); - let sequence = engine - .session - .next_sequence(false) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let outbound = OutboundMessage { - message_id, - sequence, - body, - }; - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(conn, framing, &sealed)?; - Ok(ping_id) -} - -pub(crate) fn send_state_probe( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - ids: &[i64], - clock: &SystemClock, -) -> Result { - let body = encode_boxed_bytes(&MsgsStateReqConstructor { - msg_ids: Box::new(Vector::Vector(VectorConstructor { - field_0: ids.len() as u32, - field_1: ids.to_vec(), - })), - })?; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let sequence = engine - .session - .next_sequence(false) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let padding = make_padding(body.len())?; - let sealed = engine - .seal_outbound( - &OutboundMessage { - message_id, - sequence, - body, - }, - &padding, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(conn, framing, &sealed)?; - Ok(message_id) -} - -struct StoredPlain { - content_related: bool, - body: Vec, -} - -static PLAINTEXT_SENT: std::sync::LazyLock< - std::sync::Mutex< - std::collections::HashMap>, - >, -> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); - -const PLAINTEXT_CAP: usize = 64; - -pub(crate) fn remember_plaintext( - session_id: i64, - message_id: i64, - content_related: bool, - body: Vec, -) { - let mut sent = PLAINTEXT_SENT - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - let queue = sent.entry(session_id).or_default(); - queue.push_back(( - message_id, - StoredPlain { - content_related, - body, - }, - )); - while queue.len() > PLAINTEXT_CAP { - queue.pop_front(); - } -} - -pub(crate) fn replay_plaintext(session_id: i64, message_id: i64) -> Option<(bool, Vec)> { - PLAINTEXT_SENT - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .get(&session_id)? - .iter() - .find(|(id, _)| *id == message_id) - .map(|(_, stored)| (stored.content_related, stored.body.clone())) -} - -pub(crate) fn resend_plaintext( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, - bad_msg_id: i64, -) -> Result { - let session_id = engine.session.session_id; - let Some((content_related, body)) = replay_plaintext(session_id, bad_msg_id) else { - return Ok(false); - }; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let sequence = engine - .session - .next_sequence(content_related) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - remember_plaintext(session_id, message_id, content_related, body.clone()); - let outbound = OutboundMessage { - message_id, - sequence, - body, - }; - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(conn, framing, &sealed)?; - Ok(true) -} - -pub(crate) fn send_future_salts( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, -) -> Result { - let body = encode_boxed_bytes(&GetFutureSaltsRequest { num: 32 })?; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - remember_plaintext(engine.session.session_id, message_id, true, body.clone()); - let sequence = engine - .session - .next_sequence(true) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let outbound = OutboundMessage { - message_id, - sequence, - body, - }; - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(conn, framing, &sealed)?; - Ok(message_id) -} - -pub(crate) fn flush_acks( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, -) -> Result<(), MtprotoError> { - prefetch_future_salts(engine, conn, framing, clock)?; - loop { - let ids = engine.session.take_acknowledgements(64); - if ids.is_empty() { - return Ok(()); - } - let count = ids.len() as u32; - let retry_ids = ids.clone(); - let ack = MsgsAckConstructor { - msg_ids: Box::new(Vector::Vector(VectorConstructor { - field_0: count, - field_1: ids, - })), - }; - let body = encode_boxed_bytes(&ack)?; - let message_id = engine - .session - .next_message_id(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - remember_plaintext(engine.session.session_id, message_id, false, body.clone()); - let sequence = engine - .session - .next_sequence(false) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let outbound = OutboundMessage { - message_id, - sequence, - body, - }; - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if let Err(error) = send_framed(conn, framing, &sealed) { - for id in retry_ids { - let _ = engine.session.acknowledge(id); - } - return Err(error); - } - } -} - -fn prefetch_future_salts( - engine: &mut Engine

, - conn: &mut dyn Connection, - framing: &mut PaddedIntermediate, - clock: &SystemClock, -) -> Result<(), MtprotoError> { - let now = (clock.unix_micros() / 1_000_000) as i32; - super::salts::apply_preferred_salt(&mut engine.session, now); - if !super::salts::should_prefetch(engine.session.session_id, now) { - return Ok(()); - } - send_future_salts(engine, conn, framing, clock)?; - super::salts::mark_requested(engine.session.session_id, now); - Ok(()) -} - -pub(crate) fn bad_msg_should_reconnect(error_code: i32) -> bool { - matches!(error_code, 17 | 18 | 19 | 32 | 33 | 64) -} - -pub(crate) fn recreate_session_after_bad_message( - snapshot: &mut Snapshot, -) -> Result<(), MtprotoError> { - let fresh = Snapshot::new(snapshot.dc_id, &mut OsRandom) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - snapshot.session_id = fresh.session_id; - snapshot.last_message_id = 0; - snapshot.content_sequence = 0; - snapshot.pending_acknowledgements.clear(); - snapshot.received_message_ids.clear(); - snapshot.reconnect = Default::default(); - Ok(()) -} - -pub(crate) fn repair_clock_from_server_msg_id( - snapshot: &mut Snapshot, - server_msg_id: i64, - clock: &SystemClock, -) { - snapshot.time_offset_micros = snapshot - .time_offset_micros - .saturating_add(clock_delta_micros( - server_msg_id, - clock.unix_micros(), - snapshot.time_offset_micros, - )); -} - -pub(crate) fn clock_delta_micros( - server_msg_id: i64, - local_micros: i64, - current_offset: i64, -) -> i64 { - let server_seconds = server_msg_id >> 32; - let local_seconds = local_micros - .saturating_add(current_offset) - .div_euclid(1_000_000); - server_seconds.saturating_sub(local_seconds) * 1_000_000 -} - -#[cfg(test)] -#[path = "../../tests/unit/rpc_framing_plaintext_tests.rs"] -mod plaintext_tests; diff --git a/native/mtproto-rs/src/rpc/inbound.rs b/native/mtproto-rs/src/rpc/inbound.rs deleted file mode 100644 index 38994fbae..000000000 --- a/native/mtproto-rs/src/rpc/inbound.rs +++ /dev/null @@ -1,554 +0,0 @@ -//! https://core.telegram.org/mtproto/service_messages -//! https://core.telegram.org/mtproto/service_messages_about_messages - -use flate2::Compression; -use flate2::read::GzDecoder; -use flate2::write::GzEncoder; -use std::cell::RefCell; -use std::io::{Read, Write}; -use tellers_mtproto::codec::{Boxed, Decoder, Encoder, Limits, TlDecode}; -use tellers_mtproto::transport::{ - BadMsgNotificationConstructor, BadServerSaltConstructor, FutureSaltsConstructor, - GzipPackedConstructor, MsgContainerConstructor, MsgCopyConstructor, MsgDetailedInfoConstructor, - MsgNewDetailedInfoConstructor, MsgsAckConstructor, MsgsStateInfoConstructor, - NewSessionCreatedConstructor, PongConstructor, RpcErrorConstructor, RpcResultConstructor, -}; -use tellers_mtproto_session::{Clock, ReceivedMessageResult, Snapshot}; - -use crate::MtprotoError; - -pub(crate) const RPC_RESULT: u32 = RpcResultConstructor::ID; -pub(crate) const GZIP_PACKED: u32 = GzipPackedConstructor::ID; -pub(crate) const NEW_SESSION_CREATED: u32 = NewSessionCreatedConstructor::ID; -pub(crate) const BAD_SERVER_SALT: u32 = BadServerSaltConstructor::ID; -pub(crate) const BAD_MSG_NOTIFICATION: u32 = BadMsgNotificationConstructor::ID; -pub(crate) const MSGS_ACK: u32 = MsgsAckConstructor::ID; -pub(crate) const MSG_CONTAINER: u32 = MsgContainerConstructor::ID; -pub(crate) const MSG_COPY: u32 = MsgCopyConstructor::ID; -pub(crate) const PONG: u32 = PongConstructor::ID; - -pub fn encode_boxed_bytes(value: &T) -> Result, MtprotoError> { - let mut encoder = Encoder::new(); - value - .encode_boxed(&mut encoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(encoder.into_bytes()) -} - -/// Wrapper so nested `!X` query fields write a constructor id. -pub struct BoxedQuery(pub T); -impl tellers_mtproto::codec::TlEncode for BoxedQuery { - fn encode(&self, encoder: &mut Encoder) -> Result<(), tellers_mtproto::codec::Error> { - self.0.encode_boxed(encoder) - } -} - -pub(crate) const MAX_UNPACKED_BYTES: usize = 8 * 1024 * 1024; -pub(crate) const MAX_WRAPPER_DEPTH: usize = 8; - -pub(crate) fn ungzip_if_needed(bytes: &[u8]) -> Result, MtprotoError> { - if bytes.len() < 4 { - return Ok(bytes.to_vec()); - } - let ctor = u32::from_le_bytes(bytes[0..4].try_into().unwrap()); - if ctor != GZIP_PACKED { - return Ok(bytes.to_vec()); - } - let mut decoder = - Decoder::new(bytes, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let packed: Vec = - TlDecode::decode(&mut decoder).map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut gz = GzDecoder::new(packed.as_slice()).take(MAX_UNPACKED_BYTES as u64 + 1); - let mut out = Vec::new(); - gz.read_to_end(&mut out) - .map_err(|e| MtprotoError::Message(format!("gzip: {e}")))?; - if out.len() > MAX_UNPACKED_BYTES { - return Err(MtprotoError::Message("gzip output limit exceeded".into())); - } - Ok(out) -} - -/// Gzip an API body when the wrapper is smaller. Bodies under 256 bytes stay plain. -pub(crate) fn gzip_if_smaller(body: &[u8]) -> Vec { - const MIN_BYTES: usize = 256; - if body.len() < MIN_BYTES { - return body.to_vec(); - } - if body.len() >= 4 { - let ctor = u32::from_le_bytes(body[0..4].try_into().unwrap()); - if ctor == GZIP_PACKED { - return body.to_vec(); - } - } - let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); - if encoder.write_all(body).is_err() { - return body.to_vec(); - } - let Ok(packed) = encoder.finish() else { - return body.to_vec(); - }; - if packed.len() >= body.len() { - return body.to_vec(); - } - encode_boxed_bytes(&GzipPackedConstructor { - packed_data: packed, - }) - .unwrap_or_else(|_| body.to_vec()) -} - -/// `future_salts` uses a bare vector of bare `future_salt` values: a count, then -/// `valid_since`, `valid_until`, and `salt` with no constructor ids. A malformed -/// answer is ignored so it cannot fail the RPC that shared the socket. -fn parse_future_salts(body: &[u8]) -> Result, MtprotoError> { - let ignore = || Ok(vec![InboundEvent::Ignored]); - if body.len() < 20 { - return ignore(); - } - let count = u32::from_le_bytes(body[16..20].try_into().unwrap()); - if count > 64 { - return ignore(); - } - let count = count as usize; - if body.len() < 20 + count * 16 { - return ignore(); - } - let mut windows = Vec::with_capacity(count); - for index in 0..count { - let at = 20 + index * 16; - windows.push(super::salts::SaltWindow { - valid_since: i32::from_le_bytes(body[at..at + 4].try_into().unwrap()), - valid_until: i32::from_le_bytes(body[at + 4..at + 8].try_into().unwrap()), - salt: i64::from_le_bytes(body[at + 8..at + 16].try_into().unwrap()), - }); - } - Ok(vec![InboundEvent::FutureSalts(windows)]) -} - -#[cfg(test)] -#[path = "../../tests/unit/rpc_inbound_gzip_tests.rs"] -mod gzip_tests; - -#[cfg(test)] -#[path = "../../tests/unit/rpc_inbound_future_salt_tests.rs"] -mod future_salt_tests; - -pub(crate) enum InboundEvent { - Updates(Vec), - RpcResult { req_msg_id: i64, body: Vec }, - SaltUpdated { body: Vec }, - RetryableFailure { message_id: i64 }, - BadMessage { bad_msg_id: i64, error_code: i32 }, - Pong { ping_id: i64 }, - AnswerAvailable { answer_msg_id: i64 }, - MessageStatus { req_msg_id: i64, info: Vec }, - FutureSalts(Vec), - Ignored, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct NewSessionMetadata { - pub first_msg_id: i64, - pub unique_id: i64, - pub server_salt: i64, -} - -thread_local! { - static LAST_NEW_SESSION: RefCell> = const { RefCell::new(None) }; -} - -pub fn clear_new_session_metadata() { - LAST_NEW_SESSION.with(|value| *value.borrow_mut() = None); -} - -pub fn take_new_session_metadata() -> Option { - LAST_NEW_SESSION.with(|value| value.borrow_mut().take()) -} - -pub(crate) fn parse_service_or_result(body: &[u8]) -> Result, MtprotoError> { - parse_service_at_depth(body, 0) -} - -/// Validate child messages before committing any replay/ack state. A malformed -/// child invalidates the container, including its otherwise valid siblings. -pub(crate) fn parse_authenticated( - body: &[u8], - outer_id: i64, - snapshot: &mut Snapshot, - clock: &impl Clock, -) -> Result, MtprotoError> { - let mut validated = snapshot.clone(); - let events = parse_authenticated_inner(body, outer_id, &mut validated, clock, 0, false)?; - *snapshot = validated; - Ok(events) -} - -pub(crate) fn parse_authenticated_inner( - body: &[u8], - outer_id: i64, - snapshot: &mut Snapshot, - clock: &impl Clock, - depth: usize, - in_container: bool, -) -> Result, MtprotoError> { - let invalid = || MtprotoError::Message("invalid MTProto container framing".into()); - if depth > MAX_WRAPPER_DEPTH || body.len() < 4 { - return Err(invalid()); - } - let constructor = peek_u32(body, 0).ok_or_else(invalid)?; - if constructor == GZIP_PACKED { - return parse_authenticated_inner( - &ungzip_if_needed(body)?, - outer_id, - snapshot, - clock, - depth + 1, - in_container, - ); - } - if constructor != MSG_CONTAINER && constructor != MSG_COPY { - return parse_service_at_depth(body, depth); - } - if constructor == MSG_CONTAINER && in_container { - return Err(invalid()); - } - let mut decoder = Decoder::new(body, Limits::default()).map_err(|_| invalid())?; - decoder.read_u32().map_err(|_| invalid())?; - let count = if constructor == MSG_CONTAINER { - if peek_u32(body, decoder.offset()) == Some(0x1cb5_c415) { - decoder.read_u32().map_err(|_| invalid())?; - } - let count = i32::decode(&mut decoder).map_err(|_| invalid())?; - if !(0..=1024).contains(&count) { - return Err(invalid()); - } - count - } else { - 1 - }; - let mut events = Vec::new(); - for _ in 0..count { - if peek_u32(body, decoder.offset()) == Some(0x5bb8_e511) { - decoder.read_u32().map_err(|_| invalid())?; - } - let id = i64::decode(&mut decoder).map_err(|_| invalid())?; - let seqno = i32::decode(&mut decoder).map_err(|_| invalid())?; - let length = i32::decode(&mut decoder).map_err(|_| invalid())?; - if id >= outer_id || id & 1 == 0 || seqno < 0 || length < 4 || length % 4 != 0 { - return Err(MtprotoError::Message( - "invalid MTProto container child metadata".into(), - )); - } - let payload = decoder.read_raw(length as usize).map_err(|_| invalid())?; - // A freshly authenticated container can carry an old retransmitted - // message. Its outer time was checked by Engine; children use replay - // protection rather than rejecting legitimate delayed answers. - let disposition = snapshot - .register_received_message(id, seqno & 1 != 0, clock, false) - .map_err(|_| invalid())?; - if disposition == ReceivedMessageResult::InvalidTime { - return Err(invalid()); - } - if should_process_inbound(disposition) { - events.extend(parse_authenticated_inner( - payload, - id, - snapshot, - clock, - depth + 1, - in_container || constructor == MSG_CONTAINER, - )?); - } - } - decoder.finish().map_err(|_| invalid())?; - Ok(events) -} - -pub(crate) fn parse_service_at_depth( - body: &[u8], - depth: usize, -) -> Result, MtprotoError> { - if depth > MAX_WRAPPER_DEPTH { - return Err(MtprotoError::Message( - "MTProto wrapper depth exceeded".into(), - )); - } - if body.len() < 4 { - return Ok(vec![InboundEvent::Ignored]); - } - let ctor = u32::from_le_bytes(body[0..4].try_into().unwrap()); - match ctor { - MSG_CONTAINER => parse_container(body, depth), - GZIP_PACKED => { - let inner = ungzip_if_needed(body)?; - parse_service_at_depth(&inner, depth + 1) - } - RPC_RESULT => { - let mut decoder = Decoder::new(body, Limits::default()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let req_msg_id = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let rest = ungzip_if_needed(&body[decoder.offset()..])?; - Ok(vec![InboundEvent::RpcResult { - req_msg_id, - body: rest, - }]) - } - NEW_SESSION_CREATED => Ok(vec![InboundEvent::SaltUpdated { - body: body.to_vec(), - }]), - ctor if ctor == FutureSaltsConstructor::ID => parse_future_salts(body), - BAD_SERVER_SALT => { - let mut decoder = Decoder::new(body, Limits::default()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let bad = BadServerSaltConstructor::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(vec![ - InboundEvent::SaltUpdated { - body: body.to_vec(), - }, - InboundEvent::RetryableFailure { - message_id: bad.bad_msg_id, - }, - ]) - } - ctor if is_updates_type(ctor) => Ok(vec![InboundEvent::Updates(body.to_vec())]), - MSG_COPY => parse_msg_copy(body, depth), - BAD_MSG_NOTIFICATION => { - let mut decoder = Decoder::new(body, Limits::default()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let bad = BadMsgNotificationConstructor::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(vec![InboundEvent::BadMessage { - bad_msg_id: bad.bad_msg_id, - error_code: bad.error_code, - }]) - } - PONG => { - let mut decoder = Decoder::new(body, Limits::default()) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let pong = PongConstructor::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(vec![InboundEvent::Pong { - ping_id: pong.ping_id, - }]) - } - MsgDetailedInfoConstructor::ID | MsgNewDetailedInfoConstructor::ID => { - let invalid = || MtprotoError::Message("invalid MTProto detailed info".into()); - let mut decoder = Decoder::new(body, Limits::default()).map_err(|_| invalid())?; - decoder.read_u32().map_err(|_| invalid())?; - let (answer_msg_id, bytes) = if ctor == MsgDetailedInfoConstructor::ID { - let info = - MsgDetailedInfoConstructor::decode(&mut decoder).map_err(|_| invalid())?; - (info.answer_msg_id, info.bytes) - } else { - let info = - MsgNewDetailedInfoConstructor::decode(&mut decoder).map_err(|_| invalid())?; - (info.answer_msg_id, info.bytes) - }; - decoder.finish().map_err(|_| invalid())?; - if answer_msg_id & 1 == 0 || bytes < 0 { - return Err(invalid()); - } - Ok(vec![InboundEvent::AnswerAvailable { answer_msg_id }]) - } - MsgsStateInfoConstructor::ID => { - // A resend request can receive status instead of a forgotten answer. - // Keep waiting for the RPC under its existing bounded deadline. - let invalid = || MtprotoError::Message("invalid MTProto message status".into()); - let mut decoder = Decoder::new(body, Limits::default()).map_err(|_| invalid())?; - decoder.read_u32().map_err(|_| invalid())?; - let status = MsgsStateInfoConstructor::decode(&mut decoder).map_err(|_| invalid())?; - decoder.finish().map_err(|_| invalid())?; - Ok(vec![InboundEvent::MessageStatus { - req_msg_id: status.req_msg_id, - info: status.info, - }]) - } - MSGS_ACK => Ok(vec![InboundEvent::Ignored]), - // The constructor ID is protocol metadata, not message content. Keep it - // in the error so a new, legitimate service constructor can be added - // deliberately instead of weakening the unknown-message fail-closed - // path. - _ => Err(MtprotoError::Message(format!( - "unknown MTProto constructor 0x{ctor:08x}; updates recovery required" - ))), - } -} - -/// Unsolicited `Updates` on the RPC socket. Query lanes use invokeWithoutUpdates; -/// if one still arrives, keep waiting for `rpc_result` instead of treating it as unknown. -pub(crate) fn is_updates_type(ctor: u32) -> bool { - use tellers_mtproto::latest::api::{ - UpdateShortChatMessageConstructor, UpdateShortConstructor, UpdateShortMessageConstructor, - UpdateShortSentMessageConstructor, UpdatesCombinedConstructor, UpdatesConstructor, - UpdatesTooLongConstructor, - }; - matches!( - ctor, - UpdatesConstructor::ID - | UpdatesCombinedConstructor::ID - | UpdatesTooLongConstructor::ID - | UpdateShortConstructor::ID - | UpdateShortMessageConstructor::ID - | UpdateShortChatMessageConstructor::ID - | UpdateShortSentMessageConstructor::ID - ) -} - -pub(crate) fn parse_msg_copy(body: &[u8], depth: usize) -> Result, MtprotoError> { - const MT_MESSAGE: u32 = 0x5bb8_e511; - let mut decoder = - Decoder::new(body, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if peek_u32(body, decoder.offset()) == Some(MT_MESSAGE) { - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - let _msg_id = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _seqno = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let bytes = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))? as usize; - let payload = decoder - .read_raw(bytes) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - parse_service_at_depth(payload, depth + 1) -} - -pub(crate) fn parse_container( - body: &[u8], - depth: usize, -) -> Result, MtprotoError> { - const VECTOR: u32 = 0x1cb5_c415; - const MT_MESSAGE: u32 = 0x5bb8_e511; - let mut out = Vec::new(); - let mut decoder = - Decoder::new(body, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - // Bare `vector<%Message>` is count+messages. Tolerate a boxed vector ctor. - if let Some(peek) = peek_u32(body, decoder.offset()) { - if peek == VECTOR { - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - } - let count = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))? as usize; - if count > 1024 { - return Err(MtprotoError::Message(format!( - "msg_container count too large: {count}" - ))); - } - for _ in 0..count { - if peek_u32(body, decoder.offset()) == Some(MT_MESSAGE) { - let _ = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - } - let _msg_id = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let _seqno = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let bytes = ::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))? as usize; - let payload = decoder - .read_raw(bytes) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let pad = (4 - (bytes % 4)) % 4; - if pad > 0 { - let _ = decoder.read_raw(pad); - } - let events = parse_service_at_depth(payload, depth + 1)?; - out.extend(events); - } - Ok(out) -} - -pub(crate) fn peek_u32(body: &[u8], offset: usize) -> Option { - body.get(offset..offset + 4)? - .try_into() - .ok() - .map(u32::from_le_bytes) -} - -pub(crate) fn apply_new_session_salt( - snapshot: &mut Snapshot, - body: &[u8], -) -> Result<(), MtprotoError> { - let mut decoder = - Decoder::new(body, Limits::default()).map_err(|e| MtprotoError::Message(e.to_string()))?; - let ctor = decoder - .read_u32() - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if ctor == NEW_SESSION_CREATED { - let ns = NewSessionCreatedConstructor::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - snapshot.server_salt = ns.server_salt; - LAST_NEW_SESSION.with(|value| { - *value.borrow_mut() = Some(NewSessionMetadata { - first_msg_id: ns.first_msg_id, - unique_id: ns.unique_id, - server_salt: ns.server_salt, - }); - }); - } else if ctor == BAD_SERVER_SALT { - let bad = BadServerSaltConstructor::decode(&mut decoder) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - snapshot.server_salt = bad.new_server_salt; - } - Ok(()) -} - -pub(crate) fn should_process_inbound(disposition: ReceivedMessageResult) -> bool { - disposition == ReceivedMessageResult::Accepted -} - -pub(crate) fn map_rpc_error(bytes: &[u8]) -> Option { - if bytes.len() < 4 { - return None; - } - let ctor = u32::from_le_bytes(bytes[0..4].try_into().unwrap()); - if ctor != RpcErrorConstructor::ID { - return None; - } - let mut decoder = Decoder::new(bytes, Limits::default()).ok()?; - let _ = decoder.read_u32().ok()?; - let err = RpcErrorConstructor::decode(&mut decoder).ok()?; - let rpc = tellers_mtproto_engine::RpcError::new(err.error_code, err.error_message); - if rpc.message.contains("SESSION_PASSWORD_NEEDED") { - return Some(MtprotoError::PasswordRequired); - } - if rpc.message.contains("PHONE_NUMBER_UNOCCUPIED") || rpc.message.contains("SIGN_UP_REQUIRED") { - return Some(MtprotoError::RegistrationRequired); - } - // Telegram asks the client to restart the user-auth flow (sendCode again). - if rpc.message.contains("AUTH_RESTART") { - return Some(MtprotoError::Message("AUTH_RESTART".into())); - } - Some(MtprotoError::Message(format!( - "RPC {}: {}", - rpc.code, rpc.message - ))) -} diff --git a/native/mtproto-rs/src/rpc/invoke.rs b/native/mtproto-rs/src/rpc/invoke.rs deleted file mode 100644 index 4a35e0a1e..000000000 --- a/native/mtproto-rs/src/rpc/invoke.rs +++ /dev/null @@ -1,1292 +0,0 @@ -//! https://core.telegram.org/mtproto/description -//! https://core.telegram.org/api/invoking - -use tellers_mtproto_engine::{Engine, ExponentialBackoff, Method, RequestHandle}; -use tellers_mtproto_session::{Clock, ReceivedMessageResult, Snapshot}; -use tellers_mtproto_transport::{Error as TransportError, Framing}; - -use super::salts::SaltWindow; -use crate::MtprotoError; - -/// A bad server salt is retryable. Only a bad message aborts the updates session. -pub(crate) fn updates_session_should_abort(retryable_salt: bool) -> bool { - !retryable_salt -} - -#[cfg(test)] -#[path = "../../tests/unit/rpc_invoke_updates_salt_tests.rs"] -mod updates_salt_tests; - -fn take_salt_resend( - engine: &mut Engine

, - message_id: i64, - clock: &SystemClock, -) -> Result, MtprotoError> { - let err = tellers_mtproto_engine::Error::Authorization("bad_server_salt".into()); - match engine.fail_request(message_id, clock.unix_micros(), &err) { - Ok(()) => {} - Err(tellers_mtproto_engine::Error::UnknownRequest(_)) => return Ok(Vec::new()), - Err(e) => return Err(MtprotoError::Message(e.to_string())), - } - engine - .poll(clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut resent = Vec::new(); - while let Some(outbound) = engine.next_outbound() { - resent.push(outbound); - } - Ok(resent) -} - -fn remember_future_salts(snapshot: &mut Snapshot, windows: &[SaltWindow], clock: &SystemClock) { - super::salts::store_windows(snapshot.session_id, windows); - let now = (clock.unix_micros() / 1_000_000) as i32; - if let Some(salt) = super::salts::choose_salt(windows, now) { - snapshot.server_salt = salt; - } -} - -use super::dc::{extra_reconnect_same_host, reconnect_backoff, same_ip_endpoints}; -use super::framing::{ - SystemClock, bad_msg_should_reconnect, flush_acks, make_padding, open_live, open_live_addr, - queue_update, recover_detailed_answer, recreate_session_after_bad_message, recv_framed, - repair_clock_from_server_msg_id, send_framed, send_ping, send_state_probe, try_decode_complete, -}; -use super::inbound::{ - BAD_MSG_NOTIFICATION, InboundEvent, MAX_UNPACKED_BYTES, apply_new_session_salt, map_rpc_error, - parse_authenticated, should_process_inbound, -}; -use super::live::LiveTransport; -use super::supervisor::{ConnectionSupervisor, FailureClass, failure_class, is_transport_error}; -use super::timeout::{ - LAST_INBOUND_CTOR, extend_streaming_deadlines, leftover_frame_grace, live_transport_stale, - note_inbound_liveness, rpc_attempt_budget, rpc_timeout_message, rpc_timeout_secs, - subscribed_read_deadline, trim_padded_mtproto_packet, -}; - -pub(crate) struct RawMethod { - pub(crate) body: Vec, -} -impl Method for RawMethod { - type Response = Vec; - fn encode_request(&self) -> Result, tellers_mtproto_codec::Error> { - Ok(self.body.clone()) - } - fn decode_response(bytes: &[u8]) -> Result { - Ok(bytes.to_vec()) - } -} - -/// Invoke a pre-encoded boxed TL request and return the raw `rpc_result` payload. -pub fn invoke_raw(snapshot: &mut Snapshot, request_body: Vec) -> Result, MtprotoError> { - invoke_raw_with_retry(snapshot, request_body, false) -} - -#[derive(Clone, Copy)] -pub(crate) struct RpcRetryPolicy { - pub(crate) backoff: ExponentialBackoff, - pub(crate) replay_safe: bool, -} - -impl tellers_mtproto_engine::RetryPolicy for RpcRetryPolicy { - fn deadline(&self, attempt: u32, now: i64) -> i64 { - self.backoff.deadline(attempt, now) - } - - fn after_failure( - &self, - attempt: u32, - now: i64, - error: &tellers_mtproto_engine::Error, - ) -> tellers_mtproto_engine::RetryDecision { - if matches!(error, tellers_mtproto_engine::Error::Timeout { .. }) { - return tellers_mtproto_engine::RetryDecision::Fail; - } - self.backoff.after_failure(attempt, now, error) - } -} - -pub(crate) fn may_reconnect_request(replay_safe: bool, send_started: bool) -> bool { - replay_safe || !send_started -} - -fn check_silent_requests( - engine: &mut Engine

, - transport: &mut LiveTransport, - clock: &SystemClock, - silent: &mut super::main_policy::SilentRequests, - ids: &[i64], - sent: bool, - attempt: &mut std::time::Instant, - overall: &mut std::time::Instant, - hard_cap: std::time::Instant, -) -> Result<(), MtprotoError> { - let now = std::time::Instant::now(); - silent.observe(ids, now); - if !sent { - return Ok(()); - } - let due = silent.due(now, now >= *overall); - if due.is_empty() { - return Ok(()); - } - let probe = send_state_probe( - engine, - &mut transport.conn, - &mut transport.framing, - &due, - clock, - )?; - transport.last_io = now; - silent.probed(probe, due); - // Give a status response a bounded grace period before any reconnect retry. - *overall = (*overall) - .max(now + std::time::Duration::from_secs(5)) - .min(hard_cap); - *attempt = (*attempt).max(*overall); - Ok(()) -} - -pub(crate) fn invoke_raw_with_retry( - snapshot: &mut Snapshot, - request_body: Vec, - replay_safe: bool, -) -> Result, MtprotoError> { - invoke_raw_with_retry_factory(snapshot, replay_safe, |_| Ok(request_body.clone())) -} - -/// Invoke an API body which may need to be rebuilt for each TCP connection. -/// -/// `new_connection` is true for the first request on a newly-opened socket, -/// including replay-safe retries after a transport failure. MTProto API callers -/// use it to attach `invokeWithLayer(initConnection(...))` exactly where the -/// protocol requires it, without keeping that wrapper on every request. -pub(crate) fn invoke_raw_with_retry_factory( - snapshot: &mut Snapshot, - replay_safe: bool, - mut request_body: F, -) -> Result, MtprotoError> -where - F: FnMut(bool) -> Result, MtprotoError>, -{ - if snapshot.auth_key.as_ref().map(|k| k.len()) != Some(256) { - return Err(MtprotoError::Message( - "auth key required before API RPC".into(), - )); - } - - let clock = SystemClock; - let timeout_secs = rpc_timeout_secs(); - let policy = RpcRetryPolicy { - replay_safe, - backoff: ExponentialBackoff { - timeout_micros: (timeout_secs as i64) * 1_000_000, - initial_delay_micros: 0, - max_attempts: 3, - }, - }; - let mut last_error: Option = None; - let started = std::time::Instant::now(); - let overall_deadline = started + std::time::Duration::from_secs(timeout_secs); - let hard_cap = started - + std::time::Duration::from_secs(timeout_secs.saturating_mul(2).max(timeout_secs + 8)); - let dc_for_eps = snapshot.dc_id; - let pinned = same_ip_endpoints(dc_for_eps); - let extras: Vec = crate::dns_txt::endpoints_for(dc_for_eps) - .into_iter() - .filter(|e| extra_reconnect_same_host(&pinned, &e.addr)) - .take(3) - .collect(); - let same_ip_tries = pinned.len().min(3).max(1); - let max_attempts = same_ip_tries + extras.len(); - let mut supervisor = ConnectionSupervisor::acquire(snapshot.dc_id)?; - for attempt in 0..max_attempts { - let mut remaining = overall_deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - break; - } - if attempt > 0 { - let delay = reconnect_backoff(attempt); - if remaining <= delay { - break; - } - supervisor.backoff(delay)?; - remaining = overall_deadline.saturating_duration_since(std::time::Instant::now()); - } - let last = attempt + 1 == max_attempts; - let budget = rpc_attempt_budget( - attempt, - last, - remaining.max(std::time::Duration::from_secs(1)), - ); - let attempt_deadline = std::time::Instant::now() + budget; - let attempt_deadline = if attempt_deadline > overall_deadline { - overall_deadline - } else { - attempt_deadline - }; - let io_secs = budget.as_secs().max(1); - let mut engine = Engine::new(snapshot.clone(), policy) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let dc_id = engine.session.dc_id; - if attempt > 0 { - eprintln!("monogram.api reconnect attempt={attempt} after transport error"); - } - let opened = supervisor.connect(|| { - if attempt < same_ip_tries { - // Same host:port as the live session. Rotating 443→5222→80 after a - // stall is what produced recv=0 on :80 / :5222 through the emulator path. - open_live(dc_id, &engine.session, io_secs, 0) - } else { - let extra = &extras[attempt - same_ip_tries]; - eprintln!( - "monogram.api reconnect dns-txt dc={} addr={}", - extra.dc_id, extra.addr - ); - open_live_addr( - extra.dc_id, - extra.addr.as_str(), - extra.secret.as_ref().map(|s| s.as_slice()), - io_secs, - ) - } - }); - let reused = match opened { - Ok(reused) => reused, - Err(err) if is_transport_error(&err) => { - last_error = Some(err); - continue; - } - Err(err) => return Err(err), - }; - let transport = supervisor - .transport - .as_mut() - .expect("connected supervisor transport"); - let endpoint = transport.endpoint.clone(); - transport.conn.set_io_timeout(io_secs); - let mut send_started = false; - let body = request_body(!reused)?; - let result = invoke_until_result( - &mut engine, - transport, - body, - &clock, - attempt_deadline, - overall_deadline, - hard_cap, - reused, - &mut send_started, - ); - match result { - Ok(value) => { - *snapshot = engine.session; - supervisor.park_ready(); - return Ok(value); - } - Err(err) if is_transport_error(&err) => { - // bad_msg 32/33 and clock fixes mutate engine.session; keep them - // so the retry uses a fresh seqno instead of replaying the same one. - *snapshot = engine.session; - if !may_reconnect_request(replay_safe, send_started) { - return Err(err); - } - last_error = Some(MtprotoError::Message(format!( - "{err} via {endpoint} reconnect" - ))); - // Never put a timed-out or partially consumed stream back into - // the lane. Close it before retry backoff starts. - supervisor.disconnect(); - } - Err(err) => { - *snapshot = engine.session; - // A completed validation/flood RPC does not invalidate the - // authenticated connection. Keep it only at a frame boundary. - if failure_class(&err) == FailureClass::RpcRejected && transport.input.is_empty() { - supervisor.park_ready(); - } - return Err(err); - } - } - } - Err(last_error.unwrap_or_else(|| MtprotoError::Message("RPC timeout".into()))) -} - -/// Sends the whole batch on one session before awaiting, then pumps until every request -/// is matched by `rpc_result`. Per-request RPC errors come back as `Err` entries; -/// transport or timeout failures abort the batch. -pub(crate) fn invoke_batch_raw_with_retry( - snapshot: &mut Snapshot, - replay_safe: bool, - make_bodies: F, -) -> Result, MtprotoError>>, MtprotoError> -where - F: FnMut(bool) -> Result>, MtprotoError>, -{ - invoke_batch_raw_with_retry_streaming(snapshot, replay_safe, make_bodies, &mut |_, _| None) -} - -pub(crate) fn invoke_batch_raw_with_retry_streaming( - snapshot: &mut Snapshot, - replay_safe: bool, - mut make_bodies: F, - on_chunk: &mut dyn FnMut(usize, Result<&[u8], &MtprotoError>) -> Option>, -) -> Result, MtprotoError>>, MtprotoError> -where - F: FnMut(bool) -> Result>, MtprotoError>, -{ - if snapshot.auth_key.as_ref().map(|k| k.len()) != Some(256) { - return Err(MtprotoError::Message( - "auth key required before API RPC".into(), - )); - } - let clock = SystemClock; - let timeout_secs = rpc_timeout_secs(); - let policy = RpcRetryPolicy { - replay_safe, - backoff: ExponentialBackoff { - timeout_micros: (timeout_secs as i64) * 1_000_000, - initial_delay_micros: 0, - max_attempts: 3, - }, - }; - let mut last_error: Option = None; - let started = std::time::Instant::now(); - let overall_deadline = started + std::time::Duration::from_secs(timeout_secs); - let hard_cap = started - + std::time::Duration::from_secs(timeout_secs.saturating_mul(2).max(timeout_secs + 8)); - let dc_for_eps = snapshot.dc_id; - let pinned = same_ip_endpoints(dc_for_eps); - let extras: Vec = crate::dns_txt::endpoints_for(dc_for_eps) - .into_iter() - .filter(|e| extra_reconnect_same_host(&pinned, &e.addr)) - .take(3) - .collect(); - let same_ip_tries = pinned.len().min(3).max(1); - let max_attempts = same_ip_tries + extras.len(); - let mut supervisor = ConnectionSupervisor::acquire(snapshot.dc_id)?; - for attempt in 0..max_attempts { - let mut remaining = overall_deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - break; - } - if attempt > 0 { - let delay = reconnect_backoff(attempt); - if remaining <= delay { - break; - } - supervisor.backoff(delay)?; - remaining = overall_deadline.saturating_duration_since(std::time::Instant::now()); - } - let last = attempt + 1 == max_attempts; - let budget = rpc_attempt_budget( - attempt, - last, - remaining.max(std::time::Duration::from_secs(1)), - ); - let attempt_deadline = std::time::Instant::now() + budget; - let attempt_deadline = if attempt_deadline > overall_deadline { - overall_deadline - } else { - attempt_deadline - }; - let io_secs = budget.as_secs().max(1); - let mut engine = Engine::new(snapshot.clone(), policy) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let dc_id = engine.session.dc_id; - let opened = supervisor.connect(|| { - if attempt < same_ip_tries { - open_live(dc_id, &engine.session, io_secs, 0) - } else { - let extra = &extras[attempt - same_ip_tries]; - open_live_addr( - extra.dc_id, - extra.addr.as_str(), - extra.secret.as_ref().map(|s| s.as_slice()), - io_secs, - ) - } - }); - let reused = match opened { - Ok(reused) => reused, - Err(err) if is_transport_error(&err) => { - last_error = Some(err); - continue; - } - Err(err) => return Err(err), - }; - let transport = supervisor - .transport - .as_mut() - .expect("connected supervisor transport"); - let endpoint = transport.endpoint.clone(); - transport.conn.set_io_timeout(io_secs); - let mut send_started = false; - // A fresh connection needs InitConnection again. - let bodies = make_bodies(!reused)?; - if bodies.is_empty() { - return Ok(Vec::new()); - } - let result = invoke_batch_until_results_streaming( - &mut engine, - transport, - &bodies, - &clock, - attempt_deadline, - overall_deadline, - hard_cap, - reused, - &mut send_started, - &mut *on_chunk, - ); - match result { - Ok(values) => { - *snapshot = engine.session; - supervisor.park_ready(); - return Ok(values); - } - Err(err) if is_transport_error(&err) => { - *snapshot = engine.session; - if !may_reconnect_request(replay_safe, send_started) { - return Err(err); - } - last_error = Some(MtprotoError::Message(format!( - "{err} via {endpoint} reconnect" - ))); - supervisor.disconnect(); - } - Err(err) => { - *snapshot = engine.session; - return Err(err); - } - } - } - Err(last_error.unwrap_or_else(|| MtprotoError::Message("RPC timeout".into()))) -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn invoke_batch_until_results( - engine: &mut Engine

, - transport: &mut LiveTransport, - bodies: &[Vec], - clock: &SystemClock, - attempt_deadline: std::time::Instant, - overall_deadline: std::time::Instant, - hard_cap: std::time::Instant, - reused: bool, - send_started: &mut bool, -) -> Result, MtprotoError>>, MtprotoError> { - invoke_batch_until_results_streaming( - engine, - transport, - bodies, - clock, - attempt_deadline, - overall_deadline, - hard_cap, - reused, - send_started, - |_, _| None, - ) -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn invoke_batch_until_results_streaming( - engine: &mut Engine

, - transport: &mut LiveTransport, - bodies: &[Vec], - clock: &SystemClock, - mut attempt_deadline: std::time::Instant, - mut overall_deadline: std::time::Instant, - mut hard_cap: std::time::Instant, - reused: bool, - send_started: &mut bool, - mut on_chunk: impl FnMut(usize, Result<&[u8], &MtprotoError>) -> Option>, -) -> Result, MtprotoError>>, MtprotoError> { - LAST_INBOUND_CTOR.with(|c| c.set(0)); - let mut pending: Vec>>> = Vec::with_capacity(bodies.len()); - for body in bodies { - let method = RawMethod { body: body.clone() }; - let handle = engine - .invoke(&method, clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - pending.push(Some(handle)); - } - let mut silent = super::main_policy::SilentRequests::new( - crate::scheduler::family(crate::scheduler::current_class()) - == crate::scheduler::LaneFamily::Media, - ); - let mut results: Vec, MtprotoError>>> = - (0..bodies.len()).map(|_| None).collect(); - let mut received = 0usize; - let mut leftover_grace = false; - let mut ping_inflight: Option = None; - let mut wait_for_keepalive = - reused && live_transport_stale(transport.last_io, std::time::Instant::now()); - if wait_for_keepalive { - ping_inflight = Some(send_ping( - engine, - &mut transport.conn, - &mut transport.framing, - clock, - )?); - transport.last_io = std::time::Instant::now(); - } - loop { - let ids: Vec = pending - .iter() - .flatten() - .map(|handle| handle.message_id()) - .collect(); - check_silent_requests( - engine, - transport, - clock, - &mut silent, - &ids, - *send_started && !wait_for_keepalive, - &mut attempt_deadline, - &mut overall_deadline, - hard_cap, - )?; - let mut recv_attempt = silent - .next_deadline() - .map_or(attempt_deadline, |due| attempt_deadline.min(due)); - let mut recv_overall = overall_deadline; - if std::time::Instant::now() > overall_deadline { - match transport.framing.decode(&transport.input) { - Ok(_) => {} - Err(TransportError::Incomplete { needed, available }) => { - let now = std::time::Instant::now(); - if available > 0 && now < hard_cap { - leftover_grace = true; - recv_overall = (now + std::time::Duration::from_secs(2)).min(hard_cap); - recv_attempt = recv_overall; - } else if leftover_frame_grace(leftover_grace, available) && now < hard_cap { - leftover_grace = true; - recv_overall = (now + std::time::Duration::from_secs(8)).min(hard_cap); - recv_attempt = recv_overall; - } else { - return Err(MtprotoError::Message(rpc_timeout_message( - received, - &transport.input, - needed, - available, - ))); - } - } - Err(_) => { - return Err(MtprotoError::Message(rpc_timeout_message( - received, - &transport.input, - 0, - transport.input.len(), - ))); - } - } - } - - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - - if !wait_for_keepalive { - while let Some(outbound) = engine.next_outbound() { - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - *send_started = true; - send_framed(&mut transport.conn, &mut transport.framing, &sealed)?; - transport.last_io = std::time::Instant::now(); - } - - let mut complete = true; - let mut refills: Vec> = Vec::new(); - for (index, slot) in pending.iter_mut().enumerate() { - if results[index].is_some() { - continue; - } - let Some(handle) = slot.as_ref() else { - continue; - }; - if let Some(response) = engine - .take_response::(handle) - .map_err(|e| MtprotoError::Message(e.to_string()))? - { - let mapped = match map_rpc_error(&response) { - Some(err) => { - if let Some(body) = on_chunk(index, Err(&err)) { - refills.push(body); - } - Err(err) - } - None => { - if let Some(body) = on_chunk(index, Ok(&response)) { - refills.push(body); - } - Ok(response) - } - }; - results[index] = Some(mapped); - *slot = None; - extend_streaming_deadlines( - &mut attempt_deadline, - &mut overall_deadline, - &mut hard_cap, - ); - } else { - complete = false; - } - } - let refilled = !refills.is_empty(); - for body in refills { - let method = RawMethod { body }; - let handle = engine - .invoke(&method, clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - pending.push(Some(handle)); - results.push(None); - complete = false; - } - if refilled { - continue; - } - if complete { - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - return Ok(results - .into_iter() - .map(|value| { - value.unwrap_or_else(|| { - Err(MtprotoError::Message("missing rpc result".into())) - }) - }) - .collect()); - } - } - - let packet = match recv_framed( - &mut transport.conn, - &mut transport.framing, - &mut transport.input, - &mut received, - recv_attempt, - recv_overall, - reused, - 2_000, - ) { - Ok(packet) => { - note_inbound_liveness(&mut ping_inflight); - transport.last_io = std::time::Instant::now(); - wait_for_keepalive = false; - packet - } - Err(err) if wait_for_keepalive => return Err(err), - Err(err) - if matches!(&err, MtprotoError::Message(message) if message.starts_with("RPC timeout")) - && transport.input.is_empty() - && (std::time::Instant::now() < overall_deadline - || !silent.due(std::time::Instant::now(), true).is_empty()) => - { - continue; - } - Err(err) => return Err(err), - }; - let packet = trim_padded_mtproto_packet(&packet); - let inbound = engine - .open_inbound(packet, clock, true, 1024 * 1024) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - transport.ping_sent = None; - if inbound.disposition == ReceivedMessageResult::InvalidTime { - repair_clock_from_server_msg_id(&mut engine.session, inbound.message.message_id, clock); - return Err(MtprotoError::Message( - "invalid inbound message time; reconnect".into(), - )); - } - if !should_process_inbound(inbound.disposition) { - continue; - } - let body = inbound.message.body; - if body.len() >= 4 { - let ctor = u32::from_le_bytes(body[0..4].try_into().unwrap()); - LAST_INBOUND_CTOR.with(|c| c.set(ctor)); - } - for event in parse_authenticated( - &body, - inbound.message.message_id, - &mut engine.session, - clock, - )? { - match event { - InboundEvent::RpcResult { req_msg_id, body } => { - match engine.receive_result(req_msg_id, body, inbound.message.message_id) { - Ok(()) => {} - Err(tellers_mtproto_engine::Error::UnknownRequest(_)) => {} - Err(e) => { - return Err(MtprotoError::Message(e.to_string())); - } - } - } - InboundEvent::SaltUpdated { body } => { - apply_new_session_salt(&mut engine.session, &body)?; - } - InboundEvent::RetryableFailure { message_id } => { - replay_updates_rejection( - engine, - &mut transport.conn, - &mut transport.framing, - clock, - message_id, - Some(&mut silent), - )?; - } - InboundEvent::BadMessage { - bad_msg_id, - error_code, - } => { - if engine - .session - .apply_bad_message(error_code, bad_msg_id, inbound.message.message_id) - .is_err() - { - engine.session.last_message_id = 0; - } - if bad_msg_should_reconnect(error_code) { - recreate_session_after_bad_message(&mut engine.session)?; - return Err(MtprotoError::Message(format!( - "bad_msg_notification {error_code} recv={received} last_ctor={BAD_MSG_NOTIFICATION:#x}" - ))); - } - if matches!(error_code, 19 | 64) { - return Err(MtprotoError::Message(format!( - "bad_msg_notification {error_code}" - ))); - } - for outbound in take_salt_resend(engine, bad_msg_id, clock)? { - silent.resent(bad_msg_id, outbound.message_id, std::time::Instant::now()); - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(&mut transport.conn, &mut transport.framing, &sealed)?; - transport.last_io = std::time::Instant::now(); - } - } - InboundEvent::FutureSalts(windows) => { - remember_future_salts(&mut engine.session, &windows, clock); - } - InboundEvent::Pong { ping_id } => { - if ping_inflight == Some(ping_id) || ping_inflight.is_some() { - ping_inflight = None; - } - } - InboundEvent::Updates(body) => queue_update(transport, body)?, - InboundEvent::AnswerAvailable { answer_msg_id } => { - recover_detailed_answer(engine, transport, answer_msg_id, clock)?; - } - InboundEvent::MessageStatus { req_msg_id, info } => { - silent.status(req_msg_id, &info)? - } - InboundEvent::Ignored => {} - } - } - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - } -} - -fn replay_updates_rejection( - engine: &mut Engine

, - conn: &mut dyn tellers_mtproto_transport::Connection, - framing: &mut tellers_mtproto_transport::PaddedIntermediate, - clock: &SystemClock, - message_id: i64, - mut silent: Option<&mut super::main_policy::SilentRequests>, -) -> Result { - let resent = take_salt_resend(engine, message_id, clock)?; - if !resent.is_empty() { - for outbound in resent { - if let Some(silent) = silent.as_mut() { - silent.resent(message_id, outbound.message_id, std::time::Instant::now()); - } - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(conn, framing, &sealed)?; - } - return Ok(true); - } - super::framing::resend_plaintext(engine, conn, framing, clock, message_id) -} - -fn ingest_update_packet( - engine: &mut Engine

, - transport: &mut LiveTransport, - packet: Vec, - clock: &SystemClock, -) -> Result, MtprotoError> { - let inbound = engine - .open_inbound( - trim_padded_mtproto_packet(&packet), - clock, - true, - MAX_UNPACKED_BYTES, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if inbound.disposition == ReceivedMessageResult::InvalidTime { - return Err(MtprotoError::Message("invalid inbound message time".into())); - } - if !should_process_inbound(inbound.disposition) { - return Ok(None); - } - transport.ping_sent = None; - transport.last_io = std::time::Instant::now(); - let mut pong = None; - for event in parse_authenticated( - &inbound.message.body, - inbound.message.message_id, - &mut engine.session, - clock, - )? { - match event { - InboundEvent::Updates(body) => queue_update(transport, body)?, - InboundEvent::AnswerAvailable { answer_msg_id } => { - recover_detailed_answer(engine, transport, answer_msg_id, clock)?; - } - InboundEvent::SaltUpdated { body } => { - apply_new_session_salt(&mut engine.session, &body)? - } - InboundEvent::RetryableFailure { message_id } => { - replay_updates_rejection( - engine, - &mut transport.conn, - &mut transport.framing, - clock, - message_id, - None, - )?; - } - InboundEvent::FutureSalts(windows) => { - remember_future_salts(&mut engine.session, &windows, clock); - } - InboundEvent::BadMessage { .. } => { - if updates_session_should_abort(false) { - return Err(MtprotoError::Message( - "updates session rejected; recovery required".into(), - )); - } - } - InboundEvent::Pong { ping_id } => pong = Some(ping_id), - InboundEvent::RpcResult { .. } - | InboundEvent::MessageStatus { .. } - | InboundEvent::Ignored => {} - } - } - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - Ok(pong) -} - -pub(crate) fn receive_updates(snapshot: &mut Snapshot) -> Result>, MtprotoError> { - let mut owner = ConnectionSupervisor::acquire(snapshot.dc_id)?; - let transport = owner - .transport - .as_mut() - .ok_or_else(|| MtprotoError::Message("updates connection unavailable".into()))?; - let clock = SystemClock; - let policy = RpcRetryPolicy { - replay_safe: false, - backoff: ExponentialBackoff { - timeout_micros: 20_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - }; - let mut engine = - Engine::new(snapshot.clone(), policy).map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut received = 0; - if transport - .ping_sent - .map(|sent| sent.elapsed() >= std::time::Duration::from_secs(20)) - .unwrap_or(false) - { - return Err(MtprotoError::Message( - "updates connection ping timeout".into(), - )); - } - if transport.ping_sent.is_none() - && live_transport_stale(transport.last_io, std::time::Instant::now()) - { - send_ping( - &mut engine, - &mut transport.conn, - &mut transport.framing, - &clock, - )?; - transport.ping_sent = Some(std::time::Instant::now()); - transport.last_io = std::time::Instant::now(); - } - // This reader shares the main RPC lane: idle polling must yield promptly. - let deadline = subscribed_read_deadline(transport.last_io, transport.ping_sent) - .min(std::time::Instant::now() + std::time::Duration::from_millis(100)); - let idle_wait_ms = deadline - .saturating_duration_since(std::time::Instant::now()) - .as_millis() as u64; - while transport.updates.is_empty() { - let packet = match recv_framed( - &mut transport.conn, - &mut transport.framing, - &mut transport.input, - &mut received, - deadline, - deadline, - false, - idle_wait_ms, - ) { - Ok(packet) => packet, - Err(MtprotoError::Message(message)) if message.starts_with("RPC timeout") => break, - Err(error) => return Err(error), - }; - ingest_update_packet(&mut engine, transport, packet, &clock)?; - if std::time::Instant::now() >= deadline { - break; - } - } - // Decode additional complete frames already in `input` without another socket wait. - while let Some(packet) = try_decode_complete(&mut transport.framing, &mut transport.input)? { - ingest_update_packet(&mut engine, transport, packet, &clock)?; - if std::time::Instant::now() >= deadline { - break; - } - } - let updates = std::mem::take(&mut transport.updates); - transport.updates_bytes = 0; - *snapshot = engine.session; - owner.park_ready(); - Ok(updates) -} - -pub(crate) fn ping_existing_rtt( - snapshot: &mut Snapshot, -) -> Result, MtprotoError> { - let mut owner = ConnectionSupervisor::acquire(snapshot.dc_id)?; - let Some(transport) = owner.transport.as_mut() else { - return Ok(None); - }; - let clock = SystemClock; - let mut engine = Engine::new( - snapshot.clone(), - ExponentialBackoff { - timeout_micros: 2_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .map_err(|error| MtprotoError::Message(error.to_string()))?; - let started = std::time::Instant::now(); - let deadline = started + std::time::Duration::from_secs(2); - let ping_id = send_ping( - &mut engine, - &mut transport.conn, - &mut transport.framing, - &clock, - )?; - let mut received = 0; - loop { - let packet = recv_framed( - &mut transport.conn, - &mut transport.framing, - &mut transport.input, - &mut received, - deadline, - deadline, - false, - 2_000, - )?; - let pong = ingest_update_packet(&mut engine, transport, packet, &clock)?; - if pong == Some(ping_id) { - *snapshot = engine.session; - owner.park_ready(); - return Ok(Some(started.elapsed())); - } - if std::time::Instant::now() >= deadline { - return Err(MtprotoError::Message( - "RPC timeout measuring upload RTT".into(), - )); - } - } -} - -pub(crate) fn flush_pending_acks( - snapshot: &mut Snapshot, - transport: &mut LiveTransport, -) -> Result<(), MtprotoError> { - let mut engine = Engine::new( - snapshot.clone(), - ExponentialBackoff { - timeout_micros: 8_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let result = flush_acks( - &mut engine, - &mut transport.conn, - &mut transport.framing, - &SystemClock, - ); - *snapshot = engine.session; - if result.is_ok() { - transport.last_io = std::time::Instant::now(); - } - result -} - -pub(crate) fn invoke_until_result( - engine: &mut Engine

, - transport: &mut LiveTransport, - request_body: Vec, - clock: &SystemClock, - mut attempt_deadline: std::time::Instant, - mut overall_deadline: std::time::Instant, - hard_cap: std::time::Instant, - reused: bool, - send_started: &mut bool, -) -> Result, MtprotoError> { - LAST_INBOUND_CTOR.with(|c| c.set(0)); - let method = RawMethod { body: request_body }; - let handle = engine - .invoke(&method, clock) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - let mut silent = super::main_policy::SilentRequests::new( - crate::scheduler::family(crate::scheduler::current_class()) - == crate::scheduler::LaneFamily::Media, - ); - let mut received = 0usize; - let mut leftover_grace = false; - let mut ping_inflight: Option = None; - let mut wait_for_keepalive = - reused && live_transport_stale(transport.last_io, std::time::Instant::now()); - if wait_for_keepalive { - ping_inflight = Some(send_ping( - engine, - &mut transport.conn, - &mut transport.framing, - clock, - )?); - transport.last_io = std::time::Instant::now(); - } - loop { - check_silent_requests( - engine, - transport, - clock, - &mut silent, - &[handle.message_id()], - *send_started && !wait_for_keepalive, - &mut attempt_deadline, - &mut overall_deadline, - hard_cap, - )?; - let mut recv_attempt = silent - .next_deadline() - .map_or(attempt_deadline, |due| attempt_deadline.min(due)); - let mut recv_overall = overall_deadline; - if std::time::Instant::now() > overall_deadline { - match transport.framing.decode(&transport.input) { - Ok(_) => {} - Err(TransportError::Incomplete { needed, available }) => { - let now = std::time::Instant::now(); - if available > 0 && now < hard_cap { - leftover_grace = true; - recv_overall = (now + std::time::Duration::from_secs(2)).min(hard_cap); - recv_attempt = recv_overall; - } else if leftover_frame_grace(leftover_grace, available) && now < hard_cap { - leftover_grace = true; - recv_overall = (now + std::time::Duration::from_secs(8)).min(hard_cap); - recv_attempt = recv_overall; - } else { - return Err(MtprotoError::Message(rpc_timeout_message( - received, - &transport.input, - needed, - available, - ))); - } - } - Err(_) => { - return Err(MtprotoError::Message(rpc_timeout_message( - received, - &transport.input, - 0, - transport.input.len(), - ))); - } - } - } - - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - - if !wait_for_keepalive { - while let Some(outbound) = engine.next_outbound() { - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - // write_all can fail after sending a prefix. The result is then - // unknown, even when the transport reports an error. - *send_started = true; - send_framed(&mut transport.conn, &mut transport.framing, &sealed)?; - transport.last_io = std::time::Instant::now(); - } - - if let Some(response) = engine - .take_response::(&handle) - .map_err(|e| MtprotoError::Message(e.to_string()))? - { - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - if let Some(err) = map_rpc_error(&response) { - return Err(err); - } - return Ok(response); - } - } - - let packet = match recv_framed( - &mut transport.conn, - &mut transport.framing, - &mut transport.input, - &mut received, - recv_attempt, - recv_overall, - reused, - 2_000, - ) { - Ok(packet) => { - // Any inbound is liveness. - note_inbound_liveness(&mut ping_inflight); - transport.last_io = std::time::Instant::now(); - wait_for_keepalive = false; - packet - } - Err(err) if wait_for_keepalive => return Err(err), - Err(err) - if matches!(&err, MtprotoError::Message(message) if message.starts_with("RPC timeout")) - && transport.input.is_empty() - && (std::time::Instant::now() < overall_deadline - || !silent.due(std::time::Instant::now(), true).is_empty()) => - { - continue; - } - Err(err) => return Err(err), - }; - let packet = trim_padded_mtproto_packet(&packet); - let inbound = engine - .open_inbound(packet, clock, true, 1024 * 1024) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - transport.ping_sent = None; - if inbound.disposition == ReceivedMessageResult::InvalidTime { - repair_clock_from_server_msg_id(&mut engine.session, inbound.message.message_id, clock); - // A message outside the accepted time window is never parsed on - // this connection. Reconnect after repairing the clock so the - // packet cannot be replayed or correlated on a stale transport. - return Err(MtprotoError::Message( - "invalid inbound message time; reconnect".into(), - )); - } - // Security guidelines require dropping replayed, too-old, and mistimed - // packets after authentication. Their bodies must never be correlated - // with an outstanding RPC or interpreted as a service message. - if !should_process_inbound(inbound.disposition) { - continue; - } - let body = inbound.message.body; - if body.len() >= 4 { - let ctor = u32::from_le_bytes(body[0..4].try_into().unwrap()); - LAST_INBOUND_CTOR.with(|c| c.set(ctor)); - } - for event in parse_authenticated( - &body, - inbound.message.message_id, - &mut engine.session, - clock, - )? { - match event { - InboundEvent::RpcResult { req_msg_id, body } => { - match engine.receive_result(req_msg_id, body, inbound.message.message_id) { - Ok(()) => {} - Err(tellers_mtproto_engine::Error::UnknownRequest(_)) => {} - Err(e) => { - return Err(MtprotoError::Message(e.to_string())); - } - } - } - InboundEvent::SaltUpdated { body } => { - apply_new_session_salt(&mut engine.session, &body)?; - } - InboundEvent::RetryableFailure { message_id } => { - replay_updates_rejection( - engine, - &mut transport.conn, - &mut transport.framing, - clock, - message_id, - Some(&mut silent), - )?; - } - InboundEvent::BadMessage { - bad_msg_id, - error_code, - } => { - // https://core.telegram.org/mtproto/service_messages_about_messages - if engine - .session - .apply_bad_message(error_code, bad_msg_id, inbound.message.message_id) - .is_err() - { - engine.session.last_message_id = 0; - } - if bad_msg_should_reconnect(error_code) { - // A sequence mismatch can be larger than one after a stale - // persisted snapshot. A new session_id gives it an independent - // seqno space while retaining this DC's authorization key. - recreate_session_after_bad_message(&mut engine.session)?; - return Err(MtprotoError::Message(format!( - "bad_msg_notification {error_code} recv={received} last_ctor={BAD_MSG_NOTIFICATION:#x}" - ))); - } - if matches!(error_code, 19 | 64) { - return Err(MtprotoError::Message(format!( - "bad_msg_notification {error_code}" - ))); - } - for outbound in take_salt_resend(engine, bad_msg_id, clock)? { - silent.resent(bad_msg_id, outbound.message_id, std::time::Instant::now()); - let padding = make_padding(outbound.body.len())?; - let sealed = engine - .seal_outbound(&outbound, &padding) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - send_framed(&mut transport.conn, &mut transport.framing, &sealed)?; - transport.last_io = std::time::Instant::now(); - } - // A rejected ping/ack is not evidence that the RPC failed. - } - InboundEvent::FutureSalts(windows) => { - remember_future_salts(&mut engine.session, &windows, clock); - } - InboundEvent::Pong { ping_id } => { - if ping_inflight == Some(ping_id) || ping_inflight.is_some() { - ping_inflight = None; - } - } - InboundEvent::Updates(body) => queue_update(transport, body)?, - InboundEvent::AnswerAvailable { answer_msg_id } => { - recover_detailed_answer(engine, transport, answer_msg_id, clock)?; - } - InboundEvent::MessageStatus { req_msg_id, info } => { - silent.status(req_msg_id, &info)? - } - InboundEvent::Ignored => {} - } - } - flush_acks(engine, &mut transport.conn, &mut transport.framing, clock)?; - } -} diff --git a/native/mtproto-rs/src/rpc/live.rs b/native/mtproto-rs/src/rpc/live.rs deleted file mode 100644 index da47da7df..000000000 --- a/native/mtproto-rs/src/rpc/live.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::cell::RefCell; - -use tellers_mtproto_transport::PaddedIntermediate; - -use crate::tcp::ObfuscatedTcp; - -/// Reused obfuscated TCP for sequential RPCs on one client (new socket per RPC times out). -pub struct LiveTransport { - pub(crate) ping_sent: Option, - pub(crate) updates: Vec>, - pub(crate) updates_bytes: usize, - pub(crate) dc_id: i32, - pub(crate) endpoint: String, - pub(crate) conn: ObfuscatedTcp, - pub(crate) framing: PaddedIntermediate, - pub(crate) input: Vec, - /// Last send/recv on this TCP; stale reuse probes ping first. - pub(crate) last_io: std::time::Instant, -} - -// Per-lane stack. Nested media must not swap away the home TCP. Drop only the top frame. -thread_local! { - static LIVE: RefCell>> = const { RefCell::new(Vec::new()) }; - pub(crate) static SUPERVISING: RefCell> = const { RefCell::new(Vec::new()) }; -} - -pub fn with_live_transport(slot: &mut Option, body: impl FnOnce() -> T) -> T { - LIVE.with(|cell| { - struct Pop<'a>(&'a RefCell>>); - impl Drop for Pop<'_> { - fn drop(&mut self) { - self.0.borrow_mut().pop(); - SUPERVISING.with(|stack| { - stack.borrow_mut().pop(); - }); - } - } - cell.borrow_mut().push(slot.take()); - SUPERVISING.with(|stack| stack.borrow_mut().push(false)); - let _pop = Pop(cell); - let result = body(); - *slot = cell.borrow_mut().last_mut().and_then(Option::take); - result - }) -} - -pub fn drop_live_transport() { - LIVE.with(|cell| { - if let Some(top) = cell.borrow_mut().last_mut() { - *top = None; - } - }); -} - -pub(crate) fn take_live(dc_id: i32) -> Option { - LIVE.with(|cell| { - let mut stack = cell.borrow_mut(); - let top = stack.last_mut()?; - match top.as_ref() { - Some(existing) if existing.dc_id == dc_id => top.take(), - _ => { - *top = None; - None - } - } - }) -} - -pub(crate) fn queue_rpc_updates(dc_id: i32, body: Vec) -> Result<(), crate::MtprotoError> { - let mut transport = take_live(dc_id).ok_or_else(|| { - crate::MtprotoError::Message("management updates require recovery".into()) - })?; - super::framing::queue_update(&mut transport, body)?; - put_live(transport); - Ok(()) -} - -pub(crate) fn put_live(transport: LiveTransport) { - LIVE.with(|cell| { - let mut stack = cell.borrow_mut(); - if let Some(top) = stack.last_mut() { - *top = Some(transport); - } - }); -} - -#[cfg(test)] -pub(crate) fn live_stack_depth() -> usize { - LIVE.with(|cell| cell.borrow().len()) -} - -#[cfg(test)] -pub(crate) fn live_top_occupied() -> bool { - LIVE.with(|cell| { - cell.borrow() - .last() - .and_then(|slot| slot.as_ref()) - .is_some() - }) -} diff --git a/native/mtproto-rs/src/rpc/main_policy.rs b/native/mtproto-rs/src/rpc/main_policy.rs deleted file mode 100644 index 6623510ae..000000000 --- a/native/mtproto-rs/src/rpc/main_policy.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::time::{Duration, Instant}; - -use crate::{HashMap, HashMapExt, MtprotoError}; - -pub(crate) const ACK_DELAY: Duration = Duration::from_secs(10); - -pub(crate) struct SilentRequests { - sent: HashMap, - probes: HashMap>, - wire_ids: HashMap, - delay: Duration, -} - -impl SilentRequests { - pub(crate) fn new(media: bool) -> Self { - Self { - sent: HashMap::new(), - probes: HashMap::new(), - wire_ids: HashMap::new(), - delay: Duration::from_secs(if media { 30 } else { 8 }), - } - } - - pub(crate) fn observe(&mut self, ids: &[i64], now: Instant) { - self.wire_ids.retain(|id, _| ids.contains(id)); - for &id in ids { - self.wire_ids.entry(id).or_insert(id); - } - self.sent - .retain(|id, _| self.wire_ids.values().any(|wire| wire == id)); - for &id in self.wire_ids.values() { - self.sent.entry(id).or_insert((now, false)); - } - } - - pub(crate) fn resent(&mut self, old_id: i64, new_id: i64, now: Instant) { - for wire in self.wire_ids.values_mut() { - if *wire == old_id { - *wire = new_id; - } - } - if self.sent.remove(&old_id).is_some() { - self.sent.insert(new_id, (now, false)); - } - } - - pub(crate) fn next_deadline(&self) -> Option { - self.sent - .values() - .filter(|(_, probed)| !probed) - .map(|(at, _)| *at + self.delay) - .min() - } - - pub(crate) fn due(&self, now: Instant, force: bool) -> Vec { - self.sent - .iter() - .filter(|(_, (at, probed))| !probed && (force || now >= *at + self.delay)) - .map(|(id, _)| *id) - .collect() - } - - pub(crate) fn probed(&mut self, probe: i64, ids: Vec) { - for id in &ids { - if let Some((_, done)) = self.sent.get_mut(id) { - *done = true; - } - } - self.probes.insert(probe, ids); - } - - pub(crate) fn status(&mut self, probe: i64, info: &[u8]) -> Result<(), MtprotoError> { - let Some(ids) = self.probes.remove(&probe) else { - return Ok(()); - }; - if ids.len() != info.len() { - return Err(MtprotoError::Message( - "invalid MTProto probe status count".into(), - )); - } - for (&id, &state) in ids.iter().zip(info) { - if self.sent.contains_key(&id) && matches!(state & 7, 2 | 3) { - return Err(MtprotoError::Message( - "RPC timeout: status probe confirms request not received".into(), - )); - } - } - Ok(()) - } -} - -#[cfg(test)] -#[path = "../../tests/unit/main_policy_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/rpc/salts.rs b/native/mtproto-rs/src/rpc/salts.rs deleted file mode 100644 index 7e82bcdc3..000000000 --- a/native/mtproto-rs/src/rpc/salts.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Prefetched server salts. The active salt is the valid one with the latest `valid_until`. -//! https://core.telegram.org/api/optimisation - -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; - -use tellers_mtproto_session::Snapshot; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct SaltWindow { - pub valid_since: i32, - pub valid_until: i32, - pub salt: i64, -} - -struct Book { - windows: HashMap>, - requested_at: HashMap, -} - -static BOOK: LazyLock> = LazyLock::new(|| { - Mutex::new(Book { - windows: HashMap::new(), - requested_at: HashMap::new(), - }) -}); - -fn book() -> std::sync::MutexGuard<'static, Book> { - BOOK.lock().unwrap_or_else(|poison| poison.into_inner()) -} - -pub(crate) fn choose_salt(windows: &[SaltWindow], now: i32) -> Option { - windows - .iter() - .filter(|window| window.valid_since <= now && now < window.valid_until) - .max_by_key(|window| window.valid_until) - .map(|window| window.salt) -} - -pub(crate) fn forget_session(session_id: i64) { - let mut book = book(); - book.windows.remove(&session_id); - book.requested_at.remove(&session_id); -} - -pub(crate) fn store_windows(session_id: i64, windows: &[SaltWindow]) { - let mut book = book(); - book.windows.insert(session_id, windows.to_vec()); - if !windows.is_empty() { - book.requested_at.remove(&session_id); - } -} - -pub(crate) fn preferred_salt(session_id: i64, now: i32) -> Option { - let book = book(); - book.windows - .get(&session_id) - .and_then(|windows| choose_salt(windows, now)) -} - -pub(crate) fn mark_requested(session_id: i64, now: i32) { - book().requested_at.insert(session_id, now); -} - -/// Prefetch when no salt window is stored. A missed answer can be retried after 30 seconds. -pub(crate) fn should_prefetch(session_id: i64, now: i32) -> bool { - let book = book(); - if book - .windows - .get(&session_id) - .is_some_and(|windows| !windows.is_empty()) - { - return false; - } - match book.requested_at.get(&session_id) { - Some(sent_at) => now.saturating_sub(*sent_at) >= 30, - None => true, - } -} - -pub(crate) fn apply_preferred_salt(snapshot: &mut Snapshot, now: i32) { - if let Some(salt) = preferred_salt(snapshot.session_id, now) { - snapshot.server_salt = salt; - } -} - -#[cfg(test)] -#[path = "../../tests/unit/salt_book_tests.rs"] -mod salt_book_tests; - -#[cfg(test)] -#[path = "../../tests/unit/rpc_salts_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/rpc/supervisor.rs b/native/mtproto-rs/src/rpc/supervisor.rs deleted file mode 100644 index c87ebc672..000000000 --- a/native/mtproto-rs/src/rpc/supervisor.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::MtprotoError; -use crate::tcp; - -use super::live::{LiveTransport, SUPERVISING, put_live, take_live}; - -pub(crate) use crate::tcp::is_waitable_io; - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum FailureClass { - Transport, - Protocol, - Migration, - Authorization, - Cancelled, - RpcRejected, - Internal, -} - -pub(crate) fn failure_class(err: &MtprotoError) -> FailureClass { - let MtprotoError::Message(message) = err else { - return FailureClass::RpcRejected; - }; - // A correlated RPC error is authoritative. Never infer socket failure from - // words inside its server-provided description (e.g. CONNECTION_NOT_INITED). - if let Some(code) = message - .strip_prefix("RPC ") - .and_then(|v| v.split_once(':')) - .and_then(|(code, _)| code.parse::().ok()) - { - return match code { - 303 => FailureClass::Migration, - 401 => FailureClass::Authorization, - 406 if message.contains("AUTH_KEY_DUPLICATED") => FailureClass::Authorization, - 500..=599 => FailureClass::Internal, - _ => FailureClass::RpcRejected, - }; - } - let lower = message.to_ascii_lowercase(); - if lower.contains("client closed") || lower.contains("cancelled") { - return FailureClass::Cancelled; - } - if lower.contains("malformed") - || lower.contains("envelope") - || lower.contains("ciphertext") - || lower.contains("msg_key") - || lower.contains("invalid inbound message time") - || lower.contains("bad_msg_notification") - || lower.contains("invalid mtproto") - || lower.contains("gzip") - || lower.contains("wrapper depth") - { - return FailureClass::Protocol; - } - if !is_waitable_io(message) - && (lower.contains("rpc timeout") - || lower.contains("timeout") - || lower.contains("connection") - || lower.contains("broken pipe") - || lower.contains("reset")) - { - FailureClass::Transport - } else { - FailureClass::Internal - } -} - -pub(crate) fn is_transport_error(err: &MtprotoError) -> bool { - matches!( - failure_class(err), - FailureClass::Transport | FailureClass::Protocol - ) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ConnectionState { - Disconnected, - Connecting, - /// Obfuscation is established; the first authenticated exchange is pending. - Handshaking, - Ready, - BackingOff, - Closing, -} - -/// The lane mutex protects cross-thread entry. This lease additionally rejects -/// reentrant RPCs on the same lane and owns its socket across all retry attempts. -/// There is no detached reconnect task that can outlive the invoking operation. -pub(crate) struct ConnectionSupervisor { - lane: Option, - pub(crate) state: ConnectionState, - pub(crate) transport: Option, -} - -impl ConnectionSupervisor { - pub(crate) fn acquire(dc_id: i32) -> Result { - let lane = SUPERVISING.with(|stack| { - let mut stack = stack.borrow_mut(); - let lane = stack.len().checked_sub(1); - if let Some(index) = lane { - if stack[index] { - return Err(MtprotoError::Message( - "RPC supervisor already active on lane".into(), - )); - } - stack[index] = true; - } - Ok(lane) - })?; - let transport = take_live(dc_id); - Ok(Self { - lane, - state: if transport.is_some() { - ConnectionState::Ready - } else { - ConnectionState::Disconnected - }, - transport, - }) - } - - pub(crate) fn disconnect(&mut self) { - self.state = ConnectionState::Closing; - // Dropping TCP closes the socket before a replacement can be opened. - self.transport.take(); - self.state = ConnectionState::Disconnected; - } - - pub(crate) fn backoff(&mut self, delay: std::time::Duration) -> Result<(), MtprotoError> { - self.disconnect(); - self.state = ConnectionState::BackingOff; - let result = tcp::wait_reconnect(delay).map_err(|e| MtprotoError::Message(e.to_string())); - self.state = ConnectionState::Disconnected; - result - } - - pub(crate) fn connect( - &mut self, - open: impl FnOnce() -> Result, - ) -> Result { - tcp::wait_reconnect(std::time::Duration::ZERO) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if self.transport.is_some() { - debug_assert_eq!(self.state, ConnectionState::Ready); - return Ok(true); - } - debug_assert_eq!(self.state, ConnectionState::Disconnected); - self.state = ConnectionState::Connecting; - match open() { - Ok(transport) => { - self.transport = Some(transport); - self.state = ConnectionState::Handshaking; - Ok(false) - } - Err(error) => { - self.state = ConnectionState::Disconnected; - Err(error) - } - } - } - - pub(crate) fn park_ready(&mut self) { - self.state = ConnectionState::Ready; - if let Some(transport) = self.transport.take() { - put_live(transport); - } - } -} - -impl Drop for ConnectionSupervisor { - fn drop(&mut self) { - if self.transport.is_some() { - self.disconnect(); - } - if let Some(index) = self.lane { - SUPERVISING.with(|stack| { - if let Some(active) = stack.borrow_mut().get_mut(index) { - *active = false; - } - }); - } - } -} diff --git a/native/mtproto-rs/src/rpc/timeout.rs b/native/mtproto-rs/src/rpc/timeout.rs deleted file mode 100644 index 9d6f74a6d..000000000 --- a/native/mtproto-rs/src/rpc/timeout.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::cell::Cell; - -use crate::MtprotoError; - -// Per OS thread: Kotlin runs drain / history / media on different threads. -// A process-wide atomic let getHistory(8s) steal download_media(45s) mid-wait. -thread_local! { - static RPC_TIMEOUT_SECS: Cell = const { Cell::new(20) }; -} - -pub fn with_rpc_timeout_secs(secs: u64, body: impl FnOnce() -> T) -> T { - RPC_TIMEOUT_SECS.with(|cell| { - struct Restore<'a>(&'a Cell, u64); - impl Drop for Restore<'_> { - fn drop(&mut self) { - self.0.set(self.1); - } - } - let previous = cell.replace(secs); - let _restore = Restore(cell, previous); - body() - }) -} - -pub(crate) fn rpc_timeout_secs() -> u64 { - RPC_TIMEOUT_SECS.with(|cell| cell.get()).max(1) -} - -/// Idle timeout for a streaming getFile window: each completed part proves the -/// socket is alive, so the transfer may exceed the original RPC budget. -pub(crate) fn extend_streaming_deadlines( - attempt_deadline: &mut std::time::Instant, - overall_deadline: &mut std::time::Instant, - hard_cap: &mut std::time::Instant, -) { - let timeout = std::time::Duration::from_secs(rpc_timeout_secs()); - let now = std::time::Instant::now(); - *overall_deadline = now + timeout; - *hard_cap = now - + std::time::Duration::from_secs( - rpc_timeout_secs() - .saturating_mul(2) - .max(rpc_timeout_secs() + 8), - ); - *attempt_deadline = now + timeout; -} - -/// Padded intermediate appends 0..=15 random bytes after the encrypted envelope. -/// Encrypted MTProto length is 24 + 16k (auth_key_id + msg_key + AES blocks). -pub(crate) fn trim_padded_mtproto_packet(packet: &[u8]) -> &[u8] { - let mut n = packet.len(); - while n >= 24 && n % 16 != 8 { - n -= 1; - } - if n < 24 { packet } else { &packet[..n] } -} - -thread_local! { - pub(crate) static LAST_INBOUND_CTOR: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -pub(crate) fn rpc_timeout_message( - received: usize, - input: &[u8], - needed: usize, - available: usize, -) -> String { - let prefix = if input.len() >= 4 { - u32::from_le_bytes(input[..4].try_into().expect("four bytes")) - } else { - 0 - }; - let ctor = LAST_INBOUND_CTOR.with(|c| c.get()); - format!( - "RPC timeout recv={received} needed={needed} available={available} prefix={prefix} last_ctor={ctor:#x}" - ) -} - -/// Idle attempts reserve a reconnect window. Mid-frame reads still use the -/// overall RPC deadline so a large in-flight padded frame is not abandoned. -pub(crate) fn rpc_attempt_budget( - _attempt: usize, - last: bool, - remaining: std::time::Duration, -) -> std::time::Duration { - if last { - return remaining; - } - let reserve = std::cmp::min(std::time::Duration::from_secs(2), remaining / 4); - let floor = std::time::Duration::from_secs(1); - if remaining <= reserve.saturating_add(floor) { - remaining - } else { - remaining - reserve - } -} - -pub(crate) fn is_mid_frame(needed: usize, available: usize) -> bool { - available > 0 && (needed > available || needed == 0) -} - -/// Leftover encrypted bytes after a decrypted container. Finish that frame -/// instead of `needed=0` abort + new TCP (logs: prefix=630 available=314). -pub(crate) fn leftover_frame_grace(used: bool, available: usize) -> bool { - !used && available > 0 -} - -/// Empty buffer waiting for the next 4-byte length, after we already drained -/// complete frames. Do not spend the rest of the RPC budget idle. -pub(crate) fn idle_after_complete_frames( - received: usize, - needed: usize, - available: usize, - last_ctor: u32, -) -> bool { - // Leftover 4-byte length after bytes, but no decrypted TL yet. - // Do not fail-fast on msg_container/acks: history/media still expect - // rpc_result in the next frame (emulator: recv=60KiB last_ctor=0x73f1f8dc). - received > 0 && available == 0 && needed <= 4 && last_ctor == 0 -} - -/// New TCP accepted but silent (`drain updates` recv=0 last_ctor=0x0). -/// Fail before the whole RPC budget is burned. -pub(crate) fn idle_empty_first_byte(received: usize, needed: usize, available: usize) -> bool { - received == 0 && available == 0 && needed <= 4 -} - -/// Parked/reused live TCP with no first length prefix (`loadMoreChats` log: -/// recv=0 needed=4 available=0 last_ctor=0x0). Fail fast so invoke_raw reconnects. -pub(crate) fn idle_reused_socket( - reused: bool, - received: usize, - needed: usize, - available: usize, -) -> bool { - reused && received == 0 && available == 0 && needed <= 4 -} - -pub(crate) fn fail_fast_idle( - reused: bool, - received: usize, - needed: usize, - available: usize, - last_ctor: u32, -) -> bool { - idle_after_complete_frames(received, needed, available, last_ctor) - || idle_reused_socket(reused, received, needed, available) -} - -/// Silence after a decrypted service frame (msg_container/updates/acks). -/// Do not fail-fast the RPC: rpc_result may still be inbound. Probe with ping. -/// https://core.telegram.org/mtproto/service_messages#ping-messages-ping-pong -pub(crate) fn idle_needs_liveness_probe( - received: usize, - needed: usize, - available: usize, - last_ctor: u32, -) -> bool { - received > 0 && available == 0 && needed <= 4 && last_ctor != 0 -} - -pub(crate) fn capture_timeout_field(message: &str, key: &str) -> Option { - let rest = message.split(key).nth(1)?; - let token = rest.split(|c: char| !c.is_ascii_digit()).next()?; - token.parse().ok() -} - -pub(crate) fn last_ctor_from_timeout(message: &str) -> Option { - let rest = message.split("last_ctor=").nth(1)?; - let token = rest.split_whitespace().next()?; - let hex = token.strip_prefix("0x").unwrap_or(token); - u32::from_str_radix(hex, 16).ok() -} - -pub(crate) fn timeout_idle_needs_probe(err: &MtprotoError) -> bool { - let MtprotoError::Message(message) = err else { - return false; - }; - if !message.contains("RPC timeout") { - return false; - } - let recv = capture_timeout_field(message, "recv=").unwrap_or(0); - let needed = capture_timeout_field(message, "needed=").unwrap_or(0); - let available = capture_timeout_field(message, "available=").unwrap_or(0); - let last_ctor = last_ctor_from_timeout(message).unwrap_or(0); - idle_needs_liveness_probe(recv, needed, available, last_ctor) -} - -/// Generic keepalive ping interval. -pub(crate) const KEEPALIVE_PING_SECS: u64 = 19; - -pub(crate) fn live_transport_stale(last_io: std::time::Instant, now: std::time::Instant) -> bool { - now.duration_since(last_io) >= std::time::Duration::from_secs(KEEPALIVE_PING_SECS) -} - -pub(crate) fn subscribed_read_deadline( - last_io: std::time::Instant, - ping_sent: Option, -) -> std::time::Instant { - match ping_sent { - Some(sent) => sent + std::time::Duration::from_secs(20), - None => last_io + std::time::Duration::from_secs(KEEPALIVE_PING_SECS), - } -} - -/// Treats any inbound as liveness, not only `pong`. -pub(crate) fn note_inbound_liveness(ping_inflight: &mut Option) { - *ping_inflight = None; -} - -pub(crate) fn keepalive_probe_failed(ping_inflight: Option) -> bool { - ping_inflight.is_some() -} - -pub(crate) fn recv_wait_deadline( - attempt_deadline: std::time::Instant, - overall_deadline: std::time::Instant, - needed: usize, - available: usize, -) -> std::time::Instant { - if is_mid_frame(needed, available) { - overall_deadline - } else { - attempt_deadline - } -} - -#[cfg(test)] -#[path = "../../tests/unit/rpc_timeout_mod_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/scheduler.rs b/native/mtproto-rs/src/scheduler.rs deleted file mode 100644 index a8210546d..000000000 --- a/native/mtproto-rs/src/scheduler.rs +++ /dev/null @@ -1,433 +0,0 @@ -//! Dispatch policy: which lane family serves a request class, and in what order a -//! freed lane is handed out. The updates lane stays FIFO (it owns `seq`/`pts`). -//! https://core.telegram.org/api/invoking - -use std::collections::VecDeque; -use std::sync::Arc; -use std::time::Duration; - -use parking_lot::{Condvar, Mutex}; - -use crate::MtprotoError; - -/// Lower [`RequestClass::priority`] wins inside a lane family. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum RequestClass { - InteractiveRead, - BackgroundRead, - InteractiveMedia, - BackgroundMedia, - InteractiveWrite, -} - -impl RequestClass { - pub fn bits(self) -> u8 { - match self { - RequestClass::InteractiveRead => 0, - RequestClass::BackgroundRead => 1, - RequestClass::InteractiveMedia => 2, - RequestClass::BackgroundMedia => 3, - RequestClass::InteractiveWrite => 4, - } - } - - pub fn priority(self) -> u8 { - match self { - RequestClass::InteractiveWrite => 0, - RequestClass::InteractiveRead => 1, - RequestClass::InteractiveMedia => 2, - RequestClass::BackgroundRead => 3, - RequestClass::BackgroundMedia => 4, - } - } - - pub(crate) fn from_bits(bits: u8) -> Option { - match bits { - 0 => Some(RequestClass::InteractiveRead), - 1 => Some(RequestClass::BackgroundRead), - 2 => Some(RequestClass::InteractiveMedia), - 3 => Some(RequestClass::BackgroundMedia), - 4 => Some(RequestClass::InteractiveWrite), - _ => None, - } - } -} - -impl std::fmt::Display for RequestClass { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let name = match self { - RequestClass::InteractiveRead => "read", - RequestClass::BackgroundRead => "background-read", - RequestClass::InteractiveMedia => "media", - RequestClass::BackgroundMedia => "background-media", - RequestClass::InteractiveWrite => "write", - }; - f.write_str(name) - } -} - -thread_local! { - /// Set per request by the bridge, in the same context that carries cancellation. - static DISPATCH_CLASS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -pub fn set_current_class(class: RequestClass) { - DISPATCH_CLASS.with(|cell| cell.set(class.bits())); -} - -pub fn current_class() -> RequestClass { - DISPATCH_CLASS - .with(|cell| RequestClass::from_bits(cell.get())) - .unwrap_or(RequestClass::InteractiveRead) -} - -pub fn with_class(class: RequestClass, body: impl FnOnce() -> T) -> T { - struct Restore(u8); - impl Drop for Restore { - fn drop(&mut self) { - DISPATCH_CLASS.with(|cell| cell.set(self.0)); - } - } - let previous = DISPATCH_CLASS.with(|cell| cell.replace(class.bits())); - let _restore = Restore(previous); - body() -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum LaneFamily { - Read, - Media, -} - -/// Home-DC forks (`invokeWithoutUpdates`) so one read never blocks another. -/// Only used when the server grants extra main sessions; see -/// [`extra_main_sessions`]. -pub const READ_LANES: usize = 2; - -pub const UPLOAD_LANES: usize = 4; - -/// Most parallel main sessions we will ever open on one non-media DC. -pub const MAX_MAIN_SESSIONS: i32 = 8; - -/// Serializes tests that change unbound policy defaults. -#[cfg(test)] -pub(crate) static ALLOWANCE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Records the server's permission for parallel main sessions. -/// -/// A non-media DC detects parallel requests from two TCP connections of the same -/// authorization and answers `406 AUTH_KEY_DUPLICATED`, which **invalidates** the -/// authorization. `tmp_sessions` absent or ≤1 therefore means: a single main -/// session. Dedicated file-transfer sessions on media DCs are exempt. -/// -pub fn set_main_session_allowance(tmp_sessions: Option) -> i32 { - let allowance = tmp_sessions.unwrap_or(1).clamp(1, MAX_MAIN_SESSIONS); - crate::transfer_policy::current() - .main_session_allowance - .store(allowance, std::sync::atomic::Ordering::Relaxed); - allowance -} - -/// Parallel main sessions the server allows; unknown counts as the safe single one. -pub fn main_session_allowance() -> i32 { - match crate::transfer_policy::current() - .main_session_allowance - .load(std::sync::atomic::Ordering::Relaxed) - { - known if known >= 1 => known, - _ => 1, - } -} - -pub fn main_session_allowance_known() -> bool { - crate::transfer_policy::current() - .main_session_allowance - .load(std::sync::atomic::Ordering::Relaxed) - >= 1 -} - -/// Records how many extra home sessions have a bound temporary key. -/// -/// A server allowance alone does not authorize reusing the permanent key. -/// https://core.telegram.org/api/datacenter#parallel-sessions -pub fn set_bound_extra_sessions(count: usize) { - crate::transfer_policy::current() - .bound_extra_sessions - .store(count.min(READ_LANES), std::sync::atomic::Ordering::Relaxed); -} - -/// Extra main sessions that may be used. Stays zero until a temp key is bound. -pub fn extra_main_sessions() -> usize { - let bound = crate::transfer_policy::current() - .bound_extra_sessions - .load(std::sync::atomic::Ordering::Relaxed); - if bound == 0 { - return 0; - } - let extras = usize::try_from(main_session_allowance().saturating_sub(1)).unwrap_or(0); - bound.min(extras).min(READ_LANES) -} - -/// Home-DC read lanes the current allowance pays for. -pub fn read_lanes() -> usize { - READ_LANES.min(extra_main_sessions()) -} - -/// Lanes are allocated up front; only `active_media_lanes()` are used. -pub const MAX_MEDIA_LANES: usize = 8; - -pub const DEFAULT_MEDIA_LANES: usize = 2; - -pub fn active_media_lanes() -> usize { - crate::transfer_policy::current().media_lanes() -} - -/// Runtime knob for the "Faster downloads" setting; applies to the next acquisition. -pub fn set_active_media_lanes(lanes: usize) { - let lanes = lanes.clamp(1, MAX_MEDIA_LANES); - crate::transfer_policy::default_policy().assign_media_lanes(lanes); - crate::client::visit_policies(|policy| policy.follow_media_lanes(lanes)); -} - -pub fn family(class: RequestClass) -> LaneFamily { - match class { - RequestClass::InteractiveWrite - | RequestClass::InteractiveRead - | RequestClass::BackgroundRead => LaneFamily::Read, - RequestClass::InteractiveMedia | RequestClass::BackgroundMedia => LaneFamily::Media, - } -} - -/// Read-lane acquisition order (sibling last). Concrete type so callers can clone it. -pub fn read_lane_order() -> std::iter::Rev> { - (0..read_lanes()).rev() -} - -/// Media-lane acquisition order over the *active* lanes. The gate decides who gets a -/// freed lane. -pub fn media_lane_order() -> std::ops::Range { - 0..active_media_lanes() -} - -/// Priority gate for one lane, used by the read and media families. -/// -/// Waiters are admitted by class priority, FIFO inside a class. Waits are timed so -/// cancellation (`request_control`) is honoured while queued. -pub struct LaneGate { - state: Mutex, - wake: Condvar, - clock: Arc, -} - -pub trait SchedulerClock: Send + Sync { - fn now_ms(&self) -> u64; -} - -pub struct SystemSchedulerClock { - origin: std::time::Instant, -} - -impl Default for SystemSchedulerClock { - fn default() -> Self { - Self { - origin: std::time::Instant::now(), - } - } -} - -impl SchedulerClock for SystemSchedulerClock { - fn now_ms(&self) -> u64 { - self.origin.elapsed().as_millis() as u64 - } -} - -pub(crate) const MAX_LANE_WAITERS: usize = 256; - -const AGING_INTERVAL_MS: u64 = 5_000; - -#[derive(Default)] -struct GateState { - busy: bool, - waiters: VecDeque, - next_ticket: u64, -} - -struct Waiter { - ticket: u64, - priority: u8, - queued_at: u64, -} - -impl Waiter { - fn order_at(&self, now: u64) -> (u8, u64) { - let promotion = (now.saturating_sub(self.queued_at) / AGING_INTERVAL_MS).min(4) as u8; - let priority = if self.priority > 2 { - self.priority.saturating_sub(promotion).max(1) - } else { - self.priority - }; - (priority, self.ticket) - } -} - -/// Holds a lane; releases it (and wakes the best waiter) on drop. -pub struct LaneGuard<'a> { - gate: &'a LaneGate, -} - -pub(crate) struct LaneWaiter<'a> { - gate: &'a LaneGate, - ticket: u64, -} - -impl<'a> LaneWaiter<'a> { - pub(crate) fn try_acquire(&self) -> Option> { - let mut state = self.gate.state.lock(); - let now = self.gate.clock.now_ms(); - let best = state - .waiters - .iter() - .min_by_key(|waiter| waiter.order_at(now)); - if state.busy || !best.is_some_and(|waiter| waiter.ticket == self.ticket) { - return None; - } - state.busy = true; - state.waiters.retain(|waiter| waiter.ticket != self.ticket); - Some(LaneGuard { gate: self.gate }) - } - - pub(crate) fn is_next(&self) -> bool { - self.gate - .state - .lock() - .waiters - .iter() - .min_by_key(|waiter| waiter.order_at(self.gate.clock.now_ms())) - .is_some_and(|waiter| waiter.ticket == self.ticket) - } - - pub(crate) fn promote(&self, class: RequestClass) { - self.gate.promote(self.ticket, class); - } - - pub(crate) fn wait(&self) { - self.gate - .wake - .wait_for(&mut self.gate.state.lock(), Duration::from_millis(5)); - } -} - -impl Drop for LaneWaiter<'_> { - fn drop(&mut self) { - self.gate - .state - .lock() - .waiters - .retain(|waiter| waiter.ticket != self.ticket); - self.gate.wake.notify_all(); - } -} - -impl Default for LaneGate { - fn default() -> Self { - Self { - state: Mutex::new(GateState::default()), - wake: Condvar::new(), - clock: Arc::new(SystemSchedulerClock::default()), - } - } -} - -impl LaneGate { - pub fn new() -> Self { - Self::default() - } - - pub fn with_clock(clock: Arc) -> Self { - Self { - state: Mutex::new(GateState::default()), - wake: Condvar::new(), - clock, - } - } - - pub(crate) fn promote(&self, ticket: u64, class: RequestClass) { - let mut state = self.state.lock(); - if let Some(waiter) = state.waiters.iter_mut().find(|w| w.ticket == ticket) { - waiter.priority = waiter.priority.min(class.priority()); - } - drop(state); - self.wake.notify_all(); - } - - pub(crate) fn enqueue(&self, class: RequestClass) -> Result, MtprotoError> { - let mut state = self.state.lock(); - if state.waiters.len() >= MAX_LANE_WAITERS { - return Err(MtprotoError::Message( - "request queue capacity exceeded".into(), - )); - } - let ticket = state.next_ticket; - state.next_ticket = ticket - .checked_add(1) - .ok_or_else(|| MtprotoError::Message("request queue ticket exhausted".into()))?; - state.waiters.push_back(Waiter { - ticket, - priority: class.priority(), - queued_at: self.clock.now_ms(), - }); - self.wake.notify_all(); - Ok(LaneWaiter { gate: self, ticket }) - } - - #[cfg(test)] - pub(crate) fn waiter_count(&self) -> usize { - self.state.lock().waiters.len() - } - - /// Non-blocking admission. Fails when the lane is busy *or* when a higher-priority - /// waiter is already queued, so a fast path can never jump the queue. - pub fn try_acquire(&self, class: RequestClass) -> Option> { - let priority = class.priority(); - let mut state = self.state.lock(); - if state.busy - || state - .waiters - .iter() - .any(|waiter| waiter.order_at(self.clock.now_ms()).0 <= priority) - { - return None; - } - state.busy = true; - Some(LaneGuard { gate: self }) - } - - /// Blocks until this request's class holds the lane. - pub fn acquire(&self, class: RequestClass) -> Result, MtprotoError> { - let waiter = self.enqueue(class)?; - loop { - crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - if let Some(promoted) = crate::request_control::promoted_class() { - waiter.promote(promoted); - } - if let Some(guard) = waiter.try_acquire() { - return Ok(guard); - } - waiter.wait(); - } - } -} - -impl Drop for LaneGuard<'_> { - fn drop(&mut self) { - { - let mut state = self.gate.state.lock(); - state.busy = false; - } - self.gate.wake.notify_all(); - } -} - -#[cfg(test)] -#[path = "../tests/unit/scheduler_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/service_messages.rs b/native/mtproto-rs/src/service_messages.rs deleted file mode 100644 index 2852a4222..000000000 --- a/native/mtproto-rs/src/service_messages.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Chat service messages (`messageService` + `MessageAction`). -//! https://core.telegram.org/type/MessageAction - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ChatTheme, MessageAction}; - -use crate::peers::vector_items; - -const UNIT: char = '\u{1f}'; - -pub fn encode_service(kind: &str, actor: &str, extra: &str) -> String { - format!("{kind}{UNIT}{actor}{UNIT}{extra}") -} - -pub fn service_action_text( - action: &MessageAction, - actor_id: Option, - actor: &str, - titles: &HashMap, -) -> String { - let (kind, extra) = action_parts(action, actor_id, titles); - encode_service(kind, actor, &extra) -} - -fn name_of(id: i64, titles: &HashMap) -> String { - titles - .get(&id) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - .unwrap_or_else(|| "User".into()) -} - -fn names_of(ids: &[i64], titles: &HashMap) -> String { - ids.iter() - .map(|id| name_of(*id, titles)) - .collect::>() - .join(", ") -} - -fn action_parts( - action: &MessageAction, - actor_id: Option, - titles: &HashMap, -) -> (&'static str, String) { - match action { - MessageAction::MessageActionEmpty(_) => ("empty", String::new()), - MessageAction::MessageActionChatCreate(value) => ("create", value.title.clone()), - MessageAction::MessageActionChatEditTitle(value) => ("title", value.title.clone()), - MessageAction::MessageActionChatEditPhoto(_) => ("photo", String::new()), - MessageAction::MessageActionChatDeletePhoto(_) => ("photo_remove", String::new()), - MessageAction::MessageActionChatAddUser(value) => { - ("add", names_of(vector_items(value.users.as_ref()), titles)) - } - MessageAction::MessageActionChatDeleteUser(value) => { - let kind = if actor_id == Some(value.user_id) { - "leave" - } else { - "kick" - }; - (kind, name_of(value.user_id, titles)) - } - MessageAction::MessageActionChatJoinedByLink(_) => ("join_link", String::new()), - MessageAction::MessageActionChannelCreate(value) => ("channel_create", value.title.clone()), - MessageAction::MessageActionChatMigrateTo(_) => ("migrate_to", String::new()), - MessageAction::MessageActionChannelMigrateFrom(value) => { - ("migrate_from", value.title.clone()) - } - MessageAction::MessageActionPinMessage(_) => ("pin", String::new()), - MessageAction::MessageActionHistoryClear(_) => ("history_clear", String::new()), - MessageAction::MessageActionGameScore(value) => ("game_score", value.score.to_string()), - MessageAction::MessageActionPaymentSentMe(_) - | MessageAction::MessageActionPaymentSent(_) => ("payment", String::new()), - MessageAction::MessageActionPhoneCall(_) => ("call", String::new()), - MessageAction::MessageActionScreenshotTaken(_) => ("screenshot", String::new()), - MessageAction::MessageActionCustomAction(value) => ("custom", value.message.clone()), - MessageAction::MessageActionBotAllowed(_) => ("bot_allowed", String::new()), - MessageAction::MessageActionSecureValuesSentMe(_) - | MessageAction::MessageActionSecureValuesSent(_) => ("passport", String::new()), - MessageAction::MessageActionContactSignUp(_) => ("contact_signup", String::new()), - MessageAction::MessageActionGeoProximityReached(_) => ("proximity", String::new()), - MessageAction::MessageActionGroupCall(_) => ("group_call", String::new()), - MessageAction::MessageActionInviteToGroupCall(value) => ( - "invite_call", - names_of(vector_items(value.users.as_ref()), titles), - ), - MessageAction::MessageActionSetMessagesTtl(value) => ("ttl", value.period.to_string()), - MessageAction::MessageActionGroupCallScheduled(_) => ("call_scheduled", String::new()), - MessageAction::MessageActionSetChatTheme(value) => { - let theme = match value.theme.as_ref() { - ChatTheme::ChatTheme(theme) => theme.emoticon.clone(), - _ => String::new(), - }; - ("theme", theme) - } - MessageAction::MessageActionChatJoinedByRequest(_) => ("joined_request", String::new()), - MessageAction::MessageActionWebViewDataSentMe(value) => ("webview", value.text.clone()), - MessageAction::MessageActionWebViewDataSent(value) => ("webview", value.text.clone()), - MessageAction::MessageActionGiftPremium(value) => ("gift_premium", value.days.to_string()), - MessageAction::MessageActionTopicCreate(value) => ("topic_create", value.title.clone()), - MessageAction::MessageActionTopicEdit(_) => ("topic_edit", String::new()), - MessageAction::MessageActionSuggestProfilePhoto(_) => ("suggest_photo", String::new()), - MessageAction::MessageActionRequestedPeer(_) - | MessageAction::MessageActionRequestedPeerSentMe(_) => ("requested_peer", String::new()), - MessageAction::MessageActionSetChatWallPaper(_) => ("wallpaper", String::new()), - MessageAction::MessageActionGiftCode(_) => ("gift_code", String::new()), - MessageAction::MessageActionGiveawayLaunch(_) => ("giveaway", String::new()), - MessageAction::MessageActionGiveawayResults(value) => { - ("giveaway_results", value.winners_count.to_string()) - } - MessageAction::MessageActionBoostApply(value) => ("boost", value.boosts.to_string()), - MessageAction::MessageActionPaymentRefunded(_) => ("payment_refund", String::new()), - MessageAction::MessageActionGiftStars(value) => ("gift_stars", value.stars.to_string()), - MessageAction::MessageActionPrizeStars(value) => ("prize_stars", value.stars.to_string()), - MessageAction::MessageActionStarGift(_) => ("star_gift", String::new()), - MessageAction::MessageActionStarGiftUnique(_) => ("star_gift_unique", String::new()), - MessageAction::MessageActionPaidMessagesRefunded(_) => ("paid_refund", String::new()), - MessageAction::MessageActionPaidMessagesPrice(value) => { - ("paid_price", value.stars.to_string()) - } - MessageAction::MessageActionConferenceCall(_) => ("conference_call", String::new()), - MessageAction::MessageActionTodoCompletions(_) => ("todo", String::new()), - MessageAction::MessageActionTodoAppendTasks(_) => ("todo_add", String::new()), - MessageAction::MessageActionSuggestedPostApproval(_) => ("suggested_post", String::new()), - MessageAction::MessageActionSuggestedPostSuccess(_) => ("suggested_post_ok", String::new()), - MessageAction::MessageActionSuggestedPostRefund(_) => { - ("suggested_post_refund", String::new()) - } - MessageAction::MessageActionGiftTon(_) => ("gift_ton", String::new()), - MessageAction::MessageActionSuggestBirthday(_) => ("suggest_birthday", String::new()), - MessageAction::MessageActionStarGiftPurchaseOffer(_) => ("star_gift_offer", String::new()), - MessageAction::MessageActionStarGiftPurchaseOfferDeclined(_) => { - ("star_gift_offer_declined", String::new()) - } - MessageAction::MessageActionNewCreatorPending(value) => { - ("creator_pending", name_of(value.new_creator_id, titles)) - } - MessageAction::MessageActionChangeCreator(value) => { - ("creator_change", name_of(value.new_creator_id, titles)) - } - MessageAction::MessageActionNoForwardsToggle(_) => ("noforwards_toggle", String::new()), - MessageAction::MessageActionNoForwardsRequest(_) => ("noforwards_request", String::new()), - MessageAction::MessageActionPollAppendAnswer(_) => ("poll_answer_added", String::new()), - MessageAction::MessageActionPollDeleteAnswer(_) => ("poll_answer_removed", String::new()), - MessageAction::MessageActionManagedBotCreated(value) => { - ("managed_bot", name_of(value.bot_id, titles)) - } - MessageAction::MessageActionChangeCommunity(_) => ("community_change", String::new()), - MessageAction::MessageActionChatJoinedViaCommunity(_) => ("join_community", String::new()), - MessageAction::MessageActionGramTransfer(value) => { - ("gram_transfer", value.amount.to_string()) - } - MessageAction::MessageActionWalletTonConnectRequest(value) => { - ("ton_connect", value.dapp_name.clone().unwrap_or_default()) - } - MessageAction::MessageActionNoPaidMessagesTokenUsed(_) => { - ("paid_token_used", String::new()) - } - _ => ("unknown", String::new()), - } -} - -#[cfg(test)] -#[path = "../tests/unit/service_messages_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/session_crypto.rs b/native/mtproto-rs/src/session_crypto.rs deleted file mode 100644 index eb92f35a9..000000000 --- a/native/mtproto-rs/src/session_crypto.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Authenticated session storage; Android supplies a Keystore-wrapped data key. -use aes_gcm::{ - Aes256Gcm, KeyInit, Nonce, - aead::{Aead, Payload}, -}; -use parking_lot::Mutex; -use std::{ - path::{Path, PathBuf}, - sync::{Arc, LazyLock, Weak}, -}; - -use crate::{HashMap, HashMapExt}; -use tellers_mtproto_session::Error; -use zeroize::Zeroizing; - -/// Encrypted session v1 tag. Non-ASCII so plaintext JSON never matches. -pub(crate) const MAGIC: &[u8] = &[0xC3, 0xA7, 0x5E, 0x01]; -pub(crate) const LEGACY_MAGIC: &[u8] = b"MONORE_SESSION\x01"; -const BRANDED_MAGIC: &[u8] = b"MONOGRAM_SESSION\x01"; -const SALT_LEN: usize = 16; -const NONCE_LEN: usize = 12; -const GCM_TAG_LEN: usize = 16; -pub(crate) type SessionKey = Zeroizing<[u8; 32]>; -static KEYS: LazyLock>>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -fn error() -> Error { - Error::Persistence("session encryption validation failed".into()) -} - -pub(crate) fn register(path: &Path, bytes: &[u8]) -> Result, Error> { - let key = Zeroizing::new(<[u8; 32]>::try_from(bytes).map_err(|_| error())?); - let mut keys = KEYS.lock(); - keys.retain(|_, key| key.strong_count() != 0); - if let Some(existing) = keys.get(path).and_then(Weak::upgrade) { - if existing.as_ref().as_ref() != key.as_ref() { - return Err(error()); - } - return Ok(existing); - } - let key = Arc::new(key); - keys.insert(path.to_owned(), Arc::downgrade(&key)); - Ok(key) -} - -pub(crate) fn key_for(path: &Path) -> Option> { - KEYS.lock().get(path).and_then(Weak::upgrade) -} - -pub(crate) fn is_encrypted(bytes: &[u8]) -> bool { - bytes.starts_with(MAGIC) || bytes.starts_with(BRANDED_MAGIC) || bytes.starts_with(LEGACY_MAGIC) -} - -pub(crate) fn header(bytes: &[u8]) -> Option { - if bytes.starts_with(MAGIC) { - let aad_len = MAGIC.len() + SALT_LEN; - (bytes.len() >= aad_len + NONCE_LEN + GCM_TAG_LEN).then_some(aad_len) - } else if bytes.starts_with(BRANDED_MAGIC) { - (bytes.len() >= BRANDED_MAGIC.len() + NONCE_LEN + GCM_TAG_LEN) - .then_some(BRANDED_MAGIC.len()) - } else if bytes.starts_with(LEGACY_MAGIC) { - (bytes.len() >= LEGACY_MAGIC.len() + NONCE_LEN + GCM_TAG_LEN).then_some(LEGACY_MAGIC.len()) - } else { - None - } -} - -pub(crate) fn encrypt(bytes: &[u8], key: &SessionKey) -> Result, Error> { - let mut salt = [0u8; SALT_LEN]; - tellers_mtproto_crypto::fill_random(&mut salt).map_err(|_| error())?; - let mut aad = Vec::with_capacity(MAGIC.len() + SALT_LEN); - aad.extend_from_slice(MAGIC); - aad.extend_from_slice(&salt); - encrypt_with(bytes, key, &aad) -} - -#[cfg(test)] -pub(crate) fn encrypt_legacy(bytes: &[u8], key: &SessionKey) -> Result, Error> { - encrypt_with(bytes, key, LEGACY_MAGIC) -} - -fn encrypt_with(bytes: &[u8], key: &SessionKey, aad: &[u8]) -> Result, Error> { - let cipher = Aes256Gcm::new_from_slice(key.as_ref()).map_err(|_| error())?; - let mut nonce = [0u8; NONCE_LEN]; - tellers_mtproto_crypto::fill_random(&mut nonce).map_err(|_| error())?; - let nonce = Nonce::try_from(nonce.as_slice()).map_err(|_| error())?; - let ciphertext = cipher - .encrypt(&nonce, Payload { msg: bytes, aad }) - .map_err(|_| error())?; - let mut result = Vec::with_capacity(aad.len() + nonce.len() + ciphertext.len()); - result.extend_from_slice(aad); - result.extend_from_slice(&nonce); - result.extend_from_slice(&ciphertext); - Ok(result) -} - -pub(crate) fn decrypt(bytes: &[u8], key: &SessionKey) -> Result>, Error> { - let aad_len = header(bytes).ok_or_else(error)?; - let cipher = Aes256Gcm::new_from_slice(key.as_ref()).map_err(|_| error())?; - let nonce = Nonce::try_from(&bytes[aad_len..aad_len + NONCE_LEN]).map_err(|_| error())?; - cipher - .decrypt( - &nonce, - Payload { - msg: &bytes[aad_len + NONCE_LEN..], - aad: &bytes[..aad_len], - }, - ) - .map(Zeroizing::new) - .map_err(|_| error()) -} diff --git a/native/mtproto-rs/src/session_file.rs b/native/mtproto-rs/src/session_file.rs deleted file mode 100644 index c12079ec8..000000000 --- a/native/mtproto-rs/src/session_file.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Durable client session: Snapshot, user id, peer cache, updates cursor. - -use crate::{HashMap, HashMapExt, HashSet, HashSetExt}; -use std::fs; -use std::io::{ErrorKind, Write}; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use tellers_mtproto_session::{Error as SessionError, Snapshot}; - -use crate::UpdatesStateDto; -use crate::media::{MediaIndex, MediaRef}; -use crate::peers::CachedPeer; -use crate::rpc::NewSessionMetadata; - -/// Imported master key for one foreign datacenter. Key bytes stay out of `Debug`. -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct DcAuthKey { - pub key: Vec, - pub salt: i64, -} - -impl std::fmt::Debug for DcAuthKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DcAuthKey") - .field("key_len", &self.key.len()) - .finish_non_exhaustive() - } -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub(crate) struct ChannelRecovery { - pub chat_id: i64, - pub due_at: u64, - /// Completed open-channel subscription; drop when the user leaves it. - pub watching: bool, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ClientSession { - pub snapshot: Snapshot, - #[serde(default)] - pub user_id: Option, - #[serde(default)] - pub peers: HashMap, - #[serde(default)] - pub updates: Option, - /// Media download metadata keyed as "chatId:messageId". - #[serde(default)] - pub media: HashMap, - /// Oldest-first keys for `media`. Empty on sessions saved before retention. - #[serde(default)] - pub media_order: Vec, - /// Last applied per-channel pts, keyed by chat id. - #[serde(default)] - pub channel_pts: HashMap, - #[serde(default)] - pub(crate) channel_recovery: std::collections::VecDeque, - /// Deduplication keys for update events. - #[serde(default)] - pub seen_messages: HashSet<(i64, i32)>, - /// Home auth key was rejected (401 AUTH_KEY_* / SESSION_* / USER_DEACTIVATED). - #[serde(default)] - pub session_dead: bool, - /// Bounded future auth tokens returned by `auth.logOut`. - #[serde(default)] - pub logout_tokens: Vec>, - /// Last `new_session_created` metadata observed on any lane. - #[serde(default)] - pub new_session: Option, - /// Connected to Telegram test DCs (`99966XYYYY`). - #[serde(default)] - pub test_dc: bool, - /// Imported master keys for foreign datacenters, keyed by DC id. - #[serde(default)] - pub dc_auth_keys: HashMap, - #[serde(default)] - pub(crate) journal: crate::update_journal::UpdateJournal, -} - -pub fn media_key(chat_id: i64, message_id: i32) -> String { - format!("{chat_id}:{message_id}") -} - -pub fn media_from_index(index: &MediaIndex) -> HashMap { - let mut out = HashMap::with_capacity(index.len()); - for ((chat, message), media) in index { - if let Some(stored) = crate::media::persisted_media_entry(*message, media) { - out.insert(media_key(*chat, *message), stored); - } - } - out -} - -pub fn media_order_from_index(index: &MediaIndex) -> Vec { - index - .iter() - .filter(|((_, message), media)| crate::media::session_media_kept(*message, media)) - .map(|((chat, message), _)| media_key(*chat, *message)) - .collect() -} - -pub fn media_to_index(map: &HashMap, order: &[String]) -> MediaIndex { - let mut index = MediaIndex::default(); - let listed: HashSet<&str> = order.iter().map(String::as_str).collect(); - for (key, media) in map { - if !listed.contains(key.as_str()) { - insert_parsed(&mut index, key, media); - } - } - for key in order { - if let Some(media) = map.get(key) { - insert_parsed(&mut index, key, media); - } - } - crate::media::bound_media_index(&mut index); - index -} - -fn insert_parsed(index: &mut MediaIndex, key: &str, media: &MediaRef) { - let mut parts = key.splitn(2, ':'); - let chat = parts.next().and_then(|s| s.parse().ok()); - let message = parts.next().and_then(|s| s.parse().ok()); - if let (Some(chat), Some(message)) = (chat, message) { - index.insert((chat, message), media.clone()); - } -} - -pub struct FileSessionStore { - path: PathBuf, -} - -impl FileSessionStore { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - pub fn load(&self) -> Result, SessionError> { - let bytes = match fs::read(&self.path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(SessionError::Persistence(error.to_string())), - }; - if bytes.is_empty() { - return Err(SessionError::Persistence("empty session file".into())); - } - let key = crate::session_crypto::key_for(&self.path); - let encrypted = crate::session_crypto::is_encrypted(&bytes); - let bytes = if encrypted { - crate::session_crypto::decrypt( - &bytes, - key.as_deref() - .ok_or_else(|| SessionError::Persistence("session key unavailable".into()))?, - )? - } else { - zeroize::Zeroizing::new(bytes) - }; - if let Ok(session) = serde_json::from_slice::(&bytes) { - session.snapshot.validate()?; - if !encrypted && key.is_some() { - self.save(&session)?; - } - return Ok(Some(session)); - } - let snapshot: Snapshot = - serde_json::from_slice(&bytes).map_err(|e| SessionError::Persistence(e.to_string()))?; - snapshot.validate()?; - let session = ClientSession { - snapshot, - user_id: None, - peers: HashMap::new(), - updates: None, - media: HashMap::new(), - media_order: Vec::new(), - channel_pts: HashMap::new(), - channel_recovery: Default::default(), - seen_messages: HashSet::new(), - session_dead: false, - logout_tokens: Vec::new(), - new_session: None, - test_dc: false, - dc_auth_keys: HashMap::new(), - journal: Default::default(), - }; - if !encrypted && key.is_some() { - self.save(&session)?; - } - Ok(Some(session)) - } - - pub fn save(&self, session: &ClientSession) -> Result<(), SessionError> { - crate::tcp::while_client_open(|| self.save_open(session)) - .map_err(|_| SessionError::Persistence("client closed".into()))? - } - - pub(crate) fn save_open(&self, session: &ClientSession) -> Result<(), SessionError> { - session.snapshot.validate()?; - if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) { - fs::create_dir_all(parent).map_err(|e| SessionError::Persistence(e.to_string()))?; - } - let plaintext = { - let _span = crate::perf::span("session_save.encode"); - zeroize::Zeroizing::new( - serde_json::to_vec(session) - .map_err(|_| SessionError::Persistence("session encoding failed".into()))?, - ) - }; - let bytes = match crate::session_crypto::key_for(&self.path) { - Some(key) => { - let _span = crate::perf::span("session_save.encrypt"); - zeroize::Zeroizing::new(crate::session_crypto::encrypt(&plaintext, &key)?) - } - None => plaintext, - }; - // Each writer owns its temporary file. Never truncate another writer's - // snapshot, and flush its contents before the atomic replacement. - let mut nonce = [0_u8; 16]; - tellers_mtproto_crypto::fill_random(&mut nonce) - .map_err(|e| SessionError::Persistence(e.to_string()))?; - let suffix = u128::from_le_bytes(nonce); - let tmp = self.path.with_extension(format!("{suffix:032x}.tmp")); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&tmp) - .map_err(|e| SessionError::Persistence(e.to_string()))?; - let result = (|| { - { - let _span = crate::perf::span("session_save.write"); - file.write_all(&bytes)?; - } - { - let _span = crate::perf::span("session_save.fsync"); - file.sync_all()?; - } - drop(file); - fs::rename(&tmp, &self.path)?; - #[cfg(unix)] - { - let parent = self - .path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); - let _span = crate::perf::span("session_save.directory_sync"); - fs::File::open(parent)?.sync_all()?; - } - Ok::<_, std::io::Error>(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result.map_err(|e| SessionError::Persistence(e.to_string())) - } - - pub fn clear(&self) -> Result<(), SessionError> { - match fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), - Err(error) => Err(SessionError::Persistence(error.to_string())), - } - } -} - -#[cfg(test)] -#[path = "../tests/unit/session_file_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/stripped_jpeg.rs b/native/mtproto-rs/src/stripped_jpeg.rs deleted file mode 100644 index 165809175..000000000 --- a/native/mtproto-rs/src/stripped_jpeg.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Rebuild Telegram `photoStrippedSize` (`i`) into a JPEG. -//! https://core.telegram.org/constructor/photoStrippedSize - -const HEADER: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, - 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x28, 0x1C, 0x1E, 0x23, 0x1E, 0x19, 0x28, - 0x23, 0x21, 0x23, 0x2D, 0x2B, 0x28, 0x30, 0x3C, 0x64, 0x41, 0x3C, 0x37, 0x37, 0x3C, 0x7B, 0x58, - 0x5D, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8F, 0x80, 0x8C, 0x8A, 0xA0, 0xB4, 0xE6, 0xC3, 0xA0, - 0xAA, 0xDA, 0xAD, 0x8A, 0x8C, 0xC8, 0xFF, 0xCB, 0xDA, 0xEE, 0xF5, 0xFF, 0xFF, 0xFF, 0x9B, 0xC1, - 0xFF, 0xFF, 0xFF, 0xFA, 0xFF, 0xE6, 0xFD, 0xFF, 0xF8, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x2B, 0x2D, - 0x2D, 0x3C, 0x35, 0x3C, 0x76, 0x41, 0x41, 0x76, 0xF8, 0xA5, 0x8C, 0xA5, 0xF8, 0xF8, 0xF8, 0xF8, - 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, - 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, - 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xFF, 0xC0, - 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, - 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, - 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, - 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, - 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, - 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, - 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, - 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, - 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, - 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, - 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, - 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, - 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, - 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, - 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, - 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, - 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, - 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, - 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, - 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, - 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, - 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, - 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, - 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, - 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, - 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, - 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, -]; - -const FOOTER: &[u8] = &[0xFF, 0xD9]; - -/// `bytes[0] == 1` is the compact form: header + payload[3..] + JPEG EOI. -pub(crate) fn expand_stripped_jpeg(bytes: &[u8]) -> Option> { - if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 { - return Some(bytes.to_vec()); - } - if bytes.len() < 3 || bytes[0] != 1 { - return None; - } - let mut out = Vec::with_capacity(HEADER.len() + bytes.len() - 3 + FOOTER.len()); - out.extend_from_slice(HEADER); - out[164] = bytes[1]; - out[166] = bytes[2]; - out.extend_from_slice(&bytes[3..]); - out.extend_from_slice(FOOTER); - Some(out) -} - -#[cfg(test)] -#[path = "../tests/unit/stripped_jpeg_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/tcp.rs b/native/mtproto-rs/src/tcp.rs deleted file mode 100644 index 3b370138e..000000000 --- a/native/mtproto-rs/src/tcp.rs +++ /dev/null @@ -1,441 +0,0 @@ -use std::cell::RefCell; -use std::net::TcpStream; -use std::sync::Arc; -use std::time::Duration; - -use monogram_mtproto_transport::tcp as transport_tcp; -use tellers_mtproto_transport::{Connection, Error as TransportError}; - -pub(crate) fn set_proxy( - config: Option, -) -> Result<(), TransportError> { - transport_tcp::set_proxy(config) -} - -fn proxy() -> Option { - transport_tcp::config().proxy -} - -pub(crate) fn set_transport_mode(mode: monogram_mtproto_transport::TransportMode) { - transport_tcp::set_transport_mode(mode); -} -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) }; -} - -pub(crate) fn with_connection_control( - control: &Arc, - body: impl FnOnce() -> T, -) -> T { - struct Restore(Option>); - impl Drop for Restore { - fn drop(&mut self) { - crate::request_control::detach_sockets(); - CONTROL.with(|cell| cell.replace(self.0.take())); - } - } - let _restore = Restore(CONTROL.with(|cell| cell.replace(Some(control.clone())))); - 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()); - transport_tcp::wait_reconnect(remaining.min(Duration::from_millis(50)))?; - if remaining.is_zero() { - return Ok(()); - } - } -} - -pub(crate) fn is_waitable_io(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("timed out") - || lower.contains("would block") - || lower.contains("try again") - || lower.contains("interrupted") - || lower.contains("os error 4)") - || lower.contains("os error 10004)") - || lower.contains("eagain") - || lower.contains("os error 11") - || lower.contains("os error 35") - || lower.contains("os error 60") - || lower.contains("os error 10060") - || lower.contains("wsaetimedout") - || lower.contains("resource temporarily unavailable") -} - -fn check_open() -> Result<(), TransportError> { - wait_reconnect(Duration::ZERO) -} - -pub(crate) fn while_client_open(operation: impl FnOnce() -> T) -> Result { - 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 { - pub fn set_io_timeout(&mut self, secs: u64) { - self.set_io_timeout_ms(secs.saturating_mul(1000).max(1)); - } - - pub fn set_io_timeout_ms(&mut self, ms: u64) { - let timeout = Duration::from_millis(ms.max(50)); - self.read_timeout = timeout; - let _ = self.stream.set_read_timeout(Some(timeout)); - let _ = self.stream.set_write_timeout(Some(timeout)); - } - - pub fn connect_timeout_secs(addr: &str, connect_secs: u64) -> Result { - let (inner, stream) = open_registered_connection(addr, connect_secs)?; - Ok(Self { - inner, - stream, - read_timeout: Duration::from_secs(8), - }) - } -} - -impl Connection for TcpConnection { - fn send(&mut self, packet: &[u8]) -> Result<(), TransportError> { - check_open()?; - crate::request_control::register(&self.stream)?; - self.inner - .send(packet) - .map_err(|e| TransportError::Connection(e.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 self.inner.receive(output) { - Ok(result) => return Ok(result), - Err(error) - if std::time::Instant::now() < deadline - && is_waitable_io(&error.to_string()) => - { - continue; - } - Err(error) => return Err(TransportError::Connection(error.to_string())), - } - } - } - - fn close(&mut self) -> Result<(), TransportError> { - let _ = self.stream.shutdown(std::net::Shutdown::Both); - Ok(()) - } -} - -enum NativeTransportConnection { - Obfuscated(monogram_mtproto_transport::ObfuscatedConnection), - Http(monogram_mtproto_transport::TransportConnection), -} - -pub struct ObfuscatedTcp { - 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 read = self.read_timeout; - // Shutdown of a cloned socket can leave a Windows reader blocked. - #[cfg(windows)] - let read = read.min(Duration::from_millis(250)); - let _ = match &mut self.inner { - NativeTransportConnection::Obfuscated(connection) => connection.set_io_timeout_ms(ms), - NativeTransportConnection::Http(connection) => connection.set_io_timeout_ms(ms), - }; - let _ = match &mut self.inner { - NativeTransportConnection::Obfuscated(connection) => connection.set_read_timeout(read), - NativeTransportConnection::Http(connection) => connection.set_read_timeout(read), - }; - let _ = self.stream.set_read_timeout(Some(read)); - } -} - -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 - && is_waitable_io(&error.to_string()) => - { - 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())) - } -} - -pub fn connect_obfuscated(addr: &str) -> Result { - connect_obfuscated_timeout(addr, 5) -} - -pub fn connect_obfuscated_timeout( - addr: &str, - connect_secs: u64, -) -> Result { - connect_obfuscated_timeout_obf(addr, connect_secs, None, None) -} - -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 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() - ))); - } - } - } - let _ = obfuscated.close(); - Ok(started.elapsed().as_millis().max(1) as i64) -} - -pub fn connect_obfuscated_dc(addrs: &[&str]) -> Result { - let mut last_err = None; - for addr in addrs { - match connect_obfuscated(addr) { - Ok(conn) => return Ok(conn), - Err(err) => last_err = Some(err), - } - } - Err(last_err.unwrap_or_else(|| TransportError::Connection("no DC endpoints".into()))) -} -#[cfg(test)] -#[path = "../tests/unit/tcp_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/transfer_policy.rs b/native/mtproto-rs/src/transfer_policy.rs deleted file mode 100644 index 146ebe649..000000000 --- a/native/mtproto-rs/src/transfer_policy.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Per-client transfer limits. -//! https://core.telegram.org/api/files -//! https://core.telegram.org/api/optimisation - -use std::cell::RefCell; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock}; - -use crate::HashMapExt; -use parking_lot::RwLock; - -use crate::client::{DEFAULT_PIPELINE_PARTS, MAX_PIPELINE_PARTS}; -use crate::media::DEFAULT_CHUNK; -use crate::scheduler::{DEFAULT_MEDIA_LANES, MAX_MEDIA_LANES}; -use crate::upload::upload_rpc::{FILE_PART_FAST, FILE_PART_SMALL}; - -pub type ProgressCallback = Arc; - -pub(crate) struct TransferPolicy { - pub(crate) main_session_allowance: AtomicI32, - pub(crate) bound_extra_sessions: AtomicUsize, - media_lanes: AtomicUsize, - pub(crate) premium: AtomicBool, - pub(crate) upload_parts: parking_lot::Mutex<(i32, i32)>, - pipeline_parts: AtomicUsize, - chunk_size: AtomicI32, - file_part: AtomicUsize, - progress: RwLock>, - pub(crate) floods: parking_lot::Mutex, - pub(crate) queue_limits: parking_lot::Mutex<(usize, usize)>, - downloads: parking_lot::Mutex, - download_wake: parking_lot::Condvar, - pub(crate) upload_rtt: - parking_lot::Mutex>, - pub(crate) config_due: std::sync::atomic::AtomicU64, - media_lanes_owned: AtomicBool, - pipeline_owned: AtomicBool, - chunk_owned: AtomicBool, - file_part_owned: AtomicBool, - progress_owned: AtomicBool, -} - -impl TransferPolicy { - pub(crate) fn stock() -> Self { - Self { - main_session_allowance: AtomicI32::new(-1), - bound_extra_sessions: AtomicUsize::new(0), - media_lanes: AtomicUsize::new(DEFAULT_MEDIA_LANES), - premium: AtomicBool::new(false), - upload_parts: parking_lot::Mutex::new((4_000, 8_000)), - pipeline_parts: AtomicUsize::new(DEFAULT_PIPELINE_PARTS), - chunk_size: AtomicI32::new(DEFAULT_CHUNK), - file_part: AtomicUsize::new(FILE_PART_FAST), - progress: RwLock::new(None), - floods: parking_lot::Mutex::new(crate::media::FloodScope::new()), - queue_limits: parking_lot::Mutex::new((5, 2)), - downloads: parking_lot::Mutex::new(ActiveDownloads::default()), - download_wake: parking_lot::Condvar::new(), - upload_rtt: parking_lot::Mutex::new(None), - config_due: std::sync::atomic::AtomicU64::new(0), - media_lanes_owned: AtomicBool::new(false), - pipeline_owned: AtomicBool::new(false), - chunk_owned: AtomicBool::new(false), - file_part_owned: AtomicBool::new(false), - progress_owned: AtomicBool::new(false), - } - } - - pub(crate) fn upload_max_parts(&self) -> i32 { - let limits = *self.upload_parts.lock(); - if self.premium.load(Ordering::Relaxed) { - limits.1 - } else { - limits.0 - } - } - - pub(crate) fn media_lanes(&self) -> usize { - self.media_lanes - .load(Ordering::Relaxed) - .clamp(1, MAX_MEDIA_LANES) - } - - pub(crate) fn admit_download( - self: &Arc, - dc: i32, - large: bool, - ) -> Result { - let priority = crate::scheduler::current_class().priority(); - let mut active = self.downloads.lock(); - let ticket = active.next_ticket; - active.next_ticket = active.next_ticket.wrapping_add(1); - active.waiters.push(DownloadWaiter { - ticket, - priority, - dc, - large, - queued_at: std::time::Instant::now(), - }); - loop { - if let Err(error) = crate::request_control::check() { - active.waiters.retain(|waiter| waiter.ticket != ticket); - self.download_wake.notify_all(); - return Err(crate::MtprotoError::Message(error.to_string())); - } - let limits = *self.queue_limits.lock(); - let limit = if large { limits.1.min(2) } else { limits.0 }; - let count = active.counts.get(&(dc, large)).copied().unwrap_or(0); - let best = active - .waiters - .iter() - .filter(|waiter| waiter.dc == dc && waiter.large == large) - .min_by_key(|waiter| waiter.order_at(std::time::Instant::now())) - .map(|waiter| waiter.ticket); - let recovering = self.floods.lock().recovering_download(dc); - let dc_count: usize = active - .counts - .iter() - .filter(|((active_dc, _), _)| *active_dc == dc) - .map(|(_, count)| *count) - .sum(); - if count < limit.max(1) && (!recovering || dc_count == 0) && best == Some(ticket) { - *active.counts.entry((dc, large)).or_insert(0) += 1; - active.waiters.retain(|waiter| waiter.ticket != ticket); - return Ok(DownloadAdmission { - policy: self.clone(), - dc, - large, - }); - } - self.download_wake - .wait_for(&mut active, std::time::Duration::from_millis(50)); - } - } - - pub(crate) fn pipeline_parts(&self) -> usize { - self.pipeline_parts - .load(Ordering::Relaxed) - .clamp(1, MAX_PIPELINE_PARTS) - } - - pub(crate) fn chunk_size(&self) -> i32 { - let size = self.chunk_size.load(Ordering::Relaxed); - if valid_chunk(size) { - size - } else { - DEFAULT_CHUNK - } - } - - pub(crate) fn file_part(&self) -> usize { - self.file_part - .load(Ordering::Relaxed) - .clamp(FILE_PART_SMALL, FILE_PART_FAST) - } - - pub(crate) fn assign_media_lanes(&self, lanes: usize) { - self.media_lanes - .store(lanes.clamp(1, MAX_MEDIA_LANES), Ordering::Relaxed); - } - - pub(crate) fn assign_pipeline_parts(&self, parts: usize) { - self.pipeline_parts - .store(parts.clamp(1, MAX_PIPELINE_PARTS), Ordering::Relaxed); - } - - pub(crate) fn assign_chunk_size(&self, size: i32) { - self.chunk_size - .store(normalize_chunk(size), Ordering::Relaxed); - } - - pub(crate) fn assign_file_part_kib(&self, kib: i32) { - self.file_part - .store(normalize_file_part_kib(kib), Ordering::Relaxed); - } - - pub(crate) fn assign_progress(&self, callback: Option) { - *self.progress.write() = callback; - } - - pub(crate) fn follow_media_lanes(&self, lanes: usize) { - if !self.media_lanes_owned.load(Ordering::Relaxed) { - self.assign_media_lanes(lanes); - } - } - - pub(crate) fn follow_pipeline_parts(&self, parts: usize) { - if !self.pipeline_owned.load(Ordering::Relaxed) { - self.assign_pipeline_parts(parts); - } - } - - pub(crate) fn follow_chunk_size(&self, size: i32) { - if !self.chunk_owned.load(Ordering::Relaxed) { - self.assign_chunk_size(size); - } - } - - pub(crate) fn follow_file_part_kib(&self, kib: i32) { - if !self.file_part_owned.load(Ordering::Relaxed) { - self.assign_file_part_kib(kib); - } - } - - pub(crate) fn follow_progress(&self, callback: &Option) { - if !self.progress_owned.load(Ordering::Relaxed) { - self.assign_progress(callback.clone()); - } - } - - pub(crate) fn own_media_lanes(&self, lanes: usize) { - self.media_lanes_owned.store(true, Ordering::Relaxed); - self.assign_media_lanes(lanes); - } - - pub(crate) fn own_pipeline_parts(&self, parts: usize) { - self.pipeline_owned.store(true, Ordering::Relaxed); - self.assign_pipeline_parts(parts); - } - - pub(crate) fn own_chunk_size(&self, size: i32) { - self.chunk_owned.store(true, Ordering::Relaxed); - self.assign_chunk_size(size); - } - - pub(crate) fn is_chunk_owned(&self) -> bool { - self.chunk_owned.load(Ordering::Relaxed) - } - - pub(crate) fn own_file_part_kib(&self, kib: i32) { - self.file_part_owned.store(true, Ordering::Relaxed); - self.assign_file_part_kib(kib); - } - - pub(crate) fn own_progress(&self, callback: Option) { - self.progress_owned.store(true, Ordering::Relaxed); - self.assign_progress(callback); - } - - pub(crate) fn notify(&self, path: &str, downloaded: i64, total: i64) { - if let Some(callback) = self.progress.read().as_ref() { - callback(path, downloaded, total); - } - } -} - -pub(crate) struct DownloadAdmission { - policy: Arc, - dc: i32, - large: bool, -} - -impl Drop for DownloadAdmission { - fn drop(&mut self) { - let mut active = self.policy.downloads.lock(); - if let Some(count) = active.counts.get_mut(&(self.dc, self.large)) { - *count = count.saturating_sub(1); - } - self.policy.download_wake.notify_all(); - } -} - -#[derive(Default)] -struct ActiveDownloads { - counts: crate::HashMap<(i32, bool), usize>, - waiters: Vec, - next_ticket: u64, -} - -struct DownloadWaiter { - ticket: u64, - priority: u8, - dc: i32, - large: bool, - queued_at: std::time::Instant, -} - -impl DownloadWaiter { - fn order_at(&self, now: std::time::Instant) -> (u8, u64) { - let priority = if self.priority > 2 { - let age = (now.saturating_duration_since(self.queued_at).as_secs() / 5).min(255) as u8; - self.priority.saturating_sub(age).max(1) - } else { - self.priority - }; - (priority, self.ticket) - } -} - -#[cfg(test)] -#[path = "../tests/unit/transfer_policy_aging_tests.rs"] -mod tests; - -fn valid_chunk(size: i32) -> bool { - matches!(size, 131_072 | 262_144 | 524_288) -} - -pub(crate) fn normalize_chunk(size: i32) -> i32 { - if valid_chunk(size) { - size - } else { - DEFAULT_CHUNK - } -} - -pub(crate) fn normalize_file_part_kib(kib: i32) -> usize { - if kib >= 512 { - FILE_PART_FAST - } else { - FILE_PART_SMALL - } -} - -pub(crate) fn default_policy() -> Arc { - static POLICY: OnceLock> = OnceLock::new(); - POLICY - .get_or_init(|| Arc::new(TransferPolicy::stock())) - .clone() -} - -pub(crate) fn snapshot() -> Arc { - let defaults = default_policy(); - let policy = TransferPolicy::stock(); - policy.assign_media_lanes(defaults.media_lanes()); - policy.assign_pipeline_parts(defaults.pipeline_parts()); - policy.assign_chunk_size(defaults.chunk_size()); - policy - .file_part - .store(defaults.file_part(), Ordering::Relaxed); - policy.assign_progress(defaults.progress.read().clone()); - Arc::new(policy) -} - -thread_local! { - static BOUND: RefCell>> = RefCell::new(None); -} - -pub(crate) struct PolicyGuard { - previous: Option>, -} - -impl Drop for PolicyGuard { - fn drop(&mut self) { - let previous = self.previous.take(); - BOUND.with(|slot| *slot.borrow_mut() = previous); - } -} - -pub(crate) fn bind(policy: Arc) -> PolicyGuard { - BOUND.with(|slot| PolicyGuard { - previous: slot.borrow_mut().replace(policy), - }) -} - -pub(crate) fn current() -> Arc { - BOUND - .with(|slot| slot.borrow().clone()) - .unwrap_or_else(default_policy) -} diff --git a/native/mtproto-rs/src/update_buffer.rs b/native/mtproto-rs/src/update_buffer.rs deleted file mode 100644 index 57b62ba38..000000000 --- a/native/mtproto-rs/src/update_buffer.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Bounded reordering window. A restart recovers from the persisted cursor. -use std::time::{Duration, Instant}; - -#[derive(Default)] -pub(crate) struct PushBuffer { - packets: Vec>, - bytes: usize, - gap_since: Option, - overflow: bool, -} - -impl PushBuffer { - pub(crate) fn is_empty(&self) -> bool { - self.packets.is_empty() && !self.overflow - } - - pub(crate) fn append(&mut self, packets: Vec>) { - for packet in packets { - if self.packets.len() >= 256 - || self.bytes.saturating_add(packet.len()) > 8 * 1024 * 1024 - { - self.overflow = true; - break; - } - self.bytes += packet.len(); - self.packets.push(packet); - } - } - - /// `apply` returns false only for a gap; success includes discarded duplicates. - /// Revisit blocked packets after any progress, preserving arrival order among - /// independent streams. Returns true when authoritative recovery is required. - pub(crate) fn resolve( - &mut self, - now: Instant, - mut apply: impl FnMut(&[u8]) -> Result, - ) -> Result { - if self.overflow { - *self = Self::default(); - return Ok(true); - } - loop { - let mut progress = false; - let mut index = 0; - while index < self.packets.len() { - if apply(&self.packets[index])? { - self.bytes -= self.packets.remove(index).len(); - progress = true; - } else { - index += 1; - } - } - if !progress { - break; - } - } - if self.packets.is_empty() { - self.gap_since = None; - return Ok(false); - } - let started = self.gap_since.get_or_insert(now); - if now.saturating_duration_since(*started) >= Duration::from_millis(500) { - *self = Self::default(); - return Ok(true); - } - Ok(false) - } -} - -#[cfg(test)] -#[path = "../tests/unit/update_buffer_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/update_journal.rs b/native/mtproto-rs/src/update_journal.rs deleted file mode 100644 index 4f6ade04c..000000000 --- a/native/mtproto-rs/src/update_journal.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Replayable journal of update batches. A batch lives in the encrypted session -//! file next to the cursor it advanced, and stays there until the application -//! acknowledges that it committed the batch. -use std::collections::VecDeque; - -use serde::{Deserialize, Serialize}; - -use crate::{UpdateEventDto, UpdatesStateDto}; - -/// Admission closes at either bound; recovery work stays in the journal. -pub(crate) const MAX_UNACKED_BATCHES: usize = 256; -pub(crate) const MAX_UNACKED_BYTES: usize = 8 * 1024 * 1024; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub(crate) struct DurableBatch { - pub batch_id: i64, - pub events: Vec, - pub cursor_after: Option, - pub channel_pts_after: Vec<(i64, i32)>, - #[serde(default)] - pub approx_bytes: usize, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub(crate) struct UpdateJournal { - /// Random per account so a recreated session file cannot collide with receipts - /// the application stored for an earlier journal. - pub account_generation: i64, - pub next_batch_id: i64, - pub batches: VecDeque, - /// Leading batches already handed to the application in this process. - #[serde(skip)] - delivered: usize, -} - -fn random_generation() -> i64 { - let mut bytes = [0_u8; 8]; - if tellers_mtproto_crypto::fill_random(&mut bytes).is_err() { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as u64; - bytes = nanos.to_le_bytes(); - } - let value = (u64::from_le_bytes(bytes) >> 1) as i64; - value.max(1) -} - -impl Default for UpdateJournal { - fn default() -> Self { - Self { - account_generation: random_generation(), - next_batch_id: 1, - batches: VecDeque::new(), - delivered: 0, - } - } -} - -/// Typing, presence and sync edges are recomputed from live traffic; everything -/// that mutates stored application state must be replayable. -pub(crate) fn is_durable(event: &UpdateEventDto) -> bool { - !matches!( - event, - UpdateEventDto::PeerTyping { .. } - | UpdateEventDto::PeerStatus { .. } - | UpdateEventDto::PeerEmojiStatus { .. } - | UpdateEventDto::SyncState { .. } - | UpdateEventDto::SavedGifsChanged - ) && !matches!(event, UpdateEventDto::Ignored { kind } if !is_durable_ignored(kind)) -} - -/// `Ignored` carries a few state mutations encoded in `kind`; the rest are noise. -fn is_durable_ignored(kind: &str) -> bool { - [ - "dialog_unread_mark:", - "notify_settings:", - "unread_mentions_delta:", - "unread_reactions_delta:", - ] - .iter() - .any(|prefix| kind.starts_with(prefix)) -} - -impl UpdateJournal { - /// Drops every batch: they belong to an account that is gone. - pub(crate) fn header_only(&self) -> Self { - Self { - account_generation: self.account_generation, - next_batch_id: self.next_batch_id, - batches: VecDeque::new(), - delivered: 0, - } - } - - pub(crate) fn reset_account(&mut self) { - let previous = self.account_generation; - *self = Self::default(); - if self.account_generation == previous { - self.account_generation = previous.wrapping_add(1).max(1); - } - } - - fn unacked_bytes(&self) -> usize { - self.batches.iter().map(|b| b.approx_bytes).sum() - } - - pub(crate) fn admission_open(&self) -> bool { - self.batches.len() < MAX_UNACKED_BATCHES && self.unacked_bytes() < MAX_UNACKED_BYTES - } - - pub(crate) fn has_undelivered(&self) -> bool { - self.delivered < self.batches.len() - } - - pub(crate) fn push( - &mut self, - events: Vec, - cursor_after: Option, - channel_pts_after: Vec<(i64, i32)>, - ) -> DurableBatch { - let approx_bytes = serde_json::to_vec(&events).map(|v| v.len()).unwrap_or(0); - let batch = DurableBatch { - batch_id: self.next_batch_id, - events, - cursor_after, - channel_pts_after, - approx_bytes, - }; - self.next_batch_id = self.next_batch_id.saturating_add(1); - self.batches.push_back(batch.clone()); - batch - } - - pub(crate) fn mark_delivered(&mut self, batch_id: i64) { - if let Some(index) = self.batches.iter().position(|b| b.batch_id == batch_id) { - self.delivered = self.delivered.max(index + 1); - } - } - - pub(crate) fn mark_all_delivered(&mut self) { - self.delivered = self.batches.len(); - } - - pub(crate) fn pending(&self) -> Vec { - self.batches.iter().cloned().collect() - } - - /// Returns true when a batch was removed. - pub(crate) fn acknowledge(&mut self, account_generation: i64, batch_id: i64) -> bool { - if account_generation != self.account_generation { - return false; - } - let Some(index) = self.batches.iter().position(|b| b.batch_id == batch_id) else { - return false; - }; - self.batches.remove(index); - if index < self.delivered { - self.delivered -= 1; - } - true - } -} - -#[cfg(test)] -#[path = "../tests/unit/update_journal_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/src/updates/mod.rs b/native/mtproto-rs/src/updates/mod.rs deleted file mode 100644 index ac680fb2f..000000000 --- a/native/mtproto-rs/src/updates/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) mod updates_rpc; diff --git a/native/mtproto-rs/src/updates/updates_rpc.rs b/native/mtproto-rs/src/updates/updates_rpc.rs deleted file mode 100644 index a561e038e..000000000 --- a/native/mtproto-rs/src/updates/updates_rpc.rs +++ /dev/null @@ -1,864 +0,0 @@ -//! Live updates dispatching, push verification, and difference recovery engine. -//! https://core.telegram.org/api/updates -//! https://core.telegram.org/method/updates.getDifference -//! https://core.telegram.org/method/updates.getChannelDifference - -use crate::HashMap; - -use tellers_mtproto::latest::api::{ - ChannelMessagesFilter, ChannelMessagesFilterEmptyConstructor, Dialog, DialogPeer, InputChannel, - InputChannelConstructor, Message, Peer, Update, UpdatesChannelDifference, UpdatesDifference, - UpdatesGetChannelDifferenceRequest, UpdatesGetDifferenceRequest, UpdatesGetStateRequest, - UpdatesState, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::dialogs::{cache_from_users_chats, message_to_dto}; -use crate::emoji_status::emoji_status_document_id; -use crate::media::MediaIndex; -use crate::peers::{ - self, CachedPeer, PeerKind, channel_id_from_chat_id, chat_id_for_channel, chat_id_for_chat, - chat_id_for_user, peer_chat_id, vector_boxed_items, vector_items, -}; -use crate::presence::{action_kind, user_status_parts}; -use crate::{MtprotoError, UpdateEventDto, UpdatesStateDto}; - -fn push_gap() -> MtprotoError { - MtprotoError::Message("updates gap".into()) -} - -/// Message-box continuity is independent of the outer Updates sequence. -/// https://core.telegram.org/api/updates#pts-checking-and-applying -pub(crate) fn advance_push_counter( - local: &mut i32, - remote: i32, - count: i32, -) -> Result { - if *local < 0 || remote < 0 || count < 0 { - return Err(push_gap()); - } - let expected = local.checked_add(count).ok_or_else(push_gap)?; - if expected < remote { - return Err(push_gap()); - } - if expected > remote { - return Ok(false); - } - *local = remote; - Ok(true) -} - -fn push_channel(message: &Message) -> Result { - let peer = match message { - Message::Message(m) => &m.peer_id, - Message::MessageService(m) => &m.peer_id, - _ => return Err(push_gap()), - }; - match peer.as_ref() { - Peer::PeerChannel(p) => Ok(chat_id_for_channel(p.channel_id)), - _ => Err(push_gap()), - } -} - -pub(crate) fn advance_push_update( - update: &Update, - cursor: &mut UpdatesStateDto, - channels: &mut HashMap, - apply_unsequenced: bool, - pending_channels: &mut Vec, -) -> Result { - let common = match update { - Update::UpdateNewMessage(u) => Some((u.pts, u.pts_count)), - Update::UpdateEditMessage(u) => Some((u.pts, u.pts_count)), - Update::UpdateDeleteMessages(u) => Some((u.pts, u.pts_count)), - Update::UpdateReadHistoryInbox(u) => Some((u.pts, u.pts_count)), - Update::UpdateReadHistoryOutbox(u) => Some((u.pts, u.pts_count)), - Update::UpdateReadMessagesContents(u) => Some((u.pts, u.pts_count)), - Update::UpdateWebPage(u) => Some((u.pts, u.pts_count)), - Update::UpdateFolderPeers(u) => Some((u.pts, u.pts_count)), - Update::UpdatePinnedMessages(u) => Some((u.pts, u.pts_count)), - _ => None, - }; - if let Some((pts, count)) = common { - return advance_push_counter(&mut cursor.pts, pts, count); - } - let channel = match update { - Update::UpdateNewChannelMessage(u) => Some((push_channel(&u.message)?, u.pts, u.pts_count)), - Update::UpdateEditChannelMessage(u) => { - Some((push_channel(&u.message)?, u.pts, u.pts_count)) - } - Update::UpdateDeleteChannelMessages(u) => { - Some((chat_id_for_channel(u.channel_id), u.pts, u.pts_count)) - } - Update::UpdateChannelWebPage(u) => { - Some((chat_id_for_channel(u.channel_id), u.pts, u.pts_count)) - } - Update::UpdatePinnedChannelMessages(u) => { - Some((chat_id_for_channel(u.channel_id), u.pts, u.pts_count)) - } - Update::UpdateReadChannelInbox(u) => Some((chat_id_for_channel(u.channel_id), u.pts, 0)), - _ => None, - }; - if let Some((id, pts, count)) = channel { - let result = channels - .get_mut(&id) - .ok_or_else(push_gap) - .and_then(|local| advance_push_counter(local, pts, count)); - if result.is_err() && !pending_channels.contains(&id) { - pending_channels.push(id); - } - // Channel gaps are independent of the common pts/seq sequence. - return Ok(result.unwrap_or(false)); - } - let qts = match update { - Update::UpdateNewEncryptedMessage(u) => Some(u.qts), - Update::UpdateMessagePollVote(u) => Some(u.qts), - Update::UpdateChatParticipant(u) => Some(u.qts), - Update::UpdateChannelParticipant(u) => Some(u.qts), - Update::UpdateBotStopped(u) => Some(u.qts), - Update::UpdateBotChatInviteRequester(u) => Some(u.qts), - Update::UpdateBotChatBoost(u) => Some(u.qts), - Update::UpdateBotMessageReaction(u) => Some(u.qts), - Update::UpdateBotMessageReactions(u) => Some(u.qts), - Update::UpdateBotBusinessConnect(u) => Some(u.qts), - Update::UpdateBotNewBusinessMessage(u) => Some(u.qts), - Update::UpdateBotEditBusinessMessage(u) => Some(u.qts), - Update::UpdateBotDeleteBusinessMessage(u) => Some(u.qts), - Update::UpdateBotPurchasedPaidMedia(u) => Some(u.qts), - Update::UpdateManagedBot(u) => Some(u.qts), - Update::UpdateBotGuestChatQuery(u) => Some(u.qts), - Update::UpdateBotStarsSubscription(u) => Some(u.qts), - _ => None, - }; - if let Some(qts) = qts { - return advance_push_counter(&mut cursor.qts, qts, 1); - } - // Keep this explicit: a new TL variant may add sequence metadata, and must - // reach authoritative difference recovery until its contract is reviewed. - match update { - Update::UpdatePtsChanged(_) => Err(push_gap()), - Update::UpdateChannelTooLong(_) - | Update::UpdateMessageId(_) - | Update::UpdateReadChannelDiscussionOutbox(_) - | Update::UpdateRecentStickers(_) - | Update::UpdateFavedStickers(_) - | Update::UpdateReadFeaturedStickers(_) - | Update::UpdateReadFeaturedEmojiStickers(_) - | Update::UpdateRecentEmojiStatuses(_) - | Update::UpdateRecentReactions(_) - | Update::UpdateConfig(_) - | Update::UpdateDcOptions(_) - | Update::UpdateContactsReset(_) - | Update::UpdateDraftMessage(_) - | Update::UpdateNewScheduledMessage(_) - | Update::UpdateDeleteScheduledMessages(_) - | Update::UpdateAutoSaveSettings(_) - | Update::UpdateDialogFilter(_) - | Update::UpdateDialogFilterOrder(_) - | Update::UpdateDialogFilters(_) - | Update::UpdateDialogPinned(_) - | Update::UpdatePinnedDialogs(_) - | Update::UpdateChannel(_) - | Update::UpdateChat(_) - | Update::UpdateUser(_) - | Update::UpdateUserName(_) - | Update::UpdateUserPhone(_) - | Update::UpdateChatParticipants(_) - | Update::UpdateDialogUnreadMark(_) - | Update::UpdatePeerSettings(_) - | Update::UpdatePeerBlocked(_) - | Update::UpdateNotifySettings(_) - | Update::UpdateSavedGifs(_) - | Update::UpdateChannelReadMessagesContents(_) - | Update::UpdateReadChannelDiscussionInbox(_) - | Update::UpdateMessagePoll(_) - | Update::UpdateMessageReactions(_) - | Update::UpdateUserTyping(_) - | Update::UpdateChatUserTyping(_) - | Update::UpdateChannelUserTyping(_) - | Update::UpdateUserEmojiStatus(_) - | Update::UpdateUserStatus(_) - | Update::UpdateReadChannelOutbox(_) => Ok(apply_unsequenced), - _ => Err(push_gap()), - } -} - -/// Validates all sequence transitions before mutating caches or emitting events. -/// Unsupported compact forms recover through difference, preserving peer metadata. -pub(crate) fn apply_push( - bytes: &[u8], - peers: &mut HashMap, - media: &mut MediaIndex, - cursor: &mut UpdatesStateDto, - channel_pts: &mut HashMap, - pending_channels: &mut Vec, -) -> Result, MtprotoError> { - use tellers_mtproto::codec::{BoxedDecode, Decoder, Limits}; - use tellers_mtproto::latest::api::Updates; - if bytes.len() > 8 * 1024 * 1024 { - return Err(push_gap()); - } - let limits = Limits { - max_bytes: 8 * 1024 * 1024, - max_vector_len: 4096, - max_depth: 32, - max_total_bytes: 8 * 1024 * 1024, - }; - let mut decoder = Decoder::new(bytes, limits).map_err(|_| push_gap())?; - let invalid = - || MtprotoError::Message("invalid updates constructor; reconnect required".into()); - let decoded = Updates::decode_boxed(&mut decoder).map_err(|_| invalid())?; - decoder.finish().map_err(|_| invalid())?; - let (updates, users, chats, date, start, end) = match &decoded { - Updates::Updates(u) => ( - vector_boxed_items(&u.updates).collect::>(), - Some(&u.users), - Some(&u.chats), - u.date, - u.seq, - u.seq, - ), - Updates::UpdatesCombined(u) => ( - vector_boxed_items(&u.updates).collect::>(), - Some(&u.users), - Some(&u.chats), - u.date, - u.seq_start, - u.seq, - ), - Updates::UpdateShort(u) => (vec![u.update.as_ref()], None, None, u.date, 0, 0), - _ => return Err(push_gap()), - }; - if start < 0 || end < start || date < 0 || updates.len() > 4096 { - return Err(push_gap()); - } - let mut next = cursor.clone(); - let mut next_channels = channel_pts.clone(); - let apply_unsequenced = if start == 0 { - if end != 0 { - return Err(push_gap()); - } - true - } else if cursor.seq.checked_add(1).ok_or_else(push_gap)? == start { - next.seq = end; - true - } else if cursor.seq >= start { - false - } else { - return Err(push_gap()); - }; - let mut accepted = Vec::with_capacity(updates.len()); - for update in updates { - if advance_push_update( - update, - &mut next, - &mut next_channels, - apply_unsequenced, - pending_channels, - )? { - accepted.push(update); - } - } - if apply_unsequenced || !accepted.is_empty() { - next.date = next.date.max(date); - } - if let (Some(users), Some(chats)) = (users, chats) { - cache_from_users_chats( - peers, - media, - vector_boxed_items(users).cloned(), - vector_boxed_items(chats).cloned(), - ); - } - let mut events = Vec::new(); - collect_other_updates(accepted.into_iter(), media, pending_channels, &mut events); - *cursor = next; - *channel_pts = next_channels; - Ok(events) -} - -pub fn get_updates_state( - snapshot: &mut Snapshot, - api_id: i32, -) -> Result { - let state: UpdatesState = - api_invoke::invoke_api_allow_updates(snapshot, api_id, UpdatesGetStateRequest {})?; - let UpdatesState::UpdatesState(s) = state else { - return Err(MtprotoError::Message("unexpected updates.state".into())); - }; - Ok(UpdatesStateDto { - pts: s.pts, - qts: s.qts, - date: s.date, - seq: s.seq, - }) -} - -fn update_is_silent(update: &Update) -> bool { - matches!( - update, - Update::UpdateReadChannelDiscussionOutbox(_) - | Update::UpdateRecentStickers(_) - | Update::UpdateFavedStickers(_) - | Update::UpdateReadFeaturedStickers(_) - | Update::UpdateReadFeaturedEmojiStickers(_) - | Update::UpdateRecentEmojiStatuses(_) - | Update::UpdateRecentReactions(_) - | Update::UpdateConfig(_) - | Update::UpdatePtsChanged(_) - | Update::UpdateDcOptions(_) - | Update::UpdateContactsReset(_) - | Update::UpdateDraftMessage(_) - | Update::UpdateNewScheduledMessage(_) - | Update::UpdateDeleteScheduledMessages(_) - | Update::UpdateWebPage(_) - | Update::UpdateChannelWebPage(_) - | Update::UpdateAutoSaveSettings(_) - ) -} - -fn message_mentioned(msg: &Message) -> bool { - match msg { - Message::Message(m) => m.mentioned.is_some() && m.out.is_none(), - Message::MessageService(m) => m.mentioned.is_some() && m.out.is_none(), - _ => false, - } -} - -fn push_mentioned(events: &mut Vec, msg: &Message) { - if !message_mentioned(msg) { - return; - } - let chat_id = match msg { - Message::Message(m) => peer_chat_id(&m.peer_id), - Message::MessageService(m) => peer_chat_id(&m.peer_id), - _ => return, - }; - events.push(UpdateEventDto::Ignored { - kind: format!("unread_mentions_delta:{chat_id}:1"), - }); -} - -fn push_peer_typing( - events: &mut Vec, - chat_id: i64, - user_id: i64, - action: &tellers_mtproto::latest::api::SendMessageAction, -) { - let kind = action_kind(action); - events.push(UpdateEventDto::PeerTyping { - chat_id, - user_id, - typing: crate::presence::action_is_active(action), - action: kind.unwrap_or("").to_string(), - }); -} - -pub(crate) fn collect_other_updates<'a>( - updates: impl Iterator, - media_index: &mut MediaIndex, - pending_channels: &mut Vec, - events: &mut Vec, -) { - let mut folders_changed = false; - let mut chats_changed = false; - for update in updates { - match update { - Update::UpdateNewScheduledMessage(update) => { - match crate::client::scheduled::map_update(&update.message, media_index) { - Ok(message) => events.push(UpdateEventDto::ScheduledMessageChanged { message }), - Err(_) => events.push(UpdateEventDto::Ignored { - kind: "unsupported scheduled message".into(), - }), - } - } - Update::UpdateDeleteScheduledMessages(update) => { - let chat_id = peer_chat_id(&update.peer); - let message_ids = vector_items(&update.messages).to_vec(); - for id in &message_ids { - if let Ok(media_id) = crate::media::scheduled_media_id(*id) { - media_index.shift_remove(&(chat_id, media_id)); - } - } - events.push(UpdateEventDto::ScheduledMessagesDeleted { - chat_id, - message_ids, - sent_message_ids: update - .sent_messages - .as_deref() - .map(|ids| vector_items(ids).to_vec()), - }); - } - Update::UpdateDraftMessage(update) => { - events.push(UpdateEventDto::DraftChanged { - draft: crate::client::drafts::draft_from_update(update, 0), - }); - } - Update::UpdateNewMessage(u) => { - push_mentioned(events, &u.message); - if let Some(dto) = message_to_dto(&u.message, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - } - } - Update::UpdateNewChannelMessage(u) => { - push_mentioned(events, &u.message); - if let Some(dto) = message_to_dto(&u.message, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - } - } - Update::UpdateEditMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - events.push(UpdateEventDto::MessageEdited { message: dto }); - } - } - Update::UpdateEditChannelMessage(u) => { - if let Some(dto) = message_to_dto(&u.message, media_index) { - events.push(UpdateEventDto::MessageEdited { message: dto }); - } - } - Update::UpdateDeleteMessages(u) => { - events.push(UpdateEventDto::MessagesDeleted { - chat_id: None, - message_ids: vector_items(&u.messages).to_vec(), - }); - } - Update::UpdateDeleteChannelMessages(u) => { - events.push(UpdateEventDto::MessagesDeleted { - chat_id: Some(chat_id_for_channel(u.channel_id)), - message_ids: vector_items(&u.messages).to_vec(), - }); - } - Update::UpdateChannelTooLong(u) => { - pending_channels.push(chat_id_for_channel(u.channel_id)); - } - Update::UpdateFolderPeers(_) - | Update::UpdateDialogFilter(_) - | Update::UpdateDialogFilterOrder(_) - | Update::UpdateDialogFilters(_) => folders_changed = true, - Update::UpdateDialogPinned(_) - | Update::UpdatePinnedDialogs(_) - | Update::UpdateChannel(_) - | Update::UpdateChat(_) - | Update::UpdateUser(_) - | Update::UpdateUserName(_) - | Update::UpdateUserPhone(_) - | Update::UpdateChatParticipants(_) - | Update::UpdatePeerSettings(_) - | Update::UpdatePeerBlocked(_) => chats_changed = true, - Update::UpdateDialogUnreadMark(u) => { - if let DialogPeer::DialogPeer(peer) = u.peer.as_ref() { - events.push(UpdateEventDto::Ignored { - kind: format!( - "dialog_unread_mark:{}:{}", - peer_chat_id(peer.peer.as_ref()), - u.unread.is_some() as u8 - ), - }); - } - } - Update::UpdateNotifySettings(_) => { - if let Some(ex) = crate::client::push_rpc::exception_from_update(update) { - events.push(UpdateEventDto::Ignored { - kind: format!( - "notify_settings:{}:{}:{}:{}:{}", - ex.peer_kind, - ex.chat_id, - ex.mute_until, - ex.topic_id.unwrap_or(0), - crate::client::push_rpc::update_mute_specified(update) as u8 - ), - }); - } - } - Update::UpdatePinnedMessages(_) | Update::UpdatePinnedChannelMessages(_) => { - chats_changed = true - } - Update::UpdateSavedGifs(_) => { - events.push(crate::UpdateEventDto::SavedGifsChanged); - } - Update::UpdateReadChannelDiscussionInbox(u) => { - events.push(crate::UpdateEventDto::DiscussionInbox { - channel_id: crate::peers::chat_id_for_channel(u.channel_id), - top_message_id: u.top_msg_id, - read_max_id: u.read_max_id, - }); - chats_changed = true; - } - Update::UpdateMessagePoll(u) => { - events.push(crate::UpdateEventDto::PollChanged { poll_id: u.poll_id }); - } - Update::UpdateMessageReactions(u) => { - // This is a message snapshot, not a dialog-counter delta. It can repeat an - // unread reaction or remove the last one, so reconcile absolute dialog counts. - chats_changed = true; - events.push(crate::UpdateEventDto::MessageReactions { - chat_id: peer_chat_id(&u.peer), - message_id: u.msg_id, - reactions_json: crate::client::extras_rpc::reaction_update_json( - u.reactions.as_ref(), - ), - }); - } - u if update_is_silent(u) => {} - Update::UpdateUserTyping(u) => { - push_peer_typing(events, chat_id_for_user(u.user_id), u.user_id, &u.action); - } - Update::UpdateChatUserTyping(u) => { - push_peer_typing( - events, - chat_id_for_chat(u.chat_id), - peer_chat_id(&u.from_id), - &u.action, - ); - } - Update::UpdateChannelUserTyping(u) => { - push_peer_typing( - events, - chat_id_for_channel(u.channel_id), - peer_chat_id(&u.from_id), - &u.action, - ); - } - Update::UpdateUserEmojiStatus(u) => { - events.push(UpdateEventDto::PeerEmojiStatus { - user_id: u.user_id, - document_id: emoji_status_document_id(Some(u.emoji_status.as_ref())), - }); - } - Update::UpdateUserStatus(u) => { - let (status, status_at) = user_status_parts(Some(u.status.as_ref())); - events.push(UpdateEventDto::PeerStatus { - user_id: u.user_id, - status, - status_at, - }); - } - Update::UpdateReadHistoryInbox(u) => { - events.push(UpdateEventDto::ReadInbox { - chat_id: peer_chat_id(&u.peer), - max_id: u.max_id, - still_unread: u.still_unread_count, - }); - } - // These carry message ids but no absolute dialog-counter delta. Re-read dialogs so - // reading mentions/reactions on another device updates their absolute counters. - Update::UpdateReadMessagesContents(_) - | Update::UpdateChannelReadMessagesContents(_) => { - chats_changed = true; - } - Update::UpdateReadChannelInbox(u) => { - events.push(UpdateEventDto::ReadInbox { - chat_id: chat_id_for_channel(u.channel_id), - max_id: u.max_id, - still_unread: u.still_unread_count, - }); - } - Update::UpdateReadHistoryOutbox(u) => { - events.push(UpdateEventDto::ReadOutbox { - chat_id: peer_chat_id(&u.peer), - max_id: u.max_id, - }); - } - Update::UpdateReadChannelOutbox(u) => { - events.push(UpdateEventDto::ReadOutbox { - chat_id: chat_id_for_channel(u.channel_id), - max_id: u.max_id, - }); - } - Update::UpdateMessageId(u) => { - events.push(UpdateEventDto::Ignored { - kind: format!("UpdateMessageId:{}:{}", u.random_id, u.id), - }); - } - other => { - let kind = format!("{other:?}"); - let kind = kind.split('(').next().unwrap_or("Update").to_string(); - eprintln!("monogram.updates ignored {kind}"); - events.push(UpdateEventDto::Ignored { kind }); - } - } - } - if folders_changed { - events.push(UpdateEventDto::FoldersChanged); - } - if chats_changed { - events.push(UpdateEventDto::ChatsChanged); - } -} - -pub fn drain_difference( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - cursor: &mut UpdatesStateDto, - pending_channels: &mut Vec, -) -> Result<(Vec, bool), MtprotoError> { - let request = UpdatesGetDifferenceRequest { - flags: 0, - pts: cursor.pts, - pts_limit: None, - pts_total_limit: None, - date: cursor.date, - qts: cursor.qts, - qts_limit: None, - }; - let response: UpdatesDifference = - api_invoke::invoke_api_allow_updates(snapshot, api_id, request)?; - let has_more = matches!(&response, UpdatesDifference::UpdatesDifferenceSlice(_)); - let mut events = Vec::new(); - match response { - UpdatesDifference::UpdatesDifferenceEmpty(e) => { - cursor.date = e.date; - cursor.seq = e.seq; - } - UpdatesDifference::UpdatesDifference(diff) => { - cache_from_users_chats( - peers, - media_index, - vector_boxed_items(&diff.users).cloned(), - vector_boxed_items(&diff.chats).cloned(), - ); - for msg in vector_boxed_items(&diff.new_messages) { - if let Some(dto) = message_to_dto(msg, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - } - } - collect_other_updates( - vector_boxed_items(&diff.other_updates), - media_index, - pending_channels, - &mut events, - ); - if let UpdatesState::UpdatesState(s) = *diff.state { - cursor.pts = s.pts; - cursor.qts = s.qts; - cursor.date = s.date; - cursor.seq = s.seq; - } - } - UpdatesDifference::UpdatesDifferenceSlice(diff) => { - cache_from_users_chats( - peers, - media_index, - vector_boxed_items(&diff.users).cloned(), - vector_boxed_items(&diff.chats).cloned(), - ); - for msg in vector_boxed_items(&diff.new_messages) { - if let Some(dto) = message_to_dto(msg, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - } - } - collect_other_updates( - vector_boxed_items(&diff.other_updates), - media_index, - pending_channels, - &mut events, - ); - if let UpdatesState::UpdatesState(s) = *diff.intermediate_state { - cursor.pts = s.pts; - cursor.qts = s.qts; - cursor.date = s.date; - cursor.seq = s.seq; - } - } - UpdatesDifference::UpdatesDifferenceTooLong(t) => { - cursor.pts = t.pts; - // The server has compacted the missing update range. The cursor - // is now authoritative, but the open dialog needs a bounded - // history refresh to refill messages that cannot be reconstructed - // from the difference response. - events.push(UpdateEventDto::Ignored { - kind: "difference_too_long".into(), - }); - events.push(UpdateEventDto::ChatsChanged); - } - _ => {} - } - Ok((events, has_more)) -} - -pub(crate) struct ChannelDifferencePage { - pub events: Vec, - pub pts: i32, - pub final_page: bool, - pub timeout: Option, - pub pending_channels: Vec, -} - -pub(crate) fn drain_channel_difference( - snapshot: &mut Snapshot, - api_id: i32, - peers: &mut HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - pts: i32, -) -> Result { - let channel_id = channel_id_from_chat_id(chat_id) - .ok_or_else(|| MtprotoError::Message("not a channel".into()))?; - let access_hash = peers - .get(&chat_id) - .filter(|p| p.kind == PeerKind::Channel && peers::has_usable_access_hash(p)) - .map(|p| p.access_hash) - .ok_or_else(|| { - MtprotoError::Message(format!("unknown peer {chat_id}; refresh chats first")) - })?; - let request = UpdatesGetChannelDifferenceRequest { - flags: 0, - force: None, - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id, - access_hash, - })), - filter: Box::new(ChannelMessagesFilter::ChannelMessagesFilterEmpty( - ChannelMessagesFilterEmptyConstructor {}, - )), - pts: pts.max(1), - limit: 100, - }; - let response: UpdatesChannelDifference = - api_invoke::invoke_api_allow_updates(snapshot, api_id, request)?; - let mut events = Vec::new(); - let mut next_pts = pts.max(1); - let (mut final_page, mut timeout) = (false, None); - let mut pending_channels = Vec::new(); - match response { - UpdatesChannelDifference::UpdatesChannelDifferenceEmpty(e) => { - final_page = e.final_.is_some(); - timeout = e.timeout; - next_pts = e.pts; - } - UpdatesChannelDifference::UpdatesChannelDifference(diff) => { - final_page = diff.final_.is_some(); - timeout = diff.timeout; - cache_from_users_chats( - peers, - media_index, - vector_boxed_items(&diff.users).cloned(), - vector_boxed_items(&diff.chats).cloned(), - ); - for msg in vector_boxed_items(&diff.new_messages) { - if let Some(dto) = message_to_dto(msg, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - } - } - collect_other_updates( - vector_boxed_items(&diff.other_updates), - media_index, - &mut pending_channels, - &mut events, - ); - if !pending_channels.is_empty() { - events.push(UpdateEventDto::ChatsChanged); - } - next_pts = diff.pts; - } - UpdatesChannelDifference::UpdatesChannelDifferenceTooLong(t) => { - final_page = t.final_.is_some(); - timeout = t.timeout; - cache_from_users_chats( - peers, - media_index, - vector_boxed_items(&t.users).cloned(), - vector_boxed_items(&t.chats).cloned(), - ); - let mut n = 0_i32; - for msg in vector_boxed_items(&t.messages) { - if let Some(dto) = message_to_dto(msg, media_index) { - events.push(UpdateEventDto::NewMessage { message: dto }); - n += 1; - } - } - if let Dialog::Dialog(d) = t.dialog.as_ref() { - if let Some(p) = d.pts { - next_pts = p; - } - } - next_pts = next_channel_pts_after_too_long(pts, next_pts, n); - events.push(UpdateEventDto::ChatsChanged); - } - _ => {} - } - Ok(ChannelDifferencePage { - events, - pts: next_pts, - final_page, - timeout, - pending_channels, - }) -} - -/// Message count is not a pts delta. Only server state can advance the cursor. -pub(crate) fn next_channel_pts_after_too_long( - current: i32, - dialog_pts: i32, - _new_messages: i32, -) -> i32 { - dialog_pts.max(current) -} - -#[cfg(test)] -#[path = "../../tests/unit/updates_rpc_tests.rs"] -mod tests; - -pub(crate) struct LazyChannelPage { - pub chats: Vec, - pub unresolved: Vec, -} - -/// Dialog state for offscreen channels. Does not replace `getChannelDifference`. -/// https://core.telegram.org/method/messages.getPeerDialogs -pub(crate) fn drain_lazy_channels( - snapshot: &mut Snapshot, - api_id: i32, - peers_cache: &mut HashMap, - media_index: &mut MediaIndex, - channel_pts: &HashMap, - lazy_batch: &[i64], -) -> Result { - use tellers_mtproto::latest::api::{ - InputDialogPeer, InputDialogPeerConstructor, MessagesGetPeerDialogsRequest, - MessagesPeerDialogs, Vector, VectorConstructor, - }; - - let mut input_peers = Vec::new(); - let mut unresolved = Vec::new(); - for &chat_id in lazy_batch { - match crate::peers::require_usable_peer(peers_cache, chat_id) { - Ok(cached) => input_peers.push(Box::new(InputDialogPeer::InputDialogPeer( - InputDialogPeerConstructor { - peer: Box::new(crate::peers::input_peer_from_cached(cached)), - }, - ))), - Err(_) => unresolved.push(chat_id), - } - } - if input_peers.is_empty() { - return Ok(LazyChannelPage { - chats: Vec::new(), - unresolved, - }); - } - - let request = MessagesGetPeerDialogsRequest { - peers: Box::new(Vector::Vector(VectorConstructor { - field_0: input_peers.len() as u32, - field_1: input_peers, - })), - }; - let response: MessagesPeerDialogs = api_invoke::invoke_api(snapshot, api_id, request)?; - let MessagesPeerDialogs::MessagesPeerDialogs(d) = response; - // Dialog metadata is a preview, not application of the intervening updates. - let mut dialog_pts = channel_pts.clone(); - let chats = crate::dialogs::map_peer_dialogs( - peers_cache, - media_index, - &mut dialog_pts, - d.dialogs, - d.messages, - d.chats, - d.users, - ); - Ok(LazyChannelPage { chats, unresolved }) -} diff --git a/native/mtproto-rs/src/upload/mod.rs b/native/mtproto-rs/src/upload/mod.rs deleted file mode 100644 index b53d86bd9..000000000 --- a/native/mtproto-rs/src/upload/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) mod upload_rpc; diff --git a/native/mtproto-rs/src/upload/upload_rpc.rs b/native/mtproto-rs/src/upload/upload_rpc.rs deleted file mode 100644 index 96f00e2a4..000000000 --- a/native/mtproto-rs/src/upload/upload_rpc.rs +++ /dev/null @@ -1,788 +0,0 @@ -//! File upload parts and outgoing media. -//! https://core.telegram.org/api/files -//! https://core.telegram.org/method/upload.saveFilePart -//! https://core.telegram.org/method/upload.saveBigFilePart -//! https://core.telegram.org/method/messages.sendMedia -//! https://core.telegram.org/method/messages.sendMultiMedia - -use crate::HashMap; -use std::fs::File; -use std::io::Read; -use std::path::Path; -use std::time::Duration; - -use tellers_mtproto::latest::api::{ - Bool, Document, DocumentAttribute, DocumentAttributeFilenameConstructor, - DocumentAttributeVideoConstructor, InputDocument, InputDocumentConstructor, InputFile, - InputFileBigConstructor, InputFileConstructor, InputMedia, InputMediaDocumentConstructor, - InputMediaPhotoConstructor, InputMediaUploadedDocumentConstructor, - InputMediaUploadedPhotoConstructor, InputPhoto, InputPhotoConstructor, InputSingleMedia, - InputSingleMediaConstructor, MessageMedia, MessagesEditMessageRequest, MessagesSendMediaRequest, - MessagesSendMultiMediaRequest, MessagesUploadMediaRequest, Photo, True, TrueConstructor, - Updates, UploadSaveBigFilePartRequest, UploadSaveFilePartRequest, Vector, VectorConstructor, -}; -use tellers_mtproto_session::Snapshot; - -use crate::api_invoke; -use crate::media::MediaIndex; -use crate::messages::{self, input_reply_to_thread, random_id}; -use crate::peers::{self, CachedPeer, input_peer_from_cached}; -use crate::{MessageDto, MtprotoError, UploadItemDto}; - -pub const FILE_PART_SMALL: usize = 32 * 1024; -pub const FILE_PART_FAST: usize = 512 * 1024; -pub const FILE_PART: usize = FILE_PART_SMALL; - -pub fn file_part() -> usize { - crate::transfer_policy::current().file_part() -} - -pub fn set_file_part_kib(kib: i32) { - let bytes = crate::transfer_policy::normalize_file_part_kib(kib); - crate::transfer_policy::default_policy().assign_file_part_kib(if bytes == FILE_PART_FAST { - 512 - } else { - 32 - }); - crate::client::visit_policies(|policy| { - policy.follow_file_part_kib(if bytes == FILE_PART_FAST { 512 } else { 32 }); - }); -} -pub const PHOTO_MAX: u64 = 10 * 1024 * 1024; -pub const BIG_FILE_THRESHOLD: u64 = 10 * 1024 * 1024; -pub const ALBUM_MAX: usize = 10; -pub const MAX_PARTS_IN_FLIGHT: usize = 8; - -/// Round trip at or above this sends one 32 KiB part. -/// https://core.telegram.org/api/files -pub const SLOW_UPLOAD_RTT: Duration = Duration::from_secs(1); - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct UploadLimits { - pub part_size: usize, - pub in_flight: usize, -} - -/// Honor the configured part size with at most eight parts in flight. A tiny -/// file or slow link uses one 32 KiB part at a time. -/// https://core.telegram.org/api/files -pub fn upload_limits(size: u64, rtt: Option) -> UploadLimits { - let slow = rtt.is_some_and(|rtt| rtt >= SLOW_UPLOAD_RTT); - if slow || size <= FILE_PART_SMALL as u64 { - UploadLimits { - part_size: FILE_PART_SMALL, - in_flight: 1, - } - } else { - UploadLimits { - part_size: file_part(), - in_flight: MAX_PARTS_IN_FLIGHT, - } - } -} - -pub fn is_big_file(size: u64) -> bool { - size > BIG_FILE_THRESHOLD -} - -pub fn part_count(size: u64) -> i32 { - part_count_for(size, upload_limits(size, None).part_size) -} - -fn part_count_for(size: u64, part_size: usize) -> i32 { - if size == 0 || part_size == 0 { - 0 - } else { - ((size + part_size as u64 - 1) / part_size as u64) as i32 - } -} - -pub fn album_group(kind: &str) -> Option<&'static str> { - match kind { - "photo" | "video" => Some("media"), - "document" => Some("document"), - _ => None, - } -} - -/// Mixed photo/video + document albums are invalid (`MEDIA_GROUPED_INVALID`). -/// More than 10 items is `MULTI_MEDIA_TOO_LONG`. -pub fn validate_album(kinds: &[&str]) -> Result<(), MtprotoError> { - if kinds.is_empty() { - return Err(MtprotoError::Message("empty album".into())); - } - if kinds.len() > ALBUM_MAX { - return Err(MtprotoError::Message("MULTI_MEDIA_TOO_LONG".into())); - } - let mut group: Option<&str> = None; - for kind in kinds { - let next = album_group(kind) - .ok_or_else(|| MtprotoError::Message("MEDIA_GROUPED_INVALID".into()))?; - match group { - None => group = Some(next), - Some(existing) if existing != next => { - return Err(MtprotoError::Message("MEDIA_GROUPED_INVALID".into())); - } - Some(_) => {} - } - } - Ok(()) -} - -pub fn send_uploaded( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - item: &UploadItemDto, - input: InputFile, - reply_to_msg_id: i32, - top_msg_id: i32, - entities_json: Option<&str>, -) -> Result { - let mut normalized = item.clone(); - if normalized.random_id == 0 { - normalized.random_id = random_id(); - } - match send_uploaded_with_options( - snapshot, - api_id, - peers, - media_index, - chat_id, - &normalized, - input, - reply_to_msg_id, - top_msg_id, - entities_json, - &crate::SendOptions::default(), - )? { - crate::OutgoingMessageResult::Sent { message } => Ok(message), - crate::OutgoingMessageResult::Scheduled { .. } => Err(MtprotoError::Message( - "unexpected scheduled upload result".into(), - )), - } -} - -pub fn send_uploaded_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - item: &UploadItemDto, - input: InputFile, - reply_to_msg_id: i32, - top_msg_id: i32, - entities_json: Option<&str>, - options: &crate::SendOptions, -) -> Result { - options.validate(item.random_id)?; - if item.random_id == 0 { - return Err(MtprotoError::Message( - "upload options require a persisted random id".into(), - )); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let media = input_media(item, input)?; - let random = if item.random_id != 0 { - item.random_id - } else { - random_id() - }; - let (reply_flag, reply_to) = input_reply_to_thread(reply_to_msg_id, top_msg_id); - let (_, entities) = messages::input_entities_from_json(entities_json, peers)?; - let entities_flag = if entities.is_some() { - MessagesSendMediaRequest::ENTITIES_FLAG - } else { - 0 - }; - let mut request = MessagesSendMediaRequest { - flags: reply_flag | entities_flag, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(cached)), - reply_to, - media: Box::new(media), - message: item.caption.clone(), - random_id: random, - reply_markup: None, - entities, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - options.apply_media(&mut request); - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - let mapped = messages::send_result( - updates.clone(), - chat_id, - &item.caption, - random, - peers, - media_index, - ); - if let Ok(ref result) = mapped { - if let Some(ack) = messages::implicit_ack(&updates, result, random) { - crate::client::management::enqueue_response(snapshot.dc_id, &ack)?; - } - } - crate::client::management::enqueue_response(snapshot.dc_id, &updates)?; - let mut result = mapped?; - let dto = match &mut result { - crate::OutgoingMessageResult::Sent { message } => message, - crate::OutgoingMessageResult::Scheduled { message } => &mut message.message, - }; - if dto.entities_json.is_none() { - dto.entities_json = entities_json.map(str::to_owned); - } - Ok(result) -} - -fn album_entry( - item: &UploadItemDto, - media: InputMedia, - peers: &HashMap, -) -> Result { - let random = if item.random_id != 0 { - item.random_id - } else { - random_id() - }; - let (_, entities) = messages::input_entities_from_json(item.entities_json.as_deref(), peers)?; - Ok(InputSingleMedia::InputSingleMedia( - InputSingleMediaConstructor { - flags: if entities.is_some() { - InputSingleMediaConstructor::ENTITIES_FLAG - } else { - 0 - }, - media: Box::new(media), - random_id: random, - message: item.caption.clone(), - entities, - }, - )) -} - -pub fn send_album( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - items: &[UploadItemDto], - inputs: &[InputFile], - reply_to_msg_id: i32, - top_msg_id: i32, -) -> Result, MtprotoError> { - let items: Vec<_> = items - .iter() - .cloned() - .map(|mut item| { - if item.random_id == 0 { - item.random_id = random_id(); - } - item - }) - .collect(); - send_album_with_options( - snapshot, - api_id, - peers, - media_index, - chat_id, - &items, - inputs, - reply_to_msg_id, - top_msg_id, - &crate::SendOptions::default(), - )? - .into_iter() - .map(|result| match result { - crate::OutgoingMessageResult::Sent { message } => Ok(message), - crate::OutgoingMessageResult::Scheduled { .. } => Err(MtprotoError::Message( - "unexpected scheduled album result".into(), - )), - }) - .collect() -} - -pub fn send_album_with_options( - snapshot: &mut Snapshot, - api_id: i32, - peers: &HashMap, - media_index: &mut MediaIndex, - chat_id: i64, - items: &[UploadItemDto], - inputs: &[InputFile], - reply_to_msg_id: i32, - top_msg_id: i32, - options: &crate::SendOptions, -) -> Result, MtprotoError> { - let ids = items - .iter() - .map(|item| item.random_id) - .collect::>(); - if ids.contains(&0) || ids.len() != items.len() { - return Err(MtprotoError::Message( - "album options require distinct persisted random ids".into(), - )); - } - for item in items { - options.validate(item.random_id)?; - messages::input_entities_from_json(item.entities_json.as_deref(), peers)?; - } - let kinds: Vec<&str> = items.iter().map(|item| item.kind.as_str()).collect(); - validate_album(&kinds)?; - if inputs.len() != items.len() { - return Err(MtprotoError::Message("upload parts missing".into())); - } - let cached = peers::require_usable_peer(peers, chat_id)?; - let mut multi = Vec::with_capacity(items.len()); - for (item, input) in items.iter().zip(inputs) { - let uploaded = input_media(item, input.clone())?; - let media = commit_album_media(snapshot, api_id, cached, uploaded)?; - multi.push(Box::new(album_entry(item, media, peers)?)); - } - let (reply_flag, reply_to) = input_reply_to_thread(reply_to_msg_id, top_msg_id); - let identities: Vec<_> = multi - .iter() - .zip(items) - .map(|(entry, item)| { - let InputSingleMedia::InputSingleMedia(value) = entry.as_ref(); - (value.random_id, item.caption.as_str()) - }) - .collect(); - let mut request = MessagesSendMultiMediaRequest { - flags: reply_flag, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(input_peer_from_cached(cached)), - reply_to, - multi_media: Box::new(Vector::Vector(VectorConstructor { - field_0: multi.len() as u32, - field_1: multi, - })), - schedule_date: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - }; - options.apply_album(&mut request); - let updates: Updates = api_invoke::invoke_api(snapshot, api_id, request)?; - let mapped = - messages::album_send_results(updates.clone(), chat_id, &identities, peers, media_index); - if let Ok(ref results) = mapped { - for ((random_id, _), result) in identities.iter().zip(results) { - if let Some(ack) = messages::implicit_ack(&updates, result, *random_id) { - crate::client::management::enqueue_response(snapshot.dc_id, &ack)?; - } - } - } - crate::client::management::enqueue_response(snapshot.dc_id, &updates)?; - mapped -} - -pub struct UploadBatch { - pub file_id: i64, - pub parts_total: i32, - pub big: bool, - pub offset: i32, - pub bytes: Vec>, -} - -pub struct UploadStaging { - file: File, - file_id: i64, - big: bool, - parts_total: i32, - next: i32, - part_size: usize, - in_flight: usize, -} - -pub fn open_staging(item: &UploadItemDto) -> Result { - open_staging_with_rtt(item, None) -} - -pub fn open_staging_with_rtt( - item: &UploadItemDto, - rtt: Option, -) -> Result { - let path = Path::new(&item.path); - let size = std::fs::metadata(path) - .map_err(|e| MtprotoError::Message(e.to_string()))? - .len(); - if size == 0 { - return Err(MtprotoError::Message("empty file".into())); - } - if item.kind == "photo" && size > PHOTO_MAX { - return Err(MtprotoError::Message("photo too large".into())); - } - let mut limits = upload_limits(size, rtt); - let max_parts = crate::transfer_policy::current().upload_max_parts(); - // Slow-network chunks must still fit the account's server part-count limit. - if part_count_for(size, limits.part_size) > max_parts { - limits.part_size = FILE_PART_FAST; - } - let parts_total = part_count_for(size, limits.part_size); - if parts_total <= 0 || parts_total > max_parts { - return Err(MtprotoError::Message("file too large".into())); - } - Ok(UploadStaging { - file: File::open(path).map_err(|e| MtprotoError::Message(e.to_string()))?, - file_id: random_id(), - big: is_big_file(size), - parts_total, - next: 0, - part_size: limits.part_size, - in_flight: limits.in_flight, - }) -} - -impl UploadStaging { - pub fn is_complete(&self) -> bool { - self.next >= self.parts_total - } - - pub fn in_flight(&self) -> usize { - self.in_flight.max(1) - } - - pub fn next_batch(&mut self, width: usize) -> Option> { - if self.is_complete() { - return None; - } - let end = (self.next + width.max(1) as i32).min(self.parts_total); - let mut bytes = Vec::with_capacity((end - self.next) as usize); - for _ in self.next..end { - match read_part(&mut self.file, self.part_size) { - Ok(part) => bytes.push(part), - Err(err) => return Some(Err(err)), - } - } - let batch = UploadBatch { - file_id: self.file_id, - parts_total: self.parts_total, - big: self.big, - offset: self.next, - bytes, - }; - self.next = end; - Some(Ok(batch)) - } -} - -pub fn save_batch( - snapshot: &mut Snapshot, - api_id: i32, - batch: &UploadBatch, -) -> Result<(), MtprotoError> { - crate::request_control::check() - .map_err(|_| MtprotoError::Message("request cancelled".into()))?; - let accepted = if batch.big { - let requests: Vec = batch - .bytes - .iter() - .enumerate() - .map(|(index, bytes)| UploadSaveBigFilePartRequest { - file_id: batch.file_id, - file_part: batch.offset + index as i32, - file_total_parts: batch.parts_total, - bytes: bytes.clone(), - }) - .collect(); - api_invoke::invoke_api_batch_without_updates::<_, Bool>(snapshot, api_id, requests)? - } else { - let requests: Vec = batch - .bytes - .iter() - .enumerate() - .map(|(index, bytes)| UploadSaveFilePartRequest { - file_id: batch.file_id, - file_part: batch.offset + index as i32, - bytes: bytes.clone(), - }) - .collect(); - api_invoke::invoke_api_batch_without_updates::<_, Bool>(snapshot, api_id, requests)? - }; - for result in accepted { - match result { - Ok(Bool::BoolTrue(_)) => {} - Ok(_) => return Err(MtprotoError::Message("upload part rejected".into())), - Err(err) => return Err(err), - } - } - Ok(()) -} - -pub fn staged_input_file(item: &UploadItemDto, staging: &UploadStaging) -> InputFile { - let name = file_name(item); - if staging.big { - InputFile::InputFileBig(InputFileBigConstructor { - id: staging.file_id, - parts: staging.parts_total, - name, - }) - } else { - InputFile::InputFile(InputFileConstructor { - id: staging.file_id, - parts: staging.parts_total, - name, - md5_checksum: String::new(), - }) - } -} - -pub fn is_missing_file_part(err: &MtprotoError) -> bool { - let MtprotoError::Message(msg) = err else { - return false; - }; - msg.contains("FILE_PART") -} - -fn read_part(file: &mut File, part_size: usize) -> Result, MtprotoError> { - let mut buf = vec![0_u8; part_size]; - let mut filled = 0usize; - while filled < buf.len() { - let n = file - .read(&mut buf[filled..]) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - if n == 0 { - break; - } - filled += n; - } - if filled == 0 { - return Err(MtprotoError::Message("upload truncated".into())); - } - buf.truncate(filled); - Ok(buf) -} - -fn file_name(item: &UploadItemDto) -> String { - if !item.file_name.trim().is_empty() { - return item.file_name.clone(); - } - Path::new(&item.path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string() -} - -fn mime_type(item: &UploadItemDto) -> String { - if !item.mime_type.trim().is_empty() { - return item.mime_type.clone(); - } - match item.kind.as_str() { - "photo" => "image/jpeg".into(), - "video" => "video/mp4".into(), - _ => "application/octet-stream".into(), - } -} - -fn commit_album_media( - snapshot: &mut Snapshot, - api_id: i32, - cached: &CachedPeer, - uploaded: InputMedia, -) -> Result { - let media: MessageMedia = api_invoke::invoke_api( - snapshot, - api_id, - MessagesUploadMediaRequest { - flags: 0, - business_connection_id: None, - peer: Box::new(input_peer_from_cached(cached)), - media: Box::new(uploaded), - }, - )?; - match &media { - MessageMedia::MessageMediaPhoto(p) => { - let Some(Photo::Photo(ph)) = p.photo.as_ref().map(|inner| inner.as_ref()) else { - return Err(MtprotoError::Message("MEDIA_INVALID".into())); - }; - Ok(InputMedia::InputMediaPhoto(InputMediaPhotoConstructor { - flags: 0, - spoiler: None, - live_photo: None, - id: Box::new(InputPhoto::InputPhoto(InputPhotoConstructor { - id: ph.id, - access_hash: ph.access_hash, - file_reference: ph.file_reference.clone(), - })), - ttl_seconds: None, - video: None, - })) - } - MessageMedia::MessageMediaDocument(d) => { - let Some(Document::Document(doc)) = d.document.as_ref().map(|inner| inner.as_ref()) - else { - return Err(MtprotoError::Message("MEDIA_INVALID".into())); - }; - Ok(InputMedia::InputMediaDocument( - InputMediaDocumentConstructor { - flags: 0, - spoiler: None, - id: Box::new(InputDocument::InputDocument(InputDocumentConstructor { - id: doc.id, - access_hash: doc.access_hash, - file_reference: doc.file_reference.clone(), - })), - video_cover: None, - video_timestamp: None, - ttl_seconds: None, - query: None, - }, - )) - } - _ => Err(MtprotoError::Message("MEDIA_INVALID".into())), - } -} - -pub(crate) fn replacement_media_request( - peers: &HashMap, - chat_id: i64, - message_id: i32, - original_kind: &str, - item: &UploadItemDto, - file: InputFile, - spoiler: bool, - ttl_seconds: Option, - invert_media: bool, -) -> Result { - if message_id <= 0 - || !matches!(original_kind, "photo" | "video" | "document") - || item.kind != original_kind - || !item.caption.is_empty() - || item.entities_json.is_some() - || ttl_seconds.is_some_and(|ttl| ttl <= 0) - { - return Err(MtprotoError::Message("invalid media replacement".into())); - } - let mut media = input_media(item, file)?; - match &mut media { - InputMedia::InputMediaUploadedPhoto(photo) => { - photo.spoiler = spoiler.then(|| Box::new(True::True(TrueConstructor {}))); - photo.ttl_seconds = ttl_seconds; - if spoiler { photo.flags |= InputMediaUploadedPhotoConstructor::SPOILER_FLAG; } - if ttl_seconds.is_some() { photo.flags |= InputMediaUploadedPhotoConstructor::TTL_SECONDS_FLAG; } - } - InputMedia::InputMediaUploadedDocument(document) => { - document.spoiler = spoiler.then(|| Box::new(True::True(TrueConstructor {}))); - document.ttl_seconds = ttl_seconds; - if spoiler { document.flags |= InputMediaUploadedDocumentConstructor::SPOILER_FLAG; } - if ttl_seconds.is_some() { document.flags |= InputMediaUploadedDocumentConstructor::TTL_SECONDS_FLAG; } - } - _ => return Err(MtprotoError::Message("invalid media replacement".into())), - } - Ok(MessagesEditMessageRequest { - flags: MessagesEditMessageRequest::MEDIA_FLAG | if invert_media {MessagesEditMessageRequest::INVERT_MEDIA_FLAG} else {0}, - no_webpage: None, - invert_media: invert_media.then(|| Box::new(True::True(TrueConstructor {}))), - peer: Box::new(input_peer_from_cached(peers::require_usable_peer(peers, chat_id)?)), - id: message_id, - message: None, - media: Some(Box::new(media)), - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - quick_reply_shortcut_id: None, - rich_message: None, - }) -} - -fn input_media(item: &UploadItemDto, file: InputFile) -> Result { - match item.kind.as_str() { - "photo" => Ok(InputMedia::InputMediaUploadedPhoto( - InputMediaUploadedPhotoConstructor { - flags: 0, - spoiler: None, - live_photo: None, - file: Box::new(file), - stickers: None, - ttl_seconds: None, - video: None, - }, - )), - "video" => Ok(uploaded_document(item, file, false)), - "document" => Ok(uploaded_document(item, file, true)), - other => Err(MtprotoError::Message(format!( - "unsupported upload kind {other}" - ))), - } -} - -fn uploaded_document(item: &UploadItemDto, file: InputFile, force_file: bool) -> InputMedia { - let mut attributes = vec![Box::new(DocumentAttribute::DocumentAttributeFilename( - DocumentAttributeFilenameConstructor { - file_name: file_name(item), - }, - ))]; - if item.kind == "video" { - let streaming = Box::new(True::True(TrueConstructor {})); - attributes.push(Box::new(DocumentAttribute::DocumentAttributeVideo( - DocumentAttributeVideoConstructor { - flags: DocumentAttributeVideoConstructor::SUPPORTS_STREAMING_FLAG, - round_message: None, - supports_streaming: Some(streaming), - nosound: None, - duration: f64::from(item.duration.max(0)), - w: item.width.max(0), - h: item.height.max(0), - preload_prefix_size: None, - video_start_ts: None, - video_codec: None, - }, - ))); - } - let force = if force_file { - Some(Box::new(True::True(TrueConstructor {}))) - } else { - None - }; - InputMedia::InputMediaUploadedDocument(InputMediaUploadedDocumentConstructor { - flags: if force_file { - InputMediaUploadedDocumentConstructor::FORCE_FILE_FLAG - } else { - 0 - }, - nosound_video: None, - force_file: force, - spoiler: None, - file: Box::new(file), - thumb: None, - mime_type: mime_type(item), - attributes: Box::new(Vector::Vector(VectorConstructor { - field_0: attributes.len() as u32, - field_1: attributes, - })), - stickers: None, - video_cover: None, - video_timestamp: None, - ttl_seconds: None, - }) -} - -#[cfg(test)] -#[path = "../../tests/unit/upload_upload_rpc_tests.rs"] -mod tests; diff --git a/native/mtproto-rs/tests/unit/adapter_boundary_tests.rs b/native/mtproto-rs/tests/unit/adapter_boundary_tests.rs new file mode 100644 index 000000000..f69d3247d --- /dev/null +++ b/native/mtproto-rs/tests/unit/adapter_boundary_tests.rs @@ -0,0 +1,62 @@ +use crate::*; +use mono_proto_client::adapter::runtime as proto_runtime; +use std::sync::{Arc, Mutex}; + +struct ProgressProbe(Arc>>); + +impl DownloadProgressListener for ProgressProbe { + fn on_progress(&self, path: String, downloaded: i64, total: i64) { + self.0.lock().unwrap().push((path, downloaded, total)); + } +} + +#[test] +fn adapter_settings_controls_proxy_and_progress_preserve_boundary_behavior() { + let id = create_request_control(); + assert_ne!(id, 0); + let previous = bind_request_control(id); + assert_eq!(previous, 0); + cancel_request_control(id); + release_request_control(id); + bind_request_control(0); + + set_download_concurrency(3, 7); + assert_eq!(download_concurrency(), vec![3, 7]); + set_download_concurrency(-2, 0); + assert_eq!(download_concurrency(), vec![1, 1]); + + set_download_chunk_kib(128); + assert_eq!(download_chunk_kib(), 128); + set_download_chunk_kib(7); + assert_eq!(download_chunk_kib(), 128); + set_file_part_kib(512); + assert_eq!( + proto_runtime::transfer_policy::current().file_part(), + 512 * 1024 + ); + set_file_part_kib(32); + assert_eq!( + proto_runtime::transfer_policy::current().file_part(), + 32 * 1024 + ); + + assert!( + set_proxy( + "unsupported".into(), + "localhost".into(), + 1080, + None, + None, + Vec::new(), + ) + .is_err() + ); + + let events = Arc::new(Mutex::new(Vec::new())); + set_download_progress_listener(Box::new(ProgressProbe(events.clone()))); + let callback = proto_runtime::transfer_policy::current() + .progress_callback() + .expect("listener installed"); + callback("/tmp/file", 12, 34); + assert_eq!(*events.lock().unwrap(), vec![("/tmp/file".into(), 12, 34)]); +} diff --git a/native/mtproto-rs/tests/unit/api_invoke_tests.rs b/native/mtproto-rs/tests/unit/api_invoke_tests.rs deleted file mode 100644 index 442935c99..000000000 --- a/native/mtproto-rs/tests/unit/api_invoke_tests.rs +++ /dev/null @@ -1,229 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{Bool, BoolTrueConstructor, HelpGetConfigRequest}; - -#[test] -fn reconnect_replays_reads_but_not_mutations_or_unknown_methods() { - use tellers_mtproto::latest::api::*; - assert!(replay_safe_method(MessagesGetHistoryRequest::ID)); - assert!(replay_safe_method(UploadGetFileRequest::ID)); - assert!(replay_safe_method(UploadSaveFilePartRequest::ID)); - assert!(replay_safe_method(UploadSaveBigFilePartRequest::ID)); - assert!(!replay_safe_method(AuthSendCodeRequest::ID)); - assert!(!replay_safe_method(MessagesSendMessageRequest::ID)); - assert!(!replay_safe_method(MessagesSendMediaRequest::ID)); - assert!(!replay_safe_method(MessagesForwardMessagesRequest::ID)); - assert!(!replay_safe_method(0)); -} - -#[test] -fn management_mutations_are_not_replayed_after_an_unknown_network_outcome() { - use tellers_mtproto::latest::api::*; - for id in [ - MessagesUpdatePinnedMessageRequest::ID, - MessagesToggleDialogPinRequest::ID, - FoldersEditPeerFoldersRequest::ID, - ChannelsLeaveChannelRequest::ID, - MessagesDeleteChatUserRequest::ID, - MessagesDeleteHistoryRequest::ID, - ChannelsDeleteHistoryRequest::ID, - MessagesCreateChatRequest::ID, - ChannelsCreateChannelRequest::ID, - ChannelsInviteToChannelRequest::ID, - MessagesAddChatUserRequest::ID, - ChannelsEditAdminRequest::ID, - MessagesEditChatAdminRequest::ID, - ChannelsEditBannedRequest::ID, - MessagesEditChatTitleRequest::ID, - ChannelsEditTitleRequest::ID, - MessagesEditChatPhotoRequest::ID, - ChannelsEditPhotoRequest::ID, - MessagesEditChatAboutRequest::ID, - ChannelsUpdateUsernameRequest::ID, - MessagesEditChatDefaultBannedRightsRequest::ID, - MessagesEditChatCreatorRequest::ID, - MessagesCreateForumTopicRequest::ID, - MessagesEditForumTopicRequest::ID, - MessagesDeleteTopicHistoryRequest::ID, - MessagesUpdatePinnedForumTopicRequest::ID, - MessagesReorderPinnedForumTopicsRequest::ID, - ChatlistsExportChatlistInviteRequest::ID, - ChatlistsEditExportedInviteRequest::ID, - ChatlistsDeleteExportedInviteRequest::ID, - ChatlistsJoinChatlistInviteRequest::ID, - ChatlistsJoinChatlistUpdatesRequest::ID, - ChatlistsHideChatlistUpdatesRequest::ID, - ChatlistsLeaveChatlistRequest::ID, - MessagesUpdateDialogFilterRequest::ID, - MessagesUpdateDialogFiltersOrderRequest::ID, - ChannelsToggleForumRequest::ID, - MessagesExportChatInviteRequest::ID, - MessagesEditExportedChatInviteRequest::ID, - MessagesDeleteExportedChatInviteRequest::ID, - MessagesImportChatInviteRequest::ID, - MessagesHideChatJoinRequestRequest::ID, - MessagesHideAllChatJoinRequestsRequest::ID, - AccountUpdateNotifySettingsRequest::ID, - MessagesSaveDraftRequest::ID, - ] { - assert!( - !replay_safe_method(id), - "management method {id:#x} must not replay" - ); - } -} - -#[test] -fn scheduled_queue_reads_replay_but_mutations_do_not() { - use tellers_mtproto::latest::api::*; - assert!(replay_safe_method(MessagesGetScheduledHistoryRequest::ID)); - assert!(replay_safe_method(MessagesGetScheduledMessagesRequest::ID)); - for id in [ - MessagesSendScheduledMessagesRequest::ID, - MessagesDeleteScheduledMessagesRequest::ID, - MessagesEditMessageRequest::ID, - ] { - assert!(!replay_safe_method(id)); - } -} - -fn contains_ctor(bytes: &[u8], ctor: u32) -> bool { - let marker = ctor.to_le_bytes(); - bytes.windows(4).any(|w| w == marker) -} - -#[test] -fn updates_lane_gzip_packs_a_large_query() { - let plain = vec![b'q'; 20_000]; - let encoded = super::encode_updates_lane_body(&plain); - assert_eq!( - u32::from_le_bytes(encoded[0..4].try_into().unwrap()), - 0x3072_cfa1 - ); - assert!(encoded.len() < plain.len()); - assert_eq!(rpc::ungzip_if_needed(&encoded).expect("ungzip"), plain); -} - -#[test] -fn file_wrap_includes_invoke_without_updates() { - let query = rpc::encode_boxed_bytes(&wrap_init_connection_without_updates( - 1, - HelpGetConfigRequest {}, - )) - .expect("query wrap"); - let updates = rpc::encode_boxed_bytes(&wrap_init_connection(1, HelpGetConfigRequest {})) - .expect("updates wrap"); - let file = rpc::encode_boxed_bytes(&wrap_init_connection_without_updates( - 1, - HelpGetConfigRequest {}, - )) - .expect("file wrap"); - assert!( - contains_ctor(&query, InvokeWithoutUpdatesRequest::<()>::ID), - "query RPCs must wrap invokeWithoutUpdates so updates do not starve rpc_result", - ); - assert!( - !contains_ctor(&updates, InvokeWithoutUpdatesRequest::<()>::ID), - "getDifference/getState must subscribe the updates lane", - ); - assert!( - contains_ctor(&file, InvokeWithoutUpdatesRequest::<()>::ID), - "file RPCs must wrap invokeWithoutUpdates (0xbf9459b7)", - ); - assert!(contains_ctor(&file, InitConnectionRequest::<()>::ID)); - assert!(contains_ctor(&file, InvokeWithLayerRequest::<()>::ID)); - assert_ne!(query, updates); -} - -#[test] -fn established_query_uses_only_invoke_without_updates() { - let first = rpc::encode_boxed_bytes(&wrap_init_connection_without_updates( - 1, - HelpGetConfigRequest {}, - )) - .expect("first query wrap"); - let established = rpc::encode_boxed_bytes(&wrap_without_updates(HelpGetConfigRequest {})) - .expect("established query wrap"); - assert!(contains_ctor(&first, InitConnectionRequest::<()>::ID)); - assert!(contains_ctor(&first, InvokeWithLayerRequest::<()>::ID)); - assert!(contains_ctor( - &established, - InvokeWithoutUpdatesRequest::<()>::ID - )); - assert!(!contains_ctor( - &established, - InitConnectionRequest::<()>::ID - )); - assert!(!contains_ctor( - &established, - InvokeWithLayerRequest::<()>::ID - )); -} - -#[test] -fn established_updates_lane_uses_the_method_without_wrappers() { - let first = rpc::encode_boxed_bytes(&wrap_init_connection(1, HelpGetConfigRequest {})) - .expect("first update-lane wrap"); - let established = - rpc::encode_boxed_bytes(&HelpGetConfigRequest {}).expect("established update-lane request"); - assert!(contains_ctor(&first, InitConnectionRequest::<()>::ID)); - assert!(contains_ctor(&first, InvokeWithLayerRequest::<()>::ID)); - assert!(!contains_ctor( - &established, - InitConnectionRequest::<()>::ID - )); - assert!(!contains_ctor( - &established, - InvokeWithLayerRequest::<()>::ID - )); - assert!(!contains_ctor( - &established, - InvokeWithoutUpdatesRequest::<()>::ID - )); -} - -#[test] -fn file_migrate_still_media_dc_only() { - assert_eq!( - migrate_dc(&MtprotoError::Message("FILE_MIGRATE_4".into())), - Some(4) - ); - assert_eq!( - migrate_dc(&MtprotoError::Message("RPC 303: FILE_MIGRATE_2".into())), - Some(2) - ); - assert_eq!( - migrate_dc(&MtprotoError::Message("USER_MIGRATE_5".into())), - Some(5) - ); - assert_eq!( - migrate_dc(&MtprotoError::Message("RPC timeout recv=0".into())), - None - ); -} - -#[test] -fn extra_read_flag_selects_without_updates_wrap() { - assert!(!invoking_without_updates()); - with_invoke_without_updates(|| { - assert!(invoking_without_updates()); - let established = rpc::encode_boxed_bytes(&wrap_without_updates(HelpGetConfigRequest {})) - .expect("extra-lane wrap"); - assert!(contains_ctor( - &established, - InvokeWithoutUpdatesRequest::<()>::ID, - )); - assert!(!contains_ctor( - &established, - InitConnectionRequest::<()>::ID - )); - }); - assert!(!invoking_without_updates()); -} - -#[test] -fn response_decode_rejects_trailing_tl_bytes() { - let mut encoded = rpc::encode_boxed_bytes(&BoolTrueConstructor {}).expect("encode bool"); - encoded.extend_from_slice(&0_u32.to_le_bytes()); - - assert!(decode_response::(&encoded).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs b/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs deleted file mode 100644 index 8e82b4539..000000000 --- a/native/mtproto-rs/tests/unit/auth_auth_key_tests.rs +++ /dev/null @@ -1,210 +0,0 @@ -use super::*; -use num_bigint::BigUint; -use tellers_mtproto::transport::{ - DhGenOkConstructor, ServerDhInnerDataConstructor, ServerDhParamsOkConstructor, -}; - -// Public group-14 prime, RFC 3526 section 3. Synthetic peer/key material only. -const DH_PRIME: &str = concat!( - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1", - "29024E088A67CC74020BBEA63B139B22514A08798E3404DD", - "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245", - "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED", - "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D", - "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F", - "83655D23DCA3AD961C62F356208552BB9ED529077096966D", - "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B", - "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9", - "DE2BCBF6955817183995497CEA956AE515D2261898FA0510", - "15728E5A8AACAA68FFFFFFFFFFFFFFFF" -); - -fn dh_fixture() -> (AuthAdapter, ServerDhInnerDataConstructor, BigUint) { - let mut adapter = AuthAdapter::new(2).unwrap(); - adapter.nonce = i128_from_bytes([1; 16]); - adapter.server_nonce = Some(i128_from_bytes([2; 16])); - adapter.new_nonce = Some(i256_from_bytes([3; 32])); - adapter.expires_in = Some(120); - adapter.clock = || 1_700_000_000_000_000; - let prime = BigUint::parse_bytes(DH_PRIME.as_bytes(), 16).unwrap(); - let secret = BigUint::from_bytes_be(&[5; 256]); - let g_a = BigUint::from(2_u32).modpow(&secret, &prime).to_bytes_be(); - let inner = ServerDhInnerDataConstructor { - nonce: adapter.nonce, - server_nonce: adapter.server_nonce.unwrap(), - g: 2, - dh_prime: prime.to_bytes_be(), - g_a, - server_time: 1_700_000_500, - }; - (adapter, inner, secret) -} - -fn dh_response( - adapter: &AuthAdapter, - inner: &ServerDhInnerDataConstructor, -) -> ServerDhParamsOkConstructor { - let bytes = encode_boxed(inner).unwrap(); - let mut plain = tellers_mtproto_crypto::sha1(&bytes).to_vec(); - plain.extend_from_slice(&bytes); - plain.resize(plain.len().next_multiple_of(16), 0); - let (key, iv) = temporary_aes_key_iv( - &i256_to_bytes(&adapter.new_nonce.unwrap()), - &i128_to_bytes(&adapter.server_nonce.unwrap()), - ); - ServerDhParamsOkConstructor { - nonce: adapter.nonce, - server_nonce: adapter.server_nonce.unwrap(), - encrypted_answer: aes_ige_encrypt(&plain, &key, &iv).unwrap(), - } -} - -#[test] -fn verified_dh_uses_authenticated_server_clock_expiry_and_independent_shared_key() { - let (mut adapter, inner, secret) = dh_fixture(); - let response = encode_boxed(&dh_response(&adapter, &inner)).unwrap(); - let request_bytes = adapter.accept_dh(&response).unwrap(); - assert_eq!( - &request_bytes[..4], - &tellers_mtproto::transport::SetClientDhParamsRequest::ID.to_le_bytes() - ); - let mut decoder = Decoder::new(&request_bytes[4..], Limits::default()).unwrap(); - let request = ::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - let (key, iv) = temporary_aes_key_iv(&[3; 32], &[2; 16]); - let plain = aes_ige_decrypt(&request.encrypted_data, &key, &iv).unwrap(); - let (tellers_mtproto::transport::ClientDhInnerData::ClientDhInnerData(client), used) = - decode_boxed_prefix::(&plain[20..]).unwrap(); - assert_eq!( - &plain[..20], - tellers_mtproto_crypto::sha1(&plain[20..20 + used]) - ); - assert_eq!(client.nonce, inner.nonce); - assert_eq!(client.server_nonce, inner.server_nonce); - let prime = BigUint::from_bytes_be(&inner.dh_prime); - let shared = BigUint::from_bytes_be(&client.g_b) - .modpow(&secret, &prime) - .to_bytes_be(); - let expected = pad_be(&shared, 256); - assert_eq!(adapter.auth_key.as_deref(), Some(expected.as_slice())); - assert_eq!(adapter.time_offset_micros(), Some(500_000_000)); - assert_eq!(adapter.expires_at, Some(1_700_000_620)); - let mut hashed = vec![3; 32]; - hashed.push(1); - hashed.extend_from_slice(&tellers_mtproto_crypto::sha1(&expected)[..8]); - let confirmation = DhGenOkConstructor { - nonce: inner.nonce, - server_nonce: inner.server_nonce, - new_nonce_hash1: I128::from_le_slice(&tellers_mtproto_crypto::sha1(&hashed)[4..20]) - .unwrap(), - }; - for mutate in [0, 1] { - let mut bad = confirmation.clone(); - if mutate == 0 { - bad.nonce = i128_from_bytes([0; 16]); - } else { - bad.server_nonce = i128_from_bytes([0; 16]); - } - assert!( - adapter - .accept_authorization(&encode_boxed(&bad).unwrap()) - .unwrap_err() - .to_string() - .contains("completion nonce") - ); - } - let (key, salt) = adapter - .accept_authorization(&encode_boxed(&confirmation).unwrap()) - .unwrap(); - assert_eq!(key, expected); - assert_eq!(salt, initial_server_salt(&[3; 32], &[2; 16])); - assert!(adapter.auth_key.is_none()); -} - -#[test] -fn dh_nonce_mismatch_is_rejected_before_any_key_or_clock_publication() { - let (mut adapter, inner, _) = dh_fixture(); - for field in 0..4 { - let mut bad_inner = inner.clone(); - if field == 2 { - bad_inner.nonce = i128_from_bytes([0; 16]); - } - if field == 3 { - bad_inner.server_nonce = i128_from_bytes([0; 16]); - } - let mut response = dh_response(&adapter, &bad_inner); - if field == 0 { - response.nonce = i128_from_bytes([0; 16]); - } - if field == 1 { - response.server_nonce = i128_from_bytes([0; 16]); - } - let error = adapter - .accept_dh(&encode_boxed(&response).unwrap()) - .unwrap_err(); - assert!(error.to_string().contains("nonce mismatch")); - assert!(adapter.auth_key.is_none()); - assert!(adapter.time_offset_micros().is_none()); - assert!(adapter.expires_at.is_none()); - } -} - -#[test] -fn authorization_rejects_an_oversized_incomplete_frame_before_buffering_it_all() { - struct Peer { - reads: usize, - } - impl Connection for Peer { - fn send(&mut self, _: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { - Ok(()) - } - fn receive( - &mut self, - output: &mut [u8], - ) -> Result { - output.fill(0); - if self.reads == 0 { - output[..4].copy_from_slice(&(2_u32 * 1024 * 1024).to_le_bytes()); - } - self.reads += 1; - Ok(output.len()) - } - fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { - Ok(()) - } - } - let mut peer = Peer { reads: 0 }; - let error = recv_plain(&mut peer, &mut PaddedIntermediate::default()).unwrap_err(); - assert!(error.to_string().contains("size limit")); - assert!(peer.reads <= 18); -} - -#[test] -fn trim_plain_envelope_drops_transport_pad() { - let mut packet = vec![0_u8; 20]; - packet[16..20].copy_from_slice(&4u32.to_le_bytes()); - packet.extend_from_slice(&[1, 2, 3, 4]); - packet.extend_from_slice(&[9; 7]); - let trimmed = trim_padded_plain_packet(&packet); - assert_eq!(trimmed.len(), 24); - assert_eq!(&trimmed[20..], &[1, 2, 3, 4]); - decode_plain_message(trimmed, 1024).expect("plain envelope"); -} - -#[test] -fn test_dc_inner_id_adds_10000() { - crate::rpc::set_use_test_dc(true); - assert_eq!(inner_data_dc(2), 10_002); - crate::rpc::set_use_test_dc(false); - assert_eq!(inner_data_dc(2), 2); -} - -#[test] -fn test_and_prod_rsa_keys_parse_and_differ() { - let prod = parse_rsa_public_key(PROD_RSA_PEM).expect("prod rsa"); - let test = parse_rsa_public_key(TEST_RSA_PEM).expect("test rsa"); - assert_ne!( - rsa_public_key_fingerprint(&prod), - rsa_public_key_fingerprint(&test), - ); -} diff --git a/native/mtproto-rs/tests/unit/auth_auth_rpc_tests.rs b/native/mtproto-rs/tests/unit/auth_auth_rpc_tests.rs deleted file mode 100644 index 8a7c552c4..000000000 --- a/native/mtproto-rs/tests/unit/auth_auth_rpc_tests.rs +++ /dev/null @@ -1,79 +0,0 @@ -use super::{code_settings, is_srp_id_invalid, test_dc_from_phone}; -use crate::MtprotoError; -use tellers_mtproto::latest::api::{CodeSettings, CodeSettingsConstructor, Vector}; - -#[test] -fn detects_stale_srp_id() { - assert!(is_srp_id_invalid(&MtprotoError::Message( - "RPC 400: SRP_ID_INVALID".into(), - ))); - assert!(!is_srp_id_invalid(&MtprotoError::Message( - "RPC 400: PASSWORD_HASH_INVALID".into(), - ))); -} - -#[test] -fn test_phone_maps_to_test_dc() { - assert_eq!(test_dc_from_phone("+9996621234"), Some(2)); - assert_eq!(test_dc_from_phone("9996610000"), Some(1)); - assert_eq!(test_dc_from_phone("9996639999"), Some(3)); - assert_eq!(test_dc_from_phone("9996640000"), None); - assert_eq!(test_dc_from_phone("+79180705210"), None); - assert_eq!(test_dc_from_phone("99966"), None); -} - -#[test] -fn code_settings_allows_telegram_app_code() { - let settings = code_settings(&[]); - let CodeSettings::CodeSettings(settings) = *settings; - assert_eq!(settings.flags, CodeSettingsConstructor::ALLOW_APP_HASH_FLAG); - assert!(settings.allow_app_hash.is_some()); - assert!(settings.logout_tokens.is_none()); -} - -#[test] -fn code_settings_carries_bounded_logout_tokens() { - let settings = code_settings(&[vec![1, 2, 3]]); - let CodeSettings::CodeSettings(settings) = *settings; - assert_eq!( - settings.flags, - CodeSettingsConstructor::ALLOW_APP_HASH_FLAG | CodeSettingsConstructor::LOGOUT_TOKENS_FLAG, - ); - assert!(settings.allow_app_hash.is_some()); - let Some(tokens) = settings.logout_tokens else { - panic!("logout tokens missing"); - }; - let Vector::Vector(tokens) = *tokens; - assert_eq!(tokens.field_0, 1); - assert_eq!(tokens.field_1, vec![vec![1, 2, 3]]); -} - -#[test] -fn sent_code_to_dto_extracts_code_length() { - use super::{sent_code_to_dto, sent_code_type_length}; - use tellers_mtproto::latest::api::{ - AuthSentCode, AuthSentCodeConstructor, AuthSentCodeType, AuthSentCodeTypeAppConstructor, - AuthSentCodeTypeSmsConstructor, - }; - - let app_type = - AuthSentCodeType::AuthSentCodeTypeApp(AuthSentCodeTypeAppConstructor { length: 6 }); - assert_eq!(sent_code_type_length(&app_type), 6); - - let sent = AuthSentCode::AuthSentCode(AuthSentCodeConstructor { - flags: 0, - type_: Box::new(app_type), - phone_code_hash: "hash123".into(), - next_type: None, - timeout: None, - }); - let dto = sent_code_to_dto("+123456789".into(), sent).expect("dto"); - assert_eq!(dto.phone, "+123456789"); - assert_eq!(dto.phone_code_hash, "hash123"); - assert_eq!(dto.code_type, "app"); - assert_eq!(dto.code_length, 6); - - let sms_type = - AuthSentCodeType::AuthSentCodeTypeSms(AuthSentCodeTypeSmsConstructor { length: 5 }); - assert_eq!(sent_code_type_length(&sms_type), 5); -} diff --git a/native/mtproto-rs/tests/unit/auth_bind_temp_crypto_tests.rs b/native/mtproto-rs/tests/unit/auth_bind_temp_crypto_tests.rs deleted file mode 100644 index 4c25ab66e..000000000 --- a/native/mtproto-rs/tests/unit/auth_bind_temp_crypto_tests.rs +++ /dev/null @@ -1,28 +0,0 @@ -use super::*; - -#[test] -fn bind_message_uses_v1_msg_key_without_padding() { - let perm = vec![3_u8; 256]; - let inner = BindAuthKeyInnerConstructor { - nonce: 7, - temp_auth_key_id: 8, - perm_auth_key_id: auth_key_id(&perm), - temp_session_id: 9, - expires_at: 1_700_000_000, - }; - let prefix = [1_u8; 16]; - let packet = encrypt_bind_message(&perm, 100, &inner, &prefix, &[2_u8; 8]).unwrap(); - assert_eq!( - i64::from_le_bytes(packet[..8].try_into().unwrap()), - auth_key_id(&perm) - ); - let msg_key: [u8; 16] = packet[8..24].try_into().unwrap(); - let (aes_key, aes_iv) = v1_aes(&msg_key, &perm); - let plain = aes_ige_decrypt(&packet[24..], &aes_key, &aes_iv).unwrap(); - assert_eq!(&plain[..16], &prefix); - assert_eq!(i64::from_le_bytes(plain[16..24].try_into().unwrap()), 100); - assert_eq!(i32::from_le_bytes(plain[24..28].try_into().unwrap()), 0); - assert_eq!(i32::from_le_bytes(plain[28..32].try_into().unwrap()), 40); - assert_eq!(v1_msg_key(&plain[..plain.len() - 8]), msg_key); - assert_eq!(&plain[plain.len() - 8..], &[2_u8; 8]); -} diff --git a/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs b/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs deleted file mode 100644 index 71ded1299..000000000 --- a/native/mtproto-rs/tests/unit/auth_bind_temp_tests.rs +++ /dev/null @@ -1,394 +0,0 @@ -use super::*; -use std::collections::VecDeque; -use tellers_mtproto_crypto::{Direction, MessageToEncrypt, decrypt_message, encrypt_message}; - -struct FixedClock; -impl Clock for FixedClock { - fn unix_micros(&self) -> i64 { - 1_700_000_000_000_000 - } -} - -struct BindPeer { - session: i64, - mode: &'static str, - input: VecDeque, - requests: usize, -} - -impl Connection for BindPeer { - fn send(&mut self, packet: &[u8]) -> Result<(), tellers_mtproto_transport::Error> { - self.requests += 1; - let frame = PaddedIntermediate::default().decode(packet).unwrap(); - let request = decrypt_message( - &[4; 256], - &crate::rpc::trim_padded_mtproto_packet(&frame.payload), - Direction::ClientToServer, - self.session, - 4096, - ) - .unwrap(); - let mut decoder = Decoder::new(&request.body, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - tellers_mtproto::latest::api::AuthBindTempAuthKeyRequest::ID - ); - let permanent_id = i64::decode(&mut decoder).unwrap(); - let nonce = i64::decode(&mut decoder).unwrap(); - let expires = i32::decode(&mut decoder).unwrap(); - let encrypted = decoder.read_bytes().unwrap(); - decoder.finish().unwrap(); - let permanent = [3; 256]; - assert_eq!(permanent_id, auth_key_id(&permanent)); - assert_eq!( - i64::from_le_bytes(encrypted[..8].try_into().unwrap()), - permanent_id - ); - let message_key = &encrypted[8..24]; - // Derive v1 keys from the documented byte ranges, independently of v1_aes. - let a = sha1(&[message_key, &permanent[..32]].concat()); - let b = sha1(&[&permanent[32..48], message_key, &permanent[48..64]].concat()); - let c = sha1(&[&permanent[64..96], message_key].concat()); - let d = sha1(&[message_key, &permanent[96..128]].concat()); - let key: [u8; 32] = [&a[..8], &b[8..20], &c[4..16]].concat().try_into().unwrap(); - let iv: [u8; 32] = [&a[8..20], &b[..8], &c[16..20], &d[..8]] - .concat() - .try_into() - .unwrap(); - let plain = aes_ige_decrypt(&encrypted[24..], &key, &iv).unwrap(); - assert_eq!(&sha1(&plain[..72])[4..20], message_key); - assert_eq!( - i64::from_le_bytes(plain[16..24].try_into().unwrap()), - request.message_id - ); - assert_eq!(&plain[24..28], &0_i32.to_le_bytes()); - assert_eq!(&plain[28..32], &40_i32.to_le_bytes()); - assert_eq!( - &plain[32..36], - &BindAuthKeyInnerConstructor::ID.to_le_bytes() - ); - assert_eq!(&plain[36..44], &nonce.to_le_bytes()); - assert_eq!(&plain[44..52], &auth_key_id(&[4; 256]).to_le_bytes()); - assert_eq!(&plain[52..60], &permanent_id.to_le_bytes()); - assert_eq!(&plain[60..68], &self.session.to_le_bytes()); - assert_eq!(&plain[68..72], &expires.to_le_bytes()); - let id = request.message_id + if self.mode == "wrong_request" { 4 } else { 0 }; - let mut body = result_body( - id, - if self.mode == "false" { - BOOL_FALSE - } else { - BOOL_TRUE - }, - ); - if self.mode == "error" { - body.truncate(12); - body.extend_from_slice( - &crate::rpc::encode_boxed_bytes(&tellers_mtproto::transport::RpcErrorConstructor { - error_code: 400, - error_message: "ENCRYPTED_MESSAGE_INVALID".into(), - }) - .unwrap(), - ); - } - if matches!( - self.mode, - "container" | "duplicate_service" | "duplicate_in_container" - ) { - let mut service = 0x9ec2_0908_u32.to_le_bytes().to_vec(); - service.extend_from_slice(&request.message_id.to_le_bytes()); - service.extend_from_slice(&9_i64.to_le_bytes()); - service.extend_from_slice(&123_i64.to_le_bytes()); - if self.mode == "duplicate_in_container" { - let mut replay = service.clone(); - replay[20..28].copy_from_slice(&999_i64.to_le_bytes()); - body = container(&[service, replay, body]); - let first_id = body[8..16].to_vec(); - body[52..60].copy_from_slice(&first_id); - } else { - body = container(&[service, body]); - } - } else if matches!( - self.mode, - "duplicate_result" | "retired_result" | "future_child" | "negative_child" - ) { - body = container(&[body]); - if self.mode == "future_child" { - body[8..16].copy_from_slice(&(request.message_id + 37).to_le_bytes()); - } - if self.mode == "negative_child" { - body[16..20].copy_from_slice(&(-1_i32).to_le_bytes()); - } - } - let padding = vec![7; v2_pad_len(body.len())]; - let mut reply = encrypt_message( - &[4; 256], - MessageToEncrypt { - server_salt: 456, - session_id: self.session + i64::from(self.mode == "wrong_session"), - message_id: request.message_id - + 33 - + if self.mode == "stale" { - 40_i64 << 32 - } else { - 0 - }, - sequence: 1, - body: &body, - padding: &padding, - }, - Direction::ServerToClient, - ) - .unwrap(); - if self.mode == "corrupt" { - reply[24] ^= 1; - } - self.input - .extend(PaddedIntermediate::default().encode(&reply).unwrap()); - Ok(()) - } - - fn receive(&mut self, output: &mut [u8]) -> Result { - let n = 7.min(output.len()).min(self.input.len()); - for byte in &mut output[..n] { - *byte = self.input.pop_front().unwrap(); - } - Ok(n) - } - - fn close(&mut self) -> Result<(), tellers_mtproto_transport::Error> { - Ok(()) - } -} - -fn result_body(request: i64, result: u32) -> Vec { - [ - RPC_RESULT.to_le_bytes().as_slice(), - &request.to_le_bytes(), - &result.to_le_bytes(), - ] - .concat() -} - -fn container(children: &[Vec]) -> Vec { - let mut out = MSG_CONTAINER.to_le_bytes().to_vec(); - out.extend_from_slice(&(children.len() as u32).to_le_bytes()); - for (index, child) in children.iter().enumerate() { - let child_id = ((FixedClock.unix_micros() / 1_000_000) << 32) + 1 + 4 * index as i64; - out.extend_from_slice(&child_id.to_le_bytes()); - out.extend_from_slice(&1_i32.to_le_bytes()); - out.extend_from_slice(&(child.len() as u32).to_le_bytes()); - out.extend_from_slice(child); - } - out -} - -fn bind_response(body: &[u8], request: i64) -> Result { - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - super::bind_response(body, request, &mut snapshot, &FixedClock, i64::MAX) -} - -#[test] -fn authenticated_binding_children_preserve_replay_and_acknowledgement_state() { - let base = (FixedClock.unix_micros() / 1_000_000) << 32; - for mode in [ - "container", - "duplicate_service", - "duplicate_in_container", - "duplicate_result", - "retired_result", - ] { - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![4; 256]); - if matches!(mode, "duplicate_service" | "duplicate_result") { - snapshot - .register_received_message(base + 1, true, &FixedClock, false) - .unwrap(); - snapshot.take_acknowledgements(usize::MAX); - } - if mode == "retired_result" { - snapshot - .register_received_message(base + 1, true, &FixedClock, false) - .unwrap(); - snapshot.take_acknowledgements(usize::MAX); - for index in 0..tellers_mtproto_session::RECEIVED_MESSAGE_WINDOW { - snapshot - .register_received_message( - base + 7 + 4 * index as i64, - false, - &FixedClock, - false, - ) - .unwrap(); - } - } - let mut peer = BindPeer { - session: snapshot.session_id, - mode, - input: VecDeque::new(), - requests: 0, - }; - let result = bind_temporary_key( - &mut peer, - &mut PaddedIntermediate::default(), - &mut snapshot, - &[3; 256], - 1_700_086_400, - &FixedClock, - ); - assert_eq!( - result.is_ok(), - !matches!(mode, "duplicate_result" | "retired_result"), - "{mode}" - ); - assert!( - snapshot.received_message_ids.contains_key(&(base + 33)), - "outer must be fresh: {mode}" - ); - let acknowledgements = snapshot.take_acknowledgements(usize::MAX); - assert!(acknowledgements.contains(&(base + 33))); - if mode == "retired_result" { - assert!(!snapshot.received_message_ids.contains_key(&(base + 1))); - assert!(!acknowledgements.contains(&(base + 1))); - } else { - assert!( - acknowledgements.contains(&(base + 1)), - "child ACK/re-ACK: {mode}" - ); - } - if mode == "duplicate_service" { - assert_eq!(snapshot.server_salt, 0); - assert!(acknowledgements.contains(&(base + 5))); - } - if matches!(mode, "container" | "duplicate_in_container") { - assert_eq!(snapshot.server_salt, 123); - assert!(acknowledgements.contains(&(base + if mode == "container" { 5 } else { 9 }))); - } - } -} - -#[test] -fn binding_authenticates_both_envelopes_and_rejects_invalid_replies() { - for mode in [ - "true", - "false", - "error", - "wrong_request", - "wrong_session", - "corrupt", - "stale", - "container", - "future_child", - "negative_child", - ] { - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![4; 256]); - let mut peer = BindPeer { - session: snapshot.session_id, - mode, - input: VecDeque::new(), - requests: 0, - }; - let result = bind_temporary_key( - &mut peer, - &mut PaddedIntermediate::default(), - &mut snapshot, - &[3; 256], - 1_700_086_400, - &FixedClock, - ); - assert_eq!( - result.is_ok(), - matches!(mode, "true" | "container"), - "case {mode}" - ); - assert_eq!(peer.requests, 1); - if mode == "container" { - assert_eq!(snapshot.server_salt, 123); - } - if mode == "error" { - assert!( - result - .unwrap_err() - .to_string() - .contains("ENCRYPTED_MESSAGE_INVALID") - ); - } - } -} - -#[test] -fn binding_deadline_and_client_close_stop_a_stalled_peer() { - use std::io::Read; - for cancel in [false, true] { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let (started_tx, started_rx) = std::sync::mpsc::channel(); - let server = std::thread::spawn(move || { - let (mut socket, _) = listener.accept().unwrap(); - socket - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - socket.read_exact(&mut [0; 64]).unwrap(); - socket.read_exact(&mut [0; 1]).unwrap(); - let _ = started_tx.send(()); - let mut buffer = [0; 4096]; - while socket.read(&mut buffer).unwrap_or(0) > 0 {} - }); - let control = std::sync::Arc::new(crate::tcp::ConnectionControl { - config: monogram_mtproto_transport::TransportConfig::default(), - ..Default::default() - }); - let closing = control.clone(); - let closer = std::thread::spawn(move || { - started_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap(); - if cancel { - closing.close(); - } - }); - let started = std::time::Instant::now(); - let result = crate::tcp::with_connection_control(&control, || { - let mut socket = crate::tcp::connect_obfuscated_timeout(&address, 1).unwrap(); - let mut connection = DeadlineConnection { - inner: &mut socket, - deadline: std::time::Instant::now() + std::time::Duration::from_millis(100), - }; - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![4; 256]); - bind_temporary_key( - &mut connection, - &mut PaddedIntermediate::default(), - &mut snapshot, - &[3; 256], - 1_700_086_400, - &FixedClock, - ) - }); - assert!(result.is_err()); - assert!(started.elapsed() < std::time::Duration::from_secs(2)); - closer.join().unwrap(); - server.join().unwrap(); - } -} - -#[test] -fn bind_containers_validate_all_siblings_and_bound_depth_and_count() { - let valid = result_body(100, BOOL_TRUE); - assert!(bind_response(&valid, 100).unwrap().accepted); - let mut trailing = valid.clone(); - trailing.extend_from_slice(&0_u32.to_le_bytes()); - assert!(bind_response(&trailing, 100).is_err()); - let mut malformed_sibling = container(&[valid.clone()]); - malformed_sibling[4..8].copy_from_slice(&2_u32.to_le_bytes()); - assert!(bind_response(&malformed_sibling, 100).is_err()); - let mut too_deep = valid.clone(); - for depth in 0..9 { - too_deep = container(&[too_deep]); - let id = ((FixedClock.unix_micros() / 1_000_000) << 32) + 1 + 4 * depth; - too_deep[8..16].copy_from_slice(&id.to_le_bytes()); - } - assert!(bind_response(&too_deep, 100).is_err()); - assert!(bind_response(&container(&vec![valid; 1024]), 100).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/auth_srp_tests.rs b/native/mtproto-rs/tests/unit/auth_srp_tests.rs deleted file mode 100644 index a25ad0b3e..000000000 --- a/native/mtproto-rs/tests/unit/auth_srp_tests.rs +++ /dev/null @@ -1,67 +0,0 @@ -use super::*; - -#[test] -fn ph1_uses_nested_sh_not_sha256_password() { - let salt1 = [1_u8]; - let salt2 = [2_u8]; - let password = [7_u8]; - // Standard PH1: - let correct = ph1(&password, &salt1, &salt2); - // Old incorrect formula we used previously: - let mut hasher = Sha256::new(); - hasher.update(password); - let pwd_hash: [u8; 32] = hasher.finalize().into(); - let wrong = h(&[&salt1[..], &pwd_hash, &salt1[..]]); - assert_ne!(correct, wrong); -} - -#[test] -fn small_srp_m1_matches_vector() { - let salt1 = [1_u8]; - let salt2 = [2_u8]; - let p = pad_to_256(&[47]); - let g_b = pad_to_256(&[5]); - let a = pad_to_256(&[6]); - let password = [7_u8]; - let g = 3_i32; - - let big_p = BigUint::from_bytes_be(&p); - let g_for_hash = pad_to_256(&[g as u8]); - let big_g_b = BigUint::from_bytes_be(&g_b); - let big_g = BigUint::from(g as u32); - let big_a = BigUint::from_bytes_be(&a); - let k = h(&[&p, &g_for_hash]); - let big_k = BigUint::from_bytes_be(&k); - let g_a = pad_to_256(&big_g.modpow(&big_a, &big_p).to_bytes_be()); - let u = BigUint::from_bytes_be(&h(&[&g_a, &g_b])); - let x = BigUint::from_bytes_be(&ph2(&password, &salt1, &salt2)); - let big_v = big_g.modpow(&x, &big_p); - let k_v = (&big_k * &big_v) % &big_p; - let big_t = if big_g_b >= k_v { - (&big_g_b - &k_v) % &big_p - } else { - (&big_p + &big_g_b - &k_v) % &big_p - }; - let big_s_a = big_t.modpow(&(&big_a + &u * &x), &big_p); - let k_a = h(&[&pad_to_256(&big_s_a.to_bytes_be())]); - let m1 = h(&[ - &xor32(&h(&[&p]), &h(&[&g_for_hash])), - &h(&[&salt1]), - &h(&[&salt2]), - &g_a, - &g_b, - &k_a, - ]); - - let expected_m1 = [ - 157u8, 131, 196, 103, 0, 184, 116, 232, 7, 196, 85, 231, 17, 36, 30, 222, 158, 234, 98, 88, - 59, 56, 71, 215, 183, 123, 122, 50, 19, 32, 54, 206, - ]; - let expected_g_a = { - let mut v = [0_u8; 256]; - v[255] = 24; - v - }; - assert_eq!(m1, expected_m1); - assert_eq!(g_a, expected_g_a); -} diff --git a/native/mtproto-rs/tests/unit/cdn_transfer_tests.rs b/native/mtproto-rs/tests/unit/cdn_transfer_tests.rs deleted file mode 100644 index 43d7af398..000000000 --- a/native/mtproto-rs/tests/unit/cdn_transfer_tests.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::*; -use sha2::{Digest, Sha256}; -use std::cell::Cell; -use std::path::PathBuf; -use tellers_mtproto::latest::api::FileHashConstructor; - -struct Fixture { - root: PathBuf, - dest: PathBuf, - job: media::CdnRedirect, - encrypted: Vec, -} - -impl Fixture { - fn new(label: &str) -> Self { - let id = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = - std::env::temp_dir().join(format!("monogram-cdn-{label}-{}-{id}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let dest = root.join("file"); - std::fs::write(&dest, b"original").unwrap(); - let bytes = b"verified CDN bytes"; - let job = media::CdnRedirect { - dc_id: 203, - file_token: vec![1], - encryption_key: vec![7; 32], - encryption_iv: vec![9; 16], - offset: 0, - file_hashes: vec![FileHash::FileHash(FileHashConstructor { - offset: 0, - limit: bytes.len() as i32, - hash: Sha256::digest(bytes).to_vec(), - })], - }; - let mut encrypted = bytes.to_vec(); - media::decrypt_cdn_part(&job.encryption_key, &job.encryption_iv, 0, &mut encrypted) - .unwrap(); - Self { - root, - dest, - job, - encrypted, - } - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.root); - } -} - -#[test] -fn verified_cdn_transfer_stages_then_publishes_through_session_guard() { - let fixture = Fixture::new("verified"); - let handle = create_client( - 1, - "fixture".into(), - fixture.root.join("session").display().to_string(), - ); - let client = get_client(handle).unwrap(); - let session = client.data.lock().home_session_id; - let staged = media::StagedDownload::new(&fixture.dest).unwrap(); - receive_cdn_parts( - &fixture.job, - &fixture.dest, - &staged, - |_| Ok(Ok(fixture.encrypted.clone())), - |_| panic!("unexpected reupload"), - ) - .unwrap(); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"original"); - assert_eq!(std::fs::read(staged.path()).unwrap(), b"verified CDN bytes"); - publish_media(&client, session, staged, &fixture.dest).unwrap(); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"verified CDN bytes"); - destroy_client(handle); -} - -#[test] -fn corrupt_or_missing_cdn_hashes_never_write_and_fall_back_to_origin() { - for missing in [false, true] { - let mut fixture = Fixture::new(if missing { "missing" } else { "corrupt" }); - if missing { - fixture.job.file_hashes.clear(); - } else { - fixture.encrypted[0] ^= 1; - } - let fallback = Cell::new(0); - let staged = media::StagedDownload::new(&fixture.dest).unwrap(); - let partial = staged.path().to_path_buf(); - cdn_or_origin( - || { - let result = receive_cdn_parts( - &fixture.job, - &fixture.dest, - &staged, - |_| Ok(Ok(fixture.encrypted.clone())), - |_| panic!("unexpected reupload"), - ); - assert!(result.is_err()); - assert!(std::fs::read(staged.path()).unwrap().is_empty()); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"original"); - drop(staged); - result - }, - || { - assert!(!media::cdn_supported()); - assert!(!partial.exists()); - fallback.set(fallback.get() + 1); - std::fs::write(&fixture.dest, b"origin verified").unwrap(); - Ok(()) - }, - ) - .unwrap(); - assert_eq!(fallback.get(), 1); - assert!(media::cdn_supported()); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"origin verified"); - } -} - -#[test] -fn repeated_reuploads_are_bounded_and_cleanup_staging() { - let fixture = Fixture::new("reupload"); - let staged = media::StagedDownload::new(&fixture.dest).unwrap(); - let partial = staged.path().to_path_buf(); - let fetches = Cell::new(0); - let reuploads = Cell::new(0); - let result = receive_cdn_parts( - &fixture.job, - &fixture.dest, - &staged, - |_| { - fetches.set(fetches.get() + 1); - Ok(Err(vec![2])) - }, - |_| { - reuploads.set(reuploads.get() + 1); - Ok(()) - }, - ); - assert!(result.is_err()); - assert_eq!(fetches.get(), 4); - assert_eq!(reuploads.get(), 3); - assert!(std::fs::read(staged.path()).unwrap().is_empty()); - drop(staged); - assert!(!partial.exists()); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"original"); -} - -#[test] -fn cancellation_after_fetch_prevents_writing_or_publication() { - let fixture = Fixture::new("cancel"); - let staged = media::StagedDownload::new(&fixture.dest).unwrap(); - let partial = staged.path().to_path_buf(); - let result = receive_cdn_parts( - &fixture.job, - &fixture.dest, - &staged, - |_| { - std::fs::write(media::cancel_marker(&fixture.dest), b"").unwrap(); - Ok(Ok(fixture.encrypted.clone())) - }, - |_| panic!("unexpected reupload"), - ); - assert!(result.is_err()); - assert!(std::fs::read(staged.path()).unwrap().is_empty()); - drop(staged); - assert!(!partial.exists()); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"original"); -} - -#[test] -fn session_replacement_during_fetch_prevents_publication() { - let fixture = Fixture::new("session"); - let handle = create_client( - 1, - "fixture".into(), - fixture.root.join("session").display().to_string(), - ); - let client = get_client(handle).unwrap(); - let session = client.data.lock().home_session_id; - let staged = media::StagedDownload::new(&fixture.dest).unwrap(); - let partial = staged.path().to_path_buf(); - receive_cdn_parts( - &fixture.job, - &fixture.dest, - &staged, - |_| { - client.data.lock().home_session_id = session.wrapping_add(1); - Ok(Ok(fixture.encrypted.clone())) - }, - |_| panic!("unexpected reupload"), - ) - .unwrap(); - assert!(publish_media(&client, session, staged, &fixture.dest).is_err()); - assert!(!partial.exists()); - assert_eq!(std::fs::read(&fixture.dest).unwrap(), b"original"); - destroy_client(handle); -} diff --git a/native/mtproto-rs/tests/unit/client_chat_invites_tests.rs b/native/mtproto-rs/tests/unit/client_chat_invites_tests.rs deleted file mode 100644 index c92ae592c..000000000 --- a/native/mtproto-rs/tests/unit/client_chat_invites_tests.rs +++ /dev/null @@ -1,100 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn pagination_preserves_the_paired_cursor_and_revoked_filter() { - for cursor in [ - None, - Some(InviteCursor { - date: 123, - link: "fixture-link".into(), - }), - ] { - for revoked in [false, true] { - let request = list_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - InputUser::InputUserSelf(InputUserSelfConstructor {}), - cursor.as_ref(), - revoked, - 100, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesGetExportedChatInvitesRequest::ID - ); - let decoded = MessagesGetExportedChatInvitesRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.offset_date, cursor.as_ref().map(|item| item.date)); - assert_eq!( - decoded.offset_link, - cursor.as_ref().map(|item| item.link.clone()) - ); - assert_eq!(decoded.revoked.is_some(), revoked); - assert_eq!(decoded.limit, 100); - } - } -} - -#[test] -fn export_preserves_expiry_usage_and_approval_options() { - for approved in [false, true] { - let options = InviteOptions { - expire_date: Some(123), - usage_limit: Some(7), - title: Some("Fixture".into()), - request_needed: approved, - revoke_permanent: false, - }; - let request = export_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &options, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesExportChatInviteRequest::ID - ); - let decoded = MessagesExportChatInviteRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.expire_date, Some(123)); - assert_eq!(decoded.usage_limit, Some(7)); - assert_eq!(decoded.title.as_deref(), Some("Fixture")); - assert_eq!(decoded.request_needed.is_some(), approved); - assert!(decoded.legacy_revoke_permanent.is_none()); - assert!(decoded.subscription_pricing.is_none()); - } -} - -#[test] -fn invalid_pages_links_and_empty_edits_fail_before_session_access() { - for limit in [0, -1, 101] { - assert!( - get_invites(0, -12, None, None, false, limit) - .unwrap_err() - .to_string() - .contains("limit") - ); - assert!( - get_importers(0, -12, None, true, None, limit) - .unwrap_err() - .to_string() - .contains("limit") - ); - } - assert!( - check_invite(0, " ") - .unwrap_err() - .to_string() - .contains("link") - ); - assert!(join_invite(0, "").unwrap_err().to_string().contains("link")); - assert!( - edit_invite(0, -12, "fixture-link", InviteChanges::default()) - .unwrap_err() - .to_string() - .contains("change") - ); -} diff --git a/native/mtproto-rs/tests/unit/client_checklists_tests.rs b/native/mtproto-rs/tests/unit/client_checklists_tests.rs deleted file mode 100644 index bb04e6c5f..000000000 --- a/native/mtproto-rs/tests/unit/client_checklists_tests.rs +++ /dev/null @@ -1,267 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn title_edit_preserves_item_ids_rich_text_and_permissions_on_wire() { - let title = TextWithEntities::TextWithEntities(TextWithEntitiesConstructor { - text: "Old".into(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }); - let todo = TodoList::TodoList(TodoListConstructor { - flags: TodoListConstructor::OTHERS_CAN_APPEND_FLAG - | TodoListConstructor::OTHERS_CAN_COMPLETE_FLAG, - others_can_append: Some(Box::new(True::True(TrueConstructor {}))), - others_can_complete: Some(Box::new(True::True(TrueConstructor {}))), - title: Box::new(title.clone()), - list: Box::new(Vector::Vector(VectorConstructor { - field_0: 2, - field_1: vec![ - Box::new(TodoItem::TodoItem(TodoItemConstructor { - id: -7, - title: Box::new(title.clone()), - })), - Box::new(TodoItem::TodoItem(TodoItemConstructor { - id: i32::MAX, - title: Box::new(title), - })), - ], - })), - }); - assert!(title_media(&todo, " \n ").is_err()); - let InputMedia::InputMediaTodo(input) = title_media(&todo, " New title ").unwrap() else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(&input).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let InputMedia::InputMediaTodo(input) = InputMedia::decode(&mut decoder).unwrap() else { - panic!() - }; - decoder.finish().unwrap(); - let TodoList::TodoList(updated) = *input.todo; - let mut row = raw_message(8, 37); - let Message::Message(message) = &mut row else { - panic!() - }; - message.out = Some(Box::new(True::True(TrueConstructor {}))); - message.media = Some(Box::new(MessageMedia::MessageMediaToDo( - MessageMediaToDoConstructor { - flags: 0, - todo: Box::new(todo.clone()), - completions: None, - }, - ))); - let rows = Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(row.clone())], - }); - let peer = InputPeer::InputPeerSelf(InputPeerSelfConstructor {}); - let request = title_request(&rows, peer.clone(), -3, 37, "New").unwrap(); - assert_eq!(request.flags, MessagesEditMessageRequest::MEDIA_FLAG); - assert!( - request.message.is_none() - && request.entities.is_none() - && request.reply_markup.is_none() - && request.schedule_date.is_none() - ); - assert!(title_request(&rows, peer.clone(), -4, 37, "New").is_err()); - assert!(title_request(&rows, peer.clone(), -3, 38, "New").is_err()); - let peers = crate::HashMap::default(); - let mut edit = ChecklistTextEditDto { - item_id: Some(-7), - text: " 😀 task ".into(), - entities_json: Some(r#"[{"kind":"bold","offset":4,"length":4}]"#.into()), - expected_text: "Old".into(), - expected_entities_json: None, - }; - let rich = text_request(&rows, peer.clone(), -3, 37, &edit, &peers).unwrap(); - let InputMedia::InputMediaTodo(rich_media) = rich.media.as_deref().unwrap() else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(rich_media).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let InputMedia::InputMediaTodo(rich) = InputMedia::decode(&mut decoder).unwrap() else { - panic!() - }; - decoder.finish().unwrap(); - let TodoList::TodoList(rich) = *rich.todo; - assert_eq!( - rich.title, - match &todo { - TodoList::TodoList(t) => t.title.clone(), - } - ); - let rich_items = crate::peers::vector_boxed_items(&rich.list).collect::>(); - let TodoItem::TodoItem(first) = rich_items[0]; - let TextWithEntities::TextWithEntities(text) = first.title.as_ref(); - assert_eq!(first.id, -7); - assert_eq!(text.text, " 😀 task "); - assert_eq!(crate::peers::vector_boxed_items(&text.entities).count(), 1); - let TodoList::TodoList(original_list) = &todo; - assert_eq!( - rich_items[1], - crate::peers::vector_boxed_items(&original_list.list) - .nth(1) - .unwrap() - ); - assert_eq!(rich.flags, original_list.flags); - edit.item_id = None; - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_ok()); - edit.expected_text = "Concurrent".into(); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - edit.expected_text = "Old".into(); - edit.expected_entities_json = edit.entities_json.clone(); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - edit.expected_entities_json = None; - edit.item_id = Some(-7); - let removed = content_request(&rows, peer.clone(), -3, 37, &edit, &peers, true).unwrap(); - let InputMedia::InputMediaTodo(media) = removed.media.as_deref().unwrap() else { - panic!() - }; - let TodoList::TodoList(list) = media.todo.as_ref(); - assert_eq!(crate::peers::vector_boxed_items(&list.list).count(), 1); - assert_eq!( - crate::peers::vector_boxed_items(&list.list).next().unwrap(), - crate::peers::vector_boxed_items(&original_list.list) - .nth(1) - .unwrap() - ); - assert_eq!(list.title, original_list.title); - assert_eq!(list.flags, original_list.flags); - let mut single = rows.clone(); - let Vector::Vector(values) = &mut single; - let Message::Message(message) = values.field_1[0].as_mut() else { - panic!() - }; - let MessageMedia::MessageMediaToDo(media) = message.media.as_deref_mut().unwrap() else { - panic!() - }; - let TodoList::TodoList(list) = media.todo.as_mut(); - let Vector::Vector(items) = list.list.as_mut(); - items.field_1.truncate(1); - items.field_0 = 1; - assert!(content_request(&single, peer.clone(), -3, 37, &edit, &peers, true).is_err()); - edit.expected_text = "changed".into(); - assert!(content_request(&rows, peer.clone(), -3, 37, &edit, &peers, true).is_err()); - edit.expected_text = "Old".into(); - edit.item_id = None; - assert!(content_request(&rows, peer.clone(), -3, 37, &edit, &peers, true).is_err()); - edit.item_id = Some(0); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - let mut zero_rows = rows.clone(); - let Vector::Vector(values) = &mut zero_rows; - let Message::Message(message) = values.field_1[0].as_mut() else { - panic!() - }; - let MessageMedia::MessageMediaToDo(media) = message.media.as_deref_mut().unwrap() else { - panic!() - }; - let TodoList::TodoList(list) = media.todo.as_mut(); - let Vector::Vector(items) = list.list.as_mut(); - let TodoItem::TodoItem(first) = items.field_1[0].as_mut(); - first.id = 0; - assert!(text_request(&zero_rows, peer.clone(), -3, 37, &edit, &peers).is_ok()); - edit.item_id = Some(i32::MAX); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_ok()); - edit.entities_json = Some(r#"[{"kind":"bold","offset":2,"length":1}]"#.into()); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - edit.entities_json = Some(r#"[{"kind":"unsupported","offset":4,"length":4}]"#.into()); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - edit.entities_json = None; - edit.text = "x".repeat(65); - assert!(text_request(&rows, peer.clone(), -3, 37, &edit, &peers).is_err()); - let Message::Message(message) = &mut row else { - panic!() - }; - message.out = None; - let rows = Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(row)], - }); - assert!(title_request(&rows, peer, -3, 37, "New").is_err()); - let TodoList::TodoList(original) = todo; - assert_eq!(updated.flags, original.flags); - assert_eq!(updated.others_can_append, original.others_can_append); - assert_eq!(updated.others_can_complete, original.others_can_complete); - assert_eq!(updated.list, original.list); - let TextWithEntities::TextWithEntities(title) = *updated.title; - assert_eq!(title.text, "New title"); - assert_eq!(crate::peers::vector_boxed_items(&title.entities).count(), 0); - let empty = Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - }); - assert!( - title_request( - &empty, - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 7, - 37, - "New" - ) - .is_err() - ); -} - -fn raw_message(sender_id: i64, id: i32) -> tellers_mtproto::latest::api::Message { - use tellers_mtproto::latest::api::{ - Message, MessageConstructor, Peer, PeerChatConstructor, PeerUserConstructor, - }; - Message::Message(MessageConstructor { - flags: 0, - out: None, - mentioned: None, - media_unread: None, - silent: None, - post: None, - from_scheduled: None, - legacy: None, - edit_hide: None, - pinned: None, - noforwards: None, - invert_media: None, - flags2: 0, - offline: None, - video_processing_pending: None, - paid_suggested_post_stars: None, - paid_suggested_post_ton: None, - id, - from_id: Some(Box::new(Peer::PeerUser(PeerUserConstructor { - user_id: sender_id, - }))), - from_boosts_applied: None, - from_rank: None, - peer_id: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id: 3 })), - saved_peer_id: None, - fwd_from: None, - via_bot_id: None, - via_business_bot_id: None, - guestchat_via_from: None, - reply_to: None, - date: 1, - message: "hi".into(), - media: None, - reply_markup: None, - entities: None, - views: None, - forwards: None, - replies: None, - edit_date: None, - post_author: None, - grouped_id: None, - reactions: None, - restriction_reason: None, - ttl_period: None, - quick_reply_shortcut_id: None, - effect: None, - factcheck: None, - report_delivery_until_date: None, - paid_message_stars: None, - suggested_post: None, - schedule_repeat_period: None, - summary_from_language: None, - rich_message: None, - }) -} diff --git a/native/mtproto-rs/tests/unit/client_contact_import_tests.rs b/native/mtproto-rs/tests/unit/client_contact_import_tests.rs deleted file mode 100644 index ea113c81b..000000000 --- a/native/mtproto-rs/tests/unit/client_contact_import_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn synthetic_contact_import_wire_and_partial_result_identity_are_preserved() { - let contacts = vec![ - ContactImportSpec { - client_id: 7, - phone: "+15555550123".into(), - first_name: "Synthetic".into(), - last_name: "One".into(), - }, - ContactImportSpec { - client_id: 9, - phone: "+15555550124".into(), - first_name: "Synthetic".into(), - last_name: "Two".into(), - }, - ]; - let request = import_request(&contacts).unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - ContactsImportContactsRequest::ID - ); - let decoded = ContactsImportContactsRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - let rows = crate::peers::vector_boxed_items(&decoded.contacts).collect::>(); - let InputContact::InputPhoneContact(first) = rows[0]; - assert_eq!(first.client_id, 7); - assert_eq!(first.phone, contacts[0].phone); - assert!(first.note.is_none()); - assert!(import_request(&[]).is_err()); - let mut duplicate = contacts.clone(); - duplicate[1].client_id = 7; - assert!(import_request(&duplicate).is_err()); - duplicate = contacts.clone(); - duplicate[1].phone = "15555550123".into(); - assert!(import_request(&duplicate).is_err()); - let mut body = ContactsImportedContactsConstructor { - imported: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(ImportedContact::ImportedContact( - ImportedContactConstructor { - client_id: 7, - user_id: 42, - }, - ))], - })), - popular_invites: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - retry_contacts: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![9], - })), - users: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }; - let result = import_result(&contacts, &body).unwrap(); - assert_eq!( - result.imported, - vec![ContactImportMatch { - client_id: 7, - user_id: 42 - }] - ); - assert_eq!(result.retry_client_ids, vec![9]); - body.retry_contacts = Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![7], - })); - assert!(import_result(&contacts, &body).is_err()); - body.retry_contacts = Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![99], - })); - assert!(import_result(&contacts, &body).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/client_drafts_tests.rs b/native/mtproto-rs/tests/unit/client_drafts_tests.rs deleted file mode 100644 index 6dd9d88cc..000000000 --- a/native/mtproto-rs/tests/unit/client_drafts_tests.rs +++ /dev/null @@ -1,187 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -fn local(thread_id: i32) -> CloudDraft { - CloudDraft { - chat_id: 7, - thread_id, - text: "Hello".into(), - entities_json: Some(r#"[{"kind":"bold","offset":0,"length":5}]"#.into()), - reply_to_message_id: 42, - date: 10, - no_webpage: true, - has_unsupported_content: false, - } -} - -fn update(draft: DraftMessage) -> UpdateDraftMessageConstructor { - UpdateDraftMessageConstructor { - flags: 1, - peer: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 7 })), - top_msg_id: Some(99), - saved_peer_id: None, - draft: Box::new(draft), - } -} - -#[test] -fn saved_named_mentions_use_full_input_user_references() { - let mut draft = local(0); - draft.entities_json = - Some(r#"[{"kind":"mention_name","offset":0,"length":5,"url":"42"}]"#.into()); - let peers = [( - 42, - crate::peers::CachedPeer { - kind: crate::peers::PeerKind::User, - id: 42, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - let request = save_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &draft, - &peers, - ) - .unwrap(); - assert!(matches!( - crate::peers::vector_items(request.entities.as_ref().unwrap())[0].as_ref(), - MessageEntity::InputMessageEntityMentionName(_) - )); - assert!( - save_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &draft, - &crate::HashMap::default() - ) - .is_err() - ); -} - -#[test] -fn save_flags_preserve_text_entities_reply_and_thread_context() { - for thread in [0, 99] { - let request = save_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &local(thread), - &crate::HashMap::default(), - ) - .unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSaveDraftRequest::ID); - let result = MessagesSaveDraftRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert!(result.no_webpage.is_some()); - assert_eq!(result.message, "Hello"); - assert_eq!( - crate::peers::vector_items(result.entities.as_ref().unwrap()).len(), - 1 - ); - let InputReplyTo::InputReplyToMessage(reply) = *result.reply_to.unwrap() else { - panic!("message reply required") - }; - assert_eq!(reply.reply_to_msg_id, 42); - assert_eq!(reply.top_msg_id, (thread != 0).then_some(thread)); - assert!(result.media.is_none()); - } -} - -#[test] -fn clearing_a_thread_draft_keeps_its_thread_and_deletion_timestamp() { - let mut draft = local(99); - draft.text.clear(); - draft.entities_json = None; - draft.reply_to_message_id = 0; - draft.no_webpage = false; - let request = save_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &draft, - &crate::HashMap::default(), - ) - .unwrap(); - assert!(request.message.is_empty()); - let InputReplyTo::InputReplyToMessage(reply) = *request.reply_to.unwrap() else { - panic!("thread context required") - }; - assert_eq!((reply.reply_to_msg_id, reply.top_msg_id), (99, Some(99))); - for date in [None, Some(456)] { - let result = draft_from_update( - &update(DraftMessage::DraftMessageEmpty( - DraftMessageEmptyConstructor { - flags: u32::from(date.is_some()), - date, - }, - )), - 123, - ); - assert_eq!(result.date, date.unwrap_or(123)); - assert_eq!((result.chat_id, result.thread_id), (7, 99)); - assert!(result.text.is_empty()); - } -} - -#[test] -fn decoded_formatting_round_trips_and_lossy_entities_are_protected() { - let request = save_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - &local(99), - &crate::HashMap::default(), - ) - .unwrap(); - let mut remote = DraftMessageConstructor { - flags: request.flags, - no_webpage: request.no_webpage, - invert_media: None, - reply_to: request.reply_to, - message: request.message, - entities: request.entities, - media: None, - date: 123, - effect: None, - suggested_post: None, - rich_message: None, - }; - let result = draft_from_update(&update(DraftMessage::DraftMessage(remote.clone())), 0); - assert_eq!(result.reply_to_message_id, 42); - assert_eq!(result.date, 123); - assert!(!result.has_unsupported_content); - remote.entities = Some(Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(MessageEntity::MessageEntityMentionName( - MessageEntityMentionNameConstructor { - offset: 0, - length: 5, - user_id: 123, - }, - ))], - }))); - let result = draft_from_update(&update(DraftMessage::DraftMessage(remote.clone())), 0); - assert!(!result.has_unsupported_content); - assert!( - result - .entities_json - .as_deref() - .unwrap() - .contains("mention_name") - ); - remote.entities = Some(Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(MessageEntity::MessageEntityUnknown( - MessageEntityUnknownConstructor { - offset: 0, - length: 5, - }, - ))], - }))); - let result = draft_from_update(&update(DraftMessage::DraftMessage(remote)), 0); - assert!(result.has_unsupported_content); - assert!( - save_cloud_draft(0, result) - .unwrap_err() - .to_string() - .contains("content") - ); -} diff --git a/native/mtproto-rs/tests/unit/client_extras_rpc_tests.rs b/native/mtproto-rs/tests/unit/client_extras_rpc_tests.rs deleted file mode 100644 index 0b333d6c4..000000000 --- a/native/mtproto-rs/tests/unit/client_extras_rpc_tests.rs +++ /dev/null @@ -1,266 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - MessagePeerVoteConstructor, MessagePeerVoteInputOptionConstructor, - MessagePeerVoteMultipleConstructor, Peer, PeerUserConstructor, -}; - -#[test] -fn live_location_edits_validate_movement_and_encode_stop_without_extending_period() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - let peers = [( - 7, - CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - for (id, lat, long, heading, stopped) in [ - (0, Some(0.0), Some(0.0), None, false), - (37, Some(f64::NAN), Some(0.0), None, false), - (37, Some(91.0), Some(0.0), None, false), - (37, Some(0.0), Some(-181.0), None, false), - (37, Some(0.0), None, None, false), - (37, Some(0.0), Some(0.0), Some(0), false), - (37, Some(0.0), Some(0.0), Some(361), false), - (37, Some(0.0), None, None, true), - (37, None, None, Some(1), true), - ] { - assert!(live_location_edit_request(&peers, 7, id, lat, long, heading, stopped).is_err()); - } - for stopped in [false, true] { - let request = live_location_edit_request( - &peers, - 7, - 37, - (!stopped).then_some(-90.0), - (!stopped).then_some(180.0), - (!stopped).then_some(360), - stopped, - ) - .unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesEditMessageRequest::ID); - let row = MessagesEditMessageRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(row.flags, MessagesEditMessageRequest::MEDIA_FLAG); - assert!( - row.message.is_none() - && row.entities.is_none() - && row.reply_markup.is_none() - && row.schedule_date.is_none() - ); - let InputMedia::InputMediaGeoLive(media) = *row.media.unwrap() else { - panic!() - }; - assert!(media.period.is_none() && media.proximity_notification_radius.is_none()); - assert_eq!(media.stopped.is_some(), stopped); - if stopped { - assert!(matches!( - *media.geo_point, - InputGeoPoint::InputGeoPointEmpty(_) - )); - assert!(media.heading.is_none()); - } else { - let InputGeoPoint::InputGeoPoint(point) = *media.geo_point else { - panic!() - }; - assert_eq!((point.lat, point.long), (-90.0, 180.0)); - assert_eq!(media.heading, Some(360)); - } - } -} - -#[test] -fn checklist_append_checks_arithmetic_before_rpc_and_preserves_trimmed_text() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - let peers = [( - 7, - CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - for (message_id, first, titles) in [ - (0, 1, vec!["A".into()]), - (37, 1, vec![" ".into()]), - (37, i32::MAX, vec!["A".into(), "B".into()]), - ] { - assert!(todo_append_request(&peers, 7, message_id, first, &titles).is_err()); - } - let request = todo_append_request( - &peers, - 7, - 37, - 0, - &[" First ".into(), " ".into(), "Second".into()], - ) - .unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesAppendTodoListRequest::ID - ); - let row = MessagesAppendTodoListRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - let items: Vec<_> = vector_boxed_items(&row.list) - .map(|item| { - let TodoItem::TodoItem(item) = item; - let TextWithEntities::TextWithEntities(title) = item.title.as_ref(); - (item.id, title.text.as_str()) - }) - .collect(); - assert_eq!(items, vec![(0, "First"), (1, "Second")]); - assert!(todo_append_request(&peers, 7, 37, i32::MAX, &["Last".into()]).is_ok()); -} - -#[test] -fn checklist_toggle_rejects_empty_duplicate_and_conflicting_ids_but_preserves_signed_identity() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - let peers = [( - 7, - CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - for (complete, incomplete) in [ - (vec![], vec![]), - (vec![1, 1], vec![]), - (vec![1], vec![1]), - (vec![], vec![1, 1]), - ] { - assert!(todo_toggle_request(&peers, 7, 37, &complete, &incomplete).is_err()); - } - let request = todo_toggle_request(&peers, 7, 37, &[0, -7], &[i32::MAX]).unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesToggleTodoCompletedRequest::ID - ); - let row = MessagesToggleTodoCompletedRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(vector_items(&row.completed), &[0, -7]); - assert_eq!(vector_items(&row.incompleted), &[i32::MAX]); -} - -#[test] -fn viewer_pages_encode_opaque_offsets_and_bounded_limits() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - use tellers_mtproto::latest::api::{InputPeer, InputPeerSelfConstructor}; - for offset in [None, Some("opaque+/=cursor")] { - let request = reaction_page_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 37, - offset, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesGetMessageReactionsListRequest::ID - ); - let row = MessagesGetMessageReactionsListRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(row.offset.as_deref(), offset); - assert_eq!(row.limit, 50); - assert_eq!(row.id, 37); - let request = poll_page_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 37, - offset, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesGetPollVotesRequest::ID); - let row = MessagesGetPollVotesRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(row.offset.as_deref(), offset); - assert_eq!(row.limit, 50); - assert_eq!(row.id, 37); - } -} - -#[test] -fn vote_requests_encode_retraction_and_all_selected_options_without_normalizing_bytes() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - let peers = [( - 7, - CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - for options in [vec![], vec![vec![0, 255], vec![3, 0, 7]]] { - let request = poll_vote_request(&peers, 7, 37, &options).unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSendVoteRequest::ID); - let decoded = MessagesSendVoteRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.msg_id, 37); - assert_eq!( - crate::peers::vector_items(&decoded.options), - options.as_slice() - ); - } - for id in [0, -1] { - assert!(matches!(poll_vote_request(&HashMap::default(), 7, id, &[]), - Err(MtprotoError::Message(ref message)) if message == "message id required")); - } - assert!(poll_vote_request(&HashMap::default(), 7, 37, &[]).is_err()); -} - -fn user_peer(id: i64) -> Box { - Box::new(Peer::PeerUser(PeerUserConstructor { user_id: id })) -} - -#[test] -fn single_vote_keeps_option_bytes() { - let row = MessagePeerVote::MessagePeerVote(MessagePeerVoteConstructor { - peer: user_peer(7), - option: vec![0x01, 0xa0], - date: 11, - }); - assert_eq!(poll_vote_options(&row), vec![vec![0x01, 0xa0]]); -} - -#[test] -fn input_option_vote_has_no_bytes() { - let row = MessagePeerVote::MessagePeerVoteInputOption(MessagePeerVoteInputOptionConstructor { - peer: user_peer(7), - date: 11, - }); - assert!(poll_vote_options(&row).is_empty()); -} - -#[test] -fn multiple_vote_keeps_every_option() { - let row = MessagePeerVote::MessagePeerVoteMultiple(MessagePeerVoteMultipleConstructor { - peer: user_peer(7), - options: Box::new(Vector::Vector(VectorConstructor { - field_0: 2, - field_1: vec![b"a".to_vec(), b"b".to_vec()], - })), - date: 11, - }); - assert_eq!(poll_vote_options(&row), vec![b"a".to_vec(), b"b".to_vec()]); -} diff --git a/native/mtproto-rs/tests/unit/client_forum_management_tests.rs b/native/mtproto-rs/tests/unit/client_forum_management_tests.rs deleted file mode 100644 index c83a825b4..000000000 --- a/native/mtproto-rs/tests/unit/client_forum_management_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; -use tellers_mtproto::latest::api::InputPeerSelfConstructor; - -#[test] -fn editing_preserves_unset_false_true_and_explicit_icon_removal() { - for closed in [None, Some(false), Some(true)] { - for hidden in [None, Some(false), Some(true)] { - let changes = TopicChanges { - title: Some("General".into()), - icon_emoji_id: Some(0), - closed, - hidden, - }; - let request = edit_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 1, - &changes, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesEditForumTopicRequest::ID - ); - let decoded = MessagesEditForumTopicRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - let value = - |item: Option>| item.map(|value| matches!(*value, Bool::BoolTrue(_))); - assert_eq!(value(decoded.closed), closed); - assert_eq!(value(decoded.hidden), hidden); - assert_eq!(decoded.icon_emoji_id, Some(0)); - assert_eq!(decoded.title.as_deref(), Some("General")); - } - } -} - -#[test] -fn invalid_topic_operations_fail_before_accessing_a_session() { - assert!(create_topic(0, -12, "Topic", 0, None, None).is_err()); - assert!(create_topic(0, -12, " ", 7, None, None).is_err()); - assert!(edit_topic(0, -12, 7, TopicChanges::default()).is_err()); - assert!( - edit_topic( - 0, - -12, - 7, - TopicChanges { - hidden: Some(false), - ..Default::default() - } - ) - .is_err() - ); - assert!(pin_topic(0, -12, 0, true).is_err()); - assert!(delete_topic(0, -12, 1).is_err()); - assert!(reorder_pinned_topics(0, -12, &[7, 7], false).is_err()); - assert!(reorder_pinned_topics(0, -12, &[-1], true).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/client_group_management_tests.rs b/native/mtproto-rs/tests/unit/client_group_management_tests.rs deleted file mode 100644 index ef61e2edd..000000000 --- a/native/mtproto-rs/tests/unit/client_group_management_tests.rs +++ /dev/null @@ -1,148 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn false_mutation_result_is_not_reported_as_success() { - assert!(confirm_change(Bool::BoolTrue(BoolTrueConstructor {})).is_ok()); - assert!(confirm_change(Bool::BoolFalse(BoolFalseConstructor {})).is_err()); -} - -#[test] -fn membership_rejects_empty_invites_and_invalid_history_limits_before_session_access() { - assert!( - invite_channel_members(0, -12, &[]) - .unwrap_err() - .to_string() - .contains("member") - ); - for limit in [-1, 101] { - assert!( - add_group_member(0, -12, 7, limit) - .unwrap_err() - .to_string() - .contains("limit") - ); - } -} - -#[test] -fn management_peer_resolution_preserves_roles_and_rejects_min_hashes() { - use crate::peers::{CachedPeer, chat_id_for_channel}; - let directory = std::env::temp_dir().join(format!( - "monogram-management-peers-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&directory).unwrap(); - let handle = super::super::create_client( - 1, - "fixture".into(), - directory.join("session.json").to_string_lossy().into(), - ); - let result = with_client_mut(handle, |state| { - for (id, kind, raw, hash) in [ - (7, PeerKind::User, 7, 70), - (-12, PeerKind::Chat, 12, 0), - (chat_id_for_channel(34), PeerKind::Channel, 34, 340), - ] { - state.peers.insert( - id, - CachedPeer { - kind, - id: raw, - access_hash: hash, - min_hash: false, - }, - ); - } - assert_eq!(basic_group_id(state, -12).unwrap(), 12); - let InputUser::InputUser(user) = user_peer(state, 7).unwrap() else { - panic!("user required") - }; - assert_eq!((user.user_id, user.access_hash), (7, 70)); - let InputChannel::InputChannel(channel) = - channel_peer(state, chat_id_for_channel(34)).unwrap() - else { - panic!("channel required") - }; - assert_eq!((channel.channel_id, channel.access_hash), (34, 340)); - assert!(group_peer(state, -12).is_ok()); - assert!(group_peer(state, chat_id_for_channel(34)).is_ok()); - assert!(group_peer(state, 7).is_err()); - assert!(basic_group_id(state, chat_id_for_channel(34)).is_err()); - assert!(channel_peer(state, -12).is_err()); - assert!(user_peer(state, -12).is_err()); - assert!(user_peer(state, 99).is_err()); - state.peers.get_mut(&7).unwrap().min_hash = true; - assert!(user_peer(state, 7).is_err()); - state - .peers - .get_mut(&chat_id_for_channel(34)) - .unwrap() - .access_hash = 0; - assert!(channel_peer(state, chat_id_for_channel(34)).is_err()); - Ok(()) - }); - super::super::destroy_client(handle); - std::fs::remove_dir_all(&directory).unwrap(); - result.unwrap(); -} - -#[test] -fn channel_kinds_encode_exclusive_broadcast_and_megagroup_flags() { - for kind in [ - ChannelKind::Broadcast, - ChannelKind::Megagroup, - ChannelKind::Forum, - ] { - let bytes = - crate::rpc::encode_boxed_bytes(&channel_request("Group", "About", kind)).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - ChannelsCreateChannelRequest::ID - ); - let request = ChannelsCreateChannelRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(request.broadcast.is_some(), kind == ChannelKind::Broadcast); - assert_eq!(request.megagroup.is_some(), kind != ChannelKind::Broadcast); - assert_eq!(request.forum.is_some(), kind == ChannelKind::Forum); - assert_eq!(request.title, "Group"); - assert_eq!(request.about, "About"); - assert!(request.for_import.is_none()); - assert!(request.ttl_period.is_none()); - } -} - -#[test] -fn creation_rejects_missing_titles_and_non_user_members_before_session_access() { - assert!( - create_group(0, " ", &[7]) - .unwrap_err() - .to_string() - .contains("title") - ); - for members in [&[][..], &[0][..], &[-12][..]] { - assert!( - create_group(0, "Group", members) - .unwrap_err() - .to_string() - .contains("user peers") - ); - } - for kind in [ - ChannelKind::Broadcast, - ChannelKind::Megagroup, - ChannelKind::Forum, - ] { - assert!( - create_channel(0, "\n", "", kind) - .unwrap_err() - .to_string() - .contains("title") - ); - } -} diff --git a/native/mtproto-rs/tests/unit/client_management_tests.rs b/native/mtproto-rs/tests/unit/client_management_tests.rs deleted file mode 100644 index b2976a842..000000000 --- a/native/mtproto-rs/tests/unit/client_management_tests.rs +++ /dev/null @@ -1,73 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; -use tellers_mtproto::latest::api::InputPeerSelfConstructor; - -#[test] -fn message_pin_options_survive_wire_encoding_independently() { - for pinned in [false, true] { - for silent in [false, true] { - for one_sided in [false, true] { - let request = pin_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 37, - pinned, - silent, - one_sided, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesUpdatePinnedMessageRequest::ID - ); - let decoded = MessagesUpdatePinnedMessageRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.id, 37); - assert_eq!(decoded.unpin.is_some(), !pinned); - assert_eq!(decoded.silent.is_some(), silent); - assert_eq!(decoded.pm_oneside.is_some(), one_sided); - } - } - } -} - -#[test] -fn history_clear_and_revoke_scopes_survive_wire_encoding() { - for clear in [false, true] { - for revoke in [false, true] { - let request = history_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 512, - revoke, - clear, - ); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - MessagesDeleteHistoryRequest::ID - ); - let decoded = MessagesDeleteHistoryRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.max_id, 512); - assert_eq!(decoded.just_clear.is_some(), clear); - assert_eq!(decoded.revoke.is_some(), revoke); - assert!(decoded.min_date.is_none()); - assert!(decoded.max_date.is_none()); - } - } -} - -#[test] -fn missing_history_boundary_is_rejected_before_accessing_a_session() { - for max_id in [0, -1] { - assert!(clear_history(0, 7, max_id, false).is_err()); - assert!(delete_dialog(0, 7, max_id, false).is_err()); - } -} - -#[test] -fn invalid_message_id_is_rejected_before_accessing_a_session() { - assert!(pin_message(0, 7, 0, true, false, false).is_err()); - assert!(pin_message(0, 7, -1, true, false, false).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs deleted file mode 100644 index df26eb3ed..000000000 --- a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs +++ /dev/null @@ -1,238 +0,0 @@ -use super::*; - -#[test] -fn channel_recovery_keeps_every_server_hint_and_only_the_open_channel() { - let first = peers::chat_id_for_channel(1); - let second = peers::chat_id_for_channel(2); - let third = peers::chat_id_for_channel(3); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![first, second, first], third, 100); - let targets: Vec<_> = queue.iter().map(|entry| entry.chat_id).collect(); - assert_eq!(targets.len(), 3); - for id in [first, second, third] { - assert!(targets.contains(&id)); - } - let mut empty = VecDeque::new(); - prepare_channel_recovery(&mut empty, Vec::new(), 42, 100); - assert!(empty.is_empty()); -} - -#[test] -fn channel_recovery_rotates_pages_and_respects_final_timeout() { - let first = peers::chat_id_for_channel(1); - let second = peers::chat_id_for_channel(2); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![first, second], first, 100); - let entry = queue.pop_front().unwrap(); - finish_channel_recovery(&mut queue, entry, false, Some(60), first, 100); - assert_eq!(queue.front().unwrap().chat_id, second); - assert_eq!(queue.back().unwrap().due_at, 100); - let entry = queue.pop_front().unwrap(); - finish_channel_recovery(&mut queue, entry, true, Some(60), first, 100); - assert_eq!(queue.len(), 1); - let entry = queue.pop_front().unwrap(); - finish_channel_recovery(&mut queue, entry, true, Some(60), first, 100); - prepare_channel_recovery(&mut queue, Vec::new(), first, 101); - assert_eq!(queue.front().unwrap().due_at, 160); - prepare_channel_recovery(&mut queue, vec![first], first, 102); - assert_eq!(queue.front().unwrap().due_at, 102); - assert!(!queue.front().unwrap().watching); -} - -#[test] -fn channel_recovery_survives_restart_and_closed_view() { - let first = peers::chat_id_for_channel(1); - let second = peers::chat_id_for_channel(2); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![first], second, 100); - let encoded = serde_json::to_vec(&queue).unwrap(); - let mut restored = serde_json::from_slice(&encoded).unwrap(); - prepare_channel_recovery(&mut restored, Vec::new(), 0, 101); - assert_eq!(restored.len(), 1); - assert_eq!(restored.front().unwrap().chat_id, first); -} - -#[test] -fn begin_updates_preserves_the_unapplied_cursor_in_persistence() { - let path = std::env::temp_dir().join(format!( - "monogram-start-updates-{}-{}.json", - std::process::id(), - recovery_now() - )); - let snapshot = Snapshot::new(DEFAULT_DC_ID, &mut OsRandom).expect("snapshot"); - let mut state = ClientState { - api_id: 1, - api_hash: "hash".into(), - session_path: path.clone(), - snapshot, - user_id: None, - peers: HashMap::new(), - updates: None, - media: MediaIndex::default(), - updates_started: false, - channel_pts: HashMap::new(), - channel_recovery: VecDeque::new(), - seen_messages: HashSet::new(), - logout_tokens: Vec::new(), - new_session: None, - session_dead: false, - session_dead_reason: None, - test_dc: false, - last_inline: None, - permanent: None, - temp_expires_at: 0, - dc_auth_keys: HashMap::new(), - journal: Default::default(), - }; - - begin_updates(&mut state); - persist(&mut state).expect("persist start marker"); - let restored = FileSessionStore::new(&path) - .load() - .expect("load start marker") - .expect("session"); - - assert!(state.updates_started); - assert!(restored.updates.is_none()); - let _ = std::fs::remove_file(path); -} - -#[test] -fn clearing_active_dialog_keeps_server_recovery_hints() { - let path = std::env::temp_dir().join(format!( - "monogram-clear-dialog-{}-{}.json", - std::process::id(), - recovery_now() - )); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let watched = peers::chat_id_for_channel(1); - let server_hint = peers::chat_id_for_channel(2); - let client = get_client(handle).expect("client"); - { - let mut data = client.data.lock(); - data.last_history_chat_id = watched; - data.channel_recovery = VecDeque::from([ - ChannelRecovery { - chat_id: watched, - due_at: 1, - watching: true, - }, - ChannelRecovery { - chat_id: server_hint, - due_at: 2, - watching: false, - }, - ]); - } - - clear_active_dialog(handle).expect("clear active dialog"); - let restored = FileSessionStore::new(&path) - .load() - .expect("load cleared dialog") - .expect("session"); - - assert_eq!(client.data.lock().last_history_chat_id, 0); - assert_eq!(restored.channel_recovery.len(), 1); - assert_eq!(restored.channel_recovery[0].chat_id, server_hint); - assert!(!restored.channel_recovery[0].watching); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn channel_recovery_failure_retains_hint_without_blocking_other_channels() { - let first = peers::chat_id_for_channel(1); - let second = peers::chat_id_for_channel(2); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![first, second], 0, 100); - let entry = queue.pop_front().unwrap(); - defer_channel_recovery( - &mut queue, - entry, - &MtprotoError::Message("RPC 420: FLOOD_WAIT_60".into()), - 100, - ); - assert_eq!(queue.front().unwrap().chat_id, second); - assert_eq!(queue.back().unwrap().due_at, 160); - prepare_channel_recovery(&mut queue, vec![first], 0, 101); - assert_eq!(queue.back().unwrap().due_at, 160); -} - -#[test] -fn drop_updates_transport_unknown_handle_is_ok() { - drop_updates_transport(0); -} - -#[test] -fn prefer_newer_cursor_keeps_higher_pts() { - let older = UpdatesStateDto { - pts: 10, - qts: 0, - date: 1, - seq: 1, - }; - let newer = UpdatesStateDto { - pts: 20, - qts: 0, - date: 2, - seq: 2, - }; - let kept = prefer_newer_cursor(Some(newer.clone()), Some(older)); - assert_eq!(kept.unwrap().pts, 20); -} - -#[test] -fn cursor_sequences_progress_independently_of_pts() { - let old = UpdatesStateDto { - pts: 20, - qts: 3, - date: 4, - seq: 5, - }; - let next = UpdatesStateDto { - pts: 20, - qts: 4, - date: 6, - seq: 7, - }; - let kept = prefer_newer_cursor(Some(old), Some(next)).unwrap(); - assert_eq!((kept.pts, kept.qts, kept.date, kept.seq), (20, 4, 6, 7)); -} - -#[test] -fn lane_merge_preserves_concurrent_insert_update_and_delete() { - let before = HashMap::from_iter([(1, 10), (2, 20), (3, 30), (4, 40)]); - let mut current = HashMap::from_iter([(1, 11), (2, 20), (4, 40), (5, 50)]); - let incoming = HashMap::from_iter([(1, 10), (2, 22), (3, 30), (6, 60)]); - merge_changed_entries(&mut current, &before, incoming); - assert_eq!( - current, - HashMap::from_iter([(1, 11), (2, 22), (5, 50), (6, 60)]) - ); -} - -#[test] -fn main_lane_does_not_restore_stale_updates_metadata() { - let path = std::env::temp_dir().join(format!("mtproto-merge-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - with_client_mut(handle, |_state| { - let mut data = client.data.lock(); - data.channel_pts.insert(42, 13); - data.seen_messages.insert((42, 11)); - data.updates = Some(UpdatesStateDto { - pts: 9, - qts: 8, - date: 7, - seq: 6, - }); - Ok(()) - }) - .unwrap(); - let data = client.data.lock(); - assert_eq!(data.channel_pts.get(&42), Some(&13)); - assert!(data.seen_messages.contains(&(42, 11))); - assert_eq!(data.updates.as_ref().unwrap().qts, 8); - drop(data); - destroy_client(handle); -} diff --git a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs deleted file mode 100644 index b92e8567e..000000000 --- a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs +++ /dev/null @@ -1,946 +0,0 @@ -use super::*; -use crate::media; - -#[test] -fn configured_single_media_lane_disables_second_download_socket() { - let policy = std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock()); - let _guard = crate::transfer_policy::bind(policy.clone()); - policy.own_media_lanes(1); - assert!(!should_open_second_media_lane(2, 8)); - policy.own_media_lanes(2); - assert!(should_open_second_media_lane(2, 8)); - policy.floods.lock().park( - 2, - media::TransferClass::Download, - 1, - false, - std::time::Instant::now() - std::time::Duration::from_secs(5), - media::DownloadWindow { - chunk: media::FAST_CHUNK, - in_flight: 8, - }, - ); - assert!(media::check_transfer_flood(2, media::TransferClass::Download).is_ok()); - assert!(!should_open_second_media_lane(2, 8)); - assert!(should_open_second_media_lane(4, 8)); -} - -#[test] -fn download_sessions_grow_from_one_to_eight_and_reset_after_idle() { - let path = std::env::temp_dir().join(format!("monogram-growth-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - client.policy.own_media_lanes(8); - assert_eq!(client.media_open.load(Ordering::Acquire), 1); - scheduler::with_class(scheduler::RequestClass::InteractiveMedia, || { - let mut leases = Vec::new(); - for expected in 1..=8 { - leases.push(lock_media_lane(&client).unwrap()); - assert_eq!(client.media_open.load(Ordering::Acquire), expected); - } - assert!(close_idle_file_transports( - &client, - std::time::Instant::now() - )); - assert_eq!(client.media_open.load(Ordering::Acquire), 8); - drop(leases); - assert!(!close_idle_file_transports( - &client, - std::time::Instant::now() - )); - assert_eq!(client.media_open.load(Ordering::Acquire), 1); - }); - destroy_client(handle); - std::fs::remove_file(path).ok(); -} - -#[test] -fn idle_main_ack_deadline_flushes_all_pending_ids_without_an_rpc() { - let path = std::env::temp_dir().join(format!("monogram-ack-idle-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let transport = crate::rpc::open_live_addr(2, &address, None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - { - let mut io = client.main.lock(); - io.snapshot.auth_key = Some(vec![7; 256]); - for id in 0..130 { - io.snapshot.acknowledge((id * 4) + 1).unwrap(); - } - io.transport = Some(transport); - } - let now = std::time::Instant::now(); - let due = now + crate::rpc::ACK_DELAY; - assert!(flush_idle_main_acks(&client, now, due)); - assert_eq!( - client.main.lock().snapshot.pending_acknowledgements.len(), - 130 - ); - assert!(!flush_idle_main_acks(&client, due, due)); - assert!( - client - .main - .lock() - .snapshot - .pending_acknowledgements - .is_empty() - ); - assert!(client.main.lock().transport.is_some()); - destroy_client(handle); - std::fs::remove_file(path).ok(); -} - -#[test] -fn measured_upload_rtt_selects_the_small_part_path_in_production_staging() { - let path = - std::env::temp_dir().join(format!("monogram-upload-rtt-{}.json", std::process::id())); - let file = path.with_extension("bin"); - std::fs::write(&file, vec![1; 96 * 1024]).unwrap(); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - client.policy.own_file_part_kib(512); - client.policy.own_pipeline_parts(8); - let dc = client.main.lock().snapshot.dc_id; - *client.policy.upload_rtt.lock() = Some(( - dc, - std::time::Instant::now(), - std::time::Duration::from_secs(1), - )); - let item = crate::UploadItemDto { - path: file.to_string_lossy().into(), - kind: "document".into(), - mime_type: String::new(), - file_name: "test.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let mut sizes = Vec::new(); - save_items_with(&client, 1, &[item], |_, batch| { - assert_eq!(batch.bytes.len(), 1); - sizes.push(batch.bytes[0].len()); - Ok(()) - }) - .unwrap(); - assert_eq!(sizes, vec![32 * 1024; 3]); - destroy_client(handle); - std::fs::remove_file(path).ok(); - std::fs::remove_file(file).ok(); -} - -#[test] -fn updates_yield_to_main_rpc_and_media_keep_distinct_sessions() { - let path = std::env::temp_dir().join(format!("monogram-lane-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - let main = client.main.lock(); - let main_id = main.snapshot.session_id; - let drained = drain_updates(handle).unwrap(); - assert!(drained.batches.is_empty() && drained.live_events.is_empty()); - let media_id = client.media[0].io.lock().snapshot.session_id; - // Distinct session per lane, whatever the configured lane count. - let media_ids: Vec = client - .media - .iter() - .map(|lane| lane.io.lock().snapshot.session_id) - .collect(); - drop(main); - destroy_client(handle); - let _ = std::fs::remove_file(path); - assert_ne!(main_id, media_id); - let mut unique = media_ids.clone(); - unique.sort_unstable(); - unique.dedup(); - assert_eq!( - unique.len(), - media_ids.len(), - "media lanes share a session: {media_ids:?}" - ); -} - -#[test] -fn updates_drain_uses_main_gate_between_home_media_batches() { - let path = - std::env::temp_dir().join(format!("monogram-updates-gate-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - let media_batch = client - .main_gate - .acquire(crate::scheduler::RequestClass::InteractiveMedia) - .expect("media batch gate"); - let (started_tx, started_rx) = std::sync::mpsc::channel(); - let (admitted_tx, admitted_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let updates_client = client.clone(); - let updates = std::thread::spawn(move || { - started_tx.send(()).expect("updates thread start"); - let _updates_lane = lock_updates_lane(&updates_client) - .expect("updates lane") - .expect("idle transport"); - admitted_tx.send(()).expect("updates lane admitted"); - release_rx.recv().expect("release updates lane"); - }); - started_rx.recv().expect("updates thread started"); - assert!( - admitted_rx - .recv_timeout(std::time::Duration::from_millis(50)) - .is_err(), - "update drain must wait for the home-media batch" - ); - drop(media_batch); - admitted_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("updates lane admitted after media batch"); - assert!( - client - .main_gate - .try_acquire(crate::scheduler::RequestClass::InteractiveMedia) - .is_none(), - "a later media batch must wait for the admitted updates drain" - ); - release_tx.send(()).expect("release updates lane"); - updates.join().expect("updates thread"); - assert!( - client - .main_gate - .try_acquire(crate::scheduler::RequestClass::InteractiveMedia) - .is_some(), - "media must resume after updates releases the main gate" - ); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -fn sample_home() -> Snapshot { - let mut home = Snapshot::new(2, &mut OsRandom).expect("home"); - home.auth_key = Some(vec![7_u8; 256]); - home.server_salt = 99; - home.time_offset_micros = 1_000; - home -} - -#[test] -fn home_dc_media_reuses_forked_lane_without_export() { - let home = sample_home(); - let mut lane = fork_session(&home); - let session_id = lane.session_id; - assert_ne!(session_id, home.session_id); - assert_eq!( - prepare_media_lane_snapshot(&mut lane, &home, 2), - MediaLanePrep::Reuse - ); - assert_eq!(lane.session_id, session_id); - assert_eq!(lane.auth_key, home.auth_key); - assert_eq!(lane.server_salt, 99); -} - -#[test] -fn home_dc_media_reforks_after_foreign_dc_auth() { - let home = sample_home(); - let mut lane = fork_session(&home); - lane.dc_id = 4; - lane.auth_key = Some(vec![3_u8; 256]); - let old_session = lane.session_id; - assert_eq!( - prepare_media_lane_snapshot(&mut lane, &home, 2), - MediaLanePrep::Replaced - ); - assert_eq!(lane.dc_id, 2); - assert_eq!(lane.auth_key, home.auth_key); - assert_ne!(lane.session_id, home.session_id); - assert_ne!(lane.session_id, old_session); -} - -#[test] -fn foreign_dc_media_exports_when_lane_still_has_home_auth() { - let home = sample_home(); - let mut lane = fork_session(&home); - assert_eq!( - prepare_media_lane_snapshot(&mut lane, &home, 4), - MediaLanePrep::NeedExport - ); - assert_eq!(lane.auth_key, home.auth_key); -} - -#[test] -fn foreign_dc_media_reuses_exported_auth() { - let home = sample_home(); - let mut lane = fork_session(&home); - lane.dc_id = 4; - lane.auth_key = Some(vec![9_u8; 256]); - let session_id = lane.session_id; - assert_eq!( - prepare_media_lane_snapshot(&mut lane, &home, 4), - MediaLanePrep::Reuse - ); - assert_eq!(lane.session_id, session_id); - assert_eq!(lane.dc_id, 4); -} - -/// Advertise server permission to verify that permission alone cannot enable -/// permanent-key parallel sessions before PFS is implemented. -fn allow_read_lanes() -> std::sync::MutexGuard<'static, ()> { - let guard = crate::scheduler::ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - crate::scheduler::set_main_session_allowance(Some(3)); - guard -} - -#[test] -fn read_keeps_home_session_when_a_secondary_lane_is_busy() { - let _allowance = allow_read_lanes(); - let path = - std::env::temp_dir().join(format!("monogram-read-lane-{}.session", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - authorize_test_client(handle); - let client = get_client(handle).expect("client"); - // A busy unused secondary lane must not affect the single home session. - let _held = client.rpc[0] - .gate - .acquire(crate::scheduler::RequestClass::InteractiveRead) - .expect("hold lane"); - let started = std::time::Instant::now(); - let mut used_read_lane = false; - with_read_lane(handle, |_state| { - used_read_lane = crate::api_invoke::invoking_without_updates(); - Ok(()) - }) - .expect("read lane call"); - let elapsed = started.elapsed(); - destroy_client(handle); - let _ = std::fs::remove_file(path); - assert!( - !used_read_lane, - "permanent-key read opened a parallel session" - ); - assert!( - elapsed < std::time::Duration::from_millis(150), - "read waited for an unused secondary lane ({elapsed:?})" - ); -} - -#[test] -fn read_does_not_wait_for_unused_secondary_lanes_without_pfs() { - let _allowance = allow_read_lanes(); - let path = - std::env::temp_dir().join(format!("monogram-read-wait-{}.session", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - authorize_test_client(handle); - let client = get_client(handle).expect("client"); - // Hold both secondary lanes: the caller must still use the home session. - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); - let holder = { - let client = client.clone(); - std::thread::spawn(move || { - let _first = client.rpc[0] - .gate - .acquire(crate::scheduler::RequestClass::InteractiveRead) - .expect("hold lane 0"); - let _second = client.rpc[1] - .gate - .acquire(crate::scheduler::RequestClass::InteractiveRead) - .expect("hold lane 1"); - let _ = held_tx.send(()); - let _ = release_rx.recv_timeout(std::time::Duration::from_secs(5)); - }) - }; - held_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("holder acquired both lanes"); - let (done_tx, done_rx) = std::sync::mpsc::channel::(); - let caller = std::thread::spawn(move || { - let result = with_read_lane(handle, |_state| { - Ok(crate::api_invoke::invoking_without_updates()) - }); - let _ = done_tx.send(result.expect("read lane call")); - }); - let used_read_lane = done_rx.recv_timeout(std::time::Duration::from_secs(5)); - release_tx.send(()).expect("release lanes"); - holder.join().expect("holder thread"); - caller.join().expect("caller thread"); - destroy_client(handle); - let _ = std::fs::remove_file(path); - assert!(!used_read_lane.expect("home read completed while secondary lanes held")); -} - -fn authorize_test_client(handle: u64) -> (i64, Vec) { - let client = get_client(handle).expect("client"); - let _policy = crate::transfer_policy::bind(client.policy.clone()); - crate::scheduler::set_main_session_allowance(Some(3)); - let mut main = client.main.lock(); - main.snapshot.auth_key = Some(vec![7_u8; 256]); - main.snapshot.server_salt = 99; - let home_id = main.snapshot.session_id; - let auth = main.snapshot.auth_key.clone().unwrap(); - let mut data = client.data.lock(); - data.user_id = Some(1); - data.home_dc = main.snapshot.dc_id; - data.home_session_id = home_id; - data.home_auth_key = Some(auth.clone()); - data.home_salt = 99; - data.home_snapshot = main.snapshot.clone(); - (home_id, auth) -} - -#[test] -fn extra_read_lane_reuses_home_auth_without_export() { - let home = sample_home(); - let mut lane = fork_session(&home); - let session_id = lane.session_id; - assert_ne!(session_id, home.session_id); - assert_eq!( - prepare_media_lane_snapshot(&mut lane, &home, home.dc_id), - MediaLanePrep::Reuse - ); - assert_eq!(lane.session_id, session_id); - assert_eq!(lane.auth_key, home.auth_key); - assert_ne!(lane.session_id, home.session_id); -} - -#[test] -fn server_allowance_without_pfs_preserves_home_identity() { - let _allowance = allow_read_lanes(); - let path = - std::env::temp_dir().join(format!("monogram-extra-rpc-{}.session", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let (home_id, auth) = authorize_test_client(handle); - let mut seen_session = 0_i64; - with_read_lane(handle, |state| { - seen_session = state.snapshot.session_id; - assert_eq!(state.snapshot.session_id, home_id); - assert_eq!(state.snapshot.auth_key.as_deref(), Some(auth.as_slice())); - assert!(!crate::api_invoke::invoking_without_updates()); - Ok(()) - }) - .expect("extra lane"); - let client = get_client(handle).expect("client"); - { - let data = client.data.lock(); - assert_eq!(data.home_session_id, home_id); - assert_eq!(data.home_auth_key.as_deref(), Some(auth.as_slice())); - } - assert_eq!(seen_session, home_id); - assert!(!crate::api_invoke::invoking_without_updates()); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn idle_work_uses_home_session_without_pfs() { - let _allowance = allow_read_lanes(); - let path = std::env::temp_dir().join(format!( - "monogram-extra-busy-{}.session", - std::process::id() - )); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let (_home_id, _) = authorize_test_client(handle); - let mut used_read_lane = false; - with_read_lane(handle, |_state| { - used_read_lane = crate::api_invoke::invoking_without_updates(); - Ok(()) - }) - .expect("read lane"); - destroy_client(handle); - let _ = std::fs::remove_file(path); - assert!(!used_read_lane); -} - -#[test] -fn bound_temp_read_lane_does_not_reuse_the_permanent_key() { - let _allowance = allow_read_lanes(); - struct Reset; - impl Drop for Reset { - fn drop(&mut self) { - crate::scheduler::set_bound_extra_sessions(0); - } - } - let _reset = Reset; - let path = - std::env::temp_dir().join(format!("monogram-temp-read-{}.session", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let (home_id, home_auth) = authorize_test_client(handle); - let client = get_client(handle).expect("client"); - let _policy = crate::transfer_policy::bind(client.policy.clone()); - let temp_key = vec![9_u8; 256]; - let temp_id = { - let mut io = client.rpc[0].io.lock(); - let mut snap = Snapshot::new(io.snapshot.dc_id, &mut OsRandom).expect("temp"); - snap.auth_key = Some(temp_key.clone()); - snap.server_salt = 99; - let id = snap.session_id; - io.snapshot = snap; - io.perm_key = Some(home_auth.clone()); - io.temp_expires_at = crate::auth::bind_temp::unix_now().saturating_add(3600); - io.transport = None; - id - }; - crate::scheduler::set_bound_extra_sessions(1); - let mut seen = 0_i64; - with_read_lane(handle, |state| { - seen = state.snapshot.session_id; - assert_eq!( - state.snapshot.auth_key.as_deref(), - Some(temp_key.as_slice()) - ); - assert_ne!(state.snapshot.session_id, home_id); - assert!(crate::api_invoke::invoking_without_updates()); - Ok(()) - }) - .expect("temp read"); - assert_eq!(seen, temp_id); - let data = client.data.lock(); - assert_eq!(data.home_auth_key.as_deref(), Some(home_auth.as_slice())); - assert_eq!(data.home_session_id, home_id); - drop(data); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn upload_batches_take_the_main_lane_once_per_batch() { - let _allowance = crate::scheduler::ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let original = crate::scheduler::main_session_allowance(); - crate::scheduler::set_main_session_allowance(None); - assert_eq!( - crate::scheduler::extra_main_sessions(), - 0, - "a home DC without tmp_sessions allows one main session" - ); - - let stem = format!("monogram-upload-{}", std::process::id()); - let file = std::env::temp_dir().join(format!("{stem}.bin")); - let session = std::env::temp_dir().join(format!("{stem}.session")); - std::fs::write( - &file, - vec![5_u8; crate::upload::upload_rpc::FILE_PART_FAST + 1], - ) - .expect("fixture"); - let item = crate::UploadItemDto { - path: file.to_string_lossy().into_owned(), - kind: "document".into(), - mime_type: String::new(), - file_name: "fixture.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let handle = create_client(1, "hash".into(), session.to_string_lossy().into()); - authorize_test_client(handle); - let client = get_client(handle).expect("client"); - let mut staging = crate::upload::upload_rpc::open_staging(&item).expect("staging"); - let expected_parts = - crate::upload::upload_rpc::part_count(std::fs::metadata(&file).unwrap().len()); - let mut batches = 0; - upload_batches(&mut staging, 1, |_batch| { - assert!( - client.main.try_lock().is_some(), - "the upload held the main lane across batches" - ); - batches += 1; - Ok(()) - }) - .expect("batches"); - assert_eq!( - batches, expected_parts, - "one lane acquisition per part batch" - ); - destroy_client(handle); - let _ = std::fs::remove_file(file); - let _ = std::fs::remove_file(session); - crate::scheduler::set_main_session_allowance(Some(original)); -} - -#[test] -fn even_odd_media_offsets_split_by_chunk() { - let chunk = 128 * 1024; - let offsets = [0, chunk, chunk * 2, chunk * 3, chunk * 4]; - let (even, odd) = split_even_odd_request_indices(&offsets, chunk as i32); - assert_eq!(even, vec![0, 2, 4]); - assert_eq!(odd, vec![1, 3]); -} - -#[test] -fn parallel_media_refills_fast_lane_before_slow_lane_completes() { - use std::sync::mpsc::channel; - use std::time::Duration; - let (refilled, wait_for_refill) = channel(); - let caller = std::thread::current().id(); - let mut delivered = Vec::new(); - let results = media_download::invoke_parallel_media_batches( - [vec![(0, 0usize)], vec![(1, 1usize)]], - |mut requests, on_chunk| { - let mut results = Vec::new(); - let mut index = 0; - while index < requests.len() { - if index == 1 { - refilled.send(()).unwrap(); - } - let value = requests[index]; - if let Some(next) = on_chunk(index, Ok(value)) { - requests.push(next); - } - results.push(Ok(value)); - index += 1; - } - Ok(results) - }, - move |requests, on_chunk| { - wait_for_refill - .recv_timeout(Duration::from_secs(2)) - .expect("fast lane must refill while slow lane is outstanding"); - assert_eq!(requests, vec![1]); - assert!(on_chunk(0, Ok(1)).is_none()); - Ok(vec![Ok(1)]) - }, - &mut |index, result| { - assert_eq!(std::thread::current().id(), caller); - delivered.push((index, result.unwrap())); - (index == 0).then_some(2) - }, - ) - .unwrap(); - assert_eq!( - results.into_iter().collect::, _>>().unwrap(), - vec![0, 1, 2] - ); - delivered.sort_unstable(); - assert_eq!(delivered, vec![(0, 0), (1, 1), (2, 2)]); -} - -#[test] -fn parallel_media_preserves_lane_error_and_delivers_other_lane() { - let mut delivered = Vec::new(); - let result = media_download::invoke_parallel_media_batches( - [vec![(0, 0usize)], vec![(1, 1usize)]], - |_, _| Err(MtprotoError::Message("lane failed".into())), - |_, on_chunk| { - assert!(on_chunk(0, Ok(1usize)).is_none()); - Ok(vec![Ok(1)]) - }, - &mut |index, result| { - delivered.push((index, result.unwrap())); - None - }, - ); - assert!(matches!(result, Err(MtprotoError::Message(message)) if message == "lane failed")); - assert_eq!(delivered, vec![(1, 1)]); -} - -#[test] -fn parallel_media_does_not_refill_when_callback_stops() { - let invoke = - |requests: Vec, - on_chunk: &mut dyn FnMut(usize, Result) -> Option| { - for (index, value) in requests.iter().copied().enumerate() { - assert!(on_chunk(index, Ok(value)).is_none()); - } - Ok(requests.into_iter().map(Ok).collect()) - }; - let mut delivered = Vec::new(); - let results = media_download::invoke_parallel_media_batches( - [vec![(0, 0)], vec![(1, 1)]], - invoke, - invoke, - &mut |index, result| { - delivered.push((index, result.unwrap())); - None - }, - ) - .unwrap(); - assert_eq!(results.len(), 2); - delivered.sort_unstable(); - assert_eq!(delivered, vec![(0, 0), (1, 1)]); -} - -#[test] -fn extra_main_sessions_stay_zero_without_tmp_sessions() { - let _allowance = crate::scheduler::ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - assert_eq!(crate::scheduler::extra_main_sessions(), 0); -} - -#[test] -fn file_parts_use_one_upload_session_and_metadata_stays_on_main() { - let stem = format!( - "monogram-upload-session-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let small = std::env::temp_dir().join(format!("{stem}-small.bin")); - let big = std::env::temp_dir().join(format!("{stem}-big.bin")); - let session = std::env::temp_dir().join(format!("{stem}.session")); - std::fs::write(&small, vec![9_u8; 64]).expect("small fixture"); - let big_file = std::fs::File::create(&big).expect("big fixture"); - big_file - .set_len(crate::upload::upload_rpc::BIG_FILE_THRESHOLD + 1) - .expect("big length"); - drop(big_file); - let small_item = crate::UploadItemDto { - path: small.to_string_lossy().into_owned(), - kind: "document".into(), - mime_type: String::new(), - file_name: "small.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let big_item = crate::UploadItemDto { - path: big.to_string_lossy().into_owned(), - kind: "document".into(), - mime_type: String::new(), - file_name: "big.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let handle = create_client(1, "hash".into(), session.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - let main_id = client.main.lock().snapshot.session_id; - let media_ids: Vec = client - .media - .iter() - .map(|lane| lane.io.lock().snapshot.session_id) - .collect(); - let rpc_ids = [ - client.rpc[0].io.lock().snapshot.session_id, - client.rpc[1].io.lock().snapshot.session_id, - ]; - let mut seen = Vec::new(); - save_items_with(&client, 1, &[small_item, big_item], |snapshot, batch| { - seen.push((snapshot.session_id, batch.big)); - Ok(()) - }) - .expect("recorded parts"); - let upload_id = client.upload[0].io.lock().snapshot.session_id; - assert_ne!(upload_id, main_id); - assert!(!media_ids.contains(&upload_id)); - assert!(!rpc_ids.contains(&upload_id)); - assert!(seen.iter().any(|(_, big)| !big), "saveFilePart"); - assert!(seen.iter().any(|(_, big)| *big), "saveBigFilePart"); - assert!(seen.iter().all(|(id, _)| *id == upload_id)); - with_interactive_client_mut(handle, |state| { - assert_eq!(state.snapshot.session_id, main_id); - Ok(()) - }) - .expect("main metadata session"); - destroy_client(handle); - let _ = std::fs::remove_file(small); - let _ = std::fs::remove_file(big); - let _ = std::fs::remove_file(session); -} - -#[test] -fn idle_file_cleanup_closes_sockets_and_preserves_busy_lanes_and_main() { - use std::io::Read; - let path = std::env::temp_dir().join(format!("monogram-file-idle-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let now = std::time::Instant::now(); - let mut media = crate::rpc::open_live_addr(2, &address, None, 1).unwrap(); - let (mut media_peer, _) = listener.accept().unwrap(); - let mut upload = crate::rpc::open_live_addr(2, &address, None, 1).unwrap(); - let (mut upload_peer, _) = listener.accept().unwrap(); - let main = crate::rpc::open_live_addr(2, &address, None, 1).unwrap(); - let (_main_peer, _) = listener.accept().unwrap(); - media.last_io = now; - upload.last_io = now; - client.media[0].io.lock().transport = Some(media); - client.upload[0].io.lock().transport = Some(upload); - client.main.lock().transport = Some(main); - assert!(close_idle_file_transports(&client, now)); - let busy = client.media[0].io.lock(); - let later = now + crate::media::DOWNLOAD_SESSION_IDLE; - assert!(close_idle_file_transports(&client, later)); - assert!(busy.transport.is_some()); - assert!(client.upload[0].io.lock().transport.is_none()); - drop(busy); - assert!(!close_idle_file_transports(&client, later)); - assert!(client.media[0].io.lock().transport.is_none()); - assert!(client.main.lock().transport.is_some()); - for peer in [&mut media_peer, &mut upload_peer] { - peer.set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - let mut header = [0u8; 64]; - peer.read_exact(&mut header).unwrap(); - assert_eq!(peer.read(&mut [0u8; 1]).unwrap(), 0); - } - destroy_client(handle); - std::fs::remove_file(path).ok(); -} - -#[test] -fn media_waiters_use_any_freed_lane_and_admit_interactive_before_background() { - let path = - std::env::temp_dir().join(format!("monogram-lane-wakeup-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - client.policy.own_media_lanes(2); - let (first, last) = scheduler::with_class(scheduler::RequestClass::InteractiveMedia, || { - ( - lock_media_lane(&client).unwrap(), - lock_media_lane(&client).unwrap(), - ) - }); - let first_id = first.snapshot.session_id; - let (tx, rx) = std::sync::mpsc::channel(); - let background = client.clone(); - let background_tx = tx.clone(); - let worker = std::thread::spawn(move || { - scheduler::with_class(scheduler::RequestClass::BackgroundMedia, || { - let lease = lock_media_lane(&background).unwrap(); - background_tx - .send((false, lease.snapshot.session_id)) - .unwrap(); - }) - }); - let wait_for_queue = |count| { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - while client.media_gate.waiter_count() < count && std::time::Instant::now() < deadline { - std::thread::yield_now(); - } - assert_eq!(client.media_gate.waiter_count(), count); - }; - wait_for_queue(1); - let interactive = client.clone(); - let foreground = std::thread::spawn(move || { - scheduler::with_class(scheduler::RequestClass::InteractiveMedia, || { - let lease = lock_media_lane(&interactive).unwrap(); - tx.send((true, lease.snapshot.session_id)).unwrap(); - }) - }); - wait_for_queue(2); - drop(first); - assert_eq!( - rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), - (true, first_id) - ); - assert_eq!( - rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(), - (false, first_id) - ); - drop(last); - foreground.join().unwrap(); - worker.join().unwrap(); - destroy_client(handle); - std::fs::remove_file(path).ok(); -} - -#[test] -fn expired_upload_rtt_does_not_require_a_probe_connection_or_inherit_download_caps() { - let path = std::env::temp_dir().join(format!( - "monogram-upload-fallback-{}.json", - std::process::id() - )); - let file = path.with_extension("bin"); - std::fs::write(&file, vec![1; 96 * 1024]).unwrap(); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - client.policy.own_pipeline_parts(2); - let dc = client.main.lock().snapshot.dc_id; - *client.policy.upload_rtt.lock() = Some(( - dc, - std::time::Instant::now() - std::time::Duration::from_secs(61), - std::time::Duration::from_secs(1), - )); - let item = crate::UploadItemDto { - path: file.to_string_lossy().into(), - kind: "document".into(), - mime_type: String::new(), - file_name: "test.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let staging = save_items_with(&client, 1, &[item], |snapshot, batch| { - assert!(snapshot.auth_key.is_none()); - assert_eq!(batch.bytes[0].len(), 96 * 1024); - Ok(()) - }) - .unwrap(); - assert_eq!(staging[0].in_flight(), 8); - assert!(client.upload[0].io.lock().transport.is_none()); - destroy_client(handle); - std::fs::remove_file(path).ok(); - std::fs::remove_file(file).ok(); -} - -#[test] -fn failed_rtt_probe_on_an_existing_upload_connection_does_not_fail_saved_parts() { - let path = std::env::temp_dir().join(format!( - "monogram-upload-probe-error-{}.json", - std::process::id() - )); - let file = path.with_extension("bin"); - std::fs::write(&file, vec![1; 96 * 1024]).unwrap(); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - authorize_test_client(handle); - let client = get_client(handle).unwrap(); - let home = client.main.lock().snapshot.clone(); - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let transport = crate::rpc::open_live_addr( - home.dc_id, - &listener.local_addr().unwrap().to_string(), - None, - 1, - ) - .unwrap(); - let (peer, _) = listener.accept().unwrap(); - drop(peer); - { - let mut io = client.upload[0].io.lock(); - io.snapshot = fork_session(&home); - io.transport = Some(transport); - } - let item = crate::UploadItemDto { - path: file.to_string_lossy().into(), - kind: "document".into(), - mime_type: String::new(), - file_name: "test.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let mut saved = 0; - let staging = save_items_with(&client, 1, &[item], |_, batch| { - saved += batch.bytes.len(); - Ok(()) - }) - .unwrap(); - assert_eq!(saved, 1); - assert_eq!(staging.len(), 1); - assert!(client.policy.upload_rtt.lock().is_none()); - assert!(client.upload[0].io.lock().transport.is_none()); - destroy_client(handle); - std::fs::remove_file(path).ok(); - std::fs::remove_file(file).ok(); -} diff --git a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs deleted file mode 100644 index 8a290e615..000000000 --- a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs +++ /dev/null @@ -1,467 +0,0 @@ -use super::*; - -#[test] -fn logout_clears_unauthorized_local_session_without_network() { - let path = std::env::temp_dir().join(format!("monogram-logout-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - { - let mut io = client.main.lock(); - io.snapshot.auth_key = Some(vec![7; 256]); - } - let state = ClientState { - api_id: 1, - api_hash: "hash".into(), - session_path: path.clone(), - snapshot: client.main.lock().snapshot.clone(), - user_id: None, - peers: HashMap::new(), - updates: None, - media: MediaIndex::default(), - updates_started: false, - channel_pts: HashMap::new(), - channel_recovery: VecDeque::new(), - seen_messages: HashSet::new(), - logout_tokens: Vec::new(), - new_session: None, - session_dead: false, - session_dead_reason: None, - test_dc: false, - last_inline: None, - permanent: None, - temp_expires_at: 0, - dc_auth_keys: HashMap::new(), - journal: Default::default(), - }; - let mut state = state; - persist(&mut state).expect("seed session"); - assert!(path.exists()); - - logout(handle).expect("local logout"); - assert!(!path.exists()); - assert!(!is_authorized(handle).expect("authorization state")); - destroy_client(handle); -} - -fn dc_key() -> crate::session_file::DcAuthKey { - crate::session_file::DcAuthKey { - key: vec![9; 256], - salt: 3, - } -} - -fn seed_dc_auth(handle: u64) { - with_client_mut(handle, |state| { - state.dc_auth_keys.insert(4, dc_key()); - Ok(()) - }) - .unwrap(); - let client = get_client(handle).unwrap(); - let snapshot = client.main.lock().snapshot.clone(); - client.data.lock().dc_temp_auth.insert( - 4, - DcTempAuth { - snapshot, - temp_expires_at: i32::MAX, - perm_key: vec![9; 256], - }, - ); -} - -#[test] -fn cached_foreign_dc_key_skips_export_until_the_key_is_unusable() { - let path = std::env::temp_dir().join(format!("monogram-dc-fast-{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let mut snapshot = client.main.lock().snapshot.clone(); - snapshot.dc_id = 2; - let mut dc_auth_keys = HashMap::new(); - dc_auth_keys.insert(4, dc_key()); - dc_auth_keys.insert( - 2, - crate::session_file::DcAuthKey { - key: vec![4; 256], - salt: 1, - }, - ); - dc_auth_keys.insert( - 5, - crate::session_file::DcAuthKey { - key: vec![1; 8], - salt: 0, - }, - ); - let mut state = ClientState { - api_id: 1, - api_hash: "hash".into(), - session_path: path.clone(), - snapshot, - user_id: Some(1), - peers: HashMap::new(), - updates: None, - media: MediaIndex::default(), - updates_started: false, - channel_pts: HashMap::new(), - channel_recovery: VecDeque::new(), - seen_messages: HashSet::new(), - logout_tokens: Vec::new(), - new_session: None, - session_dead: false, - session_dead_reason: None, - test_dc: false, - last_inline: None, - permanent: None, - temp_expires_at: 0, - dc_auth_keys, - journal: Default::default(), - }; - let foreign = copy_authorization_to_dc(&mut state, 4).unwrap(); - assert_eq!(foreign.dc_id, 4); - assert_eq!(foreign.server_salt, 3); - assert_eq!(foreign.auth_key.as_ref().map(Vec::len), Some(256)); - assert!(copy_authorization_to_dc(&mut state, 2).is_err()); - assert!(copy_authorization_to_dc(&mut state, 5).is_err()); - assert!(is_foreign_auth_key_failure(&MtprotoError::Message( - "401: AUTH_KEY_UNREGISTERED".into() - ))); - state.dc_auth_keys.remove(&4); - assert!(!state.dc_auth_keys.contains_key(&4)); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn identity_changes_drop_dc_auth_and_a_later_save_cannot_restore_them() { - let path = std::env::temp_dir().join(format!("monogram-dc-clear-{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - seed_dc_auth(handle); - with_client_mut(handle, |state| { - state.user_id = Some(7); - Ok(()) - }) - .unwrap(); - { - let client = get_client(handle).unwrap(); - let data = client.data.lock(); - assert!(data.dc_auth_keys.is_empty()); - assert!(data.dc_temp_auth.is_empty()); - } - seed_dc_auth(handle); - with_client_mut(handle, |state| { - state.test_dc = !state.test_dc; - Ok(()) - }) - .unwrap(); - { - let client = get_client(handle).unwrap(); - let data = client.data.lock(); - assert!(data.dc_auth_keys.is_empty()); - assert!(data.dc_temp_auth.is_empty()); - } - seed_dc_auth(handle); - with_client_mut(handle, |state| { - state.snapshot.auth_key = Some(vec![8; 256]); - Ok(()) - }) - .unwrap(); - { - let client = get_client(handle).unwrap(); - let data = client.data.lock(); - assert!(data.dc_auth_keys.is_empty()); - assert!(data.dc_temp_auth.is_empty()); - } - destroy_client(handle); - let _ = std::fs::remove_file(&path); - let path = std::env::temp_dir().join(format!("monogram-dc-logout-{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - seed_dc_auth(handle); - logout(handle).unwrap(); - let client = get_client(handle).unwrap(); - assert!(client.data.lock().dc_auth_keys.is_empty()); - assert!(client.data.lock().dc_temp_auth.is_empty()); - let snapshot = client.main.lock().snapshot.clone(); - let mut state = ClientState { - api_id: 1, - api_hash: "hash".into(), - session_path: path.clone(), - snapshot, - user_id: None, - peers: HashMap::new(), - updates: None, - media: MediaIndex::default(), - updates_started: false, - channel_pts: HashMap::new(), - channel_recovery: VecDeque::new(), - seen_messages: HashSet::new(), - logout_tokens: Vec::new(), - new_session: None, - session_dead: false, - session_dead_reason: None, - test_dc: false, - last_inline: None, - permanent: None, - temp_expires_at: 0, - dc_auth_keys: client.data.lock().dc_auth_keys.clone(), - journal: Default::default(), - }; - persist(&mut state).unwrap(); - let loaded = FileSessionStore::new(&path).load().unwrap().unwrap(); - assert!(loaded.dc_auth_keys.is_empty()); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn update_persistence_uses_current_home_snapshot() { - let path = std::env::temp_dir().join(format!( - "mtproto-persist-current-{}.json", - std::process::id() - )); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let session_id = client.main.lock().snapshot.session_id; - persist_updates_data(&client, session_id).unwrap(); - client.main.lock().snapshot.server_salt = 73; - persist_updates_data(&client, session_id).unwrap(); - let loaded = FileSessionStore::new(&path).load().unwrap().unwrap(); - assert_eq!(loaded.snapshot.server_salt, 73); - destroy_client(handle); - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn logout_fences_completed_update_lane_and_snapshot_save() { - let path = - std::env::temp_dir().join(format!("mtproto-logout-fence-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let old_home = client.main.lock().snapshot.clone(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let (resume_tx, resume_rx) = std::sync::mpsc::channel(); - let worker_client = client.clone(); - let worker = std::thread::spawn(move || { - ready_tx.send(()).unwrap(); - resume_rx.recv().unwrap(); - assert!(persist_updates_data(&worker_client, old_home.session_id).is_err()); - }); - ready_rx.recv().unwrap(); - logout(handle).unwrap(); - resume_tx.send(()).unwrap(); - worker.join().unwrap(); - assert!(!path.exists()); - assert_eq!(client.main.lock().snapshot.server_salt, 0); - assert!(client.data.lock().updates.is_none()); - destroy_client(handle); -} - -#[test] -fn dead_media_response_cannot_invalidate_replacement_session() { - let path = - std::env::temp_dir().join(format!("mtproto-media-fence-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let old_session_id = client.main.lock().snapshot.session_id; - logout(handle).unwrap(); - let result = with_client_mut(handle, |state| { - if state.snapshot.session_id != old_session_id { - return Err(expired_session_lease()); - } - mark_session_dead( - state, - &MtprotoError::Message("RPC 401: AUTH_KEY_UNREGISTERED".into()), - ); - Ok(()) - }); - assert!(result.is_err()); - assert!(!client.data.lock().session_dead); - assert!(!path.exists()); - destroy_client(handle); -} - -#[test] -fn logout_prevents_completed_media_publication() { - let root = std::env::temp_dir().join(format!("mtproto-media-publish-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let handle = create_client( - 1, - "hash".into(), - root.join("session").to_string_lossy().into(), - ); - let client = get_client(handle).unwrap(); - let session_id = client.main.lock().snapshot.session_id; - let dest = root.join("media"); - std::fs::write(&dest, b"previous").unwrap(); - let staged = media_rpc::StagedDownload::new(&dest).unwrap(); - let staging_path = staged.path().to_owned(); - std::fs::write(&staging_path, b"old session result").unwrap(); - logout(handle).unwrap(); - assert!(publish_media(&client, session_id, staged, &dest).is_err()); - assert!(!staging_path.exists()); - assert_eq!(std::fs::read(&dest).unwrap(), b"previous"); - destroy_client(handle); - std::fs::remove_dir_all(root).unwrap(); -} - -#[test] -fn cancelled_request_exits_while_lane_is_still_owned_by_another_call() { - let lane = Arc::new(Mutex::new(SessionIo { - pending_push: Default::default(), - last_difference: None, - snapshot: Snapshot::new(2, &mut OsRandom).unwrap(), - transport: None, - temp_expires_at: 0, - perm_key: None, - })); - let held = lane.lock(); - let worker_lane = lane.clone(); - let id = crate::request_control::create(); - let (started_tx, started_rx) = std::sync::mpsc::channel(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let previous = crate::request_control::bind(id); - started_tx.send(()).unwrap(); - done_tx - .send(lock_request_lane(&worker_lane, "lane_wait.test").is_err()) - .unwrap(); - crate::request_control::bind(previous); - }); - started_rx.recv().unwrap(); - crate::request_control::cancel(id); - let result = done_rx.recv_timeout(std::time::Duration::from_secs(2)); - drop(held); - worker.join().unwrap(); - crate::request_control::release(id); - assert_eq!(result.unwrap(), true); -} - -#[test] -fn merge_changed_entries_reports_no_change() { - let before = HashMap::from_iter([(1, 10)]); - let mut current = before.clone(); - let incoming = before.clone(); - assert!(!merge_changed_entries(&mut current, &before, incoming)); - assert_eq!(current, before); -} - -#[test] -fn empty_update_poll_skips_cache_clone() { - let path = - std::env::temp_dir().join(format!("monogram-empty-poll-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - authorize_test_client(handle); - { - let mut data = client.data.lock(); - data.updates_started = true; - data.updates = Some(UpdatesStateDto { - pts: 1, - qts: 1, - date: 1, - seq: 1, - }); - for i in 0..2_000 { - data.peers.insert( - i, - peers::CachedPeer { - kind: peers::PeerKind::User, - id: i, - access_hash: i, - min_hash: false, - }, - ); - } - } - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let transport = - crate::rpc::open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1) - .unwrap(); - let (_peer, _) = listener.accept().unwrap(); - { - let mut main = client.main.lock(); - main.transport = Some(transport); - main.last_difference = Some(std::time::Instant::now()); - } - let _ = take_update_cache_clones(); - let events = drain_updates(handle).unwrap(); - assert!(events.batches.is_empty() && events.live_events.is_empty()); - assert_eq!(take_update_cache_clones(), 0); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn extra_lane_persist_is_coalesced_onto_one_worker() { - let path = - std::env::temp_dir().join(format!("monogram-persist-{}.session", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - let session_id = client.data.lock().home_session_id; - schedule_persist(&client, session_id); - schedule_persist(&client, session_id); - schedule_persist(&client, session_id); - let started = std::time::Instant::now(); - while client.persist_running.load(Ordering::Acquire) - || client.persist_queued.load(Ordering::Acquire) - { - if started.elapsed() > std::time::Duration::from_secs(2) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - destroy_client(handle); - let _ = std::fs::remove_file(&path); - let _ = std::fs::remove_file(format!("{}.tmp", path.display())); - assert!(!client.persist_running.load(Ordering::Acquire)); - assert!(!client.persist_queued.load(Ordering::Acquire)); -} - -#[test] -fn main_lane_schedules_persist_and_flush_on_destroy() { - let path = std::env::temp_dir().join(format!( - "monogram-main-persist-{}.session", - std::process::id() - )); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).expect("client"); - with_client_mut(handle, |_state| Ok(())).unwrap(); - with_client_mut(handle, |_state| Ok(())).unwrap(); - with_client_mut(handle, |_state| Ok(())).unwrap(); - assert_eq!(client.data.lock().persist_epoch, 0); - with_client_mut(handle, |state| { - state.user_id = Some(1); - Ok(()) - }) - .unwrap(); - let started = std::time::Instant::now(); - while client.persist_running.load(Ordering::Acquire) - || client.persist_queued.load(Ordering::Acquire) - { - if started.elapsed() > std::time::Duration::from_secs(2) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(client.data.lock().persist_epoch >= 1); - destroy_client(handle); - assert!(!client.persist_running.load(Ordering::Acquire)); - let _ = std::fs::remove_file(&path); -} - -fn authorize_test_client(handle: u64) -> (i64, Vec) { - let client = get_client(handle).expect("client"); - let mut main = client.main.lock(); - main.snapshot.auth_key = Some(vec![7_u8; 256]); - main.snapshot.server_salt = 99; - let home_id = main.snapshot.session_id; - let auth = main.snapshot.auth_key.clone().unwrap(); - let mut data = client.data.lock(); - data.user_id = Some(1); - data.home_dc = main.snapshot.dc_id; - data.home_session_id = home_id; - data.home_auth_key = Some(auth.clone()); - data.home_salt = 99; - (home_id, auth) -} diff --git a/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs deleted file mode 100644 index c79f724db..000000000 --- a/native/mtproto-rs/tests/unit/client_mgr_pfs_tests.rs +++ /dev/null @@ -1,969 +0,0 @@ -use super::*; -use crate::UpdateEventDto; - -struct Fixture { - handle: u64, - path: PathBuf, -} - -impl Fixture { - fn new() -> Self { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = - std::env::temp_dir().join(format!("monogram-pfs-{}-{nonce}.json", std::process::id())); - let handle = create_client(1, String::new(), path.to_string_lossy().into_owned()); - Self { handle, path } - } - fn temporary(&self, expires_at: i32) -> Arc { - let client = get_client(self.handle).unwrap(); - { - let mut main = client.main.lock(); - let mut data = client.data.lock(); - data.user_id = Some(7); - data.persist_epoch = 1; - data.home_auth_key = Some(vec![3; 256]); - data.home_snapshot.auth_key = data.home_auth_key.clone(); - main.snapshot = fork_session(&main.snapshot); - main.snapshot.auth_key = Some(vec![4; 256]); - main.temp_expires_at = expires_at; - main.perm_key = Some(vec![3; 256]); - } - client - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - destroy_client(self.handle); - let _ = std::fs::remove_file(&self.path); - } -} - -#[test] -fn main_renews_at_lane_boundary_and_preserves_permanent_identity() { - let now = crate::auth::bind_temp::unix_now(); - for expiry in [1, now + 30, now + 3600] { - let fixture = Fixture::new(); - let client = fixture.temporary(expiry); - let original_home = client.data.lock().home_session_id; - let calls = Cell::new(0); - with_client_mut_binding( - fixture.handle, - |state| { - let expected = if expiry > now + 60 { 4 } else { 5 }; - assert!(state.snapshot.auth_key.as_deref() == Some([expected; 256].as_slice())); - assert!( - state.permanent.as_ref().unwrap().auth_key.as_deref() - == Some([3; 256].as_slice()) - ); - persist(state) - }, - |permanent, dc, offset| { - calls.set(calls.get() + 1); - assert!(permanent == [3; 256]); - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![5; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: now + 3600, - }) - }, - ) - .unwrap(); - assert_eq!(calls.get(), usize::from(expiry <= now + 60)); - let restored = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); - assert_eq!(restored.snapshot.session_id, original_home); - let data = client.data.lock(); - assert!(data.home_auth_key.as_deref() == Some([3; 256].as_slice())); - assert_eq!(data.home_session_id, original_home); - } -} - -#[test] -fn failed_or_stale_renewal_never_runs_the_application_request_or_publishes_a_key() { - for mode in ["failure", "session", "account", "dc", "close"] { - let fixture = Fixture::new(); - let client = fixture.temporary(1); - let original = client.main.lock().snapshot.clone(); - let invoked = Cell::new(false); - let result = with_client_mut_binding( - fixture.handle, - |_| { - invoked.set(true); - Ok(()) - }, - |_, dc, _| { - if mode == "failure" { - return Err(MtprotoError::Message("injected bind failure".into())); - } - match mode { - "session" => client.data.lock().home_session_id = original.session_id, - "account" => client.data.lock().user_id = Some(8), - "dc" => client.data.lock().home_dc = dc + 1, - "close" => client.connections.close(), - _ => unreachable!(), - } - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![5; 256]); - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }, - ); - assert!(result.is_err(), "case {mode}"); - assert!(!invoked.get()); - assert!(client.main.lock().snapshot == original); - assert!(!fixture.path.exists()); - } -} - -#[test] -fn temporary_key_rejection_preserves_login_and_renews_before_the_next_request() { - for message in [ - "RPC 401: AUTH_KEY_UNREGISTERED", - "RPC 401: AUTH_KEY_PERM_EMPTY", - "RPC 406: AUTH_KEY_DUPLICATED", - "MTProto transport error -404", - ] { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - let original_home = client.data.lock().home_session_id; - let invocations = Cell::new(0); - let result = with_client_mut_binding( - fixture.handle, - |state| { - call_with_migrate(state, |_| { - invocations.set(invocations.get() + 1); - Err::<(), _>(MtprotoError::Message(message.into())) - }) - }, - |_, _, _| panic!("valid key was renewed before rejection"), - ); - assert_eq!( - result.unwrap_err().to_string(), - "connection closed: temporary authorization renewal required" - ); - assert_eq!(invocations.get(), 1); - assert_eq!(client.main.lock().temp_expires_at, 1); - { - let data = client.data.lock(); - assert!(!data.session_dead); - assert_eq!(data.home_session_id, original_home); - assert!(data.home_auth_key.as_deref() == Some([3; 256].as_slice())); - } - with_client_mut_binding( - fixture.handle, - |state| { - assert!(state.snapshot.auth_key.as_deref() == Some([5; 256].as_slice())); - Ok(()) - }, - |_, dc, offset| { - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![5; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }, - ) - .unwrap(); - } -} - -#[test] -fn permanent_authorization_revocation_remains_terminal_under_pfs() { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - let result = with_client_mut_binding( - fixture.handle, - |state| { - call_with_migrate(state, |_| { - Err::<(), _>(MtprotoError::Message("RPC 401: SESSION_REVOKED".into())) - }) - }, - |_, _, _| panic!("valid temporary key was renewed"), - ); - assert!(result.is_err()); - assert!(client.data.lock().session_dead); - let restored = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert!(restored.session_dead); - assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); -} - -#[test] -fn destruction_cancels_renewal_then_saves_the_permanent_session() { - let fixture = Fixture::new(); - let client = fixture.temporary(1); - let handle = fixture.handle; - let waiting = client.clone(); - let (entered_tx, entered_rx) = std::sync::mpsc::channel(); - let (finished_tx, finished_rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let result: Result<(), MtprotoError> = with_client_mut_binding( - handle, - |_| panic!("request ran during shutdown"), - |_, _, _| { - entered_tx.send(()).unwrap(); - waiting - .connections - .wait(std::time::Duration::from_secs(60)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - unreachable!("shutdown did not cancel renewal") - }, - ); - finished_tx.send(result.is_err()).unwrap(); - }); - entered_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap(); - let started = std::time::Instant::now(); - destroy_client(handle); - assert!(started.elapsed() < std::time::Duration::from_secs(2)); - assert!( - finished_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap() - ); - worker.join().unwrap(); - assert!(!client_exists(handle)); - let restored = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert!(restored.snapshot.auth_key.as_deref() == Some([3; 256].as_slice())); - assert_eq!(restored.user_id, Some(7)); - assert!( - client - .connections - .while_persisting(|| panic!("late persistence")) - .is_err() - ); -} - -#[test] -fn failed_final_save_does_not_advance_persistence_state() { - let fixture = Fixture::new(); - let client = fixture.temporary(1); - std::fs::create_dir(&fixture.path).unwrap(); - let home_id = client.data.lock().home_session_id; - { - let mut data = client.data.lock(); - data.persist_epoch = 7; - data.persisted_epoch = 0; - } - assert!(persist_updates_data(&client, home_id).is_err()); - assert_eq!(client.data.lock().persisted_epoch, 0); - std::fs::remove_dir(&fixture.path).unwrap(); -} - -#[test] -fn scheduler_permissions_and_bound_keys_are_isolated_between_clients() { - let first = Fixture::new(); - let second = Fixture::new(); - with_client_mut(first.handle, |_| { - crate::scheduler::set_main_session_allowance(Some(3)); - crate::scheduler::set_bound_extra_sessions(2); - assert_eq!(crate::scheduler::extra_main_sessions(), 2); - Ok(()) - }) - .unwrap(); - with_client_mut(second.handle, |_| { - assert!(!crate::scheduler::main_session_allowance_known()); - assert_eq!(crate::scheduler::extra_main_sessions(), 0); - crate::scheduler::set_main_session_allowance(Some(1)); - crate::scheduler::set_bound_extra_sessions(2); - assert_eq!(crate::scheduler::extra_main_sessions(), 0); - Ok(()) - }) - .unwrap(); - with_client_mut(first.handle, |_| { - assert_eq!(crate::scheduler::main_session_allowance(), 3); - assert_eq!(crate::scheduler::extra_main_sessions(), 2); - crate::scheduler::set_bound_extra_sessions(0); - Ok(()) - }) - .unwrap(); - with_client_mut(second.handle, |_| { - assert_eq!(crate::scheduler::main_session_allowance(), 1); - assert_eq!( - crate::transfer_policy::current() - .bound_extra_sessions - .load(Ordering::Acquire), - 2 - ); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn saturated_sibling_queue_releases_partial_family_reservations() { - let fixture = Fixture::new(); - let client = get_client(fixture.handle).unwrap(); - let saturated = &client.media[1].gate; - let waiters: Vec<_> = (0..crate::scheduler::MAX_LANE_WAITERS) - .map(|_| { - saturated - .enqueue(scheduler::RequestClass::BackgroundMedia) - .unwrap() - }) - .collect(); - let result = super::lanes::acquire_lane_family( - 0..2, - scheduler::RequestClass::InteractiveMedia, - "queue_capacity", - "queue_capacity", - |index| &client.media[index], - ); - assert!(result.is_err()); - assert_eq!(client.media[0].gate.waiter_count(), 0); - assert_eq!(saturated.waiter_count(), crate::scheduler::MAX_LANE_WAITERS); - drop(waiters); - assert_eq!(saturated.waiter_count(), 0); -} - -#[test] -fn key_migration_preserves_same_account_journal_and_concurrent_cursors() { - for replace_user in [false, true] { - let fixture = Fixture::new(); - let cursor = |pts| UpdatesStateDto { - pts, - qts: 0, - date: pts, - seq: pts, - }; - with_client_mut(fixture.handle, |state| { - state.user_id = Some(7); - state.snapshot.auth_key = Some(vec![3; 256]); - state.updates = Some(cursor(100)); - state.channel_pts.insert(7, 100); - for chat_id in [1, 2] { - state.channel_recovery.push_back(ChannelRecovery { - chat_id, - due_at: 100, - watching: false, - }); - } - Ok(()) - }) - .unwrap(); - let client = get_client(fixture.handle).unwrap(); - let generation = { - let mut data = client.data.lock(); - Arc::make_mut(&mut data.journal).push( - vec![UpdateEventDto::MessagesDeleted { - chat_id: Some(7), - message_ids: vec![1], - }], - Some(cursor(100)), - vec![(7, 100)], - ); - data.journal.account_generation - }; - with_client_mut(fixture.handle, |state| { - { - let mut data = client.data.lock(); - data.updates = Some(cursor(200)); - data.channel_pts.insert(7, 200); - data.channel_recovery - .iter_mut() - .find(|entry| entry.chat_id == 2) - .unwrap() - .due_at = 200; - data.channel_recovery.push_back(ChannelRecovery { - chat_id: 9, - due_at: 100, - watching: false, - }); - Arc::make_mut(&mut data.journal).push( - vec![UpdateEventDto::MessagesDeleted { - chat_id: Some(7), - message_ids: vec![2], - }], - Some(cursor(200)), - vec![(7, 200)], - ); - } - state.snapshot = Snapshot::new(4, &mut OsRandom).unwrap(); - state.snapshot.auth_key = Some(vec![8; 256]); - state.channel_recovery.clear(); - if replace_user { - state.user_id = Some(8); - state.updates = None; - state.channel_pts.clear(); - } else { - state.channel_recovery.push_back(ChannelRecovery { - chat_id: 8, - due_at: 100, - watching: false, - }); - } - Ok(()) - }) - .unwrap(); - let session = client.data.lock().home_session_id; - persist_updates_data(&client, session).unwrap(); - let saved = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - if replace_user { - assert_ne!(saved.journal.account_generation, generation); - assert!(saved.journal.pending().is_empty()); - assert!(saved.updates.is_none()); - assert!(saved.channel_pts.is_empty()); - assert!(saved.channel_recovery.is_empty()); - } else { - assert_eq!(saved.journal.account_generation, generation); - assert_eq!(saved.journal.pending().len(), 2); - assert_eq!(saved.updates.as_ref().unwrap().pts, 200); - assert_eq!(saved.channel_pts.get(&7), Some(&200)); - assert_eq!( - saved - .channel_recovery - .iter() - .map(|entry| entry.chat_id) - .collect::>(), - vec![2, 9, 8] - ); - assert_eq!(saved.channel_recovery.front().unwrap().due_at, 200); - } - } -} - -#[test] -fn successful_sign_in_requires_pfs_before_business_io_without_blocking_local_authorization() { - let fixture = Fixture::new(); - with_client_mut(fixture.handle, |state| { - state.snapshot.auth_key = Some(vec![3; 256]); - super::auth::complete_sign_in(state, 7) - }) - .unwrap(); - let client = get_client(fixture.handle).unwrap(); - let permanent = client.data.lock().home_snapshot.clone(); - assert!(is_authorized(fixture.handle).unwrap()); - assert_eq!(client.main.lock().temp_expires_at, 1); - let invoked = Cell::new(false); - let failed = with_client_mut_binding( - fixture.handle, - |_| { - invoked.set(true); - Ok(()) - }, - |_, _, _| { - Err(MtprotoError::Message( - "injected offline bind failure".into(), - )) - }, - ); - assert!(failed.is_err()); - assert!(!invoked.get()); - assert!(is_authorized(fixture.handle).unwrap()); - with_client_mut_binding( - fixture.handle, - |state| { - invoked.set(true); - assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[4; 256][..])); - assert_eq!(state.permanent.as_ref(), Some(&permanent)); - Ok(()) - }, - |_, dc, offset| { - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![4; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }, - ) - .unwrap(); - assert!(invoked.get()); - client.main.lock().temp_expires_at = 1; - assert!(is_authorized(fixture.handle).unwrap()); - persist_updates_data(&client, permanent.session_id).unwrap(); - let saved = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert_eq!(saved.snapshot, permanent); - assert_eq!(saved.user_id, Some(7)); -} - -#[test] -fn media_binding_publishes_only_for_the_current_account_dc_master_and_open_client() { - for mode in [ - "ok", "failure", "session", "account", "home_key", "master", "dc", "shutdown", - ] { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - client.data.lock().dc_auth_keys.insert( - 4, - crate::session_file::DcAuthKey { - key: vec![6; 256], - salt: 9, - }, - ); - let owner = client.data.lock().home_snapshot.clone(); - let mut io = client.media[0].io.lock(); - let original = io.snapshot.clone(); - let result = crate::auth::bind_temp::ensure_dc_temp_with( - &client, - &mut io, - 4, - Some(owner.session_id), - &crate::rpc::SystemClock, - |key, dc, offset| { - assert_eq!(key, &[6; 256]); - match mode { - "failure" => { - return Err(MtprotoError::Message( - "injected media binding failure".into(), - )); - } - "session" => { - client.data.lock().home_session_id = owner.session_id.wrapping_add(1) - } - "account" => client.data.lock().user_id = Some(8), - "home_key" => client.data.lock().home_auth_key = Some(vec![9; 256]), - "master" => { - client.data.lock().dc_auth_keys.get_mut(&4).unwrap().key = vec![9; 256] - } - "dc" => client.data.lock().home_dc = 5, - "shutdown" => client.connections.begin_shutdown(), - _ => {} - } - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![8; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }, - ); - if mode == "ok" { - assert!(result.unwrap()); - assert_eq!(io.snapshot.auth_key.as_deref(), Some(&[8; 256][..])); - assert_eq!( - client.data.lock().dc_temp_auth.get(&4).unwrap().snapshot, - io.snapshot - ); - let first = io.snapshot.clone(); - drop(io); - let mut second = client.media[1].io.lock(); - crate::auth::bind_temp::ensure_dc_temp_with( - &client, - &mut second, - 4, - Some(owner.session_id), - &crate::rpc::SystemClock, - |_, _, _| panic!("valid cached binding must be reused"), - ) - .unwrap(); - assert_eq!(second.snapshot.auth_key, first.auth_key); - assert_ne!(second.snapshot.session_id, first.session_id); - } else { - assert!(result.is_err(), "{mode}"); - assert_eq!(io.snapshot, original); - assert!(client.data.lock().dc_temp_auth.is_empty()); - let mut data = client.data.lock(); - data.home_dc = owner.dc_id; - data.home_session_id = owner.session_id; - data.home_auth_key = owner.auth_key.clone(); - data.user_id = Some(7); - } - } -} - -#[test] -fn terminal_lane_error_survives_a_concurrent_main_commit_and_persists_the_permanent_owner() { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - let owner = client.data.lock().home_session_id; - let error = MtprotoError::Message("RPC 401: SESSION_REVOKED".into()); - assert!(!mark_lane_session_dead( - &client, - owner.wrapping_add(1), - &error - )); - with_client_mut_binding( - fixture.handle, - |_| { - assert!(mark_lane_session_dead(&client, owner, &error)); - Ok(()) - }, - |_, _, _| panic!("terminal failure must not negotiate"), - ) - .unwrap(); - assert!(client.data.lock().session_dead); - persist_updates_data(&client, owner).unwrap(); - let saved = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert!(saved.session_dead); - assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[3; 256][..])); - assert_eq!(saved.snapshot.session_id, owner); -} - -#[test] -fn temporary_lane_rejection_preserves_dc_master_and_does_not_evict_a_newer_cached_key() { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - client.data.lock().dc_auth_keys.insert( - 4, - crate::session_file::DcAuthKey { - key: vec![6; 256], - salt: 9, - }, - ); - let mut io = client.media[0].io.lock(); - io.snapshot.dc_id = 4; - io.snapshot.auth_key = Some(vec![8; 256]); - io.perm_key = Some(vec![6; 256]); - io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; - client.data.lock().dc_temp_auth.insert( - 4, - DcTempAuth { - snapshot: io.snapshot.clone(), - temp_expires_at: io.temp_expires_at, - perm_key: vec![6; 256], - }, - ); - let error = MtprotoError::Message("MTProto transport error -404".into()); - assert!(expire_temporary_lane(&client, &mut io, &error)); - assert_eq!(io.temp_expires_at, 1); - assert!(client.data.lock().dc_temp_auth.is_empty()); - let mut newer = io.snapshot.clone(); - newer.auth_key = Some(vec![9; 256]); - client.data.lock().dc_temp_auth.insert( - 4, - DcTempAuth { - snapshot: newer, - temp_expires_at: crate::auth::bind_temp::unix_now() + 3600, - perm_key: vec![6; 256], - }, - ); - assert!(expire_temporary_lane(&client, &mut io, &error)); - let data = client.data.lock(); - assert!(!data.session_dead); - assert_eq!(data.dc_auth_keys.get(&4).unwrap().key, vec![6; 256]); - assert_eq!( - data.dc_temp_auth - .get(&4) - .unwrap() - .snapshot - .auth_key - .as_deref(), - Some(&[9; 256][..]) - ); -} - -#[test] -fn upload_rejection_expires_the_lane_without_replaying_the_batch_or_revoking_login() { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - let home = client.main.lock().snapshot.clone(); - for lane in &client.upload { - let mut io = lane.io.lock(); - io.snapshot = fork_session(&home); - io.perm_key = Some(vec![3; 256]); - io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; - } - let payload = fixture.path.with_extension("payload"); - std::fs::write(&payload, [0_u8; 8]).unwrap(); - let item = crate::UploadItemDto { - path: payload.to_string_lossy().into_owned(), - kind: "document".into(), - mime_type: String::new(), - file_name: "fixture.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 7, - }; - let calls = Cell::new(0); - let result = save_items_with(&client, 1, &[item], |_, _| { - calls.set(calls.get() + 1); - Err(MtprotoError::Message( - "RPC 401: AUTH_KEY_UNREGISTERED".into(), - )) - }); - std::fs::remove_file(payload).unwrap(); - assert_eq!( - result.err().unwrap().to_string(), - "connection closed: temporary authorization renewal required" - ); - assert_eq!(calls.get(), 1); - assert_eq!( - client - .upload - .iter() - .filter(|lane| lane.io.lock().temp_expires_at == 1) - .count(), - 1 - ); - assert!(!client.data.lock().session_dead); - assert!(is_authorized(fixture.handle).unwrap()); -} - -#[test] -fn pfs_migration_binds_before_retry_and_rolls_back_on_binding_failure() { - for mode in ["ok", "failure", "invalid"] { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - client.data.lock().dc_auth_keys.insert( - 4, - crate::session_file::DcAuthKey { - key: vec![6; 256], - salt: 9, - }, - ); - let original = client.data.lock().home_snapshot.clone(); - let retry = Cell::new(false); - let expires = crate::auth::bind_temp::unix_now() + 3600; - let result = with_client_mut_binding( - fixture.handle, - |state| { - ensure_ready_after_migrate_with_binding( - state, - MtprotoError::Message("MIGRATE_4".into()), - |key, dc, offset| { - assert_eq!(key, &[6; 256]); - if mode == "failure" { - return Err(MtprotoError::Message("offline".into())); - } - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![if mode == "invalid" { 6 } else { 8 }; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: expires, - }) - }, - )?; - retry.set(true); - assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[8; 256][..])); - assert_eq!( - state.permanent.as_ref().unwrap().auth_key.as_deref(), - Some(&[6; 256][..]) - ); - Ok(()) - }, - |_, _, _| panic!("existing home binding must not renew"), - ); - if mode == "ok" { - result.unwrap(); - assert!(retry.get()); - assert_eq!(client.main.lock().temp_expires_at, expires); - let owner = client.data.lock().home_session_id; - persist_updates_data(&client, owner).unwrap(); - let saved = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[6; 256][..])); - assert_eq!(saved.snapshot.dc_id, 4); - assert_eq!(saved.user_id, Some(7)); - } else { - assert!(result.is_err()); - assert!(!retry.get()); - assert_eq!(client.data.lock().home_snapshot, original); - assert_eq!( - client.main.lock().snapshot.auth_key.as_deref(), - Some(&[4; 256][..]) - ); - } - } -} - -#[test] -fn secondary_read_lane_distinguishes_temporary_rejection_from_account_revocation() { - for revoked in [false, true] { - let fixture = Fixture::new(); - let client = fixture.temporary(crate::auth::bind_temp::unix_now() + 3600); - let owner = client.data.lock().home_session_id; - let _policy = crate::transfer_policy::bind(client.policy.clone()); - scheduler::set_main_session_allowance(Some(2)); - scheduler::set_bound_extra_sessions(1); - { - let mut io = client.rpc[0].io.lock(); - io.snapshot.auth_key = Some(vec![5; 256]); - io.perm_key = Some(vec![3; 256]); - io.temp_expires_at = crate::auth::bind_temp::unix_now() + 3600; - } - let result = with_read_lane(fixture.handle, |state| { - assert_eq!(state.snapshot.auth_key.as_deref(), Some(&[5; 256][..])); - call_with_migrate(state, |_| { - Err::<(), _>(MtprotoError::Message( - if revoked { - "RPC 401: SESSION_REVOKED" - } else { - "RPC 401: AUTH_KEY_UNREGISTERED" - } - .into(), - )) - }) - }); - let error = result.unwrap_err().to_string(); - if revoked { - assert!(error.contains("SESSION_REVOKED")); - assert!(client.data.lock().session_dead); - persist_updates_data(&client, owner).unwrap(); - let saved = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert!(saved.session_dead); - assert_eq!(saved.snapshot.auth_key.as_deref(), Some(&[3; 256][..])); - } else { - assert_eq!( - error, - "connection closed: temporary authorization renewal required" - ); - assert_eq!(client.rpc[0].io.lock().temp_expires_at, 1); - assert!(!client.data.lock().session_dead); - assert!(is_authorized(fixture.handle).unwrap()); - } - } -} - -#[test] -fn missing_permanent_identity_cannot_publish_temporary_key() { - let fixture = Fixture::new(); - let client = get_client(fixture.handle).unwrap(); - let home_id = client.data.lock().home_session_id; - { - let mut main = client.main.lock(); - main.snapshot.auth_key = Some(vec![4; 256]); - main.temp_expires_at = 1; - main.perm_key = Some(vec![3; 256]); - } - assert!(persist_updates_data(&client, home_id).is_err()); - assert!(!fixture.path.exists()); -} - -#[test] -fn permanent_sequence_state_survives_initial_pfs_activation_and_all_persist_paths() { - let fixture = Fixture::new(); - with_client_mut(fixture.handle, |state| { - state.user_id = Some(7); - state.snapshot.auth_key = Some(vec![3; 256]); - state.snapshot.server_salt = 123; - state.snapshot.content_sequence = 11; - state.snapshot.last_message_id = 400; - state.snapshot.pending_acknowledgements.insert(1); - state.snapshot.received_message_ids.insert(1, true); - Ok(()) - }) - .unwrap(); - let client = get_client(fixture.handle).unwrap(); - let permanent = client.main.lock().snapshot.clone(); - { - let mut main = client.main.lock(); - crate::auth::bind_temp::ensure_main_temp_with( - &client, - &mut main, - true, - &crate::rpc::SystemClock, - |_, dc, offset| { - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![4; 256]); - snapshot.time_offset_micros = offset; - snapshot.content_sequence = 88; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }, - ) - .unwrap(); - } - with_client_mut_binding( - fixture.handle, - |state| { - assert_eq!(state.snapshot.content_sequence, 88); - assert_eq!(state.permanent.as_ref(), Some(&permanent)); - persist(state) - }, - |_, _, _| panic!("valid bound key must not renew"), - ) - .unwrap(); - let direct = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert_eq!(direct.snapshot, permanent); - persist_updates_data(&client, permanent.session_id).unwrap(); - let background = FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert_eq!(background.snapshot, permanent); -} - -#[test] -fn expired_temporary_key_never_replaces_durable_login_identity() { - for expires_at in [crate::auth::bind_temp::unix_now() + 3600, 1] { - let fixture = Fixture::new(); - let client = get_client(fixture.handle).unwrap(); - let permanent = vec![3; 256]; - let home_id; - { - let mut main = client.main.lock(); - let mut data = client.data.lock(); - data.user_id = Some(7); - data.home_auth_key = Some(permanent.clone()); - data.home_salt = 123; - data.home_snapshot = main.snapshot.clone(); - data.home_snapshot.auth_key = Some(permanent.clone()); - data.home_snapshot.server_salt = 123; - home_id = data.home_session_id; - let mut temporary = fork_session(&main.snapshot); - temporary.auth_key = Some(vec![4; 256]); - temporary.server_salt = 456; - temporary.content_sequence = 99; - temporary.last_message_id = 400; - temporary.pending_acknowledgements.insert(1); - temporary.received_message_ids.insert(1, true); - main.snapshot = temporary; - main.temp_expires_at = expires_at; - main.perm_key = Some(permanent.clone()); - } - assert!(persist_updates_data(&client, home_id).unwrap()); - let restored = crate::session_file::FileSessionStore::new(&fixture.path) - .load() - .unwrap() - .unwrap(); - assert_eq!( - restored.snapshot.auth_key.as_deref(), - Some(permanent.as_slice()) - ); - assert_eq!(restored.snapshot.session_id, home_id); - assert_eq!(restored.snapshot.server_salt, 123); - assert_eq!(restored.snapshot.content_sequence, 0); - assert_eq!(restored.snapshot.last_message_id, 0); - assert!(restored.snapshot.pending_acknowledgements.is_empty()); - assert!(restored.snapshot.received_message_ids.is_empty()); - assert_eq!(restored.user_id, Some(7)); - let data = client.data.lock(); - assert_eq!(data.home_auth_key.as_deref(), Some(permanent.as_slice())); - assert_eq!(data.home_session_id, home_id); - } -} diff --git a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs deleted file mode 100644 index 174ab96a0..000000000 --- a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs +++ /dev/null @@ -1,334 +0,0 @@ -use super::*; - -#[test] -fn duplicated_auth_key_is_unrecoverable() { - assert!(is_unrecoverable_session(&MtprotoError::Message( - "RPC 406: AUTH_KEY_DUPLICATED".into(), - ))); - assert!(!is_unrecoverable_session(&MtprotoError::Message( - "RPC timeout recv=139455".into(), - ))); -} - -#[test] -fn unregistered_key_without_a_user_does_not_kill_the_login_session() { - let unregistered = MtprotoError::Message("RPC 401: AUTH_KEY_UNREGISTERED".into()); - assert!(!kills_session(None, &unregistered)); - assert!(kills_session(Some(7), &unregistered)); - assert!(kills_session( - None, - &MtprotoError::Message("RPC 401: SESSION_REVOKED".into()), - )); -} - -#[test] -fn password_required_normalizes_tl_and_text_errors() { - assert!(is_password_required(&MtprotoError::PasswordRequired)); - assert!(is_password_required(&MtprotoError::Message( - "RPC 401: SESSION_PASSWORD_NEEDED".into(), - ))); - assert!(!is_password_required(&MtprotoError::Message( - "RPC 400: PASSWORD_HASH_INVALID".into(), - ))); -} - -#[test] -fn sticker_document_refreshes_from_its_set_not_saved_gifs() { - let mut sticker = media_rpc::MediaRef { - file_size: Some(12), - kind: "sticker".into(), - cache_key: "emoji:42".into(), - location: media_rpc::MediaLocation::Document { - id: 42, - access_hash: 7, - file_reference: vec![1], - thumb_size: String::new(), - dc_id: 2, - mime_type: "image/webp".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: Some(9), - sticker_set_access_hash: Some(11), - source_url: None, - }; - assert_eq!(sticker_set_of(&sticker), Some((9, 11))); - sticker.sticker_set_id = None; - sticker.sticker_set_access_hash = None; - assert_eq!(sticker_set_of(&sticker), None); -} - -#[test] -fn custom_emoji_miss_uses_sticker_set_not_custom_emoji_documents() { - let sticker = media_rpc::MediaRef { - file_size: Some(12), - kind: "sticker".into(), - cache_key: "emoji:42".into(), - location: media_rpc::MediaLocation::Document { - id: 42, - access_hash: 7, - file_reference: vec![1], - thumb_size: String::new(), - dc_id: 2, - mime_type: "image/webp".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: Some(9), - sticker_set_access_hash: Some(11), - source_url: None, - }; - let mut index = media_rpc::MediaIndex::default(); - index.insert((-1004434463760, 106), sticker); - assert!(index.get(&(42, 0)).is_none()); - assert_eq!( - custom_emoji_document_source(&index, 42), - CustomEmojiDocumentSource::StickerSet { - set_id: 9, - access_hash: 11, - }, - ); - - let mut plain = sticker_without_set(99); - plain.sticker_set_id = None; - plain.sticker_set_access_hash = None; - index.insert((7, 3), plain); - assert_eq!( - custom_emoji_document_source(&index, 99), - CustomEmojiDocumentSource::CustomEmojiDocuments, - ); - assert_eq!( - custom_emoji_document_source(&index, 1000), - CustomEmojiDocumentSource::CustomEmojiDocuments, - ); -} - -fn sticker_without_set(id: i64) -> media_rpc::MediaRef { - media_rpc::MediaRef { - file_size: Some(4), - kind: "sticker".into(), - cache_key: format!("emoji:{id}"), - location: media_rpc::MediaLocation::Document { - id, - access_hash: 1, - file_reference: vec![2], - thumb_size: String::new(), - dc_id: 2, - mime_type: "image/webp".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - } -} - -#[test] -fn file_reference_retry_requires_new_bytes() { - let old = media_rpc::MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1, 2, 3], - thumb_size: "m".into(), - dc_id: 2, - }; - let same = old.clone(); - let mut newer = old.clone(); - if let media_rpc::MediaLocation::Photo { file_reference, .. } = &mut newer { - *file_reference = vec![9, 9, 9]; - } - assert_eq!( - media_rpc::location_token(&old), - media_rpc::location_token(&same), - ); - assert_ne!( - media_rpc::location_token(&old), - media_rpc::location_token(&newer), - ); - assert!(media_rpc::is_file_reference_error(&MtprotoError::Message( - "FILE_REFERENCE_EXPIRED".into(), - ))); - // Retry is one getFile after source refresh, not gated on token equality. -} - -#[test] -fn peer_refreshable_errors_are_detected() { - assert!(is_peer_refreshable(&MtprotoError::Message( - "unknown peer -100123; refresh chats first".into(), - ))); - assert!(is_peer_refreshable(&MtprotoError::Message( - "RPC 400: PEER_ID_INVALID".into(), - ))); - assert!(!is_peer_refreshable(&MtprotoError::Message( - "RPC timeout".into() - ))); -} - -#[test] -fn fork_session_keeps_auth_and_new_session_id() { - let mut home = Snapshot::new(2, &mut OsRandom).expect("home"); - home.auth_key = Some(vec![7_u8; 256]); - home.server_salt = 99; - home.time_offset_micros = 1_000; - let forked = fork_session(&home); - assert_eq!(forked.dc_id, 2); - assert_eq!(forked.auth_key.as_ref().map(|k| k.len()), Some(256)); - assert_eq!(forked.server_salt, 99); - assert_eq!(forked.time_offset_micros, 1_000); - assert_ne!(forked.session_id, home.session_id); -} - -#[test] -fn existing_auth_key_does_not_rewrite_snapshot_on_every_rpc() { - let path = - std::env::temp_dir().join(format!("mtproto-auth-no-write-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - with_client_mut(handle, |state| { - state.snapshot.auth_key = Some(vec![7; 256]); - ensure_auth_key(state) - }) - .unwrap(); - assert!(!path.exists()); - destroy_client(handle); -} - -#[test] -fn cached_connect_does_not_rewrite_session() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!("mtproto-cached-connect-{nonce}")); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("session.json"); - let expires = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64 - + 3600; - std::fs::write( - dir.join("dc_txt.config"), - format!("# dc_txt.config v2\nexpires {expires}\ntmp_sessions 1\n2 149.154.167.51:443\n"), - ) - .unwrap(); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - with_client_mut(handle, |state| { - state.snapshot.auth_key = Some(vec![7; 256]); - state.user_id = Some(42); - persist(state) - }) - .unwrap(); - let before = std::fs::read(&path).unwrap(); - assert!(!before.is_empty()); - let mut issued = 0_u8; - connect_with_binding(handle, |permanent, dc, offset| { - assert!(permanent == [7; 256]); - issued += 1; - let mut snapshot = Snapshot::new(dc, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![8 + issued; 256]); - snapshot.time_offset_micros = offset; - Ok(crate::auth::bind_temp::BoundTemp { - snapshot, - expires_at: crate::auth::bind_temp::unix_now() + 3600, - }) - }) - .expect("cached connect"); - assert_eq!(issued, 1 + crate::scheduler::UPLOAD_LANES as u8); - let after = std::fs::read(&path).unwrap(); - assert_eq!( - after, before, - "cached connect must not rewrite the session file" - ); - destroy_client(handle); - let _ = std::fs::remove_dir_all(dir); -} - -#[test] -fn getfile_without_durable_change_does_not_rewrite_session() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!("mtproto-getfile-nopersist-{nonce}.json")); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - with_client_mut(handle, |state| { - state.snapshot.auth_key = Some(vec![7; 256]); - state.user_id = Some(42); - persist(state) - }) - .unwrap(); - let client = get_client(handle).unwrap(); - flush_persist(&client); - let before = std::fs::read(&path).unwrap(); - with_client_mut(handle, |state| { - state.snapshot.server_salt = state.snapshot.server_salt.wrapping_add(1); - state.snapshot.time_offset_micros = state.snapshot.time_offset_micros.wrapping_add(1); - Ok(()) - }) - .unwrap(); - flush_persist(&client); - let after = std::fs::read(&path).unwrap(); - assert_eq!( - after, before, - "getFile-like RPC must not rewrite the session file" - ); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn dead_session_reports_the_invalidating_error_not_a_fake_401() { - let path = - std::env::temp_dir().join(format!("mtproto-dead-reason-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - with_client_mut(handle, |state| { - state.session_dead = true; - state.session_dead_reason = Some("AUTH_KEY_DUPLICATED".into()); - let err = reject_if_dead(state).expect_err("a dead session must refuse RPCs"); - assert!( - matches!(&err, MtprotoError::Message(message) - if message == "session invalidated: AUTH_KEY_DUPLICATED"), - "unexpected error: {err}" - ); - // A local gate must not be mistakable for a fresh server rejection. - assert!(!err.to_string().contains("RPC 401")); - assert!(!err.to_string().contains("AUTH_KEY_UNREGISTERED")); - Ok(()) - }) - .expect("dead session probe"); - destroy_client(handle); - let _ = std::fs::remove_file(&path); -} - -#[test] -fn session_token_keeps_only_catalog_tokens() { - assert_eq!( - session_dead_token(&MtprotoError::Message( - "RPC 406: AUTH_KEY_DUPLICATED".into() - )), - "AUTH_KEY_DUPLICATED" - ); - assert_eq!( - session_dead_token(&MtprotoError::Message( - "RPC 401: AUTH_KEY_UNREGISTERED".into() - )), - "AUTH_KEY_UNREGISTERED" - ); - assert_eq!( - session_dead_token(&MtprotoError::Message( - "decode failed for +1 555 0100 secret note".into() - )), - "UNKNOWN" - ); - assert_eq!( - session_dead_token(&MtprotoError::RegistrationRequired), - "UNKNOWN" - ); -} diff --git a/native/mtproto-rs/tests/unit/client_outgoing_receipts_tests.rs b/native/mtproto-rs/tests/unit/client_outgoing_receipts_tests.rs deleted file mode 100644 index b2a6e5a18..000000000 --- a/native/mtproto-rs/tests/unit/client_outgoing_receipts_tests.rs +++ /dev/null @@ -1,130 +0,0 @@ -use super::*; - -fn result(scheduled: bool) -> crate::OutgoingMessageResult { - let message = serde_json::from_str(r#"{"chat_id":7,"id":37,"text":"fixture","date":100,"outgoing":true,"noforwards":false,"supports_streaming":false,"replies_count":0,"forum_topic":false}"#).unwrap(); - if scheduled { - crate::OutgoingMessageResult::Scheduled { - message: crate::ScheduledMessageDto { - message, - has_unsupported_content: false, - }, - } - } else { - crate::OutgoingMessageResult::Sent { message } - } -} - -#[test] -fn outgoing_modes_and_random_ids_survive_restart_without_claiming_cursors() { - for scheduled in [false, true] { - let path = std::env::temp_dir().join(format!( - "monogram-outcome-{}-{scheduled}.session", - std::process::id() - )); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let generation = client.data.lock().journal.account_generation; - let session_id = { - let _main = client.main.lock(); - updates::stage_outgoing_result(&client, generation, 123, result(scheduled)).unwrap() - }; - persist::persist_journal_sync(&client, session_id).unwrap(); - destroy_client(handle); - let restarted = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let batches = pending_update_batches(restarted).unwrap(); - assert_eq!(batches.len(), 1); - assert!(batches[0].cursor_after.is_none()); - assert!(batches[0].channel_pts_after.is_empty()); - let crate::UpdateEventDto::OutgoingMessageAcknowledged { random_id, result } = - &batches[0].events[0] - else { - panic!("receipt required") - }; - assert_eq!(*random_id, 123); - assert_eq!( - matches!(result, crate::OutgoingMessageResult::Scheduled { .. }), - scheduled - ); - acknowledge_update_batch(restarted, generation, batches[0].batch_id).unwrap(); - assert!(pending_update_batches(restarted).unwrap().is_empty()); - destroy_client(restarted); - let _ = std::fs::remove_file(path); - } -} - -#[test] -fn album_receipts_are_one_restart_durable_batch_and_invalid_sets_are_atomic() { - let path = std::env::temp_dir().join(format!( - "monogram-album-receipts-{}.session", - std::process::id() - )); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let generation = client.data.lock().journal.account_generation; - for invalid in [ - vec![], - vec![(123, result(true)), (0, result(true))], - vec![(123, result(true)), (123, result(true))], - ] { - assert!(updates::stage_outgoing_results(&client, generation, invalid).is_err()); - assert!(client.data.lock().journal.batches.is_empty()); - } - let mut second = result(true); - if let crate::OutgoingMessageResult::Scheduled { message } = &mut second { - message.message.id = 38; - } - let session = { - let _main = client.main.lock(); - updates::stage_outgoing_results( - &client, - generation, - vec![(123, result(true)), (456, second)], - ) - .unwrap() - }; - persist::persist_journal_sync(&client, session).unwrap(); - destroy_client(handle); - let restarted = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let batches = pending_update_batches(restarted).unwrap(); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].events.len(), 2); - assert!(batches[0].cursor_after.is_none()); - assert!(batches[0].channel_pts_after.is_empty()); - let ids = batches[0] - .events - .iter() - .map(|event| match event { - crate::UpdateEventDto::OutgoingMessageAcknowledged { random_id, .. } => *random_id, - _ => panic!("receipt expected"), - }) - .collect::>(); - assert_eq!(ids, vec![123, 456]); - acknowledge_update_batch(restarted, generation, batches[0].batch_id).unwrap(); - assert!(pending_update_batches(restarted).unwrap().is_empty()); - destroy_client(restarted); - let _ = std::fs::remove_file(path); -} - -#[test] -fn receipts_reject_stale_account_generation_zero_random_id_and_expired_session() { - let path = std::env::temp_dir().join(format!( - "monogram-outcome-lease-{}.session", - std::process::id() - )); - let _ = std::fs::remove_file(&path); - let handle = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let generation = client.data.lock().journal.account_generation; - assert!( - updates::stage_outgoing_result(&client, generation.wrapping_add(1), 123, result(true)) - .is_err() - ); - assert!(updates::stage_outgoing_result(&client, generation, 0, result(true)).is_err()); - client.data.lock().session_dead = true; - assert!(updates::stage_outgoing_result(&client, generation, 123, result(true)).is_err()); - assert!(client.data.lock().journal.batches.is_empty()); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} diff --git a/native/mtproto-rs/tests/unit/client_persist_tests.rs b/native/mtproto-rs/tests/unit/client_persist_tests.rs deleted file mode 100644 index 4ea8acdb5..000000000 --- a/native/mtproto-rs/tests/unit/client_persist_tests.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::*; - -#[test] -fn generation_lookup_does_not_wait_for_disk_write() { - let write = PERSIST_WRITE.lock(); - let (tx, rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let clock = persist_generation(Path::new("generation-lookup-test")); - tx.send(clock.load(Ordering::Acquire)).unwrap(); - }); - let result = rx.recv_timeout(std::time::Duration::from_millis(500)); - drop(write); - worker.join().unwrap(); - assert_eq!(result.ok(), Some(0)); -} - -#[test] -fn generations_are_per_file_and_visible_to_existing_captures() { - let first = persist_generation(Path::new("generation-first-test")); - let same = persist_generation(Path::new("generation-first-test")); - let other = persist_generation(Path::new("generation-other-test")); - let previous = first.load(Ordering::Acquire); - let unrelated = other.load(Ordering::Acquire); - bump_persist_clock(&first); - assert_eq!(same.load(Ordering::Acquire), previous + 1); - assert_eq!(other.load(Ordering::Acquire), unrelated); - assert!(captured_session_is_stale( - previous, - same.load(Ordering::Acquire), - 1, - 1 - )); -} diff --git a/native/mtproto-rs/tests/unit/client_poll_tests.rs b/native/mtproto-rs/tests/unit/client_poll_tests.rs deleted file mode 100644 index 3507896c1..000000000 --- a/native/mtproto-rs/tests/unit/client_poll_tests.rs +++ /dev/null @@ -1,205 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; -fn poll(creator: bool, closed: bool) -> PollConstructor { - PollConstructor { - id: 123, - flags: 0, - closed: closed.then(|| Box::new(True::True(TrueConstructor {}))), - creator: creator.then(|| Box::new(True::True(TrueConstructor {}))), - public_voters: None, - multiple_choice: None, - quiz: None, - open_answers: None, - revoting_disabled: None, - shuffle_answers: None, - hide_results_until_close: None, - subscribers_only: None, - question: Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: "Fixture".into(), - entities: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - }, - )), - answers: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - close_period: None, - close_date: None, - countries_iso2: None, - hash: 0, - } -} -#[test] -fn close_media_requires_creator_and_closed_poll_is_idempotent() { - assert!(close_poll_media(&poll(false, false)).is_err()); - assert!(close_poll_media(&poll(true, true)).unwrap().is_none()); - let mut original = poll(true, false); - original.answers = Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(PollAnswer::PollAnswer(PollAnswerConstructor { - flags: 0, - text: original.question.clone(), - option: vec![0, 255, 128], - media: None, - added_by: None, - date: None, - }))], - })); - let media = close_poll_media(&original).unwrap().unwrap(); - let InputMedia::InputMediaPoll(media) = media else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(&media).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), InputMediaPollConstructor::ID); - let media = InputMediaPollConstructor::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert!(media.correct_answers.is_none()); - assert!(media.solution.is_none()); - let Poll::Poll(closed) = *media.poll; - assert_eq!(closed.id, 123); - let answer = crate::peers::vector_boxed_items(&closed.answers) - .next() - .unwrap(); - let PollAnswer::PollAnswer(answer) = answer else { - panic!() - }; - assert_eq!(answer.option, vec![0, 255, 128]); - assert!(closed.closed.is_some()); - assert!(closed.creator.is_none()); - let TextWithEntities::TextWithEntities(question) = *closed.question; - assert_eq!(question.text, "Fixture"); -} -#[test] -fn close_rejects_nonpositive_ids_before_client_access() { - for id in [0, -1] { - assert!( - matches!(close_poll(0,7,id),Err(MtprotoError::Message(ref value)) if value=="message id required") - ); - } -} -#[test] -fn close_rejects_empty_or_deleted_messages() { - let messages = Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - }); - let peer = InputPeer::InputPeerSelf(InputPeerSelfConstructor {}); - assert!(close_request(&messages, peer, 7, 37).is_err()); -} - -fn raw_message(sender_id: i64, id: i32) -> tellers_mtproto::latest::api::Message { - use tellers_mtproto::latest::api::{ - Message, MessageConstructor, Peer, PeerChatConstructor, PeerUserConstructor, - }; - Message::Message(MessageConstructor { - flags: 0, - out: None, - mentioned: None, - media_unread: None, - silent: None, - post: None, - from_scheduled: None, - legacy: None, - edit_hide: None, - pinned: None, - noforwards: None, - invert_media: None, - flags2: 0, - offline: None, - video_processing_pending: None, - paid_suggested_post_stars: None, - paid_suggested_post_ton: None, - id, - from_id: Some(Box::new(Peer::PeerUser(PeerUserConstructor { - user_id: sender_id, - }))), - from_boosts_applied: None, - from_rank: None, - peer_id: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id: 3 })), - saved_peer_id: None, - fwd_from: None, - via_bot_id: None, - via_business_bot_id: None, - guestchat_via_from: None, - reply_to: None, - date: 1, - message: "hi".into(), - media: None, - reply_markup: None, - entities: None, - views: None, - forwards: None, - replies: None, - edit_date: None, - post_author: None, - grouped_id: None, - reactions: None, - restriction_reason: None, - ttl_period: None, - quick_reply_shortcut_id: None, - effect: None, - factcheck: None, - report_delivery_until_date: None, - paid_message_stars: None, - suggested_post: None, - schedule_repeat_period: None, - summary_from_language: None, - rich_message: None, - }) -} - -#[test] -fn close_request_checks_identity_and_encodes_media_only_edit() { - let mut row = raw_message(8, 37); - let Message::Message(message) = &mut row else { - panic!() - }; - message.media = Some(Box::new(MessageMedia::MessageMediaPoll( - MessageMediaPollConstructor { - flags: 0, - poll: Box::new(Poll::Poll(poll(true, false))), - attached_media: None, - results: Box::new(PollResults::PollResults(PollResultsConstructor { - flags: 0, - min: None, - has_unread_votes: None, - can_view_stats: None, - results: None, - total_voters: None, - recent_voters: None, - solution: None, - solution_entities: None, - solution_media: None, - })), - }, - ))); - let rows = Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(row.clone())], - }); - let peer = || InputPeer::InputPeerChat(InputPeerChatConstructor { chat_id: 3 }); - assert!(close_request(&rows, peer(), -4, 37).is_err()); - assert!(close_request(&rows, peer(), -3, 38).is_err()); - let request = close_request(&rows, peer(), -3, 37).unwrap().unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesEditMessageRequest::ID); - let request = MessagesEditMessageRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(request.flags, MessagesEditMessageRequest::MEDIA_FLAG); - assert_eq!(request.id, 37); - assert!(request.message.is_none()); - assert!(request.entities.is_none()); - assert!(request.schedule_date.is_none()); - assert!(request.reply_markup.is_none()); - let duplicate = Vector::Vector(VectorConstructor { - field_0: 2, - field_1: vec![Box::new(row.clone()), Box::new(row)], - }); - assert!(close_request(&duplicate, peer(), -3, 37).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/client_scheduled_tests.rs b/native/mtproto-rs/tests/unit/client_scheduled_tests.rs deleted file mode 100644 index db09c9db6..000000000 --- a/native/mtproto-rs/tests/unit/client_scheduled_tests.rs +++ /dev/null @@ -1,352 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Encoder, Limits, TlDecode, TlEncode}; - -#[test] -fn scheduled_text_replacement_resolves_named_mentions_before_rpc() { - let peers = [( - 42, - crate::peers::CachedPeer { - kind: crate::peers::PeerKind::User, - id: 42, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - let json = r#"[{"kind":"mention_name","offset":0,"length":5,"url":"42"}]"#; - let request = edit_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 7, - 100, - Some("Alice".into()), - Some(json), - false, - &peers, - ) - .unwrap(); - assert!(matches!( - crate::peers::vector_items(request.entities.as_ref().unwrap())[0].as_ref(), - MessageEntity::InputMessageEntityMentionName(_) - )); - assert!( - edit_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 7, - 100, - Some("Alice".into()), - Some(json), - false, - &crate::HashMap::default() - ) - .is_err() - ); -} - -#[test] -fn edit_wire_distinguishes_reschedule_from_text_replacement_and_format_clear() { - for text in [None, Some("new caption".into()), Some(String::new())] { - let request = edit_request( - InputPeer::InputPeerSelf(InputPeerSelfConstructor {}), - 7, - 100, - text.clone(), - None, - text.is_some(), - &crate::HashMap::default(), - ) - .unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesEditMessageRequest::ID); - let decoded = MessagesEditMessageRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.schedule_date, Some(100)); - assert_eq!(decoded.message, text); - assert_eq!(decoded.entities.is_some(), text.is_some()); - assert!(decoded.media.is_none()); - assert!(decoded.rich_message.is_none()); - } -} - -fn photo_message() -> MessageConstructor { - let mut encoder = Encoder::new(); - for value in [0u32, 0, 37] { - value.encode(&mut encoder).unwrap(); - } - Peer::PeerUser(PeerUserConstructor { user_id: 7 }) - .encode(&mut encoder) - .unwrap(); - SCHEDULE_WHEN_ONLINE.encode(&mut encoder).unwrap(); - String::from("Alice").encode(&mut encoder).unwrap(); - let bytes = encoder.into_bytes(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let mut message = MessageConstructor::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - message.flags |= MessageConstructor::ENTITIES_FLAG; - message.entities = Some(vector(vec![Box::new( - MessageEntity::MessageEntityMentionName(MessageEntityMentionNameConstructor { - offset: 0, - length: 5, - user_id: 42, - }), - )])); - message.flags |= MessageConstructor::MEDIA_FLAG; - message.media = Some(Box::new(MessageMedia::MessageMediaPhoto( - MessageMediaPhotoConstructor { - flags: MessageMediaPhotoConstructor::PHOTO_FLAG, - spoiler: None, - live_photo: None, - ttl_seconds: None, - video: None, - photo: Some(Box::new(Photo::Photo(PhotoConstructor { - flags: 0, - has_stickers: None, - id: 123, - access_hash: 456, - file_reference: vec![1], - date: 100, - dc_id: 2, - video_sizes: None, - sizes: vector(vec![Box::new(PhotoSize::PhotoSize(PhotoSizeConstructor { - type_: "x".into(), - w: 128, - h: 128, - size: 123, - }))]), - }))), - }, - ))); - message -} - -#[test] -fn unsupported_queue_entities_and_old_journal_records_cannot_be_edited() { - let mut source = photo_message(); - source.entities = Some(vector(vec![Box::new(MessageEntity::MessageEntityUnknown( - MessageEntityUnknownConstructor { - offset: 0, - length: 5, - }, - ))])); - let response = MessagesMessages::MessagesMessages(MessagesMessagesConstructor { - messages: vector(vec![Box::new(Message::Message(source))]), - topics: vector(Vec::new()), - chats: vector(Vec::new()), - users: vector(Vec::new()), - }); - let result = map_response( - response, - &mut crate::HashMap::default(), - &mut crate::media::MediaIndex::default(), - ) - .unwrap(); - assert!(result[0].has_unsupported_content); - assert!(require_editable(&result, 7, 37).is_err()); - let mut known = result[0].clone(); - known.has_unsupported_content = false; - assert!(require_editable(&[known.clone()], 7, 37).is_ok()); - assert!(require_editable(&[known.clone()], 8, 37).is_err()); - assert!(require_editable(&[known.clone()], 7, 38).is_err()); - assert!(require_editable(&[known.clone(), known.clone()], 7, 37).is_err()); - assert!(require_editable(&[], 7, 37).is_err()); - let mut old = serde_json::to_value(known).unwrap(); - old.as_object_mut() - .unwrap() - .remove("has_unsupported_content"); - let restored: ScheduledMessageDto = serde_json::from_value(old).unwrap(); - assert!(restored.has_unsupported_content); -} - -#[test] -fn rich_messages_and_unsupported_media_are_not_caption_editable() { - let mut rich = photo_message(); - rich.rich_message = Some(Box::new(RichMessage::RichMessage(RichMessageConstructor { - flags: 0, - rtl: None, - part: None, - blocks: vector(Vec::new()), - photos: vector(Vec::new()), - documents: vector(Vec::new()), - }))); - let mut unsupported = photo_message(); - unsupported.media = Some(Box::new(MessageMedia::MessageMediaUnsupported( - MessageMediaUnsupportedConstructor {}, - ))); - let mut caption_above = photo_message(); - caption_above.invert_media = Some(Box::new(True::True(TrueConstructor {}))); - for source in [rich, unsupported, caption_above] { - let response = MessagesMessages::MessagesMessages(MessagesMessagesConstructor { - messages: vector(vec![Box::new(Message::Message(source))]), - topics: vector(Vec::new()), - chats: vector(Vec::new()), - users: vector(Vec::new()), - }); - let mapped = map_response( - response, - &mut crate::HashMap::default(), - &mut crate::media::MediaIndex::default(), - ) - .unwrap(); - assert!(mapped[0].has_unsupported_content); - assert!(require_editable(&mapped, 7, 37).is_err()); - } -} - -#[test] -fn scheduled_snapshots_preserve_ids_online_dates_and_named_mentions() { - let message = photo_message(); - let response = MessagesMessages::MessagesMessages(MessagesMessagesConstructor { - messages: vector(vec![Box::new(Message::Message(message))]), - topics: vector(Vec::new()), - chats: vector(Vec::new()), - users: vector(Vec::new()), - }); - let mut peers = crate::HashMap::default(); - let mut media = crate::media::MediaIndex::default(); - crate::dialogs::dtos_from_messages(response.clone(), &mut peers, &mut media).unwrap(); - let original = media.get(&(7, 37)).cloned().unwrap(); - let result = map_response(response, &mut peers, &mut media).unwrap(); - assert_eq!((result[0].message.chat_id, result[0].message.id), (7, 37)); - assert!(!result[0].has_unsupported_content); - assert_eq!(result[0].message.date, i64::from(SCHEDULE_WHEN_ONLINE)); - assert_eq!(result[0].message.media_kind.as_deref(), Some("photo")); - assert!(result[0].message.media_cache_key.is_some()); - assert_eq!(media.get(&(7, 37)), Some(&original)); - assert!(media.contains_key(&(7, -38))); - let entities: serde_json::Value = - serde_json::from_str(result[0].message.entities_json.as_ref().unwrap()).unwrap(); - assert_eq!(entities[0]["kind"], "mention_name"); - assert_eq!(entities[0]["url"], "42"); - assert!( - map_response( - MessagesMessages::MessagesMessagesNotModified(MessagesMessagesNotModifiedConstructor { - count: 1 - }), - &mut peers, - &mut media - ) - .is_err() - ); -} - -#[test] -fn scheduled_pushes_use_distinct_media_identity_and_roundtrip_through_journal_events() { - let mut media = crate::media::MediaIndex::default(); - let mut events = Vec::new(); - let update = Update::UpdateNewScheduledMessage(UpdateNewScheduledMessageConstructor { - message: Box::new(Message::Message(photo_message())), - }); - crate::updates::updates_rpc::collect_other_updates( - std::iter::once(&update), - &mut media, - &mut Vec::new(), - &mut events, - ); - assert_eq!(events.len(), 1); - let crate::UpdateEventDto::ScheduledMessageChanged { message } = &events[0] else { - panic!("queue event required") - }; - assert_eq!(message.message.id, 37); - assert_eq!(message.message.date, i64::from(SCHEDULE_WHEN_ONLINE)); - assert!( - message - .message - .entities_json - .as_ref() - .unwrap() - .contains("mention_name") - ); - assert!(media.contains_key(&(7, -38))); - assert!(!media.contains_key(&(7, 37))); - let encoded = serde_json::to_string(&events).unwrap(); - let decoded: Vec = serde_json::from_str(&encoded).unwrap(); - assert!( - matches!(&decoded[0], crate::UpdateEventDto::ScheduledMessageChanged { message } if message.message.id == 37) - ); - let mut replacement = photo_message(); - replacement.media = None; - replacement.flags &= !MessageConstructor::MEDIA_FLAG; - let update = Update::UpdateNewScheduledMessage(UpdateNewScheduledMessageConstructor { - message: Box::new(Message::Message(replacement)), - }); - crate::updates::updates_rpc::collect_other_updates( - std::iter::once(&update), - &mut media, - &mut Vec::new(), - &mut events, - ); - assert!(!media.contains_key(&(7, -38))); -} - -#[test] -fn queue_deletion_retains_delivery_ids_without_deleting_normal_media_or_advancing_pts() { - for sent in [None, Some(vec![500])] { - let mut media = crate::media::MediaIndex::default(); - crate::dialogs::message_to_dto(&Message::Message(photo_message()), &mut media).unwrap(); - map_update(&Message::Message(photo_message()), &mut media).unwrap(); - let update = - Update::UpdateDeleteScheduledMessages(UpdateDeleteScheduledMessagesConstructor { - flags: if sent.is_some() { - UpdateDeleteScheduledMessagesConstructor::SENT_MESSAGES_FLAG - } else { - 0 - }, - peer: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 7 })), - messages: vector(vec![37]), - sent_messages: sent.clone().map(vector), - }); - let mut cursor = crate::UpdatesStateDto { - pts: 10, - qts: 4, - date: 100, - seq: 2, - }; - assert!( - crate::updates::updates_rpc::advance_push_update( - &update, - &mut cursor, - &mut crate::HashMap::default(), - true, - &mut Vec::new() - ) - .unwrap() - ); - assert_eq!( - (cursor.pts, cursor.qts, cursor.seq, cursor.date), - (10, 4, 2, 100) - ); - let mut events = Vec::new(); - crate::updates::updates_rpc::collect_other_updates( - std::iter::once(&update), - &mut media, - &mut Vec::new(), - &mut events, - ); - let crate::UpdateEventDto::ScheduledMessagesDeleted { - chat_id, - message_ids, - sent_message_ids, - } = &events[0] - else { - panic!("queue deletion required") - }; - assert_eq!((*chat_id, message_ids.as_slice()), (7, &[37][..])); - assert_eq!(*sent_message_ids, sent); - assert!(media.contains_key(&(7, 37))); - assert!(!media.contains_key(&(7, -38))); - } -} - -#[test] -fn selections_and_dates_are_validated_before_client_lookup() { - for ids in [vec![], vec![0], vec![-1], vec![1, 1], (1..=101).collect()] { - assert!(validate_ids(&ids).is_err()); - assert!(delete_scheduled_messages(0, 7, ids).is_err()); - } - assert!(validate_ids(&(1..=100).collect::>()).is_ok()); - for date in [-1, 0, i32::MAX] { - assert!(validate_date(date).is_err()); - } - assert!(validate_date(SCHEDULE_WHEN_ONLINE).is_ok()); -} diff --git a/native/mtproto-rs/tests/unit/client_upload_options_tests.rs b/native/mtproto-rs/tests/unit/client_upload_options_tests.rs deleted file mode 100644 index e99b790f6..000000000 --- a/native/mtproto-rs/tests/unit/client_upload_options_tests.rs +++ /dev/null @@ -1,99 +0,0 @@ -use super::*; - -#[test] -fn upload_owner_changes_stop_before_the_next_chunk_or_file_access() { - let path = std::env::temp_dir().join(format!( - "monogram-upload-owner-{}.session", - std::process::id() - )); - let file = path.with_extension("bin"); - std::fs::write(&file, vec![1u8; 96 * 1024]).unwrap(); - let handle = create_client(1, "fixture".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - client.policy.own_file_part_kib(32); - client.policy.own_pipeline_parts(1); - let dc = client.main.lock().snapshot.dc_id; - *client.policy.upload_rtt.lock() = Some(( - dc, - std::time::Instant::now(), - std::time::Duration::from_secs(1), - )); - let owner = { - let data = client.data.lock(); - UploadOwner { - generation: data.journal.account_generation, - session_id: data.home_session_id, - } - }; - let item = crate::UploadItemDto { - path: file.to_string_lossy().into(), - kind: "document".into(), - mime_type: String::new(), - file_name: "fixture.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 123, - }; - let mut chunks = 0; - let result = save_items_with_owner(&client, 1, &[item.clone()], Some(owner), |_, _| { - chunks += 1; - let mut data = client.data.lock(); - Arc::make_mut(&mut data.journal).account_generation = owner.generation.wrapping_add(1); - Ok(()) - }); - assert!(result.is_err()); - assert_eq!(chunks, 1); - let stale = save_items_with_owner(&client, 1, &[item], Some(owner), |_, _| { - panic!("stale owner must not upload") - }); - assert!( - matches!(stale, Err(MtprotoError::Message(ref value)) if value == "request cancelled: session changed") - ); - destroy_client(handle); - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_file(file); -} - -#[test] -fn upload_options_reject_unpersisted_ids_before_client_or_file_access() { - let item = crate::UploadItemDto { - path: "synthetic-unused".into(), - kind: "photo".into(), - mime_type: String::new(), - file_name: "fixture.jpg".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - }; - let result = send_uploaded_media_with_options( - 0, - 7, - item.clone(), - 0, - 0, - None, - crate::SendOptions::default(), - ); - assert!( - matches!(result, Err(MtprotoError::Message(ref value)) if value.contains("distinct persisted random ids")) - ); - let mut item = item; - item.random_id = 123; - assert!( - send_uploaded_album_with_options( - 0, - 7, - vec![item.clone(), item], - 0, - 0, - crate::SendOptions::default() - ) - .is_err() - ); -} diff --git a/native/mtproto-rs/tests/unit/destination_lock_tests.rs b/native/mtproto-rs/tests/unit/destination_lock_tests.rs deleted file mode 100644 index 2bd74eb4e..000000000 --- a/native/mtproto-rs/tests/unit/destination_lock_tests.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::*; - -#[test] -fn idle_destination_lock_leaves_the_map() { - let path = std::env::temp_dir().join(format!( - "monogram-dest-lock-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) - )); - let before = destination_lock_entries(); - { - let outer = DestinationLock::acquire(&path); - let inner = DestinationLock::acquire(&path); - assert!(destination_lock_entries() >= before + 1); - drop(inner); - assert!(destination_lock_entries() >= before + 1); - drop(outer); - } - assert_eq!(destination_lock_entries(), before); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_forum_tests.rs b/native/mtproto-rs/tests/unit/dialogs_forum_tests.rs deleted file mode 100644 index 0dcb0c8b1..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_forum_tests.rs +++ /dev/null @@ -1,85 +0,0 @@ -use super::map_forum_topic; -use tellers_mtproto::latest::api::{ - ForumTopic, ForumTopicConstructor, ForumTopicDeletedConstructor, Peer, PeerChannelConstructor, - PeerNotifySettings, PeerNotifySettingsConstructor, PeerUserConstructor, True, TrueConstructor, -}; - -fn flag(on: bool) -> Option> { - on.then(|| Box::new(True::True(TrueConstructor {}))) -} - -fn topic(id: i32, title: &str, short: bool, unread: i32) -> ForumTopic { - ForumTopic::ForumTopic(ForumTopicConstructor { - flags: 0, - my: None, - closed: None, - pinned: flag(id == 1), - short: flag(short), - hidden: None, - title_missing: None, - id, - date: 10, - peer: Box::new(Peer::PeerChannel(PeerChannelConstructor { channel_id: 5 })), - title: title.into(), - icon_color: 0x6FB9F0, - icon_emoji_id: None, - top_message: 99, - read_inbox_max_id: 80, - read_outbox_max_id: 90, - unread_count: unread, - unread_mentions_count: 1, - unread_reactions_count: 3, - unread_poll_votes_count: 0, - from_id: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 7 })), - notify_settings: Box::new(PeerNotifySettings::PeerNotifySettings( - PeerNotifySettingsConstructor { - flags: 0, - show_previews: None, - silent: None, - mute_until: None, - ios_sound: None, - android_sound: None, - other_sound: None, - stories_muted: None, - stories_hide_sender: None, - stories_ios_sound: None, - stories_android_sound: None, - stories_other_sound: None, - }, - )), - draft: None, - }) -} - -#[test] -fn maps_general_topic() { - let mapped = map_forum_topic(&topic(1, "General", false, 2), Some("hi".into())); - assert_eq!(mapped.id, 1); - assert_eq!(mapped.title, "General"); - assert!(mapped.pinned); - assert!(!mapped.deleted); - assert_eq!(mapped.unread_count, 2); - assert_eq!(mapped.unread_reactions_count, 3); - assert_eq!(mapped.last_message_preview.as_deref(), Some("hi")); - assert_eq!(mapped.icon_color, 0x6FB9F0); -} - -#[test] -fn maps_short_topic_unread() { - let mapped = map_forum_topic(&topic(8, "Bugs", true, 4), None); - assert!(mapped.short); - assert_eq!(mapped.unread_count, 4); - assert_eq!(mapped.unread_mentions_count, 1); -} - -#[test] -fn maps_deleted_topic() { - let mapped = map_forum_topic( - &ForumTopic::ForumTopicDeleted(ForumTopicDeletedConstructor { id: 9 }), - Some("gone".into()), - ); - assert_eq!(mapped.id, 9); - assert!(mapped.deleted); - assert!(mapped.title.is_empty()); - assert!(mapped.last_message_preview.is_none()); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_header_tests.rs b/native/mtproto-rs/tests/unit/dialogs_header_tests.rs deleted file mode 100644 index 723f674c4..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_header_tests.rs +++ /dev/null @@ -1,425 +0,0 @@ -use super::{fill_missing_reply_quotes, fwd_from_label, message_to_dto, reply_meta, via_bot_label}; -use crate::MessageDto; -use crate::{HashMap, HashMapExt}; -use tellers_mtproto::latest::api::{ - MessageFwdHeader, MessageFwdHeaderConstructor, MessageReplyHeader, - MessageReplyHeaderConstructor, RichText, TextBoldConstructor, TextPlainConstructor, True, - TrueConstructor, Vector, VectorConstructor, -}; - -fn dto(id: i32, text: &str, reply_to: Option, quote: Option<&str>) -> MessageDto { - MessageDto { - media_preview_restricted: None, - chat_id: 1, - id, - sender_id: None, - text: Some(text.into()), - date: 0, - edit_date: None, - outgoing: false, - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: quote.map(str::to_string), - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id: reply_to, - reply_to_top_id: None, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - } -} - -#[test] -fn reply_meta_reads_id_and_quote() { - let header = MessageReplyHeader::MessageReplyHeader(MessageReplyHeaderConstructor { - flags: 0, - reply_to_scheduled: None, - forum_topic: None, - quote: None, - reply_to_msg_id: Some(9), - reply_to_peer_id: None, - reply_from: None, - reply_media: None, - reply_to_top_id: Some(42), - quote_text: Some("quoted".into()), - quote_entities: None, - quote_offset: None, - quote_rich: None, - todo_item_id: None, - poll_option: None, - reply_to_ephemeral: None, - }); - assert_eq!( - reply_meta(Some(&header)), - (Some(9), Some(42), Some("quoted".into()), None, false), - ); -} - -#[test] -fn reply_meta_reads_rich_quote() { - let rich = Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(RichText::TextBold(TextBoldConstructor { - text: Box::new(RichText::TextPlain(TextPlainConstructor { - text: "quoted".into(), - })), - }))], - }); - let header = MessageReplyHeader::MessageReplyHeader(MessageReplyHeaderConstructor { - flags: MessageReplyHeaderConstructor::QUOTE_RICH_FLAG, - reply_to_scheduled: None, - forum_topic: None, - quote: None, - reply_to_msg_id: Some(9), - reply_to_peer_id: None, - reply_from: None, - reply_media: None, - reply_to_top_id: None, - quote_text: None, - quote_entities: None, - quote_offset: None, - quote_rich: Some(Box::new(rich)), - todo_item_id: None, - poll_option: None, - reply_to_ephemeral: None, - }); - let (id, top, quote, entities, forum) = reply_meta(Some(&header)); - assert_eq!( - (id, top, quote, forum), - (Some(9), None, Some("quoted".into()), false) - ); - assert!( - entities - .as_deref() - .unwrap_or_default() - .contains(r#""kind":"bold""#), - "expected bold entity, got {entities:?}", - ); -} - -#[test] -fn reply_meta_reads_forum_topic_flag() { - let header = MessageReplyHeader::MessageReplyHeader(MessageReplyHeaderConstructor { - flags: 0, - reply_to_scheduled: None, - forum_topic: Some(Box::new(True::True(TrueConstructor {}))), - quote: None, - reply_to_msg_id: Some(9), - reply_to_peer_id: None, - reply_from: None, - reply_media: None, - reply_to_top_id: Some(42), - quote_text: None, - quote_entities: None, - quote_offset: None, - quote_rich: None, - todo_item_id: None, - poll_option: None, - reply_to_ephemeral: None, - }); - assert_eq!(reply_meta(Some(&header)).4, true); -} - -#[test] -fn fwd_prefers_from_name() { - let header = MessageFwdHeader::MessageFwdHeader(MessageFwdHeaderConstructor { - flags: 0, - imported: None, - saved_out: None, - from_id: None, - from_name: Some("Alice".into()), - date: 1, - channel_post: None, - post_author: Some("Bob".into()), - saved_from_peer: None, - saved_from_msg_id: None, - saved_from_id: None, - saved_from_name: None, - saved_date: None, - psa_type: None, - }); - assert_eq!( - fwd_from_label(Some(&header), &HashMap::new()).as_deref(), - Some("Alice") - ); - assert_eq!(super::fwd_origin(Some(&header)), (None, Some(1))); - let MessageFwdHeader::MessageFwdHeader(mut visible) = header else { - panic!("expected forward header"); - }; - visible.from_id = Some(Box::new(tellers_mtproto::latest::api::Peer::PeerUser( - tellers_mtproto::latest::api::PeerUserConstructor { user_id: 7 }, - ))); - assert_eq!( - super::fwd_origin(Some(&MessageFwdHeader::MessageFwdHeader(visible))), - (Some(7), Some(1)) - ); -} - -#[test] -fn via_bot_uses_username() { - let mut names = HashMap::new(); - names.insert(7_i64, "Helper Bot".into()); - let mut users = HashMap::new(); - users.insert(7_i64, "helperbot".into()); - assert_eq!( - via_bot_label(Some(7), &users, &names).as_deref(), - Some("helperbot") - ); -} - -#[test] -fn fill_reply_quote_from_sibling() { - let mut dtos = vec![ - dto(1, "original", None, None), - dto(2, "reply", Some(1), None), - ]; - fill_missing_reply_quotes(&mut dtos); - assert_eq!(dtos[1].reply_quote.as_deref(), Some("original")); -} - -use super::entity_json; - -#[test] -fn entity_json_roundtrip_shape() { - let payload = serde_json::to_string(&vec![entity_json("bold", 0, 4, None)]).expect("json"); - assert!(payload.contains("\"kind\":\"bold\"")); - assert!(payload.contains("\"offset\":0")); - assert!(payload.contains("\"length\":4")); -} - -fn photo_slot(cache_key: &str) -> crate::media::MediaRef { - crate::media::MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: cache_key.into(), - location: crate::media::MediaLocation::Inline { - bytes: Vec::new(), - extension: "jpg".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - } -} - -fn text_message(sender_id: i64, id: i32) -> tellers_mtproto::latest::api::Message { - use tellers_mtproto::latest::api::{ - Message, MessageConstructor, Peer, PeerChatConstructor, PeerUserConstructor, - }; - Message::Message(MessageConstructor { - flags: 0, - out: None, - mentioned: None, - media_unread: None, - silent: None, - post: None, - from_scheduled: None, - legacy: None, - edit_hide: None, - pinned: None, - noforwards: None, - invert_media: None, - flags2: 0, - offline: None, - video_processing_pending: None, - paid_suggested_post_stars: None, - paid_suggested_post_ton: None, - id, - from_id: Some(Box::new(Peer::PeerUser(PeerUserConstructor { - user_id: sender_id, - }))), - from_boosts_applied: None, - from_rank: None, - peer_id: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id: 3 })), - saved_peer_id: None, - fwd_from: None, - via_bot_id: None, - via_business_bot_id: None, - guestchat_via_from: None, - reply_to: None, - date: 1, - message: "hi".into(), - media: None, - reply_markup: None, - entities: None, - views: None, - forwards: None, - replies: None, - edit_date: None, - post_author: None, - grouped_id: None, - reactions: None, - restriction_reason: None, - ttl_period: None, - quick_reply_shortcut_id: None, - effect: None, - factcheck: None, - report_delivery_until_date: None, - paid_message_stars: None, - suggested_post: None, - schedule_repeat_period: None, - summary_from_language: None, - rich_message: None, - }) -} - -fn service_message(sender_id: i64, id: i32) -> tellers_mtproto::latest::api::Message { - use tellers_mtproto::latest::api::{ - Message, MessageAction, MessageActionHistoryClearConstructor, MessageServiceConstructor, - Peer, PeerChatConstructor, PeerUserConstructor, - }; - Message::MessageService(MessageServiceConstructor { - flags: 0, - out: None, - mentioned: None, - media_unread: None, - reactions_are_possible: None, - silent: None, - post: None, - legacy: None, - id, - from_id: Some(Box::new(Peer::PeerUser(PeerUserConstructor { - user_id: sender_id, - }))), - peer_id: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id: 3 })), - saved_peer_id: None, - reply_to: None, - date: 1, - action: Box::new(MessageAction::MessageActionHistoryClear( - MessageActionHistoryClearConstructor {}, - )), - reactions: None, - ttl_period: None, - }) -} - -#[test] -fn notification_media_privacy_keeps_unknown_distinct_from_spoilers_and_expiry() { - use tellers_mtproto::latest::api::{ - Message, MessageMedia, MessageMediaDocumentConstructor, MessageMediaPhotoConstructor, - }; - let mut old = serde_json::to_value(dto(1, "hi", None, None)).unwrap(); - old.as_object_mut() - .unwrap() - .remove("media_preview_restricted"); - assert!( - serde_json::from_value::(old) - .unwrap() - .media_preview_restricted - .is_none() - ); - for document in [false, true] { - for (spoiler, ttl) in [ - (false, None), - (true, None), - (false, Some(10)), - (true, Some(0)), - ] { - let mut message = text_message(7, 3); - let media = if document { - MessageMedia::MessageMediaDocument(MessageMediaDocumentConstructor { - flags: 0, - nopremium: None, - spoiler: spoiler.then(|| Box::new(True::True(TrueConstructor {}))), - video: None, - round: None, - voice: None, - document: None, - alt_documents: None, - video_cover: None, - video_timestamp: None, - ttl_seconds: ttl, - }) - } else { - MessageMedia::MessageMediaPhoto(MessageMediaPhotoConstructor { - flags: 0, - spoiler: spoiler.then(|| Box::new(True::True(TrueConstructor {}))), - live_photo: None, - photo: None, - ttl_seconds: ttl, - video: None, - }) - }; - if let Message::Message(ref mut inner) = message { - inner.media = Some(Box::new(media)); - } - let mapped = - message_to_dto(&message, &mut crate::media::MediaIndex::default()).unwrap(); - assert_eq!( - mapped.media_preview_restricted, - Some(spoiler || ttl.is_some()) - ); - if let Message::Message(ref mut inner) = message { - inner.ttl_period = Some(60); - } - assert_eq!( - message_to_dto(&message, &mut crate::media::MediaIndex::default()) - .unwrap() - .media_preview_restricted, - Some(true) - ); - } - } - assert!( - message_to_dto( - &text_message(7, 3), - &mut crate::media::MediaIndex::default() - ) - .unwrap() - .media_preview_restricted - .is_none() - ); -} - -#[test] -fn sender_photo_cache_key_defaults_and_copies_avatar_slots_only() { - let mut value = serde_json::to_value(dto(1, "hi", None, None)).expect("json"); - value - .as_object_mut() - .expect("object") - .remove("sender_photo_cache_key"); - let parsed: MessageDto = serde_json::from_value(value).expect("old json"); - assert!(parsed.sender_photo_cache_key.is_none()); - - let mut media = crate::media::MediaIndex::default(); - media.insert((7, 0), photo_slot("avatar:7")); - let mapped = message_to_dto(&text_message(7, 3), &mut media).expect("text"); - assert_eq!(mapped.sender_photo_cache_key.as_deref(), Some("avatar:7")); - - media.insert((8, 0), photo_slot("avatar:8:video")); - let video = message_to_dto(&text_message(8, 4), &mut media).expect("video"); - assert_eq!( - video.sender_photo_cache_key.as_deref(), - Some("avatar:8:video") - ); - - media.insert((9, 0), photo_slot("doc:9")); - let document = message_to_dto(&text_message(9, 5), &mut media).expect("doc"); - assert!(document.sender_photo_cache_key.is_none()); - - let service = message_to_dto(&service_message(7, 6), &mut media).expect("service"); - assert_eq!(service.sender_photo_cache_key.as_deref(), Some("avatar:7")); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_history_tests.rs b/native/mtproto-rs/tests/unit/dialogs_history_tests.rs deleted file mode 100644 index cc89488b5..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_history_tests.rs +++ /dev/null @@ -1,110 +0,0 @@ -use super::{get_history, refresh_message_media}; -use crate::{HashMap, HashMapExt}; -use tellers_mtproto_session::{OsRandom, Snapshot}; - -#[test] -fn unknown_peer_fails_before_rpc() { - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - let mut peers = HashMap::new(); - let mut media = crate::media::MediaIndex::default(); - let err = get_history( - &mut snapshot, - 1, - &mut peers, - &mut media, - 42, - 10, - 0, - 0, - 0, - None, - ) - .expect_err("unknown peer"); - match err { - crate::MtprotoError::Message(message) => { - assert!(message.contains("unknown peer 42")); - assert!(message.contains("refresh chats first")); - } - other => panic!("unexpected {other:?}"), - } -} - -#[test] -fn get_dialogs_first_page_uses_empty_offset_peer() { - use tellers_mtproto::latest::api::InputPeerEmptyConstructor; - let request = super::MessagesGetDialogsRequest { - flags: 0, - exclude_pinned: None, - folder_id: None, - offset_date: 0, - offset_id: 0, - offset_peer: Box::new(super::InputPeer::InputPeerEmpty( - InputPeerEmptyConstructor {}, - )), - limit: 40, - hash: 0, - }; - let bytes = crate::rpc::encode_boxed_bytes(&request).expect("encode"); - assert!( - bytes - .windows(4) - .any(|w| w == &super::MessagesGetDialogsRequest::ID.to_le_bytes()), - "messages.getDialogs ctor", - ); - assert!( - bytes - .windows(4) - .any(|w| w == &InputPeerEmptyConstructor::ID.to_le_bytes()), - "first page offset_peer is inputPeerEmpty", - ); -} - -#[test] -fn get_messages_request_encodes_input_message_id() { - let req = super::MessagesGetMessagesRequest { - id: super::input_message_ids(7), - }; - let bytes = crate::rpc::encode_boxed_bytes(&req).expect("encode"); - assert!( - bytes - .windows(4) - .any(|w| w == &super::MessagesGetMessagesRequest::ID.to_le_bytes()), - "messages.getMessages ctor", - ); - assert!( - bytes - .windows(4) - .any(|w| w == &super::InputMessageIdConstructor::ID.to_le_bytes()), - "inputMessageID ctor", - ); - assert!(bytes.windows(4).any(|w| w == &7i32.to_le_bytes())); -} - -#[test] -fn refresh_message_media_unknown_peer_fails_before_rpc() { - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - let peers = HashMap::new(); - let mut media = crate::media::MediaIndex::default(); - let err = refresh_message_media(&mut snapshot, 1, &peers, &mut media, 42, 7) - .expect_err("unknown peer"); - match err { - crate::MtprotoError::Message(message) => { - assert!(message.contains("unknown peer 42")); - } - other => panic!("unexpected {other:?}"), - } -} - -use super::merge_channel_pts; - -#[test] -fn dialog_pts_seed_is_positive_and_monotonic() { - let mut cursors = HashMap::from_iter([(42_i64, 80_i32)]); - - merge_channel_pts(&mut cursors, 42, Some(64)); - merge_channel_pts(&mut cursors, 42, Some(96)); - merge_channel_pts(&mut cursors, 42, Some(0)); - merge_channel_pts(&mut cursors, 42, None); - - assert_eq!(cursors.get(&42), Some(&96)); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_membership_tests.rs b/native/mtproto-rs/tests/unit/dialogs_membership_tests.rs deleted file mode 100644 index 34c26cc5a..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_membership_tests.rs +++ /dev/null @@ -1,167 +0,0 @@ -use super::*; -use crate::{HashMap, HashMapExt}; -use tellers_mtproto::latest::api::{ - ChannelForbiddenConstructor, ChatConstructor, ChatForbiddenConstructor, ChatPhoto, - ChatPhotoEmptyConstructor, Dialog, DialogConstructor, Message, Peer, PeerChatConstructor, - PeerNotifySettings, PeerNotifySettingsConstructor, True, TrueConstructor, User, Vector, - VectorConstructor, -}; - -fn flag(on: bool) -> Option> { - on.then(|| Box::new(True::True(TrueConstructor {}))) -} - -fn basic_group(left: bool, deactivated: bool) -> TlChat { - group(11, left, deactivated) -} - -fn group(id: i64, left: bool, deactivated: bool) -> TlChat { - TlChat::Chat(ChatConstructor { - flags: 0, - creator: None, - left: flag(left), - deactivated: flag(deactivated), - call_active: None, - call_not_empty: None, - noforwards: None, - id, - title: "Comment group".into(), - photo: Box::new(ChatPhoto::ChatPhotoEmpty(ChatPhotoEmptyConstructor {})), - participants_count: 3, - date: 20, - version: 1, - migrated_to: None, - admin_rights: None, - default_banned_rights: None, - }) -} - -#[test] -fn joined_group_is_not_left() { - let (id, meta) = chat_meta_from_tl(&basic_group(false, false)).expect("meta"); - assert_eq!(id, 11); - assert!(meta.is_group); - assert!(!meta.left); -} - -#[test] -fn left_group_reports_membership_loss() { - let (_, meta) = chat_meta_from_tl(&basic_group(true, false)).expect("meta"); - assert!(meta.left); -} - -#[test] -fn migrated_group_reports_membership_loss() { - let (_, meta) = chat_meta_from_tl(&basic_group(false, true)).expect("meta"); - assert!(meta.left); -} - -#[test] -fn forbidden_peers_are_left() { - let chat = TlChat::ChatForbidden(ChatForbiddenConstructor { - id: 12, - title: "Gone".into(), - }); - let (_, meta) = chat_meta_from_tl(&chat).expect("meta"); - assert!(meta.left, "a forbidden group cannot be a membership"); - - let channel = TlChat::ChannelForbidden(ChannelForbiddenConstructor { - flags: 0, - broadcast: None, - megagroup: flag(true), - monoforum: None, - id: 13, - access_hash: 99, - title: "Gone too".into(), - until_date: None, - }); - let (_, meta) = chat_meta_from_tl(&channel).expect("meta"); - assert!(meta.left, "a forbidden channel cannot be a membership"); -} - -fn boxed_vec(items: Vec) -> Box>> { - Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items.into_iter().map(Box::new).collect(), - })) -} - -fn notify() -> PeerNotifySettings { - PeerNotifySettings::PeerNotifySettings(PeerNotifySettingsConstructor { - flags: 0, - show_previews: None, - silent: None, - mute_until: None, - ios_sound: None, - android_sound: None, - other_sound: None, - stories_muted: None, - stories_hide_sender: None, - stories_ios_sound: None, - stories_android_sound: None, - stories_other_sound: None, - }) -} - -fn chat_dialog(chat_id: i64) -> Dialog { - Dialog::Dialog(DialogConstructor { - flags: 0, - pinned: None, - unread_mark: None, - view_forum_as_messages: None, - peer: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id })), - top_message: 1, - read_inbox_max_id: 1, - read_outbox_max_id: 0, - unread_count: 6, - unread_mentions_count: 0, - unread_reactions_count: 0, - unread_poll_votes_count: 0, - notify_settings: Box::new(notify()), - pts: None, - draft: None, - folder_id: None, - ttl_period: None, - }) -} - -#[test] -fn left_dialogs_are_kept_so_room_can_hide_them() { - let mut peers = HashMap::new(); - let mut media = crate::media::MediaIndex::default(); - let mut channel_pts = HashMap::new(); - let out = map_peer_dialogs( - &mut peers, - &mut media, - &mut channel_pts, - boxed_vec(vec![chat_dialog(11), chat_dialog(12)]), - boxed_vec(Vec::::new()), - 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)); - assert!(left.expect("left dialog").left); - assert!(!joined.expect("joined dialog").left); -} - -#[test] -fn unknown_peer_dialogs_are_dropped() { - let mut peers = HashMap::new(); - let mut media = crate::media::MediaIndex::default(); - let mut channel_pts = HashMap::new(); - let out = map_peer_dialogs( - &mut peers, - &mut media, - &mut channel_pts, - boxed_vec(vec![chat_dialog(99)]), - boxed_vec(Vec::::new()), - boxed_vec(Vec::::new()), - boxed_vec(Vec::::new()), - ); - assert!(out.is_empty()); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_message_lookup_tests.rs b/native/mtproto-rs/tests/unit/dialogs_message_lookup_tests.rs deleted file mode 100644 index 32c4e7406..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_message_lookup_tests.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::*; - -fn rows(messages: Vec) -> Vector> { - Vector::Vector(VectorConstructor { - field_0: messages.len() as u32, - field_1: messages.into_iter().map(Box::new).collect(), - }) -} - -fn service(id: i32) -> Message { - Message::MessageService(MessageServiceConstructor { - flags: 0, - out: None, - mentioned: None, - media_unread: None, - reactions_are_possible: None, - silent: None, - post: None, - legacy: None, - id, - from_id: Some(Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 8 }))), - peer_id: Box::new(Peer::PeerChat(PeerChatConstructor { chat_id: 3 })), - saved_peer_id: None, - reply_to: None, - date: 1, - action: Box::new(MessageAction::MessageActionChatEditTitle( - MessageActionChatEditTitleConstructor { - title: "fixture".into(), - }, - )), - reactions: None, - ttl_period: None, - }) -} - -#[test] -fn exact_lookup_accepts_only_one_matching_message() { - let mut index = MediaIndex::default(); - let result = select_message(&rows(vec![service(37)]), &mut index, -3, 37) - .unwrap() - .unwrap(); - assert_eq!((result.chat_id, result.id), (-3, 37)); - assert!(select_message(&rows(vec![service(37)]), &mut index, -4, 37).is_err()); - assert!(select_message(&rows(vec![service(38)]), &mut index, -3, 37).is_err()); - assert!(select_message(&rows(vec![service(37), service(37)]), &mut index, -3, 37).is_err()); -} - -#[test] -fn deleted_lookup_requires_matching_empty_identity() { - let mut index = MediaIndex::default(); - assert!( - select_message(&rows(vec![]), &mut index, -3, 37) - .unwrap() - .is_none() - ); - for (id, peer, valid) in [(37, 3, true), (38, 3, false), (37, 4, false)] { - let empty = Message::MessageEmpty(MessageEmptyConstructor { - flags: 1, - id, - peer_id: Some(Box::new(Peer::PeerChat(PeerChatConstructor { - chat_id: peer, - }))), - }); - assert_eq!( - select_message(&rows(vec![empty]), &mut index, -3, 37).is_ok(), - valid - ); - } -} - -#[test] -fn lookup_rejects_local_ids_before_resolving_peers_or_connecting() { - let mut snapshot = - tellers_mtproto_session::Snapshot::new(2, &mut tellers_mtproto_session::OsRandom).unwrap(); - for id in [0, -1] { - assert!( - matches!(get_message(&mut snapshot, 1, &HashMap::default(), &mut MediaIndex::default(), -3, id), - Err(MtprotoError::Message(ref value)) if value == "message id required") - ); - } -} diff --git a/native/mtproto-rs/tests/unit/dialogs_mute_tests.rs b/native/mtproto-rs/tests/unit/dialogs_mute_tests.rs deleted file mode 100644 index d5c655e18..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_mute_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -use tellers_mtproto::latest::api::{PeerNotifySettings, PeerNotifySettingsConstructor}; - -fn settings(mute_until: Option) -> PeerNotifySettings { - PeerNotifySettings::PeerNotifySettings(PeerNotifySettingsConstructor { - flags: 0, - show_previews: None, - silent: None, - mute_until, - ios_sound: None, - android_sound: None, - other_sound: None, - stories_muted: None, - stories_hide_sender: None, - stories_ios_sound: None, - stories_android_sound: None, - stories_other_sound: None, - }) -} - -fn now() -> i32 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i32) - .unwrap_or(0) -} - -#[test] -fn future_mute_is_muted_and_is_an_override() { - let s = settings(Some(now() + 3600)); - assert!(super::is_muted(&s)); - assert!(super::is_mute_override(&s)); -} - -#[test] -fn absent_mute_until_inherits_the_type_default() { - let s = settings(None); - assert!(!super::is_muted(&s)); - assert!(!super::is_mute_override(&s)); -} - -#[test] -fn expired_mute_is_unmuted_but_still_an_override() { - let s = settings(Some(now() - 60)); - assert!(!super::is_muted(&s)); - assert!(super::is_mute_override(&s)); -} - -#[test] -fn forever_mute_is_muted() { - // Telegram uses i32::MAX for "mute forever". - let s = settings(Some(i32::MAX)); - assert!(super::is_muted(&s)); - assert!(super::is_mute_override(&s)); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_preview_tests.rs b/native/mtproto-rs/tests/unit/dialogs_preview_tests.rs deleted file mode 100644 index 33fd5d46e..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_preview_tests.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::{compose_chat_list_preview, preview_hides_file_name}; - -#[test] -fn dm_keeps_media_and_caption() { - assert_eq!( - compose_chat_list_preview(false, false, Some("Mike"), Some("Photo"), "hello").as_deref(), - Some("Photo, hello"), - ); -} - -#[test] -fn group_prefixes_sender() { - assert_eq!( - compose_chat_list_preview(true, false, Some("Mike"), Some("Photo"), "hello").as_deref(), - Some("Mike: Photo, hello"), - ); -} - -#[test] -fn group_outgoing_uses_you() { - assert_eq!( - compose_chat_list_preview(true, true, Some("Ada"), None, "sent").as_deref(), - Some("You: sent"), - ); -} - -#[test] -fn sticker_and_gif_file_names_stay_out_of_previews() { - for kind in ["sticker", "sticker_animated", "sticker_video", "gif"] { - assert!(preview_hides_file_name(Some(kind), "AnimatedSticker.tgs")); - assert!(preview_hides_file_name(Some(kind), "sticker.webp")); - assert!(preview_hides_file_name(Some(kind), "mp4.mp4")); - } - // A sticker file can also arrive as a plain document. - assert!(preview_hides_file_name(Some("document"), "sticker.webm")); - assert!(preview_hides_file_name(None, "AnimatedSticker.tgs")); -} - -#[test] -fn generated_media_names_stay_out_of_previews() { - assert!(preview_hides_file_name( - Some("photo"), - "Screenshot_20200722-104614385.jpg" - )); - assert!(preview_hides_file_name(Some("video"), "VID_20200803.mp4")); - assert!(preview_hides_file_name(Some("voice"), "audio.ogg")); - assert!(preview_hides_file_name(Some("video_note"), "circle.mp4")); - assert!(preview_hides_file_name(Some("poll"), "{\"question\":1}")); -} - -#[test] -fn documents_and_audio_keep_their_name() { - assert!(!preview_hides_file_name(Some("document"), "report.pdf")); - assert!(!preview_hides_file_name(Some("audio"), "Track — Artist")); -} -#[test] -fn only_documents_and_audio_preview_their_file_name() { - for kind in [ - "todo", - "poll", - "geo", - "venue", - "contact", - "dice", - "photo", - "video", - "gif", - "sticker", - "sticker_animated", - "sticker_video", - "voice", - "webpage", - ] { - assert!( - preview_hides_file_name(Some(kind), "real-name.png"), - "{kind} must not surface its file name" - ); - } - assert!(!preview_hides_file_name(Some("document"), "report.pdf")); - assert!(!preview_hides_file_name(Some("audio"), "Track — Artist")); - assert!(!preview_hides_file_name(None, "holiday.jpg")); -} diff --git a/native/mtproto-rs/tests/unit/dialogs_rights_tests.rs b/native/mtproto-rs/tests/unit/dialogs_rights_tests.rs deleted file mode 100644 index 1921f09a7..000000000 --- a/native/mtproto-rs/tests/unit/dialogs_rights_tests.rs +++ /dev/null @@ -1,131 +0,0 @@ -use super::resolve_permissions; - -#[test] -fn poll_permissions_are_independent_of_plain_text_and_respect_membership_and_admins() { - use super::permissions::resolve_poll_permission as allowed; - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - use tellers_mtproto::latest::api::{ChatBannedRights, ChatBannedRightsConstructor as Rights}; - let decode = |flags: u32| { - let bytes = [ - Rights::ID.to_le_bytes(), - flags.to_le_bytes(), - 0_i32.to_le_bytes(), - ] - .concat(); - ChatBannedRights::decode(&mut Decoder::new(&bytes, Limits::default()).unwrap()).unwrap() - }; - let plain = decode(Rights::SEND_PLAIN_FLAG); - let polls = decode(Rights::SEND_POLLS_FLAG); - let send = decode(Rights::SEND_MESSAGES_FLAG); - let view = decode(Rights::VIEW_MESSAGES_FLAG); - assert!(allowed( - false, - false, - false, - false, - false, - Some(&plain), - Some(&plain) - )); - assert!(!allowed( - false, - false, - false, - false, - false, - None, - Some(&polls) - )); - assert!(allowed( - false, - false, - false, - true, - false, - None, - Some(&polls) - )); - assert!(allowed(false, false, true, false, false, None, Some(&send))); - assert!(!allowed( - false, - false, - false, - true, - false, - Some(&polls), - None - )); - assert!(!allowed( - false, - false, - false, - true, - false, - Some(&send), - None - )); - assert!(!allowed( - false, - false, - false, - true, - false, - Some(&view), - None - )); - assert!(!allowed(true, false, true, true, true, None, None)); - assert!(!allowed(false, true, false, true, false, None, None)); - assert!(allowed(false, true, false, true, true, None, Some(&polls))); -} - -#[test] -fn broadcast_member_cannot_send() { - let r = resolve_permissions( - false, false, true, false, false, false, false, false, false, false, false, false, false, - false, false, false, false, - ); - assert_eq!(r, (true, false, false, true, false)); -} - -#[test] -fn broadcast_admin_can_post() { - let r = resolve_permissions( - false, false, true, false, true, true, true, false, false, false, false, false, false, - true, true, true, true, - ); - assert_eq!(r, (true, true, true, true, true)); -} - -#[test] -fn default_ban_plain_blocks_text_not_photos() { - let r = resolve_permissions( - false, false, false, false, false, false, false, false, false, false, false, false, false, - false, true, false, false, - ); - assert_eq!(r.1, false); - assert_eq!(r.2, true); - assert_eq!(r.3, true); -} - -#[test] -fn admin_overrides_default_ban() { - let r = resolve_permissions( - false, false, false, false, false, false, true, false, false, false, false, false, false, - true, true, true, true, - ); - assert_eq!(r, (true, true, true, true, false)); -} - -#[test] -fn noforwards_and_kicked() { - let protected = resolve_permissions( - false, false, false, false, false, false, false, true, false, false, false, false, false, - false, false, false, false, - ); - assert!(!protected.3); - let kicked = resolve_permissions( - false, false, false, false, false, false, false, false, true, true, true, true, true, - false, false, false, false, - ); - assert_eq!(kicked, (false, false, false, false, false)); -} diff --git a/native/mtproto-rs/tests/unit/dns_txt_tests.rs b/native/mtproto-rs/tests/unit/dns_txt_tests.rs deleted file mode 100644 index 2d99f5ceb..000000000 --- a/native/mtproto-rs/tests/unit/dns_txt_tests.rs +++ /dev/null @@ -1,157 +0,0 @@ -use super::*; - -#[test] -fn concat_puts_longer_first_when_needed() { - assert_eq!(concat_txt_parts(&["ab".into(), "cdef".into()]), "cdefab"); - assert_eq!(concat_txt_parts(&["abcd".into(), "ef".into()]), "abcdef"); - assert_eq!(concat_txt_parts(&["aa".into(), "bb".into()]), "aabb"); -} - -#[test] -fn parse_config_simple_ip_port() { - let mut body = Vec::new(); - body.extend_from_slice(&1i32.to_le_bytes()); - body.extend_from_slice(&2i32.to_le_bytes()); - body.extend_from_slice(&1i32.to_le_bytes()); - body.extend_from_slice(&ACCESS_POINT_RULE.to_le_bytes()); - body.extend_from_slice(&[0, 0, 0, 0]); - body.extend_from_slice(&2i32.to_le_bytes()); - body.extend_from_slice(&1i32.to_le_bytes()); - body.extend_from_slice(&IP_PORT.to_le_bytes()); - let ip: u32 = (149 << 24) | (154 << 16) | (167 << 8) | 51; - body.extend_from_slice(&ip.to_le_bytes()); - body.extend_from_slice(&443i32.to_le_bytes()); - let got = parse_config_simple(&body).expect("parse"); - assert_eq!(got.len(), 1); - assert_eq!(got[0].dc_id, 2); - assert_eq!(got[0].addr, "149.154.167.51:443"); - assert!(got[0].secret.is_none()); -} - -#[test] -fn decode_emulator_sidecar_if_present() { - let path = std::env::var("TEMP") - .ok() - .map(|t| std::path::PathBuf::from(t).join("dc_txt.parts")); - let Some(path) = path.filter(|p| p.is_file()) else { - return; - }; - let text = std::fs::read_to_string(&path).expect("sidecar"); - let parts: Vec = text - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect(); - match decode_txt_parts(&parts) { - Ok(eps) => { - let summary: Vec = eps - .iter() - .map(|e| { - format!( - "dc={} addr={} secret={}", - e.dc_id, - e.addr, - e.secret.is_some() - ) - }) - .collect(); - eprintln!("decoded {}", summary.join("; ")); - assert!(!eps.is_empty()); - } - Err(e) => panic!("{e:?}"), - } -} - -#[test] -fn normalize_dd_prefixed_secret() { - let mut raw = vec![0xdd]; - raw.extend_from_slice(&[7u8; 16]); - assert_eq!(normalize_secret(&raw), Some([7u8; 16])); - assert_eq!(normalize_secret(&[7u8; 16]), Some([7u8; 16])); - assert_eq!(normalize_secret(&[1, 2, 3]), None); -} - -#[test] -fn config_cache_roundtrip_and_freshness() { - let secret = [7u8; 16]; - let text = format!( - "# dc_txt.config v1\nexpires 2000\n2 149.154.167.51:443 {}\n", - hex_encode(&secret) - ); - let cached = parse_config_cache(&text).expect("cache"); - assert_eq!(cached.tmp_sessions, None); - assert_eq!(cached.endpoints.len(), 1); - assert_eq!(cached.endpoints[0].addr, "149.154.167.51:443"); - assert_eq!(cached.endpoints[0].secret, Some(secret)); - assert!(cached.expires > 1000); - assert!(parse_config_cache("expires 1\n").is_none()); -} - -#[test] -fn secret_for_matches_exact_dc_addr() { - let previous = TXT_ENDPOINTS - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - *TXT_ENDPOINTS.lock().unwrap_or_else(|e| e.into_inner()) = vec![TxtEndpoint { - dc_id: 2, - addr: "149.154.167.51:443".into(), - secret: Some([9u8; 16]), - }]; - let found = secret_for(2, "149.154.167.51:443"); - let miss_port = secret_for(2, "149.154.167.51:5222"); - let miss_dc = secret_for(1, "149.154.167.51:443"); - *TXT_ENDPOINTS.lock().unwrap_or_else(|e| e.into_inner()) = previous; - assert_eq!(found, Some([9u8; 16])); - assert_eq!(miss_port, None); - assert_eq!(miss_dc, None); -} - -#[test] -fn pick_backup_prefers_other_addr_then_secret() { - let home = "149.154.167.51:443"; - let same = TxtEndpoint { - dc_id: 2, - addr: home.into(), - secret: Some([1u8; 16]), - }; - let other = TxtEndpoint { - dc_id: 2, - addr: "149.154.167.91:443".into(), - secret: None, - }; - let foreign = TxtEndpoint { - dc_id: 4, - addr: "149.154.167.91:443".into(), - secret: None, - }; - assert_eq!( - pick_backup_endpoint(2, home, &[same.clone(), other.clone()]).map(|e| e.addr), - Some(other.addr.clone()) - ); - assert_eq!( - pick_backup_endpoint(2, home, &[foreign.clone()]).map(|e| e.addr), - Some(foreign.addr) - ); - assert_eq!( - pick_backup_endpoint(2, home, &[same.clone()]).and_then(|e| e.secret), - Some([1u8; 16]) - ); - assert!(pick_backup_endpoint(2, home, &[]).is_none()); -} - -#[test] -fn clamp_expires_uses_server_then_bounds() { - let now = 1_700_000_000; - assert_eq!(clamp_expires(0, now), now); - assert_eq!(clamp_expires(-1, now), now); - assert_eq!(clamp_expires(now as i32 - 10, now), now); - assert_eq!(clamp_expires(now as i32, now), now); - assert_eq!(clamp_expires(now as i32 + 10, now), now + 10); - assert_eq!( - clamp_expires(now as i32 + 100_000, now), - now + CONFIG_TTL_MAX_SECS - ); - assert_eq!(clamp_expires(now as i32 + 3_600, now), now + 3_600); -} diff --git a/native/mtproto-rs/tests/unit/emoji_status_tests.rs b/native/mtproto-rs/tests/unit/emoji_status_tests.rs deleted file mode 100644 index a31316b43..000000000 --- a/native/mtproto-rs/tests/unit/emoji_status_tests.rs +++ /dev/null @@ -1,50 +0,0 @@ -use super::emoji_status_document_id; -use tellers_mtproto::latest::api::{ - EmojiStatus, EmojiStatusCollectibleConstructor, EmojiStatusConstructor, - EmojiStatusEmptyConstructor, -}; - -#[test] -fn empty_and_missing_are_none() { - assert_eq!(emoji_status_document_id(None), None); - assert_eq!( - emoji_status_document_id(Some(&EmojiStatus::EmojiStatusEmpty( - EmojiStatusEmptyConstructor {} - ))), - None - ); -} - -#[test] -fn regular_and_collectible_use_document_id() { - let regular = EmojiStatus::EmojiStatus(EmojiStatusConstructor { - flags: 0, - document_id: 42, - until: None, - }); - assert_eq!(emoji_status_document_id(Some(®ular)), Some(42)); - let gift = EmojiStatus::EmojiStatusCollectible(EmojiStatusCollectibleConstructor { - flags: 0, - collectible_id: 9, - document_id: 77, - title: "Star".into(), - slug: "star".into(), - pattern_document_id: 0, - center_color: 0, - edge_color: 0, - pattern_color: 0, - text_color: 0, - until: None, - }); - assert_eq!(emoji_status_document_id(Some(&gift)), Some(77)); -} - -#[test] -fn expired_until_hides_status() { - let expired = EmojiStatus::EmojiStatus(EmojiStatusConstructor { - flags: 1, - document_id: 42, - until: Some(1), - }); - assert_eq!(emoji_status_document_id(Some(&expired)), None); -} diff --git a/native/mtproto-rs/tests/unit/inline_rpc_tests.rs b/native/mtproto-rs/tests/unit/inline_rpc_tests.rs deleted file mode 100644 index b1da97074..000000000 --- a/native/mtproto-rs/tests/unit/inline_rpc_tests.rs +++ /dev/null @@ -1,128 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - BotInlineResultConstructor, MessagesBotResultsConstructor, Vector, VectorConstructor, -}; - -#[test] -fn inline_peer_rejected_matches_406() { - assert!(super::inline_peer_rejected(&MtprotoError::Message( - "RPC 406: unknown".into() - ))); - assert!(super::inline_peer_rejected(&MtprotoError::Message( - "RPC 400: PEER_ID_INVALID".into() - ))); - assert!(!super::inline_peer_rejected(&MtprotoError::Message( - "RPC 400: BOT_INLINE_DISABLED".into() - ))); -} - -#[test] -fn strip_username_drops_at() { - assert_eq!(strip_username(" @gif "), "gif"); - assert_eq!(strip_username("pic"), "pic"); - assert!(strip_username(" ").is_empty()); -} - -#[test] -fn web_result_maps_article_fields() { - let item = BotInlineResult::BotInlineResult(BotInlineResultConstructor { - flags: 0, - id: "1".into(), - type_: "article".into(), - title: Some("Cat".into()), - description: Some("desc".into()), - url: Some("https://t.me".into()), - thumb: None, - content: None, - send_message: Box::new( - tellers_mtproto::latest::api::BotInlineMessage::BotInlineMessageText( - tellers_mtproto::latest::api::BotInlineMessageTextConstructor { - flags: 0, - no_webpage: None, - invert_media: None, - message: "hi".into(), - entities: None, - reply_markup: None, - }, - ), - ), - }); - let mut media = MediaIndex::default(); - let dto = map_inline_result(&item, &mut media); - assert_eq!(dto.id, "1"); - assert_eq!(dto.kind, "article"); - assert_eq!(dto.title.as_deref(), Some("Cat")); - assert_eq!(dto.url.as_deref(), Some("https://t.me")); - assert!(dto.document_id.is_none()); - assert!(dto.thumb_cache_key.is_none()); -} - -#[test] -fn web_thumb_url_maps_http_cache_key() { - use tellers_mtproto::latest::api::{Vector, VectorConstructor, WebDocumentNoProxyConstructor}; - let item = BotInlineResult::BotInlineResult(BotInlineResultConstructor { - flags: BotInlineResultConstructor::THUMB_FLAG, - id: "2".into(), - type_: "photo".into(), - title: None, - description: None, - url: Some("https://example.com/cat".into()), - thumb: Some(Box::new(WebDocument::WebDocumentNoProxy( - WebDocumentNoProxyConstructor { - url: "https://cdn.example/t.jpg".into(), - size: 12, - mime_type: "image/jpeg".into(), - attributes: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - }, - ))), - content: None, - send_message: Box::new( - tellers_mtproto::latest::api::BotInlineMessage::BotInlineMessageText( - tellers_mtproto::latest::api::BotInlineMessageTextConstructor { - flags: 0, - no_webpage: None, - invert_media: None, - message: "hi".into(), - entities: None, - reply_markup: None, - }, - ), - ), - }); - let dto = map_inline_result(&item, &mut MediaIndex::default()); - assert_eq!( - dto.thumb_cache_key.as_deref(), - Some("https://cdn.example/t.jpg") - ); -} - -#[test] -fn results_body_maps_gallery_and_offset() { - let body = MessagesBotResultsConstructor { - flags: 1, - gallery: Some(Box::new(True::True(TrueConstructor {}))), - query_id: 99, - next_offset: Some("abc".into()), - switch_pm: None, - switch_webview: None, - results: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - cache_time: 30, - users: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - }; - let mut media = MediaIndex::default(); - let dto = results_from_body(&body, &mut media); - assert_eq!(dto.query_id, 99); - assert!(dto.gallery); - assert_eq!(dto.next_offset.as_deref(), Some("abc")); - assert_eq!(dto.cache_time, 30); - assert!(dto.results.is_empty()); -} diff --git a/native/mtproto-rs/tests/unit/instant_view_block_tests.rs b/native/mtproto-rs/tests/unit/instant_view_block_tests.rs deleted file mode 100644 index 867be7d95..000000000 --- a/native/mtproto-rs/tests/unit/instant_view_block_tests.rs +++ /dev/null @@ -1,369 +0,0 @@ -use crate::media::MediaIndex; -use crate::{HashMap, HashMapExt}; -use serde_json::Value; -use tellers_mtproto::latest::api::{ - Document, GeoPoint, InlineButtonType, Page, PageBlock, PageButton, PageCaption, PageListItem, - PageListOrderedItem, PageRelatedArticle, PageTableRow, Photo, RichText, WebPage, -}; - -use super::*; -use tellers_mtproto::latest::api::{ - PageBlockParagraphConstructor, PageCaptionConstructor, TextEmptyConstructor, - TextPlainConstructor, Vector, VectorConstructor, WebPageConstructor, -}; - -fn plain(text: &str) -> Box { - Box::new(RichText::TextPlain(TextPlainConstructor { - text: text.to_string(), - })) -} - -fn boxed_vector(items: Vec) -> Vector> { - Vector::Vector(VectorConstructor { - field_0: VectorConstructor::>::ID, - field_1: items.into_iter().map(Box::new).collect(), - }) -} - -fn empty_caption() -> Box { - Box::new(PageCaption::PageCaption(PageCaptionConstructor { - text: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - credit: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - })) -} - -#[test] -fn maps_all_documented_block_kinds_without_drop() { - let blocks = vec![ - PageBlock::PageBlockUnsupported( - tellers_mtproto::latest::api::PageBlockUnsupportedConstructor {}, - ), - PageBlock::PageBlockTitle(tellers_mtproto::latest::api::PageBlockTitleConstructor { - text: plain("Title"), - }), - PageBlock::PageBlockSubtitle(tellers_mtproto::latest::api::PageBlockSubtitleConstructor { - text: plain("Sub"), - }), - PageBlock::PageBlockAuthorDate( - tellers_mtproto::latest::api::PageBlockAuthorDateConstructor { - author: plain("Ada"), - published_date: 1, - }, - ), - PageBlock::PageBlockHeader(tellers_mtproto::latest::api::PageBlockHeaderConstructor { - text: plain("H"), - }), - PageBlock::PageBlockSubheader( - tellers_mtproto::latest::api::PageBlockSubheaderConstructor { text: plain("SH") }, - ), - PageBlock::PageBlockParagraph(PageBlockParagraphConstructor { text: plain("Hi") }), - PageBlock::PageBlockPreformatted( - tellers_mtproto::latest::api::PageBlockPreformattedConstructor { - text: plain("code"), - language: "kt".into(), - }, - ), - PageBlock::PageBlockFooter(tellers_mtproto::latest::api::PageBlockFooterConstructor { - text: plain("F"), - }), - PageBlock::PageBlockDivider(tellers_mtproto::latest::api::PageBlockDividerConstructor {}), - PageBlock::PageBlockAnchor(tellers_mtproto::latest::api::PageBlockAnchorConstructor { - name: "a".into(), - }), - PageBlock::PageBlockKicker(tellers_mtproto::latest::api::PageBlockKickerConstructor { - text: plain("K"), - }), - PageBlock::PageBlockMath(tellers_mtproto::latest::api::PageBlockMathConstructor { - source: "x^2".into(), - }), - ]; - let ctx = PageContext { - photos: HashMap::new(), - documents: HashMap::new(), - }; - let mapped: Vec = blocks - .iter() - .map(|b| map_block(b, &ctx)["k"].as_str().unwrap().to_string()) - .collect(); - assert_eq!( - mapped, - vec![ - "unsupported", - "title", - "subtitle", - "authorDate", - "heading", - "heading", - "paragraph", - "pre", - "footer", - "divider", - "anchor", - "kicker", - "math", - ] - ); -} - -#[test] -fn maps_remaining_official_page_blocks() { - let caption = empty_caption(); - let blocks = vec![ - PageBlock::PageBlockList(tellers_mtproto::latest::api::PageBlockListConstructor { - items: Box::new(boxed_vector(Vec::::new())), - }), - PageBlock::PageBlockOrderedList( - tellers_mtproto::latest::api::PageBlockOrderedListConstructor { - flags: 0, - reversed: None, - items: Box::new(boxed_vector(Vec::::new())), - start: None, - type_: None, - }, - ), - PageBlock::PageBlockBlockquote( - tellers_mtproto::latest::api::PageBlockBlockquoteConstructor { - flags: 0, - collapsed: None, - text: plain("q"), - caption: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - }, - ), - PageBlock::PageBlockPullquote( - tellers_mtproto::latest::api::PageBlockPullquoteConstructor { - text: plain("p"), - caption: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - }, - ), - PageBlock::PageBlockPhoto(tellers_mtproto::latest::api::PageBlockPhotoConstructor { - flags: 0, - spoiler: None, - photo_id: 1, - caption: caption.clone(), - url: Some("https://example.com".into()), - webpage_id: None, - }), - PageBlock::PageBlockVideo(tellers_mtproto::latest::api::PageBlockVideoConstructor { - flags: 0, - autoplay: None, - loop_: None, - spoiler: None, - video_id: 2, - caption: caption.clone(), - }), - PageBlock::PageBlockAudio(tellers_mtproto::latest::api::PageBlockAudioConstructor { - audio_id: 3, - caption: caption.clone(), - }), - PageBlock::PageBlockCover(tellers_mtproto::latest::api::PageBlockCoverConstructor { - cover: Box::new(PageBlock::PageBlockParagraph( - PageBlockParagraphConstructor { text: plain("c") }, - )), - }), - PageBlock::PageBlockEmbed(tellers_mtproto::latest::api::PageBlockEmbedConstructor { - flags: 0, - full_width: None, - allow_scrolling: None, - url: Some("https://embed".into()), - html: None, - poster_photo_id: None, - w: Some(16), - h: Some(9), - caption: caption.clone(), - }), - PageBlock::PageBlockEmbedPost( - tellers_mtproto::latest::api::PageBlockEmbedPostConstructor { - url: "https://post".into(), - webpage_id: 0, - author_photo_id: 0, - author: "Ada".into(), - date: 1, - blocks: Box::new(boxed_vector(Vec::::new())), - caption: caption.clone(), - }, - ), - PageBlock::PageBlockCollage(tellers_mtproto::latest::api::PageBlockCollageConstructor { - items: Box::new(boxed_vector(Vec::::new())), - caption: caption.clone(), - }), - PageBlock::PageBlockSlideshow( - tellers_mtproto::latest::api::PageBlockSlideshowConstructor { - items: Box::new(boxed_vector(Vec::::new())), - caption: caption.clone(), - }, - ), - PageBlock::PageBlockTable(tellers_mtproto::latest::api::PageBlockTableConstructor { - flags: 0, - bordered: None, - striped: None, - compact: None, - title: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - rows: Box::new(boxed_vector(Vec::::new())), - }), - PageBlock::PageBlockDetails(tellers_mtproto::latest::api::PageBlockDetailsConstructor { - flags: 0, - open: None, - blocks: Box::new(boxed_vector(Vec::::new())), - title: plain("d"), - }), - PageBlock::PageBlockRelatedArticles( - tellers_mtproto::latest::api::PageBlockRelatedArticlesConstructor { - title: plain("more"), - articles: Box::new(boxed_vector(Vec::::new())), - }, - ), - PageBlock::PageBlockMap(tellers_mtproto::latest::api::PageBlockMapConstructor { - geo: Box::new(GeoPoint::GeoPointEmpty( - tellers_mtproto::latest::api::GeoPointEmptyConstructor {}, - )), - zoom: 10, - w: 320, - h: 180, - caption: caption.clone(), - }), - ]; - let ctx = PageContext { - photos: HashMap::new(), - documents: HashMap::new(), - }; - let mapped: Vec = blocks - .iter() - .map(|b| map_block(b, &ctx)["k"].as_str().unwrap().to_string()) - .collect(); - assert_eq!( - mapped, - vec![ - "list", - "list", - "quote", - "quote", - "photo", - "video", - "audio", - "cover", - "embed", - "embedPost", - "collage", - "slideshow", - "table", - "details", - "related", - "map", - ] - ); - assert_eq!(map_block(&blocks[4], &ctx)["url"], "https://example.com"); -} - -#[test] -fn maps_document_filename_and_text_anchor() { - let document = Document::Document(tellers_mtproto::latest::api::DocumentConstructor { - flags: 0, - id: 8, - access_hash: 1, - file_reference: vec![1], - date: 0, - mime_type: "application/pdf".into(), - size: 2048, - thumbs: None, - video_thumbs: None, - dc_id: 2, - attributes: Box::new(boxed_vector(vec![ - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeFilename( - tellers_mtproto::latest::api::DocumentAttributeFilenameConstructor { - file_name: "notes.pdf".into(), - }, - ), - ])), - }); - let ctx = PageContext { - photos: HashMap::new(), - documents: HashMap::from_iter([(8, &document)]), - }; - let mapped = map_block( - &PageBlock::PageBlockDocument(tellers_mtproto::latest::api::PageBlockDocumentConstructor { - document_id: 8, - caption: empty_caption(), - }), - &ctx, - ); - assert_eq!(mapped["k"], "document"); - assert_eq!(mapped["name"], "notes.pdf"); - assert_eq!(mapped["mime"], "application/pdf"); - assert_eq!(mapped["size"], 2048); - let anchored = rich_json(&RichText::TextAnchor( - tellers_mtproto::latest::api::TextAnchorConstructor { - text: plain("here"), - name: "intro".into(), - }, - )); - assert_eq!(anchored["t"], "here"); - assert_eq!(anchored["e"][0]["k"], "anchor"); - assert_eq!(anchored["e"][0]["u"], "#intro"); - let button = page_button(&PageButton::PageButton( - tellers_mtproto::latest::api::PageButtonConstructor { - flags: 0, - text: plain("Open"), - type_: Box::new(InlineButtonType::InlineButtonTypeUrl( - tellers_mtproto::latest::api::InlineButtonTypeUrlConstructor { - url: "https://example.com/go".into(), - }, - )), - style: None, - }, - )) - .unwrap(); - assert_eq!(button["t"], "Open"); - assert_eq!(button["e"][0]["k"], "text_url"); - assert_eq!(button["e"][0]["u"], "https://example.com/go"); -} - -#[test] -fn video_block_carries_document_dimensions() { - let document = Document::Document(tellers_mtproto::latest::api::DocumentConstructor { - flags: 0, - id: 9, - access_hash: 1, - file_reference: vec![1], - date: 0, - mime_type: "video/mp4".into(), - size: 4096, - thumbs: None, - video_thumbs: None, - dc_id: 2, - attributes: Box::new(boxed_vector(vec![ - tellers_mtproto::latest::api::DocumentAttribute::DocumentAttributeVideo( - tellers_mtproto::latest::api::DocumentAttributeVideoConstructor { - flags: 0, - round_message: None, - supports_streaming: None, - nosound: None, - duration: 17.0, - w: 1280, - h: 720, - preload_prefix_size: None, - video_start_ts: None, - video_codec: None, - }, - ), - ])), - }); - let ctx = PageContext { - photos: HashMap::new(), - documents: HashMap::from_iter([(9, &document)]), - }; - let mapped = map_block( - &PageBlock::PageBlockVideo(tellers_mtproto::latest::api::PageBlockVideoConstructor { - flags: 0, - autoplay: None, - loop_: None, - spoiler: None, - video_id: 9, - caption: empty_caption(), - }), - &ctx, - ); - assert_eq!(mapped["k"], "video"); - assert_eq!(mapped["w"], 1280); - assert_eq!(mapped["h"], 720); - assert_eq!(mapped["duration"], 17); -} diff --git a/native/mtproto-rs/tests/unit/instant_view_page_tests.rs b/native/mtproto-rs/tests/unit/instant_view_page_tests.rs deleted file mode 100644 index 835c10044..000000000 --- a/native/mtproto-rs/tests/unit/instant_view_page_tests.rs +++ /dev/null @@ -1,297 +0,0 @@ -use crate::media::MediaIndex; -use crate::{HashMap, HashMapExt}; -use serde_json::Value; -use tellers_mtproto::latest::api::{ - Document, GeoPoint, InlineButtonType, Page, PageBlock, PageButton, PageCaption, PageListItem, - PageListOrderedItem, PageRelatedArticle, PageTableRow, Photo, RichText, WebPage, -}; - -use super::*; -use tellers_mtproto::latest::api::{ - PageBlockParagraphConstructor, PageCaptionConstructor, TextEmptyConstructor, - TextPlainConstructor, Vector, VectorConstructor, WebPageConstructor, -}; - -#[test] -fn not_modified_reuses_requested_hash() { - let dto = map_messages_web_page( - &WebPage::WebPageNotModified( - tellers_mtproto::latest::api::WebPageNotModifiedConstructor { - flags: 0, - cached_page_views: None, - }, - ), - &mut MediaIndex::default(), - 42, - ); - assert!(dto.not_modified); - assert_eq!(dto.hash, 42); - assert!(!dto.has_instant_view); -} - -#[test] -fn web_page_without_cached_page_has_no_iv() { - let page = WebPage::WebPage(WebPageConstructor { - flags: 0, - has_large_media: None, - video_cover_photo: None, - id: 1, - url: "https://example.com".into(), - display_url: "example.com".into(), - hash: 9, - type_: Some("article".into()), - site_name: Some("Example".into()), - title: Some("Hello".into()), - description: Some("Desc".into()), - photo: None, - embed_url: None, - embed_type: None, - embed_width: None, - embed_height: None, - duration: None, - author: None, - document: None, - cached_page: None, - attributes: None, - }); - let dto = map_messages_web_page(&page, &mut MediaIndex::default(), 0); - assert!(!dto.has_instant_view); - assert_eq!(dto.hash, 9); - assert_eq!(dto.url, "https://example.com"); - assert_eq!(dto.blocks_json, "[]"); -} - -#[test] -fn cached_page_part_flag_is_preserved() { - let cached = Page::Page(tellers_mtproto::latest::api::PageConstructor { - flags: tellers_mtproto::latest::api::PageConstructor::PART_FLAG, - part: Some(Box::new(tellers_mtproto::latest::api::True::True( - tellers_mtproto::latest::api::TrueConstructor {}, - ))), - rtl: None, - v2: None, - url: "https://example.com".into(), - blocks: Box::new(boxed_vector(Vec::::new())), - photos: Box::new(boxed_vector(Vec::::new())), - documents: Box::new(boxed_vector(Vec::::new())), - views: None, - }); - let page = WebPage::WebPage(WebPageConstructor { - flags: 0, - has_large_media: None, - video_cover_photo: None, - id: 1, - url: "https://example.com".into(), - display_url: "example.com".into(), - hash: 4, - type_: Some("article".into()), - site_name: None, - title: None, - description: None, - photo: None, - embed_url: None, - embed_type: None, - embed_width: None, - embed_height: None, - duration: None, - author: None, - document: None, - cached_page: Some(Box::new(cached)), - attributes: None, - }); - let dto = map_messages_web_page(&page, &mut MediaIndex::default(), 0); - assert!(dto.has_instant_view); - assert!(dto.part); - assert_eq!(dto.hash, 4); -} - -#[test] -fn cached_page_indexes_photo_and_maps_paragraph() { - let photo = Photo::Photo(tellers_mtproto::latest::api::PhotoConstructor { - flags: 0, - has_stickers: None, - id: 77, - access_hash: 2, - file_reference: vec![1], - date: 0, - sizes: Box::new(boxed_vector(vec![ - tellers_mtproto::latest::api::PhotoSize::PhotoSize( - tellers_mtproto::latest::api::PhotoSizeConstructor { - type_: "y".into(), - w: 800, - h: 450, - size: 10, - }, - ), - ])), - video_sizes: None, - dc_id: 2, - }); - let blocks = vec![ - PageBlock::PageBlockParagraph(PageBlockParagraphConstructor { - text: plain("Body"), - }), - PageBlock::PageBlockPhoto(tellers_mtproto::latest::api::PageBlockPhotoConstructor { - flags: 0, - spoiler: None, - photo_id: 77, - caption: empty_caption(), - url: None, - webpage_id: None, - }), - ]; - let cached = Page::Page(tellers_mtproto::latest::api::PageConstructor { - flags: 0, - part: None, - rtl: None, - v2: None, - url: "https://example.com".into(), - blocks: Box::new(boxed_vector(blocks)), - photos: Box::new(boxed_vector(vec![photo])), - documents: Box::new(boxed_vector(Vec::::new())), - views: None, - }); - let page = WebPage::WebPage(WebPageConstructor { - flags: 0, - has_large_media: None, - video_cover_photo: None, - id: 1, - url: "https://example.com".into(), - display_url: "example.com".into(), - hash: 3, - type_: Some("article".into()), - site_name: None, - title: Some("Hello".into()), - description: None, - photo: None, - embed_url: None, - embed_type: None, - embed_width: None, - embed_height: None, - duration: None, - author: None, - document: None, - cached_page: Some(Box::new(cached)), - attributes: None, - }); - let mut media = MediaIndex::default(); - let dto = map_messages_web_page(&page, &mut media, 0); - assert!(dto.has_instant_view); - assert!(media.contains_key(&(77, INSTANT_VIEW_MEDIA_MSG))); - assert_eq!( - media - .get(&(77, INSTANT_VIEW_MEDIA_MSG)) - .unwrap() - .source_url - .as_deref(), - Some("https://example.com"), - ); - assert_eq!( - media_refresh_url(&media, 77, INSTANT_VIEW_MEDIA_MSG).as_deref(), - Some("https://example.com"), - ); - assert_eq!(media_refresh_url(&media, 77, 12), None); - let parsed: Value = serde_json::from_str(&dto.blocks_json).unwrap(); - assert_eq!(parsed[0]["k"], "paragraph"); - assert_eq!(parsed[0]["t"], "Body"); - assert_eq!(parsed[1]["k"], "photo"); - assert_eq!(parsed[1]["cache"], "photo:77"); -} - -#[test] -fn webpage_cover_photo_is_indexed_for_article_blocks() { - let cover = Photo::Photo(tellers_mtproto::latest::api::PhotoConstructor { - flags: 0, - has_stickers: None, - id: 88, - access_hash: 2, - file_reference: vec![1], - date: 0, - sizes: Box::new(boxed_vector(vec![ - tellers_mtproto::latest::api::PhotoSize::PhotoSize( - tellers_mtproto::latest::api::PhotoSizeConstructor { - type_: "y".into(), - w: 1200, - h: 675, - size: 10, - }, - ), - ])), - video_sizes: None, - dc_id: 2, - }); - let cached = Page::Page(tellers_mtproto::latest::api::PageConstructor { - flags: 0, - part: None, - rtl: None, - v2: None, - url: "https://example.com/post".into(), - blocks: Box::new(boxed_vector(vec![PageBlock::PageBlockPhoto( - tellers_mtproto::latest::api::PageBlockPhotoConstructor { - flags: 0, - spoiler: None, - photo_id: 88, - caption: empty_caption(), - url: None, - webpage_id: None, - }, - )])), - photos: Box::new(boxed_vector(Vec::::new())), - documents: Box::new(boxed_vector(Vec::::new())), - views: None, - }); - let page = WebPage::WebPage(WebPageConstructor { - flags: 0, - has_large_media: None, - video_cover_photo: None, - id: 1, - url: "https://example.com/post".into(), - display_url: "example.com".into(), - hash: 3, - type_: Some("article".into()), - site_name: None, - title: Some("Hello".into()), - description: None, - photo: Some(Box::new(cover)), - embed_url: None, - embed_type: None, - embed_width: None, - embed_height: None, - duration: None, - author: None, - document: None, - cached_page: Some(Box::new(cached)), - attributes: None, - }); - let mut media = MediaIndex::default(); - let dto = map_messages_web_page(&page, &mut media, 0); - assert!(media.contains_key(&(88, INSTANT_VIEW_MEDIA_MSG))); - assert_eq!( - media_refresh_url(&media, 88, INSTANT_VIEW_MEDIA_MSG).as_deref(), - Some("https://example.com/post"), - ); - let parsed: Value = serde_json::from_str(&dto.blocks_json).unwrap(); - assert_eq!(parsed[0]["cache"], "photo:88"); - assert_eq!(parsed[0]["w"], 1200); - assert_eq!(parsed[0]["h"], 675); -} - -fn plain(text: &str) -> Box { - Box::new(RichText::TextPlain(TextPlainConstructor { - text: text.to_string(), - })) -} - -fn boxed_vector(items: Vec) -> Vector> { - Vector::Vector(VectorConstructor { - field_0: VectorConstructor::>::ID, - field_1: items.into_iter().map(Box::new).collect(), - }) -} - -fn empty_caption() -> Box { - Box::new(PageCaption::PageCaption(PageCaptionConstructor { - text: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - credit: Box::new(RichText::TextEmpty(TextEmptyConstructor {})), - })) -} diff --git a/native/mtproto-rs/tests/unit/lazy_sync_tests.rs b/native/mtproto-rs/tests/unit/lazy_sync_tests.rs deleted file mode 100644 index 6bd667d04..000000000 --- a/native/mtproto-rs/tests/unit/lazy_sync_tests.rs +++ /dev/null @@ -1,241 +0,0 @@ -use super::*; - -#[test] -fn sync_edge_emits_on_change_and_not_while_unchanged() { - let mut syncing = false; - assert!(sync_edge(syncing, true)); - syncing = true; - assert!(!sync_edge(syncing, true)); - assert!(sync_edge(syncing, false)); - syncing = false; - assert!(!sync_edge(syncing, false)); -} - -#[test] -fn lazy_route_preserves_difference_recovery_for_every_channel_gap() { - let open = peers::chat_id_for_channel(1); - let exception = peers::chat_id_for_channel(2); - let background = peers::chat_id_for_channel(3); - let user = peers::chat_id_for_user(4); - let mut exceptions = HashSet::new(); - exceptions.insert(exception); - let mut lazy = HashSet::new(); - let full = route_lazy_channels( - vec![open, exception, background, user, background], - open, - &exceptions, - true, - &mut lazy, - ); - assert_eq!(full, vec![open, exception, background, background]); - assert_eq!(lazy.len(), 1); - assert!(lazy.contains(&background)); - assert!(!lazy.contains(&open)); - assert!(!lazy.contains(&exception)); - assert!(!lazy.contains(&user)); -} - -#[test] -fn lazy_gap_survives_session_reload_without_advancing_pts() { - let path = std::env::temp_dir().join(format!("monogram-lazy-gap-{}.json", std::process::id())); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let chat = peers::chat_id_for_channel(3); - let mut lazy = HashSet::new(); - let pending = route_lazy_channels(vec![chat], 0, &HashSet::new(), true, &mut lazy); - with_client_mut(handle, |state| { - state.channel_pts.insert(chat, 20); - prepare_channel_recovery(&mut state.channel_recovery, pending, 0, 100); - persist(state) - }) - .unwrap(); - destroy_client(handle); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - update_lazy_sync_config(handle, true, Vec::new()); - { - let data = client.data.lock(); - assert_eq!(data.channel_pts.get(&chat), Some(&20)); - assert!( - data.channel_recovery - .iter() - .any(|entry| entry.chat_id == chat && !entry.watching) - ); - assert!(data.lazy_channel_recovery.contains(&chat)); - assert!(!visible_gap_recovery_pending( - &data.channel_recovery, - 0, - &HashSet::new(), - true - )); - assert_eq!( - next_channel_recovery( - &data.channel_recovery, - chat, - &HashSet::new(), - true, - recovery_now(), - ), - Some(0) - ); - } - destroy_client(handle); - std::fs::remove_file(path).ok(); -} - -#[test] -fn lazy_background_gaps_do_not_block_sync_or_run_until_opened() { - let mut queue = VecDeque::new(); - let channels: Vec<_> = (1..=100).map(peers::chat_id_for_channel).collect(); - prepare_channel_recovery(&mut queue, channels, 0, 100); - let exceptions = HashSet::new(); - assert!(gap_recovery_pending(&queue)); - assert!(!visible_gap_recovery_pending(&queue, 0, &exceptions, true)); - assert_eq!( - next_channel_recovery(&queue, 0, &exceptions, true, 100), - None - ); - assert_eq!( - next_channel_recovery(&queue, 0, &exceptions, true, 100_000), - None - ); - assert_eq!( - next_channel_recovery(&queue, 0, &exceptions, false, 100), - Some(0) - ); - assert!(visible_gap_recovery_pending(&queue, 0, &exceptions, false)); -} - -#[test] -fn opening_a_previewed_gap_prioritizes_recovery_but_preserves_flood_wait() { - let background = peers::chat_id_for_channel(1); - let open = peers::chat_id_for_channel(2); - let exception = peers::chat_id_for_channel(3); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![background, exception, open], 0, 100); - let exceptions = HashSet::from_iter([exception]); - assert_eq!( - next_channel_recovery(&queue, open, &exceptions, true, 100), - Some(2) - ); - assert!(visible_gap_recovery_pending( - &queue, - open, - &exceptions, - true - )); - queue[2].due_at = 220; - let mut previews = HashSet::from_iter([open]); - pull_priority_channels(&mut previews, &mut queue, open, &exceptions, 100); - assert!(previews.is_empty()); - assert_eq!(queue[2].due_at, 220); - assert_eq!( - next_channel_recovery(&queue, open, &exceptions, true, 100), - Some(1) - ); - assert_eq!( - next_channel_recovery(&queue, open, &HashSet::new(), true, 100), - None - ); - assert_eq!( - next_channel_recovery(&queue, open, &HashSet::new(), true, 220), - Some(2) - ); -} - -#[test] -fn lazy_failures_honor_flood_deadlines_and_terminal_peers() { - let flood = MtprotoError::Message("RPC 420: FLOOD_WAIT_120".into()); - assert_eq!(recovery_wait_secs(&flood), 120); - assert!(!terminal_channel_error(&flood)); - assert!(terminal_channel_error(&MtprotoError::Message( - "RPC 400: CHANNEL_PRIVATE".into() - ))); - assert_eq!( - recovery_wait_secs(&MtprotoError::Message("RPC timeout".into())), - 5 - ); -} - -#[test] -fn lazy_route_is_unchanged_when_disabled() { - let open = peers::chat_id_for_channel(1); - let other = peers::chat_id_for_channel(2); - let mut lazy = HashSet::new(); - let pending = vec![open, other]; - let full = route_lazy_channels(pending.clone(), open, &HashSet::new(), false, &mut lazy); - assert_eq!(full, pending); - assert!(lazy.is_empty()); -} - -#[test] -fn lazy_batch_leaves_unusable_peers_queued_and_restores_after_failure() { - let ready = peers::chat_id_for_channel(1); - let waiting = peers::chat_id_for_channel(2); - let mut queue = HashSet::new(); - queue.insert(waiting); - queue.insert(ready); - let batch = take_lazy_batch(&mut queue, |id| id == ready, LAZY_CHANNEL_BATCH); - assert_eq!(batch, vec![ready]); - assert!(queue.contains(&waiting)); - assert!(!queue.contains(&ready)); - restore_lazy_batch(&mut queue, &batch); - assert!(queue.contains(&ready)); - assert!(queue.contains(&waiting)); -} - -#[test] -fn open_channel_watcher_is_not_catch_up() { - let open = peers::chat_id_for_channel(1); - let mut queue = VecDeque::new(); - prepare_channel_recovery(&mut queue, vec![open], open, 100); - let entry = queue.pop_front().unwrap(); - finish_channel_recovery(&mut queue, entry, true, Some(1), open, 100); - assert!(queue.front().unwrap().watching); - assert!(!gap_recovery_pending(&queue)); - assert!(!catch_up_active(true, gap_recovery_pending(&queue), false)); - queue.push_back(ChannelRecovery { - chat_id: peers::chat_id_for_channel(2), - due_at: 100, - watching: false, - }); - assert!(catch_up_active(true, gap_recovery_pending(&queue), false,)); -} - -#[test] -fn opening_a_queued_channel_leaves_the_lazy_batch() { - let open = peers::chat_id_for_channel(1); - let other = peers::chat_id_for_channel(2); - let mut lazy = HashSet::new(); - lazy.insert(open); - lazy.insert(other); - let mut recovery = VecDeque::new(); - pull_priority_channels(&mut lazy, &mut recovery, open, &HashSet::new(), 50); - assert!(!lazy.contains(&open)); - assert!(lazy.contains(&other)); - let promoted = recovery.iter().find(|entry| entry.chat_id == open).unwrap(); - assert!(!promoted.watching); - assert_eq!(promoted.due_at, 50); - let batch = take_lazy_batch(&mut lazy, |_| true, LAZY_CHANNEL_BATCH); - assert_eq!(batch, vec![other]); -} - -#[test] -fn lazy_rpc_failure_restores_without_dropping_transport() { - let plan = plan_lazy_failure(false); - assert!(plan.restore); - assert!(!plan.drop_transport); - let fatal = plan_lazy_failure(true); - assert!(!fatal.restore); - assert!(!fatal.drop_transport); -} - -#[test] -fn lazy_batch_caps_at_one_hundred() { - let mut queue = HashSet::new(); - for id in 1..=101 { - queue.insert(peers::chat_id_for_channel(id)); - } - let batch = take_lazy_batch(&mut queue, |_| true, LAZY_CHANNEL_BATCH); - assert_eq!(batch.len(), LAZY_CHANNEL_BATCH); - assert_eq!(queue.len(), 1); -} diff --git a/native/mtproto-rs/tests/unit/lib_tests.rs b/native/mtproto-rs/tests/unit/lib_tests.rs deleted file mode 100644 index 461aea14e..000000000 --- a/native/mtproto-rs/tests/unit/lib_tests.rs +++ /dev/null @@ -1,80 +0,0 @@ -use super::*; - -#[test] -fn create_and_destroy_client() { - let path = std::env::temp_dir().join("monogram-tellers-test.session"); - let handle = create_client(12345, "hash".into(), path.display().to_string()); - assert!(client_exists(handle)); - assert_eq!(client_api_id(handle), 12345); - destroy_client(handle); - assert!(!client_exists(handle)); -} - -#[test] -fn session_file_persists_user_id() { - use crate::session_file::{ClientSession, FileSessionStore}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - - let path = std::env::temp_dir().join(format!("monogram-session-{}.json", std::process::id())); - let _ = std::fs::remove_file(&path); - let store = FileSessionStore::new(&path); - let snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - store - .save(&ClientSession { - channel_recovery: Default::default(), - snapshot: snapshot.clone(), - user_id: Some(42), - peers: Default::default(), - updates: None, - media: Default::default(), - media_order: Vec::new(), - channel_pts: Default::default(), - seen_messages: Default::default(), - logout_tokens: vec![vec![1, 2, 3]], - new_session: None, - session_dead: true, - test_dc: false, - dc_auth_keys: Default::default(), - journal: Default::default(), - }) - .expect("save"); - let raw = std::fs::read(&path).expect("read compact"); - assert!(!raw.contains(&b'\n'), "session JSON must be compact"); - let loaded = store.load().expect("load").expect("present"); - assert_eq!(loaded.user_id, Some(42)); - assert_eq!(loaded.snapshot.dc_id, 2); - assert!(loaded.session_dead); - assert_eq!(loaded.logout_tokens, vec![vec![1, 2, 3]]); - store.clear().expect("clear"); - assert!(store.load().expect("load empty").is_none()); -} - -#[test] -fn peer_chat_id_roundtrip_helpers() { - use crate::peers::{ - channel_id_from_chat_id, chat_id_for_channel, chat_id_for_chat, chat_id_for_user, - is_channel_chat_id, - }; - assert_eq!(chat_id_for_user(42), 42); - assert_eq!(chat_id_for_chat(7), -7); - assert_eq!(chat_id_for_channel(123), -(1_000_000_000_000 + 123)); - assert!(is_channel_chat_id(chat_id_for_channel(123))); - assert_eq!(channel_id_from_chat_id(chat_id_for_channel(123)), Some(123)); - assert!(!is_channel_chat_id(-7)); -} - -/// Live DC2 auth-key smoke. Ignored by default; run with -/// `cargo test live_create_auth_key -- --ignored --nocapture`. -#[test] -#[ignore] -fn live_create_auth_key_dc2() { - use crate::auth::auth_key::create_auth_key; - use tellers_mtproto_session::{OsRandom, Snapshot}; - use tellers_mtproto_transport::PaddedIntermediate; - - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - let mut conn = crate::tcp::connect_obfuscated("149.154.167.51:443").expect("tcp"); - let mut framing = PaddedIntermediate::default(); - create_auth_key(&mut conn, &mut framing, &mut snapshot).expect("auth key"); - assert_eq!(snapshot.auth_key.as_ref().map(|k| k.len()), Some(256)); -} diff --git a/native/mtproto-rs/tests/unit/main_policy_tests.rs b/native/mtproto-rs/tests/unit/main_policy_tests.rs deleted file mode 100644 index ca9f01351..000000000 --- a/native/mtproto-rs/tests/unit/main_policy_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; - -#[test] -fn silent_requests_probe_once_before_retry_and_correlate_status() { - let now = Instant::now(); - let mut main = SilentRequests::new(false); - main.observe(&[12], now); - assert!(main.due(now + Duration::from_secs(7), false).is_empty()); - assert_eq!(main.due(now + Duration::from_secs(8), false), vec![12]); - main.probed(24, vec![12]); - assert!(main.due(now + Duration::from_secs(30), true).is_empty()); - assert!(main.status(99, &[2]).is_ok()); - assert!(main.status(24, &[4 | 32]).is_ok()); - let mut media = SilentRequests::new(true); - media.observe(&[16], now); - assert!(media.due(now + Duration::from_secs(29), false).is_empty()); - assert_eq!(media.due(now + Duration::from_secs(30), false), vec![16]); - media.probed(28, vec![16]); - assert!(media.status(28, &[2]).is_err()); -} - -#[test] -fn completed_requests_are_excluded_and_status_lengths_must_match() { - let now = Instant::now(); - let mut requests = SilentRequests::new(false); - requests.observe(&[12, 16], now); - requests.observe(&[16], now); - assert_eq!(requests.due(now, true), vec![16]); - requests.probed(24, vec![16]); - assert!(requests.status(24, &[]).is_err()); -} - -#[test] -fn probe_is_a_non_content_service_message_with_the_original_request_ids() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - use tellers_mtproto::transport::{MsgsStateReqConstructor, Vector}; - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - use tellers_mtproto_transport::{Connection, Error, Framing, PaddedIntermediate}; - struct Memory(Vec); - impl Connection for Memory { - fn send(&mut self, bytes: &[u8]) -> Result<(), Error> { - self.0.extend_from_slice(bytes); - Ok(()) - } - fn receive(&mut self, _: &mut [u8]) -> Result { - Err(Error::Closed) - } - fn close(&mut self) -> Result<(), Error> { - Ok(()) - } - } - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let mut engine = Engine::new( - snapshot.clone(), - ExponentialBackoff { - timeout_micros: 8_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .unwrap(); - let mut memory = Memory(Vec::new()); - let mut framing = PaddedIntermediate::default(); - let id = super::super::framing::send_state_probe( - &mut engine, - &mut memory, - &mut framing, - &[12, 16], - &super::super::SystemClock, - ) - .unwrap(); - let packet = framing.decode(&memory.0).unwrap().payload; - let message = tellers_mtproto_crypto::decrypt_message( - snapshot.auth_key.as_deref().unwrap(), - super::super::timeout::trim_padded_mtproto_packet(&packet), - tellers_mtproto_crypto::Direction::ClientToServer, - snapshot.session_id, - 1024, - ) - .unwrap(); - assert_eq!(message.message_id, id); - assert_eq!(message.sequence & 1, 0); - let mut decoder = Decoder::new(&message.body, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MsgsStateReqConstructor::ID); - let request = MsgsStateReqConstructor::decode(&mut decoder).unwrap(); - let Vector::Vector(ids) = *request.msg_ids; - assert_eq!(ids.field_1, vec![12, 16]); - assert_eq!(engine.pending_count(), 0); -} diff --git a/native/mtproto-rs/tests/unit/media_cdn_tests.rs b/native/mtproto-rs/tests/unit/media_cdn_tests.rs deleted file mode 100644 index 713362755..000000000 --- a/native/mtproto-rs/tests/unit/media_cdn_tests.rs +++ /dev/null @@ -1,47 +0,0 @@ -use super::*; - -#[test] -fn cdn_hash_rejects_truncated_hash_ranges() { - use tellers_mtproto::latest::api::FileHashConstructor; - let data = b"short"; - let hash = FileHash::FileHash(FileHashConstructor { - offset: 0, - limit: 128, - hash: Sha256::digest(data).to_vec(), - }); - assert!(!hash_matches(&hash, 0, data)); -} - -#[test] -fn cdn_part_requires_complete_matching_hash_coverage() { - use tellers_mtproto::latest::api::FileHashConstructor; - let hashes: Vec<_> = [b"abcd", b"efgh"] - .iter() - .enumerate() - .map(|(index, bytes)| { - FileHash::FileHash(FileHashConstructor { - offset: (index * 4) as i64, - limit: 4, - hash: Sha256::digest(bytes).to_vec(), - }) - }) - .collect(); - assert!(verify_cdn_part(&hashes, 0, b"abcdefgh")); - assert!(!verify_cdn_part(&hashes[..1], 0, b"abcdefgh")); - assert!(!verify_cdn_part(&hashes, 0, b"abcdEfgh")); - assert!(!verify_cdn_part(&hashes, 0, b"abcdefg")); - assert!(!verify_cdn_part(&hashes, 1, b"abcdefgh")); - assert!(!verify_cdn_part(&hashes, 0, b"")); -} - -#[test] -fn decrypt_is_self_inverse() { - let key = [7u8; 32]; - let iv = [9u8; 32]; - let mut data = b"cdn-part-fixture!!!!".to_vec(); - let original = data.clone(); - decrypt_cdn_part(&key, &iv, 0, &mut data).unwrap(); - assert_ne!(data, original); - decrypt_cdn_part(&key, &iv, 0, &mut data).unwrap(); - assert_eq!(data, original); -} diff --git a/native/mtproto-rs/tests/unit/media_download_profile_tests.rs b/native/mtproto-rs/tests/unit/media_download_profile_tests.rs deleted file mode 100644 index f94878a65..000000000 --- a/native/mtproto-rs/tests/unit/media_download_profile_tests.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::*; - -#[test] -fn download_profiles_bound_part_size_and_concurrency() { - assert_eq!( - download_window(DownloadProfile::Visible), - DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 8, - } - ); - assert_eq!( - download_window(DownloadProfile::Playing), - download_window(DownloadProfile::User) - ); - assert_eq!(download_window(DownloadProfile::User).chunk, 512 * 1024); - assert_eq!(download_window(DownloadProfile::User).in_flight, 8); - for profile in [DownloadProfile::Ordinary, DownloadProfile::Background] { - let window = download_window(profile); - assert_eq!(window.chunk, DEFAULT_CHUNK); - assert_eq!(window.in_flight, 4); - } - assert_eq!( - download_window(DownloadProfile::Thumb), - DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight: 1, - } - ); - let lowered = - limit_invalid_window(download_window(DownloadProfile::User)).expect("smaller window"); - assert_eq!(lowered.chunk, DEFAULT_CHUNK); - assert!(lowered.in_flight < 8); - assert!(lowered.chunk < FAST_CHUNK); -} - -#[test] -fn parts_outside_the_seek_window_are_cancelled_and_cdn_stays_on_origin() { - let chunk = FAST_CHUNK; - let step = i64::from(chunk); - let inflight = vec![0, step, step * 2, step * 3, step * 5]; - let cancelled = cancel_outside_seek_window(step * 2 + 10, chunk, 2, &inflight); - assert_eq!(cancelled, vec![0, step, step * 5]); - - let media = crate::media::MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "seek-cdn".into(), - location: crate::media::MediaLocation::Document { - id: 3, - access_hash: 4, - file_reference: vec![1], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-seek-cdn-{}", std::process::id())); - let mut cdn_flags = Vec::new(); - let result = download_media_range_with_fetch(2, &media, &path, &path, Some(step), |request| { - cdn_flags.push(request.cdn_supported.is_some()); - assert_eq!(request.offset, step); - Ok(tellers_mtproto::latest::api::UploadFile::UploadFile( - tellers_mtproto::latest::api::UploadFileConstructor { - type_: Box::new( - tellers_mtproto::latest::api::StorageFileType::StorageFileUnknown( - tellers_mtproto::latest::api::StorageFileUnknownConstructor {}, - ), - ), - mtime: 0, - bytes: vec![9u8; 16], - }, - )) - }); - let _ = std::fs::remove_file(&path); - assert!(result.is_ok(), "{result:?}"); - assert_eq!(cdn_flags, vec![false]); -} - -#[test] -fn flood_parks_one_dc_and_class_for_the_server_interval() { - let fast = download_window(DownloadProfile::User); - let ordinary = download_window(DownloadProfile::Ordinary); - let mut scope = FloodScope::new(); - let now = std::time::Instant::now(); - scope.park(2, TransferClass::Download, 120, true, now, fast); - scope.park(2, TransferClass::Download, 5, false, now, fast); - assert!( - scope - .pending_error(2, TransferClass::Download, now + flood_wait_duration(6)) - .is_some() - ); - assert!( - scope - .pending_error(2, TransferClass::Download, now) - .is_some() - ); - assert!( - scope - .pending_error(4, TransferClass::Download, now) - .is_none() - ); - assert!(scope.pending_error(2, TransferClass::Upload, now).is_none()); - assert!( - scope - .pending_error(2, TransferClass::Download, now + flood_wait_duration(120)) - .is_none() - ); - assert_eq!(flood_wait_duration(120).as_secs(), 120); - assert_eq!( - scope - .window(2, TransferClass::Download, now, fast) - .in_flight, - 1 - ); - assert_eq!(scope.window(4, TransferClass::Download, now, fast), fast); - assert_eq!( - scope.window(2, TransferClass::Upload, now, ordinary), - ordinary - ); - let later = now + flood_wait_duration(120); - assert_eq!( - scope - .window(2, TransferClass::Download, later, fast) - .in_flight, - 1 - ); -} diff --git a/native/mtproto-rs/tests/unit/media_download_tests.rs b/native/mtproto-rs/tests/unit/media_download_tests.rs deleted file mode 100644 index 1dc4ecb62..000000000 --- a/native/mtproto-rs/tests/unit/media_download_tests.rs +++ /dev/null @@ -1,1443 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -fn hold_media_globals() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) -} -#[allow(unused_imports)] -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -#[test] -fn stale_staging_preserves_existing_destination_and_removes_partial_bytes() { - let dest = std::env::temp_dir().join(format!("monogram-staging-{}", std::process::id())); - fs::write(&dest, b"previous").unwrap(); - let staged = StagedDownload::new(&dest).unwrap(); - let path = staged.path().to_owned(); - fs::write(&path, b"uncommitted").unwrap(); - drop(staged); - assert!(!path.exists()); - assert_eq!(fs::read(&dest).unwrap(), b"previous"); - fs::remove_file(dest).unwrap(); -} - -#[test] -fn cancel_before_publication_cleans_staging_without_publishing() { - let dest = std::env::temp_dir().join(format!("monogram-publish-cancel-{}", std::process::id())); - let staged = StagedDownload::new(&dest).unwrap(); - let path = staged.path().to_owned(); - fs::write(&path, b"complete").unwrap(); - fs::write(cancel_marker(&dest), []).unwrap(); - let ranges = ranges_path(&path); - fs::write(&ranges, b"ranges").unwrap(); - assert!(staged.publish(&dest).is_err()); - assert!(path.exists()); - assert!(ranges.exists()); - assert!(!dest.exists()); - let _ = fs::remove_file(&path); - let _ = fs::remove_file(&ranges); - fs::remove_file(cancel_marker(&dest)).unwrap(); -} - -#[test] -fn successful_staging_publishes_complete_file() { - let dest = std::env::temp_dir().join(format!("monogram-publish-{}", std::process::id())); - let staged = StagedDownload::new(&dest).unwrap(); - let path = staged.path().to_owned(); - fs::write(&path, b"complete").unwrap(); - let ranges = ranges_path(&path); - fs::write(&ranges, b"stale").unwrap(); - staged.publish(&dest).unwrap(); - assert!(!path.exists()); - assert!(!ranges.exists()); - assert_eq!(fs::read(&dest).unwrap(), b"complete"); - fs::remove_file(dest).unwrap(); -} - -#[test] -fn resume_reopens_the_longest_part_and_cancel_deletes_it() { - let dest = std::env::temp_dir().join(format!("monogram-resume-{}", std::process::id())); - let short = StagedDownload::new(&dest).unwrap(); - fs::write(short.path(), vec![1; 32]).unwrap(); - let short_path = short.path().to_owned(); - short.keep(); - let long = StagedDownload::new(&dest).unwrap(); - fs::write(long.path(), vec![2; 128 * 1024 + 10]).unwrap(); - let long_path = long.path().to_owned(); - long.keep(); - let resumed = StagedDownload::resume_or_create(&dest).unwrap(); - assert_eq!(resumed.path(), long_path); - assert!(!short_path.exists()); - assert_eq!(aligned_prefix(128 * 1024 + 10, CHUNK), 128 * 1024); - assert_eq!(aligned_prefix(10, CHUNK), 0); - resumed.keep(); - fs::write(cancel_marker(&dest), []).unwrap(); - assert!(StagedDownload::resume_or_create(&dest).is_err()); - assert!(long_path.exists(), "cancel keeps the partial like Android"); - let _ = fs::remove_file(&long_path); - let _ = fs::remove_file(cancel_marker(&dest)); - let _ = fs::remove_file(&dest); -} - -#[test] -fn android_range_file_round_trips_and_rejects_a_negative_count() { - let ranges = MissingRanges::span(128 * 1024, 512 * 1024); - let bytes = ranges.to_bytes().unwrap(); - assert_eq!(i32::from_be_bytes(bytes[0..4].try_into().unwrap()), 1); - assert_eq!( - i64::from_be_bytes(bytes[4..12].try_into().unwrap()), - 128 * 1024 - ); - assert_eq!( - i64::from_be_bytes(bytes[12..20].try_into().unwrap()), - 512 * 1024 - ); - assert_eq!(MissingRanges::load(&bytes), Some(ranges)); - assert!(MissingRanges::load(&(-1i32).to_be_bytes()).is_none()); - let mut huge = vec![0x7f, 0xff, 0xff, 0xff]; - huge.extend_from_slice(&[0; 4]); - assert!(MissingRanges::load(&huge).is_none()); - assert_eq!( - resume_start(MissingRanges::load(&bytes).as_ref(), 0, CHUNK), - 128 * 1024 - ); - assert_eq!(resume_start(None, 128 * 1024 + 10, CHUNK), 128 * 1024); - let mut hole = MissingRanges::span(100, 200); - assert!(preserved_end(1_000, 200, Some(1_000), Some(&hole)).is_err()); - hole.cover(100, 200); - assert_eq!( - preserved_end(1_000, 200, Some(1_000), Some(&hole)), - Ok(1_000) - ); - let leading = MissingRanges::span(0, CHUNK as i64); - assert!(preserved_end(1_048_576, 0, Some(1_048_576), Some(&leading)).is_err()); - let mut unknown = MissingRanges::span(100, 200); - unknown.cover(400, i64::MAX); - assert!(preserved_end(1_000, 180, None, Some(&unknown)).is_err()); - unknown.cover(100, 200); - unknown.clip_to_end(180); - assert_eq!(preserved_end(1_000, 180, None, Some(&unknown)), Ok(1_000)); - assert!(keep_partial(&crate::MtprotoError::Message( - "download incomplete: received 200 of 1000 bytes".into() - ))); - assert!(keep_partial(&crate::MtprotoError::Message( - "401: AUTH_KEY_UNREGISTERED".into() - ))); - assert!(keep_partial(&crate::MtprotoError::Message( - "401: AUTH_KEY_INVALID".into() - ))); - assert!(keep_partial(&crate::MtprotoError::Message( - "406: AUTH_KEY_DUPLICATED".into() - ))); - let staging = std::path::PathBuf::from("video.mp4.100.2.part"); - let sidecar = ranges_path(&staging); - assert!(sidecar.ends_with("video.mp4.100.2.ranges.part")); - assert!(!is_native_staging_name( - "video.mp4.100.2.ranges.part", - "video.mp4" - )); -} - -#[test] -fn retryable_failure_keeps_the_part_and_cancel_does_not() { - let dest = std::env::temp_dir().join(format!("monogram-keep-{}", std::process::id())); - let staged = StagedDownload::new(&dest).unwrap(); - let path = staged.path().to_owned(); - fs::write(&path, b"partial").unwrap(); - assert!(keep_partial(&crate::MtprotoError::Message( - "FLOOD_WAIT_3".into() - ))); - assert!(keep_partial(&crate::MtprotoError::Message( - "FILE_REFERENCE_EXPIRED".into() - ))); - assert!(!keep_partial(&crate::MtprotoError::Message( - "cancelled".into() - ))); - staged.keep(); - assert!(path.exists()); - let _ = fs::remove_file(&path); - let _ = fs::remove_file(&dest); -} - -#[test] -fn one_destination_has_one_writer() { - let dest = std::env::temp_dir().join(format!("monogram-lock-{}", std::process::id())); - let first = DestinationLock::acquire(&dest); - let dest_for_thread = dest.clone(); - let (started, started_rx) = std::sync::mpsc::channel(); - let thread = std::thread::spawn(move || { - started.send(()).unwrap(); - let _second = DestinationLock::acquire(&dest_for_thread); - started.send(()).unwrap(); - }); - started_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .unwrap(); - assert!( - started_rx - .recv_timeout(std::time::Duration::from_millis(50)) - .is_err() - ); - drop(first); - started_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .unwrap(); - thread.join().unwrap(); -} - -#[test] -fn chunk_is_telegram_max_part() { - assert_eq!(CHUNK, 128 * 1024); - assert_eq!(CHUNK % 4096, 0); - assert_eq!(CHUNK % 1024, 0); -} - -#[test] -fn download_dc_uses_location_or_home() { - let photo = MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 4, - }; - let home_photo = MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 2, - }; - let unset = MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 0, - }; - assert_eq!(download_dc(2, &photo), 4); - assert_eq!(download_dc(2, &home_photo), 2); - assert_eq!(download_dc(2, &unset), 2); - assert_eq!( - download_dc( - 2, - &MediaLocation::Inline { - bytes: vec![1], - extension: "jpg".into() - } - ), - 2 - ); -} - -#[test] -fn failed_chunked_download_removes_partial_file() { - let path = std::env::temp_dir().join(format!( - "monogram-download-partial-{}.bin", - std::process::id() - )); - let _ = fs::remove_file(&path); - let err = write_file_or_cleanup(&path, |file| { - file.write_all(b"hello") - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Err(MtprotoError::Message("boom".into())) - }); - assert!(err.is_err()); - assert!(!path.exists()); -} - -#[test] -fn cancel_marker_aborts_partial_write() { - let path = std::env::temp_dir().join(format!( - "monogram-download-cancel-{}.bin", - std::process::id() - )); - let marker = cancel_marker(&path); - let _ = fs::remove_file(&path); - let _ = fs::remove_file(&marker); - let err = write_file_or_cleanup(&path, |file| { - file.write_all(b"hello") - .map_err(|e| MtprotoError::Message(e.to_string()))?; - fs::write(&marker, b"").unwrap(); - if download_cancelled(&path) { - return Err(MtprotoError::Message("cancelled".into())); - } - Ok(()) - }); - assert!(err.is_err()); - assert!(!path.exists()); - let _ = fs::remove_file(&marker); -} - -#[test] -fn successful_chunked_download_keeps_file() { - let path = - std::env::temp_dir().join(format!("monogram-download-ok-{}.bin", std::process::id())); - let _ = fs::remove_file(&path); - write_file_or_cleanup(&path, |file| { - file.write_all(b"hello") - .map_err(|e| MtprotoError::Message(e.to_string()))?; - Ok(()) - }) - .unwrap(); - assert_eq!(fs::read(&path).unwrap(), b"hello"); - let _ = fs::remove_file(&path); -} - -#[test] -fn shared_transport_fetches_bounded_parts_and_removes_failed_download() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "test".into(), - location: MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 2, - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-shared-parts-{}", std::process::id())); - for fail_second in [false, true] { - let mut offsets = Vec::new(); - let result = download_media_with_fetch(2, &media, &path, &path, |request| { - assert_eq!(request.limit, CHUNK); - offsets.push(request.offset); - if fail_second && request.offset > 0 { - return Err(MtprotoError::Message("cancelled".into())); - } - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![ - 7; - if request.offset == 0 { - CHUNK as usize - } else { - 3 - } - ], - })) - }); - assert_eq!(offsets, vec![0, CHUNK as i64]); - if fail_second { - assert!(result.is_err()); - assert!(!path.exists()); - } else { - result.unwrap(); - assert_eq!(std::fs::metadata(&path).unwrap().len(), CHUNK as u64 + 3); - std::fs::remove_file(&path).unwrap(); - } - } - assert!( - download_media_with_fetch(4, &media, &path, &path, |_| { - panic!("A foreign-DC file must not send requests on the home transport") - }) - .is_err() - ); -} - -#[test] -fn batched_range_requests_parts_together_and_writes_each_at_its_offset() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "batched-range".into(), - location: MediaLocation::Document { - id: 3, - access_hash: 4, - file_reference: vec![2], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-batched-{}", std::process::id())); - let file_size = (CHUNK as i64) * 2 + 5; - let mut batches: Vec> = Vec::new(); - let result = download_media_range_batched( - 2, - &media, - &path, - &path, - None, - pipeline_parts(), - |_init, requests| { - // Every request in the batch is handed over before any response is - // produced: that is the in-session pipelining contract. - let offsets: Vec = requests.iter().map(|r| r.offset).collect(); - assert!(requests.iter().all(|r| r.limit == CHUNK)); - batches.push(offsets.clone()); - Ok(offsets - .into_iter() - .map(|offset| { - let remaining = (file_size - offset).max(0) as usize; - let len = remaining.min(CHUNK as usize); - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![(offset % 251) as u8; len], - })) - }) - .collect()) - }, - ); - assert!(result.is_ok(), "batched download failed: {result:?}"); - assert_eq!( - batches[0].len(), - pipeline_parts(), - "first batch must pipeline parts" - ); - assert_eq!(batches[0][0], 0); - let written = fs::read(&path).expect("file"); - assert_eq!(written.len() as i64, file_size); - // Each part landed at its own offset. - assert_eq!(written[0], 0); - assert_eq!(written[CHUNK as usize], (CHUNK as i64 % 251) as u8); - assert_eq!(written[2 * CHUNK as usize], (2 * CHUNK as i64 % 251) as u8); - fs::remove_file(&path).unwrap(); -} - -#[test] -fn batched_range_reports_a_failed_part_and_removes_the_partial_file() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "batched-fail".into(), - location: MediaLocation::Document { - id: 5, - access_hash: 6, - file_reference: vec![3], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-batched-fail-{}", std::process::id())); - let result = download_media_range_batched( - 2, - &media, - &path, - &path, - None, - pipeline_parts(), - |_init, requests| { - Ok(requests - .into_iter() - .map(|request| { - if request.offset == 0 { - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![1; CHUNK as usize], - })) - } else { - Err(MtprotoError::Message( - "RPC 400: FILE_REFERENCE_EXPIRED".into(), - )) - } - }) - .collect()) - }, - ); - assert!(result.is_err(), "a failed part must fail the download"); - assert!(is_file_reference_error(&result.unwrap_err())); - assert!( - path.exists(), - "a recoverable file-reference error must retain the partial file" - ); - assert_eq!(fs::read(&path).unwrap(), vec![1; CHUNK as usize]); - fs::remove_file(&path).ok(); - fs::remove_file(ranges_path(&path)).ok(); -} - -#[test] -fn media_range_fetches_one_aligned_part_and_rejects_invalid_bounds() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "range-test".into(), - location: MediaLocation::Document { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-range-{}", std::process::id())); - for size in [0, 3, CHUNK as usize, CHUNK as usize + 1] { - let mut calls = 0; - let result = download_media_range_with_fetch( - 2, - &media, - &path, - &path, - Some(2 * i64::from(CHUNK)), - |request| { - calls += 1; - assert_eq!(request.offset, 2 * i64::from(CHUNK)); - assert_eq!(request.limit, CHUNK); - assert!(request.cdn_supported.is_none()); - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![7; size], - })) - }, - ); - assert_eq!(calls, 1); - if size > CHUNK as usize { - assert!(result.is_err()); - assert!(!path.exists()); - } else { - result.unwrap(); - assert_eq!(fs::metadata(&path).unwrap().len(), size as u64); - fs::remove_file(&path).unwrap(); - } - } - for callback in [false, true] { - let origin = 2 * i64::from(CHUNK); - download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - Some(origin), - 8, - |_, requests, on_chunk| { - assert_eq!(requests.len(), 4); - let mut results = Vec::new(); - for (index, request) in requests.into_iter().enumerate() { - let result = Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![(request.offset / i64::from(CHUNK)) as u8; CHUNK as usize], - })); - if callback { - assert!(on_chunk(index, result.clone()).is_none()); - } - results.push(result); - } - Ok(results) - }, - ) - .unwrap(); - let bytes = fs::read(&path).unwrap(); - assert_eq!(bytes.len(), 512 * 1024); - for index in 0..4 { - assert!( - bytes[index * CHUNK as usize..(index + 1) * CHUNK as usize] - .iter() - .all(|byte| *byte == (index + 2) as u8) - ); - } - fs::remove_file(&path).unwrap(); - } - for offset in [-1, 1, i64::from(CHUNK) + 1] { - assert!( - download_media_range_with_fetch(2, &media, &path, &path, Some(offset), |_| { - panic!("invalid range must not reach transport") - }) - .is_err() - ); - } - assert!( - download_media_range_with_fetch(4, &media, &path, &path, Some(0), |_| { - panic!("foreign DC must migrate before fetching") - }) - .is_err() - ); -} - -#[test] -fn default_pipeline_parts_is_four() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use crate::client::{DEFAULT_PIPELINE_PARTS, pipeline_parts, set_pipeline_parts}; - let previous = pipeline_parts(); - set_pipeline_parts(DEFAULT_PIPELINE_PARTS); - assert_eq!(pipeline_parts(), 4); - set_pipeline_parts(previous); -} - -#[test] -fn limit_invalid_falls_back_to_128kib() { - let _globals = hold_media_globals(); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let policy = std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock()); - policy.own_chunk_size(512 * 1024); - policy.own_pipeline_parts(8); - let _policy = crate::transfer_policy::bind(policy); - let _profile = bind_download_profile(DownloadProfile::User); - let previous_chunk = chunk_size(); - set_chunk_size(512 * 1024); - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "limit-invalid".into(), - location: MediaLocation::Document { - id: 9, - access_hash: 10, - file_reference: vec![4], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-limit-invalid-{}", std::process::id())); - let mut limits: Vec = Vec::new(); - let file_size = 200i64; - let result = - download_media_range_batched(2, &media, &path, &path, None, 2, |_init, requests| { - limits.push(requests[0].limit); - if requests[0].limit > DEFAULT_CHUNK { - return Err(MtprotoError::Message("RPC 400: LIMIT_INVALID".into())); - } - Ok(requests - .into_iter() - .map(|request| { - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![7u8; len], - })) - }) - .collect()) - }); - assert_eq!( - chunk_size(), - 512 * 1024, - "fallback must not change the configured chunk" - ); - set_chunk_size(previous_chunk); - assert!(result.is_ok(), "fallback download failed: {result:?}"); - assert_eq!(limits, vec![512 * 1024, DEFAULT_CHUNK]); - assert_eq!(fs::read(&path).unwrap(), vec![7u8; file_size as usize]); - fs::remove_file(&path).unwrap(); -} - -#[test] -fn batched_streaming_writes_a_part_before_the_batch_returns() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let previous = pipeline_parts(); - set_pipeline_parts(4); - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "stream-write".into(), - location: MediaLocation::Document { - id: 11, - access_hash: 12, - file_reference: vec![5], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-stream-write-{}", std::process::id())); - let mut lengths_during_batch: Vec = Vec::new(); - let mut batches = 0usize; - let file_size = CHUNK as i64 * 3; - let result = download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - None, - 3, - |_init, requests, on_chunk| { - batches += 1; - let mut results = Vec::with_capacity(requests.len()); - for (index, request) in requests.iter().enumerate() { - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![(request.offset % 251) as u8; len], - }); - let _ = on_chunk(index, Ok(file.clone())); - if index == 0 { - lengths_during_batch.push(fs::metadata(&path).map(|m| m.len()).unwrap_or(0)); - } - results.push(Ok(file)); - } - Ok(results) - }, - ); - set_pipeline_parts(previous); - assert!(result.is_ok(), "streaming download failed: {result:?}"); - assert!( - lengths_during_batch.iter().any(|len| *len > 0), - "part must be written before fetch_batch returns: {lengths_during_batch:?}" - ); - assert!(batches >= 1); - fs::remove_file(&path).ok(); -} - -#[test] -fn progress_counts_each_offset_once_when_on_chunk_and_batch_both_deliver() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use std::sync::{Arc, Mutex}; - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let seen = Arc::new(Mutex::new(Vec::::new())); - let seen_cb = seen.clone(); - set_progress_callback(Some(Arc::new(move |_, downloaded, _| { - seen_cb.lock().unwrap().push(downloaded); - }))); - let media = MediaRef { - file_size: None, - kind: "document".into(), - cache_key: "progress-once".into(), - location: MediaLocation::Document { - id: 41, - access_hash: 42, - file_reference: vec![8], - thumb_size: String::new(), - dc_id: 2, - mime_type: "application/pdf".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-progress-once-{}", std::process::id())); - let file_size = CHUNK as i64 * 4; - let result = download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - None, - 2, - |_init, requests, on_chunk| { - let mut pending = requests; - let mut results = Vec::new(); - let mut index = 0usize; - while index < pending.len() { - let request = pending[index].clone(); - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![4u8; len], - }); - if let Some(next) = on_chunk(index, Ok(file.clone())) { - pending.push(next); - } - results.push(Ok(file)); - index += 1; - } - Ok(results) - }, - ); - set_progress_callback(None); - assert!(result.is_ok(), "{result:?}"); - let values = seen.lock().unwrap().clone(); - assert!(!values.is_empty(), "progress must be pushed"); - let last = *values.last().unwrap(); - assert_eq!(last, file_size, "progress doubled or short: {values:?}"); - assert!( - values.windows(2).all(|w| w[1] >= w[0]), - "progress went backwards: {values:?}" - ); - fs::remove_file(&path).ok(); -} - -#[test] -fn completed_part_refills_inflight_window_without_waiting_for_the_batch() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let previous = pipeline_parts(); - set_pipeline_parts(2); - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "slide-refill".into(), - location: MediaLocation::Document { - id: 31, - access_hash: 32, - file_reference: vec![7], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-slide-refill-{}", std::process::id())); - let file_size = CHUNK as i64 * 5; - let mut batches = 0usize; - let mut max_inflight = 0usize; - let mut seen_offsets: Vec = Vec::new(); - let result = download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - None, - 2, - |_init, requests, on_chunk| { - batches += 1; - let mut pending = requests; - let mut results = Vec::new(); - let mut index = 0usize; - while index < pending.len() { - max_inflight = max_inflight.max(pending.len() - index); - let request = pending[index].clone(); - seen_offsets.push(request.offset); - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![3u8; len], - }); - if let Some(next) = on_chunk(index, Ok(file.clone())) { - pending.push(next); - } - results.push(Ok(file)); - index += 1; - } - Ok(results) - }, - ); - set_pipeline_parts(previous); - assert!(result.is_ok(), "sliding download failed: {result:?}"); - assert_eq!(batches, 1, "refill must stay inside the first window call"); - assert!( - seen_offsets.len() >= 5, - "expected whole file via refill: {seen_offsets:?}" - ); - assert!( - max_inflight <= 2, - "window must stay at parts_in_flight: {max_inflight}" - ); - assert_eq!(fs::read(&path).unwrap().len(), file_size as usize); - fs::remove_file(&path).ok(); -} - -#[test] -fn parallel_media_download_preserves_bytes_with_out_of_order_refills() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, UploadGetFileRequest, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "parallel-refill".into(), - location: MediaLocation::Document { - id: 31, - access_hash: 32, - file_reference: vec![7], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = - std::env::temp_dir().join(format!("monogram-parallel-refill-{}", std::process::id())); - let mut batches = 0; - let mut file_size = 0; - let result = download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - None, - 4, - |_, requests, on_chunk| { - batches += 1; - file_size = requests[0].limit as usize * 7 + 123; - let mut lanes = [Vec::new(), Vec::new()]; - for (index, request) in requests.into_iter().enumerate() { - lanes[index % 2].push((index, request)); - } - let invoke = |mut requests: Vec, - on_chunk: &mut dyn FnMut( - usize, - Result, - ) -> Option| { - let mut results = Vec::new(); - let mut index = 0; - while index < requests.len() { - let request = &requests[index]; - if request.offset == 0 { - std::thread::sleep(std::time::Duration::from_millis(20)); - } - let start = request.offset as usize; - let len = file_size.saturating_sub(start).min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: (start..start + len) - .map(|offset| (offset % 251) as u8) - .collect(), - }); - if let Some(next) = on_chunk(index, Ok(file.clone())) { - requests.push(next); - } - results.push(Ok(file)); - index += 1; - } - Ok(results) - }; - crate::client::invoke_parallel_media_batches(lanes, invoke, invoke, on_chunk) - }, - ); - assert!(result.is_ok(), "parallel download failed: {result:?}"); - assert_eq!(batches, 1); - assert_eq!( - fs::read(&path).unwrap(), - (0..file_size) - .map(|offset| (offset % 251) as u8) - .collect::>() - ); - fs::remove_file(path).unwrap(); -} - -#[test] -fn one_mib_chunks_are_rejected() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - let previous = chunk_size(); - set_chunk_size(1024 * 1024); - assert_eq!(chunk_size(), DEFAULT_CHUNK); - set_chunk_size(512 * 1024); - assert_eq!(chunk_size(), 512 * 1024); - set_chunk_size(previous); - assert!(window_candidate(128 * 1024, 4, false).is_some()); - assert!(window_candidate(512 * 1024, 8, false).is_some()); - assert!(window_candidate(1024 * 1024, 12, false).is_none()); - assert!(window_candidate(1024 * 1024, 12, true).is_some()); - assert!(window_candidate(1024 * 1024, 4, true).is_none()); -} - -#[test] -fn overlapping_ranges_collapse_and_a_second_claim_waits() { - let offsets = dedup_offsets(&[0, 100, 128 * 1024, 50, 128 * 1024], 128 * 1024); - assert_eq!(offsets, vec![0, 128 * 1024]); - let file_id = i64::MIN + 9; - let first = claim_offsets(file_id, &[0, 128 * 1024]); - assert_eq!(first, vec![0, 128 * 1024]); - assert!(claim_offsets(file_id, &[0]).is_empty()); - assert_eq!(claim_offsets(file_id, &[256 * 1024]), vec![256 * 1024]); - release_offsets(file_id, &[0, 128 * 1024, 256 * 1024]); - assert_eq!(claim_offsets(file_id, &[0]), vec![0]); - release_offsets(file_id, &[0]); -} - -#[test] -fn resume_rejects_a_partial_whose_identity_does_not_match() { - let current = RangeIdentity { - file_id: 7, - access_hash: 8, - dc_id: 2, - size: 100, - version: 0, - }; - assert!(!resume_allowed(None, ¤t)); - let mut other = current.clone(); - other.file_id = 9; - assert!(!resume_allowed(Some(&other), ¤t)); - other.file_id = 7; - other.size = 90; - assert!(!resume_allowed(Some(&other), ¤t)); - assert!(resume_allowed(Some(¤t), ¤t)); -} - -#[test] -fn optional_transfer_pauses_only_while_visible_work_is_waiting() { - crate::client::lanes::set_visible_transfer_waiters_for_test(0); - assert!(!should_pause_optional(Some(DownloadProfile::Background))); - crate::client::lanes::set_visible_transfer_waiters_for_test(1); - assert!(should_pause_optional(Some(DownloadProfile::Background))); - assert!(!should_pause_optional(Some(DownloadProfile::Visible))); - assert!(!should_pause_optional(Some(DownloadProfile::User))); - crate::client::lanes::set_visible_transfer_waiters_for_test(0); - assert!(!should_pause_optional(Some(DownloadProfile::Background))); - assert!(keep_partial(&crate::MtprotoError::Message( - "OPTIONAL_PAUSED".into() - ))); -} - -#[test] -fn file_migrate_retries_that_dc_and_flood_resumes_the_same_transfer() { - let migrate = MtprotoError::Message("RPC 303: FILE_MIGRATE_4".into()); - assert_eq!(transfer_step(&migrate, 2, 0, 0), TransferStep::Migrate(4)); - assert_eq!(transfer_step(&migrate, 4, 0, 0), TransferStep::Stop); - assert_eq!(transfer_step(&migrate, 2, 3, 0), TransferStep::Stop); - let user = MtprotoError::Message("RPC 303: USER_MIGRATE_4".into()); - assert_eq!(transfer_step(&user, 2, 0, 0), TransferStep::Stop); - let flood = MtprotoError::Message("RPC 420: FLOOD_WAIT_2".into()); - assert_eq!(transfer_step(&flood, 2, 0, 0), TransferStep::ResumeFlood); - assert_eq!(transfer_step(&flood, 2, 0, 3), TransferStep::Stop); - - let mut dc = 2i32; - let mut migrations = 0u8; - let mut flood_resumes = 0u8; - let mut attempts = Vec::new(); - let err = loop { - let step = match attempts.len() { - 0 => transfer_step(&flood, dc, migrations, flood_resumes), - 1 => transfer_step(&migrate, dc, migrations, flood_resumes), - _ => break None, - }; - match step { - TransferStep::ResumeFlood => { - attempts.push(dc); - flood_resumes += 1; - } - TransferStep::Migrate(next) => { - attempts.push(dc); - dc = next; - migrations += 1; - } - TransferStep::Stop => break Some(step), - } - }; - assert_eq!(err, None); - assert_eq!(attempts, vec![2, 2]); - assert_eq!(dc, 4); -} - -#[test] -fn flood_wait_unwinds_without_retrying_on_the_lane() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - let _policy = crate::transfer_policy::bind(std::sync::Arc::new( - crate::transfer_policy::TransferPolicy::stock(), - )); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "flood-retry".into(), - location: MediaLocation::Document { - id: 21, - access_hash: 22, - file_reference: vec![6], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-flood-{}", std::process::id())); - let mut calls = 0u8; - let result = - download_media_range_batched(2, &media, &path, &path, None, 1, |_init, requests| { - calls += 1; - if calls == 1 { - return Err(MtprotoError::Message("RPC 420: FLOOD_WAIT_2".into())); - } - Ok(requests - .into_iter() - .map(|request| { - let remaining = (20i64 - request.offset).max(0) as usize; - Ok(UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![9u8; remaining.min(request.limit as usize)], - })) - }) - .collect()) - }); - assert!(result.is_err()); - assert_eq!(calls, 1); - fs::remove_file(&path).ok(); -} - -#[test] -fn ordinary_flood_wait_reduces_aggressive_download_window() { - let now = std::time::Instant::now(); - let current = DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 8, - }; - let mut scope = FloodScope::new(); - - scope.park(2, TransferClass::Download, 2, false, now, current); - assert_eq!( - scope.window(2, TransferClass::Download, now, current), - DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 2, - } - ); - - let mut premium_scope = FloodScope::new(); - premium_scope.park(2, TransferClass::Download, 2, true, now, current); - assert_eq!( - premium_scope.window(2, TransferClass::Download, now, current), - DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 1, - } - ); -} - -#[test] -fn flood_retry_keeps_window_after_deadline_and_recovers_only_with_success() { - let now = std::time::Instant::now(); - let fast = DownloadWindow { - chunk: FAST_CHUNK, - in_flight: 8, - }; - let mut scope = FloodScope::new(); - scope.park(2, TransferClass::Download, 2, false, now, fast); - let retry = now + std::time::Duration::from_secs(3); - assert!( - scope - .pending_error(2, TransferClass::Download, retry) - .is_none() - ); - assert_eq!( - scope - .window(2, TransferClass::Download, retry, fast) - .in_flight, - 2 - ); - assert!(scope.recovering_download(2)); - assert!(!scope.recovering_download(4)); - scope.park(2, TransferClass::Download, 2, false, retry, fast); - let ready = retry + std::time::Duration::from_secs(2); - assert_eq!( - scope - .window(2, TransferClass::Download, ready, fast) - .in_flight, - 1 - ); - let later = ready + std::time::Duration::from_secs(31); - assert_eq!( - scope - .window(2, TransferClass::Download, later, fast) - .in_flight, - 1 - ); - for _ in 0..31 { - scope.download_succeeded(2, later); - } - assert_eq!( - scope - .window(2, TransferClass::Download, later, fast) - .in_flight, - 1 - ); - scope.download_succeeded(2, later); - assert_eq!( - scope - .window(2, TransferClass::Download, later, fast) - .in_flight, - 2 - ); - for _ in 0..32 { - scope.download_succeeded(2, later); - } - assert_eq!( - scope - .window(2, TransferClass::Download, later, fast) - .in_flight, - 2 - ); - let stable = later + std::time::Duration::from_secs(30); - scope.download_succeeded(2, stable); - assert_eq!( - scope - .window(2, TransferClass::Download, stable, fast) - .in_flight, - 3 - ); - assert_eq!(scope.window(4, TransferClass::Download, stable, fast), fast); - let small = DownloadWindow { - chunk: DEFAULT_CHUNK, - in_flight: 1, - }; - assert_eq!( - scope.window(2, TransferClass::Download, stable, small), - small - ); -} - -#[test] -fn rpc_timeout_retries_from_missing_offset_without_dropping_written_parts() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "document".into(), - cache_key: "timeout-retry".into(), - location: MediaLocation::Document { - id: 51, - access_hash: 52, - file_reference: vec![9], - thumb_size: String::new(), - dc_id: 2, - mime_type: "application/pdf".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-timeout-{}", std::process::id())); - let file_size = CHUNK as i64 * 3; - let mut calls = 0u8; - let result = download_media_range_batched_streaming( - 2, - &media, - &path, - &path, - None, - 2, - |_init, requests, on_chunk| { - calls += 1; - if calls == 1 { - let request = requests[0].clone(); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![1u8; request.limit as usize], - }); - let _ = on_chunk(0, Ok(file.clone())); - return Err(MtprotoError::Message( - "RPC timeout recv=95973128 needed=524399 available=513559 prefix=524395 last_ctor=0xf35c6d01".into(), - )); - } - let mut results = Vec::new(); - for (index, request) in requests.iter().enumerate() { - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![2u8; len], - }); - let _ = on_chunk(index, Ok(file.clone())); - results.push(Ok(file)); - } - Ok(results) - }, - ); - assert!(result.is_ok(), "{result:?}"); - assert!(calls >= 2, "timeout must retry the window: calls={calls}"); - assert_eq!(fs::read(&path).unwrap().len(), file_size as usize); - fs::remove_file(&path).ok(); -} - -#[test] -fn capped_refill_releases_fetch_batch_before_eof() { - let _globals = hold_media_globals(); - let _profile = bind_download_profile(DownloadProfile::Ordinary); - use tellers_mtproto::latest::api::{ - StorageFileType, StorageFileUnknownConstructor, UploadFileConstructor, - }; - let media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "window-release".into(), - location: MediaLocation::Document { - id: 61, - access_hash: 62, - file_reference: vec![10], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let path = std::env::temp_dir().join(format!("monogram-window-{}", std::process::id())); - let file_size = CHUNK as i64 * 6; - let mut batches = 0usize; - let result = download_media_range_batched_streaming_capped( - 2, - &media, - &path, - &path, - None, - 2, - Some(2), - |_init, requests, on_chunk| { - batches += 1; - let mut pending = requests; - let mut results = Vec::new(); - let mut index = 0usize; - while index < pending.len() { - let request = pending[index].clone(); - let remaining = (file_size - request.offset).max(0) as usize; - let len = remaining.min(request.limit as usize); - let file = UploadFile::UploadFile(UploadFileConstructor { - type_: Box::new(StorageFileType::StorageFileUnknown( - StorageFileUnknownConstructor {}, - )), - mtime: 0, - bytes: vec![5u8; len], - }); - if let Some(next) = on_chunk(index, Ok(file.clone())) { - pending.push(next); - } - results.push(Ok(file)); - index += 1; - } - Ok(results) - }, - ); - assert!(result.is_ok(), "{result:?}"); - assert!( - batches >= 2, - "home-DC window must return fetch_batch before EOF: batches={batches}" - ); - assert_eq!(fs::read(&path).unwrap().len(), file_size as usize); - fs::remove_file(&path).ok(); -} diff --git a/native/mtproto-rs/tests/unit/media_index_tests.rs b/native/mtproto-rs/tests/unit/media_index_tests.rs deleted file mode 100644 index 66042cd8f..000000000 --- a/native/mtproto-rs/tests/unit/media_index_tests.rs +++ /dev/null @@ -1,114 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -#[test] -fn classify_document_kinds() { - assert_eq!( - classify_document("application/x-tgsticker", true, true, false), - "sticker_animated" - ); - assert_eq!( - classify_document("image/webp", true, false, false), - "sticker" - ); - assert_eq!(classify_document("video/mp4", false, true, false), "gif"); - assert_eq!(classify_document("video/mp4", false, false, true), "video"); -} - -#[test] -fn round_video_attribute_is_video_note_not_voice() { - use tellers_mtproto::latest::api::{ - DocumentAttribute, DocumentAttributeVideoConstructor, DocumentConstructor, True, - TrueConstructor, Vector, VectorConstructor, - }; - let mut doc = DocumentConstructor { - flags: 0, - id: 1, - access_hash: 1, - file_reference: vec![1], - date: 0, - mime_type: "video/mp4".into(), - size: 12, - thumbs: None, - video_thumbs: None, - dc_id: 2, - attributes: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(DocumentAttribute::DocumentAttributeVideo( - DocumentAttributeVideoConstructor { - flags: DocumentAttributeVideoConstructor::ROUND_MESSAGE_FLAG, - round_message: Some(Box::new(True::True(TrueConstructor {}))), - supports_streaming: None, - nosound: None, - duration: 3.0, - w: 240, - h: 240, - preload_prefix_size: None, - video_start_ts: None, - video_codec: None, - }, - ))], - })), - }; - assert_eq!(document_kind(&doc), "video_note"); - doc.attributes = Box::new(Vector::Vector(VectorConstructor { - field_0: 2, - field_1: vec![ - Box::new(DocumentAttribute::DocumentAttributeAudio( - tellers_mtproto::latest::api::DocumentAttributeAudioConstructor { - flags: 0, - voice: None, - duration: 2, - title: None, - performer: None, - waveform: None, - }, - )), - Box::new(DocumentAttribute::DocumentAttributeVideo( - DocumentAttributeVideoConstructor { - flags: DocumentAttributeVideoConstructor::ROUND_MESSAGE_FLAG, - round_message: Some(Box::new(True::True(TrueConstructor {}))), - supports_streaming: None, - nosound: None, - duration: 3.0, - w: 240, - h: 240, - preload_prefix_size: None, - video_start_ts: None, - video_codec: None, - }, - )), - ], - })); - assert_eq!( - document_kind(&doc), - "video_note", - "round video wins over a sidecar audio attribute", - ); - assert_eq!(classify_document("image/gif", false, false, false), "gif"); - assert_eq!( - classify_document("image/jpeg", false, false, false), - "document" - ); - assert_eq!( - classify_document("image/png", false, false, false), - "document" - ); - assert_eq!( - classify_document("image/webp", false, false, false), - "document" - ); - assert_eq!( - classify_document("application/pdf", false, false, false), - "document" - ); -} diff --git a/native/mtproto-rs/tests/unit/media_location_tests.rs b/native/mtproto-rs/tests/unit/media_location_tests.rs deleted file mode 100644 index 75a776bb1..000000000 --- a/native/mtproto-rs/tests/unit/media_location_tests.rs +++ /dev/null @@ -1,93 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -#[test] -fn scheduled_media_ids_exclude_history_avatars_and_instant_view() { - for id in [1, 37, i32::MAX] { - let encoded = scheduled_media_id(id).unwrap(); - assert!(encoded < -1); - assert_eq!(scheduled_message_id(encoded), Some(id)); - } - for id in [i32::MIN, -1, 0] { - assert!(scheduled_media_id(id).is_err()); - } - for id in [-1, 0, 1, i32::MAX] { - assert_eq!(scheduled_message_id(id), None); - } -} - -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -#[test] -fn file_reference_error_matches_expired_and_invalid() { - assert!(is_file_reference_error(&MtprotoError::Message( - "RPC 400: FILE_REFERENCE_EXPIRED".into(), - ))); - assert!(is_file_reference_error(&MtprotoError::Message( - "FILE_REFERENCE_INVALID".into(), - ))); - assert!(is_file_reference_error(&MtprotoError::Message( - "FILE_REFERENCE_3_EXPIRED".into(), - ))); - assert!(!is_file_reference_error(&MtprotoError::Message( - "RPC timeout recv=2880".into(), - ))); - assert!(!is_file_reference_error(&MtprotoError::UnknownClient)); -} - -#[test] -fn peer_photo_location_token_tracks_access_hash() { - let peer = MediaLocation::PeerPhoto { - peer_kind: crate::peers::PeerKind::User, - peer_id: 1, - access_hash: 0, - photo_id: 1, - big: true, - dc_id: 2, - }; - let mut updated = peer.clone(); - if let MediaLocation::PeerPhoto { access_hash, .. } = &mut updated { - *access_hash = 9; - } - assert_ne!(location_token(&peer), location_token(&updated)); -} - -#[test] -fn fill_zero_peer_photo_hashes_from_full_peer() { - let mut media = MediaIndex::default(); - media.insert( - (-1_000_000_000_042, 0), - media_ref_peer_photo( - crate::peers::PeerKind::Channel, - 42, - 0, - 9, - 2, - "avatar:-1000000000042".into(), - false, - None, - ), - ); - let mut peers = HashMap::new(); - peers.insert( - -1_000_000_000_042, - crate::peers::CachedPeer { - kind: crate::peers::PeerKind::Channel, - id: 42, - access_hash: 77, - min_hash: false, - }, - ); - fill_zero_peer_photo_hashes(&mut media, &peers); - match media.get(&(-1_000_000_000_042, 0)).unwrap().location { - MediaLocation::PeerPhoto { access_hash, .. } => assert_eq!(access_hash, 77), - _ => panic!("peer photo"), - } -} diff --git a/native/mtproto-rs/tests/unit/media_page_tests.rs b/native/mtproto-rs/tests/unit/media_page_tests.rs deleted file mode 100644 index a8238c166..000000000 --- a/native/mtproto-rs/tests/unit/media_page_tests.rs +++ /dev/null @@ -1,403 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -fn plain(text: &str) -> Box { - Box::new(RichText::TextPlain( - tellers_mtproto::latest::api::TextPlainConstructor { - text: text.to_string(), - }, - )) -} - -fn boxed_vector(items: Vec) -> Vector> { - Vector::Vector(tellers_mtproto::latest::api::VectorConstructor { - field_0: tellers_mtproto::latest::api::VectorConstructor::>::ID, - field_1: items.into_iter().map(Box::new).collect(), - }) -} - -fn paragraph(text: &str) -> PageBlock { - PageBlock::PageBlockParagraph( - tellers_mtproto::latest::api::PageBlockParagraphConstructor { text: plain(text) }, - ) -} - -#[test] -fn layer_230_ton_address_preserves_text_and_formatting() { - let rich = RichText::TextTonAddress(tellers_mtproto::latest::api::TextTonAddressConstructor { - text: plain("EQ_example"), - }); - let json = crate::instant_view::rich_json(&rich); - assert_eq!(json["t"], "EQ_example"); - assert_eq!(json["e"][0]["k"], "code"); - let block = paragraph_of(rich); - let formatted = page_blocks_formatted(std::iter::once(&block)); - assert_eq!(formatted.text, "EQ_example"); - assert!( - formatted - .entities - .iter() - .any(|entity| entity.kind == "code" && entity.offset == 0 && entity.length == 10) - ); -} - -#[test] -fn nested_blockquote_blocks_overlap() { - let inner = PageBlock::PageBlockBlockquoteBlocks( - tellers_mtproto::latest::api::PageBlockBlockquoteBlocksConstructor { - blocks: Box::new(boxed_vector(vec![paragraph("inner")])), - caption: Box::new(RichText::TextEmpty( - tellers_mtproto::latest::api::TextEmptyConstructor {}, - )), - }, - ); - let outer = PageBlock::PageBlockBlockquoteBlocks( - tellers_mtproto::latest::api::PageBlockBlockquoteBlocksConstructor { - blocks: Box::new(boxed_vector(vec![paragraph("outer"), inner])), - caption: Box::new(RichText::TextEmpty( - tellers_mtproto::latest::api::TextEmptyConstructor {}, - )), - }, - ); - let formatted = page_blocks_formatted(std::iter::once(&outer)); - assert_eq!(formatted.text, "outer\ninner"); - let quotes: Vec<_> = formatted - .entities - .iter() - .filter(|e| e.kind == "blockquote") - .collect(); - assert_eq!(quotes.len(), 2); - assert_eq!(quotes[0].offset, utf16_len("outer\n")); - assert_eq!(quotes[0].length, utf16_len("inner")); - assert_eq!(quotes[1].offset, 0); - assert_eq!(quotes[1].length, utf16_len("outer\ninner")); -} - -#[test] -fn photo_caption_is_kept() { - let block = - PageBlock::PageBlockPhoto(tellers_mtproto::latest::api::PageBlockPhotoConstructor { - flags: 0, - spoiler: None, - photo_id: 1, - caption: Box::new(PageCaption::PageCaption( - tellers_mtproto::latest::api::PageCaptionConstructor { - text: plain("Image title"), - credit: Box::new(RichText::TextEmpty( - tellers_mtproto::latest::api::TextEmptyConstructor {}, - )), - }, - )), - url: None, - webpage_id: None, - }); - let formatted = page_blocks_formatted(std::iter::once(&block)); - assert_eq!(formatted.text, "Image title"); - let photo = Photo::Photo(tellers_mtproto::latest::api::PhotoConstructor { - flags: 0, - has_stickers: None, - id: 1, - access_hash: 1, - file_reference: vec![1], - date: 0, - sizes: Box::new(boxed_vector(vec![PhotoSize::PhotoSize( - tellers_mtproto::latest::api::PhotoSizeConstructor { - type_: "y".into(), - w: 800, - h: 450, - size: 100, - }, - )])), - video_sizes: None, - dc_id: 2, - }); - let with_photo = page_blocks_formatted_media(std::iter::once(&block), &[photo]); - assert!(with_photo.text.contains('\u{FFFC}')); - assert!(with_photo.text.contains("Image title")); - let photo_entity = with_photo - .entities - .iter() - .find(|entity| entity.kind == "photo") - .expect("photo entity"); - assert_eq!(photo_entity.url.as_deref(), Some("photo:1:800x450")); -} - -fn empty_rich() -> Box { - Box::new(RichText::TextEmpty( - tellers_mtproto::latest::api::TextEmptyConstructor {}, - )) -} - -fn empty_caption() -> Box { - Box::new(PageCaption::PageCaption( - tellers_mtproto::latest::api::PageCaptionConstructor { - text: empty_rich(), - credit: empty_rich(), - }, - )) -} - -fn paragraph_of(text: RichText) -> PageBlock { - PageBlock::PageBlockParagraph( - tellers_mtproto::latest::api::PageBlockParagraphConstructor { - text: Box::new(text), - }, - ) -} - -#[test] -fn rich_message_fields_survive_flattening() { - let marked = paragraph_of(RichText::TextMarked( - tellers_mtproto::latest::api::TextMarkedConstructor { - text: plain("secret"), - }, - )); - let marked_text = page_blocks_formatted(std::iter::once(&marked)); - assert_eq!(marked_text.text, "secret"); - assert!( - marked_text - .entities - .iter() - .any(|entity| entity.kind == "marked") - ); - assert!( - marked_text - .entities - .iter() - .all(|entity| entity.kind != "spoiler") - ); - - let email = paragraph_of(RichText::TextEmail( - tellers_mtproto::latest::api::TextEmailConstructor { - text: plain("Email"), - email: "ada@example.com".into(), - }, - )); - let email_text = page_blocks_formatted(std::iter::once(&email)); - assert_eq!(email_text.text, "Email"); - assert_eq!( - email_text.entities[0].url.as_deref(), - Some("ada@example.com") - ); - - let phone = paragraph_of(RichText::TextPhone( - tellers_mtproto::latest::api::TextPhoneConstructor { - text: plain("Phone"), - phone: "+15551212".into(), - }, - )); - let phone_text = page_blocks_formatted(std::iter::once(&phone)); - assert_eq!(phone_text.entities[0].kind, "phone"); - assert_eq!(phone_text.entities[0].url.as_deref(), Some("+15551212")); - - let mention = paragraph_of(RichText::TextMentionName( - tellers_mtproto::latest::api::TextMentionNameConstructor { - text: plain("Ada"), - user_id: 42, - }, - )); - let mention_text = page_blocks_formatted(std::iter::once(&mention)); - assert_eq!(mention_text.entities[0].kind, "mention_name"); - assert_eq!(mention_text.entities[0].url.as_deref(), Some("42")); - - let emoji = paragraph_of(RichText::TextCustomEmoji( - tellers_mtproto::latest::api::TextCustomEmojiConstructor { - document_id: 99, - alt: "©".into(), - }, - )); - let emoji_text = page_blocks_formatted(std::iter::once(&emoji)); - assert_eq!(emoji_text.text, "©"); - assert_eq!(emoji_text.entities[0].kind, "custom_emoji"); - assert_eq!(emoji_text.entities[0].url.as_deref(), Some("99")); - - let anchor = paragraph_of(RichText::TextAnchor( - tellers_mtproto::latest::api::TextAnchorConstructor { - text: plain("here"), - name: "intro".into(), - }, - )); - let anchor_text = page_blocks_formatted(std::iter::once(&anchor)); - assert_eq!(anchor_text.entities[0].kind, "anchor"); - assert_eq!(anchor_text.entities[0].url.as_deref(), Some("intro")); - - let math = paragraph_of(RichText::TextMath( - tellers_mtproto::latest::api::TextMathConstructor { - source: "E=mc^2".into(), - }, - )); - let math_text = page_blocks_formatted(std::iter::once(&math)); - assert_eq!(math_text.text, "E=mc^2"); - assert_eq!(math_text.entities[0].kind, "math"); - assert_eq!(math_text.entities[0].url.as_deref(), Some("inline")); - - let date = paragraph_of(RichText::TextDate( - tellers_mtproto::latest::api::TextDateConstructor { - flags: tellers_mtproto::latest::api::TextDateConstructor::SHORT_DATE_FLAG, - relative: None, - short_time: None, - long_time: None, - short_date: Some(Box::new(tellers_mtproto::latest::api::True::True( - tellers_mtproto::latest::api::TrueConstructor {}, - ))), - long_date: None, - day_of_week: None, - text: plain("fallback"), - date: 1_710_000_000, - }, - )); - let date_text = page_blocks_formatted(std::iter::once(&date)); - assert_eq!(date_text.text, "fallback"); - assert_eq!(date_text.entities[0].kind, "date"); - assert_eq!(date_text.entities[0].url.as_deref(), Some("1710000000|8")); -} - -#[test] -fn divider_table_and_block_math_keep_structure() { - let divider = - PageBlock::PageBlockDivider(tellers_mtproto::latest::api::PageBlockDividerConstructor {}); - let divided = page_blocks_formatted(std::iter::once(÷r)); - assert_eq!(divided.text, "---"); - assert_eq!(divided.entities[0].kind, "rule"); - - let cell = |text: &str, header: bool| { - tellers_mtproto::latest::api::PageTableCell::PageTableCell( - tellers_mtproto::latest::api::PageTableCellConstructor { - flags: 0, - header: header.then(|| { - Box::new(tellers_mtproto::latest::api::True::True( - tellers_mtproto::latest::api::TrueConstructor {}, - )) - }), - align_center: None, - align_right: None, - valign_middle: None, - valign_bottom: None, - text: Some(plain(text)), - colspan: None, - rowspan: None, - }, - ) - }; - let row = |cells: Vec| { - tellers_mtproto::latest::api::PageTableRow::PageTableRow( - tellers_mtproto::latest::api::PageTableRowConstructor { - cells: Box::new(boxed_vector(cells)), - }, - ) - }; - let table = - PageBlock::PageBlockTable(tellers_mtproto::latest::api::PageBlockTableConstructor { - flags: 0, - bordered: None, - striped: None, - compact: None, - title: empty_rich(), - rows: Box::new(boxed_vector(vec![ - row(vec![cell("A", true), cell("B", true)]), - row(vec![cell("1", false), cell("2", false)]), - ])), - }); - let formatted = page_blocks_formatted(std::iter::once(&table)); - assert!(formatted.text.contains("| A | B |")); - assert!(formatted.text.contains("| 1 | 2 |")); - let table_entity = formatted - .entities - .iter() - .find(|entity| entity.kind == "table") - .expect("table entity"); - assert_eq!(table_entity.url.as_deref(), Some("A\u{1f}B\u{1e}1\u{1f}2")); - assert!(formatted.entities.iter().all(|entity| entity.kind != "pre")); - - let block_math = - PageBlock::PageBlockMath(tellers_mtproto::latest::api::PageBlockMathConstructor { - source: "\\frac{1}{2}".into(), - }); - let math = page_blocks_formatted(std::iter::once(&block_math)); - assert_eq!(math.entities[0].kind, "math"); - assert_eq!(math.entities[0].url.as_deref(), Some("block")); - assert!(math.entities.iter().all(|entity| entity.kind != "code")); -} - -#[test] -fn unsupported_rich_media_is_not_invented() { - let video = - PageBlock::PageBlockVideo(tellers_mtproto::latest::api::PageBlockVideoConstructor { - flags: 0, - autoplay: None, - loop_: None, - spoiler: None, - video_id: 7, - caption: empty_caption(), - }); - let audio = - PageBlock::PageBlockAudio(tellers_mtproto::latest::api::PageBlockAudioConstructor { - audio_id: 8, - caption: empty_caption(), - }); - let collage = - PageBlock::PageBlockCollage(tellers_mtproto::latest::api::PageBlockCollageConstructor { - items: Box::new(boxed_vector(Vec::::new())), - caption: empty_caption(), - }); - let slideshow = PageBlock::PageBlockSlideshow( - tellers_mtproto::latest::api::PageBlockSlideshowConstructor { - items: Box::new(boxed_vector(Vec::::new())), - caption: empty_caption(), - }, - ); - let map = PageBlock::PageBlockMap(tellers_mtproto::latest::api::PageBlockMapConstructor { - geo: Box::new(tellers_mtproto::latest::api::GeoPoint::GeoPointEmpty( - tellers_mtproto::latest::api::GeoPointEmptyConstructor {}, - )), - zoom: 1, - w: 10, - h: 10, - caption: empty_caption(), - }); - let video_text = page_blocks_formatted(std::iter::once(&video)); - assert_eq!(video_text.text, "Video"); - assert!( - video_text - .entities - .iter() - .any(|entity| entity.kind == "video") - ); - let audio_text = page_blocks_formatted(std::iter::once(&audio)); - assert_eq!(audio_text.text, "Audio"); - assert!( - audio_text - .entities - .iter() - .any(|entity| entity.kind == "audio") - ); - - for block in [&collage, &slideshow, &map] { - let formatted = page_blocks_formatted(std::iter::once(block)); - assert!(formatted.text.is_empty(), "{}", formatted.text); - assert!(formatted.entities.iter().all(|entity| { - entity.kind != "photo" && entity.kind != "video" && entity.kind != "audio" - })); - } -} - -#[test] -fn plain_dashes_are_not_a_divider() { - let formatted = page_blocks_formatted(std::iter::once(¶graph("---"))); - assert_eq!(formatted.text, "---"); - assert!( - formatted - .entities - .iter() - .all(|entity| entity.kind != "rule") - ); -} diff --git a/native/mtproto-rs/tests/unit/media_structured_tests.rs b/native/mtproto-rs/tests/unit/media_structured_tests.rs deleted file mode 100644 index ea46faf12..000000000 --- a/native/mtproto-rs/tests/unit/media_structured_tests.rs +++ /dev/null @@ -1,249 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -fn text(value: &str) -> Box { - Box::new(TextWithEntities::TextWithEntities( - TextWithEntitiesConstructor { - text: value.to_owned(), - entities: vector::(&[]), - }, - )) -} - -fn vector(items: &[T]) -> Box>> -where - T: Clone, -{ - Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items.iter().cloned().map(Box::new).collect(), - })) -} - -fn geo(lat: f64, long: f64) -> Box { - Box::new(GeoPoint::GeoPoint(GeoPointConstructor { - flags: 0, - long, - lat, - access_hash: 0, - accuracy_radius: None, - })) -} - -#[test] -fn checklist_payload_preserves_rich_entities_utf16_offsets_and_completion_identity() { - use tellers_mtproto::latest::api::*; - let mut title = text("😀 task"); - let TextWithEntities::TextWithEntities(value) = title.as_mut(); - value.entities = vector(&[MessageEntity::MessageEntityBold( - MessageEntityBoldConstructor { - offset: 3, - length: 4, - }, - )]); - let media = MessageMediaToDoConstructor { - flags: MessageMediaToDoConstructor::COMPLETIONS_FLAG, - todo: Box::new(TodoList::TodoList(TodoListConstructor { - flags: TodoListConstructor::OTHERS_CAN_APPEND_FLAG - | TodoListConstructor::OTHERS_CAN_COMPLETE_FLAG, - others_can_append: Some(Box::new(True::True(TrueConstructor {}))), - others_can_complete: Some(Box::new(True::True(TrueConstructor {}))), - title: title.clone(), - list: vector(&[TodoItem::TodoItem(TodoItemConstructor { id: -7, title })]), - })), - completions: Some(vector(&[TodoCompletion::TodoCompletion( - TodoCompletionConstructor { - id: -7, - completed_by: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 42 })), - date: 9, - }, - )])), - }; - let value: serde_json::Value = serde_json::from_str(&todo_to_json(&media)).unwrap(); - let entities: serde_json::Value = serde_json::from_str(value["e"].as_str().unwrap()).unwrap(); - assert_eq!(entities[0]["kind"], "bold"); - assert_eq!(entities[0]["offset"], 3); - assert_eq!(entities[0]["length"], 4); - assert_eq!(value["i"][0]["e"], value["e"]); - assert_eq!(value["t"], "😀 task"); - assert_eq!(value["i"][0]["id"], -7); - assert_eq!(value["i"][0]["d"], 1); - assert_eq!(value["i"][0]["by"], 42); - assert_eq!(value["i"][0]["at"], 9); - assert_eq!(value["a"], true); - assert_eq!(value["c"], true); -} - -#[test] -fn dice_payload_carries_value_and_emoji() { - let json = dice_to_json(&MessageMediaDiceConstructor { - flags: 0, - value: 4, - emoticon: "🎲".to_owned(), - game_outcome: None, - }); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["v"], 4); - assert_eq!(parsed["e"], "🎲"); -} - -#[test] -fn contact_payload_carries_names_phone_and_user() { - let json = contact_to_json(&MessageMediaContactConstructor { - phone_number: "+79000000000".to_owned(), - first_name: "Ada".to_owned(), - last_name: "Lovelace".to_owned(), - vcard: "BEGIN:VCARD".to_owned(), - user_id: 42, - }); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["p"], "+79000000000"); - assert_eq!(parsed["f"], "Ada"); - assert_eq!(parsed["l"], "Lovelace"); - assert_eq!(parsed["u"], 42); -} - -#[test] -fn venue_payload_carries_coordinates_and_place_details() { - let json = venue_to_json(&MessageMediaVenueConstructor { - geo: geo(55.75, 37.61), - title: "Red Square".to_owned(), - address: "Moscow".to_owned(), - provider: "foursquare".to_owned(), - venue_id: "abc".to_owned(), - venue_type: "square".to_owned(), - }) - .expect("venue payload"); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["lat"], 55.75); - assert_eq!(parsed["long"], 37.61); - assert_eq!(parsed["t"], "Red Square"); - assert_eq!(parsed["y"], "square"); -} - -#[test] -fn live_geo_payload_marks_live_and_keeps_heading() { - let json = geo_live_to_json(&MessageMediaGeoLiveConstructor { - flags: 0, - geo: geo(10.0, 20.0), - heading: Some(90), - period: 900, - proximity_notification_radius: None, - }) - .expect("live payload"); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["live"], 1); - assert_eq!(parsed["p"], 900); - assert_eq!(parsed["h"], 90); -} - -#[test] -fn empty_geo_point_has_no_payload() { - assert!( - geo_to_json(&MessageMediaGeoConstructor { - geo: Box::new(GeoPoint::GeoPointEmpty(GeoPointEmptyConstructor {})), - }) - .is_none() - ); -} - -#[test] -fn poll_payload_marks_answers_voters_and_quiz() { - let answers = vector(&[ - PollAnswer::PollAnswer(PollAnswerConstructor { - flags: 0, - text: text("yes"), - option: vec![0x01, 0xa0], - media: None, - added_by: None, - date: None, - }), - PollAnswer::PollAnswer(PollAnswerConstructor { - flags: 0, - text: text("no"), - option: b"b".to_vec(), - media: None, - added_by: None, - date: None, - }), - ]); - let voters = vector(&[PollAnswerVoters::PollAnswerVoters( - PollAnswerVotersConstructor { - flags: 0, - chosen: Some(Box::new(True::True(TrueConstructor {}))), - correct: Some(Box::new(True::True(TrueConstructor {}))), - option: vec![0x01, 0xa0], - voters: Some(7), - recent_voters: None, - }, - )]); - let mut media = MessageMediaPollConstructor { - flags: 0, - poll: Box::new(Poll::Poll(PollConstructor { - id: 5, - flags: 0, - closed: None, - public_voters: None, - multiple_choice: None, - quiz: Some(Box::new(True::True(TrueConstructor {}))), - open_answers: None, - revoting_disabled: None, - shuffle_answers: None, - hide_results_until_close: None, - creator: None, - subscribers_only: None, - question: text("ready?"), - answers, - close_period: None, - close_date: None, - countries_iso2: None, - hash: 0, - })), - results: Box::new(PollResults::PollResults(PollResultsConstructor { - flags: 0, - min: None, - has_unread_votes: None, - can_view_stats: None, - results: Some(voters), - total_voters: Some(7), - recent_voters: None, - solution: Some("because".to_owned()), - solution_entities: None, - solution_media: None, - })), - attached_media: None, - }; - let absent: serde_json::Value = serde_json::from_str(&poll_to_json(&media)).unwrap(); - assert_eq!(absent["r"], 0); - assert_eq!(absent["creator"], 0); - let Poll::Poll(poll) = media.poll.as_mut(); - poll.creator = Some(Box::new(True::True(TrueConstructor {}))); - poll.revoting_disabled = Some(Box::new(True::True(TrueConstructor {}))); - let json = poll_to_json(&media); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed["r"], 1); - assert_eq!(parsed["id"], "5"); - assert_eq!(parsed["creator"], 1); - assert_eq!(parsed["q"], "ready?"); - assert_eq!(parsed["z"], 1); - assert_eq!(parsed["n"], 7); - assert_eq!(parsed["s"], "because"); - let answers = parsed["a"].as_array().expect("answers"); - assert_eq!(answers.len(), 2); - assert_eq!(answers[0]["t"], "yes"); - assert_eq!(answers[0]["c"], 1); - assert_eq!(answers[0]["k"], 1); - assert_eq!(answers[0]["v"], 7); - assert_eq!(answers[0]["o"], "01a0"); - assert_eq!(answers[1]["c"], 0); - assert_eq!(answers[1]["v"], 0); -} diff --git a/native/mtproto-rs/tests/unit/media_thumbs_tests.rs b/native/mtproto-rs/tests/unit/media_thumbs_tests.rs deleted file mode 100644 index cb1a864d0..000000000 --- a/native/mtproto-rs/tests/unit/media_thumbs_tests.rs +++ /dev/null @@ -1,479 +0,0 @@ -use super::*; -use crate::client::pipeline_parts; - -use crate::client::set_pipeline_parts; -use tellers_mtproto::latest::api::{ - GeoPointConstructor, GeoPointEmptyConstructor, MessageEntity, MessageMediaContactConstructor, - MessageMediaDiceConstructor, MessageMediaGeoConstructor, MessageMediaGeoLiveConstructor, - MessageMediaPollConstructor, MessageMediaVenueConstructor, PollAnswerConstructor, - PollAnswerVotersConstructor, PollConstructor, PollResultsConstructor, - TextWithEntitiesConstructor, -}; - -#[test] -fn duration_rounds_seconds() { - assert_eq!(duration_secs(12.4), 12); - assert_eq!(duration_secs(12.5), 13); - assert_eq!(duration_secs(-1.0), 0); -} - -#[test] -fn pick_thumb_prefers_m_then_smallest() { - let sizes = vec![ - ("y".into(), 1280 * 720, None), - ("m".into(), 320 * 180, None), - ("s".into(), 100 * 56, None), - ]; - let picked = pick_thumb_candidate(&sizes).expect("thumb"); - assert_eq!(picked.0, "m"); - let no_named = vec![("y".into(), 800 * 800, None), ("a".into(), 40 * 40, None)]; - assert_eq!(pick_thumb_candidate(&no_named).unwrap().0, "a"); -} - -#[test] -fn pick_thumb_skips_streaming_letters_without_inline() { - let streaming = vec![("u".into(), 200 * 200, None), ("v".into(), 40 * 40, None)]; - assert!(pick_thumb_candidate(&streaming).is_none()); - let inline_u = vec![("u".into(), 200 * 200, Some(vec![1, 2, 3, 4]))]; - assert_eq!(pick_thumb_candidate(&inline_u).unwrap().0, "u"); - let stripped_only = vec![("i".into(), 40 * 40, Some(vec![0xFF, 0xD8, 0xFF, 0xD9]))]; - assert_eq!(pick_thumb_candidate(&stripped_only).unwrap().0, "i"); - let stripped_and_m = vec![ - ("i".into(), 40 * 40, Some(vec![0xFF, 0xD8])), - ("m".into(), 320 * 180, None), - ]; - assert_eq!(pick_thumb_candidate(&stripped_and_m).unwrap().0, "i"); - assert_eq!( - pick_inline_or_thumb_candidate(&stripped_and_m).unwrap().0, - "i" - ); - assert_eq!(pick_getfile_preview(&stripped_and_m).unwrap().0, "m"); - let s_and_m = vec![("m".into(), 320 * 180, None), ("s".into(), 100 * 56, None)]; - assert_eq!(pick_thumb_candidate(&s_and_m).unwrap().0, "m"); - assert_eq!(pick_inline_or_thumb_candidate(&s_and_m).unwrap().0, "s"); - assert!(is_getfile_thumb_size("m")); - assert!(!is_getfile_thumb_size("u")); - assert!(!is_getfile_thumb_size("")); - assert_eq!(next_getfile_thumb_sizes("m"), &["s", "x", "a"]); - let loc = MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - }; - let alt = with_thumb_size(&loc, "s").unwrap(); - assert!(matches!( - alt, - MediaLocation::Photo { ref thumb_size, .. } if thumb_size == "s" - )); - let mut media = MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "photo:1".into(), - location: loc.clone(), - thumb_cache_key: Some("photo:1:thumb".into()), - thumb_location: Some(loc), - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - remember_thumb_location(&mut media, alt); - assert!(matches!( - media.thumb_location, - Some(MediaLocation::Photo { ref thumb_size, .. }) if thumb_size == "s" - )); -} - -#[test] -fn inline_thumb_jpeg_returns_stripped_bytes_without_getfile() { - let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9]; - let media = MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "photo:stripped".into(), - location: MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 2, - }, - thumb_cache_key: Some("photo:stripped:thumb".into()), - thumb_location: Some(MediaLocation::Inline { - bytes: jpeg.clone(), - extension: "jpg".into(), - }), - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - assert_eq!(inline_thumb_jpeg(&media).as_deref(), Some(jpeg.as_slice())); - let getfile_only = MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "photo:m".into(), - location: MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - assert!(inline_thumb_jpeg(&getfile_only).is_none()); -} - -#[test] -fn empty_cached_size_is_skipped_for_getfile() { - use tellers_mtproto::latest::api::{PhotoCachedSizeConstructor, PhotoSizeConstructor}; - let sizes = vec![ - PhotoSize::PhotoCachedSize(PhotoCachedSizeConstructor { - type_: "m".into(), - w: 320, - h: 180, - bytes: Vec::new(), - }), - PhotoSize::PhotoSize(PhotoSizeConstructor { - type_: "s".into(), - w: 100, - h: 56, - size: 100 * 56, - }), - ]; - assert_eq!(pick_document_thumb_size(&sizes).unwrap().0, "s"); - assert_eq!(pick_thumb_size(&sizes).unwrap().0, "s"); -} - -#[test] -fn pick_display_prefers_m_for_chat_cells() { - let sizes = vec![ - ("w".into(), 2560 * 1440, None), - ("x".into(), 800 * 450, None), - ("m".into(), 320 * 180, None), - ]; - let picked = pick_display_candidate(&sizes, Some("i"), "w").expect("display"); - assert_eq!(picked.0, "m"); - let when_thumb_is_m = pick_display_candidate(&sizes, Some("m"), "w").expect("x fallback"); - assert_eq!(when_thumb_is_m.0, "x"); - assert!(pick_display_candidate(&sizes, Some("m"), "x").is_none()); -} - -#[test] -fn media_for_download_display_uses_m_not_original() { - let media = MediaRef { - file_size: Some(25_000_000), - kind: "photo".into(), - cache_key: "photo:1".into(), - location: MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "w".into(), - dc_id: 2, - }, - thumb_cache_key: Some("photo:1:thumb".into()), - thumb_location: Some(MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - }), - display_cache_key: Some("photo:1:display".into()), - display_location: Some(MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - }), - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let display = media_for_download(&media, MediaDownloadKind::Display).expect("display"); - assert!(matches!( - display.location, - MediaLocation::Photo { thumb_size, .. } if thumb_size == "m" - )); - assert_eq!(display.cache_key, "photo:1:display"); - let mut no_display = media.clone(); - no_display.display_location = None; - assert!(media_for_download(&no_display, MediaDownloadKind::Display).is_err()); - assert_eq!( - media_for_download(&media, MediaDownloadKind::Full) - .unwrap() - .file_size, - Some(25_000_000) - ); - assert_eq!( - media_for_download(&media, MediaDownloadKind::Thumb) - .unwrap() - .file_size, - None - ); - assert_eq!( - media_for_download(&media, MediaDownloadKind::Display) - .unwrap() - .file_size, - None - ); - let mut old = serde_json::to_value(&media).unwrap(); - old.as_object_mut().unwrap().remove("file_size"); - let restored: MediaRef = serde_json::from_value(old).unwrap(); - assert_eq!(restored.file_size, None); -} - -#[test] -fn media_for_download_thumb_skips_non_getfile_size() { - let mut media = MediaRef { - file_size: None, - kind: "video".into(), - cache_key: "doc:1".into(), - location: MediaLocation::Document { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: String::new(), - dc_id: 2, - mime_type: "video/mp4".into(), - }, - thumb_cache_key: Some("doc:1:thumb".into()), - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - thumb_location: Some(MediaLocation::Document { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "u".into(), - dc_id: 2, - mime_type: "video/mp4".into(), - }), - }; - assert!(media_for_download(&media, MediaDownloadKind::Thumb).is_err()); - assert!(media_for_download(&media, MediaDownloadKind::Full).is_ok()); - media.thumb_location = None; - assert!(media_for_download(&media, MediaDownloadKind::Thumb).is_err()); - let photo_only = MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "photo:1".into(), - location: MediaLocation::Photo { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - }, - thumb_cache_key: Some("photo:1".into()), - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - let as_thumb = - media_for_download(&photo_only, MediaDownloadKind::Thumb).expect("photo m is a thumb"); - assert!(matches!( - as_thumb.location, - MediaLocation::Photo { thumb_size, .. } if thumb_size == "m" - )); - media.thumb_location = Some(MediaLocation::Document { - id: 1, - access_hash: 2, - file_reference: vec![1], - thumb_size: "m".into(), - dc_id: 2, - mime_type: "video/mp4".into(), - }); - let thumb = media_for_download(&media, MediaDownloadKind::Thumb).expect("m thumb"); - assert!(matches!( - thumb.location, - MediaLocation::Document { - thumb_size, - .. - } if thumb_size == "m" - )); - assert!(is_file_id_invalid(&MtprotoError::Message( - "RPC 400: FILE_ID_INVALID".into(), - ))); - assert!(!is_file_id_invalid(&MtprotoError::Message( - "FILE_REFERENCE_EXPIRED".into(), - ))); -} - -#[test] -fn distinct_thumb_key_only_when_types_differ() { - assert_eq!(distinct_thumb_key("photo:1", "y", "m"), "photo:1:thumb"); - assert_eq!(distinct_thumb_key("photo:1", "y", "y"), "photo:1"); -} - -#[test] -fn still_photo_indexes_small_thumb_not_video_size() { - use tellers_mtproto::latest::api::{ - PhotoConstructor, PhotoSizeConstructor, Vector, VectorConstructor, - }; - let size = |ty: &str, w: i32| { - PhotoSize::PhotoSize(PhotoSizeConstructor { - type_: ty.into(), - w, - h: w, - size: w * w, - }) - }; - let photo = Photo::Photo(PhotoConstructor { - flags: 0, - has_stickers: None, - id: 88, - access_hash: 1, - file_reference: vec![1], - date: 0, - sizes: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![Box::new(size("m", 320)), Box::new(size("y", 1280))], - })), - video_sizes: None, - dc_id: 2, - }); - let indexed = media_ref_from_photo_with_thumbs(&photo, "photo:88".into()).expect("photo"); - assert_eq!(indexed.thumb_cache_key.as_deref(), Some("photo:88:thumb")); - assert!(matches!( - indexed.thumb_location, - Some(MediaLocation::Photo { ref thumb_size, .. }) if thumb_size == "m" - )); - assert!(matches!( - indexed.location, - MediaLocation::Photo { ref thumb_size, .. } if thumb_size == "y" - )); -} - -#[test] -fn profile_video_sizes_index_as_video_avatar() { - use tellers_mtproto::latest::api::{ - PhotoConstructor, PhotoSizeConstructor, Vector, VectorConstructor, VideoSize, - VideoSizeConstructor, - }; - let size = PhotoSize::PhotoSize(PhotoSizeConstructor { - type_: "m".into(), - w: 320, - h: 320, - size: 100, - }); - let video = VideoSize::VideoSize(VideoSizeConstructor { - flags: 0, - type_: "u".into(), - w: 800, - h: 800, - size: 4000, - video_start_ts: None, - }); - let photo = Photo::Photo(PhotoConstructor { - flags: PhotoConstructor::VIDEO_SIZES_FLAG, - has_stickers: None, - id: 88, - access_hash: 1, - file_reference: vec![1], - date: 0, - sizes: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![Box::new(size)], - })), - video_sizes: Some(Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![Box::new(video)], - }))), - dc_id: 2, - }); - let indexed = media_ref_from_photo(&photo, "avatar:42".into()).expect("video avatar"); - assert_eq!(indexed.kind, "video_avatar"); - assert_eq!(indexed.cache_key, "avatar:42:video"); - assert!(matches!( - indexed.location, - MediaLocation::Photo { ref thumb_size, .. } if thumb_size == "u" - )); -} - -#[test] -fn resolved_video_avatar_survives_still_peer_photo() { - let mut media = MediaIndex::default(); - let video = MediaRef { - file_size: None, - kind: "video_avatar".into(), - cache_key: "avatar:7:video".into(), - location: MediaLocation::Photo { - id: 99, - access_hash: 1, - file_reference: vec![1], - thumb_size: "u".into(), - dc_id: 2, - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - }; - assert_eq!(index_avatar(&mut media, 7, video.clone()), "avatar:7:video"); - let still = media_ref_peer_photo( - crate::peers::PeerKind::User, - 7, - 1, - 99, - 2, - "avatar:7".into(), - false, - None, - ); - assert_eq!(index_avatar(&mut media, 7, still), "avatar:7:video"); - assert!(is_resolved_video_avatar(media.get(&(7, 0)).unwrap())); - let placeholder = media_ref_peer_photo( - crate::peers::PeerKind::User, - 7, - 1, - 99, - 2, - "avatar:7:video".into(), - true, - None, - ); - assert!(needs_video_avatar_upgrade(&placeholder)); - assert_eq!(index_avatar(&mut media, 7, placeholder), "avatar:7:video"); - assert!(is_resolved_video_avatar(media.get(&(7, 0)).unwrap())); -} - -#[test] -fn peer_photo_with_stripped_thumb() { - let raw_stripped = [0x01, 0x08, 0x08, 0x28, 0x00]; - let photo = media_ref_peer_photo( - crate::peers::PeerKind::User, - 7, - 1, - 99, - 2, - "avatar:7".into(), - false, - Some(&raw_stripped), - ); - assert_eq!(photo.thumb_cache_key, Some("avatar:7:thumb".into())); - assert!( - matches!(photo.thumb_location, Some(MediaLocation::Inline { extension, .. }) if extension == "jpg") - ); -} diff --git a/native/mtproto-rs/tests/unit/media_wallpaper_rpc_tests.rs b/native/mtproto-rs/tests/unit/media_wallpaper_rpc_tests.rs deleted file mode 100644 index 3428a087c..000000000 --- a/native/mtproto-rs/tests/unit/media_wallpaper_rpc_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{WallPaperNoFileConstructor, WallPaperSettingsConstructor}; - -#[test] -fn not_modified_preserves_requested_hash() { - let result = map_catalog( - AccountWallPapers::AccountWallPapersNotModified( - tellers_mtproto::latest::api::AccountWallPapersNotModifiedConstructor {}, - ), - 42, - ); - assert!(result.not_modified); - assert_eq!(result.hash, 42); - assert!(result.wallpapers.is_empty()); -} - -#[test] -fn no_file_preserves_black_gradient_and_negative_intensity() { - let wallpaper = WallPaper::WallPaperNoFile(WallPaperNoFileConstructor { - id: 7, - flags: 4, - default: None, - dark: None, - settings: Some(Box::new(WallPaperSettings::WallPaperSettings( - WallPaperSettingsConstructor { - flags: 25, - blur: None, - motion: None, - background_color: Some(0), - second_background_color: Some(0xffffff), - third_background_color: None, - fourth_background_color: None, - intensity: Some(-40), - rotation: Some(135), - emoticon: None, - }, - ))), - }); - let result = map_wallpaper(&wallpaper).unwrap(); - assert_eq!(result.colors, vec![0, 0xffffff]); - assert_eq!(result.intensity, Some(-40)); - assert_eq!(result.rotation, 135); - assert_eq!(result.document_id, None); -} diff --git a/native/mtproto-rs/tests/unit/messages_checklist_creation_tests.rs b/native/mtproto-rs/tests/unit/messages_checklist_creation_tests.rs deleted file mode 100644 index 7429725bd..000000000 --- a/native/mtproto-rs/tests/unit/messages_checklist_creation_tests.rs +++ /dev/null @@ -1,56 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn checklist_creation_wire_keeps_stable_items_and_independent_permissions_with_bounds() { - let mut spec = ChecklistSendSpec { - title: "Synthetic list".into(), - items: vec!["First".into(), "Second".into()], - others_can_append: false, - others_can_complete: false, - }; - for append in [false, true] { - for complete in [false, true] { - spec.others_can_append = append; - spec.others_can_complete = complete; - let InputMedia::InputMediaTodo(input) = checklist_media(&spec).unwrap() else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(&input).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let InputMedia::InputMediaTodo(input) = InputMedia::decode(&mut decoder).unwrap() - else { - panic!() - }; - decoder.finish().unwrap(); - let TodoList::TodoList(todo) = *input.todo; - assert_eq!(todo.others_can_append.is_some(), append); - assert_eq!(todo.others_can_complete.is_some(), complete); - let TextWithEntities::TextWithEntities(title) = *todo.title; - assert_eq!(title.text, spec.title); - let items = crate::peers::vector_boxed_items(&todo.list) - .map(|item| { - let TodoItem::TodoItem(item) = item; - let TextWithEntities::TextWithEntities(title) = item.title.as_ref(); - (item.id, title.text.clone()) - }) - .collect::>(); - assert_eq!(items, vec![(1, "First".into()), (2, "Second".into())]); - } - } - spec.items = vec!["Valid".into(); 30]; - assert!(checklist_media(&spec).is_ok()); - spec.items.push("Extra".into()); - assert!(checklist_media(&spec).is_err()); - spec.items.clear(); - assert!(checklist_media(&spec).is_err()); - spec.items = vec![" ".into()]; - assert!(checklist_media(&spec).is_err()); - spec.items = vec!["😀".repeat(33)]; - assert!(checklist_media(&spec).is_err()); - spec.items = vec!["Valid".into()]; - spec.title = "😀".repeat(17); - assert!(checklist_media(&spec).is_err()); - spec.title = " ".into(); - assert!(checklist_media(&spec).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/messages_contact_send_tests.rs b/native/mtproto-rs/tests/unit/messages_contact_send_tests.rs deleted file mode 100644 index f5cd6afeb..000000000 --- a/native/mtproto-rs/tests/unit/messages_contact_send_tests.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn synthetic_contact_wire_preserves_card_and_rejects_invalid_configuration() { - let mut spec = ContactSendSpec { - phone: "+15555550123".into(), - first_name: "Synthetic".into(), - last_name: "Contact".into(), - vcard: "BEGIN:VCARD\nVERSION:3.0\nFN:Synthetic Contact\nEND:VCARD".into(), - }; - let InputMedia::InputMediaContact(input) = contact_media(&spec).unwrap() else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(&input).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let InputMedia::InputMediaContact(input) = InputMedia::decode(&mut decoder).unwrap() else { - panic!() - }; - decoder.finish().unwrap(); - assert_eq!(input.phone_number, spec.phone); - assert_eq!(input.first_name, spec.first_name); - assert_eq!(input.last_name, spec.last_name); - assert_eq!(input.vcard, spec.vcard); - spec.phone = "invalid".into(); - assert!(contact_media(&spec).is_err()); - spec.phone = "+15555550123".into(); - spec.first_name = " ".into(); - assert!(contact_media(&spec).is_err()); - spec.first_name = "Synthetic".into(); - spec.vcard = "x".repeat(65537); - assert!(contact_media(&spec).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/messages_dice_tests.rs b/native/mtproto-rs/tests/unit/messages_dice_tests.rs deleted file mode 100644 index 89e500bd6..000000000 --- a/native/mtproto-rs/tests/unit/messages_dice_tests.rs +++ /dev/null @@ -1,69 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn dice_wire_identity_options_and_authoritative_payload_contract() { - let peers = [( - 7, - CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - let options = SendOptions { - silent: true, - no_webpage: false, - schedule_date: Some(0x7fff_fffe), - }; - for emoji in ["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"] { - let request = dice_request(&peers, 7, emoji, 8, 9, 123, &options).unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSendMediaRequest::ID); - let request = MessagesSendMediaRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(request.random_id, 123); - assert!(request.silent.is_some()); - assert_eq!(request.schedule_date, Some(0x7fff_fffe)); - let InputMedia::InputMediaDice(media) = *request.media else { - panic!() - }; - assert_eq!(media.emoticon, emoji); - let InputReplyTo::InputReplyToMessage(reply) = *request.reply_to.unwrap() else { - panic!() - }; - assert_eq!(reply.reply_to_msg_id, 8); - assert_eq!(reply.top_msg_id, Some(9)); - } - assert!(dice_request(&peers, 7, "🎲", 0, 0, 0, &options).is_err()); - assert!(dice_request(&peers, 7, "text", 0, 0, 123, &options).is_err()); - assert!(valid_dice_payload( - Some("dice"), - Some(r#"{"e":"🎲","v":4}"#), - "🎲", - false - )); - assert!(!valid_dice_payload( - Some("dice"), - Some(r#"{"e":"🎲","v":0}"#), - "🎲", - false - )); - assert!(valid_dice_payload( - Some("dice"), - Some(r#"{"e":"🎲","v":0}"#), - "🎲", - true - )); - assert!(!valid_dice_payload( - Some("dice"), - Some(r#"{"e":"🎯","v":4}"#), - "🎲", - false - )); - assert!(!valid_dice_payload(None, None, "🎲", false)); -} diff --git a/native/mtproto-rs/tests/unit/messages_entities_tests.rs b/native/mtproto-rs/tests/unit/messages_entities_tests.rs deleted file mode 100644 index 3fdc1e01e..000000000 --- a/native/mtproto-rs/tests/unit/messages_entities_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; -use crate::peers::{CachedPeer, PeerKind}; -use tellers_mtproto::codec::{Decoder, Encoder, Limits, TlDecode, TlEncode}; - -const MENTION: &str = r#"[{"kind":"mention_name","offset":3,"length":5,"url":"42"}]"#; - -fn cache() -> crate::HashMap { - [( - 42, - CachedPeer { - kind: PeerKind::User, - id: 42, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect() -} - -fn encoded(value: &impl TlEncode) -> Vec { - let mut encoder = Encoder::new(); - value.encode(&mut encoder).unwrap(); - encoder.into_bytes() -} - -#[test] -fn input_mentions_encode_input_user_and_preserve_utf16_offsets() { - let (_, entities) = input_entities_from_json(Some(MENTION), &cache()).unwrap(); - let values = crate::peers::vector_items(entities.as_ref().unwrap()); - let bytes = encoded(&values[0]); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!( - decoder.read_u32().unwrap(), - tl::InputMessageEntityMentionNameConstructor::ID - ); - let input = tl::InputMessageEntityMentionNameConstructor::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!((input.offset, input.length), (3, 5)); - let tl::InputUser::InputUser(user) = *input.user_id else { - panic!("full input user required") - }; - assert_eq!((user.user_id, user.access_hash), (42, 99)); - let (_, output) = entities_from_json(Some(MENTION)).unwrap(); - assert!(matches!( - crate::peers::vector_items(output.as_ref().unwrap())[0].as_ref(), - MessageEntity::MessageEntityMentionName(_) - )); -} - -#[test] -fn unresolved_min_zero_hash_nonuser_and_mismatched_mentions_fail_closed() { - assert!(input_entities_from_json(Some(MENTION), &crate::HashMap::default()).is_err()); - for (kind, id, access_hash, min_hash) in [ - (PeerKind::User, 42, 99, true), - (PeerKind::User, 42, 0, false), - (PeerKind::Chat, 42, 0, false), - (PeerKind::Channel, 42, 99, false), - (PeerKind::User, 43, 99, false), - ] { - let peers = [( - 42, - CachedPeer { - kind, - id, - access_hash, - min_hash, - }, - )] - .into_iter() - .collect(); - assert!(input_entities_from_json(Some(MENTION), &peers).is_err()); - } -} - -#[test] -fn other_entities_and_empty_payloads_do_not_require_peer_cache() { - let json = r#"[{"kind":"bold","offset":0,"length":2},{"kind":"blockquote","offset":3,"length":5,"url":"collapsed"}]"#; - let (_, input) = input_entities_from_json(Some(json), &crate::HashMap::default()).unwrap(); - let (_, output) = entities_from_json(Some(json)).unwrap(); - assert_eq!( - encoded(input.as_ref().unwrap()), - encoded(output.as_ref().unwrap()) - ); - assert!( - input_entities_from_json(None, &cache()) - .unwrap() - .1 - .is_none() - ); -} diff --git a/native/mtproto-rs/tests/unit/messages_poll_creation_tests.rs b/native/mtproto-rs/tests/unit/messages_poll_creation_tests.rs deleted file mode 100644 index 6aef7da28..000000000 --- a/native/mtproto-rs/tests/unit/messages_poll_creation_tests.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn poll_creation_wire_preserves_anonymity_options_and_quiz_solution_with_validation() { - let mut spec = PollSendSpec { - question: "Question".into(), - answers: vec!["First".into(), "Second".into()], - anonymous: false, - multiple_choice: false, - quiz: true, - correct_answer: Some(1), - solution: Some("Explanation".into()), - }; - let InputMedia::InputMediaPoll(input) = poll_media(&spec).unwrap() else { - panic!() - }; - let bytes = crate::rpc::encode_boxed_bytes(&input).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let InputMedia::InputMediaPoll(decoded) = InputMedia::decode(&mut decoder).unwrap() else { - panic!() - }; - decoder.finish().unwrap(); - let Poll::Poll(poll) = *decoded.poll; - assert!(poll.public_voters.is_some() && poll.quiz.is_some() && poll.multiple_choice.is_none()); - assert!(poll.creator.is_none()); - assert_eq!(poll.id, 0); - let options = crate::peers::vector_boxed_items(&poll.answers) - .map(|a| { - let PollAnswer::PollAnswer(a) = a else { - panic!("unexpected input answer") - }; - a.option.clone() - }) - .collect::>(); - assert_eq!(options, vec![vec![b'0'], vec![b'1']]); - let Vector::Vector(correct) = *decoded.correct_answers.unwrap(); - assert_eq!(correct.field_1, vec![1]); - assert_eq!(decoded.solution.as_deref(), Some("Explanation")); - assert!(decoded.solution_entities.is_some()); - spec.multiple_choice = true; - assert!(poll_media(&spec).is_err()); - spec.multiple_choice = false; - spec.correct_answer = Some(2); - assert!(poll_media(&spec).is_err()); - spec.quiz = false; - spec.correct_answer = None; - assert!(poll_media(&spec).is_err()); - spec.solution = None; - spec.anonymous = true; - spec.multiple_choice = true; - let InputMedia::InputMediaPoll(input) = poll_media(&spec).unwrap() else { - panic!() - }; - let Poll::Poll(poll) = *input.poll; - assert!(poll.public_voters.is_none() && poll.multiple_choice.is_some() && poll.quiz.is_none()); - spec.answers[0] = " ".into(); - assert!(poll_media(&spec).is_err()); - spec.answers[0] = "😀".repeat(51); - assert!(poll_media(&spec).is_err()); - spec.answers[0] = "First".into(); - spec.question = " ".into(); - assert!(poll_media(&spec).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/messages_read_receipts_rpc_tests.rs b/native/mtproto-rs/tests/unit/messages_read_receipts_rpc_tests.rs deleted file mode 100644 index 67cd270bb..000000000 --- a/native/mtproto-rs/tests/unit/messages_read_receipts_rpc_tests.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - JsonNumberConstructor, JsonObjectConstructor, JsonObjectValueConstructor, - JsonStringConstructor, VectorConstructor, -}; - -fn object(entries: Vec<(&str, JsonValue)>) -> JsonValue { - JsonValue::JsonObject(JsonObjectConstructor { - value: Box::new(Vector::Vector(VectorConstructor { - field_0: 0x1cb5_c415, - field_1: entries - .into_iter() - .map(|(key, value)| { - Box::new(JsonObjectValue::JsonObjectValue( - JsonObjectValueConstructor { - key: key.to_string(), - value: Box::new(value), - }, - )) - }) - .collect(), - })), - }) -} - -fn number(value: f64) -> JsonValue { - JsonValue::JsonNumber(JsonNumberConstructor { value }) -} - -fn text(value: &str) -> JsonValue { - JsonValue::JsonString(JsonStringConstructor { - value: value.to_string(), - }) -} - -#[test] -fn reads_thresholds_from_config_object() { - let mut out = default_config(false); - collect_read_receipt_config( - &object(vec![ - ("chat_read_mark_size_threshold", number(50.0)), - ("chat_read_mark_expire_period", number(3600.0)), - ("pm_read_date_expire_period", text("ignored")), - ]), - &mut out, - ); - assert_eq!(out.chat_read_mark_size_threshold, 50); - assert_eq!(out.chat_read_mark_expire_period, 3600); - assert_eq!( - out.pm_read_date_expire_period, - DEFAULT_PM_READ_DATE_EXPIRE_PERIOD - ); - assert!(out.from_server); -} - -#[test] -fn ignores_non_object_config() { - let mut out = default_config(false); - collect_read_receipt_config(&number(1.0), &mut out); - assert!(!out.from_server); - assert_eq!( - out.chat_read_mark_size_threshold, - DEFAULT_CHAT_READ_MARK_SIZE_THRESHOLD - ); -} - -#[test] -fn known_errors_are_reported_not_raised() { - assert_eq!( - known_error(&MtprotoError::Message("RPC 400: MSG_TOO_OLD".into())).as_deref(), - Some("MSG_TOO_OLD") - ); - assert_eq!( - known_error(&MtprotoError::Message( - "RPC 403: USER_PRIVACY_RESTRICTED".into() - )) - .as_deref(), - Some("PRIVACY_RESTRICTED") - ); - assert!(known_error(&MtprotoError::Message("connection reset".into())).is_none()); -} diff --git a/native/mtproto-rs/tests/unit/messages_rpc_tests.rs b/native/mtproto-rs/tests/unit/messages_rpc_tests.rs deleted file mode 100644 index bd2a53860..000000000 --- a/native/mtproto-rs/tests/unit/messages_rpc_tests.rs +++ /dev/null @@ -1,317 +0,0 @@ -use super::{PHOTO_MAX, PHOTO_PART}; - -#[test] -fn retained_location_sends_preserve_random_identity_thread_and_options() { - use crate::messages::location_send::location_send_request; - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - use tellers_mtproto::latest::api::*; - let peers = [( - 7, - crate::peers::CachedPeer { - kind: crate::peers::PeerKind::User, - id: 7, - access_hash: 99, - min_hash: false, - }, - )] - .into_iter() - .collect(); - let mut spec = crate::LocationSendSpec { - latitude: -90.0, - longitude: 180.0, - live_period: 0, - heading: None, - }; - let mut options = crate::SendOptions { - silent: true, - no_webpage: false, - schedule_date: Some(crate::client::scheduled::SCHEDULE_WHEN_ONLINE), - }; - for live in [false, true] { - if live { - spec.live_period = 900; - spec.heading = Some(360); - options.schedule_date = None; - } - let request = location_send_request(&peers, 7, &spec, 42, 99, 123, &options).unwrap(); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSendMediaRequest::ID); - let row = MessagesSendMediaRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(row.random_id, 123); - assert!(row.silent.is_some()); - assert_eq!(row.schedule_date, options.schedule_date); - let InputReplyTo::InputReplyToMessage(reply) = *row.reply_to.unwrap() else { - panic!() - }; - assert_eq!(reply.reply_to_msg_id, 42); - assert_eq!(reply.top_msg_id, Some(99)); - if live { - let InputMedia::InputMediaGeoLive(media) = *row.media else { - panic!() - }; - assert_eq!(media.period, Some(900)); - assert_eq!(media.heading, Some(360)); - } else { - assert!(matches!(*row.media, InputMedia::InputMediaGeoPoint(_))); - } - } - assert!(location_send_request(&peers, 7, &spec, 0, 0, 0, &options).is_err()); - options.schedule_date = Some(crate::client::scheduled::SCHEDULE_WHEN_ONLINE); - assert!(location_send_request(&peers, 7, &spec, 0, 0, 123, &options).is_err()); - options.schedule_date = None; - for (lat, long, period, heading) in [ - (f64::NAN, 0.0, 900, None), - (0.0, 181.0, 900, None), - (0.0, 0.0, -1, None), - (0.0, 0.0, 59, None), - (0.0, 0.0, 86401, None), - (0.0, 0.0, 900, Some(0)), - (0.0, 0.0, 0, Some(1)), - ] { - spec = crate::LocationSendSpec { - latitude: lat, - longitude: long, - live_period: period, - heading, - }; - assert!(location_send_request(&peers, 7, &spec, 0, 0, 123, &options).is_err()); - } -} - -#[test] -fn common_detected_entities_survive_json_round_trips() { - let kinds = [ - "url", - "mention", - "hashtag", - "bot_command", - "email", - "phone", - "cashtag", - "bank_card", - ]; - let json = serde_json::to_string( - &kinds.map(|kind| serde_json::json!({"kind": kind, "offset": 0, "length": 5})), - ) - .unwrap(); - let (_, entities) = crate::messages::entities_from_json(Some(&json)).unwrap(); - assert_eq!( - crate::peers::vector_items(entities.as_deref().unwrap()).len(), - kinds.len() - ); - let mapped = crate::dialogs::entities_to_json(entities.as_deref()); - let (_, restored) = crate::messages::entities_from_json(mapped.as_deref()).unwrap(); - assert_eq!(entities, restored); -} - -#[test] -fn channel_read_uses_channels_read_history() { - use tellers_mtproto::latest::api::{ChannelsReadHistoryRequest, MessagesReadHistoryRequest}; - assert_eq!(ChannelsReadHistoryRequest::NAME, "channels.readHistory"); - assert_ne!( - ChannelsReadHistoryRequest::ID, - MessagesReadHistoryRequest::ID - ); -} - -#[test] -fn unread_mention_and_reaction_methods_match_layer() { - use tellers_mtproto::latest::api::{ - MessagesGetUnreadMentionsRequest, MessagesGetUnreadReactionsRequest, - MessagesReadMentionsRequest, MessagesReadReactionsRequest, - }; - assert_eq!( - MessagesGetUnreadMentionsRequest::NAME, - "messages.getUnreadMentions" - ); - assert_eq!(MessagesReadMentionsRequest::NAME, "messages.readMentions"); - assert_eq!( - MessagesGetUnreadReactionsRequest::NAME, - "messages.getUnreadReactions" - ); - assert_eq!(MessagesReadReactionsRequest::NAME, "messages.readReactions"); -} - -#[test] -fn read_message_contents_methods_match_layer() { - use tellers_mtproto::latest::api::{ - ChannelsReadMessageContentsRequest, MessagesReadMessageContentsRequest, - }; - assert_eq!( - MessagesReadMessageContentsRequest::NAME, - "messages.readMessageContents" - ); - assert_eq!( - ChannelsReadMessageContentsRequest::NAME, - "channels.readMessageContents" - ); -} - -#[test] -fn entities_json_maps_bold() { - let json = r#"[{"kind":"bold","offset":0,"length":4}]"#; - let (flag, entities) = super::entities_from_json(Some(json)).unwrap(); - assert_eq!( - flag, - tellers_mtproto::latest::api::MessagesSendMessageRequest::ENTITIES_FLAG - ); - assert!(entities.is_some()); - assert_eq!(super::entities_from_json(None).unwrap().0, 0); -} - -#[test] -fn entities_json_maps_custom_emoji() { - let json = r#"[{"kind":"custom_emoji","offset":0,"length":2,"url":"42"}]"#; - let (_, entities) = super::entities_from_json(Some(json)).unwrap(); - let items = entities.expect("entities"); - match items.as_ref() { - tellers_mtproto::latest::api::Vector::Vector(v) => { - assert!(matches!( - v.field_1[0].as_ref(), - tellers_mtproto::latest::api::MessageEntity::MessageEntityCustomEmoji(_) - )); - } - } -} - -#[test] -fn photo_part_size_matches_file_api() { - assert_eq!(PHOTO_PART % 1024, 0); - assert_eq!((512 * 1024) % PHOTO_PART, 0); - assert_eq!(PHOTO_MAX, 10 * 1024 * 1024); -} - -#[test] -fn forum_topic_reply_sets_top_msg_id() { - use tellers_mtproto::latest::api::InputReplyTo; - let (flag, reply) = super::input_reply_to_thread(9, 42); - assert!(flag != 0); - let InputReplyTo::InputReplyToMessage(header) = *reply.expect("reply") else { - panic!("expected inputReplyToMessage"); - }; - assert_eq!(header.reply_to_msg_id, 9); - assert_eq!(header.top_msg_id, Some(42)); -} - -#[test] -fn send_to_topic_uses_topic_as_reply() { - use tellers_mtproto::latest::api::InputReplyTo; - let (_, reply) = super::input_reply_to_thread(42, 0); - let InputReplyTo::InputReplyToMessage(header) = *reply.expect("reply") else { - panic!("expected inputReplyToMessage"); - }; - assert_eq!(header.reply_to_msg_id, 42); - assert_eq!(header.top_msg_id, None); -} - -#[test] -fn pinned_filter_matches_layer_223() { - use tellers_mtproto::latest::api::InputMessagesFilterPinnedConstructor; - assert_eq!(InputMessagesFilterPinnedConstructor::ID, 0x1bb0_0451); - assert_eq!( - InputMessagesFilterPinnedConstructor::NAME, - "inputMessagesFilterPinned" - ); -} - -#[test] -fn search_limit_clamps_to_api_max() { - assert_eq!(super::clamp_search_limit(0), 1); - assert_eq!(super::clamp_search_limit(40), 40); - assert_eq!(super::clamp_search_limit(100), 100); - assert_eq!(super::clamp_search_limit(101), 100); -} - -#[test] -fn named_search_filters_match_layer() { - use tellers_mtproto::latest::api::MessagesFilter; - assert!(matches!( - super::messages_filter("photo_video"), - MessagesFilter::InputMessagesFilterPhotoVideo(_) - )); - assert!(matches!( - super::messages_filter("document"), - MessagesFilter::InputMessagesFilterDocument(_) - )); - assert!(matches!( - super::messages_filter("url"), - MessagesFilter::InputMessagesFilterUrl(_) - )); - assert!(matches!( - super::messages_filter("gif"), - MessagesFilter::InputMessagesFilterGif(_) - )); - assert!(matches!( - super::messages_filter("voice"), - MessagesFilter::InputMessagesFilterVoice(_) - )); - assert!(matches!( - super::messages_filter("music"), - MessagesFilter::InputMessagesFilterMusic(_) - )); - assert!(matches!( - super::messages_filter("photos"), - MessagesFilter::InputMessagesFilterPhotos(_) - )); - assert!(matches!( - super::messages_filter("videos"), - MessagesFilter::InputMessagesFilterVideo(_) - )); - assert!(matches!( - super::messages_filter("chat_photos"), - MessagesFilter::InputMessagesFilterChatPhotos(_) - )); - assert!(matches!( - super::messages_filter("pinned"), - MessagesFilter::InputMessagesFilterPinned(_) - )); - assert!(matches!( - super::messages_filter("nope"), - MessagesFilter::InputMessagesFilterEmpty(_) - )); -} - -#[test] -fn filter_keys_round_trip_profile_tabs() { - for name in [ - "photo_video", - "document", - "url", - "gif", - "voice", - "music", - "photos", - "videos", - "chat_photos", - ] { - let filter = super::messages_filter(name); - assert_eq!(super::messages_filter_key(&filter), Some(name)); - } - assert_eq!( - super::messages_filter_key(&super::messages_filter("nope")), - None - ); - assert_eq!( - super::messages_filter_key(&super::messages_filter("pinned")), - Some("pinned") - ); -} - -#[test] -fn forward_requires_known_peers() { - use crate::{HashMap, HashMapExt}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - let peers = HashMap::new(); - let mut media = crate::media::MediaIndex::default(); - let err = super::forward_messages(&mut snapshot, 1, &peers, &mut media, 1, vec![7], 2, false) - .expect_err("unknown from peer"); - match err { - crate::MtprotoError::Message(message) => { - assert!(message.contains("unknown peer 1")); - } - other => panic!("unexpected {other:?}"), - } -} diff --git a/native/mtproto-rs/tests/unit/messages_search_rpc_tests.rs b/native/mtproto-rs/tests/unit/messages_search_rpc_tests.rs deleted file mode 100644 index a33bb5752..000000000 --- a/native/mtproto-rs/tests/unit/messages_search_rpc_tests.rs +++ /dev/null @@ -1,90 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - PeerChannelConstructor, PeerChatConstructor, PeerUserConstructor, -}; - -#[test] -fn people_are_user_peers() { - let user = Peer::PeerUser(PeerUserConstructor { user_id: 7 }); - let chat = Peer::PeerChat(PeerChatConstructor { chat_id: 3 }); - let channel = Peer::PeerChannel(PeerChannelConstructor { channel_id: 9 }); - assert!(is_people_peer(&user)); - assert!(!is_people_peer(&chat)); - assert!(!is_people_peer(&channel)); -} - -#[test] -fn empty_page_clears_offsets() { - let (rate, peer, id) = next_global_page(&[], 44); - assert_eq!((rate, peer, id), (0, 0, 0)); -} - -#[test] -fn next_page_uses_last_message_and_rate() { - let last = MessageDto { - media_preview_restricted: None, - chat_id: -1_000_000_000_042, - id: 88, - sender_id: None, - text: None, - date: 1, - edit_date: None, - outgoing: false, - media_kind: None, - media_cache_key: None, - thumb_cache_key: None, - media_duration: None, - media_width: None, - media_height: None, - reply_quote: None, - reply_quote_entities_json: None, - entities_json: None, - noforwards: false, - reply_to_msg_id: None, - reply_to_top_id: None, - fwd_from: None, - fwd_from_id: None, - fwd_date: None, - via_bot: None, - sender_name: None, - sender_emoji_status_document_id: None, - grouped_id: None, - file_name: None, - file_size: None, - supports_streaming: false, - reactions_json: None, - replies_count: 0, - discussion_peer_id: None, - reply_markup_json: None, - forum_topic: false, - sender_photo_cache_key: None, - }; - let (rate, peer, id) = next_global_page(&[last], 17); - assert_eq!(rate, 17); - assert_eq!(peer, -1_000_000_000_042); - assert_eq!(id, 88); -} - -#[test] -fn first_page_uses_empty_offset_peer() { - let peers = HashMap::new(); - let peer = offset_input_peer(&peers, 0).expect("empty"); - assert!(matches!(peer, InputPeer::InputPeerEmpty(_))); -} - -#[test] -fn empty_contacts_query_is_rejected() { - let err = contacts_search_query(" ").expect_err("blank"); - assert!(matches!(err, MtprotoError::Message(message) if message == "empty search query")); - assert_eq!(contacts_search_query(" ada ").expect("trim"), "ada"); -} - -#[test] -fn search_global_always_sends_folder_id() { - let (flags, folder) = search_global_folder(0); - assert_eq!(flags, MessagesSearchGlobalRequest::FOLDER_ID_FLAG); - assert_eq!(folder, Some(0)); - let (archive_flags, archive) = search_global_folder(1); - assert_eq!(archive_flags, MessagesSearchGlobalRequest::FOLDER_ID_FLAG); - assert_eq!(archive, Some(1)); -} diff --git a/native/mtproto-rs/tests/unit/messages_send_options_tests.rs b/native/mtproto-rs/tests/unit/messages_send_options_tests.rs deleted file mode 100644 index e8a29ca3a..000000000 --- a/native/mtproto-rs/tests/unit/messages_send_options_tests.rs +++ /dev/null @@ -1,475 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Encoder, Limits, TlDecode, TlEncode}; - -fn vector(items: Vec) -> Box> { - Box::new(Vector::Vector(VectorConstructor { - field_0: items.len() as u32, - field_1: items, - })) -} - -fn message() -> Message { - let mut encoder = Encoder::new(); - for value in [0u32, 0, 37] { - value.encode(&mut encoder).unwrap(); - } - Peer::PeerUser(PeerUserConstructor { user_id: 7 }) - .encode(&mut encoder) - .unwrap(); - 100i32.encode(&mut encoder).unwrap(); - String::from("caption").encode(&mut encoder).unwrap(); - let bytes = encoder.into_bytes(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let result = Message::Message(MessageConstructor::decode(&mut decoder).unwrap()); - decoder.finish().unwrap(); - result -} - -fn response(items: Vec>) -> Updates { - Updates::Updates(UpdatesConstructor { - updates: vector(items), - users: vector(Vec::new()), - chats: vector(Vec::new()), - date: 100, - seq: 1, - }) -} - -#[test] -fn album_options_encode_silent_and_schedule_independently() { - for silent in [false, true] { - for schedule_date in [None, Some(crate::client::scheduled::SCHEDULE_WHEN_ONLINE)] { - let mut request = MessagesSendMultiMediaRequest { - flags: 0, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - reply_to: None, - multi_media: vector(Vec::new()), - schedule_date: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - }; - SendOptions { - silent, - schedule_date, - no_webpage: true, - } - .apply_album(&mut request); - let mut encoded = Encoder::new(); - request.encode(&mut encoded).unwrap(); - let bytes = encoded.into_bytes(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - let decoded = MessagesSendMultiMediaRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.silent.is_some(), silent); - assert_eq!(decoded.schedule_date, schedule_date); - assert!(decoded.allow_paid_stars.is_none() && decoded.allow_paid_floodskip.is_none()); - } - } -} - -#[test] -fn album_results_bind_each_random_id_in_request_order_and_ignore_unrelated_updates() { - let mut updates = Vec::new(); - for (id, random) in [(38, Some(456)), (99, None), (37, Some(123))] { - if let Some(random_id) = random { - updates.push(Box::new(Update::UpdateMessageId( - UpdateMessageIdConstructor { id, random_id }, - ))); - } - let mut item = message(); - if let Message::Message(value) = &mut item { - value.id = id; - } - updates.push(Box::new(Update::UpdateNewMessage( - UpdateNewMessageConstructor { - message: Box::new(item), - pts: 1, - pts_count: 1, - }, - ))); - } - let result = album_send_results( - response(updates), - 7, - &[(123, "one"), (456, "two")], - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default(), - ) - .unwrap(); - let ids = result - .iter() - .map(|result| match result { - OutgoingMessageResult::Sent { message } => message.id, - _ => panic!("immediate result required"), - }) - .collect::>(); - assert_eq!(ids, vec![37, 38]); -} - -#[test] -fn album_results_reject_missing_duplicate_and_foreign_acknowledgments() { - for acknowledgments in [ - vec![(37, 123)], - vec![(37, 123), (38, 123)], - vec![(37, 123), (37, 456)], - vec![(37, 123), (38, 999)], - vec![(37, 123), (0, 456)], - ] { - let updates = acknowledgments - .into_iter() - .map(|(id, random_id)| { - Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id, - random_id, - })) - }) - .collect(); - assert!( - album_send_results( - response(updates), - 7, - &[(123, "one"), (456, "two")], - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default() - ) - .is_err() - ); - } - for requests in [vec![], vec![(0, "zero")], vec![(123, "one"), (123, "two")]] { - assert!( - album_send_results( - response(Vec::new()), - 7, - &requests, - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default() - ) - .is_err() - ); - } - let updates = vec![ - Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id: 37, - random_id: 123, - })), - Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id: 38, - random_id: 456, - })), - Box::new(Update::UpdateNewMessage(UpdateNewMessageConstructor { - message: Box::new(message()), - pts: 1, - pts_count: 1, - })), - ]; - assert!( - album_send_results( - response(updates), - 7, - &[(123, "one"), (456, "two")], - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default() - ) - .is_err() - ); -} - -#[test] -fn album_results_keep_scheduled_identity_and_reject_foreign_peers() { - let mut updates = Vec::new(); - for (id, random_id) in [(37, 123), (38, 456)] { - let mut item = message(); - if let Message::Message(value) = &mut item { - value.id = id; - } - updates.push(Box::new(Update::UpdateMessageId( - UpdateMessageIdConstructor { id, random_id }, - ))); - updates.push(Box::new(Update::UpdateNewScheduledMessage( - UpdateNewScheduledMessageConstructor { - message: Box::new(item), - }, - ))); - } - let results = album_send_results( - response(updates.clone()), - 7, - &[(123, "one"), (456, "two")], - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default(), - ) - .unwrap(); - for (result, id) in results.into_iter().zip([37, 38]) { - let OutgoingMessageResult::Scheduled { message } = result else { - panic!("scheduled result required") - }; - assert_eq!(message.message.id, id); - assert_eq!(message.message.chat_id, 7); - } - if let Update::UpdateNewScheduledMessage(value) = updates[3].as_mut() { - if let Message::Message(item) = value.message.as_mut() { - item.peer_id = Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 9 })); - } - } - assert!( - album_send_results( - response(updates), - 7, - &[(123, "one"), (456, "two")], - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default() - ) - .is_err() - ); -} - -#[test] -fn immediate_result_uses_acknowledged_id_and_rejects_cross_namespace_ambiguity() { - let mut unrelated = message(); - if let Message::Message(value) = &mut unrelated { - value.id = 38; - } - let mut items = vec![ - Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id: 37, - random_id: 123, - })), - Box::new(Update::UpdateNewMessage(UpdateNewMessageConstructor { - message: Box::new(unrelated), - pts: 1, - pts_count: 1, - })), - Box::new(Update::UpdateNewMessage(UpdateNewMessageConstructor { - message: Box::new(message()), - pts: 2, - pts_count: 1, - })), - ]; - let mut media = crate::media::MediaIndex::default(); - let result = send_result( - response(items.clone()), - 7, - "caption", - 123, - &crate::HashMap::default(), - &mut media, - ) - .unwrap(); - let OutgoingMessageResult::Sent { message } = &result else { - panic!("immediate result required") - }; - assert_eq!(message.id, 37); - assert!(implicit_ack(&response(items.clone()), &result, 123).is_none()); - items.push(Box::new(Update::UpdateNewScheduledMessage( - UpdateNewScheduledMessageConstructor { - message: Box::new(self::message()), - }, - ))); - assert!( - send_result( - response(items), - 7, - "caption", - 123, - &crate::HashMap::default(), - &mut media - ) - .is_err() - ); -} - -#[test] -fn short_send_confirmation_produces_an_uncounted_random_id_acknowledgment() { - let updates = Updates::UpdateShortSentMessage(UpdateShortSentMessageConstructor { - flags: 0, - out: None, - id: 37, - pts: 12, - pts_count: 1, - date: 100, - media: None, - entities: None, - ttl_period: None, - }); - let result = send_result( - updates.clone(), - 7, - "caption", - 123, - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default(), - ) - .unwrap(); - let Updates::UpdateShort(value) = implicit_ack(&updates, &result, 123).unwrap() else { - panic!("uncounted ack required") - }; - assert_eq!(value.date, 100); - let Update::UpdateMessageId(ack) = *value.update else { - panic!("message-id ack required") - }; - assert_eq!((ack.id, ack.random_id), (37, 123)); -} - -#[test] -fn media_send_flags_preserve_schedule_silent_and_random_id() { - let mut request = MessagesSendMediaRequest { - flags: 0, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - reply_to: None, - media: Box::new(InputMedia::InputMediaEmpty(InputMediaEmptyConstructor {})), - message: "caption".into(), - random_id: 123, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - }; - SendOptions { - silent: true, - no_webpage: false, - schedule_date: Some(100), - } - .apply_media(&mut request); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSendMediaRequest::ID); - let decoded = MessagesSendMediaRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert!(decoded.silent.is_some()); - assert_eq!(decoded.schedule_date, Some(100)); - assert_eq!(decoded.random_id, 123); - assert!(decoded.clear_draft.is_none()); - assert!(decoded.allow_paid_stars.is_none()); -} - -#[test] -fn silent_and_schedule_options_roundtrip_independently_without_changing_random_id() { - for silent in [false, true] { - for date in [ - None, - Some(100), - Some(crate::scheduled::SCHEDULE_WHEN_ONLINE), - ] { - let options = SendOptions { - silent, - no_webpage: true, - schedule_date: date, - }; - let mut request = crate::messages::send_message_request( - 0, - Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - None, - "text".into(), - 123, - None, - ); - options.apply_text(&mut request); - let bytes = crate::rpc::encode_boxed_bytes(&request).unwrap(); - let mut decoder = Decoder::new(&bytes, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MessagesSendMessageRequest::ID); - let decoded = MessagesSendMessageRequest::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - assert_eq!(decoded.silent.is_some(), silent); - assert!(decoded.no_webpage.is_some()); - assert_eq!(decoded.schedule_date, date); - assert_eq!(decoded.random_id, 123); - assert!(decoded.clear_draft.is_none()); - assert!(decoded.allow_paid_stars.is_none()); - } - } -} - -#[test] -fn scheduled_send_requires_caller_owned_random_id_and_valid_date() { - assert!(SendOptions::default().validate(0).is_ok()); - for date in [0, -1, i32::MAX] { - assert!( - SendOptions { - silent: false, - no_webpage: false, - schedule_date: Some(date) - } - .validate(123) - .is_err() - ); - } - let options = SendOptions { - silent: true, - no_webpage: false, - schedule_date: Some(100), - }; - assert!(options.validate(0).is_err()); - assert!(options.validate(-123).is_ok()); -} - -#[test] -fn scheduled_result_requires_matching_ack_and_stays_out_of_normal_history() { - for random_id in [123, 456] { - let updates = Updates::Updates(UpdatesConstructor { - updates: vector(vec![ - Box::new(Update::UpdateMessageId(UpdateMessageIdConstructor { - id: 37, - random_id, - })), - Box::new(Update::UpdateNewScheduledMessage( - UpdateNewScheduledMessageConstructor { - message: Box::new(message()), - }, - )), - ]), - users: vector(Vec::new()), - chats: vector(Vec::new()), - date: 100, - seq: 1, - }); - let mut media = crate::media::MediaIndex::default(); - let result = send_result( - updates, - 7, - "caption", - 123, - &crate::HashMap::default(), - &mut media, - ); - if random_id != 123 { - assert!(result.is_err()); - continue; - } - let OutgoingMessageResult::Scheduled { message } = result.unwrap() else { - panic!("queue result required") - }; - assert_eq!((message.message.chat_id, message.message.id), (7, 37)); - } - assert!( - send_result( - Updates::UpdatesTooLong(UpdatesTooLongConstructor {}), - 7, - "caption", - 123, - &crate::HashMap::default(), - &mut crate::media::MediaIndex::default() - ) - .is_err() - ); -} diff --git a/native/mtproto-rs/tests/unit/messages_send_random_id_tests.rs b/native/mtproto-rs/tests/unit/messages_send_random_id_tests.rs deleted file mode 100644 index 52c597dea..000000000 --- a/native/mtproto-rs/tests/unit/messages_send_random_id_tests.rs +++ /dev/null @@ -1,32 +0,0 @@ -use super::outgoing_random_id; - -#[test] -fn pending_random_id_is_kept() { - assert_eq!(outgoing_random_id(42), 42); - assert_ne!(outgoing_random_id(0), 0); -} - -#[test] -fn pending_random_id_is_encoded_in_send_message() { - use tellers_mtproto::codec::{Boxed, Encoder}; - use tellers_mtproto::latest::api::{InputPeer, InputPeerSelfConstructor}; - let random_id = 0x0102_0304_0506_0708; - let request = super::send_message_request( - 0, - Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - None, - "hi".into(), - random_id, - None, - ); - assert_eq!(request.random_id, random_id); - let mut encoder = Encoder::new(); - request.encode_boxed(&mut encoder).expect("encode"); - let bytes = encoder.into_bytes(); - assert!( - bytes - .windows(8) - .any(|window| window == random_id.to_le_bytes()), - "sendMessage must carry the pending random_id" - ); -} diff --git a/native/mtproto-rs/tests/unit/messages_sticker_rpc_tests.rs b/native/mtproto-rs/tests/unit/messages_sticker_rpc_tests.rs deleted file mode 100644 index 103207951..000000000 --- a/native/mtproto-rs/tests/unit/messages_sticker_rpc_tests.rs +++ /dev/null @@ -1,74 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - MessagesAllStickersConstructor, MessagesAllStickersNotModifiedConstructor, Vector, - VectorConstructor, -}; - -fn sample_set() -> StickerSetConstructor { - StickerSetConstructor { - flags: 0, - archived: None, - official: None, - masks: None, - emojis: None, - text_color: None, - channel_emoji_status: None, - creator: None, - installed_date: None, - id: 9, - access_hash: 11, - title: "Cats".into(), - short_name: "cats".into(), - thumbs: None, - thumb_dc_id: None, - thumb_version: None, - thumb_document_id: Some(42), - count: 3, - hash: 0, - } -} - -#[test] -fn pack_from_set_keeps_access_hash_and_thumb() { - let dto = pack_from_set(&sample_set(), vec![42]); - assert_eq!(dto.id, 9); - assert_eq!(dto.access_hash, 11); - assert_eq!(dto.title, "Cats"); - assert_eq!(dto.preview_document_ids, vec![42]); - assert!(!dto.is_emoji); -} - -#[test] -fn catalog_maps_not_modified() { - let dto = catalog_from_all( - MessagesAllStickers::MessagesAllStickersNotModified( - MessagesAllStickersNotModifiedConstructor {}, - ), - 7, - ) - .expect("catalog"); - assert!(dto.not_modified); - assert_eq!(dto.hash, 7); - assert!(dto.sets.is_empty()); -} - -#[test] -fn catalog_maps_installed_sets() { - let set = StickerSet::StickerSet(sample_set()); - let dto = catalog_from_all( - MessagesAllStickers::MessagesAllStickers(MessagesAllStickersConstructor { - hash: 99, - sets: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(set)], - })), - }), - 0, - ) - .expect("catalog"); - assert!(!dto.not_modified); - assert_eq!(dto.hash, 99); - assert_eq!(dto.sets[0].id, 9); - assert_eq!(dto.sets[0].access_hash, 11); - assert_eq!(dto.sets[0].preview_document_ids, vec![42]); -} diff --git a/native/mtproto-rs/tests/unit/passkeys_tests.rs b/native/mtproto-rs/tests/unit/passkeys_tests.rs deleted file mode 100644 index 14d2a3c25..000000000 --- a/native/mtproto-rs/tests/unit/passkeys_tests.rs +++ /dev/null @@ -1,80 +0,0 @@ -use super::*; - -fn fixture() -> PasskeyCredentialDto { - PasskeyCredentialDto { - id: "credential".into(), - raw_id: "credential".into(), - client_data: - r#"{"type":"webauthn.get","origin":"https://telegram.org","challenge":"challenge"}"# - .into(), - authenticator_data: vec![0; 37], - signature: vec![1], - user_handle: "2:123".into(), - attestation_data: vec![], - } -} - -#[test] -fn login_credential_preserves_signed_client_data() { - let input = fixture(); - let original = input.client_data.clone(); - let InputPasskeyCredential::InputPasskeyCredentialPublicKey(result) = - credential(input, false).unwrap() - else { - panic!("wrong credential"); - }; - let InputPasskeyResponse::InputPasskeyResponseLogin(response) = *result.response else { - panic!("wrong response"); - }; - let DataJson::DataJson(data) = *response.client_data; - assert_eq!(data.data, original); - assert_eq!(response.user_handle, "2:123"); -} - -#[test] -fn rejects_wrong_origin_operation_and_incomplete_assertions() { - for data in [ - r#"{"type":"webauthn.get","origin":"https://example.org","challenge":"x"}"#, - r#"{"type":"webauthn.create","origin":"https://telegram.org","challenge":"x"}"#, - r#"{"type":"webauthn.get","origin":"https://telegram.org","challenge":""}"#, - "invalid json", - ] { - let mut input = fixture(); - input.client_data = data.into(); - assert!(credential(input, false).is_err()); - } - let mut input = fixture(); - input.authenticator_data.truncate(36); - assert!(credential(input, false).is_err()); - let mut input = fixture(); - input.signature.clear(); - assert!(credential(input, false).is_err()); -} - -#[test] -fn registration_requires_attestation() { - let mut input = fixture(); - input.client_data = input.client_data.replace("webauthn.get", "webauthn.create"); - assert!(credential(input.clone(), true).is_err()); - input.attestation_data = vec![1]; - assert!(credential(input, true).is_ok()); -} - -#[test] -fn login_identity_rejects_invalid_routes() { - assert_eq!( - login_identity("2:1234567890123").unwrap(), - (2, 1234567890123) - ); - for handle in [ - "", - "0:1", - "6:1", - "2:0", - "2:-1", - "2:1:3", - "2:9223372036854775808", - ] { - assert!(login_identity(handle).is_err()); - } -} diff --git a/native/mtproto-rs/tests/unit/peer_avatar_tests.rs b/native/mtproto-rs/tests/unit/peer_avatar_tests.rs deleted file mode 100644 index c1b05527a..000000000 --- a/native/mtproto-rs/tests/unit/peer_avatar_tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -use super::{PeerAvatarFetch, peer_avatar_fetch}; -use crate::HashMap; -use crate::peers::{CachedPeer, PeerKind}; - -fn empty_peers() -> HashMap { - HashMap::default() -} - -fn peer(kind: PeerKind, id: i64, access_hash: i64, min_hash: bool) -> CachedPeer { - CachedPeer { - kind, - id, - access_hash, - min_hash, - } -} - -#[test] -fn arbitrary_positive_id_is_not_a_profile_fetch() { - assert_eq!( - peer_avatar_fetch(&empty_peers(), Some(7), 99), - PeerAvatarFetch::Skip - ); -} - -#[test] -fn self_basic_group_and_usable_peers_fetch_a_profile() { - assert_eq!( - peer_avatar_fetch(&empty_peers(), Some(7), 0), - PeerAvatarFetch::Profile - ); - assert_eq!( - peer_avatar_fetch(&empty_peers(), Some(7), 7), - PeerAvatarFetch::Profile - ); - assert_eq!( - peer_avatar_fetch(&empty_peers(), None, -100), - PeerAvatarFetch::Profile - ); - let mut peers = empty_peers(); - peers.insert(4, peer(PeerKind::User, 4, 9, false)); - assert_eq!(peer_avatar_fetch(&peers, None, 4), PeerAvatarFetch::Profile); - let channel_id = -1_000_000_000_042; - peers.insert(channel_id, peer(PeerKind::Channel, 42, 3, false)); - assert_eq!( - peer_avatar_fetch(&peers, None, channel_id), - PeerAvatarFetch::Profile - ); -} - -#[test] -fn known_unusable_hash_refreshes_dialogs_and_min_author_stays_unresolved() { - let mut peers = empty_peers(); - peers.insert(4, peer(PeerKind::User, 4, 0, true)); - assert_eq!(peer_avatar_fetch(&peers, None, 4), PeerAvatarFetch::Dialogs); - let channel_id = -1_000_000_000_042; - peers.insert(channel_id, peer(PeerKind::Channel, 42, 0, true)); - assert_eq!( - peer_avatar_fetch(&peers, None, channel_id), - PeerAvatarFetch::Dialogs - ); - assert_eq!( - peer_avatar_fetch(&empty_peers(), None, channel_id), - PeerAvatarFetch::Skip - ); -} diff --git a/native/mtproto-rs/tests/unit/peers_tests.rs b/native/mtproto-rs/tests/unit/peers_tests.rs deleted file mode 100644 index 4aa2504bd..000000000 --- a/native/mtproto-rs/tests/unit/peers_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -use super::*; -use crate::{HashMap, HashMapExt}; - -#[test] -fn min_user_does_not_replace_full_hash() { - let mut peers = HashMap::new(); - upsert_cached_peer(&mut peers, 7, PeerKind::User, 7, 99, false); - upsert_cached_peer(&mut peers, 7, PeerKind::User, 7, 1, true); - let stored = peers.get(&7).expect("peer"); - assert_eq!(stored.access_hash, 99); - assert!(!stored.min_hash); -} - -#[test] -fn min_user_is_not_cached_without_full() { - let mut peers = HashMap::new(); - upsert_cached_peer(&mut peers, 7, PeerKind::User, 7, 1, true); - assert!(peers.get(&7).is_none()); -} - -#[test] -fn full_user_replaces_zero_hash() { - let mut peers = HashMap::new(); - peers.insert( - 7, - CachedPeer { - kind: PeerKind::User, - id: 7, - access_hash: 0, - min_hash: false, - }, - ); - upsert_cached_peer(&mut peers, 7, PeerKind::User, 7, 55, false); - assert_eq!(peers.get(&7).unwrap().access_hash, 55); - assert!(has_usable_access_hash(peers.get(&7).unwrap())); -} - -#[test] -fn zero_channel_hash_is_not_usable_input_peer() { - let mut peers = HashMap::new(); - peers.insert( - -1_000_000_000_042, - CachedPeer { - kind: PeerKind::Channel, - id: 42, - access_hash: 0, - min_hash: false, - }, - ); - assert!(usable_cached_peer(&peers, -1_000_000_000_042).is_none()); - assert!(require_usable_peer(&peers, -1_000_000_000_042).is_err()); -} - -#[test] -fn basic_chat_is_usable_without_hash() { - let mut peers = HashMap::new(); - upsert_cached_peer(&mut peers, -5, PeerKind::Chat, 5, 0, false); - assert!(has_usable_access_hash(peers.get(&-5).unwrap())); -} diff --git a/native/mtproto-rs/tests/unit/perf_tests.rs b/native/mtproto-rs/tests/unit/perf_tests.rs deleted file mode 100644 index f3d3a0781..000000000 --- a/native/mtproto-rs/tests/unit/perf_tests.rs +++ /dev/null @@ -1,46 +0,0 @@ -use super::*; - -/// One body: the recorder is process-global, so parallel tests would race. -#[test] -fn records_windows_bounded_samples_and_noops_when_disabled() { - set_enabled(false); - span("off").with(|| ()); - count("off"); - let disabled = snapshot_json(true); - assert!(!disabled.contains("\"op\":\"off\""), "{disabled}"); - - set_enabled(true); - span("unit").with(|| std::thread::sleep(std::time::Duration::from_millis(1))); - count("fallback"); - count("fallback"); - for _ in 0..MAX_SAMPLES + 10 { - span("unit").with(|| ()); - } - let snapshot = snapshot_json(true); - let parsed: serde_json::Value = serde_json::from_str(&snapshot).expect("json"); - let ops = parsed["ops"].as_array().expect("ops"); - let unit = ops - .iter() - .find(|entry| entry["op"] == "unit") - .unwrap_or_else(|| panic!("unit span missing: {snapshot}")); - assert_eq!(unit["count"].as_u64(), Some(MAX_SAMPLES as u64 + 11)); - let samples = unit["samples"].as_array().expect("samples"); - assert!(samples.len() <= MAX_SAMPLES, "{snapshot}"); - assert!(unit["max_us"].as_u64().unwrap_or(0) >= 1000, "{snapshot}"); - let fallback = parsed["counters"] - .as_array() - .expect("counters") - .iter() - .find(|entry| entry[0] == "fallback") - .unwrap_or_else(|| panic!("counter missing: {snapshot}")); - assert!(fallback[1].as_u64().unwrap_or(0) >= 2, "{snapshot}"); - - let emptied = snapshot_json(true); - assert!(!emptied.contains("\"op\":\"unit\""), "{emptied}"); - span("kept").with(|| ()); - let kept = snapshot_json(false); - assert!(kept.contains("\"op\":\"kept\""), "{kept}"); - let again = snapshot_json(true); - assert!(again.contains("\"op\":\"kept\""), "{again}"); - set_enabled(false); -} diff --git a/native/mtproto-rs/tests/unit/presence_tests.rs b/native/mtproto-rs/tests/unit/presence_tests.rs deleted file mode 100644 index 1a19e08be..000000000 --- a/native/mtproto-rs/tests/unit/presence_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - SendMessageCancelActionConstructor, SendMessageChooseStickerActionConstructor, - SendMessageRecordAudioActionConstructor, SendMessageTypingActionConstructor, - SendMessageUploadPhotoActionConstructor, UserStatusOfflineConstructor, - UserStatusOnlineConstructor, UserStatusRecentlyConstructor, -}; - -#[test] -fn maps_online_offline_recently() { - let online = UserStatus::UserStatusOnline(UserStatusOnlineConstructor { expires: 99 }); - assert_eq!( - user_status_parts(Some(&online)), - (Some("online".into()), Some(99)) - ); - let offline = UserStatus::UserStatusOffline(UserStatusOfflineConstructor { was_online: 7 }); - assert_eq!( - user_status_parts(Some(&offline)), - (Some("offline".into()), Some(7)) - ); - let recent = UserStatus::UserStatusRecently(UserStatusRecentlyConstructor { - flags: 0, - by_me: None, - }); - assert_eq!( - user_status_parts(Some(&recent)).0.as_deref(), - Some("recently") - ); - assert_eq!(user_status_parts(None), (None, None)); -} - -#[test] -fn maps_actions_and_activity() { - let typing = SendMessageAction::SendMessageTypingAction(SendMessageTypingActionConstructor {}); - let cancel = SendMessageAction::SendMessageCancelAction(SendMessageCancelActionConstructor {}); - let audio = - SendMessageAction::SendMessageRecordAudioAction(SendMessageRecordAudioActionConstructor {}); - let photo = - SendMessageAction::SendMessageUploadPhotoAction(SendMessageUploadPhotoActionConstructor { - progress: 0, - }); - let sticker = SendMessageAction::SendMessageChooseStickerAction( - SendMessageChooseStickerActionConstructor {}, - ); - - assert_eq!(action_kind(&typing), Some("typing")); - assert!(action_is_active(&typing)); - assert_eq!(action_kind(&cancel), None); - assert!(!action_is_active(&cancel)); - assert_eq!(action_kind(&audio), Some("record_audio")); - assert_eq!(action_kind(&photo), Some("upload_photo")); - assert_eq!(action_kind(&sticker), Some("choose_sticker")); - assert!(action_is_active(&audio)); -} diff --git a/native/mtproto-rs/tests/unit/profile_mod_tests.rs b/native/mtproto-rs/tests/unit/profile_mod_tests.rs deleted file mode 100644 index 081c82307..000000000 --- a/native/mtproto-rs/tests/unit/profile_mod_tests.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::profile::{custom_or_role, profile_extra_json}; - -#[test] -fn admin_tags_default_to_role_and_preserve_custom_rank() { - assert_eq!(custom_or_role(&None, "admin"), "role:admin"); - assert_eq!( - custom_or_role(&Some("Helper".into()), "admin"), - "rank:Helper" - ); -} - -#[test] -fn extra_json_omits_empty_and_keeps_counts() { - assert_eq!( - profile_extra_json( - None, None, None, None, false, false, false, None, false, None, false, - ), - None - ); - let raw = profile_extra_json( - Some(12), - Some(3), - Some(4), - Some("+1"), - true, - true, - false, - Some(9), - true, - Some(false), - true, - ) - .expect("json"); - assert!(raw.contains("\"members\":12")); - assert!(raw.contains("\"online\":3")); - assert!(raw.contains("\"common\":4")); - assert!(raw.contains("\"bot\":true")); - assert!(raw.contains("\"verified\":true")); - assert!(raw.contains("\"phone\":\"+1\"")); - assert!(raw.contains("\"premium\":true")); - assert!(raw.contains("\"participants\":false")); - assert!(raw.contains("\"blocked\":true")); -} diff --git a/native/mtproto-rs/tests/unit/push_rpc_tests.rs b/native/mtproto-rs/tests/unit/push_rpc_tests.rs deleted file mode 100644 index 3a59a60b1..000000000 --- a/native/mtproto-rs/tests/unit/push_rpc_tests.rs +++ /dev/null @@ -1,85 +0,0 @@ -use super::*; - -#[test] -fn token_types_match_telegram_push_docs() { - assert_eq!(TOKEN_TYPE_FCM, 2); - assert_eq!(TOKEN_TYPE_SIMPLE, 4); - assert_eq!(TOKEN_TYPE_WEB_PUSH, 10); -} - -#[test] -fn empty_token_is_rejected_without_network() { - let err = register_device( - &mut Snapshot::new(2, &mut tellers_mtproto_session::OsRandom).expect("snap"), - 1, - TOKEN_TYPE_FCM, - " ".into(), - vec![0; 256], - true, - false, - Vec::new(), - ) - .unwrap_err(); - assert!(format!("{err}").contains("TOKEN_EMPTY")); -} - -#[test] -fn fcm_secret_must_be_256_bytes() { - let err = register_device( - &mut Snapshot::new(2, &mut tellers_mtproto_session::OsRandom).expect("snap"), - 1, - TOKEN_TYPE_FCM, - "token".into(), - vec![0; 16], - true, - false, - vec![1], - ) - .unwrap_err(); - assert!(format!("{err}").contains("256")); -} - -#[test] -fn invalid_token_type_is_rejected_without_network() { - let err = register_device( - &mut Snapshot::new(2, &mut tellers_mtproto_session::OsRandom).expect("snap"), - 1, - 99, - "token".into(), - Vec::new(), - true, - false, - Vec::new(), - ) - .unwrap_err(); - assert!(format!("{err}").contains("TOKEN_TYPE_INVALID")); -} - -#[test] -fn decrypt_round_trip_encrypted_fcm_p() { - let secret = vec![0x42_u8; 256]; - let json = - r#"{"loc_key":"MESSAGE_TEXT","loc_args":["Ada","hi"],"custom":{"from_id":7,"msg_id":9}}"#; - let payload = encrypt_push_payload_for_test(&secret, json).expect("encrypt"); - let out = decrypt_push_payload(secret, payload).expect("decrypt"); - assert_eq!(out, json); -} - -#[test] -fn decrypt_accepts_plain_json_and_simple_push() { - let json = r#"{"loc_key":"MESSAGE_MUTED"}"#; - assert_eq!(decrypt_push_payload(Vec::new(), json.into()).unwrap(), json); - assert_eq!( - decrypt_push_payload(Vec::new(), "version=12".into()).unwrap(), - r#"{"loc_key":"WAKE"}"# - ); -} - -#[test] -fn decrypt_rejects_wrong_secret() { - let secret = vec![0x11_u8; 256]; - let json = r#"{"loc_key":"MESSAGE_TEXT"}"#; - let payload = encrypt_push_payload_for_test(&secret, json).expect("encrypt"); - let err = decrypt_push_payload(vec![0x22; 256], payload).unwrap_err(); - assert!(format!("{err}").contains("auth_key_id") || format!("{err}").contains("msg_key")); -} diff --git a/native/mtproto-rs/tests/unit/reply_markup_tests.rs b/native/mtproto-rs/tests/unit/reply_markup_tests.rs deleted file mode 100644 index 5e65c6a31..000000000 --- a/native/mtproto-rs/tests/unit/reply_markup_tests.rs +++ /dev/null @@ -1,105 +0,0 @@ -use super::*; -use tellers_mtproto::latest::api::{ - ButtonTypeDefaultConstructor, InlineButtonTypeCallbackConstructor, - InlineButtonTypeUrlConstructor, KeyboardButtonConstructor, KeyboardButtonRowConstructor, - KeyboardInlineButtonConstructor, KeyboardInlineButtonRowConstructor, - ReplyInlineMarkupConstructor, ReplyKeyboardHideConstructor, ReplyKeyboardMarkupConstructor, - Vector, VectorConstructor, -}; - -fn vec_of(items: Vec) -> Box> { - let n = items.len() as u32; - Box::new(Vector::Vector(VectorConstructor { - field_0: n, - field_1: items, - })) -} - -#[test] -fn hide_serializes() { - let markup = ReplyMarkup::ReplyKeyboardHide(ReplyKeyboardHideConstructor { - flags: 0, - selective: None, - }); - assert_eq!(to_json(Some(&markup)).as_deref(), Some(r#"{"k":"hide"}"#)); -} - -#[test] -fn keyboard_text_row_serializes() { - let button = KeyboardButton::KeyboardButton(KeyboardButtonConstructor { - flags: 0, - style: None, - text: "Yes".into(), - type_: Box::new(ButtonType::ButtonTypeDefault( - ButtonTypeDefaultConstructor {}, - )), - }); - let row = KeyboardButtonRow::KeyboardButtonRow(KeyboardButtonRowConstructor { - buttons: vec_of(vec![Box::new(button)]), - }); - let markup = ReplyMarkup::ReplyKeyboardMarkup(ReplyKeyboardMarkupConstructor { - flags: 1, - resize: Some(Box::new(tellers_mtproto::latest::api::True::True( - tellers_mtproto::latest::api::TrueConstructor {}, - ))), - single_use: None, - selective: None, - persistent: None, - force_reply: None, - rows: vec_of(vec![Box::new(row)]), - placeholder: None, - }); - let json = to_json(Some(&markup)).expect("json"); - assert!(json.contains(r#""k":"keyboard""#)); - assert!(json.contains(r#""t":"text""#)); - assert!(json.contains(r#""x":"Yes""#)); - assert!(json.contains(r#""resize":true"#)); -} - -#[test] -fn inline_url_and_callback_serialize() { - let url = KeyboardInlineButton::KeyboardInlineButton(KeyboardInlineButtonConstructor { - flags: 0, - style: None, - text: "Open".into(), - type_: Box::new(InlineButtonType::InlineButtonTypeUrl( - InlineButtonTypeUrlConstructor { - url: "https://t.me".into(), - }, - )), - }); - let cb = KeyboardInlineButton::KeyboardInlineButton(KeyboardInlineButtonConstructor { - flags: 0, - style: None, - text: "Go".into(), - type_: Box::new(InlineButtonType::InlineButtonTypeCallback( - InlineButtonTypeCallbackConstructor { - flags: 0, - requires_password: None, - data: b"ab".to_vec(), - }, - )), - }); - let row = - KeyboardInlineButtonRow::KeyboardInlineButtonRow(KeyboardInlineButtonRowConstructor { - buttons: vec_of(vec![Box::new(url), Box::new(cb)]), - }); - let markup = ReplyMarkup::ReplyInlineMarkup(ReplyInlineMarkupConstructor { - flags: 0, - force_reply: None, - rows: vec_of(vec![Box::new(row)]), - }); - let json = to_json(Some(&markup)).expect("json"); - assert!(json.contains(r#""k":"inline""#)); - assert!(json.contains(r#""t":"url""#)); - assert!(json.contains("https://t.me")); - assert!(json.contains(r#""t":"cb""#)); - assert!(json.contains("6162")); -} - -#[test] -fn hex_roundtrip_callback_data() { - assert_eq!(from_hex("6162").expect("hex"), b"ab"); - assert!(from_hex("6").is_err()); - assert!(from_hex("zz").is_err()); -} diff --git a/native/mtproto-rs/tests/unit/request_control_tests.rs b/native/mtproto-rs/tests/unit/request_control_tests.rs deleted file mode 100644 index f88802da7..000000000 --- a/native/mtproto-rs/tests/unit/request_control_tests.rs +++ /dev/null @@ -1,114 +0,0 @@ -use super::*; - -#[test] -fn finished_parallel_lane_keeps_other_socket_cancellable() { - use std::io::Read; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let socket = Arc::new(TcpStream::connect(listener.local_addr().unwrap()).unwrap()); - let (mut server, _) = listener.accept().unwrap(); - server - .set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - let id = create(); - let previous = bind(id); - register(&socket).unwrap(); - std::thread::scope(|scope| { - scope - .spawn(|| with_shared_binding(id, detach_sockets)) - .join() - .unwrap(); - }); - cancel(id); - assert_eq!(server.read(&mut [0; 1]).unwrap(), 0); - bind(previous); - release(id); -} -#[test] -fn cancellation_is_scoped_and_binding_restores() { - let first = create(); - let second = create(); - let original = bind(first); - assert!(check().is_ok()); - cancel(first); - assert!(check().is_err()); - let previous = bind(second); - assert!(check().is_ok()); - bind(previous); - assert!(check().is_err()); - bind(original); - release(first); - release(second); -} - -#[test] -fn cancel_closes_only_registered_socket_and_rejects_reuse() { - use std::io::Read; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let socket = Arc::new(TcpStream::connect(listener.local_addr().unwrap()).unwrap()); - let (mut server, _) = listener.accept().unwrap(); - server - .set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - let id = create(); - let previous = bind(id); - register(&socket).unwrap(); - cancel(id); - assert_eq!(server.read(&mut [0; 1]).unwrap(), 0); - assert!(register(&socket).is_err()); - bind(previous); - release(id); -} - -#[test] -fn finished_request_cannot_close_socket_reused_by_next_request() { - use std::io::{Read, Write}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let socket = Arc::new(TcpStream::connect(listener.local_addr().unwrap()).unwrap()); - let (mut server, _) = listener.accept().unwrap(); - server - .set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - let first = create(); - let second = create(); - let previous = bind(first); - register(&socket).unwrap(); - detach_sockets(); - bind(second); - register(&socket).unwrap(); - cancel(first); - socket.as_ref().write_all(b"ok").unwrap(); - let mut bytes = [0; 2]; - server.read_exact(&mut bytes).unwrap(); - assert_eq!(&bytes, b"ok"); - bind(previous); - release(first); - release(second); -} - -#[test] -fn promotion_is_monotonic_scoped_to_the_request_and_cleared_on_release() { - use crate::scheduler::RequestClass; - let id = create(); - let other = create(); - let previous = bind(id); - assert_eq!(promoted_class(), None); - promote(id, RequestClass::InteractiveMedia); - assert_eq!(promoted_class(), Some(RequestClass::InteractiveMedia)); - promote(id, RequestClass::BackgroundMedia); - assert_eq!( - promoted_class(), - Some(RequestClass::InteractiveMedia), - "never lowered" - ); - promote(id, RequestClass::InteractiveWrite); - assert_eq!(promoted_class(), Some(RequestClass::InteractiveWrite)); - bind(other); - assert_eq!(promoted_class(), None); - bind(previous); - release(id); - release(other); - promote(id, RequestClass::InteractiveRead); - bind(id); - assert_eq!(promoted_class(), None, "released requests ignore promotion"); - bind(previous); -} diff --git a/native/mtproto-rs/tests/unit/rpc_container_tests.rs b/native/mtproto-rs/tests/unit/rpc_container_tests.rs deleted file mode 100644 index 6eff3b5da..000000000 --- a/native/mtproto-rs/tests/unit/rpc_container_tests.rs +++ /dev/null @@ -1,258 +0,0 @@ -use super::*; - -struct ContainerClock; -impl Clock for ContainerClock { - fn unix_micros(&self) -> i64 { - 1_700_000_000_000_000 - } -} - -fn container(children: &[(i64, i32, Vec)]) -> Vec { - let mut bytes = MSG_CONTAINER.to_le_bytes().to_vec(); - bytes.extend_from_slice(&(children.len() as i32).to_le_bytes()); - for (id, seq, body) in children { - bytes.extend(pack_container_message(*id, *seq, body)); - } - bytes -} - -#[test] -fn authenticated_children_acknowledge_and_deduplicate_their_own_ids() { - let id = (1_700_000_000_i64 << 32) | 1; - let child = pack_rpc_result(99, &[1; 4]); - let body = container(&[(id, 1, child)]); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - let events = parse_authenticated(&body, id + 4, &mut snapshot, &ContainerClock).unwrap(); - assert!(matches!( - events.as_slice(), - [InboundEvent::RpcResult { req_msg_id: 99, .. }] - )); - assert!(snapshot.pending_acknowledgements.contains(&id)); - let duplicate = parse_authenticated(&body, id + 8, &mut snapshot, &ContainerClock).unwrap(); - assert!(duplicate.is_empty()); -} - -#[test] -fn authenticated_boxed_vector_preserves_legacy_server_framing() { - let id = (1_700_000_000_i64 << 32) | 1; - let mut body = MSG_CONTAINER.to_le_bytes().to_vec(); - body.extend_from_slice(&0x1cb5_c415_u32.to_le_bytes()); - body.extend_from_slice(&1_i32.to_le_bytes()); - body.extend_from_slice(&0x5bb8_e511_u32.to_le_bytes()); - body.extend(pack_container_message(id, 1, &pack_rpc_result(99, &[1; 4]))); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - assert_eq!( - parse_authenticated(&body, id + 4, &mut snapshot, &ContainerClock) - .unwrap() - .len(), - 1 - ); -} - -#[test] -fn invalid_child_rolls_back_siblings_and_never_correlates() { - let id = (1_700_000_000_i64 << 32) | 1; - for (bad_id, bad_seq) in [(id + 20, 1), (id + 1, 1), (id + 4, -1)] { - let body = container(&[ - (id, 1, pack_rpc_result(99, &[1; 4])), - (bad_id, bad_seq, pack_rpc_result(100, &[2; 4])), - ]); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - assert!(parse_authenticated(&body, id + 12, &mut snapshot, &ContainerClock).is_err()); - assert!(snapshot.received_message_ids.is_empty()); - assert!(snapshot.pending_acknowledgements.is_empty()); - } -} - -#[test] -fn nested_container_and_unaligned_child_are_rejected() { - let id = (1_700_000_000_i64 << 32) | 1; - for payload in [container(&[]), vec![0; 5], vec![]] { - let body = container(&[(id, 1, payload)]); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - assert!(parse_authenticated(&body, id + 4, &mut snapshot, &ContainerClock).is_err()); - } -} - -#[test] -fn copied_old_message_is_accepted_once_and_acknowledged() { - let id = (1_600_000_000_i64 << 32) | 1; - let mut body = MSG_COPY.to_le_bytes().to_vec(); - body.extend(pack_container_message(id, 1, &pack_rpc_result(99, &[1; 4]))); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - let outer = (1_700_000_000_i64 << 32) | 1; - assert_eq!( - parse_authenticated(&body, outer, &mut snapshot, &ContainerClock) - .unwrap() - .len(), - 1 - ); - assert!( - parse_authenticated(&body, outer + 4, &mut snapshot, &ContainerClock) - .unwrap() - .is_empty() - ); - assert!(snapshot.pending_acknowledgements.contains(&id)); -} - -fn pack_container_message(msg_id: i64, seqno: i32, inner: &[u8]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&msg_id.to_le_bytes()); - out.extend_from_slice(&seqno.to_le_bytes()); - out.extend_from_slice(&(inner.len() as i32).to_le_bytes()); - out.extend_from_slice(inner); - out -} - -fn pack_rpc_result(req_msg_id: i64, payload: &[u8]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&RPC_RESULT.to_le_bytes()); - out.extend_from_slice(&req_msg_id.to_le_bytes()); - out.extend_from_slice(payload); - out -} - -#[test] -fn container_extracts_rpc_result_after_ack() { - let ack = MSGS_ACK.to_le_bytes().to_vec(); - let rpc = pack_rpc_result(42, &[0x11, 0x22, 0x33, 0x44]); - let mut body = Vec::new(); - body.extend_from_slice(&MSG_CONTAINER.to_le_bytes()); - body.extend_from_slice(&2_i32.to_le_bytes()); - body.extend(pack_container_message(1, 1, &ack)); - body.extend(pack_container_message(2, 3, &rpc)); - let events = parse_service_or_result(&body).expect("container"); - let found = events.into_iter().find_map(|e| match e { - InboundEvent::RpcResult { req_msg_id, body } => Some((req_msg_id, body)), - _ => None, - }); - assert_eq!(found, Some((42, vec![0x11, 0x22, 0x33, 0x44]))); -} - -#[test] -fn container_with_vector_ctor_still_finds_rpc_result() { - let rpc = pack_rpc_result(7, &[0x01, 0x00, 0x00, 0x00]); - let mut body = Vec::new(); - body.extend_from_slice(&MSG_CONTAINER.to_le_bytes()); - body.extend_from_slice(&0x1cb5_c415u32.to_le_bytes()); - body.extend_from_slice(&1_i32.to_le_bytes()); - body.extend(pack_container_message(9, 1, &rpc)); - let events = parse_service_or_result(&body).expect("vector ctor container"); - assert!( - events - .iter() - .any(|e| matches!(e, InboundEvent::RpcResult { req_msg_id: 7, .. })) - ); -} - -#[test] -fn container_inner_new_session_updates_salt_without_retrying_rpc() { - let mut ns = NEW_SESSION_CREATED.to_le_bytes().to_vec(); - ns.extend_from_slice(&1_i64.to_le_bytes()); // first_msg_id - ns.extend_from_slice(&2_i64.to_le_bytes()); // unique_id - ns.extend_from_slice(&3_i64.to_le_bytes()); // server_salt - let mut body = Vec::new(); - body.extend_from_slice(&MSG_CONTAINER.to_le_bytes()); - body.extend_from_slice(&1_i32.to_le_bytes()); - body.extend(pack_container_message(10, 1, &ns)); - let events = parse_service_or_result(&body).expect("new session container"); - assert_eq!( - events - .iter() - .filter(|event| matches!(event, InboundEvent::SaltUpdated { .. })) - .count(), - 1 - ); - assert!(!events.iter().any(|event| { - matches!( - event, - InboundEvent::RetryableFailure { .. } | InboundEvent::BadMessage { .. } - ) - })); -} - -#[test] -fn container_does_not_treat_embedded_rpc_marker_as_a_response() { - let mut inner = MSGS_ACK.to_le_bytes().to_vec(); - inner.extend_from_slice(&[0, 0, 0, 0]); - inner.extend(pack_rpc_result(99, &[0x01, 0x00, 0x00, 0x00])); - let mut body = Vec::new(); - body.extend_from_slice(&MSG_CONTAINER.to_le_bytes()); - body.extend_from_slice(&1_i32.to_le_bytes()); - body.extend(pack_container_message(3, 1, &inner)); - let events = parse_service_or_result(&body).expect("scan container"); - let found = events.into_iter().find_map(|e| match e { - InboundEvent::RpcResult { req_msg_id, body } => Some((req_msg_id, body)), - _ => None, - }); - assert_eq!(found, None); -} - -#[test] -fn msg_copy_extracts_inner_rpc_result() { - let rpc = pack_rpc_result(5, &[0x22, 0x00, 0x00, 0x00]); - let mut body = MSG_COPY.to_le_bytes().to_vec(); - body.extend(pack_container_message(8, 1, &rpc)); - let events = parse_service_or_result(&body).expect("msg_copy"); - assert!( - events - .iter() - .any(|e| matches!(e, InboundEvent::RpcResult { req_msg_id: 5, .. })) - ); -} - -#[test] -fn padded_intermediate_decodes_leftover_length_prefix() { - let mut framing = PaddedIntermediate::default(); - let payload = vec![0x11; 488]; - let encoded = framing.encode(&payload).expect("encode"); - let prefix = u32::from_le_bytes(encoded[..4].try_into().unwrap()); - assert!(prefix >= 488 && prefix <= 488 + 15); - let packet = framing.decode(&encoded).expect("decode complete frame"); - assert_eq!(packet.payload.len(), prefix as usize); - assert_eq!(packet.consumed, encoded.len()); -} - -#[test] -fn clock_delta_repairs_300s_skew() { - let local = 1_700_000_000i64 * 1_000_000; - let server_msg_id = (1_700_000_400i64) << 32 | 1; - assert_eq!(clock_delta_micros(server_msg_id, local, 0), 400_000_000); - let behind = (1_699_999_600i64) << 32 | 1; - assert_eq!(clock_delta_micros(behind, local, 0), -400_000_000); -} - -#[test] -fn eagain_is_waitable_not_dead_transport() { - assert!(is_waitable_io("connection error: Try again (os error 11)")); - assert!(is_waitable_io( - "connection error: connection timed out (os error 10060)" - )); - assert!(!is_transport_error(&MtprotoError::Message( - "connection error: Try again (os error 11)".into(), - ))); - assert!(is_transport_error(&MtprotoError::Message( - "connection reset by peer".into(), - ))); - assert!(is_transport_error(&MtprotoError::Message( - "RPC timeout recv=290".into(), - ))); - assert!(is_transport_error(&MtprotoError::Message( - "RPC timeout recv=19687 needed=4 available=0 prefix=0 last_ctor=0x74ae4240".into(), - ))); - assert!(is_transport_error(&MtprotoError::Message( - "RPC timeout recv=0 needed=4 available=0 prefix=0 last_ctor=0x0".into(), - ))); - assert!(is_transport_error(&MtprotoError::Message( - "MALFORMED MTPROTO MESSAGE: PLAIN ENVELOPE LENGTH MISMATCH".into(), - ))); -} - -#[test] -fn trim_padded_mtproto_packet_drops_transport_pad() { - let mut packet = vec![0_u8; 24 + 32 + 7]; - packet[0] = 1; - let trimmed = trim_padded_mtproto_packet(&packet); - assert_eq!(trimmed.len(), 56); - assert_eq!(trimmed.len() % 16, 8); -} diff --git a/native/mtproto-rs/tests/unit/rpc_control_tests.rs b/native/mtproto-rs/tests/unit/rpc_control_tests.rs deleted file mode 100644 index 79f00b562..000000000 --- a/native/mtproto-rs/tests/unit/rpc_control_tests.rs +++ /dev/null @@ -1,35 +0,0 @@ -use super::*; - -fn frame(payload: &[u8]) -> Vec { - PaddedIntermediate::default() - .encode_with_padding(payload, &[]) - .unwrap() -} - -#[test] -fn quick_ack_preserves_buffered_authenticated_frame_and_missing_key_is_not_revocation() { - let ack = [vec![0xff; 4], 7_u32.to_le_bytes().to_vec()].concat(); - let payload = vec![0xab; 32]; - let mut input = [frame(&ack), frame(&payload)].concat(); - assert_eq!( - try_decode_complete(&mut PaddedIntermediate::default(), &mut input).unwrap(), - Some(payload) - ); - assert!(input.is_empty()); - let mut missing = frame(&(-404_i32).to_le_bytes()); - let error = try_decode_complete(&mut PaddedIntermediate::default(), &mut missing).unwrap_err(); - assert_eq!(error.to_string(), "MTProto transport error -404"); - assert_ne!( - crate::rpc::supervisor::failure_class(&error), - crate::rpc::supervisor::FailureClass::Authorization - ); -} - -#[test] -fn quick_ack_flood_is_bounded() { - let ack = [vec![0xff; 4], 7_u32.to_le_bytes().to_vec()].concat(); - let mut input = frame(&ack).repeat(256); - let error = try_decode_complete(&mut PaddedIntermediate::default(), &mut input).unwrap_err(); - assert!(error.to_string().contains("control frame limit")); - assert!(input.is_empty()); -} diff --git a/native/mtproto-rs/tests/unit/rpc_dc_tests.rs b/native/mtproto-rs/tests/unit/rpc_dc_tests.rs deleted file mode 100644 index b3e259fd2..000000000 --- a/native/mtproto-rs/tests/unit/rpc_dc_tests.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -#[test] -fn test_dc_uses_test_ips() { - struct Reset; - impl Drop for Reset { - fn drop(&mut self) { - set_use_test_dc(false); - } - } - let _reset = Reset; - set_use_test_dc(true); - assert!(dc_endpoints(2)[0].starts_with("149.154.167.40")); - set_use_test_dc(false); - assert!(dc_endpoints(2)[0].starts_with("149.154.167.51")); -} - -#[test] -fn rotated_endpoints_moves_5222_off_first() { - let first = rotated_endpoints(2, 0); - let second = rotated_endpoints(2, 1); - assert_eq!(first[0], "149.154.167.51:443"); - assert_eq!(second[0], "149.154.167.50:443"); - assert_eq!(first.len(), second.len()); - assert_eq!(first[1], second[0]); -} - -#[test] -fn authenticated_retries_stay_on_one_dc2_ip() { - let addrs = same_ip_endpoints(2); - assert_eq!(addrs[0], "149.154.167.51:443"); - assert!(addrs.iter().all(|addr| addr.starts_with("149.154.167.51:"))); - assert!(!addrs.iter().any(|addr| addr.contains("149.154.167.50"))); - assert_eq!(rotated_same_ip(2, 1)[0], "149.154.167.51:5222"); - assert_eq!(rotated_same_ip(2, 0)[0], "149.154.167.51:443"); - assert_ne!(rotated_same_ip(2, 1)[0], rotated_same_ip(2, 0)[0]); -} - -#[test] -fn extras_stay_on_pinned_dc2_host() { - let pinned = ["149.154.167.51:443"]; - assert!(!extra_reconnect_same_host(&pinned, "149.154.167.41:443")); - assert!(!extra_reconnect_same_host(&pinned, "149.154.167.51:443")); - assert!(!extra_reconnect_same_host(&pinned, "149.154.167.51:5222")); -} diff --git a/native/mtproto-rs/tests/unit/rpc_framing_plaintext_tests.rs b/native/mtproto-rs/tests/unit/rpc_framing_plaintext_tests.rs deleted file mode 100644 index 4eafb65a3..000000000 --- a/native/mtproto-rs/tests/unit/rpc_framing_plaintext_tests.rs +++ /dev/null @@ -1,61 +0,0 @@ -use super::{SystemClock, replay_plaintext, resend_plaintext, send_future_salts}; -use tellers_mtproto::transport::GetFutureSaltsRequest; -use tellers_mtproto_engine::{Engine, ExponentialBackoff}; -use tellers_mtproto_session::{OsRandom, Snapshot}; -use tellers_mtproto_transport::{Connection, Error as TransportError, PaddedIntermediate}; - -struct Mem { - writes: usize, -} - -impl Connection for Mem { - fn send(&mut self, _packet: &[u8]) -> Result<(), TransportError> { - self.writes += 1; - Ok(()) - } - fn receive(&mut self, _output: &mut [u8]) -> Result { - Err(TransportError::Closed) - } - fn close(&mut self) -> Result<(), TransportError> { - Ok(()) - } -} - -#[test] -fn future_salt_query_is_resent_after_bad_server_salt() { - let mut rng = OsRandom; - let mut snapshot = Snapshot::new(2, &mut rng).expect("snapshot"); - snapshot.auth_key = Some(vec![7; 256]); - let mut engine = Engine::new( - snapshot, - ExponentialBackoff { - timeout_micros: 5_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .expect("engine"); - let mut conn = Mem { writes: 0 }; - let mut framing = PaddedIntermediate::default(); - let clock = SystemClock; - let message_id = send_future_salts(&mut engine, &mut conn, &mut framing, &clock).expect("send"); - assert_eq!(conn.writes, 1); - let session = engine.session.session_id; - let (content_related, body) = replay_plaintext(session, message_id).expect("stored query"); - assert!(content_related); - assert_eq!( - u32::from_le_bytes(body[0..4].try_into().unwrap()), - GetFutureSaltsRequest::ID - ); - for id in 0..80 { - super::remember_plaintext(session.wrapping_add(1), id, false, vec![1]); - } - assert_eq!(engine.session.content_sequence, 1); - assert!( - resend_plaintext(&mut engine, &mut conn, &mut framing, &clock, message_id).expect("resend") - ); - assert_eq!(conn.writes, 2); - assert_eq!(engine.session.content_sequence, 2); - assert!(!resend_plaintext(&mut engine, &mut conn, &mut framing, &clock, 1).expect("miss")); - assert_eq!(conn.writes, 2); -} diff --git a/native/mtproto-rs/tests/unit/rpc_gzip_tests.rs b/native/mtproto-rs/tests/unit/rpc_gzip_tests.rs deleted file mode 100644 index 34376faad..000000000 --- a/native/mtproto-rs/tests/unit/rpc_gzip_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::*; - -fn gzip_body(bytes: &[u8]) -> Vec { - use std::io::Write; - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); - encoder.write_all(bytes).unwrap(); - encode_boxed_bytes(&GzipPackedConstructor { - packed_data: encoder.finish().unwrap(), - }) - .unwrap() -} - -#[test] -fn compressed_payloads_are_bounded_and_validate_checksum() { - let normal = gzip_body(b"bounded payload"); - assert_eq!(ungzip_if_needed(&normal).unwrap(), b"bounded payload"); - let bomb = gzip_body(&vec![0; MAX_UNPACKED_BYTES + 1]); - assert!(ungzip_if_needed(&bomb).is_err()); - let mut broken = normal; - broken[8] ^= 0xff; - assert!(ungzip_if_needed(&broken).is_err()); -} - -#[test] -fn nested_wrappers_and_short_container_entries_cannot_panic() { - let mut body = MSGS_ACK.to_le_bytes().to_vec(); - for _ in 0..=MAX_WRAPPER_DEPTH { - body = gzip_body(&body); - } - assert!(parse_service_or_result(&body).is_err()); - let mut container = MSG_CONTAINER.to_le_bytes().to_vec(); - container.extend_from_slice(&1_i32.to_le_bytes()); - container.extend(pack_container_message(3, 1, &[0; 4])); - assert!(parse_service_or_result(&container).is_err()); -} - -fn pack_container_message(msg_id: i64, seqno: i32, inner: &[u8]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&msg_id.to_le_bytes()); - out.extend_from_slice(&seqno.to_le_bytes()); - out.extend_from_slice(&(inner.len() as i32).to_le_bytes()); - out.extend_from_slice(inner); - out -} diff --git a/native/mtproto-rs/tests/unit/rpc_inbound_future_salt_tests.rs b/native/mtproto-rs/tests/unit/rpc_inbound_future_salt_tests.rs deleted file mode 100644 index 386adf4ec..000000000 --- a/native/mtproto-rs/tests/unit/rpc_inbound_future_salt_tests.rs +++ /dev/null @@ -1,28 +0,0 @@ -use super::{InboundEvent, parse_service_or_result}; - -#[test] -fn bare_future_salts_do_not_fail_the_rpc() { - let mut body = Vec::new(); - body.extend_from_slice(&0xae50_0895u32.to_le_bytes()); - body.extend_from_slice(&7i64.to_le_bytes()); - body.extend_from_slice(&1_000i32.to_le_bytes()); - body.extend_from_slice(&1u32.to_le_bytes()); - body.extend_from_slice(&900i32.to_le_bytes()); - body.extend_from_slice(&1_900i32.to_le_bytes()); - body.extend_from_slice(&99i64.to_le_bytes()); - let events = parse_service_or_result(&body).expect("future salts"); - let InboundEvent::FutureSalts(windows) = &events[0] else { - panic!("bare future_salts was not accepted"); - }; - assert_eq!(windows[0].salt, 99); - assert_eq!(windows[0].valid_since, 900); - assert_eq!(windows[0].valid_until, 1_900); - - let mut boxed = Vec::new(); - boxed.extend_from_slice(&0xae50_0895u32.to_le_bytes()); - boxed.extend_from_slice(&0i64.to_le_bytes()); - boxed.extend_from_slice(&0i32.to_le_bytes()); - boxed.extend_from_slice(&0x1cb5_c415u32.to_le_bytes()); - let events = parse_service_or_result(&boxed).expect("malformed salts stay non-fatal"); - assert!(matches!(events[0], InboundEvent::Ignored)); -} diff --git a/native/mtproto-rs/tests/unit/rpc_inbound_gzip_tests.rs b/native/mtproto-rs/tests/unit/rpc_inbound_gzip_tests.rs deleted file mode 100644 index e614e5d6d..000000000 --- a/native/mtproto-rs/tests/unit/rpc_inbound_gzip_tests.rs +++ /dev/null @@ -1,18 +0,0 @@ -use super::{GZIP_PACKED, gzip_if_smaller, ungzip_if_needed}; - -#[test] -fn repetitive_api_body_is_gzip_packed() { - let body = vec![b'a'; 2048]; - let packed = gzip_if_smaller(&body); - assert_eq!( - u32::from_le_bytes(packed[0..4].try_into().unwrap()), - GZIP_PACKED - ); - assert_eq!(ungzip_if_needed(&packed).unwrap(), body); -} - -#[test] -fn tiny_body_stays_plain() { - let body = vec![1, 2, 3, 4]; - assert_eq!(gzip_if_smaller(&body), body); -} diff --git a/native/mtproto-rs/tests/unit/rpc_invoke_updates_salt_tests.rs b/native/mtproto-rs/tests/unit/rpc_invoke_updates_salt_tests.rs deleted file mode 100644 index 9a0183418..000000000 --- a/native/mtproto-rs/tests/unit/rpc_invoke_updates_salt_tests.rs +++ /dev/null @@ -1,302 +0,0 @@ -use super::updates_session_should_abort; - -#[test] -fn streaming_refill_is_sent_before_waiting_for_another_packet() { - use tellers_mtproto_crypto::{Direction, MessageToEncrypt, encrypt_message}; - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{Clock, OsRandom, Snapshot}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = super::super::framing::open_live_addr( - 2, - &listener.local_addr().unwrap().to_string(), - None, - 1, - ) - .unwrap(); - let (_peer, _) = listener.accept().unwrap(); - let clock = super::SystemClock; - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let server_id = ((clock.unix_micros() / 1_000_000) << 32) | 1; - snapshot.last_message_id = server_id + (1_i64 << 32) - 1; - let request_id = snapshot.last_message_id + 4; - let mut body = super::super::inbound::RPC_RESULT.to_le_bytes().to_vec(); - body.extend_from_slice(&request_id.to_le_bytes()); - body.extend_from_slice(&[1; 4]); - let packet = encrypt_message( - snapshot.auth_key.as_ref().unwrap(), - MessageToEncrypt { - server_salt: 0, - session_id: snapshot.session_id, - message_id: server_id, - sequence: 1, - body: &body, - padding: &[3; 16], - }, - Direction::ServerToClient, - ) - .unwrap(); - transport - .input - .extend_from_slice(&(packet.len() as u32).to_le_bytes()); - transport.input.extend_from_slice(&packet); - let mut engine = Engine::new( - snapshot, - ExponentialBackoff { - timeout_micros: 5_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_millis(150); - let mut callbacks = 0; - let result = super::invoke_batch_until_results_streaming( - &mut engine, - &mut transport, - &[vec![1; 4]], - &clock, - deadline, - deadline, - deadline, - false, - &mut false, - |index, response| { - assert_eq!(index, 0); - assert_eq!(response.unwrap(), &[1; 4]); - callbacks += 1; - Some(vec![2; 4]) - }, - ); - assert!( - result.is_err(), - "peer deliberately withholds the refill response" - ); - assert_eq!(callbacks, 1); - assert!( - engine.next_outbound().is_none(), - "refill must be sent before receive blocks" - ); -} - -#[test] -fn salt_retry_does_not_abort_updates_session() { - assert!(!updates_session_should_abort(true)); - assert!(updates_session_should_abort(false)); -} - -#[test] -fn bad_server_salt_requeues_the_pending_request() { - use super::SystemClock; - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - let mut rng = OsRandom; - let snapshot = Snapshot::new(2, &mut rng).expect("snapshot"); - let mut engine = Engine::new( - snapshot, - ExponentialBackoff { - timeout_micros: 5_000_000, - initial_delay_micros: 0, - max_attempts: 4, - }, - ) - .expect("engine"); - let handle = engine - .invoke( - &super::RawMethod { - body: vec![1, 2, 3, 4], - }, - &SystemClock, - ) - .expect("invoke"); - let original = handle.message_id(); - let _ = engine.next_outbound(); - let resent = super::take_salt_resend(&mut engine, original, &SystemClock).expect("resend"); - assert_eq!(resent.len(), 1); - assert_ne!(resent[0].message_id, original); -} - -#[test] -fn updates_reader_resends_the_future_salt_query() { - use super::super::framing::send_future_salts; - use super::SystemClock; - use tellers_mtproto::transport::GetFutureSaltsRequest; - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - use tellers_mtproto_transport::{Connection, Error as TransportError, PaddedIntermediate}; - struct Mem { - writes: usize, - } - impl Connection for Mem { - fn send(&mut self, _packet: &[u8]) -> Result<(), TransportError> { - self.writes += 1; - Ok(()) - } - fn receive(&mut self, _output: &mut [u8]) -> Result { - Err(TransportError::Closed) - } - fn close(&mut self) -> Result<(), TransportError> { - Ok(()) - } - } - let mut rng = OsRandom; - let mut snapshot = Snapshot::new(2, &mut rng).expect("snapshot"); - snapshot.auth_key = Some(vec![7; 256]); - let mut engine = Engine::new( - snapshot, - ExponentialBackoff { - timeout_micros: 5_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .expect("engine"); - let mut conn = Mem { writes: 0 }; - let mut framing = PaddedIntermediate::default(); - let message_id = - send_future_salts(&mut engine, &mut conn, &mut framing, &SystemClock).expect("send"); - assert!( - super::replay_updates_rejection( - &mut engine, - &mut conn, - &mut framing, - &SystemClock, - message_id, - None, - ) - .expect("resend") - ); - assert_eq!(conn.writes, 2); - assert_eq!(engine.session.content_sequence, 2); - let (_, body) = super::super::framing::replay_plaintext(engine.session.session_id, message_id) - .expect("original query"); - assert_eq!( - u32::from_le_bytes(body[0..4].try_into().unwrap()), - GetFutureSaltsRequest::ID - ); -} - -#[test] -fn salt_retry_keeps_the_original_request_handle_correlated() { - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - let mut engine = Engine::new( - Snapshot::new(2, &mut OsRandom).unwrap(), - ExponentialBackoff { - timeout_micros: 8_000_000, - initial_delay_micros: 0, - max_attempts: 3, - }, - ) - .unwrap(); - let handle = engine - .invoke(&super::RawMethod { body: vec![1; 4] }, &super::SystemClock) - .unwrap(); - let _ = engine.next_outbound(); - let resent = - super::take_salt_resend(&mut engine, handle.message_id(), &super::SystemClock).unwrap(); - assert_ne!(resent[0].message_id, handle.message_id()); - engine - .receive_result(resent[0].message_id, vec![2; 4], 1) - .unwrap(); - assert_eq!( - engine.take_response::(&handle).unwrap(), - Some(vec![2; 4]) - ); -} - -#[test] -fn salt_resend_status_probe_uses_the_new_wire_id() { - use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - use tellers_mtproto::transport::{MsgsStateReqConstructor, Vector}; - use tellers_mtproto_engine::{Engine, ExponentialBackoff}; - use tellers_mtproto_session::{OsRandom, Snapshot}; - use tellers_mtproto_transport::{Connection, Error, Framing, PaddedIntermediate}; - struct Memory(Vec); - impl Connection for Memory { - fn send(&mut self, bytes: &[u8]) -> Result<(), Error> { - self.0.extend_from_slice(bytes); - Ok(()) - } - fn receive(&mut self, _: &mut [u8]) -> Result { - Err(Error::Closed) - } - fn close(&mut self) -> Result<(), Error> { - Ok(()) - } - } - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let mut engine = Engine::new( - snapshot.clone(), - ExponentialBackoff { - timeout_micros: 8_000_000, - initial_delay_micros: 0, - max_attempts: 3, - }, - ) - .unwrap(); - let handle = engine - .invoke(&super::RawMethod { body: vec![1; 4] }, &super::SystemClock) - .unwrap(); - let _ = engine.next_outbound(); - let now = std::time::Instant::now(); - let mut silent = super::super::main_policy::SilentRequests::new(false); - silent.observe(&[handle.message_id()], now); - let mut conn = Memory(Vec::new()); - let mut framing = PaddedIntermediate::default(); - super::replay_updates_rejection( - &mut engine, - &mut conn, - &mut framing, - &super::SystemClock, - handle.message_id(), - Some(&mut silent), - ) - .unwrap(); - let resent_packet = framing.decode(&conn.0).unwrap().payload; - let resent = tellers_mtproto_crypto::decrypt_message( - snapshot.auth_key.as_deref().unwrap(), - super::super::timeout::trim_padded_mtproto_packet(&resent_packet), - tellers_mtproto_crypto::Direction::ClientToServer, - snapshot.session_id, - 1024, - ) - .unwrap(); - assert_ne!(resent.message_id, handle.message_id()); - silent.observe(&[handle.message_id()], now); - let due = silent.due(now + std::time::Duration::from_secs(9), false); - assert_eq!(due, vec![resent.message_id]); - conn.0.clear(); - super::super::framing::send_state_probe( - &mut engine, - &mut conn, - &mut framing, - &due, - &super::SystemClock, - ) - .unwrap(); - let packet = framing.decode(&conn.0).unwrap().payload; - let message = tellers_mtproto_crypto::decrypt_message( - snapshot.auth_key.as_deref().unwrap(), - super::super::timeout::trim_padded_mtproto_packet(&packet), - tellers_mtproto_crypto::Direction::ClientToServer, - snapshot.session_id, - 1024, - ) - .unwrap(); - let mut decoder = Decoder::new(&message.body, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MsgsStateReqConstructor::ID); - let request = MsgsStateReqConstructor::decode(&mut decoder).unwrap(); - let Vector::Vector(ids) = *request.msg_ids; - assert_eq!(ids.field_1, vec![resent.message_id]); - engine - .receive_result(resent.message_id, vec![2; 4], 1) - .unwrap(); - assert_eq!( - engine.take_response::(&handle).unwrap(), - Some(vec![2; 4]) - ); - silent.observe(&[], now); - assert!(silent.due(now, true).is_empty()); -} diff --git a/native/mtproto-rs/tests/unit/rpc_push_tests.rs b/native/mtproto-rs/tests/unit/rpc_push_tests.rs deleted file mode 100644 index 11ffafca7..000000000 --- a/native/mtproto-rs/tests/unit/rpc_push_tests.rs +++ /dev/null @@ -1,288 +0,0 @@ -use super::*; - -#[test] -fn detailed_info_is_a_service_notification() { - use tellers_mtproto::transport::{MsgDetailedInfoConstructor, MsgNewDetailedInfoConstructor}; - let messages = [ - encode_boxed_bytes(&MsgDetailedInfoConstructor { - msg_id: 4, - answer_msg_id: 9, - bytes: 128, - status: 0, - }) - .unwrap(), - encode_boxed_bytes(&MsgNewDetailedInfoConstructor { - answer_msg_id: 9, - bytes: 128, - status: 0, - }) - .unwrap(), - ]; - for body in messages { - assert!(parse_service_or_result(&body).is_ok()); - for length in 4..body.len() { - assert!(parse_service_or_result(&body[..length]).is_err()); - } - let mut trailing = body.clone(); - trailing.extend_from_slice(&[0; 4]); - assert!(parse_service_or_result(&trailing).is_err()); - } -} - -#[test] -fn detailed_info_resends_missing_answer_and_acknowledges_received_answer() { - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - let body = detailed_answer_request(&mut snapshot, 9).unwrap().unwrap(); - let mut decoder = Decoder::new(&body, Limits::default()).unwrap(); - assert_eq!(decoder.read_u32().unwrap(), MsgResendReqConstructor::ID); - let request = MsgResendReqConstructor::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - let Vector::Vector(ids) = *request.msg_ids; - assert_eq!(ids.field_1, vec![9]); - assert!(snapshot.pending_acknowledgements.is_empty()); - assert!(!snapshot.received_message_ids.contains_key(&9)); - - snapshot.received_message_ids.insert(9, true); - assert!(detailed_answer_request(&mut snapshot, 9).unwrap().is_none()); - assert_eq!(snapshot.take_acknowledgements(64), vec![9]); -} - -#[test] -fn push_reader_authenticates_buffered_frame_and_parks_socket() { - use tellers_mtproto::latest::api::UpdatesTooLongConstructor; - use tellers_mtproto_crypto::{Direction, MessageToEncrypt, encrypt_message}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let body = UpdatesTooLongConstructor::ID.to_le_bytes().to_vec(); - let message_id = ((SystemClock.unix_micros() / 1_000_000) << 32) | 1; - let packet = encrypt_message( - snapshot.auth_key.as_ref().unwrap(), - MessageToEncrypt { - server_salt: 0, - session_id: snapshot.session_id, - message_id, - sequence: 1, - body: &body, - padding: &[3; 12], - }, - Direction::ServerToClient, - ) - .unwrap(); - transport - .input - .extend_from_slice(&(packet.len() as u32).to_le_bytes()); - transport.input.extend_from_slice(&packet); - let mut slot = Some(transport); - let updates = with_live_transport(&mut slot, || receive_updates(&mut snapshot)).unwrap(); - assert_eq!(updates, vec![body]); - assert!(slot.is_some()); - assert!(snapshot.received_message_ids.contains_key(&message_id)); -} - -#[test] -fn push_reader_drains_already_complete_buffered_frames() { - use tellers_mtproto::latest::api::UpdatesTooLongConstructor; - use tellers_mtproto_crypto::{Direction, MessageToEncrypt, encrypt_message}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let body = UpdatesTooLongConstructor::ID.to_le_bytes().to_vec(); - let base_id = ((SystemClock.unix_micros() / 1_000_000) << 32) | 1; - for (offset, message_id) in [base_id, base_id + 4].into_iter().enumerate() { - let packet = encrypt_message( - snapshot.auth_key.as_ref().unwrap(), - MessageToEncrypt { - server_salt: 0, - session_id: snapshot.session_id, - message_id, - sequence: 1 + offset as i32, - body: &body, - padding: &[3; 12], - }, - Direction::ServerToClient, - ) - .unwrap(); - transport - .input - .extend_from_slice(&(packet.len() as u32).to_le_bytes()); - transport.input.extend_from_slice(&packet); - } - let mut slot = Some(transport); - let updates = with_live_transport(&mut slot, || receive_updates(&mut snapshot)).unwrap(); - assert_eq!(updates, vec![body.clone(), body]); - assert!(slot.is_some()); - assert!(snapshot.received_message_ids.contains_key(&base_id)); - assert!(snapshot.received_message_ids.contains_key(&(base_id + 4))); - assert!(slot.as_ref().unwrap().input.is_empty()); -} - -#[test] -fn management_results_enter_the_existing_push_queue_without_advancing_the_snapshot() { - use tellers_mtproto::latest::api::UpdatesTooLongConstructor; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - let mut slot = Some(transport); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - let session_id = snapshot.session_id; - let body = UpdatesTooLongConstructor::ID.to_le_bytes().to_vec(); - with_live_transport(&mut slot, || queue_rpc_updates(2, body.clone())).unwrap(); - assert_eq!(slot.as_ref().unwrap().updates, vec![body.clone()]); - assert_eq!(slot.as_ref().unwrap().updates_bytes, body.len()); - let received = with_live_transport(&mut slot, || receive_updates(&mut snapshot)).unwrap(); - assert_eq!(received, vec![body]); - assert_eq!(snapshot.session_id, session_id); - assert!(snapshot.received_message_ids.is_empty()); - assert!(slot.as_ref().unwrap().updates.is_empty()); -} - -#[test] -fn management_queue_overflow_drops_transport_for_recovery() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - transport.updates_bytes = MAX_UNPACKED_BYTES; - let mut slot = Some(transport); - assert!(with_live_transport(&mut slot, || queue_rpc_updates(2, vec![0; 4])).is_err()); - assert!(slot.is_none()); -} - -#[test] -fn queued_push_is_bounded_and_unknown_constructor_requires_recovery() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (_peer, _) = listener.accept().unwrap(); - for _ in 0..256 { - queue_update(&mut transport, vec![1; 4]).unwrap(); - } - assert!(queue_update(&mut transport, vec![1; 4]).is_err()); - assert_eq!(transport.updates_bytes, 1024); - transport.updates.clear(); - transport.updates_bytes = MAX_UNPACKED_BYTES; - assert!(queue_update(&mut transport, vec![1; 4]).is_err()); - match parse_service_or_result(&0x01020304u32.to_le_bytes()) { - Err(error) => assert!(error.to_string().contains("0x01020304")), - Ok(_) => panic!("unknown constructor must require recovery"), - } -} - -#[test] -fn silent_push_socket_is_closed_after_unanswered_ping() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut transport = - open_live_addr(2, &listener.local_addr().unwrap().to_string(), None, 1).unwrap(); - let (mut peer, _) = listener.accept().unwrap(); - peer.set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - peer.read_exact(&mut [0; 64]).unwrap(); - transport.ping_sent = Some(std::time::Instant::now() - std::time::Duration::from_secs(21)); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let mut slot = Some(transport); - assert!(with_live_transport(&mut slot, || receive_updates(&mut snapshot)).is_err()); - assert!(slot.is_none()); - assert_eq!(peer.read(&mut [0; 1]).unwrap(), 0); -} - -#[test] -fn supervisor_rejects_reentry_but_allows_independent_media_lane() { - let mut home = None; - with_live_transport(&mut home, || { - let owner = ConnectionSupervisor::acquire(2).unwrap(); - assert!(ConnectionSupervisor::acquire(2).is_err()); - let mut media = None; - with_live_transport(&mut media, || { - let _media_owner = ConnectionSupervisor::acquire(4).unwrap(); - assert!(ConnectionSupervisor::acquire(4).is_err()); - }); - assert!(ConnectionSupervisor::acquire(2).is_err()); - drop(owner); - assert!(ConnectionSupervisor::acquire(2).is_ok()); - }); -} - -#[test] -fn supervisor_cancellation_does_not_open_or_retry_socket() { - let control = std::sync::Arc::new(tcp::ConnectionControl::default()); - control.close(); - tcp::with_connection_control(&control, || { - let mut supervisor = ConnectionSupervisor::acquire(2).unwrap(); - assert!( - supervisor - .backoff(std::time::Duration::from_secs(60)) - .is_err() - ); - assert_eq!(supervisor.state, ConnectionState::Disconnected); - assert!( - supervisor - .connect(|| panic!("cancelled connection opened")) - .is_err() - ); - }); -} - -#[test] -fn supervisor_closes_failed_socket_before_replacement_and_parks_ready_socket() { - use std::net::TcpListener; - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap().to_string(); - let mut slot = None; - with_live_transport(&mut slot, || { - let mut supervisor = ConnectionSupervisor::acquire(2).unwrap(); - assert!( - !supervisor - .connect(|| open_live_addr(2, &addr, None, 1)) - .unwrap() - ); - assert_eq!(supervisor.state, ConnectionState::Handshaking); - let (mut peer, _) = listener.accept().unwrap(); - peer.set_read_timeout(Some(std::time::Duration::from_secs(1))) - .unwrap(); - peer.read_exact(&mut [0; 64]).unwrap(); - supervisor.backoff(std::time::Duration::ZERO).unwrap(); - assert_eq!(peer.read(&mut [0; 1]).unwrap(), 0); - assert!( - !supervisor - .connect(|| open_live_addr(2, &addr, None, 1)) - .unwrap() - ); - supervisor.park_ready(); - assert_eq!(supervisor.state, ConnectionState::Ready); - }); - assert!(slot.is_some()); - with_live_transport(&mut slot, || { - let mut supervisor = ConnectionSupervisor::acquire(2).unwrap(); - assert!( - supervisor - .connect(|| panic!("healthy connection replaced")) - .unwrap() - ); - // Error/unwind drops the unparked transport and releases the lane. - }); - assert!(slot.is_none()); -} - -#[test] -fn supervisor_unwind_releases_retry_owner() { - let mut slot = None; - with_live_transport(&mut slot, || { - assert!( - std::panic::catch_unwind(|| { - let _owner = ConnectionSupervisor::acquire(2).unwrap(); - panic!("test unwind"); - }) - .is_err() - ); - assert!(ConnectionSupervisor::acquire(2).is_ok()); - }); -} diff --git a/native/mtproto-rs/tests/unit/rpc_salts_tests.rs b/native/mtproto-rs/tests/unit/rpc_salts_tests.rs deleted file mode 100644 index 4a3f5c682..000000000 --- a/native/mtproto-rs/tests/unit/rpc_salts_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::{SaltWindow, choose_salt}; - -#[test] -fn prefetch_retries_when_the_answer_never_arrives() { - use super::{SaltWindow, mark_requested, should_prefetch, store_windows}; - let session = 91; - assert!(should_prefetch(session, 1_000)); - mark_requested(session, 1_000); - assert!(!should_prefetch(session, 1_010)); - assert!(should_prefetch(session, 1_030)); - store_windows( - session, - &[SaltWindow { - valid_since: 0, - valid_until: 2_000, - salt: 5, - }], - ); - assert!(!should_prefetch(session, 1_100)); -} - -#[test] -fn longest_remaining_valid_salt_wins() { - let windows = [ - SaltWindow { - valid_since: 0, - valid_until: 100, - salt: 1, - }, - SaltWindow { - valid_since: 50, - valid_until: 200, - salt: 2, - }, - SaltWindow { - valid_since: 300, - valid_until: 400, - salt: 3, - }, - ]; - assert_eq!(choose_salt(&windows, 60), Some(2)); - assert_eq!(choose_salt(&windows, 10), Some(1)); - assert_eq!(choose_salt(&windows, 250), None); -} diff --git a/native/mtproto-rs/tests/unit/rpc_session_tests.rs b/native/mtproto-rs/tests/unit/rpc_session_tests.rs deleted file mode 100644 index 0746dfc17..000000000 --- a/native/mtproto-rs/tests/unit/rpc_session_tests.rs +++ /dev/null @@ -1,318 +0,0 @@ -use super::*; - -#[test] -fn new_session_metadata_is_captured_with_salt() { - clear_new_session_metadata(); - let mut body = NEW_SESSION_CREATED.to_le_bytes().to_vec(); - body.extend_from_slice(&11_i64.to_le_bytes()); - body.extend_from_slice(&22_i64.to_le_bytes()); - body.extend_from_slice(&33_i64.to_le_bytes()); - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - apply_new_session_salt(&mut snapshot, &body).expect("new session"); - assert_eq!(snapshot.server_salt, 33); - assert_eq!( - take_new_session_metadata(), - Some(NewSessionMetadata { - first_msg_id: 11, - unique_id: 22, - server_salt: 33, - }) - ); -} - -#[test] -fn service_constructor_ids_match_generated_schema() { - assert_eq!(NEW_SESSION_CREATED, 0x9ec2_0908); - assert_eq!(PONG, 0x3477_73c5); - assert_eq!(RPC_RESULT, 0xf35c_6d01); - assert_eq!(MSG_CONTAINER, 0x73f1_f8dc); - assert_eq!(BAD_MSG_NOTIFICATION, 0xa7ef_f811); - assert_eq!(BAD_SERVER_SALT, 0xedab_447b); - assert_eq!(NEW_SESSION_CREATED, NewSessionCreatedConstructor::ID); - assert_eq!(PONG, PongConstructor::ID); - assert!(is_updates_type(0x74ae_4240)); - assert!(is_updates_type(0x78d4_dec1)); - assert!(is_updates_type(0x725b_04c3)); - assert!(is_updates_type(0x313b_c7f8)); - assert!(is_updates_type(0x4d6d_eea5)); - assert!(is_updates_type(0x9015_e101)); - assert!(is_updates_type(0xe317_af7e)); - assert!(!is_updates_type(RPC_RESULT)); - assert!(!is_updates_type(NEW_SESSION_CREATED)); - assert_eq!( - parse_service_or_result(&0x74ae_4240u32.to_le_bytes()) - .expect("updates") - .len(), - 1 - ); -} - -#[test] -fn bad_msg_notification_is_retryable() { - let mut body = BAD_MSG_NOTIFICATION.to_le_bytes().to_vec(); - body.extend_from_slice(&42_i64.to_le_bytes()); - body.extend_from_slice(&1_i32.to_le_bytes()); - body.extend_from_slice(&16_i32.to_le_bytes()); - let events = parse_service_or_result(&body).expect("bad_msg"); - assert!(events.iter().any(|e| matches!( - e, - InboundEvent::BadMessage { - bad_msg_id: 42, - error_code: 16 - } - ))); - assert!(!bad_msg_should_reconnect(16)); - assert!(!bad_msg_should_reconnect(20)); - assert!(bad_msg_should_reconnect(17)); - assert!(bad_msg_should_reconnect(32)); - assert_eq!(BAD_MSG_NOTIFICATION, 0xa7ef_f811); - assert!(idle_needs_liveness_probe( - 175030, - 4, - 0, - BAD_MSG_NOTIFICATION - )); - assert!(is_transport_error(&MtprotoError::Message( - "bad_msg_notification 17 recv=175030 last_ctor=0xa7eff811".into(), - ))); - let user = MtprotoError::Message( - "RPC timeout recv=175030 needed=4 available=0 prefix=0 last_ctor=0xa7eff811 via 149.154.167.51:443 reconnect".into(), - ); - assert!(timeout_idle_needs_probe(&user)); - assert!(is_transport_error(&user)); -} - -#[test] -fn replay_window_rejects_non_accepted_bodies() { - assert!(should_process_inbound(ReceivedMessageResult::Accepted)); - assert!(!should_process_inbound(ReceivedMessageResult::Duplicate)); - assert!(!should_process_inbound(ReceivedMessageResult::TooOld)); - assert!(!should_process_inbound(ReceivedMessageResult::InvalidTime)); - assert!(is_transport_error(&MtprotoError::Message( - "invalid inbound message time; reconnect".into(), - ))); -} - -#[test] -fn bad_sequence_recovery_recreates_session_and_keeps_authorization() { - let mut snapshot = Snapshot::new(2, &mut OsRandom).expect("snapshot"); - snapshot.auth_key = Some(vec![7; 256]); - snapshot.server_salt = 42; - snapshot.time_offset_micros = -1_500_000; - snapshot.last_message_id = 100; - snapshot.content_sequence = 19; - snapshot.pending_acknowledgements.insert(5); - snapshot.received_message_ids.insert(7, true); - let session_id = snapshot.session_id; - - recreate_session_after_bad_message(&mut snapshot).expect("recreate session"); - - assert_ne!(snapshot.session_id, session_id); - assert_eq!(snapshot.auth_key.as_deref(), Some(vec![7; 256].as_slice())); - assert_eq!(snapshot.server_salt, 42); - assert_eq!(snapshot.time_offset_micros, -1_500_000); - assert_eq!(snapshot.last_message_id, 0); - assert_eq!(snapshot.content_sequence, 0); - assert!(snapshot.pending_acknowledgements.is_empty()); - assert!(snapshot.received_message_ids.is_empty()); - assert_eq!(snapshot.next_sequence(true).expect("seqno"), 1); -} - -#[test] -fn uncertain_mutations_are_not_replayed() { - assert!(!may_reconnect_request(false, true)); - assert!(may_reconnect_request(false, false)); - assert!(may_reconnect_request(true, true)); - let policy = RpcRetryPolicy { - replay_safe: false, - backoff: ExponentialBackoff { - timeout_micros: 1, - initial_delay_micros: 0, - max_attempts: 3, - }, - }; - let mut engine = Engine::new(Snapshot::new(2, &mut OsRandom).unwrap(), policy).unwrap(); - let handle = engine - .invoke(&RawMethod { body: vec![1; 4] }, &SystemClock) - .unwrap(); - let outbound = engine.next_outbound().unwrap(); - let timeout = tellers_mtproto_engine::Error::Timeout { - message_id: outbound.message_id, - attempts: 1, - }; - assert!( - engine - .fail_request(outbound.message_id, 0, &timeout) - .is_err() - ); - assert_eq!(engine.pending_count(), 0); - assert!(engine.next_outbound().is_none()); - assert!( - engine - .take_response::(&handle) - .unwrap() - .is_none() - ); -} - -#[test] -fn correlated_rpc_errors_never_enter_transport_retry() { - for (message, expected) in [ - ("RPC 400: CONNECTION_NOT_INITED", FailureClass::RpcRejected), - ("RPC 400: TIMEOUT", FailureClass::RpcRejected), - ("RPC 420: FLOOD_WAIT_60", FailureClass::RpcRejected), - ("RPC 303: FILE_MIGRATE_4", FailureClass::Migration), - ("RPC 303: USER_MIGRATE_2", FailureClass::Migration), - ("RPC 401: SESSION_REVOKED", FailureClass::Authorization), - ("RPC 406: AUTH_KEY_DUPLICATED", FailureClass::Authorization), - ("RPC 500: CONNECTION_ERROR", FailureClass::Internal), - ] { - let error = MtprotoError::Message(message.into()); - assert_eq!(failure_class(&error), expected, "{message}"); - assert!(!is_transport_error(&error), "{message}"); - } -} - -#[test] -fn cancellation_and_protocol_failures_have_distinct_recovery() { - for (message, expected) in [ - ("connection error: client closed", FailureClass::Cancelled), - ("RPC cancelled", FailureClass::Cancelled), - ("connection reset by peer", FailureClass::Transport), - ( - "invalid MTProto container child metadata", - FailureClass::Protocol, - ), - ("invalid msg_key", FailureClass::Protocol), - ] { - assert_eq!( - failure_class(&MtprotoError::Message(message.into())), - expected - ); - } -} - -#[test] -fn late_rpc_error_does_not_complete_current_request() { - let mut engine = Engine::new( - Snapshot::new(2, &mut OsRandom).unwrap(), - ExponentialBackoff { - timeout_micros: 1_000_000, - initial_delay_micros: 0, - max_attempts: 3, - }, - ) - .unwrap(); - let handle = engine - .invoke(&RawMethod { body: vec![1; 4] }, &SystemClock) - .unwrap(); - let outbound = engine.next_outbound().unwrap(); - let error = encode_boxed_bytes(&RpcErrorConstructor { - error_code: 401, - error_message: "SESSION_EXPIRED".into(), - }) - .unwrap(); - assert!( - engine - .receive_result(outbound.message_id - 4, error, 3) - .is_err() - ); - assert!( - engine - .take_response::(&handle) - .unwrap() - .is_none() - ); - engine - .receive_result(outbound.message_id, vec![7; 4], 5) - .unwrap(); - assert_eq!( - engine.take_response::(&handle).unwrap(), - Some(vec![7; 4]) - ); -} - -#[test] -fn inbound_envelopes_enforce_authentication_session_replay_and_time() { - use tellers_mtproto_crypto::{Direction, MessageToEncrypt, encrypt_message}; - struct FixedClock; - impl Clock for FixedClock { - fn unix_micros(&self) -> i64 { - 1_700_000_000_000_000 - } - } - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let mut engine = Engine::new( - snapshot.clone(), - ExponentialBackoff { - timeout_micros: 1_000_000, - initial_delay_micros: 0, - max_attempts: 1, - }, - ) - .unwrap(); - let server_id = (1_700_000_000_i64 << 32) | 1; - let seal = |session_id, message_id, padding: &[u8]| { - encrypt_message( - snapshot.auth_key.as_ref().unwrap(), - MessageToEncrypt { - server_salt: 0, - session_id, - message_id, - sequence: 1, - body: &[1; 4], - padding, - }, - Direction::ServerToClient, - ) - }; - let packet = seal(snapshot.session_id, server_id, &[3; 12]).unwrap(); - assert!(should_process_inbound( - engine - .open_inbound(&packet, &FixedClock, true, 1024) - .unwrap() - .disposition - )); - assert!(!should_process_inbound( - engine - .open_inbound(&packet, &FixedClock, true, 1024) - .unwrap() - .disposition - )); - let mut tampered = packet; - tampered[8] ^= 1; - assert!( - engine - .open_inbound(&tampered, &FixedClock, true, 1024) - .is_err() - ); - let wrong_session = seal(snapshot.session_id ^ 1, server_id + 4, &[3; 12]).unwrap(); - assert!( - engine - .open_inbound(&wrong_session, &FixedClock, true, 1024) - .is_err() - ); - let even_id = seal(snapshot.session_id, server_id + 1, &[3; 12]).unwrap(); - assert!( - engine - .open_inbound(&even_id, &FixedClock, true, 1024) - .is_err() - ); - for seconds in [-301_i64, 31] { - let packet = seal( - snapshot.session_id, - ((1_700_000_000 + seconds) << 32) | 1, - &[3; 12], - ) - .unwrap(); - assert!(!should_process_inbound( - engine - .open_inbound(&packet, &FixedClock, true, 1024) - .unwrap() - .disposition - )); - } - assert!(seal(snapshot.session_id, server_id, &[3; 11]).is_err()); - assert!(seal(snapshot.session_id, server_id, &[3; 1036]).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/rpc_timeout_mod_tests.rs b/native/mtproto-rs/tests/unit/rpc_timeout_mod_tests.rs deleted file mode 100644 index bbe9c9148..000000000 --- a/native/mtproto-rs/tests/unit/rpc_timeout_mod_tests.rs +++ /dev/null @@ -1,13 +0,0 @@ -use super::*; - -#[test] -fn extend_streaming_deadlines_moves_forward() { - let now = std::time::Instant::now(); - let mut attempt = now; - let mut overall = now; - let mut hard = now; - extend_streaming_deadlines(&mut attempt, &mut overall, &mut hard); - assert!(overall > now); - assert!(hard > overall); - assert!(attempt > now); -} diff --git a/native/mtproto-rs/tests/unit/rpc_timeout_tests.rs b/native/mtproto-rs/tests/unit/rpc_timeout_tests.rs deleted file mode 100644 index 5e63c21f3..000000000 --- a/native/mtproto-rs/tests/unit/rpc_timeout_tests.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::*; - -#[test] -fn nested_live_lane_does_not_drop_outer_frame() { - let mut outer = None; - let mut inner = None; - with_live_transport(&mut outer, || { - assert_eq!(live_stack_depth(), 1); - with_live_transport(&mut inner, || { - assert_eq!(live_stack_depth(), 2); - drop_live_transport(); - assert_eq!(live_stack_depth(), 2); - assert!(!live_top_occupied()); - }); - assert_eq!(live_stack_depth(), 1); - assert!(!live_top_occupied()); - }); - assert_eq!(live_stack_depth(), 0); - assert!(outer.is_none()); - assert!(inner.is_none()); -} - -#[test] -fn rpc_attempt_budget_reserves_reconnect_window() { - let twenty = std::time::Duration::from_secs(20); - let eight = std::time::Duration::from_secs(8); - let four = std::time::Duration::from_secs(4); - assert_eq!( - rpc_attempt_budget(0, false, twenty), - std::time::Duration::from_secs(18) - ); - assert_eq!( - rpc_attempt_budget(0, false, eight), - std::time::Duration::from_secs(6) - ); - assert_eq!( - rpc_attempt_budget(0, false, four), - std::time::Duration::from_secs(3) - ); - assert_eq!(rpc_attempt_budget(0, true, eight), eight); - assert_eq!(rpc_attempt_budget(2, true, eight), eight); -} - -#[test] -fn reconnect_backoff_is_bounded() { - assert_eq!(reconnect_backoff(0), std::time::Duration::ZERO); - for attempt in [1, 2, 4, 99] { - let ceiling = 100_u64 << (attempt - 1).min(3); - for random in [0, 1, 50, 400, u64::MAX] { - let delay = reconnect_delay(attempt, random).as_millis(); - assert!(delay >= (ceiling / 2) as u128 && delay <= ceiling as u128); - } - } - assert_ne!(reconnect_delay(2, 0), reconnect_delay(2, 99)); -} - -#[test] -fn timeout_and_live_scopes_unwind_without_leaking_thread_state() { - let timeout = rpc_timeout_secs(); - let depth = live_stack_depth(); - let result = std::panic::catch_unwind(|| { - with_rpc_timeout_secs(1, || { - with_live_transport(&mut None, || panic!("test unwind")); - }); - }); - assert!(result.is_err()); - assert_eq!(rpc_timeout_secs(), timeout); - assert_eq!(live_stack_depth(), depth); -} - -#[test] -fn idle_frame_uses_attempt_deadline_mid_frame_uses_overall() { - let attempt = std::time::Instant::now(); - let overall = attempt + std::time::Duration::from_secs(8); - assert_eq!(recv_wait_deadline(attempt, overall, 4, 0), attempt); - assert_eq!( - recv_wait_deadline(attempt, overall, 140_004, 139_455), - overall - ); - assert!(!is_mid_frame(4, 0)); - assert!(is_mid_frame(140_004, 139_455)); - assert!(is_mid_frame(0, 314)); - assert!(leftover_frame_grace(false, 314)); - assert!(!leftover_frame_grace(true, 314)); - assert!(!leftover_frame_grace(false, 0)); - assert!(idle_after_complete_frames(26364, 4, 0, 0)); - assert!(!idle_after_complete_frames(26364, 4, 0, 0x78d4_dec1)); - assert!(!idle_after_complete_frames(0, 4, 0, 0)); - assert!(!idle_after_complete_frames(100, 140_004, 139_455, 0)); - // history/media: container then rpc_result in the next frame — keep waiting. - assert!(!idle_after_complete_frames(1448, 4, 0, MSG_CONTAINER)); - assert!(!idle_after_complete_frames(61008, 4, 0, MSG_CONTAINER)); - assert!(idle_empty_first_byte(0, 4, 0)); - assert!(!idle_empty_first_byte(1448, 4, 0)); - // loadMoreChats: recv=0 needed=4 available=0 last_ctor=0x0 on a parked socket. - assert!(idle_reused_socket(true, 0, 4, 0)); - assert!(!idle_reused_socket(false, 0, 4, 0)); - assert!(fail_fast_idle(true, 0, 4, 0, 0)); - assert!(!fail_fast_idle(false, 0, 4, 0, 0)); - assert!(!fail_fast_idle(false, 61008, 4, 0, MSG_CONTAINER)); - // download media: updates#74ae4240 is not a fail-fast idle; isolation is invokeWithoutUpdates. - assert!(!idle_after_complete_frames(19687, 4, 0, 0x74ae_4240)); - assert!(!fail_fast_idle(false, 19687, 4, 0, 0x74ae_4240)); - // User log: recv≈60KiB needed=4 last_ctor=msg_container. Probe, do not fail-fast. - assert!(idle_needs_liveness_probe(59428, 4, 0, MSG_CONTAINER)); - assert!(idle_needs_liveness_probe(62008, 4, 0, MSG_CONTAINER)); - assert!(idle_needs_liveness_probe(61008, 4, 0, MSG_CONTAINER)); - assert!(!idle_needs_liveness_probe(0, 4, 0, MSG_CONTAINER)); - assert!(!idle_needs_liveness_probe(61008, 4, 0, 0)); - assert!(!idle_needs_liveness_probe( - 100, - 140_004, - 139_455, - MSG_CONTAINER - )); - assert!(!fail_fast_idle(false, 59428, 4, 0, MSG_CONTAINER)); -} - -#[test] -fn user_log_idle_after_container_needs_ping_probe() { - let media = MtprotoError::Message( - "RPC timeout recv=59428 needed=4 available=0 prefix=0 last_ctor=0x73f1f8dc via 149.154.167.51:443 reconnect".into(), - ); - let chats = MtprotoError::Message( - "RPC timeout recv=62008 needed=4 available=0 prefix=0 last_ctor=0x73f1f8dc via 149.154.167.51:443 reconnect".into(), - ); - assert!(timeout_idle_needs_probe(&media)); - assert!(timeout_idle_needs_probe(&chats)); - assert!(!timeout_idle_needs_probe(&MtprotoError::Message( - "RPC timeout recv=0 needed=4 available=0 prefix=0 last_ctor=0x0".into(), - ))); - assert!(!timeout_idle_needs_probe(&MtprotoError::Message( - "RPC timeout recv=290".into(), - ))); - assert_eq!(PingRequest::ID, 0x7abe_77ec); - let ping = encode_boxed_bytes(&PingRequest { ping_id: 7 }).expect("ping"); - assert_eq!(&ping[..4], &PingRequest::ID.to_le_bytes()); - let now = std::time::Instant::now(); - assert!(!live_transport_stale(now, now)); - assert!(!live_transport_stale( - now, - now + std::time::Duration::from_secs(18) - )); - assert!(live_transport_stale( - now, - now + std::time::Duration::from_secs(KEEPALIVE_PING_SECS) - )); - assert_eq!( - subscribed_read_deadline(now, None).duration_since(now), - std::time::Duration::from_secs(KEEPALIVE_PING_SECS), - ); - let ping_sent = now + std::time::Duration::from_secs(2); - assert_eq!( - subscribed_read_deadline(now, Some(ping_sent)).duration_since(ping_sent), - std::time::Duration::from_secs(20), - ); - let mut inflight = Some(99_i64); - note_inbound_liveness(&mut inflight); - assert!(inflight.is_none()); - assert!(!keepalive_probe_failed(None)); - assert!(keepalive_probe_failed(Some(1))); -} - -#[test] -fn pong_is_liveness_not_ignored() { - let mut body = PONG.to_le_bytes().to_vec(); - body.extend_from_slice(&11_i64.to_le_bytes()); - body.extend_from_slice(&99_i64.to_le_bytes()); - let events = parse_service_or_result(&body).expect("pong"); - assert!( - events - .iter() - .any(|e| matches!(e, InboundEvent::Pong { ping_id: 99 })) - ); -} - -#[test] -fn rpc_timeout_message_includes_frame_progress() { - let mut input = vec![0_u8; 8]; - input[..4].copy_from_slice(&140_000u32.to_le_bytes()); - let message = rpc_timeout_message(139455, &input, 140004, 139455); - assert!(message.contains("recv=139455")); - assert!(message.contains("needed=140004")); - assert!(message.contains("available=139455")); - assert!(message.contains("prefix=140000")); -} - -#[test] -fn rpc_timeout_scoping_and_per_thread() { - let previous = rpc_timeout_secs(); - let seen = with_rpc_timeout_secs(8, || rpc_timeout_secs()); - assert_eq!(seen, 8); - assert_eq!(rpc_timeout_secs(), previous.max(1)); - - with_rpc_timeout_secs(45, || { - let other = std::thread::spawn(|| rpc_timeout_secs()) - .join() - .expect("timeout thread"); - assert_eq!(rpc_timeout_secs(), 45); - assert_eq!(other, 20); - }); -} diff --git a/native/mtproto-rs/tests/unit/rpc_wire_tests.rs b/native/mtproto-rs/tests/unit/rpc_wire_tests.rs deleted file mode 100644 index b63bfcd68..000000000 --- a/native/mtproto-rs/tests/unit/rpc_wire_tests.rs +++ /dev/null @@ -1,317 +0,0 @@ -use super::*; -use std::io::Write; -use tellers_mtproto_crypto::{ - AesCtr, Direction, MessageToEncrypt, decrypt_message, encrypt_message, -}; - -#[test] -fn manual_time_bounds_retries_and_delivers_each_logical_result_once() { - struct ManualClock(i64); - impl Clock for ManualClock { - fn unix_micros(&self) -> i64 { - self.0 - } - } - let initial = 1_700_000_000_000_000; - let policy = ExponentialBackoff { - timeout_micros: 10, - initial_delay_micros: 1, - max_attempts: 2, - }; - let mut engine = Engine::new(Snapshot::new(2, &mut OsRandom).unwrap(), policy).unwrap(); - let handle = engine - .invoke(&RawMethod { body: vec![1; 4] }, &ManualClock(initial)) - .unwrap(); - let first = engine.next_outbound().unwrap(); - let error = tellers_mtproto_engine::Error::Transport(tellers_mtproto_transport::Error::Closed); - engine - .fail_request(first.message_id, initial, &error) - .unwrap(); - assert!(engine.poll(&ManualClock(initial + 1)).unwrap().is_empty()); - let retry = engine.next_outbound().unwrap(); - assert_ne!(first.message_id, retry.message_id); - assert!(engine.receive_result(first.message_id, vec![0], 5).is_err()); - engine.receive_result(retry.message_id, vec![9], 9).unwrap(); - assert!(engine.receive_result(retry.message_id, vec![9], 9).is_err()); - assert_eq!( - engine.take_response::(&handle).unwrap(), - Some(vec![9]) - ); - assert_eq!(engine.take_response::(&handle).unwrap(), None); - assert_eq!(engine.pending_count(), 0); - assert!(engine.next_outbound().is_none()); - - let mut engine = Engine::new(Snapshot::new(2, &mut OsRandom).unwrap(), policy).unwrap(); - engine - .invoke(&RawMethod { body: vec![1; 4] }, &ManualClock(initial)) - .unwrap(); - engine.next_outbound().unwrap(); - assert!(engine.poll(&ManualClock(initial + 10)).unwrap().is_empty()); - assert!(engine.poll(&ManualClock(initial + 11)).unwrap().is_empty()); - assert!(engine.next_outbound().is_some()); - let failures = engine.poll(&ManualClock(initial + 21)).unwrap(); - assert_eq!(failures.len(), 1); - assert!(matches!( - failures[0], - tellers_mtproto_engine::Error::Timeout { attempts: 2, .. } - )); - assert_eq!(engine.pending_count(), 0); - assert!(engine.poll(&ManualClock(initial + 22)).unwrap().is_empty()); - assert!(engine.next_outbound().is_none()); -} - -#[test] -fn encrypted_rpc_socket_correlates_results_errors_and_uncertain_disconnects() { - for case in [ - "success", - "flood", - "migrate", - "auth", - "pin_permission", - "leave_creator", - "disconnect", - "scheduled_disconnect", - "tamper", - ] { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - snapshot.auth_key = Some(vec![7; 256]); - let session = snapshot.session_id; - let address = listener.local_addr().unwrap().to_string(); - let mut slot = Some(open_live_addr(2, &address, None, 2).unwrap()); - use tellers_mtproto::latest::api::*; - let query = match case { - "pin_permission" => encode_boxed_bytes(&MessagesUpdatePinnedMessageRequest { - flags: 0, - silent: None, - unpin: None, - pm_oneside: None, - peer: Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - id: 7, - }) - .unwrap(), - "leave_creator" => encode_boxed_bytes(&ChannelsLeaveChannelRequest { - channel: Box::new(InputChannel::InputChannel(InputChannelConstructor { - channel_id: 12, - access_hash: 34, - })), - }) - .unwrap(), - "scheduled_disconnect" => { - let mut request = MessagesSendMessageRequest { - flags: 0, - no_webpage: None, - silent: None, - background: None, - clear_draft: None, - noforwards: None, - update_stickersets_order: None, - invert_media: None, - allow_paid_floodskip: None, - peer: Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - reply_to: None, - message: "synthetic scheduled fixture".into(), - random_id: 123, - reply_markup: None, - entities: None, - schedule_date: None, - schedule_repeat_period: None, - send_as: None, - quick_reply_shortcut: None, - effect: None, - allow_paid_stars: None, - suggested_post: None, - rich_message: None, - }; - crate::SendOptions { - silent: true, - schedule_date: Some(2_000_000_000), - ..Default::default() - } - .apply_text(&mut request); - encode_boxed_bytes(&request).unwrap() - } - _ => vec![0x12; 4], - }; - let reconnect_query = encode_boxed_bytes(&MessagesGetScheduledHistoryRequest { - peer: Box::new(InputPeer::InputPeerSelf(InputPeerSelfConstructor {})), - hash: 0, - }) - .unwrap(); - let expected_queries = if case == "scheduled_disconnect" { - vec![query.clone(), reconnect_query.clone()] - } else { - vec![query.clone()] - }; - let server = std::thread::spawn(move || { - let mut last_request_id = 0; - for (round, expected_query) in expected_queries.into_iter().enumerate() { - let (mut socket, _) = listener.accept().unwrap(); - socket - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - socket - .set_write_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - let mut header = [0; 64]; - socket.read_exact(&mut header).unwrap(); - let mut incoming = AesCtr::new(&header[8..40], &header[40..56]).unwrap(); - let mut decoded_header = header; - incoming.apply(&mut decoded_header); - assert_eq!(&decoded_header[56..60], &0xdddddddd_u32.to_le_bytes()); - let mut decoded_request = None; - for _ in 0..8 { - let mut length = [0; 4]; - socket.read_exact(&mut length).unwrap(); - incoming.apply(&mut length); - let length = u32::from_le_bytes(length) as usize; - assert!(length < 4096); - let mut packet = vec![0; length]; - socket.read_exact(&mut packet).unwrap(); - incoming.apply(&mut packet); - let sealed_len = 24 + ((packet.len() - 24) / 16) * 16; - let request = decrypt_message( - &[7; 256], - &packet[..sealed_len], - Direction::ClientToServer, - session, - 4096, - ) - .unwrap(); - if request.body == expected_query { - decoded_request = Some(request); - break; - } - } - let request = - decoded_request.expect("application request after bounded service traffic"); - last_request_id = request.message_id; - if case == "disconnect" { - return request.message_id; - } - if case == "scheduled_disconnect" && round == 0 { - drop(socket); - continue; - } - let result = match case { - "flood" => encode_boxed_bytes(&RpcErrorConstructor { - error_code: 420, - error_message: "FLOOD_WAIT_17".into(), - }) - .unwrap(), - "migrate" => encode_boxed_bytes(&RpcErrorConstructor { - error_code: 303, - error_message: "FILE_MIGRATE_4".into(), - }) - .unwrap(), - "auth" => encode_boxed_bytes(&RpcErrorConstructor { - error_code: 401, - error_message: "SESSION_REVOKED".into(), - }) - .unwrap(), - "pin_permission" | "leave_creator" => { - encode_boxed_bytes(&RpcErrorConstructor { - error_code: if case == "pin_permission" { 403 } else { 400 }, - error_message: if case == "pin_permission" { - "CHAT_ADMIN_REQUIRED" - } else { - "USER_CREATOR" - } - .into(), - }) - .unwrap() - } - _ => vec![0x34; 4], - }; - let mut body = RPC_RESULT.to_le_bytes().to_vec(); - body.extend_from_slice(&request.message_id.to_le_bytes()); - body.extend_from_slice(&result); - let padding = vec![3; 12 + (16 - ((32 + body.len() + 12) % 16)) % 16]; - let mut response = encrypt_message( - &[7; 256], - MessageToEncrypt { - server_salt: 0, - session_id: session, - message_id: ((SystemClock.unix_micros() / 1_000_000) << 32) | 1, - sequence: 1, - body: &body, - padding: &padding, - }, - Direction::ServerToClient, - ) - .unwrap(); - if case == "tamper" { - response[24] ^= 1; - } - let mut framed = PaddedIntermediate::default().encode(&response).unwrap(); - let mut reversed = header; - reversed.reverse(); - let mut outgoing = AesCtr::new(&reversed[8..40], &reversed[40..56]).unwrap(); - outgoing.apply(&mut framed); - for bytes in framed.chunks(7) { - socket.write_all(bytes).unwrap(); - } - } - last_request_id - }); - let result = with_live_transport(&mut slot, || { - with_rpc_timeout_secs(2, || invoke_raw(&mut snapshot, query)) - }); - if case == "scheduled_disconnect" { - assert!( - result.is_err(), - "lost mutation response must remain uncertain" - ); - assert!(slot.is_none()); - slot = Some(open_live_addr(2, &address, None, 2).unwrap()); - let history = with_live_transport(&mut slot, || { - with_rpc_timeout_secs(2, || invoke_raw(&mut snapshot, reconnect_query)) - }); - assert_eq!(history.unwrap(), vec![0x34; 4]); - assert!(slot.is_some()); - assert!(server.join().unwrap() > 0); - continue; - } - assert!(server.join().unwrap() > 0); - match case { - "success" => { - assert_eq!(result.unwrap(), vec![0x34; 4]); - assert!(slot.is_some()); - } - "flood" => { - assert!(result.unwrap_err().to_string().contains("FLOOD_WAIT_17")); - assert!( - slot.is_some(), - "correlated flood invalidated the authenticated socket" - ); - } - "pin_permission" | "leave_creator" => { - let error = result.unwrap_err().to_string(); - assert!(error.contains(if case == "pin_permission" { - "CHAT_ADMIN_REQUIRED" - } else { - "USER_CREATOR" - })); - assert!( - slot.is_some(), - "permission rejection must retain the authenticated socket" - ); - } - "migrate" => { - assert!(result.unwrap_err().to_string().contains("FILE_MIGRATE_4")); - assert!(slot.is_none()); - } - "auth" => { - assert!(result.unwrap_err().to_string().contains("SESSION_REVOKED")); - assert!(slot.is_none()); - } - _ => { - assert!( - result.is_err(), - "uncertain or unauthenticated response accepted" - ); - assert!(slot.is_none()); - } - } - } -} diff --git a/native/mtproto-rs/tests/unit/salt_book_tests.rs b/native/mtproto-rs/tests/unit/salt_book_tests.rs deleted file mode 100644 index 41f1a549a..000000000 --- a/native/mtproto-rs/tests/unit/salt_book_tests.rs +++ /dev/null @@ -1,19 +0,0 @@ -use super::*; - -#[test] -fn forget_session_drops_windows_and_prefetch_stamp() { - let id = i64::MAX - 7; - store_windows( - id, - &[SaltWindow { - valid_since: 0, - valid_until: 100, - salt: 7, - }], - ); - mark_requested(id, 1); - assert_eq!(preferred_salt(id, 10), Some(7)); - forget_session(id); - assert_eq!(preferred_salt(id, 10), None); - assert!(should_prefetch(id, 1)); -} diff --git a/native/mtproto-rs/tests/unit/scheduler_tests.rs b/native/mtproto-rs/tests/unit/scheduler_tests.rs deleted file mode 100644 index e74e0e673..000000000 --- a/native/mtproto-rs/tests/unit/scheduler_tests.rs +++ /dev/null @@ -1,441 +0,0 @@ -use super::*; -use std::sync::mpsc; - -#[test] -fn lane_queue_capacity_is_bounded_and_cancelled_admission_releases_capacity() { - let gate = LaneGate::new(); - let held = gate.acquire(RequestClass::InteractiveRead).unwrap(); - let mut waiters: Vec<_> = (0..MAX_LANE_WAITERS) - .map(|_| gate.enqueue(RequestClass::BackgroundRead).unwrap()) - .collect(); - assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS); - assert!(gate.enqueue(RequestClass::InteractiveWrite).is_err()); - assert!(gate.acquire(RequestClass::InteractiveWrite).is_err()); - assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS); - drop(waiters.pop()); - let request = crate::request_control::create(); - let previous = crate::request_control::bind(request); - crate::request_control::cancel(request); - assert!(gate.acquire(RequestClass::InteractiveRead).is_err()); - crate::request_control::bind(previous); - crate::request_control::release(request); - assert_eq!(gate.waiter_count(), MAX_LANE_WAITERS - 1); - drop(waiters); - drop(held); - assert_eq!(gate.waiter_count(), 0); - assert!(gate.try_acquire(RequestClass::InteractiveRead).is_some()); -} - -#[test] -fn background_aging_never_overtakes_writes() { - let waiter = Waiter { - ticket: 0, - priority: 4, - queued_at: 0, - }; - assert_eq!(waiter.order_at(0).0, 4); - assert_eq!(waiter.order_at(5_000).0, 3); - assert_eq!(waiter.order_at(60_000).0, 1); - let write = Waiter { - ticket: 1, - priority: 0, - queued_at: 0, - }; - assert_eq!(write.order_at(60_000).0, 0); -} - -struct ManualClock(std::sync::atomic::AtomicU64); - -impl ManualClock { - fn advance(&self, ms: u64) { - self.0.fetch_add(ms, std::sync::atomic::Ordering::SeqCst); - } -} - -impl SchedulerClock for ManualClock { - fn now_ms(&self) -> u64 { - self.0.load(std::sync::atomic::Ordering::SeqCst) - } -} - -#[test] -fn injected_clock_ages_background_work_past_a_later_interactive_read_without_sleeping() { - let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); - let gate = LaneGate::with_clock(clock.clone()); - let background = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); - clock.advance(4_999); - let read = gate.enqueue(RequestClass::InteractiveRead).unwrap(); - assert!( - background.try_acquire().is_none(), - "a fresh interactive read still wins" - ); - clock.advance(20_000); - let read_late = gate.enqueue(RequestClass::InteractiveRead).unwrap(); - assert!( - read_late.try_acquire().is_none(), - "ticket order decides equal effective priority" - ); - drop(read); - let lease = background - .try_acquire() - .expect("aged background request is next"); - drop(lease); - assert!(read_late.try_acquire().is_some()); -} - -#[test] -fn aging_stops_below_interactive_write_priority() { - let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); - let gate = LaneGate::with_clock(clock.clone()); - let background = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); - clock.advance(10 * 60_000); - let write = gate.enqueue(RequestClass::InteractiveWrite).unwrap(); - assert!(background.try_acquire().is_none()); - assert!(write.try_acquire().is_some()); -} - -#[test] -fn promotion_raises_a_queued_ticket_and_never_lowers_it() { - let gate = LaneGate::new(); - let read = gate.enqueue(RequestClass::InteractiveRead).unwrap(); - let media = gate.enqueue(RequestClass::BackgroundMedia).unwrap(); - media.promote(RequestClass::InteractiveWrite); - assert!(read.try_acquire().is_none()); - assert!(media.try_acquire().is_some()); - let other = LaneGate::new(); - let first = other.enqueue(RequestClass::InteractiveRead).unwrap(); - let second = other.enqueue(RequestClass::InteractiveRead).unwrap(); - second.promote(RequestClass::BackgroundMedia); - assert!( - first.try_acquire().is_some(), - "promotion to a lower class changes nothing" - ); - drop(second); -} - -#[test] -fn a_freed_sibling_lane_is_taken_without_waiting_for_the_first_lane() { - let first = LaneGate::new(); - let second = LaneGate::new(); - let busy = first.acquire(RequestClass::InteractiveRead).unwrap(); - let waiters = [ - first.enqueue(RequestClass::InteractiveMedia).unwrap(), - second.enqueue(RequestClass::InteractiveMedia).unwrap(), - ]; - let acquired = waiters.iter().find_map(|waiter| waiter.try_acquire()); - assert!( - acquired.is_some(), - "the free lane serves the request while another is busy" - ); - drop(busy); -} - -#[test] -fn cancelling_a_family_request_releases_every_reservation() { - let first = LaneGate::new(); - let second = LaneGate::new(); - let waiters = [ - first.enqueue(RequestClass::BackgroundMedia).unwrap(), - second.enqueue(RequestClass::BackgroundMedia).unwrap(), - ]; - assert_eq!(first.waiter_count() + second.waiter_count(), 2); - drop(waiters); - assert_eq!(first.waiter_count() + second.waiter_count(), 0); - assert!(first.try_acquire(RequestClass::BackgroundMedia).is_some()); -} - -#[test] -fn fast_admission_cannot_pass_an_equal_priority_waiter() { - let gate = LaneGate::new(); - let waiter = gate.enqueue(RequestClass::InteractiveRead).unwrap(); - assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); - let lease = waiter - .try_acquire() - .expect("queued owner acquires the lane"); - assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); - drop(lease); - drop(waiter); - assert!(gate.try_acquire(RequestClass::InteractiveRead).is_some()); -} - -#[test] -fn cancelled_waiter_does_not_block_following_requests() { - let gate = LaneGate::new(); - let held = gate.acquire(RequestClass::InteractiveRead).unwrap(); - let request = crate::request_control::create(); - let previous = crate::request_control::bind(request); - crate::request_control::cancel(request); - let result = gate.acquire(RequestClass::InteractiveWrite); - crate::request_control::bind(previous); - crate::request_control::release(request); - assert!(result.is_err()); - assert!(gate.state.lock().waiters.is_empty()); - drop(held); - assert!(gate.try_acquire(RequestClass::BackgroundMedia).is_some()); -} - -#[test] -fn reads_never_use_the_media_lanes_and_media_never_uses_read_lanes() { - assert_eq!(family(RequestClass::InteractiveWrite), LaneFamily::Read); - assert_eq!(family(RequestClass::InteractiveRead), LaneFamily::Read); - assert_eq!(family(RequestClass::BackgroundRead), LaneFamily::Read); - assert_eq!(family(RequestClass::InteractiveMedia), LaneFamily::Media); - assert_eq!(family(RequestClass::BackgroundMedia), LaneFamily::Media); -} - -#[test] -fn interactive_classes_outrank_background_classes() { - assert!(RequestClass::InteractiveWrite.priority() < RequestClass::InteractiveRead.priority()); - assert!(RequestClass::InteractiveRead.priority() < RequestClass::BackgroundRead.priority()); - assert!(RequestClass::InteractiveMedia.priority() < RequestClass::BackgroundMedia.priority()); -} - -#[test] -fn dispatch_class_round_trips_through_the_thread_local() { - for class in [ - RequestClass::InteractiveRead, - RequestClass::BackgroundRead, - RequestClass::InteractiveMedia, - RequestClass::BackgroundMedia, - RequestClass::InteractiveWrite, - ] { - set_current_class(class); - assert_eq!(current_class(), class); - } - set_current_class(RequestClass::InteractiveRead); -} - -#[test] -fn lane_orders_cover_every_lane_exactly_once() { - let _serial = ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|error| error.into_inner()); - let reads: Vec = read_lane_order().collect(); - assert_eq!(reads.len(), read_lanes()); - let mut sorted = reads.clone(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted.len(), read_lanes()); - assert_eq!(media_lane_order().count(), active_media_lanes()); -} - -#[test] -fn unknown_allowance_keeps_a_single_main_session() { - let _policy = crate::transfer_policy::bind(std::sync::Arc::new( - crate::transfer_policy::TransferPolicy::stock(), - )); - assert!(!main_session_allowance_known()); - assert_eq!(main_session_allowance(), 1); - assert_eq!(extra_main_sessions(), 0); - assert_eq!(read_lanes(), 0); -} - -#[test] -fn tmp_sessions_bounds_extra_main_sessions() { - let _serial = ALLOWANCE_TEST_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let _policy = crate::transfer_policy::bind(std::sync::Arc::new( - crate::transfer_policy::TransferPolicy::stock(), - )); - set_bound_extra_sessions(0); - // Absent `tmp_sessions` means one main session: no parallel home-DC lanes. - assert_eq!(set_main_session_allowance(None), 1); - assert!(main_session_allowance_known()); - assert_eq!(extra_main_sessions(), 0); - assert_eq!(read_lanes(), 0); - assert_eq!(set_main_session_allowance(Some(0)), 1); - assert_eq!(set_main_session_allowance(Some(99)), MAX_MAIN_SESSIONS); - assert_eq!(set_main_session_allowance(Some(3)), 3); - assert_eq!(extra_main_sessions(), 0); - assert_eq!(read_lanes(), 0); - assert_eq!(set_main_session_allowance(Some(2)), 2); - assert_eq!(read_lanes(), 0); - set_bound_extra_sessions(2); - assert_eq!(extra_main_sessions(), 1); - assert_eq!(read_lanes(), 1); - set_main_session_allowance(Some(8)); - set_bound_extra_sessions(2); - assert_eq!(extra_main_sessions(), 2); - set_bound_extra_sessions(0); - assert_eq!(extra_main_sessions(), 0); -} - -#[test] -fn active_media_lanes_is_clamped_and_restored() { - let original = active_media_lanes(); - set_active_media_lanes(0); - assert_eq!( - active_media_lanes(), - 1, - "at least one lane must stay usable" - ); - set_active_media_lanes(999); - assert_eq!(active_media_lanes(), MAX_MEDIA_LANES); - set_active_media_lanes(3); - assert_eq!(media_lane_order().count(), 3); - set_active_media_lanes(original); -} - -#[test] -fn with_class_scopes_and_restores_the_thread_class() { - set_current_class(RequestClass::InteractiveRead); - assert_eq!( - with_class(RequestClass::InteractiveMedia, current_class), - RequestClass::InteractiveMedia - ); - assert_eq!(current_class(), RequestClass::InteractiveRead); - let nested = with_class(RequestClass::BackgroundRead, || { - with_class(RequestClass::InteractiveMedia, current_class) - }); - assert_eq!(nested, RequestClass::InteractiveMedia); - assert_eq!(current_class(), RequestClass::InteractiveRead); -} - -#[test] -fn gate_admits_the_highest_priority_waiter_first() { - let gate = std::sync::Arc::new(LaneGate::new()); - let held = gate - .acquire(RequestClass::InteractiveRead) - .expect("hold the lane"); - let (done_tx, done_rx) = mpsc::channel::<&'static str>(); - - // Background asks first, interactive second: interactive must still win. - let background = { - let gate = gate.clone(); - let done_tx = done_tx.clone(); - std::thread::spawn(move || { - let _guard = gate - .acquire(RequestClass::BackgroundRead) - .expect("background"); - let _ = done_tx.send("background"); - }) - }; - std::thread::sleep(Duration::from_millis(60)); - let interactive = { - let gate = gate.clone(); - let done_tx = done_tx.clone(); - std::thread::spawn(move || { - let _guard = gate - .acquire(RequestClass::InteractiveRead) - .expect("interactive"); - let _ = done_tx.send("interactive"); - }) - }; - std::thread::sleep(Duration::from_millis(60)); - drop(held); - let first = done_rx - .recv_timeout(Duration::from_secs(5)) - .expect("first admission"); - let second = done_rx - .recv_timeout(Duration::from_secs(5)) - .expect("second admission"); - interactive.join().expect("interactive thread"); - background.join().expect("background thread"); - assert_eq!( - first, "interactive", - "background work was admitted before an interactive read" - ); - assert_eq!(second, "background"); -} - -#[test] -fn visible_media_precedes_queued_background_reads() { - let gate = std::sync::Arc::new(LaneGate::new()); - let held = gate.acquire(RequestClass::InteractiveRead).unwrap(); - let (tx, rx) = mpsc::channel(); - let background = { - let gate = gate.clone(); - let tx = tx.clone(); - std::thread::spawn(move || { - let _guard = gate.acquire(RequestClass::BackgroundRead).unwrap(); - tx.send("background").unwrap(); - }) - }; - let media = { - let gate = gate.clone(); - std::thread::spawn(move || { - let _guard = gate.acquire(RequestClass::InteractiveMedia).unwrap(); - tx.send("media").unwrap(); - }) - }; - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while gate.state.lock().waiters.len() != 2 { - assert!( - std::time::Instant::now() < deadline, - "waiters did not register" - ); - std::thread::yield_now(); - } - drop(held); - let first = rx.recv_timeout(Duration::from_secs(2)).unwrap(); - let second = rx.recv_timeout(Duration::from_secs(2)).unwrap(); - background.join().unwrap(); - media.join().unwrap(); - assert_eq!((first, second), ("media", "background")); -} - -#[test] -fn a_higher_class_waiter_never_preempts_the_running_work() { - let gate = std::sync::Arc::new(LaneGate::new()); - let held = gate - .acquire(RequestClass::BackgroundMedia) - .expect("hold the lane"); - let (done_tx, done_rx) = mpsc::channel::<&'static str>(); - let interactive = { - let gate = gate.clone(); - std::thread::spawn(move || { - let _guard = gate - .acquire(RequestClass::InteractiveMedia) - .expect("interactive media"); - let _ = done_tx.send("interactive"); - }) - }; - assert!( - done_rx.recv_timeout(Duration::from_millis(150)).is_err(), - "a higher class preempted work that was already running" - ); - drop(held); - assert_eq!( - done_rx - .recv_timeout(Duration::from_secs(5)) - .expect("admission after release"), - "interactive" - ); - interactive.join().expect("interactive thread"); -} - -#[test] -fn gate_is_fifo_inside_one_class() { - let gate = std::sync::Arc::new(LaneGate::new()); - let held = gate - .acquire(RequestClass::InteractiveRead) - .expect("hold the lane"); - let (order_tx, order_rx) = mpsc::channel::(); - let mut threads = Vec::new(); - for index in 0..3_u8 { - let gate = gate.clone(); - let order_tx = order_tx.clone(); - threads.push(std::thread::spawn(move || { - let _guard = gate.acquire(RequestClass::InteractiveRead).expect("waiter"); - let _ = order_tx.send(index); - })); - std::thread::sleep(Duration::from_millis(40)); - } - drop(held); - let order: Vec = (0..3) - .map(|_| { - order_rx - .recv_timeout(Duration::from_secs(5)) - .expect("admission") - }) - .collect(); - for thread in threads { - thread.join().expect("waiter thread"); - } - assert_eq!( - order, - vec![0, 1, 2], - "same-class waiters must keep FIFO order" - ); -} diff --git a/native/mtproto-rs/tests/unit/service_messages_tests.rs b/native/mtproto-rs/tests/unit/service_messages_tests.rs deleted file mode 100644 index 51f43d688..000000000 --- a/native/mtproto-rs/tests/unit/service_messages_tests.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::encode_service; - -#[test] -fn encode_uses_unit_separator() { - let raw = encode_service("pin", "Ada", ""); - assert_eq!(raw, "pin\u{1f}Ada\u{1f}"); - let add = encode_service("add", "Ada", "Bob, Carol"); - assert_eq!(add, "add\u{1f}Ada\u{1f}Bob, Carol"); -} diff --git a/native/mtproto-rs/tests/unit/session_file_tests.rs b/native/mtproto-rs/tests/unit/session_file_tests.rs deleted file mode 100644 index 5cbcf642d..000000000 --- a/native/mtproto-rs/tests/unit/session_file_tests.rs +++ /dev/null @@ -1,442 +0,0 @@ -use super::*; -use tellers_mtproto_session::OsRandom; - -struct TestDir(PathBuf); - -impl TestDir { - fn new() -> Self { - let mut nonce = [0_u8; 16]; - tellers_mtproto_crypto::fill_random(&mut nonce).unwrap(); - let path = std::env::temp_dir().join(format!( - "mtproto-session-{:032x}", - u128::from_le_bytes(nonce) - )); - fs::create_dir_all(&path).unwrap(); - Self(path) - } - - fn store(&self) -> FileSessionStore { - FileSessionStore::new(self.0.join("session.json")) - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} - -fn seed(store: &FileSessionStore) -> ClientSession { - let snapshot = Snapshot::new(2, &mut OsRandom).unwrap(); - fs::write(&store.path, serde_json::to_vec(&snapshot).unwrap()).unwrap(); - store.load().unwrap().unwrap() -} - -#[test] -fn missing_session_is_absent_but_empty_and_corrupt_files_fail() { - let dir = TestDir::new(); - let store = dir.store(); - assert!(store.load().unwrap().is_none()); - for bytes in [b"".as_slice(), b"{\"snapshot\":", b"{}"] { - fs::write(&store.path, bytes).unwrap(); - assert!(store.load().is_err()); - } -} - -#[test] -fn legacy_snapshot_and_full_session_round_trip() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - assert!(session.dc_auth_keys.is_empty()); - assert!(session.channel_recovery.is_empty()); - session.dc_auth_keys.insert( - 4, - DcAuthKey { - key: vec![9; 256], - salt: 11, - }, - ); - session.user_id = Some(42); - session.channel_pts.insert(-1000000000001, 17); - session.channel_recovery.push_back(ChannelRecovery { - chat_id: -1000000000001, - due_at: 123, - watching: false, - }); - session.updates = Some(UpdatesStateDto { - pts: 4, - qts: 5, - seq: 6, - date: 7, - }); - store.save(&session).unwrap(); - let loaded = store.load().unwrap().unwrap(); - assert_eq!(loaded.snapshot.session_id, session.snapshot.session_id); - assert_eq!(loaded.user_id, Some(42)); - assert_eq!(loaded.channel_pts, session.channel_pts); - assert_eq!(loaded.channel_recovery, session.channel_recovery); - assert_eq!(loaded.updates.unwrap().qts, 5); - assert_eq!(loaded.dc_auth_keys.len(), 1); - assert_eq!(loaded.dc_auth_keys[&4].salt, 11); - assert_eq!(loaded.dc_auth_keys[&4].key, vec![9; 256]); - let legacy = serde_json::json!({"snapshot": loaded.snapshot}); - let without_keys: ClientSession = serde_json::from_value(legacy).unwrap(); - assert!(without_keys.dc_auth_keys.is_empty()); - store.clear().unwrap(); - store.clear().unwrap(); -} - -#[test] -fn concurrent_writers_never_share_or_leave_temporary_files() { - let dir = TestDir::new(); - let store = dir.store(); - let session = seed(&store); - std::thread::scope(|scope| { - for user_id in 1..=8 { - let mut session = session.clone(); - let path = store.path.clone(); - scope.spawn(move || { - session.user_id = Some(user_id); - let store = FileSessionStore::new(path); - for _ in 0..8 { - store.save(&session).unwrap(); - assert!(store.load().unwrap().unwrap().user_id.is_some()); - } - }); - } - }); - assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1); -} - -#[test] -fn invalid_snapshot_does_not_replace_valid_session() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - let original = fs::read(&store.path).unwrap(); - session.snapshot.auth_key = Some(vec![1; 3]); - assert!(store.save(&session).is_err()); - assert_eq!(fs::read(&store.path).unwrap(), original); -} - -#[test] -fn destroyed_client_cannot_commit_an_old_snapshot() { - let dir = TestDir::new(); - let store = dir.store(); - let session = seed(&store); - let original = fs::read(&store.path).unwrap(); - let control = std::sync::Arc::new(crate::tcp::ConnectionControl::default()); - control.close(); - crate::tcp::with_connection_control(&control, || { - assert!(store.save(&session).is_err()); - }); - assert_eq!(fs::read(&store.path).unwrap(), original); - assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1); -} - -#[test] -fn encryption_migrates_legacy_session_without_losing_identity() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - session.user_id = Some(42); - session.snapshot.auth_key = Some(vec![7; 256]); - store.save(&session).unwrap(); - let key = crate::session_crypto::register(&store.path, &[9; 32]).unwrap(); - let restored = store.load().unwrap().unwrap(); - assert_eq!(restored.user_id, Some(42)); - assert_eq!(restored.snapshot.auth_key, session.snapshot.auth_key); - let first = fs::read(&store.path).unwrap(); - assert!(first.starts_with(crate::session_crypto::MAGIC)); - assert!(serde_json::from_slice::(&first).is_err()); - store.save(&restored).unwrap(); - assert_ne!(first, fs::read(&store.path).unwrap()); - assert_eq!(store.load().unwrap().unwrap().user_id, Some(42)); - drop(key); - assert!(store.load().is_err()); - let _wrong_key = crate::session_crypto::register(&store.path, &[8; 32]).unwrap(); - assert!(store.load().is_err()); -} - -#[test] -fn encrypted_tampering_and_truncation_fail_without_replacing_session() { - let dir = TestDir::new(); - let store = dir.store(); - let session = seed(&store); - let _key = crate::session_crypto::register(&store.path, &[9; 32]).unwrap(); - store.save(&session).unwrap(); - let valid = fs::read(&store.path).unwrap(); - for index in [0, crate::session_crypto::MAGIC.len(), valid.len() - 1] { - let mut damaged = valid.clone(); - damaged[index] ^= 1; - fs::write(&store.path, &damaged).unwrap(); - assert!(store.load().is_err()); - assert_eq!(fs::read(&store.path).unwrap(), damaged); - } - fs::write(&store.path, &valid[..valid.len() - 1]).unwrap(); - assert!(store.load().is_err()); -} - -#[test] -fn failed_encryption_migration_preserves_original() { - let dir = TestDir::new(); - let store = dir.store(); - seed(&store); - let original = fs::read(&store.path).unwrap(); - let _key = crate::session_crypto::register(&store.path, &[9; 32]).unwrap(); - let control = std::sync::Arc::new(crate::tcp::ConnectionControl::default()); - control.close(); - crate::tcp::with_connection_control(&control, || assert!(store.load().is_err())); - assert_eq!(fs::read(&store.path).unwrap(), original); - assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1); -} - -#[test] -fn encrypted_client_owns_key_until_destroy_and_restores_authorization() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - session.user_id = Some(42); - session.snapshot.auth_key = Some(vec![7; 256]); - store.save(&session).unwrap(); - let path = store.path.to_string_lossy().to_string(); - assert!( - crate::create_encrypted_client(1, "fixture".into(), path.clone(), vec![9; 31]).is_err() - ); - let handle = - crate::create_encrypted_client(1, "fixture".into(), path.clone(), vec![9; 32]).unwrap(); - assert!(crate::is_authorized(handle).unwrap()); - assert!(crate::session_crypto::key_for(&store.path).is_some()); - crate::destroy_client(handle); - assert!(crate::session_crypto::key_for(&store.path).is_none()); - assert!(crate::create_encrypted_client(1, "fixture".into(), path, vec![8; 32]).is_err()); -} - -#[test] -fn encryption_reads_legacy_magic_and_rewrites_current() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - session.user_id = Some(42); - let key = crate::session_crypto::register(&store.path, &[9; 32]).unwrap(); - let plaintext = serde_json::to_vec(&session).unwrap(); - let encrypted = crate::session_crypto::encrypt_legacy(&plaintext, &key).unwrap(); - assert!(encrypted.starts_with(crate::session_crypto::LEGACY_MAGIC)); - fs::write(&store.path, &encrypted).unwrap(); - let restored = store.load().unwrap().unwrap(); - assert_eq!(restored.user_id, Some(42)); - store.save(&restored).unwrap(); - assert!( - fs::read(&store.path) - .unwrap() - .starts_with(crate::session_crypto::MAGIC) - ); -} - -#[test] -fn encryption_uses_opaque_tag_and_fresh_salt() { - let dir = TestDir::new(); - let key = crate::session_crypto::register(&dir.0.join("key"), &[9; 32]).unwrap(); - let first = crate::session_crypto::encrypt(b"{}", &key).unwrap(); - let second = crate::session_crypto::encrypt(b"{}", &key).unwrap(); - assert!(first.starts_with(crate::session_crypto::MAGIC)); - assert_ne!(first, second); - assert!( - !first - .windows(6) - .any(|window| window == b"MONORE" || window == b"MONOGR") - ); - assert_eq!( - crate::session_crypto::decrypt(&first, &key) - .unwrap() - .as_slice(), - b"{}" - ); -} - -fn photo_ref(id: i64) -> MediaRef { - MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: format!("photo:{id}"), - location: crate::media::MediaLocation::Photo { - id, - access_hash: 1, - file_reference: vec![1], - thumb_size: "x".into(), - dc_id: 2, - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - } -} - -fn document_ref(id: i64) -> MediaRef { - MediaRef { - file_size: Some(4), - kind: "sticker".into(), - cache_key: format!("doc:{id}"), - location: crate::media::MediaLocation::Document { - id, - access_hash: 1, - file_reference: vec![2], - thumb_size: String::new(), - dc_id: 2, - mime_type: "image/webp".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - } -} - -fn inline_ref(bytes: Vec) -> MediaRef { - MediaRef { - file_size: None, - kind: "photo".into(), - cache_key: "iv".into(), - location: crate::media::MediaLocation::Inline { - bytes, - extension: "jpg".into(), - }, - thumb_cache_key: None, - thumb_location: None, - display_cache_key: None, - display_location: None, - sticker_set_id: None, - sticker_set_access_hash: None, - source_url: None, - } -} - -#[test] -fn media_retention_keeps_avatars_and_caps_other_slices() { - use crate::media::{ - MESSAGE_MEDIA_CAP, PERSISTED_INLINE_BYTE_CAP, ZERO_ID_DOCUMENT_CAP, insert_media, - }; - let mut index = MediaIndex::default(); - insert_media( - &mut index, - (9, 0), - crate::media::media_ref_peer_photo( - crate::peers::PeerKind::User, - 9, - 4, - 9, - 2, - "avatar:9".into(), - false, - None, - ), - ); - let mut video = photo_ref(8); - video.kind = "video_avatar".into(); - video.cache_key = "avatar:8:video".into(); - insert_media(&mut index, (8, 0), video); - for id in 1..=MESSAGE_MEDIA_CAP as i32 + 1 { - insert_media(&mut index, (3, id), photo_ref(i64::from(id))); - } - for id in 0..=ZERO_ID_DOCUMENT_CAP as i64 { - insert_media(&mut index, (1_000 + id, 0), document_ref(1_000 + id)); - } - insert_media(&mut index, (50, -1), inline_ref(vec![1; 32])); - let mut kept = inline_ref(vec![9; PERSISTED_INLINE_BYTE_CAP + 1]); - kept.source_url = Some("https://example.invalid/page".into()); - insert_media(&mut index, (51, -1), kept); - - assert!(index.contains_key(&(9, 0))); - assert!(index.contains_key(&(8, 0))); - assert!(!index.contains_key(&(3, 1))); - assert!(index.contains_key(&(3, MESSAGE_MEDIA_CAP as i32 + 1))); - assert!(!index.contains_key(&(1_000, 0))); - assert!(index.contains_key(&(1_000 + ZERO_ID_DOCUMENT_CAP as i64, 0))); - match &index.get(&(51, -1)).unwrap().location { - crate::media::MediaLocation::Inline { bytes, .. } => { - assert!(bytes.len() > PERSISTED_INLINE_BYTE_CAP); - } - _ => panic!("live instant view"), - } - - let saved = media_from_index(&index); - let order = media_order_from_index(&index); - assert!(saved.contains_key(&media_key(9, 0))); - assert!(saved.contains_key(&media_key(8, 0))); - assert!(!saved.contains_key(&media_key(3, 1))); - assert!(saved.contains_key(&media_key(3, MESSAGE_MEDIA_CAP as i32 + 1))); - assert!(!saved.contains_key(&media_key(1_000, 0))); - assert!(saved.contains_key(&media_key(1_000 + ZERO_ID_DOCUMENT_CAP as i64, 0))); - assert!(!saved.contains_key(&media_key(50, -1))); - let persisted = saved.get(&media_key(51, -1)).unwrap(); - assert_eq!( - persisted.source_url.as_deref(), - Some("https://example.invalid/page") - ); - match &persisted.location { - crate::media::MediaLocation::Inline { bytes, .. } => assert!(bytes.is_empty()), - _ => panic!("persisted instant view"), - } - - let restored = media_to_index(&saved, &order); - assert!(restored.contains_key(&(9, 0))); - assert!(restored.contains_key(&(8, 0))); - assert!(restored.contains_key(&(3, MESSAGE_MEDIA_CAP as i32 + 1))); - assert!(restored.contains_key(&(1_000 + ZERO_ID_DOCUMENT_CAP as i64, 0))); - assert!(restored.contains_key(&(51, -1))); - assert!(!restored.contains_key(&(50, -1))); -} - -#[test] -fn oversized_session_media_loads_and_next_save_shrinks() { - let dir = TestDir::new(); - let store = dir.store(); - let mut session = seed(&store); - for id in 1..=2_000 { - session - .media - .insert(media_key(4, id), photo_ref(i64::from(id))); - } - session.media.insert( - media_key(9, 0), - crate::media::media_ref_peer_photo( - crate::peers::PeerKind::User, - 9, - 4, - 9, - 2, - "avatar:9".into(), - false, - None, - ), - ); - session.media_order.clear(); - let mut value = serde_json::to_value(&session).unwrap(); - value.as_object_mut().unwrap().remove("media_order"); - let loaded: ClientSession = serde_json::from_value(value).unwrap(); - assert!(loaded.media_order.is_empty()); - assert!(loaded.media.len() > crate::media::MESSAGE_MEDIA_CAP); - store.save(&loaded).unwrap(); - let from_disk = store.load().unwrap().unwrap(); - assert!(from_disk.media.len() > crate::media::MESSAGE_MEDIA_CAP); - let index = media_to_index(&from_disk.media, &from_disk.media_order); - assert!(index.contains_key(&(9, 0))); - let messages = index.keys().filter(|key| key.1 > 0).count(); - assert!(messages <= crate::media::MESSAGE_MEDIA_CAP); - let mut shrunk = from_disk; - shrunk.media = media_from_index(&index); - shrunk.media_order = media_order_from_index(&index); - assert!(shrunk.media.len() < 2_000); - store.save(&shrunk).unwrap(); - let again = store.load().unwrap().unwrap(); - assert!(again.media.contains_key(&media_key(9, 0))); - assert!(again.media.len() <= crate::media::MESSAGE_MEDIA_CAP + 1); -} diff --git a/native/mtproto-rs/tests/unit/shared_folders_tests.rs b/native/mtproto-rs/tests/unit/shared_folders_tests.rs deleted file mode 100644 index b79a13e8c..000000000 --- a/native/mtproto-rs/tests/unit/shared_folders_tests.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::*; -use tellers_mtproto::codec::{Decoder, Limits, TlDecode}; - -#[test] -fn reserved_filters_blank_slugs_and_invalid_orders_fail_before_session_access() { - for id in [-1, 0, 1] { - assert!( - get_invites(0, id) - .unwrap_err() - .to_string() - .contains("folder") - ); - assert!( - leave(0, id, &[]) - .unwrap_err() - .to_string() - .contains("folder") - ); - assert!( - set_filter(0, id, None) - .unwrap_err() - .to_string() - .contains("folder") - ); - } - assert!( - check_invite(0, " ") - .unwrap_err() - .to_string() - .contains("slug") - ); - assert!( - edit_invite(0, 2, "slug", None, None) - .unwrap_err() - .to_string() - .contains("change") - ); - for order in [&[0, 2, 2][..], &[1, 2][..], &[-1][..]] { - assert!( - reorder_filters(0, order) - .unwrap_err() - .to_string() - .contains("order") - ); - } - assert!(confirm(Bool::BoolFalse(BoolFalseConstructor {})).is_err()); - assert!(confirm(Bool::BoolTrue(BoolTrueConstructor {})).is_ok()); -} - -fn user(id: i64, hash: i64, min: bool) -> User { - let flags = UserConstructor::ACCESS_HASH_FLAG | if min { UserConstructor::MIN_FLAG } else { 0 }; - let mut body = flags.to_le_bytes().to_vec(); - body.extend_from_slice(&0u32.to_le_bytes()); - body.extend_from_slice(&id.to_le_bytes()); - body.extend_from_slice(&hash.to_le_bytes()); - let mut decoder = Decoder::new(&body, Limits::default()).unwrap(); - let value = UserConstructor::decode(&mut decoder).unwrap(); - decoder.finish().unwrap(); - User::User(value) -} - -#[test] -fn preview_hashes_enable_exact_selections_and_min_updates_never_replace_them() { - let directory = std::env::temp_dir().join(format!( - "monogram-shared-folder-peers-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&directory).unwrap(); - let handle = super::super::create_client( - 1, - "fixture".into(), - directory.join("session.json").to_string_lossy().into(), - ); - let result = with_client_mut(handle, |state| { - assert!(peer_list(state, &[7]).is_err()); - let chats = Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - }); - cache_peers( - state, - &Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(user(7, 70, false))], - }), - &chats, - ); - let selection = peer_list(state, &[7])?; - let Vector::Vector(selected) = *selection; - assert_eq!(selected.field_1.len(), 1); - let InputPeer::InputPeerUser(peer) = &*selected.field_1[0] else { - panic!("user peer required") - }; - assert_eq!((peer.user_id, peer.access_hash), (7, 70)); - cache_peers( - state, - &Vector::Vector(VectorConstructor { - field_0: 2, - field_1: vec![Box::new(user(7, 999, true)), Box::new(user(8, 80, true))], - }), - &chats, - ); - assert_eq!(state.peers.get(&7).unwrap().access_hash, 70); - assert!(peer_list(state, &[8]).is_err()); - assert!(peer_list(state, &[7, 7]).is_err()); - assert!(peer_list(state, &[7, 99]).is_err()); - let Vector::Vector(empty) = *peer_list(state, &[])?; - assert!( - empty.field_1.is_empty(), - "leaving only the folder must not add chats to the leave selection" - ); - assert!(state.updates.is_none()); - let before_pts = state.channel_pts.clone(); - super::super::management::cache_response_peers( - state, - &Updates::Updates(UpdatesConstructor { - updates: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: vec![], - })), - users: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![Box::new(user(9, 90, false))], - })), - chats: Box::new(chats.clone()), - date: 123, - seq: 7, - }), - ); - assert!(peer_list(state, &[9]).is_ok()); - assert!( - state.updates.is_none(), - "metadata caching must not advance global cursors" - ); - assert_eq!(state.channel_pts, before_pts); - Ok(()) - }); - super::super::destroy_client(handle); - std::fs::remove_dir_all(&directory).unwrap(); - result.unwrap(); -} diff --git a/native/mtproto-rs/tests/unit/staging_sweep_tests.rs b/native/mtproto-rs/tests/unit/staging_sweep_tests.rs deleted file mode 100644 index fec720c95..000000000 --- a/native/mtproto-rs/tests/unit/staging_sweep_tests.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::*; - -#[test] -fn sweep_drops_stale_parts_over_the_cap_and_keeps_the_protected_file() { - let dir = std::env::temp_dir().join(format!( - "monogram-staging-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) - )); - std::fs::create_dir_all(&dir).unwrap(); - let protect = dir.join("keep.part"); - let stale = dir.join("old.part"); - std::fs::write(&protect, vec![1u8; 64]).unwrap(); - std::fs::write(&stale, vec![2u8; (STAGING_DIR_BYTE_CAP as usize) + 8]).unwrap(); - let old = std::time::SystemTime::now() - .checked_sub(std::time::Duration::from_secs(STAGING_STALE_SECS + 5)) - .unwrap(); - std::fs::File::options() - .write(true) - .open(&stale) - .unwrap() - .set_modified(old) - .unwrap(); - std::fs::File::options() - .write(true) - .open(&protect) - .unwrap() - .set_modified(old) - .unwrap(); - let orphan = dir.join("gone.identity.part"); - std::fs::write(&orphan, b"id").unwrap(); - sweep_stale_parts(&dir, &protect); - assert!(protect.is_file()); - assert!(!stale.exists()); - assert!(!orphan.exists(), "orphan identity sidecar must be removed"); - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/native/mtproto-rs/tests/unit/stripped_jpeg_tests.rs b/native/mtproto-rs/tests/unit/stripped_jpeg_tests.rs deleted file mode 100644 index 9fec0a262..000000000 --- a/native/mtproto-rs/tests/unit/stripped_jpeg_tests.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::*; - -#[test] -fn expands_compact_payload_into_jpeg() { - let stripped = [1u8, 40, 30, 0xAA, 0xBB, 0xCC]; - let jpeg = expand_stripped_jpeg(&stripped).expect("jpeg"); - assert_eq!(&jpeg[..2], &[0xFF, 0xD8]); - assert_eq!(&jpeg[jpeg.len() - 2..], &[0xFF, 0xD9]); - assert_eq!(jpeg[164], 40); - assert_eq!(jpeg[166], 30); - assert_eq!(&jpeg[HEADER.len()..HEADER.len() + 3], &[0xAA, 0xBB, 0xCC]); - assert_eq!(jpeg.len(), HEADER.len() + 3 + FOOTER.len()); -} - -#[test] -fn rejects_too_short() { - assert!(expand_stripped_jpeg(&[1, 2]).is_none()); - assert!(expand_stripped_jpeg(&[]).is_none()); -} - -#[test] -fn keeps_existing_jpeg() { - let raw = [0xFF, 0xD8, 0xFF, 0xD9]; - assert_eq!(expand_stripped_jpeg(&raw).as_deref(), Some(raw.as_slice())); -} diff --git a/native/mtproto-rs/tests/unit/tcp_tests.rs b/native/mtproto-rs/tests/unit/tcp_tests.rs deleted file mode 100644 index 483cd741f..000000000 --- a/native/mtproto-rs/tests/unit/tcp_tests.rs +++ /dev/null @@ -1,221 +0,0 @@ -use super::*; -use std::io::Read; -use std::net::TcpListener; - -#[test] -fn malformed_http_transport_returns_without_spinning_until_the_io_deadline() { - use std::io::Write; - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let peer = std::thread::spawn(move || { - let (mut socket, _) = listener.accept().unwrap(); - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut head = Vec::new(); - while !head.ends_with(b"\r\n\r\n") { - let mut byte = [0]; - socket.read_exact(&mut byte).unwrap(); - head.push(byte[0]); - assert!(head.len() < 16 * 1024); - } - socket.read_exact(&mut [0; 4]).unwrap(); - socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: invalid\r\n\r\n") - .unwrap(); - }); - let control = Arc::new(ConnectionControl { - config: monogram_mtproto_transport::TransportConfig { - mode: monogram_mtproto_transport::TransportMode::Http, - ..Default::default() - }, - ..Default::default() - }); - with_connection_control(&control, || { - let mut connection = connect_obfuscated_timeout(&address, 3).unwrap(); - connection.send(&[1, 2, 3, 4]).unwrap(); - let started = std::time::Instant::now(); - let error = connection.receive(&mut [0; 64]).unwrap_err(); - assert!(error.to_string().to_ascii_lowercase().contains("http")); - assert!(started.elapsed() < Duration::from_secs(2)); - }); - peer.join().unwrap(); -} - -#[test] -fn waitable_io_distinguishes_transient_errors_from_terminal_failures() { - for transient in [ - "operation timed out", - "os error 10060", - "Interrupted system call (os error 4)", - "resource temporarily unavailable", - ] { - assert!(is_waitable_io(transient)); - } - for terminal in [ - "invalid HTTP content length", - "connection reset by peer", - "client closed", - "MTProto transport error -404", - ] { - assert!(!is_waitable_io(terminal)); - } -} - -#[test] -fn shutdown_stops_io_and_publication_before_the_final_save() { - let control = ConnectionControl::default(); - control.begin_shutdown(); - assert!(control.wait(Duration::ZERO).is_err()); - assert!( - control - .while_open(|| panic!("publication during shutdown")) - .is_err() - ); - let saved = std::cell::Cell::new(false); - control.while_persisting(|| saved.set(true)).unwrap(); - assert!(saved.get()); - control.close(); - assert!( - control - .while_persisting(|| panic!("save after close")) - .is_err() - ); -} - -#[test] -fn persistence_does_not_block_io_and_close_joins_active_save() { - let control = Arc::new(ConnectionControl::default()); - let (entered_tx, entered_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let saving = control.clone(); - let saver = std::thread::spawn(move || { - saving - .while_open(|| { - entered_tx.send(()).unwrap(); - release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); - }) - .unwrap() - }); - entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - let checking = control.clone(); - let (checked_tx, checked_rx) = std::sync::mpsc::channel(); - let checker = std::thread::spawn(move || { - checked_tx - .send(checking.wait(Duration::ZERO).is_ok()) - .unwrap(); - }); - let checked = checked_rx.recv_timeout(Duration::from_millis(500)); - let closing = control.clone(); - let (closed_tx, closed_rx) = std::sync::mpsc::channel(); - let closer = std::thread::spawn(move || { - closing.close(); - closed_tx.send(()).unwrap(); - }); - let observing = control.clone(); - let (observed_tx, observed_rx) = std::sync::mpsc::channel(); - let observer = std::thread::spawn(move || { - observed_tx - .send(observing.wait(Duration::from_secs(3)).is_err()) - .unwrap(); - }); - let observed = observed_rx.recv_timeout(Duration::from_millis(500)); - let closed_early = closed_rx.try_recv().is_ok(); - release_tx.send(()).unwrap(); - saver.join().unwrap(); - checker.join().unwrap(); - closer.join().unwrap(); - observer.join().unwrap(); - assert_eq!(checked.ok(), Some(true), "save blocked socket state checks"); - assert_eq!(observed.ok(), Some(true), "save delayed I/O shutdown"); - assert!(!closed_early, "close returned before persistence completed"); - assert!(control.while_open(|| panic!("save after close")).is_err()); -} - -#[test] -fn close_wakes_receive_and_prevents_new_connections() { - 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 conn = TcpConnection::connect_timeout_secs(&addr, 1).unwrap(); - ready_tx.send(()).unwrap(); - let result = conn.receive(&mut [0; 4]); - assert!(matches!(result, Ok(0) | Err(_))); - assert!(TcpConnection::connect_timeout_secs(&addr, 1).is_err()); - done_tx.send(()).unwrap(); - }); - }); - let (_peer, _) = listener.accept().unwrap(); - ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - control.close(); - control.close(); - done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); - 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 (mut peer, _) = listener.accept().unwrap(); - peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); - peer.read_exact(&mut [0_u8; 64]).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()); - let worker_control = control.clone(); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - with_connection_control(&worker_control, || { - done_tx - .send(wait_reconnect(Duration::from_secs(60)).is_err()) - .unwrap(); - }); - }); - control.close(); - assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap()); - worker.join().unwrap(); - with_connection_control(&Arc::new(ConnectionControl::default()), || { - assert!(check_open().is_ok()); - with_connection_control(&control, || assert!(check_open().is_err())); - assert!(check_open().is_ok()); - }); -} - -#[test] -fn framing_survives_fragmented_tcp_and_eof() { - use tellers_mtproto_transport::{Framing, PaddedIntermediate}; - let mut framing = PaddedIntermediate::default(); - let wire = framing.encode(&[7; 32]).unwrap(); - for length in 0..wire.len() { - assert!(matches!( - framing.decode(&wire[..length]), - Err(TransportError::Incomplete { .. }) - )); - } - assert!(framing.decode(&wire).unwrap().payload.starts_with(&[7; 32])); -} diff --git a/native/mtproto-rs/tests/unit/transfer_policy_aging_tests.rs b/native/mtproto-rs/tests/unit/transfer_policy_aging_tests.rs deleted file mode 100644 index 43e160980..000000000 --- a/native/mtproto-rs/tests/unit/transfer_policy_aging_tests.rs +++ /dev/null @@ -1,25 +0,0 @@ -use super::*; - -#[test] -fn aging_preserves_fifo_and_never_overtakes_writes() { - let start = std::time::Instant::now(); - let waiter = DownloadWaiter { - ticket: 7, - priority: 4, - dc: 2, - large: false, - queued_at: start, - }; - assert_eq!( - waiter.order_at(start + std::time::Duration::from_secs(4)), - (4, 7) - ); - assert_eq!( - waiter.order_at(start + std::time::Duration::from_secs(5)), - (3, 7) - ); - assert_eq!( - waiter.order_at(start + std::time::Duration::from_secs(100)), - (1, 7) - ); -} diff --git a/native/mtproto-rs/tests/unit/transfer_policy_tests.rs b/native/mtproto-rs/tests/unit/transfer_policy_tests.rs deleted file mode 100644 index f21f6b2a8..000000000 --- a/native/mtproto-rs/tests/unit/transfer_policy_tests.rs +++ /dev/null @@ -1,406 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use super::*; -use crate::media::{DEFAULT_CHUNK, chunk_size, notify_progress, set_chunk_size}; -use crate::scheduler::{DEFAULT_MEDIA_LANES, active_media_lanes, set_active_media_lanes}; -use crate::transfer_policy::{self, TransferPolicy}; -use crate::upload::upload_rpc::{FILE_PART_FAST, FILE_PART_SMALL, file_part, set_file_part_kib}; - -#[test] -fn flood_deadlines_are_shared_between_threads_of_one_client_only() { - let first = Arc::new(TransferPolicy::stock()); - let second = Arc::new(TransferPolicy::stock()); - { - let _guard = transfer_policy::bind(Arc::clone(&first)); - crate::media::note_transfer_flood( - 2, - crate::media::TransferClass::Download, - &MtprotoError::Message("RPC 420: FLOOD_PREMIUM_WAIT_120".into()), - ); - } - std::thread::spawn(move || { - let _guard = transfer_policy::bind(first); - assert!( - crate::media::check_transfer_flood(2, crate::media::TransferClass::Download).is_err() - ); - assert!( - crate::media::check_transfer_flood(4, crate::media::TransferClass::Download).is_ok() - ); - assert!(crate::media::check_transfer_flood(2, crate::media::TransferClass::Upload).is_ok()); - }) - .join() - .unwrap(); - let _guard = transfer_policy::bind(second); - assert!(crate::media::check_transfer_flood(2, crate::media::TransferClass::Download).is_ok()); -} - -#[test] -fn download_profiles_and_uploads_respect_client_settings() { - let policy = Arc::new(TransferPolicy::stock()); - policy.own_chunk_size(256 * 1024); - policy.own_pipeline_parts(2); - policy.own_file_part_kib(32); - let _guard = transfer_policy::bind(policy); - let window = crate::media::configured_download_window(crate::media::DownloadProfile::User); - assert_eq!(window.chunk, 256 * 1024); - assert_eq!(window.in_flight, 2); - let limits = crate::upload::upload_rpc::upload_limits(8 * 1024 * 1024, None); - assert_eq!(limits.part_size, FILE_PART_SMALL); - assert_eq!(limits.in_flight, 8); - let thumb = crate::media::configured_download_window(crate::media::DownloadProfile::Thumb); - assert_eq!(thumb.in_flight, 1); -} - -#[test] -fn flood_recovery_serializes_downloads_across_file_queues_on_one_dc() { - let policy = Arc::new(TransferPolicy::stock()); - policy.floods.lock().park( - 2, - crate::media::TransferClass::Download, - 1, - false, - std::time::Instant::now() - std::time::Duration::from_secs(5), - crate::media::DownloadWindow { - chunk: crate::media::FAST_CHUNK, - in_flight: 8, - }, - ); - let held = policy.admit_download(2, true).unwrap(); - let other_dc = policy.admit_download(4, false).unwrap(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let waiting = policy.clone(); - let thread = std::thread::spawn(move || { - let _lease = waiting.admit_download(2, false).unwrap(); - ready_tx.send(()).unwrap(); - }); - assert!( - ready_rx - .recv_timeout(std::time::Duration::from_millis(100)) - .is_err() - ); - drop(held); - ready_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap(); - thread.join().unwrap(); - drop(other_dc); -} - -#[test] -fn unowned_policy_keeps_large_chunks_but_respects_pipeline_limit() { - let policy = Arc::new(TransferPolicy::stock()); - let _guard = transfer_policy::bind(policy.clone()); - - for profile in [ - crate::media::DownloadProfile::Visible, - crate::media::DownloadProfile::Playing, - crate::media::DownloadProfile::User, - ] { - let window = crate::media::configured_download_window(profile); - assert_eq!(window.chunk, crate::media::FAST_CHUNK); - assert_eq!(window.in_flight, 4); - policy.assign_pipeline_parts(2); - assert_eq!( - crate::media::configured_download_window(profile).in_flight, - 2 - ); - policy.assign_pipeline_parts(4); - } - - let ordinary = - crate::media::configured_download_window(crate::media::DownloadProfile::Ordinary); - assert_eq!(ordinary.chunk, crate::media::DEFAULT_CHUNK); - assert_eq!(ordinary.in_flight, 4); -} - -#[test] -fn ffi_media_dispatch_profiles_match_kotlin_values_and_reset_after_request() { - for (class, profile) in [ - (3, crate::media::DownloadProfile::Background), - (5, crate::media::DownloadProfile::Ordinary), - (6, crate::media::DownloadProfile::Visible), - (7, crate::media::DownloadProfile::User), - ] { - crate::ffi::set_dispatch_class(class); - assert_eq!(crate::media::request_profile(), Some(profile)); - } - crate::ffi::set_dispatch_class(0); - assert_eq!(crate::media::request_profile(), None); -} - -#[test] -fn interactive_download_preempts_a_queued_background_download() { - let policy = Arc::new(TransferPolicy::stock()); - *policy.queue_limits.lock() = (1, 1); - let held = policy.admit_download(2, false).unwrap(); - let (order_tx, order_rx) = std::sync::mpsc::channel(); - let (release_bg_tx, release_bg_rx) = std::sync::mpsc::channel::<()>(); - let background = policy.clone(); - let bg_order = order_tx.clone(); - let bg = std::thread::spawn(move || { - crate::scheduler::with_class(crate::scheduler::RequestClass::BackgroundMedia, || { - let _lease = background.admit_download(2, false).unwrap(); - bg_order.send("background").unwrap(); - let _ = release_bg_rx.recv_timeout(std::time::Duration::from_secs(2)); - }); - }); - std::thread::sleep(std::time::Duration::from_millis(40)); - let (release_fg_tx, release_fg_rx) = std::sync::mpsc::channel::<()>(); - let interactive = policy.clone(); - let fg = std::thread::spawn(move || { - crate::scheduler::with_class(crate::scheduler::RequestClass::InteractiveMedia, || { - let _lease = interactive.admit_download(2, false).unwrap(); - order_tx.send("interactive").unwrap(); - let _ = release_fg_rx.recv_timeout(std::time::Duration::from_secs(2)); - }); - }); - std::thread::sleep(std::time::Duration::from_millis(40)); - drop(held); - assert_eq!( - order_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .ok(), - Some("interactive") - ); - release_fg_tx.send(()).unwrap(); - assert_eq!( - order_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .ok(), - Some("background") - ); - release_bg_tx.send(()).unwrap(); - fg.join().unwrap(); - bg.join().unwrap(); -} - -#[test] -fn server_queue_limits_block_only_the_matching_dc_and_file_queue() { - let policy = Arc::new(TransferPolicy::stock()); - *policy.queue_limits.lock() = (1, 1); - let first = policy.admit_download(2, true).unwrap(); - let small = policy.admit_download(2, false).unwrap(); - let other_dc = policy.admit_download(4, true).unwrap(); - let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - let waiting = policy.clone(); - let thread = std::thread::spawn(move || { - let _lease = waiting.admit_download(2, true).unwrap(); - ready_tx.send(()).unwrap(); - }); - assert!( - ready_rx - .recv_timeout(std::time::Duration::from_millis(50)) - .is_err() - ); - drop(first); - ready_rx - .recv_timeout(std::time::Duration::from_secs(1)) - .unwrap(); - thread.join().unwrap(); - drop((small, other_dc)); -} - -#[test] -fn app_config_updates_the_active_clients_queue_limits() { - use tellers_mtproto::latest::api::{ - JsonNumberConstructor, JsonObjectConstructor, JsonObjectValue, JsonObjectValueConstructor, - JsonValue, Vector, VectorConstructor, - }; - let entries = [ - ("small_queue_active_operations_max", 3.0), - ("large_queue_active_operations_max", 1.0), - ] - .into_iter() - .map(|(key, value)| { - Box::new(JsonObjectValue::JsonObjectValue( - JsonObjectValueConstructor { - key: key.into(), - value: Box::new(JsonValue::JsonNumber(JsonNumberConstructor { value })), - }, - )) - }) - .collect::>(); - let config = JsonValue::JsonObject(JsonObjectConstructor { - value: Box::new(Vector::Vector(VectorConstructor { - field_0: entries.len() as u32, - field_1: entries, - })), - }); - let policy = Arc::new(TransferPolicy::stock()); - let _guard = transfer_policy::bind(policy.clone()); - crate::client::extras_rpc::apply_transfer_config(&config); - assert_eq!(*policy.queue_limits.lock(), (3, 1)); -} - -#[test] -fn stock_transfer_limits_use_the_reviewed_windows() { - let stock = TransferPolicy::stock(); - assert_eq!(stock.media_lanes(), DEFAULT_MEDIA_LANES); - assert_eq!(stock.pipeline_parts(), DEFAULT_PIPELINE_PARTS); - assert_eq!(stock.chunk_size(), DEFAULT_CHUNK); - assert_eq!(stock.file_part(), FILE_PART_FAST); - assert_eq!(DEFAULT_MEDIA_LANES, 2); - assert_eq!(DEFAULT_PIPELINE_PARTS, 4); - assert_eq!(DEFAULT_CHUNK, 128 * 1024); - assert_eq!(FILE_PART_SMALL, 32 * 1024); -} - -#[test] -fn boosted_policy_applies_full_interactive_windows_and_caps_parts_at_eight() { - let policy = Arc::new(TransferPolicy::stock()); - policy.own_pipeline_parts(16); - policy.own_chunk_size(crate::media::FAST_CHUNK); - let _guard = transfer_policy::bind(policy); - for profile in [ - crate::media::DownloadProfile::Visible, - crate::media::DownloadProfile::Playing, - crate::media::DownloadProfile::User, - ] { - let actual = crate::media::configured_download_window(profile); - assert_eq!(actual.chunk, 512 * 1024); - assert_eq!(actual.in_flight, 8); - } - let ordinary = - crate::media::configured_download_window(crate::media::DownloadProfile::Ordinary); - assert_eq!((ordinary.chunk, ordinary.in_flight), (128 * 1024, 4)); - assert_eq!( - crate::media::configured_download_window(crate::media::DownloadProfile::Thumb).in_flight, - 1 - ); -} - -#[test] -fn two_clients_keep_independent_transfer_limits() { - let path_a = std::env::temp_dir().join(format!( - "monogram-policy-a-{}-{}.json", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let path_b = std::env::temp_dir().join(format!( - "monogram-policy-b-{}-{}.json", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let handle_a = create_client(1, "hash".into(), path_a.to_string_lossy().into()); - let handle_b = create_client(1, "hash".into(), path_b.to_string_lossy().into()); - let client_a = get_client(handle_a).expect("client a"); - let client_b = get_client(handle_b).expect("client b"); - let hits_a = Arc::new(AtomicUsize::new(0)); - let hits_b = Arc::new(AtomicUsize::new(0)); - let hits_a_callback = Arc::clone(&hits_a); - let hits_b_callback = Arc::clone(&hits_b); - - client_a.policy.own_media_lanes(3); - client_b.policy.own_media_lanes(5); - client_a.policy.own_pipeline_parts(4); - client_b.policy.own_pipeline_parts(7); - client_a.policy.own_chunk_size(512 * 1024); - client_b.policy.own_chunk_size(256 * 1024); - client_a.policy.own_file_part_kib(512); - client_b.policy.own_file_part_kib(32); - client_a.policy.own_progress(Some(Arc::new(move |_, _, _| { - hits_a_callback.fetch_add(1, Ordering::Relaxed); - }))); - client_b.policy.own_progress(Some(Arc::new(move |_, _, _| { - hits_b_callback.fetch_add(1, Ordering::Relaxed); - }))); - - let previous_chunk = chunk_size(); - let previous_lanes = active_media_lanes(); - let previous_parts = pipeline_parts(); - let previous_file_part = file_part(); - set_chunk_size(DEFAULT_CHUNK); - set_active_media_lanes(DEFAULT_MEDIA_LANES); - set_pipeline_parts(DEFAULT_PIPELINE_PARTS); - set_file_part_kib(32); - - assert_eq!(client_a.policy.media_lanes(), 3); - assert_eq!(client_b.policy.media_lanes(), 5); - assert_eq!(client_a.policy.pipeline_parts(), 4); - assert_eq!(client_b.policy.pipeline_parts(), 7); - assert_eq!(client_a.policy.chunk_size(), 512 * 1024); - assert_eq!(client_b.policy.chunk_size(), 256 * 1024); - assert_eq!(client_a.policy.file_part(), FILE_PART_FAST); - assert_eq!(client_b.policy.file_part(), FILE_PART_SMALL); - - { - let _guard = transfer_policy::bind(Arc::clone(&client_a.policy)); - assert_eq!(active_media_lanes(), 3); - assert_eq!(pipeline_parts(), 4); - assert_eq!(chunk_size(), 512 * 1024); - assert_eq!(file_part(), FILE_PART_FAST); - notify_progress("a", 1, 2); - } - { - let _guard = transfer_policy::bind(Arc::clone(&client_b.policy)); - assert_eq!(active_media_lanes(), 5); - assert_eq!(pipeline_parts(), 7); - assert_eq!(chunk_size(), 256 * 1024); - assert_eq!(file_part(), FILE_PART_SMALL); - notify_progress("b", 1, 2); - } - with_client_mut(handle_a, |_| { - assert_eq!(chunk_size(), 512 * 1024); - assert_eq!(pipeline_parts(), 4); - Ok(()) - }) - .expect("bound client"); - - assert_eq!(hits_a.load(Ordering::Relaxed), 1); - assert_eq!(hits_b.load(Ordering::Relaxed), 1); - - set_chunk_size(previous_chunk); - set_active_media_lanes(previous_lanes); - set_pipeline_parts(previous_parts); - if previous_file_part == FILE_PART_FAST { - set_file_part_kib(512); - } else { - set_file_part_kib(32); - } - destroy_client(handle_a); - destroy_client(handle_b); - let _ = std::fs::remove_file(path_a); - let _ = std::fs::remove_file(path_b); -} - -#[test] -fn configured_upload_part_limits_follow_account_premium_status() { - use tellers_mtproto::latest::api::{ - JsonNumberConstructor, JsonObjectConstructor, JsonObjectValue, JsonObjectValueConstructor, - JsonValue, Vector, VectorConstructor, - }; - let policy = Arc::new(TransferPolicy::stock()); - let _guard = transfer_policy::bind(policy.clone()); - let config = JsonValue::JsonObject(JsonObjectConstructor { - value: Box::new(Vector::Vector(VectorConstructor { - field_0: 2, - field_1: [ - ("upload_max_fileparts_default", 3500.0), - ("upload_max_fileparts_premium", 7500.0), - ] - .into_iter() - .map(|(key, value)| { - Box::new(JsonObjectValue::JsonObjectValue( - JsonObjectValueConstructor { - key: key.into(), - value: Box::new(JsonValue::JsonNumber(JsonNumberConstructor { value })), - }, - )) - }) - .collect(), - })), - }); - crate::client::extras_rpc::apply_transfer_config(&config); - assert_eq!(policy.upload_max_parts(), 3500); - policy.premium.store(true, Ordering::Relaxed); - assert_eq!(policy.upload_max_parts(), 7500); - policy.premium.store(false, Ordering::Relaxed); - assert_eq!(policy.upload_max_parts(), 3500); -} diff --git a/native/mtproto-rs/tests/unit/update_buffer_tests.rs b/native/mtproto-rs/tests/unit/update_buffer_tests.rs deleted file mode 100644 index eb8847e0a..000000000 --- a/native/mtproto-rs/tests/unit/update_buffer_tests.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::*; -#[test] -fn missing_packet_closes_gap_without_difference() { - let mut buffer = PushBuffer::default(); - let now = Instant::now(); - let mut cursor = 0; - buffer.append(vec![vec![2]]); - let mut apply = |body: &[u8]| -> Result { - if body[0] > cursor + 1 { - return Ok(false); - } - cursor = cursor.max(body[0]); - Ok(true) - }; - assert!(!buffer.resolve(now, &mut apply).unwrap()); - buffer.append(vec![vec![1]]); - assert!( - !buffer - .resolve(now + Duration::from_millis(400), &mut apply) - .unwrap() - ); - assert!(buffer.is_empty()); - assert_eq!(cursor, 2); -} - -#[test] -fn unresolved_gap_expires_once_without_extending_on_more_packets() { - let mut buffer = PushBuffer::default(); - let now = Instant::now(); - buffer.append(vec![vec![2]]); - assert!(!buffer.resolve(now, |_| Ok::<_, ()>(false)).unwrap()); - buffer.append(vec![vec![3]]); - assert!( - buffer - .resolve(now + Duration::from_millis(500), |_| Ok::<_, ()>(false)) - .unwrap() - ); - assert!(buffer.is_empty()); - assert!( - !buffer - .resolve(now + Duration::from_secs(1), |_| Ok::<_, ()>(false)) - .unwrap() - ); -} - -#[test] -fn queue_overflow_requires_recovery_without_applying_partial_queue() { - let mut buffer = PushBuffer::default(); - buffer.append(vec![vec![0]; 257]); - assert!( - buffer - .resolve(Instant::now(), |_| -> Result { - panic!("overflow applied") - }) - .unwrap() - ); - assert!(buffer.is_empty()); -} diff --git a/native/mtproto-rs/tests/unit/update_journal_tests.rs b/native/mtproto-rs/tests/unit/update_journal_tests.rs deleted file mode 100644 index 90becfe8e..000000000 --- a/native/mtproto-rs/tests/unit/update_journal_tests.rs +++ /dev/null @@ -1,333 +0,0 @@ -use super::*; -use crate::client::{ - acknowledge_update_batch, create_client, destroy_client, flush_persist, get_client, - pending_update_batches, persist_journal_sync, -}; -use std::sync::Arc; - -fn message_event(id: i32) -> UpdateEventDto { - UpdateEventDto::MessagesDeleted { - chat_id: Some(7), - message_ids: vec![id], - } -} - -fn cursor(pts: i32) -> Option { - Some(UpdatesStateDto { - pts, - qts: 0, - date: 1, - seq: 1, - }) -} - -fn temp_session(name: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!( - "monogram-journal-{name}-{}.session", - std::process::id() - )); - let _ = std::fs::remove_file(&path); - path -} - -fn queue(handle: u64, id: i32) -> DurableBatch { - let client = get_client(handle).unwrap(); - let mut d = client.data.lock(); - let batch = - Arc::make_mut(&mut d.journal).push(vec![message_event(id)], cursor(id), vec![(9, id)]); - d.updates = cursor(id); - d.persist_epoch += 1; - batch -} - -#[test] -fn batches_get_increasing_ids_and_ack_is_exact() { - let mut journal = UpdateJournal::default(); - let generation = journal.account_generation; - let first = journal.push(vec![message_event(1)], cursor(1), vec![]); - let second = journal.push(vec![message_event(2)], cursor(2), vec![]); - assert!(second.batch_id > first.batch_id); - assert!(!journal.acknowledge(generation + 1, first.batch_id)); - assert_eq!(journal.batches.len(), 2); - assert!(journal.acknowledge(generation, first.batch_id)); - assert!(!journal.acknowledge(generation, first.batch_id)); - assert_eq!(journal.pending().len(), 1); - assert_eq!(journal.pending()[0].batch_id, second.batch_id); -} - -#[test] -fn delivery_tracking_follows_acknowledgement() { - let mut journal = UpdateJournal::default(); - let generation = journal.account_generation; - let a = journal.push(vec![message_event(1)], None, vec![]); - let b = journal.push(vec![message_event(2)], None, vec![]); - assert!(journal.has_undelivered()); - journal.mark_delivered(a.batch_id); - assert!(journal.has_undelivered()); - journal.acknowledge(generation, a.batch_id); - assert!(journal.has_undelivered(), "b is still undelivered"); - journal.mark_delivered(b.batch_id); - assert!(!journal.has_undelivered()); -} - -#[test] -fn admission_closes_at_the_batch_bound_and_reopens_after_ack() { - let mut journal = UpdateJournal::default(); - let generation = journal.account_generation; - let mut first = 0; - for index in 0..MAX_UNACKED_BATCHES { - let batch = journal.push(vec![message_event(index as i32)], None, vec![]); - if index == 0 { - first = batch.batch_id; - } - } - assert!(!journal.admission_open()); - assert!(journal.acknowledge(generation, first)); - assert!(journal.admission_open()); -} - -#[test] -fn account_reset_drops_batches_and_changes_generation() { - let mut journal = UpdateJournal::default(); - let previous = journal.account_generation; - journal.push(vec![message_event(1)], None, vec![]); - journal.reset_account(); - assert_ne!(journal.account_generation, previous); - assert!(journal.batches.is_empty()); -} - -#[test] -fn only_state_mutations_are_durable() { - assert!(is_durable(&message_event(1))); - assert!(is_durable(&UpdateEventDto::ChatsChanged)); - assert!(is_durable(&UpdateEventDto::ReadInbox { - chat_id: 1, - max_id: 2, - still_unread: 0 - })); - assert!(!is_durable(&UpdateEventDto::SyncState { is_syncing: true })); - assert!(!is_durable(&UpdateEventDto::PeerTyping { - chat_id: 1, - user_id: 2, - typing: true, - action: String::new() - })); - assert!(!is_durable(&UpdateEventDto::Ignored { kind: "x".into() })); - assert!(is_durable(&UpdateEventDto::Ignored { - kind: "unread_mentions_delta:5:1".into() - })); -} - -#[test] -fn journal_survives_process_restart_and_is_replayable() { - let path = temp_session("restart"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let batch = queue(handle, 5); - let session_id = get_client(handle).unwrap().data.lock().home_session_id; - persist_journal_sync(&get_client(handle).unwrap(), session_id).unwrap(); - destroy_client(handle); - - let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); - let drained = crate::client::drain_updates(restarted).unwrap(); - assert!( - drained.replay_required, - "unacknowledged batch must block new delivery" - ); - let pending = pending_update_batches(restarted).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].batch_id, batch.batch_id); - assert_eq!(pending[0].cursor_after.as_ref().map(|c| c.pts), Some(5)); - assert_eq!(pending[0].channel_pts_after[0].pts, 5); - assert!(matches!( - pending[0].events[0], - UpdateEventDto::MessagesDeleted { .. } - )); - destroy_client(restarted); - let _ = std::fs::remove_file(path); -} - -#[test] -fn crash_before_the_journal_write_loses_nothing_the_cursor_did_not_claim() { - let path = temp_session("before-save"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let session_id = get_client(handle).unwrap().data.lock().home_session_id; - persist_journal_sync(&get_client(handle).unwrap(), session_id).unwrap(); - queue(handle, 3); - // A hard kill: no orderly shutdown flush. - crate::client::CLIENTS.lock().remove(&handle); - - let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); - assert!(pending_update_batches(restarted).unwrap().is_empty()); - let client = get_client(restarted).unwrap(); - assert!( - client.data.lock().updates.is_none(), - "cursor stays behind the unwritten batch" - ); - destroy_client(restarted); - let _ = std::fs::remove_file(path); -} - -#[test] -fn a_superseded_save_is_retried_until_the_batch_is_on_disk() { - let path = temp_session("stale-retry"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let batch = queue(handle, 11); - let client = get_client(handle).unwrap(); - let session_id = client.data.lock().home_session_id; - crate::client::FORCE_STALE_SAVES.store(1, std::sync::atomic::Ordering::SeqCst); - persist_journal_sync(&client, session_id).expect("retry must write after a stale skip"); - assert_eq!( - crate::client::FORCE_STALE_SAVES.load(std::sync::atomic::Ordering::SeqCst), - 0 - ); - destroy_client(handle); - - let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); - let pending = pending_update_batches(restarted).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].batch_id, batch.batch_id); - destroy_client(restarted); - let _ = std::fs::remove_file(path); -} - -#[test] -fn an_older_snapshot_does_not_replace_a_newer_live_journal() { - let mut older = UpdateJournal::default(); - let generation = older.account_generation; - let mut newer = older.clone(); - newer.push(vec![message_event(1)], None, vec![]); - let saved = crate::client::journal_to_save(older, Some(newer.clone())); - assert_eq!(saved.next_batch_id, newer.next_batch_id); - assert_eq!(saved.batches.len(), 1); - let reset = UpdateJournal::default(); - assert_ne!(reset.account_generation, generation); - let explicit_reset = crate::client::journal_to_save(reset.clone(), Some(newer)); - assert_eq!(explicit_reset.account_generation, reset.account_generation); - assert!(explicit_reset.batches.is_empty()); -} - -#[test] -fn acknowledgement_survives_an_older_same_generation_snapshot() { - let mut live = UpdateJournal::default(); - let generation = live.account_generation; - let batch = live.push(vec![message_event(1)], cursor(1), vec![]); - let snapshot = live.clone(); - assert!(live.acknowledge(generation, batch.batch_id)); - let saved = crate::client::journal_to_save(snapshot, Some(live.clone())); - assert!( - saved.batches.is_empty(), - "an older snapshot must not restore an ack" - ); - assert_eq!(saved.next_batch_id, live.next_batch_id); -} - -#[test] -fn drain_before_updates_start_creates_no_batch() { - let path = temp_session("empty-drain"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let drained = crate::client::drain_updates(handle).unwrap(); - assert!(drained.batches.is_empty()); - assert!(drained.live_events.is_empty()); - assert!(!drained.replay_required); - assert!( - get_client(handle) - .unwrap() - .data - .lock() - .journal - .batches - .is_empty() - ); - destroy_client(handle); - let _ = std::fs::remove_file(path); -} - -#[test] -fn acknowledgement_is_idempotent_and_compaction_is_persisted() { - let path = temp_session("ack"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - let batch = queue(handle, 8); - let session_id = client.data.lock().home_session_id; - persist_journal_sync(&client, session_id).unwrap(); - let generation = pending_update_batches(handle).unwrap()[0].account_generation; - - acknowledge_update_batch(handle, generation + 1, batch.batch_id).unwrap(); - assert_eq!( - client.data.lock().journal.batches.len(), - 1, - "foreign generation is ignored" - ); - acknowledge_update_batch(handle, generation, batch.batch_id).unwrap(); - acknowledge_update_batch(handle, generation, batch.batch_id).unwrap(); - flush_persist(&client); - destroy_client(handle); - - let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); - assert!(pending_update_batches(restarted).unwrap().is_empty()); - destroy_client(restarted); - let _ = std::fs::remove_file(path); -} - -#[test] -fn exhausted_persistence_reports_the_error_and_keeps_the_batch() { - let path = temp_session("exhausted"); - let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); - let client = get_client(handle).unwrap(); - queue(handle, 4); - let session_id = client.data.lock().home_session_id; - let blocker = std::env::temp_dir().join(format!("monogram-journal-dir-{}", std::process::id())); - std::fs::create_dir_all(&blocker).unwrap(); - client.data.lock().session_path = blocker.clone(); - - assert!(persist_journal_sync(&client, session_id).is_err()); - assert!( - client - .persist_exhausted - .load(std::sync::atomic::Ordering::Acquire) - ); - assert_eq!(client.data.lock().journal.batches.len(), 1); - assert!( - pending_update_batches(handle).is_err(), - "an unwritten batch must not be handed out" - ); - - client.data.lock().session_path = path.clone(); - persist_journal_sync(&client, session_id).unwrap(); - assert!( - !client - .persist_exhausted - .load(std::sync::atomic::Ordering::Acquire) - ); - assert_eq!(pending_update_batches(handle).unwrap().len(), 1); - destroy_client(handle); - let _ = std::fs::remove_dir(blocker); - let _ = std::fs::remove_file(path); -} - -#[test] -fn old_session_files_without_a_journal_still_load() { - let session = crate::session_file::ClientSession { - snapshot: tellers_mtproto_session::Snapshot::new(2, &mut tellers_mtproto_session::OsRandom) - .unwrap(), - user_id: None, - peers: Default::default(), - updates: None, - media: Default::default(), - media_order: Vec::new(), - channel_pts: Default::default(), - channel_recovery: Default::default(), - seen_messages: Default::default(), - session_dead: false, - logout_tokens: Vec::new(), - new_session: None, - test_dc: false, - dc_auth_keys: Default::default(), - journal: Default::default(), - }; - let mut value = serde_json::to_value(&session).unwrap(); - value.as_object_mut().unwrap().remove("journal"); - let loaded: crate::session_file::ClientSession = serde_json::from_value(value).unwrap(); - assert!(loaded.journal.batches.is_empty()); - assert_eq!(loaded.journal.next_batch_id, 1); -} diff --git a/native/mtproto-rs/tests/unit/updates_rpc_tests.rs b/native/mtproto-rs/tests/unit/updates_rpc_tests.rs deleted file mode 100644 index dfc4414da..000000000 --- a/native/mtproto-rs/tests/unit/updates_rpc_tests.rs +++ /dev/null @@ -1,434 +0,0 @@ -use super::*; - -use crate::{HashMap, HashMapExt}; - -use tellers_mtproto::codec::{Encoder, TlEncode}; -use tellers_mtproto::latest::api::{ - MessageReactions, MessageReactionsConstructor, Peer, PeerUserConstructor, Update, - UpdateBotStoppedConstructor, UpdateChannelReadMessagesContentsConstructor, - UpdateChannelTooLongConstructor, UpdateDeleteChannelMessagesConstructor, - UpdateDeleteMessagesConstructor, UpdateMessageReactionsConstructor, - UpdatePtsChangedConstructor, UpdateReadChannelDiscussionInboxConstructor, - UpdateReadMessagesContentsConstructor, UpdateShortConstructor, Updates, UpdatesConstructor, - Vector, VectorConstructor, -}; - -#[test] -fn poll_results_become_durable_invalidation_without_advancing_pts() { - use tellers_mtproto::latest::api::{ - PollResults, PollResultsConstructor, UpdateMessagePollConstructor, - }; - let update = Update::UpdateMessagePoll(UpdateMessagePollConstructor { - flags: 0, - peer: None, - msg_id: None, - top_msg_id: None, - poll_id: i64::MAX, - poll: None, - results: Box::new(PollResults::PollResults(PollResultsConstructor { - flags: 4, - min: None, - has_unread_votes: None, - can_view_stats: None, - results: None, - total_voters: Some(12), - recent_voters: None, - solution: None, - solution_entities: None, - solution_media: None, - })), - }); - let mut state = cursor(); - let before = (state.pts, state.qts, state.seq); - let bytes = encode(Updates::UpdateShort(UpdateShortConstructor { - update: Box::new(update), - date: 101, - })); - let events = apply(&bytes, &mut state).unwrap(); - assert_eq!((state.pts, state.qts, state.seq), before); - assert!( - matches!(events.as_slice(),[UpdateEventDto::PollChanged {poll_id}] if *poll_id==i64::MAX) - ); - let restored: Vec = - serde_json::from_slice(&serde_json::to_vec(&events).unwrap()).unwrap(); - assert!( - matches!(restored.as_slice(),[UpdateEventDto::PollChanged {poll_id}] if *poll_id==i64::MAX) - ); -} - -#[test] -fn cloud_draft_changes_are_typed_serializable_events_instead_of_ignored_updates() { - use tellers_mtproto::latest::api::{ - DraftMessage, DraftMessageEmptyConstructor, UpdateDraftMessageConstructor, - }; - let update = Update::UpdateDraftMessage(UpdateDraftMessageConstructor { - flags: 1, - peer: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 7 })), - top_msg_id: Some(99), - saved_peer_id: None, - draft: Box::new(DraftMessage::DraftMessageEmpty( - DraftMessageEmptyConstructor { - flags: 1, - date: Some(123), - }, - )), - }); - let mut events = Vec::new(); - collect_other_updates( - std::iter::once(&update), - &mut MediaIndex::default(), - &mut Vec::new(), - &mut events, - ); - assert_eq!(events.len(), 1); - let encoded = serde_json::to_vec(&events).unwrap(); - let restored: Vec = serde_json::from_slice(&encoded).unwrap(); - let UpdateEventDto::DraftChanged { draft } = &restored[0] else { - panic!("draft event required") - }; - assert_eq!((draft.chat_id, draft.thread_id, draft.date), (7, 99, 123)); - assert!(draft.text.is_empty()); -} - -#[test] -fn too_long_uses_higher_dialog_pts() { - assert_eq!(next_channel_pts_after_too_long(10, 40, 3), 40); -} - -#[test] -fn too_long_never_invents_pts_from_message_count() { - assert_eq!(next_channel_pts_after_too_long(10, 10, 0), 10); - assert_eq!(next_channel_pts_after_too_long(10, 8, 5), 10); -} - -#[test] -fn discussion_receipt_preserves_thread_and_read_boundary() { - let update = - Update::UpdateReadChannelDiscussionInbox(UpdateReadChannelDiscussionInboxConstructor { - flags: 0, - channel_id: 42, - top_msg_id: 100, - read_max_id: 120, - broadcast_id: None, - broadcast_post: None, - }); - let mut events = Vec::new(); - collect_other_updates( - std::iter::once(&update), - &mut MediaIndex::default(), - &mut Vec::new(), - &mut events, - ); - assert!(matches!( - events.first(), - Some(UpdateEventDto::DiscussionInbox { - channel_id, top_message_id: 100, read_max_id: 120, - }) if *channel_id == chat_id_for_channel(42) - )); - assert!(matches!(events.get(1), Some(UpdateEventDto::ChatsChanged))); -} - -#[test] -fn reaction_snapshot_requests_one_authoritative_dialog_refresh_even_when_empty() { - let reactions = MessageReactions::MessageReactions(MessageReactionsConstructor { - flags: 0, - min: None, - can_see_list: None, - reactions_as_tags: None, - results: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - recent_reactions: None, - top_reactors: None, - }); - let update = Update::UpdateMessageReactions(UpdateMessageReactionsConstructor { - flags: 0, - peer: Box::new(Peer::PeerUser(PeerUserConstructor { user_id: 42 })), - msg_id: 9, - top_msg_id: None, - saved_peer_id: None, - reactions: Box::new(reactions), - }); - let mut events = Vec::new(); - collect_other_updates( - std::iter::repeat(&update).take(2), - &mut MediaIndex::default(), - &mut Vec::new(), - &mut events, - ); - assert_eq!( - events - .iter() - .filter(|event| matches!(event, UpdateEventDto::ChatsChanged)) - .count(), - 1, - ); - assert!(events.iter().all(|event| !matches!( - event, - UpdateEventDto::Ignored { kind } if kind.starts_with("unread_reactions_delta:") - ))); -} - -#[test] -fn remote_message_contents_reads_request_authoritative_dialog_refresh() { - let regular = Update::UpdateReadMessagesContents(UpdateReadMessagesContentsConstructor { - flags: 0, - messages: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![9], - })), - pts: 11, - pts_count: 1, - date: None, - }); - let channel = - Update::UpdateChannelReadMessagesContents(UpdateChannelReadMessagesContentsConstructor { - flags: 0, - channel_id: 42, - top_msg_id: None, - saved_peer_id: None, - messages: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![9], - })), - }); - assert!( - advance_push_update( - &channel, - &mut cursor(), - &mut HashMap::new(), - true, - &mut Vec::new(), - ) - .unwrap() - ); - let mut events = Vec::new(); - collect_other_updates( - [®ular, &channel].into_iter(), - &mut MediaIndex::default(), - &mut Vec::new(), - &mut events, - ); - assert_eq!(events.len(), 1); - assert!(matches!(events.first(), Some(UpdateEventDto::ChatsChanged))); -} - -#[test] -fn update_message_id_emits_random_mapping() { - let update = - Update::UpdateMessageId(tellers_mtproto::latest::api::UpdateMessageIdConstructor { - id: 44, - random_id: 99, - }); - let mut events = Vec::new(); - collect_other_updates( - std::iter::once(&update), - &mut MediaIndex::default(), - &mut Vec::new(), - &mut events, - ); - match events.as_slice() { - [UpdateEventDto::Ignored { kind }] => { - assert_eq!(kind, "UpdateMessageId:99:44"); - } - other => panic!("{other:?}"), - } -} - -fn cursor() -> UpdatesStateDto { - UpdatesStateDto { - pts: 10, - qts: 2, - seq: 3, - date: 100, - } -} - -fn delete(pts: i32) -> Update { - Update::UpdateDeleteMessages(UpdateDeleteMessagesConstructor { - messages: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![7], - })), - pts, - pts_count: 1, - }) -} - -fn packet(seq: i32, updates: Vec) -> Vec { - let value = Updates::Updates(UpdatesConstructor { - updates: Box::new(Vector::Vector(VectorConstructor { - field_0: updates.len() as u32, - field_1: updates.into_iter().map(Box::new).collect(), - })), - users: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - chats: Box::new(Vector::Vector(VectorConstructor { - field_0: 0, - field_1: Vec::new(), - })), - date: 101, - seq, - }); - encode(value) -} - -fn encode(value: Updates) -> Vec { - let mut encoder = Encoder::new(); - value.encode(&mut encoder).unwrap(); - encoder.into_bytes() -} - -fn apply(bytes: &[u8], cursor: &mut UpdatesStateDto) -> Result, MtprotoError> { - apply_push( - bytes, - &mut HashMap::new(), - &mut MediaIndex::default(), - cursor, - &mut HashMap::new(), - &mut Vec::new(), - ) -} - -#[test] -fn push_continuity_and_duplicate_suppression() { - let mut state = cursor(); - let bytes = packet(4, vec![delete(11)]); - assert_eq!(apply(&bytes, &mut state).unwrap().len(), 1); - assert_eq!((state.pts, state.seq, state.date), (11, 4, 101)); - assert!(apply(&bytes, &mut state).unwrap().is_empty()); - // Pts belongs to a separate sequence even if outer seq was seen. - assert_eq!( - apply(&packet(4, vec![delete(12)]), &mut state) - .unwrap() - .len(), - 1 - ); - assert_eq!(state.pts, 12); -} - -#[test] -fn push_gap_and_unsupported_update_roll_back_entire_packet() { - for bytes in [ - packet(5, vec![delete(11)]), - packet(4, vec![delete(11), delete(13)]), - packet( - 4, - vec![ - delete(11), - Update::UpdatePtsChanged(UpdatePtsChangedConstructor {}), - ], - ), - ] { - let mut state = cursor(); - assert!(apply(&bytes, &mut state).is_err()); - assert_eq!( - (state.pts, state.qts, state.seq, state.date), - (10, 2, 3, 100) - ); - } -} - -#[test] -fn push_counter_checks_zero_count_overflow_and_qts() { - let mut local = 10; - assert!(advance_push_counter(&mut local, 10, 0).unwrap()); - assert!(!advance_push_counter(&mut local, 9, 0).unwrap()); - assert!(advance_push_counter(&mut local, 11, 0).is_err()); - assert!(advance_push_counter(&mut i32::MAX, i32::MAX, 1).is_err()); - assert!(advance_push_counter(&mut local, 10, -1).is_err()); - let mut state = cursor(); - let update = Update::UpdateBotStopped(UpdateBotStoppedConstructor { - user_id: 42, - date: 101, - stopped: Box::new(tellers_mtproto::latest::api::Bool::BoolFalse( - tellers_mtproto::latest::api::BoolFalseConstructor {}, - )), - qts: 3, - }); - assert!( - advance_push_update( - &update, - &mut state, - &mut HashMap::new(), - true, - &mut Vec::new() - ) - .unwrap() - ); - assert_eq!(state.qts, 3); - assert!( - !advance_push_update( - &update, - &mut state, - &mut HashMap::new(), - true, - &mut Vec::new() - ) - .unwrap() - ); -} - -#[test] -fn push_channel_gaps_defer_channel_but_apply_common_updates() { - let id = chat_id_for_channel(42); - let update = Update::UpdateDeleteChannelMessages(UpdateDeleteChannelMessagesConstructor { - channel_id: 42, - messages: Box::new(Vector::Vector(VectorConstructor { - field_0: 1, - field_1: vec![7], - })), - pts: 15, - pts_count: 1, - }); - let mut state = cursor(); - let mut channels = HashMap::from_iter([(id, 10)]); - let mut pending = Vec::new(); - let events = apply_push( - &packet(4, vec![delete(11), update]), - &mut HashMap::new(), - &mut MediaIndex::default(), - &mut state, - &mut channels, - &mut pending, - ) - .unwrap(); - assert_eq!(state.pts, 11); - assert_eq!(state.seq, 4); - assert_eq!(events.len(), 1); - assert_eq!(channels[&id], 10); - assert_eq!(pending, vec![id]); -} - -#[test] -fn push_channel_too_long_and_malformed_payload() { - let mut state = cursor(); - let mut pending = Vec::new(); - let bytes = encode(Updates::UpdateShort(UpdateShortConstructor { - update: Box::new(Update::UpdateChannelTooLong( - UpdateChannelTooLongConstructor { - flags: 0, - channel_id: 42, - pts: None, - }, - )), - date: 101, - })); - apply_push( - &bytes, - &mut HashMap::new(), - &mut MediaIndex::default(), - &mut state, - &mut HashMap::new(), - &mut pending, - ) - .unwrap(); - assert_eq!(pending, vec![chat_id_for_channel(42)]); - let mut malformed = packet(4, vec![delete(11)]); - malformed.push(0); - assert!(apply(&malformed, &mut state).is_err()); - assert!(apply(&[0; 8], &mut state).is_err()); -} diff --git a/native/mtproto-rs/tests/unit/upload_upload_rpc_tests.rs b/native/mtproto-rs/tests/unit/upload_upload_rpc_tests.rs deleted file mode 100644 index ded37ba17..000000000 --- a/native/mtproto-rs/tests/unit/upload_upload_rpc_tests.rs +++ /dev/null @@ -1,261 +0,0 @@ -use super::*; - -fn stock_policy() -> crate::transfer_policy::PolicyGuard { - let policy = std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock()); - crate::transfer_policy::bind(policy) -} - -fn temp_file(name: &str, size: usize) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!( - "monogram-upload-{}-{}-{name}", - std::process::id(), - random_id(), - )); - std::fs::write(&path, vec![7_u8; size]).expect("fixture file"); - path -} - -fn item(path: &std::path::Path, kind: &str) -> UploadItemDto { - UploadItemDto { - path: path.to_string_lossy().into_owned(), - kind: kind.into(), - mime_type: String::new(), - file_name: "fixture.bin".into(), - caption: String::new(), - entities_json: None, - duration: 0, - width: 0, - height: 0, - random_id: 0, - } -} - -#[test] -fn staging_hands_out_every_part_once() { - let _policy = stock_policy(); - let path = temp_file("parts", FILE_PART_FAST * 2 + 3); - let fixture = item(&path, "document"); - let mut staging = open_staging(&fixture).expect("staging"); - - let first = staging.next_batch(2).expect("first").expect("read"); - assert_eq!(first.offset, 0); - assert_eq!(first.parts_total, 3); - assert_ne!(first.file_id, 0); - assert_eq!(first.bytes.len(), 2); - assert!(first.bytes.iter().all(|part| part.len() == FILE_PART_FAST)); - assert_eq!(staging.in_flight(), MAX_PARTS_IN_FLIGHT); - assert!(!staging.is_complete()); - - let second = staging.next_batch(2).expect("second").expect("read"); - assert_eq!(second.offset, 2); - assert_eq!(second.bytes.len(), 1); - assert_eq!(second.bytes[0].len(), 3); - assert!(staging.is_complete()); - assert!(staging.next_batch(2).is_none()); - let _ = std::fs::remove_file(&path); -} - -#[test] -fn staging_rejects_oversized_photos_and_empty_files() { - let photo = temp_file("big-photo", PHOTO_MAX as usize + 1); - let Err(err) = open_staging(&item(&photo, "photo")) else { - panic!("oversized photo must be rejected"); - }; - match err { - MtprotoError::Message(m) => assert_eq!(m, "photo too large"), - other => panic!("{other:?}"), - } - let empty = temp_file("empty", 0); - let Err(err) = open_staging(&item(&empty, "document")) else { - panic!("empty file must be rejected"); - }; - match err { - MtprotoError::Message(m) => assert_eq!(m, "empty file"), - other => panic!("{other:?}"), - } - let _ = std::fs::remove_file(&photo); - let _ = std::fs::remove_file(&empty); -} - -#[test] -fn a_missing_stored_part_is_retryable() { - assert!(is_missing_file_part(&MtprotoError::Message( - "RPC 400: FILE_PART_3_MISSING".into() - ))); - assert!(is_missing_file_part(&MtprotoError::Message( - "RPC 400: FILE_PARTS_INVALID".into() - ))); - assert!(!is_missing_file_part(&MtprotoError::Message( - "RPC 420: FLOOD_WAIT_3".into() - ))); -} - -#[test] -fn part_size_matches_file_api() { - let _policy = stock_policy(); - assert_eq!(part_count(1), 1); - assert_eq!(part_count(FILE_PART_SMALL as u64), 1); - assert_eq!(part_count(FILE_PART_FAST as u64), 1); - assert_eq!(part_count(FILE_PART_FAST as u64 + 1), 2); - assert!(!is_big_file(PHOTO_MAX)); - assert!(is_big_file(PHOTO_MAX + 1)); - assert!(!is_big_file(BIG_FILE_THRESHOLD)); - assert!(is_big_file(BIG_FILE_THRESHOLD + 1)); -} - -#[test] -fn normal_upload_uses_512kib_and_eight_in_flight() { - let _policy = stock_policy(); - let limits = upload_limits(FILE_PART_FAST as u64 + 1, None); - assert_eq!(limits.part_size, FILE_PART_FAST); - assert_eq!(limits.in_flight, 8); - - let path = temp_file("normal", FILE_PART_FAST + 3); - let mut staging = open_staging(&item(&path, "document")).expect("staging"); - assert_eq!(staging.in_flight(), 8); - assert!( - !staging - .next_batch(staging.in_flight()) - .expect("batch") - .expect("read") - .big - ); - let _ = std::fs::remove_file(&path); -} - -#[test] -fn one_part_or_slow_link_uses_32kib_and_one_in_flight() { - let _policy = stock_policy(); - let small = upload_limits(FILE_PART_SMALL as u64, None); - assert_eq!(small.part_size, FILE_PART_SMALL); - assert_eq!(small.in_flight, 1); - - let path = temp_file("small", FILE_PART_SMALL); - let staging = open_staging(&item(&path, "document")).expect("staging"); - assert_eq!(staging.in_flight(), 1); - let _ = std::fs::remove_file(&path); - - let slow = upload_limits(8 * 1024 * 1024, Some(SLOW_UPLOAD_RTT)); - assert_eq!(slow.part_size, FILE_PART_SMALL); - assert_eq!(slow.in_flight, 1); - let below = upload_limits( - 8 * 1024 * 1024, - Some(SLOW_UPLOAD_RTT - std::time::Duration::from_millis(1)), - ); - assert_eq!(below.part_size, FILE_PART_FAST); - assert_eq!(below.in_flight, 8); -} - -#[test] -fn album_rejects_mixed_and_too_long() { - validate_album(&["photo", "video"]).expect("media album"); - validate_album(&["document", "document"]).expect("file album"); - let mixed = validate_album(&["photo", "document"]).expect_err("mixed"); - match mixed { - MtprotoError::Message(m) => assert_eq!(m, "MEDIA_GROUPED_INVALID"), - other => panic!("{other:?}"), - } - let kinds = vec!["photo"; 11]; - let too_long = validate_album(&kinds).expect_err("cap"); - match too_long { - MtprotoError::Message(m) => assert_eq!(m, "MULTI_MEDIA_TOO_LONG"), - other => panic!("{other:?}"), - } -} - -#[test] -fn slow_network_preserves_large_file_support_and_premium_part_limit() { - let policy = std::sync::Arc::new(crate::transfer_policy::TransferPolicy::stock()); - let _guard = crate::transfer_policy::bind(policy.clone()); - let path = temp_file("premium-parts", 1); - let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); - file.set_len(4_001 * FILE_PART_FAST as u64).unwrap(); - assert!(open_staging_with_rtt(&item(&path, "document"), Some(SLOW_UPLOAD_RTT)).is_err()); - policy - .premium - .store(true, std::sync::atomic::Ordering::Relaxed); - let staging = open_staging_with_rtt(&item(&path, "document"), Some(SLOW_UPLOAD_RTT)).unwrap(); - assert_eq!(staging.parts_total, 4_001); - assert_eq!(staging.part_size, FILE_PART_FAST); - assert_eq!(staging.in_flight(), 1); - drop(staging); - drop(file); - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn album_entry_preserves_caption_entities_and_literal_fallback() { - let mut fixture = item(std::path::Path::new("fixture.jpg"), "photo"); - fixture.caption = "🙂 title".into(); - fixture.random_id = 7; - let input = InputFile::InputFile(InputFileConstructor { - id: 42, - parts: 1, - name: "fixture.jpg".into(), - md5_checksum: String::new(), - }); - fixture.entities_json = Some(r#"[{"kind":"italic","offset":3,"length":5}]"#.into()); - let InputSingleMedia::InputSingleMedia(styled) = album_entry( - &fixture, - input_media(&fixture, input.clone()).unwrap(), - &HashMap::default(), - ) - .unwrap(); - assert_eq!(styled.message, fixture.caption); - assert_eq!(styled.random_id, 7); - assert_eq!(styled.flags, InputSingleMediaConstructor::ENTITIES_FLAG); - let Vector::Vector(entities) = *styled.entities.unwrap(); - assert_eq!(entities.field_0, 1); - let tellers_mtproto::latest::api::MessageEntity::MessageEntityItalic(entity) = - &*entities.field_1[0] - else { - panic!("italic"); - }; - assert_eq!((entity.offset, entity.length), (3, 5)); - fixture.caption = "mediatek,gpio_usage_mapping".into(); - fixture.entities_json = None; - let InputSingleMedia::InputSingleMedia(literal) = album_entry( - &fixture, - input_media(&fixture, input.clone()).unwrap(), - &HashMap::default(), - ) - .unwrap(); - assert_eq!(literal.message, fixture.caption); - assert_eq!(literal.flags, 0); - assert!(literal.entities.is_none()); - fixture.entities_json = Some("invalid".into()); - assert!(album_entry(&fixture,input_media(&fixture,input.clone()).unwrap(),&HashMap::default()).is_err()); - fixture.caption.clear(); - fixture.entities_json = None; - let peers = [(7, CachedPeer {kind: crate::peers::PeerKind::User, id: 7, access_hash: 99, min_hash: false})].into_iter().collect(); - let input = InputFile::InputFile(InputFileConstructor {id:42,parts:1,name:"fixture.bin".into(),md5_checksum:String::new()}); - for kind in ["photo", "video", "document"] { - fixture.kind=kind.into(); - let request=replacement_media_request(&peers,7,37,kind,&fixture,input.clone(),true,Some(60),true).unwrap(); - assert_eq!(request.flags,MessagesEditMessageRequest::MEDIA_FLAG|MessagesEditMessageRequest::INVERT_MEDIA_FLAG); - assert!(request.invert_media.is_some()); - assert_eq!(request.id,37); - assert!(request.message.is_none() && request.entities.is_none() && request.schedule_date.is_none()); - match *request.media.unwrap() { - InputMedia::InputMediaUploadedPhoto(media) => { - assert!(media.spoiler.is_some());assert_eq!(media.ttl_seconds,Some(60)); - assert_ne!(media.flags & InputMediaUploadedPhotoConstructor::SPOILER_FLAG,0); - assert_ne!(media.flags & InputMediaUploadedPhotoConstructor::TTL_SECONDS_FLAG,0); - } - InputMedia::InputMediaUploadedDocument(media) => { - assert!(media.spoiler.is_some());assert_eq!(media.ttl_seconds,Some(60)); - assert_eq!(media.force_file.is_some(),kind=="document"); - assert_ne!(media.flags & InputMediaUploadedDocumentConstructor::SPOILER_FLAG,0); - assert_ne!(media.flags & InputMediaUploadedDocumentConstructor::TTL_SECONDS_FLAG,0); - } - _=>panic!("replacement media"), - } - assert!(replacement_media_request(&peers,7,37,"audio",&fixture,input.clone(),false,None,false).is_err()); - assert!(replacement_media_request(&peers,7,0,kind,&fixture,input.clone(),false,None,false).is_err()); - assert!(replacement_media_request(&peers,7,37,kind,&fixture,input.clone(),false,Some(0),false).is_err()); - } - fixture.kind="photo".into(); - assert!(replacement_media_request(&peers,7,37,"video",&fixture,input.clone(),false,None,false).is_err()); - fixture.caption="must not silently consume a caption edit".into(); - assert!(replacement_media_request(&peers,7,37,"photo",&fixture,input,false,None,false).is_err()); -} diff --git a/native/mtproto-transport/Cargo.toml b/native/mtproto-transport/Cargo.toml deleted file mode 100644 index 52c5150d5..000000000 --- a/native/mtproto-transport/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[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 = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -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 deleted file mode 100644 index 754d1cef0..000000000 --- a/native/mtproto-transport/src/config.rs +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index 9af98d0dd..000000000 --- a/native/mtproto-transport/src/connection.rs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 7fbb043dd..000000000 --- a/native/mtproto-transport/src/doh.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::tcp::{DohResolution, DohResolver}; diff --git a/native/mtproto-transport/src/error.rs b/native/mtproto-transport/src/error.rs deleted file mode 100644 index 81bd281ca..000000000 --- a/native/mtproto-transport/src/error.rs +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index b6cd6c6e7..000000000 --- a/native/mtproto-transport/src/lib.rs +++ /dev/null @@ -1,555 +0,0 @@ -#![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::{http::http_connect, socks5::socks5_connect, tls::TlsStream}; -use tellers_mtproto_transport::{ - Connection, Error as TransportError, ObfuscatedProtocol, ObfuscatedStream, -}; -use thiserror::Error; - -#[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 { - let connection = - Self::connect_registered(target, timeout, proxy, &mut register, &mut check)?; - 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))) - } - - pub fn set_read_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { - self.inner.set_read_timeout(timeout) - } -} - -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), - } - } - - pub fn set_read_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { - match self { - Self::Obfuscated(connection) => connection.set_read_timeout(timeout), - Self::Http(connection) => connection.set_read_timeout(timeout), - } - } -} -impl TcpConnection { - pub fn connect( - target: &str, - timeout: Duration, - proxy: Option<&ProxyConfig>, - ) -> Result { - Self::connect_controlled(target, timeout, proxy, |_| Ok(()), || Ok(())) - } - - fn connect_registered( - target: &str, - timeout: Duration, - proxy: Option<&ProxyConfig>, - register: &mut impl FnMut(&std::sync::Arc) -> Result<(), ProxyError>, - check: &mut impl FnMut() -> Result<(), ProxyError>, - ) -> Result { - check()?; - 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()))?; - check()?; - 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()?); - register(&socket)?; - check()?; - 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, - }) - } - fn io_socket(&self) -> &TcpStream { - match &self.stream { - ProxyStream::Plain(stream) => stream, - ProxyStream::Tls(stream) => stream.get_ref(), - ProxyStream::FakeTls(stream) => stream.tcp(), - } - } - - 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.io_socket().set_read_timeout(Some(timeout))?; - self.io_socket().set_write_timeout(Some(timeout))?; - self.timeout = timeout; - Ok(()) - } - - pub fn set_read_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { - self.socket.set_read_timeout(Some(timeout))?; - self.io_socket().set_read_timeout(Some(timeout))?; - Ok(()) - } - - pub fn into_obfuscated( - self, - secret: Option<[u8; 16]>, - dc_id: Option, - ) -> Result { - let obf = make_obfuscated_stream(secret, dc_id, |nonce| { - tellers_mtproto_crypto::fill_random(nonce) - .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) - } -} - -fn make_obfuscated_stream( - secret: Option<[u8; 16]>, - dc_id: Option, - mut fill: impl FnMut(&mut [u8; 64]) -> Result<(), ProxyError>, -) -> Result { - for _ in 0..16 { - let mut nonce = [0_u8; 64]; - fill(&mut nonce)?; - match ObfuscatedStream::new_client( - nonce, - ObfuscatedProtocol::PaddedIntermediate, - dc_id, - secret.as_ref().map(|secret| secret.as_slice()), - ) { - Ok(stream) => return Ok(stream), - Err(TransportError::InvalidObfuscationNonce) => continue, - Err(error) => return Err(ProxyError::Transport(error.to_string())), - } - } - Err(ProxyError::Transport( - "could not generate transport obfuscation nonce".into(), - )) -} - -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_read_timeout(&mut self, timeout: Duration) -> Result<(), ProxyError> { - self.inner.set_read_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() - } -} - -#[cfg(test)] -#[path = "../tests/unit/mtproxy_wire_tests.rs"] -mod mtproxy_wire_tests; - -#[cfg(test)] -#[path = "../tests/unit/proxy_config_tests.rs"] -mod proxy_config_tests; diff --git a/native/mtproto-transport/src/proxy/faketls.rs b/native/mtproto-transport/src/proxy/faketls.rs deleted file mode 100644 index db6013da6..000000000 --- a/native/mtproto-transport/src/proxy/faketls.rs +++ /dev/null @@ -1,281 +0,0 @@ -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 tcp(&self) -> &TcpStream { - &self.inner - } -} - -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)?; - if header[1..3] != [3, 3] { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "invalid TLS record version", - )); - } - 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 if payload == [1] => {} - RECORD_APPLICATION_DATA if payload.is_empty() => {} - 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)] -#[path = "../../tests/unit/faketls_tests.rs"] -mod faketls_tests; diff --git a/native/mtproto-transport/src/proxy/http.rs b/native/mtproto-transport/src/proxy/http.rs deleted file mode 100644 index 396356306..000000000 --- a/native/mtproto-transport/src/proxy/http.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::{ProxyConfig, ProxyError}; -use std::io::{Read, Write}; - -const MAX_PROXY_REPLY: usize = 16 * 1024; - -pub(crate) fn http_connect( - stream: &mut S, - 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_REPLY { - 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 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 mut fields = text.split_whitespace(); - if !matches!(fields.next(), Some("HTTP/1.0" | "HTTP/1.1")) { - return Err(ProxyError::Handshake( - "HTTP proxy version is invalid".into(), - )); - } - let status = fields - .next() - .filter(|value| value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_digit())) - .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(()) -} diff --git a/native/mtproto-transport/src/proxy/mod.rs b/native/mtproto-transport/src/proxy/mod.rs deleted file mode 100644 index 8af269f22..000000000 --- a/native/mtproto-transport/src/proxy/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod faketls; -pub(crate) mod http; -pub(crate) mod socks5; -pub(crate) mod tls; diff --git a/native/mtproto-transport/src/proxy/socks5.rs b/native/mtproto-transport/src/proxy/socks5.rs deleted file mode 100644 index daace9526..000000000 --- a/native/mtproto-transport/src/proxy/socks5.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::{ProxyConfig, ProxyError}; -use std::io::{Read, Write}; - -pub(crate) fn socks5_connect( - stream: &mut S, - 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 || !matches!(response[1], 0 | 2) || (response[1] == 2 && !authenticated) { - 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 address = target.parse::().ok(); - 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"))?; - match address.map(|address| address.ip()) { - Some(std::net::IpAddr::V4(address)) => { - stream.write_all(&[5, 1, 0, 1])?; - stream.write_all(&address.octets())?; - } - Some(std::net::IpAddr::V6(address)) => { - stream.write_all(&[5, 1, 0, 4])?; - stream.write_all(&address.octets())?; - } - None => { - 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[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 deleted file mode 100644 index 152e9c985..000000000 --- a/native/mtproto-transport/src/proxy/tls.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::{ProxyConfig, ProxyError}; -use rustls::pki_types::ServerName; -use rustls::{ClientConfig, ClientConnection, StreamOwned}; -use rustls_platform_verifier::ConfigVerifierExt; -use std::net::TcpStream; -use std::sync::Arc; - -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}")))?; - connect_with_config(stream, proxy, timeout, config) -} - -pub(crate) fn connect_with_config( - stream: TcpStream, - proxy: &ProxyConfig, - timeout: std::time::Duration, - config: ClientConfig, -) -> Result { - 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> { - crate::http_connect(stream, target, proxy) -} diff --git a/native/mtproto-transport/src/tcp.rs b/native/mtproto-transport/src/tcp.rs deleted file mode 100644 index 19b79eb8d..000000000 --- a/native/mtproto-transport/src/tcp.rs +++ /dev/null @@ -1,155 +0,0 @@ -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 closing: 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 { - self.while_active(false, operation) - } - pub fn while_persisting(&self, operation: impl FnOnce() -> T) -> Result { - self.while_active(true, operation) - } - fn while_active( - &self, - finalizing: bool, - operation: impl FnOnce() -> T, - ) -> Result { - let _commit = self.persistence.lock(); - let state = self.state.lock(); - if state.closed || (state.closing && !finalizing) { - return Err(closed_error()); - } - drop(state); - Ok(operation()) - } - pub fn begin_shutdown(&self) { - let mut state = self.state.lock(); - state.closing = true; - for stream in state - .streams - .drain(..) - .filter_map(|stream| stream.upgrade()) - { - let _ = stream.shutdown(std::net::Shutdown::Both); - } - self.wake.notify_all(); - } - pub fn close(&self) { - self.begin_shutdown(); - self.state.lock().closed = true; - let _commit = self.persistence.lock(); - } - pub fn register(&self, stream: &Arc) -> Result<(), TransportError> { - let mut state = self.state.lock(); - if state.closed || state.closing { - 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 && !state.closing { - 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/tests/fixtures/proxy-ca.der b/native/mtproto-transport/tests/fixtures/proxy-ca.der deleted file mode 100644 index e53b5336a..000000000 Binary files a/native/mtproto-transport/tests/fixtures/proxy-ca.der and /dev/null differ diff --git a/native/mtproto-transport/tests/fixtures/untrusted-proxy-cert.der b/native/mtproto-transport/tests/fixtures/untrusted-proxy-cert.der deleted file mode 100644 index 9036471f1..000000000 Binary files a/native/mtproto-transport/tests/fixtures/untrusted-proxy-cert.der and /dev/null differ diff --git a/native/mtproto-transport/tests/fixtures/untrusted-proxy-key.der b/native/mtproto-transport/tests/fixtures/untrusted-proxy-key.der deleted file mode 100644 index 4574f6bc9..000000000 Binary files a/native/mtproto-transport/tests/fixtures/untrusted-proxy-key.der and /dev/null differ diff --git a/native/mtproto-transport/tests/unit/faketls_tests.rs b/native/mtproto-transport/tests/unit/faketls_tests.rs deleted file mode 100644 index 451ff4b5c..000000000 --- a/native/mtproto-transport/tests/unit/faketls_tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -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(); -} - -#[test] -fn empty_application_record_does_not_end_stream() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let socket = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - socket - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .unwrap(); - let (mut peer, _) = listener.accept().unwrap(); - peer.write_all(&[0x17, 3, 3, 0, 0, 0x17, 3, 3, 0, 3, 1, 2, 3]) - .unwrap(); - let mut stream = FakeTlsStream::new(socket); - let mut output = [0; 3]; - stream.read_exact(&mut output).unwrap(); - assert_eq!(output, [1, 2, 3]); -} - -#[test] -fn application_record_rejects_invalid_version() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let socket = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - let (mut peer, _) = listener.accept().unwrap(); - peer.write_all(&[0x17, 1, 1, 0, 1, 9]).unwrap(); - let mut stream = FakeTlsStream::new(socket); - assert_eq!( - stream.read(&mut [0; 1]).unwrap_err().kind(), - std::io::ErrorKind::InvalidData - ); -} - -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/tests/unit/mtproxy_wire_tests.rs b/native/mtproto-transport/tests/unit/mtproxy_wire_tests.rs deleted file mode 100644 index 62f5efd2e..000000000 --- a/native/mtproto-transport/tests/unit/mtproxy_wire_tests.rs +++ /dev/null @@ -1,128 +0,0 @@ -use super::*; -use tellers_mtproto_crypto::{AesCtr, sha256}; - -fn fake_tls_server_hello(stream: &mut TcpStream, secret: &[u8; 16]) { - let mut header = [0; 5]; - stream.read_exact(&mut header).unwrap(); - assert_eq!(header[0], 0x16); - let mut body = vec![0; 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; 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(&crate::proxy::faketls::hmac_sha256(secret, &authenticated)); - stream.write_all(&response).unwrap(); -} - -fn echo_obfuscated(mut stream: ProxyStream, secret: &[u8; 16]) { - let mut header = [0; 64]; - stream.read_exact(&mut header).unwrap(); - let mut key_material = header[8..40].to_vec(); - key_material.extend_from_slice(secret); - let mut client_cipher = AesCtr::new(&sha256(&key_material), &header[40..56]).unwrap(); - let mut decoded = header; - client_cipher.apply(&mut decoded); - assert_eq!(&decoded[56..60], &0xdddddddd_u32.to_le_bytes()); - assert_eq!(&decoded[60..62], &4_i16.to_le_bytes()); - let mut request = [0; 32]; - stream.read_exact(&mut request).unwrap(); - client_cipher.apply(&mut request); - assert_eq!(request, [0x45; 32]); - - let mut reverse = header; - reverse.reverse(); - let mut key_material = reverse[8..40].to_vec(); - key_material.extend_from_slice(secret); - let mut server_cipher = AesCtr::new(&sha256(&key_material), &reverse[40..56]).unwrap(); - let mut response = [0x67; 16]; - server_cipher.apply(&mut response); - stream.write_all(&response).unwrap(); - stream.flush().unwrap(); -} - -#[test] -fn simple_dd_and_ee_proxies_route_and_exchange_obfuscated_bytes() { - for prefix in [None, Some(0xdd), Some(0xee)] { - let key = [7; 16]; - let mut encoded = Vec::new(); - if let Some(prefix) = prefix { - encoded.push(prefix); - } - encoded.extend_from_slice(&key); - if prefix == Some(0xee) { - encoded.extend_from_slice(b"proxy.example"); - } - let (secret, domain) = ProxyConfig::decode_mtproto_secret(&encoded) - .unwrap() - .unwrap(); - 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 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 stream = if prefix == Some(0xee) { - fake_tls_server_hello(&mut socket, &key); - ProxyStream::FakeTls(crate::proxy::faketls::FakeTlsStream::new(socket)) - } else { - ProxyStream::Plain(socket) - }; - echo_obfuscated(stream, &key); - }); - let proxy = ProxyConfig { - kind: ProxyKind::Mtproto, - host: "127.0.0.1".into(), - port: address.port(), - username: None, - password: None, - secret: Some(secret), - fake_tls_domain: domain, - }; - let socket = - TcpConnection::connect("149.154.167.91:443", Duration::from_secs(2), Some(&proxy)) - .unwrap(); - let mut connection = socket.into_obfuscated(Some(secret), Some(4)).unwrap(); - connection.send(&[0x45; 32]).unwrap(); - let mut reply = [0; 16]; - let mut received = 0; - while received < reply.len() { - let count = connection.receive(&mut reply[received..]).unwrap(); - assert!(count > 0); - received += count; - } - assert_eq!(reply, [0x67; 16]); - server.join().unwrap(); - } -} - -#[test] -fn ee_proxy_rejects_a_server_with_the_wrong_secret() { - 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 socket, _) = listener.accept().unwrap(); - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - fake_tls_server_hello(&mut socket, &[8; 16]); - }); - let proxy = ProxyConfig { - kind: ProxyKind::Mtproto, - host: "127.0.0.1".into(), - port: address.port(), - username: None, - password: None, - secret: Some([7; 16]), - fake_tls_domain: Some("proxy.example".into()), - }; - assert!( - TcpConnection::connect("149.154.167.91:443", Duration::from_secs(2), Some(&proxy)).is_err() - ); - server.join().unwrap(); -} diff --git a/native/mtproto-transport/tests/unit/proxy_config_tests.rs b/native/mtproto-transport/tests/unit/proxy_config_tests.rs deleted file mode 100644 index 85c1f6e94..000000000 --- a/native/mtproto-transport/tests/unit/proxy_config_tests.rs +++ /dev/null @@ -1,379 +0,0 @@ -use super::*; - -fn proxy_exchange( - response: &'static [u8], - connect: impl FnOnce(&mut TcpStream) -> Result<(), ProxyError>, -) -> (Result<(), ProxyError>, Vec) { - 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 socket, _) = listener.accept().unwrap(); - socket - .set_read_timeout(Some(Duration::from_millis(500))) - .unwrap(); - let mut request = Vec::new(); - let mut byte = [0]; - while request.len() < 4096 { - match socket.read(&mut byte) { - Ok(1) => request.push(byte[0]), - _ => break, - } - if request.ends_with(b"\r\n\r\n") { - break; - } - } - socket.write_all(response).unwrap(); - request - }); - let mut socket = TcpStream::connect(address).unwrap(); - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let result = connect(&mut socket); - (result, server.join().unwrap()) -} - -fn http_proxy() -> ProxyConfig { - ProxyConfig { - kind: ProxyKind::Http, - host: "127.0.0.1".into(), - port: 8080, - username: None, - password: None, - secret: None, - fake_tls_domain: None, - } -} - -#[test] -fn http_connect_uses_crlf_and_preserves_tunnel_bytes() { - let (result, request) = proxy_exchange( - b"HTTP/1.1 200 Connection established\r\n\r\nTUNNEL", - |socket| { - http_connect(socket, "example.com:443", &http_proxy())?; - let mut payload = [0; 6]; - socket.read_exact(&mut payload)?; - assert_eq!(&payload, b"TUNNEL"); - Ok(()) - }, - ); - assert!(result.is_ok(), "{result:?}"); - assert_eq!(request, b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nConnection: keep-alive\r\n\r\n"); -} - -#[test] -fn http_connect_rejects_malformed_or_incomplete_success_headers() { - for response in [ - b"garbage 200 OK\r\n\r\n".as_slice(), - b"HTTP/1.1 200 OK\r\n", - b"HTTP/1.1 407 Authentication required\r\n\r\n", - ] { - let (result, _) = proxy_exchange(response, |socket| { - http_connect(socket, "example.com:443", &http_proxy()) - }); - assert!(result.is_err(), "accepted {response:?}"); - } -} - -#[test] -fn controlled_connect_registers_before_proxy_handshake() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let mut proxy = http_proxy(); - proxy.port = listener.local_addr().unwrap().port(); - let (registered_tx, registered_rx) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - TcpConnection::connect_controlled( - "example.com:443", - Duration::from_secs(5), - Some(&proxy), - |socket| { - registered_tx.send(socket.clone()).unwrap(); - Ok(()) - }, - || Ok(()), - ) - .is_err() - }); - let (mut peer, _) = listener.accept().unwrap(); - peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); - peer.read_exact(&mut [0; 7]).unwrap(); - let registered = registered_rx.recv_timeout(Duration::from_millis(500)); - if let Ok(socket) = ®istered { - socket.shutdown(std::net::Shutdown::Both).unwrap(); - } - peer.shutdown(std::net::Shutdown::Both).unwrap(); - assert!(worker.join().unwrap()); - assert!( - registered.is_ok(), - "proxy negotiation blocked socket registration" - ); -} - -#[test] -fn socks5_rejects_unoffered_methods_and_malformed_connect_replies() { - struct ScriptedProxy(std::io::Cursor>); - impl Read for ScriptedProxy { - fn read(&mut self, output: &mut [u8]) -> std::io::Result { - self.0.read(output) - } - } - impl Write for ScriptedProxy { - fn write(&mut self, input: &[u8]) -> std::io::Result { - Ok(input.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - let mut proxy = http_proxy(); - proxy.kind = ProxyKind::Socks5; - for reply in [ - vec![5, 3, 5, 0, 0, 1, 127, 0, 0, 1, 0, 0], - vec![5, 2, 1, 0, 5, 0, 0, 1, 127, 0, 0, 1, 0, 0], - vec![5, 0, 4, 0, 0, 1, 127, 0, 0, 1, 0, 0], - vec![5, 0, 5, 0, 1, 1, 127, 0, 0, 1, 0, 0], - vec![5, 0, 5, 0, 0, 3, 0, 0, 0], - ] { - let mut stream = ScriptedProxy(std::io::Cursor::new(reply.clone())); - assert!( - socks5_connect(&mut stream, "example.com:443", &proxy).is_err(), - "accepted {reply:?}" - ); - } - let mut stream = ScriptedProxy(std::io::Cursor::new(vec![ - 5, 0, 5, 0, 0, 1, 127, 0, 0, 1, 0, 0, - ])); - assert!(socks5_connect(&mut stream, "example.com:443", &proxy).is_ok()); -} - -#[test] -fn socks5_encodes_ip_targets_without_domain_lookup() { - struct ProxyReply { - input: std::io::Cursor>, - output: Vec, - } - impl Read for ProxyReply { - fn read(&mut self, output: &mut [u8]) -> std::io::Result { - self.input.read(output) - } - } - impl Write for ProxyReply { - fn write(&mut self, input: &[u8]) -> std::io::Result { - self.output.extend_from_slice(input); - Ok(input.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - let mut proxy = http_proxy(); - proxy.kind = ProxyKind::Socks5; - for (target, address) in [ - ("127.0.0.1:443", vec![1, 127, 0, 0, 1]), - ( - "[::1]:443", - [vec![4], std::net::Ipv6Addr::LOCALHOST.octets().to_vec()].concat(), - ), - ] { - let mut stream = ProxyReply { - input: std::io::Cursor::new(vec![5, 0, 5, 0, 0, 1, 127, 0, 0, 1, 0, 0]), - output: Vec::new(), - }; - socks5_connect(&mut stream, target, &proxy).unwrap(); - let mut expected = vec![5, 1, 0, 5, 1, 0]; - expected.extend_from_slice(&address); - expected.extend_from_slice(&443_u16.to_be_bytes()); - assert_eq!(stream.output, expected); - } -} - -#[test] -fn https_proxy_rejects_untrusted_certificate() { - use rustls::pki_types::{CertificateDer, PrivateKeyDer}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - 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(); - // The test CA is deliberately absent from platform trust. - let key = - PrivateKeyDer::try_from(include_bytes!("../fixtures/untrusted-proxy-key.der").to_vec()) - .unwrap(); - let cert = - CertificateDer::from(include_bytes!("../fixtures/untrusted-proxy-cert.der").to_vec()); - let config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert], key) - .unwrap(); - let connection = rustls::ServerConnection::new(std::sync::Arc::new(config)).unwrap(); - let mut stream = rustls::StreamOwned::new(connection, socket); - let _ = stream.read(&mut [0; 1]); - }); - let mut proxy = http_proxy(); - proxy.kind = ProxyKind::Https; - proxy.port = port; - let result = TcpConnection::connect("example.com:443", Duration::from_secs(2), Some(&proxy)); - server.join().unwrap(); - let error = match result { - Err(error) => error.to_string(), - Ok(_) => panic!("untrusted TLS proxy accepted"), - }; - assert!( - error.to_ascii_lowercase().contains("certificate"), - "{error}" - ); -} - -#[test] -fn https_connect_verifies_trusted_certificate_and_hostname() { - use rustls::pki_types::{CertificateDer, PrivateKeyDer}; - for host in ["127.0.0.1", "wrong.example"] { - 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 (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 cert = CertificateDer::from( - include_bytes!("../fixtures/untrusted-proxy-cert.der").to_vec(), - ); - let key = PrivateKeyDer::try_from( - include_bytes!("../fixtures/untrusted-proxy-key.der").to_vec(), - ) - .unwrap(); - let config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert], key) - .unwrap(); - let connection = rustls::ServerConnection::new(std::sync::Arc::new(config)).unwrap(); - let mut stream = rustls::StreamOwned::new(connection, socket); - let mut request = Vec::new(); - let mut byte = [0]; - while request.len() < 4096 { - if stream.read_exact(&mut byte).is_err() { - return None; - } - request.push(byte[0]); - if request.ends_with(b"\r\n\r\n") { - break; - } - } - stream - .write_all(b"HTTP/1.1 200 Connection established\r\n\r\nTUNNEL") - .unwrap(); - stream.flush().unwrap(); - Some(request) - }); - let mut roots = rustls::RootCertStore::empty(); - roots - .add(CertificateDer::from( - include_bytes!("../fixtures/proxy-ca.der").to_vec(), - )) - .unwrap(); - let config = rustls::ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); - let mut proxy = http_proxy(); - proxy.kind = ProxyKind::Https; - proxy.host = host.into(); - proxy.port = address.port(); - let mut stream = crate::proxy::tls::connect_with_config( - TcpStream::connect(address).unwrap(), - &proxy, - Duration::from_secs(2), - config, - ) - .unwrap(); - let result = crate::proxy::tls::http_connect(&mut stream, "example.com:443", &proxy); - if host == "127.0.0.1" { - result.unwrap(); - let mut payload = [0; 6]; - stream.read_exact(&mut payload).unwrap(); - assert_eq!(&payload, b"TUNNEL"); - assert!( - server - .join() - .unwrap() - .unwrap() - .starts_with(b"CONNECT example.com:443 HTTP/1.1\r\n") - ); - } else { - assert!(result.is_err(), "certificate with wrong hostname accepted"); - assert!(server.join().unwrap().is_none()); - } - } -} - -#[test] -fn obfuscation_retries_forbidden_nonce_prefixes() { - let mut calls = 0; - let stream = make_obfuscated_stream(None, None, |nonce| { - calls += 1; - nonce[4] = 1; - match calls { - 1 => nonce[0] = 0xef, - 2 => nonce[..4].copy_from_slice(b"POST"), - _ => nonce[0] = 1, - } - Ok(()) - }) - .unwrap(); - assert_eq!(calls, 3); - assert_eq!(stream.header()[0], 1); -} - -#[test] -fn obfuscation_does_not_retry_entropy_errors_and_bounds_rejection() { - let mut calls = 0; - let result = make_obfuscated_stream(None, None, |_| { - calls += 1; - Err(ProxyError::Transport("fixture entropy failure".into())) - }); - assert!(result.is_err()); - assert_eq!(calls, 1); - calls = 0; - let result = make_obfuscated_stream(None, None, |_| { - calls += 1; - Ok(()) - }); - assert!(result.is_err()); - assert_eq!(calls, 16); -} - -#[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/build.gradle.kts b/native/mtproto/build.gradle.kts index b252f9c3e..6ff500eaf 100644 --- a/native/mtproto/build.gradle.kts +++ b/native/mtproto/build.gradle.kts @@ -52,7 +52,7 @@ dependencies { } val rustCrate = rootProject.layout.projectDirectory.dir("native/mtproto-rs") -val transportCrate = rootProject.layout.projectDirectory.dir("native/mtproto-transport") +val monoRsDir = rootProject.layout.projectDirectory.dir("vendor/mono-rs") val workspaceLock = rootProject.layout.projectDirectory.file("native/Cargo.lock") val workspaceTarget = providers.environmentVariable("CARGO_TARGET_DIR") .map { rustCrate.dir(it) } @@ -60,21 +60,14 @@ val workspaceTarget = providers.environmentVariable("CARGO_TARGET_DIR") val cargoConfigFiles = rootProject.layout.projectDirectory.dir(".cargo").asFileTree.matching { include("config.toml") } -val tellersCrates = listOf( - "tellers-mtproto", - "tellers-mtproto-codec", - "tellers-mtproto-crypto", - "tellers-mtproto-transport", - "tellers-mtproto-session", - "tellers-mtproto-engine", -).map { rootProject.layout.projectDirectory.dir("../telers-mtproto-impl/crates/$it") } - -fun Task.fingerprintCargoConfigAndTellers() { +fun Task.fingerprintCargoConfigAndRuntime() { inputs.files(cargoConfigFiles) - tellersCrates.forEach { crate -> - inputs.file(crate.file("Cargo.toml")) - inputs.dir(crate.dir("src")) - } + inputs.file(rootProject.layout.projectDirectory.file("native/mtproto-rs/Cargo.toml")) + inputs.file(workspaceLock) + inputs.files(monoRsDir.asFileTree.matching { + exclude("target/**") + exclude(".git/**") + }) } val jniLibs = layout.projectDirectory.dir("src/main/jniLibs") val skipNativeBuild = @@ -145,9 +138,7 @@ val buildNativeMtproto = rustCrate.file("build.rs"), ) inputs.dir(rustCrate.dir("src")) - inputs.file(transportCrate.file("Cargo.toml")) - inputs.dir(transportCrate.dir("src")) - fingerprintCargoConfigAndTellers() + fingerprintCargoConfigAndRuntime() inputs.property("abis", abis) outputs.files( abis.map { jniLibs.file("$it/libmonogram_mtproto.so") }, @@ -204,9 +195,7 @@ val buildHostUniffiMtproto = rustCrate.file("build.rs"), ) inputs.dir(rustCrate.dir("src")) - inputs.file(transportCrate.file("Cargo.toml")) - inputs.dir(transportCrate.dir("src")) - fingerprintCargoConfigAndTellers() + fingerprintCargoConfigAndRuntime() outputs.file(hostUniffiLib) enabled = !skipNativeBuild.get() } @@ -239,7 +228,7 @@ val generateUniffiMtproto = hostUniffiLib, ) inputs.dir(rustCrate.dir("src")) - fingerprintCargoConfigAndTellers() + fingerprintCargoConfigAndRuntime() outputs.file(uniffiKotlin) enabled = !skipNativeBuild.get() doLast { diff --git a/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt b/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt new file mode 100644 index 000000000..8fca9e28c --- /dev/null +++ b/native/mtproto/src/androidTest/java/org/monogram/mtproto/NativeApiContractTest.kt @@ -0,0 +1,181 @@ +package org.monogram.mtproto + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertThrows +import org.junit.Test +import uniffi.monogram_mtproto.CloudDraft +import uniffi.monogram_mtproto.MtprotoException + +class NativeApiContractTest { + @Test + fun rebuiltLibrarySmokeTestCoversGeneratedBindingAndLifecycle() { + val native = MtprotoNativeLoader.loadOrStub() + assertNotSame(MtprotoNative.Stub, native) + assertTrue(native.libraryVersion().contains("layer 232")) + assertEquals(232, native.apiLayer()) + assertNotNull(uniffi.monogram_mtproto.generateQrCode("synthetic-login", 16u)) + + native.setDownloadChunkKib(512) + assertEquals(512, native.downloadChunkKib()) + + val session = InstrumentationRegistry.getInstrumentation().targetContext + .cacheDir.resolve("native-api-contract-${System.nanoTime()}.session") + val handle = native.createClient(1, "synthetic-api-hash", session.absolutePath) + try { + assertTrue(native.clientExists(handle)) + assertEquals(1, native.clientApiId(handle)) + assertFalse(native.usesTestDc(handle)) + assertFalse(native.isAuthorized(handle)) + native.setTestDc(handle, true) + assertTrue(native.usesTestDc(handle)) + native.setTestDc(handle, false) + assertFalse(native.usesTestDc(handle)) + } finally { + native.destroyClient(handle) + session.delete() + } + } + + @Test + fun messagingEntryPointsLoadAndRejectUnknownHandles() { + val native = MtprotoNativeLoader.loadOrStub() + assertNotSame(MtprotoNative.Stub, native) + assertEquals(232, native.apiLayer()) + val draft = CloudDraft(7L, 0, "synthetic draft", null, 0, 0, false, false) + val upload = uniffi.monogram_mtproto.UploadItemDto( + path = "synthetic-unused", + kind = "photo", + mimeType = "image/jpeg", + fileName = "fixture.jpg", + caption = "synthetic caption", + entitiesJson = null, + duration = 0, + width = 0, + height = 0, + randomId = 123L, + ) + val calls: List<() -> Unit> = listOf( + { native.replaceUploadedMedia(0L, 7L, 37, "fixture", upload.copy(caption = "")) }, + { native.sendUploadedMedia(0L, 7L, upload) }, + { native.sendUploadedAlbum(0L, 7L, listOf(upload)) }, + { + native.sendUploadedMediaWithOptions( + 0L, 7L, upload, 0, 0, null, uniffi.monogram_mtproto.SendOptions( + silent = true, noWebpage = false, scheduleDate = 0x7ffffffe + ) + ) + }, + { + native.sendUploadedAlbumWithOptions( + 0L, 7L, listOf(upload), 0, 0, uniffi.monogram_mtproto.SendOptions( + silent = true, noWebpage = false, scheduleDate = 0x7ffffffe + ) + ) + }, + { native.getMessageReactionsPage(0L, 7L, 37, "fixture") }, + { native.getPollVotesPage(0L, 7L, 37, "fixture") }, + { + native.sendDiceWithOptions( + 0L, 7L, "🎲", 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( + silent = true, noWebpage = false, scheduleDate = null + ) + ) + }, + { native.toggleTodoCompleted(0L, 7L, 37, listOf(0), emptyList()) }, + { native.appendTodoItems(0L, 7L, 37, 1, listOf("Fixture item")) }, + { + native.sendPollWithOptions( + 0L, 7L, uniffi.monogram_mtproto.PollSendSpec( + "Synthetic question", + listOf("First", "Second"), + true, + false, + false, + null, + null + ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( + silent = false, noWebpage = false, scheduleDate = null + ) + ) + }, + { + native.sendChecklistWithOptions( + 0L, 7L, uniffi.monogram_mtproto.ChecklistSendSpec( + "Synthetic list", listOf("First", "Second"), true, false + ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( + silent = false, noWebpage = false, scheduleDate = null + ) + ) + }, + { + native.importContacts( + 0L, listOf( + uniffi.monogram_mtproto.ContactImportSpec( + 123L, "+15555550123", "Synthetic", "Contact" + ) + ) + ) + }, + { + native.sendLocationWithOptions( + 0L, + 7L, + uniffi.monogram_mtproto.LocationSendSpec(0.0, 0.0, 900, null), + 0, + 0, + 123L, + uniffi.monogram_mtproto.SendOptions(false, false, null) + ) + }, + { + native.sendContactWithOptions( + 0L, 7L, uniffi.monogram_mtproto.ContactSendSpec( + "+15555550123", "Synthetic", "Contact", "" + ), 0, 0, 123L, uniffi.monogram_mtproto.SendOptions( + silent = false, noWebpage = false, scheduleDate = null + ) + ) + }, + { native.editLiveLocation(0L, 7L, 37, null, null, null, true) }, + { + native.removeChecklistItem( + 0L, + 7L, + 37, + uniffi.monogram_mtproto.ChecklistTextEditDto(0, "", null, "Old", null) + ) + }, + { + native.editChecklistText( + 0L, 7L, 37, uniffi.monogram_mtproto.ChecklistTextEditDto( + null, "Synthetic title", null, "Old", null + ) + ) + }, + { native.editChecklistTitle(0L, 7L, 37, "Synthetic title") }, + { native.closePoll(0L, 7L, 37) }, + { native.getMessage(0L, 7L, 37) }, + { native.getCloudDrafts(0L) }, + { native.saveCloudDraft(0L, draft) }, + { + native.sendTextMessageWithOptions( + 0L, 7L, "synthetic draft", 0, null, 0, null, 123L, + uniffi.monogram_mtproto.SendOptions( + silent = true, noWebpage = true, scheduleDate = 0x7ffffffe + ), + ) + }, + { native.getScheduledHistory(0L, 7L) }, + { native.getScheduledMessages(0L, 7L, listOf(1)) }, + { native.sendScheduledMessages(0L, 7L, listOf(1)) }, + { native.deleteScheduledMessages(0L, 7L, listOf(1)) }, + { native.editScheduledMessage(0L, 7L, 1, 0x7ffffffe, "replacement", null, false) }, + ) + calls.forEach { call -> assertThrows(MtprotoException::class.java) { call() } } + } +} 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 46f33da5a..96c5fdb60 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt @@ -37,7 +37,7 @@ import uniffi.monogram_mtproto.UpdatesStateDto import uniffi.monogram_mtproto.UploadItemDto /** - * Low-level UniFFI surface backed by the Tellers MTProto runtime. + * Low-level UniFFI surface backed by the Mono MTProto runtime. */ data class NativeJoinResult( val joined: 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 ec948f0b7..413481dde 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 @@ -694,6 +694,8 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + + internal fun ensureInitialized() = Unit external fun uniffi_monogram_mtproto_checksum_func_accept_login_token( ): Int external fun uniffi_monogram_mtproto_checksum_func_acknowledge_update_batch( @@ -1072,6 +1074,8 @@ internal object UniffiLib { uniffiCallbackInterfaceDownloadProgressListener.register(this) } + + internal fun ensureInitialized() = Unit external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, ): Unit external fun uniffi_monogram_mtproto_fn_func_accept_login_token(`handle`: Long,`token`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1690,7 +1694,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_drain_updates() and 0xFFFF) != 3845) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_edit_checklist_text() and 0xFFFF) != 41297) { + if ((lib.uniffi_monogram_mtproto_checksum_func_edit_checklist_text() and 0xFFFF) != 58013) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_edit_checklist_title() and 0xFFFF) != 64825) { @@ -1732,7 +1736,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_chats() and 0xFFFF) != 2402) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_cloud_drafts() and 0xFFFF) != 27896) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_cloud_drafts() and 0xFFFF) != 3295) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_common_chats() and 0xFFFF) != 43261) { @@ -1771,13 +1775,13 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_message() and 0xFFFF) != 54966) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_reactions_list() and 0xFFFF) != 31437) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_reactions_list() and 0xFFFF) != 3205) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_reactions_page() and 0xFFFF) != 52904) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_reactions_page() and 0xFFFF) != 14180) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_read_participants() and 0xFFFF) != 5263) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_message_read_participants() and 0xFFFF) != 41203) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_notify_exceptions() and 0xFFFF) != 22307) { @@ -1786,7 +1790,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_notify_settings() and 0xFFFF) != 43188) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_outbox_read_date() and 0xFFFF) != 9840) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_outbox_read_date() and 0xFFFF) != 54299) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_participants() and 0xFFFF) != 24154) { @@ -1798,16 +1802,16 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_pinned_messages() and 0xFFFF) != 64998) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_poll_votes() and 0xFFFF) != 47527) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_poll_votes() and 0xFFFF) != 34056) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_poll_votes_page() and 0xFFFF) != 58907) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_poll_votes_page() and 0xFFFF) != 45304) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_profile() and 0xFFFF) != 47208) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_read_receipt_config() and 0xFFFF) != 31036) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_read_receipt_config() and 0xFFFF) != 51555) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_recent_reactions() and 0xFFFF) != 63454) { @@ -1819,10 +1823,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_saved_gifs() and 0xFFFF) != 25721) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_scheduled_history() and 0xFFFF) != 935) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_scheduled_history() and 0xFFFF) != 61655) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_scheduled_messages() and 0xFFFF) != 52924) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_scheduled_messages() and 0xFFFF) != 36241) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_get_search_counters() and 0xFFFF) != 938) { @@ -1846,16 +1850,16 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_updates_state() and 0xFFFF) != 39105) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_wallpapers() and 0xFFFF) != 22668) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_wallpapers() and 0xFFFF) != 41322) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_web_page() and 0xFFFF) != 28449) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_web_page() and 0xFFFF) != 5609) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_get_web_page_preview() and 0xFFFF) != 55665) { + if ((lib.uniffi_monogram_mtproto_checksum_func_get_web_page_preview() and 0xFFFF) != 4226) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_import_contacts() and 0xFFFF) != 1367) { + if ((lib.uniffi_monogram_mtproto_checksum_func_import_contacts() and 0xFFFF) != 33881) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_init_passkey_login() and 0xFFFF) != 36751) { @@ -1942,7 +1946,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_release_request_control() and 0xFFFF) != 4655) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_remove_checklist_item() and 0xFFFF) != 6865) { + if ((lib.uniffi_monogram_mtproto_checksum_func_remove_checklist_item() and 0xFFFF) != 8934) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_render_lottie_frame() and 0xFFFF) != 49231) { @@ -1963,7 +1967,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_revoke_device_session() and 0xFFFF) != 17378) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_save_cloud_draft() and 0xFFFF) != 65513) { + if ((lib.uniffi_monogram_mtproto_checksum_func_save_cloud_draft() and 0xFFFF) != 18174) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_search_global() and 0xFFFF) != 25087) { @@ -1981,13 +1985,13 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_send_auth_code() and 0xFFFF) != 8829) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_checklist_with_options() and 0xFFFF) != 40004) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_checklist_with_options() and 0xFFFF) != 33412) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_contact_with_options() and 0xFFFF) != 10465) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_contact_with_options() and 0xFFFF) != 26144) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_dice_with_options() and 0xFFFF) != 45776) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_dice_with_options() and 0xFFFF) != 58626) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_send_inline_bot_result() and 0xFFFF) != 10143) { @@ -1996,7 +2000,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_send_location() and 0xFFFF) != 5819) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_location_with_options() and 0xFFFF) != 30552) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_location_with_options() and 0xFFFF) != 51990) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_send_photo_message() and 0xFFFF) != 43362) { @@ -2005,7 +2009,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_send_poll_vote() and 0xFFFF) != 17828) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_poll_with_options() and 0xFFFF) != 42583) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_poll_with_options() and 0xFFFF) != 45934) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_send_reaction() and 0xFFFF) != 64471) { @@ -2020,19 +2024,19 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_send_text_message() and 0xFFFF) != 30448) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_text_message_with_options() and 0xFFFF) != 16897) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_text_message_with_options() and 0xFFFF) != 48002) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_album() and 0xFFFF) != 30233) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_album_with_options() and 0xFFFF) != 14268) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_album_with_options() and 0xFFFF) != 7820) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_media() and 0xFFFF) != 20720) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_media_with_options() and 0xFFFF) != 16088) { + if ((lib.uniffi_monogram_mtproto_checksum_func_send_uploaded_media_with_options() and 0xFFFF) != 51953) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_set_client_test_dc() and 0xFFFF) != 26005) { @@ -2107,10 +2111,10 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { * @suppress */ public fun uniffiEnsureInitialized() { - IntegrityCheckingUniffiLib - // UniffiLib() initialized as objects are used, but we still need to explicitly - // reference it so initialization across crates works as expected. - UniffiLib + // Call arbitrary methods on IntegrityCheckingUniffiLib and UniffiLib to ensure that + // their init blocks run. This ensures initialization across crates works as expected. + IntegrityCheckingUniffiLib.ensureInitialized() + UniffiLib.ensureInitialized() } // Async support diff --git a/network/bridge/build.gradle.kts b/network/bridge/build.gradle.kts index 5f2186dd3..7117aa8dd 100644 --- a/network/bridge/build.gradle.kts +++ b/network/bridge/build.gradle.kts @@ -15,6 +15,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } testOptions { @@ -29,6 +30,7 @@ dependencies { implementation(project(":network:http")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + coreLibraryDesugaring(libs.desugar.jdk.libs) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.core) diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt b/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt index 2c5614d92..3569229cd 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt @@ -1,9 +1,12 @@ package org.monogram.network.bridge import org.monogram.core.models.Chat +import org.monogram.core.models.CloudDraft import org.monogram.core.models.Folder import org.monogram.core.models.Message +import org.monogram.core.models.OutgoingMessageResult import org.monogram.core.models.PeerId +import org.monogram.core.models.ScheduledMessage import org.monogram.network.bridge.chat.ChatOps import org.monogram.network.bridge.media.MediaOps import org.monogram.network.bridge.message.MessageOps @@ -42,6 +45,17 @@ sealed class MtprotoUpdate { val accountGeneration: Long = 0, val acceptedOrder: Long = 0, ) : MtprotoUpdate() + data class OutgoingMessageAcknowledged( + val randomId: Long, val result: OutgoingMessageResult, + ) : MtprotoUpdate() + + data class ScheduledMessageChanged(val message: ScheduledMessage) : MtprotoUpdate() + data class ScheduledMessagesDeleted( + val chatId: PeerId, val messageIds: List, val sentMessageIds: List?, + ) : MtprotoUpdate() + + data class PollChanged(val pollId: Long) : MtprotoUpdate() + data class DraftChanged(val draft: CloudDraft) : MtprotoUpdate() data class MessageEdited(val message: Message) : MtprotoUpdate() data class MessagesDeleted(val chatId: PeerId?, val messageIds: List) : MtprotoUpdate() data class FoldersChanged(val folders: List) : MtprotoUpdate() diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt index 962bebec4..68d5909ae 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatApi.kt @@ -243,6 +243,7 @@ internal class ChatApi(private val core: SessionCore) : ChatOps { joined = joined.joined, canSendPlain = joined.canSendPlain, canSendPhotos = joined.canSendPhotos, + canSendPolls = joined.canSendPolls, ) } } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatOps.kt index 7e0b11d83..28e8141de 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/chat/ChatOps.kt @@ -10,6 +10,7 @@ data class JoinMembership( val joined: Boolean, val canSendPlain: Boolean, val canSendPhotos: Boolean, + val canSendPolls: Boolean = false, ) interface ChatOps { diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt b/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt index 738557f8d..de8371b28 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/chat/Mappings.kt @@ -36,6 +36,7 @@ internal fun ChatDto.toModel(pinnedOrder: Int = Int.MAX_VALUE): Chat = Chat( canView = canView, canSendPlain = canSendPlain, canSendPhotos = canSendPhotos, + canSendPolls = canSendPolls, canForward = canForward, canDeleteOthers = canDeleteOthers, canManageTopics = canManageTopics, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/DraftMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/DraftMapping.kt new file mode 100644 index 000000000..a9ea2ed3d --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/DraftMapping.kt @@ -0,0 +1,17 @@ +package org.monogram.network.bridge.message + +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.PeerId +import uniffi.monogram_mtproto.CloudDraft as NativeCloudDraft + +internal fun NativeCloudDraft.toModel() = CloudDraft( + chatId = PeerId(chatId), threadId = threadId, text = text, + entitiesJson = entitiesJson, replyToMessageId = replyToMessageId, + date = date, noWebpage = noWebpage, hasUnsupportedContent = hasUnsupportedContent, +) + +internal fun CloudDraft.toNative() = NativeCloudDraft( + chatId = chatId.value, threadId = threadId, text = text, + entitiesJson = entitiesJson, replyToMessageId = replyToMessageId, + date = date, noWebpage = noWebpage, hasUnsupportedContent = hasUnsupportedContent, +) diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt index 3d83451cd..0fe651591 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageApi.kt @@ -4,15 +4,29 @@ import kotlinx.coroutines.withTimeoutOrNull import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog +import org.monogram.core.models.ChecklistSendSpec +import org.monogram.core.models.ChecklistTextEdit +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.ContactImportMatch +import org.monogram.core.models.ContactImportPopularity +import org.monogram.core.models.ContactImportResult +import org.monogram.core.models.ContactImportSpec +import org.monogram.core.models.ContactSendSpec import org.monogram.core.models.DiscussionRef +import org.monogram.core.models.GeoPlace import org.monogram.core.models.Message +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.MessageViewer +import org.monogram.core.models.MessageViewerPage import org.monogram.core.models.MessageViewers import org.monogram.core.models.OutboxReadState +import org.monogram.core.models.OutgoingMessageResult import org.monogram.core.models.PeerId +import org.monogram.core.models.PollSendSpec import org.monogram.core.models.ReactionChoice import org.monogram.core.models.ReadReceiptConfig import org.monogram.core.models.SavedGif +import org.monogram.core.models.ScheduledMessage import org.monogram.core.models.TextEntities.serialize import org.monogram.core.models.UploadItem import org.monogram.network.bridge.MtprotoUpdate @@ -49,6 +63,23 @@ internal class MessageApi( return outcome } + override suspend fun getMessage(chatId: PeerId, messageId: Int): Outcome { + if (messageId <= 0) return Outcome.Err("message id required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return withTimeoutOrNull(core.historyTimeoutMs.milliseconds) { + core.rpc("getMessage failed") { handle -> + val message = core.native.getMessage(handle, chatId.value, messageId) + if (message != null && (message.chatId != chatId.value || message.id != messageId)) { + throw uniffi.monogram_mtproto.MtprotoException.Message("unexpected message identity") + } + message?.toModel() + } + } ?: core.fail(Exception("RPC timeout"), "getMessage failed") + } + override suspend fun getHistoryPage( chatId: PeerId, limit: Int, @@ -208,6 +239,42 @@ internal class MessageApi( return Outcome.Ok(viewers.copy(viewers = attachViewerProfiles(viewers.viewers, members))) } + override suspend fun getReactionUsersPage( + chatId: PeerId, + messageId: Int, + offset: String? + ): Outcome { + if (messageId <= 0) return Outcome.Err("message id required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpc("getMessageReactionsPage failed") { handle -> + val page = core.native.getMessageReactionsPage(handle, chatId.value, messageId, offset) + MessageViewerPage( + page.peers.map { it.toViewer() }, page.count, + page.nextOffset?.takeIf { it.isNotEmpty() }) + } + } + + override suspend fun getPollVotersPage( + chatId: PeerId, + messageId: Int, + offset: String? + ): Outcome { + if (messageId <= 0) return Outcome.Err("message id required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpc("getPollVotesPage failed") { handle -> + val page = core.native.getPollVotesPage(handle, chatId.value, messageId, offset) + MessageViewerPage( + page.voters.map { it.toViewer() }, page.count, + page.nextOffset?.takeIf { it.isNotEmpty() }) + } + } + override suspend fun getReactionUsers( chatId: PeerId, messageId: Int, @@ -264,6 +331,256 @@ internal class MessageApi( } } + override suspend fun getScheduledHistory(chatId: PeerId): Outcome> { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpc("getScheduledHistory failed") { handle -> + core.native.getScheduledHistory(handle, chatId.value).map { it.toModel() } + } + } + + override suspend fun getScheduledMessages( + chatId: PeerId, + ids: List + ): Outcome> { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpc("getScheduledMessages failed") { handle -> + core.native.getScheduledMessages(handle, chatId.value, ids).map { it.toModel() } + } + } + + override suspend fun sendScheduledMessages(chatId: PeerId, ids: List): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendScheduledMessages failed") { handle -> + core.native.sendScheduledMessages(handle, chatId.value, ids) + } + } + + override suspend fun deleteScheduledMessages(chatId: PeerId, ids: List): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("deleteScheduledMessages failed") { handle -> + core.native.deleteScheduledMessages(handle, chatId.value, ids) + } + } + + override suspend fun editScheduledMessage( + chatId: PeerId, messageId: Int, scheduleDate: Int, + text: String?, entitiesJson: String?, noWebpage: Boolean, + ): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("editScheduledMessage failed") { handle -> + core.native.editScheduledMessage( + handle, + chatId.value, + messageId, + scheduleDate, + text, + entitiesJson, + noWebpage + ) + } + } + + override suspend fun getCloudDrafts(): Outcome> { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcBackground("getCloudDrafts failed") { handle -> + core.native.getCloudDrafts(handle).map { it.toModel() } + } + } + + override suspend fun saveCloudDraft(draft: CloudDraft): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("saveCloudDraft failed") { handle -> + core.native.saveCloudDraft(handle, draft.toNative()) + } + } + + override suspend fun importContacts(contacts: List): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("importContacts failed") { handle -> + val result = core.native.importContacts(handle, contacts.map { + uniffi.monogram_mtproto.ContactImportSpec( + it.clientId, + it.phone, + it.firstName, + it.lastName + ) + }) + ContactImportResult( + result.imported.map { + ContactImportMatch( + it.clientId, + PeerId(it.userId) + ) + }, + result.retryClientIds, + result.popular.map { + ContactImportPopularity( + it.clientId, + it.importers + ) + }) + } + } + + override suspend fun sendLocationWithOptions( + chatId: PeerId, place: GeoPlace, randomId: Long, + options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int + ): Outcome { + if (randomId == 0L || (!place.live && place.periodSeconds != 0) || (place.live && place.periodSeconds <= 0)) return Outcome.Err( + "location send identity or period invalid" + ) + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected; is Outcome.Ok -> Unit + } + return core.rpcWrite("sendLocationWithOptions failed") { handle -> + core.native.sendLocationWithOptions( + handle, chatId.value, + uniffi.monogram_mtproto.LocationSendSpec( + place.latitude, + place.longitude, + if (place.live) place.periodSeconds else 0, + place.heading + ), + replyToMsgId, topMsgId, randomId, options.toNative() + ).toModel() + } + } + + override suspend fun sendContactWithOptions( + chatId: PeerId, spec: ContactSendSpec, randomId: Long, + options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + ): Outcome { + if (randomId == 0L) return Outcome.Err("contact send requires persisted identity") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendContactWithOptions failed") { handle -> + core.native.sendContactWithOptions( + handle, chatId.value, + uniffi.monogram_mtproto.ContactSendSpec( + spec.phone, + spec.firstName, + spec.lastName, + spec.vcard + ), + replyToMsgId, topMsgId, randomId, options.toNative() + ).toModel() + } + } + + override suspend fun sendChecklistWithOptions( + chatId: PeerId, spec: ChecklistSendSpec, randomId: Long, + options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + ): Outcome { + if (randomId == 0L) return Outcome.Err("checklist send requires persisted identity") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendChecklistWithOptions failed") { handle -> + core.native.sendChecklistWithOptions( + handle, chatId.value, + uniffi.monogram_mtproto.ChecklistSendSpec( + spec.title, + spec.items, + spec.othersCanAppend, + spec.othersCanComplete + ), + replyToMsgId, topMsgId, randomId, options.toNative() + ).toModel() + } + } + + override suspend fun sendPollWithOptions( + chatId: PeerId, spec: PollSendSpec, randomId: Long, + options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + ): Outcome { + if (randomId == 0L || spec.correctAnswer?.let { it < 0 } == true) return Outcome.Err("poll send identity or answer invalid") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendPollWithOptions failed") { handle -> + core.native.sendPollWithOptions( + handle, chatId.value, + uniffi.monogram_mtproto.PollSendSpec( + spec.question, spec.answers, spec.anonymous, spec.multipleChoice, + spec.quiz, spec.correctAnswer?.toUInt(), spec.solution + ), + replyToMsgId, topMsgId, randomId, options.toNative() + ).toModel() + } + } + + override suspend fun sendDiceWithOptions( + chatId: PeerId, emoticon: String, randomId: Long, + options: MessageSendOptions, replyToMsgId: Int, topMsgId: Int, + ): Outcome { + if (randomId == 0L) return Outcome.Err("dice send requires persisted identity") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendDiceWithOptions failed") { handle -> + core.native.sendDiceWithOptions( + handle, + chatId.value, + emoticon, + replyToMsgId, + topMsgId, + randomId, + options.toNative() + ).toModel() + } + } + + override suspend fun sendTextWithOptions( + chatId: PeerId, + text: String, + options: MessageSendOptions, + randomId: Long, + replyToMsgId: Int, + entitiesJson: String?, + topMsgId: Int, + webpageUrl: String?, + ): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendTextWithOptions failed") { handle -> + core.native.sendTextMessageWithOptions( + handle, chatId.value, text, replyToMsgId, + entitiesJson, topMsgId, webpageUrl, randomId, options.toNative() + ).toModel() + } + } + override suspend fun sendText( chatId: PeerId, text: String, @@ -340,6 +657,27 @@ internal class MessageApi( } } + override suspend fun replaceUploadedMedia( + chatId: PeerId, + messageId: Int, + expectedKey: String, + item: UploadItem + ): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("replaceUploadedMedia failed") { handle -> + core.native.replaceUploadedMedia( + handle, + chatId.value, + messageId, + expectedKey, + item.toDto() + ).toModel() + } + } + override suspend fun sendUploadedMedia( chatId: PeerId, item: UploadItem, @@ -384,6 +722,41 @@ internal class MessageApi( } } + override suspend fun sendUploadedMediaWithOptions( + chatId: PeerId, item: UploadItem, options: MessageSendOptions, + replyToMsgId: Int, topMsgId: Int, entitiesJson: String?, + ): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendUploadedMediaWithOptions failed") { activeHandle -> + core.native.sendUploadedMediaWithOptions( + activeHandle, chatId.value, item.toDto(), replyToMsgId, + topMsgId, entitiesJson ?: serialize(item.captionEntities), options.toNative() + ).toModel() + } + } + + override suspend fun sendUploadedAlbumWithOptions( + chatId: PeerId, + items: List, + options: MessageSendOptions, + replyToMsgId: Int, + topMsgId: Int, + ): Outcome> { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("sendUploadedAlbumWithOptions failed") { activeHandle -> + core.native.sendUploadedAlbumWithOptions( + activeHandle, chatId.value, items.map { it.toDto() }, + replyToMsgId, topMsgId, options.toNative() + ).map { it.toModel() } + } + } + override suspend fun editText( chatId: PeerId, messageId: Int, @@ -480,6 +853,31 @@ internal class MessageApi( } } + override suspend fun editLiveLocation( + chatId: PeerId, + messageId: Int, + latitude: Double?, + longitude: Double?, + heading: Int?, + stopped: Boolean + ): Outcome { + if (messageId <= 0) return Outcome.Err("live location message required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected; is Outcome.Ok -> Unit + } + return core.rpcWrite("editLiveLocation failed") { handle -> + core.native.editLiveLocation( + handle, + chatId.value, + messageId, + latitude, + longitude, + heading, + stopped + ) + } + } + override suspend fun sendLocation( chatId: PeerId, latitude: Double, @@ -505,6 +903,80 @@ internal class MessageApi( } } + override suspend fun removeChecklistItem( + chatId: PeerId, + messageId: Int, + edit: ChecklistTextEdit + ): Outcome { + if (messageId <= 0 || edit.itemId == null) return Outcome.Err("checklist item required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected; is Outcome.Ok -> Unit + } + return core.rpcWrite("removeChecklistItem failed") { handle -> + core.native.removeChecklistItem( + handle, chatId.value, messageId, uniffi.monogram_mtproto.ChecklistTextEditDto( + edit.itemId, + edit.text, + serialize(edit.entities), + edit.expectedText, + serialize(edit.expectedEntities) + ) + ) + } + } + + override suspend fun editChecklistText( + chatId: PeerId, + messageId: Int, + edit: ChecklistTextEdit + ): Outcome { + if (messageId <= 0 || edit.text.isBlank()) return Outcome.Err("checklist text required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("editChecklistText failed") { handle -> + core.native.editChecklistText( + handle, chatId.value, messageId, uniffi.monogram_mtproto.ChecklistTextEditDto( + itemId = edit.itemId, + text = edit.text, + entitiesJson = serialize(edit.entities), + expectedText = edit.expectedText, + expectedEntitiesJson = serialize(edit.expectedEntities) + ) + ) + } + } + + override suspend fun editChecklistTitle( + chatId: PeerId, + messageId: Int, + title: String + ): Outcome { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("editChecklistTitle failed") { handle -> + core.native.editChecklistTitle(handle, chatId.value, messageId, title) + } + } + + override suspend fun closePoll(chatId: PeerId, messageId: Int): Outcome { + if (messageId <= 0) return Outcome.Err("message id required") + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return core.rpcWrite("closePoll failed") { handle -> + core.native.closePoll( + handle, + chatId.value, + messageId + ) + } + } + override suspend fun sendPollVote( chatId: PeerId, messageId: Int, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt index 02b691910..582dc04f0 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt @@ -1,15 +1,27 @@ package org.monogram.network.bridge.message import org.monogram.core.common.Outcome +import org.monogram.core.models.ChecklistSendSpec +import org.monogram.core.models.ChecklistTextEdit +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.ContactImportResult +import org.monogram.core.models.ContactImportSpec +import org.monogram.core.models.ContactSendSpec import org.monogram.core.models.DiscussionRef +import org.monogram.core.models.GeoPlace import org.monogram.core.models.Message +import org.monogram.core.models.MessageSendOptions import org.monogram.core.models.MessageViewer +import org.monogram.core.models.MessageViewerPage import org.monogram.core.models.MessageViewers import org.monogram.core.models.OutboxReadState +import org.monogram.core.models.OutgoingMessageResult import org.monogram.core.models.PeerId +import org.monogram.core.models.PollSendSpec import org.monogram.core.models.ReactionChoice import org.monogram.core.models.ReadReceiptConfig import org.monogram.core.models.SavedGif +import org.monogram.core.models.ScheduledMessage import org.monogram.core.models.UploadItem data class MessagePage( @@ -19,6 +31,9 @@ data class MessagePage( interface MessageOps { suspend fun getHistory(chatId: PeerId, limit: Int = 50): Outcome> + suspend fun getMessage(chatId: PeerId, messageId: Int): Outcome = + Outcome.Err("message lookup unavailable") + suspend fun getHistoryPage( chatId: PeerId, limit: Int = 40, @@ -69,6 +84,19 @@ interface MessageOps { memberScanLimit: Int, ): Outcome = Outcome.Err("message viewers unavailable") + suspend fun getReactionUsersPage( + chatId: PeerId, + messageId: Int, + offset: String? + ): Outcome = + Outcome.Err("reaction pages unavailable") + + suspend fun getPollVotersPage( + chatId: PeerId, + messageId: Int, + offset: String? + ): Outcome = Outcome.Err("poll pages unavailable") + suspend fun getReactionUsers( chatId: PeerId, messageId: Int, @@ -89,6 +117,92 @@ interface MessageOps { suspend fun readReceiptConfig(): Outcome = Outcome.Ok(ReadReceiptConfig.Fallback) + suspend fun getScheduledHistory(chatId: PeerId): Outcome> = + Outcome.Err("scheduled messages unavailable") + + suspend fun getScheduledMessages( + chatId: PeerId, + ids: List + ): Outcome> = + Outcome.Err("scheduled messages unavailable") + + suspend fun sendScheduledMessages(chatId: PeerId, ids: List): Outcome = + Outcome.Err("scheduled messages unavailable") + + suspend fun deleteScheduledMessages(chatId: PeerId, ids: List): Outcome = + Outcome.Err("scheduled messages unavailable") + + suspend fun editScheduledMessage( + chatId: PeerId, messageId: Int, scheduleDate: Int, + text: String? = null, entitiesJson: String? = null, noWebpage: Boolean = false, + ): Outcome = Outcome.Err("scheduled messages unavailable") + + suspend fun getCloudDrafts(): Outcome> = + Outcome.Err("cloud drafts unavailable") + + suspend fun saveCloudDraft(draft: CloudDraft): Outcome = + Outcome.Err("cloud drafts unavailable") + + suspend fun importContacts(contacts: List): Outcome = + Outcome.Err("contact import unavailable") + + suspend fun sendLocationWithOptions( + chatId: PeerId, + place: GeoPlace, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0 + ): Outcome = + Outcome.Err("location send unavailable") + + suspend fun sendContactWithOptions( + chatId: PeerId, + spec: ContactSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0, + ): Outcome = + Outcome.Err("contact send unavailable") + + suspend fun sendChecklistWithOptions( + chatId: PeerId, + spec: ChecklistSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0, + ): Outcome = + Outcome.Err("checklist send unavailable") + + suspend fun sendPollWithOptions( + chatId: PeerId, + spec: PollSendSpec, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0, + ): Outcome = + Outcome.Err("poll send unavailable") + + suspend fun sendDiceWithOptions( + chatId: PeerId, + emoticon: String, + randomId: Long, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0, + ): Outcome = + Outcome.Err("dice send unavailable") + + suspend fun sendTextWithOptions( + chatId: PeerId, text: String, options: MessageSendOptions, + randomId: Long, replyToMsgId: Int = 0, entitiesJson: String? = null, topMsgId: Int = 0, + webpageUrl: String? = null, + ): Outcome = + Outcome.Err("send options unavailable") + suspend fun sendText( chatId: PeerId, text: String, @@ -108,6 +222,13 @@ interface MessageOps { entitiesJson: String? = null, ): Outcome + suspend fun replaceUploadedMedia( + chatId: PeerId, + messageId: Int, + expectedKey: String, + item: UploadItem + ): Outcome = Outcome.Err("unsupported") + suspend fun sendUploadedMedia( chatId: PeerId, item: UploadItem, @@ -123,6 +244,19 @@ interface MessageOps { topMsgId: Int = 0, ): Outcome> = Outcome.Err("unsupported") + suspend fun sendUploadedMediaWithOptions( + chatId: PeerId, item: UploadItem, options: MessageSendOptions, + replyToMsgId: Int = 0, topMsgId: Int = 0, entitiesJson: String? = null, + ): Outcome = Outcome.Err("unsupported") + + suspend fun sendUploadedAlbumWithOptions( + chatId: PeerId, + items: List, + options: MessageSendOptions, + replyToMsgId: Int = 0, + topMsgId: Int = 0, + ): Outcome> = Outcome.Err("unsupported") + suspend fun editText( chatId: PeerId, messageId: Int, @@ -151,6 +285,15 @@ interface MessageOps { incompleted: List, ): Outcome = Outcome.Err("unsupported") + suspend fun editLiveLocation( + chatId: PeerId, + messageId: Int, + latitude: Double? = null, + longitude: Double? = null, + heading: Int? = null, + stopped: Boolean = false + ): Outcome = Outcome.Err("unsupported") + /** Sends a static pin, or a live location when [livePeriodSeconds] is positive. */ suspend fun sendLocation( chatId: PeerId, @@ -162,6 +305,25 @@ interface MessageOps { ): Outcome = Outcome.Err("unsupported") /** Votes in a poll; [options] are the raw option bytes from the poll payload. */ + suspend fun removeChecklistItem( + chatId: PeerId, + messageId: Int, + edit: ChecklistTextEdit + ): Outcome = Outcome.Err("unsupported") + + suspend fun editChecklistText( + chatId: PeerId, + messageId: Int, + edit: ChecklistTextEdit + ): Outcome = + Outcome.Err("unsupported") + + suspend fun editChecklistTitle(chatId: PeerId, messageId: Int, title: String): Outcome = + Outcome.Err("checklist editing unavailable") + + suspend fun closePoll(chatId: PeerId, messageId: Int): Outcome = + Outcome.Err("poll close unavailable") + suspend fun sendPollVote( chatId: PeerId, messageId: Int, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/ScheduledMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/ScheduledMapping.kt new file mode 100644 index 000000000..31cb0976b --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/ScheduledMapping.kt @@ -0,0 +1,7 @@ +package org.monogram.network.bridge.message + +import org.monogram.core.models.ScheduledMessage +import uniffi.monogram_mtproto.ScheduledMessageDto + +internal fun ScheduledMessageDto.toModel(): ScheduledMessage = + ScheduledMessage(message.toModel(), hasUnsupportedContent) diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/SendOptionsMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/SendOptionsMapping.kt new file mode 100644 index 000000000..17505c791 --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/SendOptionsMapping.kt @@ -0,0 +1,16 @@ +package org.monogram.network.bridge.message + +import org.monogram.core.models.MessageSendOptions +import org.monogram.core.models.OutgoingMessageResult + +internal fun MessageSendOptions.toNative() = uniffi.monogram_mtproto.SendOptions( + silent = silent, noWebpage = noWebpage, scheduleDate = scheduleDate, +) + +internal fun uniffi.monogram_mtproto.OutgoingMessageResult.toModel(): OutgoingMessageResult = + when (this) { + is uniffi.monogram_mtproto.OutgoingMessageResult.Sent -> OutgoingMessageResult.Sent(message.toModel()) + is uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled -> OutgoingMessageResult.Scheduled( + message.toModel() + ) + } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt index 619808872..b9075043c 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt @@ -3,6 +3,7 @@ package org.monogram.network.bridge.updates import org.monogram.core.common.AppLog import org.monogram.core.models.PeerId import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.message.toModel as toDraftModel import org.monogram.network.bridge.chat.toChatModels import uniffi.monogram_mtproto.UpdateEventDto import org.monogram.network.bridge.message.toModel as toMessageModel @@ -14,6 +15,22 @@ import org.monogram.network.bridge.message.toModel as toMessageModel internal fun UpdateEventDto.toMtprotoUpdates(): List = when (val event = this) { is UpdateEventDto.ChatsChanged, is UpdateEventDto.FoldersChanged -> emptyList() + is UpdateEventDto.OutgoingMessageAcknowledged -> listOf( + MtprotoUpdate.OutgoingMessageAcknowledged( + event.randomId, event.result.toMessageModel(), + ) + ) + + is UpdateEventDto.ScheduledMessageChanged -> listOf(MtprotoUpdate.ScheduledMessageChanged(event.message.toMessageModel())) + is UpdateEventDto.ScheduledMessagesDeleted -> listOf( + MtprotoUpdate.ScheduledMessagesDeleted( + PeerId(event.chatId), event.messageIds, event.sentMessageIds, + ) + ) + + is UpdateEventDto.PollChanged -> listOf(MtprotoUpdate.PollChanged(event.pollId)) + is UpdateEventDto.DraftChanged -> listOf(MtprotoUpdate.DraftChanged(event.draft.toDraftModel())) + is UpdateEventDto.NewMessage -> { AppLog.api("updates", "newMessage chat=${event.message.chatId} id=${event.message.id}") listOf(MtprotoUpdate.NewMessage(event.message.toMessageModel())) diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt index 7ffcf70af..7317c425b 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt @@ -241,6 +241,10 @@ class BridgedMtprotoClientHistoryTest { "Patched", seen.filterIsInstance().single().chats.single().title, ) + assertTrue( + seen.filterIsInstance() + .single().chats.single().canSendPolls + ) assertEquals( listOf(false, true, false), seen.filterIsInstance().map { it.isSyncing }, @@ -410,6 +414,7 @@ class BridgedMtprotoClientHistoryTest { ) val joinedChat = (joined.getChats() as Outcome.Ok).value.single() assertFalse(joinedChat.left) + assertFalse(joinedChat.canSendPolls) assertFalse(joinedChat.isGroup) assertEquals(2, joinedChat.unreadMentionsCount) assertEquals(1, joinedChat.unreadReactionsCount) @@ -825,6 +830,132 @@ class BridgedMtprotoClientHistoryTest { client.close() } + @Test + fun checklistTextEditsKeepNullableItemIdentityAndEntitySnapshots() = runBlocking { + val captured = mutableListOf() + val native = object : RecordingNative() { + override fun removeChecklistItem( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto + ) { + assertEquals(42L, chatId); assertEquals(37, messageId); captured += edit + } + + override fun editChecklistText( + handle: Long, + chatId: Long, + messageId: Int, + edit: uniffi.monogram_mtproto.ChecklistTextEditDto + ) { + assertEquals(42L, chatId); assertEquals(37, messageId); captured += edit + } + } + val client = BridgedMtprotoClient( + TelegramCredentials(1, "hash"), + "checklist-text.session", + native = native, + refreshDcSidecar = {}) + val entities = listOf(org.monogram.core.models.TextEntity("bold", 3, 4)) + try { + for (id in listOf(null, 0, -7, Int.MAX_VALUE)) { + val spec = org.monogram.core.models.ChecklistTextEdit( + id, + "😀 task", + entities, + "Old", + emptyList() + ) + assertTrue(client.editChecklistText(PeerId(42), 37, spec) is Outcome.Ok) + assertEquals( + uniffi.monogram_mtproto.ChecklistTextEditDto( + id, "😀 task", + org.monogram.core.models.TextEntities.serialize(entities), "Old", null + ), captured.last() + ) + } + assertEquals(4, captured.size) + for (id in listOf(0, -7, Int.MAX_VALUE)) { + val spec = org.monogram.core.models.ChecklistTextEdit( + id, + "", + emptyList(), + "😀 task", + entities + ) + assertTrue(client.removeChecklistItem(PeerId(42), 37, spec) is Outcome.Ok) + assertEquals( + uniffi.monogram_mtproto.ChecklistTextEditDto( + id, "", null, "😀 task", + org.monogram.core.models.TextEntities.serialize(entities) + ), captured.last() + ) + } + assertTrue( + client.removeChecklistItem( + PeerId(42), 37, + org.monogram.core.models.ChecklistTextEdit( + null, + "", + emptyList(), + "Old", + emptyList() + ) + ) is Outcome.Err + ) + assertEquals(7, captured.size) + } finally { + client.close() + } + } + + @Test + fun mediaReplacementPreservesTargetAndMapsFloodWait() = runBlocking { + var observed: uniffi.monogram_mtproto.UploadItemDto? = null + val native = object : RecordingNative() { + override fun replaceUploadedMedia( + handle: Long, chatId: Long, messageId: Int, expectedKey: String, + item: uniffi.monogram_mtproto.UploadItemDto + ): uniffi.monogram_mtproto.MessageDto { + assertEquals(42L, chatId); assertEquals( + 37, + messageId + ); assertEquals("photo:fixture", expectedKey) + observed = item + throw uniffi.monogram_mtproto.MtprotoException.Message("RPC 420: FLOOD_WAIT_60") + } + } + val client = BridgedMtprotoClient( + TelegramCredentials(1, "hash"), + "media-replacement.session", + native = native, + refreshDcSidecar = {}) + try { + val result = client.replaceUploadedMedia( + PeerId(42), 37, "photo:fixture", + org.monogram.core.models.UploadItem( + "synthetic-unused", + "photo", + mimeType = "image/jpeg", + fileName = "fixture.jpg" + ) + ) + assertTrue(result is Outcome.Err) + assertEquals(60, (result as Outcome.Err).telegramError.retryAfterSeconds) + assertEquals("photo", observed!!.kind); assertEquals( + "synthetic-unused", + observed!!.path + ) + assertEquals( + "", + observed!!.caption + ); org.junit.Assert.assertNull(observed!!.entitiesJson) + } finally { + client.close() + } + } + private fun sampleChat(title: String) = ChatDto( id = 42L, title = title, @@ -851,6 +982,7 @@ class BridgedMtprotoClientHistoryTest { canView = true, canSendPlain = true, canSendPhotos = true, + canSendPolls = true, canForward = true, canDeleteOthers = false, emojiStatusDocumentId = null, @@ -941,6 +1073,7 @@ class BridgedMtprotoClientHistoryTest { canView = true, canSendPlain = true, canSendPhotos = true, + canSendPolls = false, canForward = true, canDeleteOthers = false, emojiStatusDocumentId = null, diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt index 4ba00b84b..5d5bd8eeb 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientLifecycleTest.kt @@ -1434,6 +1434,199 @@ class BridgedMtprotoClientLifecycleTest { } } + @Test + fun uploadedOptionsPreserveCallerIdentityFlagsAndAuthoritativeResultModes() = runTest { + val native = object : RecordingNative() { + override fun sendUploadedMediaWithOptions( + handle: Long, + chatId: Long, + item: uniffi.monogram_mtproto.UploadItemDto, + replyToMsgId: Int, + topMsgId: Int, + entitiesJson: String?, + options: uniffi.monogram_mtproto.SendOptions + ): uniffi.monogram_mtproto.OutgoingMessageResult { + assertEquals(7L, chatId); assertEquals(123L, item.randomId) + assertEquals(8, replyToMsgId); assertEquals(9, topMsgId) + assertTrue(options.silent); assertEquals(0x7ffffffe, options.scheduleDate) + return uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( + uniffi.monogram_mtproto.ScheduledMessageDto(sampleMessage(7, 37), false) + ) + } + + override fun sendUploadedAlbumWithOptions( + handle: Long, chatId: Long, items: List, + replyToMsgId: Int, topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions + ): List { + assertEquals(listOf(123L, 456L), items.map { it.randomId }) + assertTrue(options.silent) + return listOf( + uniffi.monogram_mtproto.OutgoingMessageResult.Sent(sampleMessage(7, 38)), + uniffi.monogram_mtproto.OutgoingMessageResult.Scheduled( + uniffi.monogram_mtproto.ScheduledMessageDto( + sampleMessage(7, 39), + false + ) + ) + ) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + val item = org.monogram.core.models.UploadItem( + path = "synthetic-unused", + kind = "photo", + randomId = 123 + ) + val options = + org.monogram.core.models.MessageSendOptions(silent = true, scheduleDate = 0x7ffffffe) + try { + val single = + client.sendUploadedMediaWithOptions(PeerId(7), item, options, 8, 9) as Outcome.Ok + assertTrue(single.value is org.monogram.core.models.OutgoingMessageResult.Scheduled) + val album = client.sendUploadedAlbumWithOptions( + PeerId(7), + listOf(item, item.copy(randomId = 456)), + options + ) as Outcome.Ok + assertTrue(album.value[0] is org.monogram.core.models.OutgoingMessageResult.Sent) + assertTrue(album.value[1] is org.monogram.core.models.OutgoingMessageResult.Scheduled) + } finally { + client.close() + } + } + + @Test + fun failedUploadedOptionsMutationIsNotAutomaticallyReplayed() = runTest { + var attempts = 0 + val native = object : RecordingNative() { + override fun sendUploadedAlbumWithOptions( + handle: Long, chatId: Long, items: List, + replyToMsgId: Int, topMsgId: Int, options: uniffi.monogram_mtproto.SendOptions + ): List { + attempts++ + throw MtprotoException.Message("RPC 500: INTERNAL") + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + val result = client.sendUploadedAlbumWithOptions( + PeerId(7), listOf( + org.monogram.core.models.UploadItem( + path = "synthetic-unused", + kind = "photo", + randomId = 123 + ) + ), + org.monogram.core.models.MessageSendOptions(silent = true) + ) + assertTrue(result is Outcome.Err) + assertEquals(1, attempts) + } finally { + client.close() + } + } + + @Test + fun viewerPageApisPreserveOpaqueCursorsCountsAndTerminalOffsets() = runTest { + val offsets = mutableListOf() + val native = object : RecordingNative() { + override fun getMessageReactionsPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String? + ): uniffi.monogram_mtproto.ReactionPeersDto { + offsets += offset + return uniffi.monogram_mtproto.ReactionPeersDto( + peers = emptyList(), + count = 60, + nextOffset = if (offset == null) "opaque+/=cursor" else "" + ) + } + + override fun getPollVotesPage( + handle: Long, + chatId: Long, + messageId: Int, + offset: String? + ): uniffi.monogram_mtproto.PollVotersDto { + offsets += offset + return uniffi.monogram_mtproto.PollVotersDto( + voters = emptyList(), + count = 75, + nextOffset = "poll-next" + ) + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.getReactionUsersPage(PeerId(7), 0, null) is Outcome.Err) + assertTrue(offsets.isEmpty()) + val first = (client.getReactionUsersPage(PeerId(7), 37, null) as Outcome.Ok).value + assertEquals(60, first.totalCount); assertEquals("opaque+/=cursor", first.nextOffset) + val second = + (client.getReactionUsersPage(PeerId(7), 37, first.nextOffset) as Outcome.Ok).value + assertEquals(null, second.nextOffset) + val poll = (client.getPollVotersPage(PeerId(7), 37, "poll-offset") as Outcome.Ok).value + assertEquals(75, poll.totalCount); assertEquals("poll-next", poll.nextOffset) + assertEquals(listOf(null, "opaque+/=cursor", "poll-offset"), offsets) + } finally { + client.close() + } + } + + @Test + fun closePollPreservesIdentityAndDoesNotReplayUncertainMutation() = runTest { + var attempts = 0 + val native = object : RecordingNative() { + override fun closePoll(handle: Long, chatId: Long, messageId: Int) { + attempts++ + assertEquals(7L, chatId) + assertEquals(37, messageId) + throw MtprotoException.Message("RPC 500: INTERNAL") + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.closePoll(PeerId(7), 0) is Outcome.Err) + assertEquals(0, attempts) + assertTrue(client.closePoll(PeerId(7), 37) is Outcome.Err) + assertEquals(1, attempts) + } finally { + client.close() + } + } + + @Test + fun exactMessageLookupPreservesIdentityAndDeletionAndRejectsForeignResponses() = runTest { + var response: MessageDto? = sampleMessage(7, 37) + var reads = 0 + val native = object : RecordingNative() { + override fun getMessage(handle: Long, chatId: Long, messageId: Int): MessageDto? { + reads++ + assertEquals(7L, chatId) + assertEquals(37, messageId) + return response + } + } + val client = client(native, StandardTestDispatcher(testScheduler)) + try { + assertTrue(client.getMessage(PeerId(7), 0) is Outcome.Err) + assertEquals(0, reads) + val found = client.getMessage(PeerId(7), 37) as Outcome.Ok + assertEquals(37, found.value?.id?.id) + assertEquals(PeerId(7), found.value?.id?.chatId) + response = null + assertEquals(null, (client.getMessage(PeerId(7), 37) as Outcome.Ok).value) + response = sampleMessage(8, 37) + assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) + response = sampleMessage(7, 38) + assertTrue(client.getMessage(PeerId(7), 37) is Outcome.Err) + } finally { + client.close() + } + } + private fun client( native: MtprotoNative, dispatcher: kotlinx.coroutines.CoroutineDispatcher, diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/message/DraftMappingTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/message/DraftMappingTest.kt new file mode 100644 index 000000000..fd918a640 --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/message/DraftMappingTest.kt @@ -0,0 +1,27 @@ +package org.monogram.network.bridge.message + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.core.models.CloudDraft +import org.monogram.core.models.PeerId +import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.bridge.updates.toMtprotoUpdates +import uniffi.monogram_mtproto.UpdateEventDto + +class DraftMappingTest { + @Test + fun draftAndDurableEventPreserveAllFields() { + val draft = CloudDraft( + chatId = PeerId(-1000000000007), threadId = 99, text = "fixture", + entitiesJson = """[{"kind":"bold","offset":0,"length":7}]""", + replyToMessageId = 123, date = 456, noWebpage = true, + hasUnsupportedContent = true, + ) + val native = draft.toNative() + assertEquals(draft, native.toModel()) + assertEquals( + listOf(MtprotoUpdate.DraftChanged(draft)), + UpdateEventDto.DraftChanged(native).toMtprotoUpdates() + ) + } +} diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/updates/ScheduledUpdateMappingTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/updates/ScheduledUpdateMappingTest.kt new file mode 100644 index 000000000..6b6bf2cf7 --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/updates/ScheduledUpdateMappingTest.kt @@ -0,0 +1,29 @@ +package org.monogram.network.bridge.updates + +import org.junit.Assert.* +import org.junit.Test +import org.monogram.core.models.PeerId +import org.monogram.network.bridge.MtprotoUpdate +import uniffi.monogram_mtproto.UpdateEventDto + +class ScheduledUpdateMappingTest { + @Test + fun pollInvalidationPreservesFullWidthIdentity() { + val updates = UpdateEventDto.PollChanged(Long.MAX_VALUE).toMtprotoUpdates() + assertEquals(listOf(MtprotoUpdate.PollChanged(Long.MAX_VALUE)), updates) + } + + @Test + fun queueDeletionsPreserveDeliveryMetadataWithoutProducingHistoryDeletions() { + for (delivered in listOf(null, emptyList(), listOf(500))) { + val updates = + UpdateEventDto.ScheduledMessagesDeleted(7, listOf(37), delivered).toMtprotoUpdates() + assertEquals(1, updates.size) + val deletion = updates.single() as MtprotoUpdate.ScheduledMessagesDeleted + assertEquals(PeerId(7), deletion.chatId) + assertEquals(listOf(37), deletion.messageIds) + assertEquals(delivered, deletion.sentMessageIds) + assertFalse(updates.any { it is MtprotoUpdate.MessagesDeleted || it is MtprotoUpdate.NewMessage }) + } + } +} diff --git a/network/http/src/main/java/org/monogram/network/http/FileCache.kt b/network/http/src/main/java/org/monogram/network/http/FileCache.kt index 2d5826753..400ce47b1 100644 --- a/network/http/src/main/java/org/monogram/network/http/FileCache.kt +++ b/network/http/src/main/java/org/monogram/network/http/FileCache.kt @@ -669,8 +669,7 @@ class FileCache( private fun peekName(key: String): String { diskNames[key]?.let { return it } - val raw = if (key.startsWith(ORPHAN_PREFIX)) key.removePrefix(ORPHAN_PREFIX) else key - return hash(raw) + return if (key.startsWith(ORPHAN_PREFIX)) key.removePrefix(ORPHAN_PREFIX) else hash(key) } private fun diskName(key: String): String = diskNames.computeIfAbsent(key) { raw -> diff --git a/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt index fc318d898..3417f1b54 100644 --- a/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt +++ b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt @@ -40,6 +40,7 @@ import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.INSTANT_VIEW_MEDIA_MSG_ID import org.monogram.core.models.Message import org.monogram.core.models.PeerId +import org.monogram.core.models.ScheduledMessage import java.io.File import java.util.PriorityQueue import java.util.concurrent.ConcurrentHashMap @@ -356,11 +357,27 @@ class MediaRepository( message: Message, priority: Int = MediaPriority.DEFAULT, retain: Boolean = false, + ): Outcome = ensureMessageMedia(message, priority, retain, scheduled = false) + + suspend fun ensureLocalScheduledMessageMedia( + scheduled: ScheduledMessage, + priority: Int = MediaPriority.VISIBLE, + ): Outcome = ensureMessageMedia( + scheduled.message.copy(id = scheduled.mediaMessageId), priority, + retain = false, scheduled = true + ) + + private suspend fun ensureMessageMedia( + message: Message, + priority: Int, + retain: Boolean, + scheduled: Boolean ): Outcome { val key = message.mediaCacheKey ?: return Outcome.Err("message has no media key") return ensureLocalTelegramMedia( key = key, + scheduled = scheduled, chatId = message.id.chatId, messageId = message.id.id, kind = MediaFetchKind.Full, @@ -550,6 +567,21 @@ class MediaRepository( suspend fun ensureLocalMessageThumb( message: Message, priority: Int = MediaPriority.THUMB, + ): Outcome = ensureMessageThumb(message, priority, scheduled = false) + + suspend fun ensureLocalScheduledMessageThumb( + scheduled: ScheduledMessage, + priority: Int = MediaPriority.THUMB, + ): Outcome = ensureMessageThumb( + scheduled.message.copy(id = scheduled.mediaMessageId), + priority, + scheduled = true + ) + + private suspend fun ensureMessageThumb( + message: Message, + priority: Int, + scheduled: Boolean ): Outcome { val distinctThumb = message.thumbCacheKey?.takeUnless { it == message.mediaCacheKey } if (distinctThumb == null && message.mediaKind in NO_SYNTHETIC_THUMB_KINDS) { @@ -563,6 +595,7 @@ class MediaRepository( chatId = message.id.chatId, messageId = message.id.id, kind = MediaFetchKind.Thumb, + scheduled = scheduled, mediaKind = message.mediaKind, priority = priority, ) @@ -735,6 +768,7 @@ class MediaRepository( totalBytes: Long? = null, retain: Boolean = false, consumer: MediaConsumer? = null, + scheduled: Boolean = false, ): Outcome { val storedChatId = if (mediaKind == "gif") null else chatId.value if (mediaKind == "gif") cache.attribute(key, chatId = null, kind = FileCache.KIND_GIFS) @@ -746,9 +780,8 @@ class MediaRepository( ) return Outcome.Ok(if (retain) keptDownload(key) ?: it else it) } - // Negative ids other than instant-view media are not Telegram file slots. - // Page photos and documents are indexed as (id, -1) and must still download. - if (messageId < 0 && messageId != INSTANT_VIEW_MEDIA_MSG_ID) { + // Pending local ids must not become remote file slots. Scheduled callers use a separate encoded namespace. + if (messageId < 0 && messageId != INSTANT_VIEW_MEDIA_MSG_ID && !scheduled) { AppLog.api("media", "skip local kind=${kind.name} key=$key id=$messageId") return Outcome.Err("local media") } diff --git a/network/http/src/test/java/org/monogram/network/http/ScheduledMediaRepositoryTest.kt b/network/http/src/test/java/org/monogram/network/http/ScheduledMediaRepositoryTest.kt new file mode 100644 index 000000000..ec45c1335 --- /dev/null +++ b/network/http/src/test/java/org/monogram/network/http/ScheduledMediaRepositoryTest.kt @@ -0,0 +1,75 @@ +package org.monogram.network.http + +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.monogram.core.common.Outcome +import org.monogram.core.models.* +import java.io.File + +class ScheduledMediaRepositoryTest { + @get:Rule + val temporary = TemporaryFolder() + private fun message(id: Int, key: String) = Message( + MessageId(PeerId(7), id), senderId = null, + text = "fixture", date = 100, outgoing = true, mediaKind = "photo", mediaCacheKey = key, + thumbCacheKey = "$key:thumb" + ) + + @Test + fun scheduledFullAndThumbUseEncodedIdsWithoutOpeningPendingLocalSlots() = runBlocking { + val requests = mutableListOf>() + val repository = MediaRepository( + temporary.newFolder(), + telegramFetcher = TelegramMediaFetcher { peer, id, path, kind, _ -> + assertEquals(PeerId(7), peer) + requests += id to kind + File(path).writeBytes(byteArrayOf(1, 2, 3)) + Outcome.Ok(path) + }) + val pending = message(-38, "local-fixture") + assertTrue(repository.ensureLocalMessageMedia(pending) is Outcome.Err) + assertTrue(repository.ensureLocalMessageThumb(pending) is Outcome.Err) + assertTrue(requests.isEmpty()) + val scheduled = ScheduledMessage(message(37, "queue-fixture")) + assertTrue(repository.ensureLocalScheduledMessageMedia(scheduled) is Outcome.Ok) + assertTrue(repository.ensureLocalScheduledMessageThumb(scheduled) is Outcome.Ok) + assertEquals(listOf(-38 to MediaFetchKind.Full, -38 to MediaFetchKind.Thumb), requests) + assertTrue(repository.ensureLocalMessageMedia(message(37, "history-fixture")) is Outcome.Ok) + assertEquals(37 to MediaFetchKind.Full, requests.last()) + assertTrue(repository.ensureLocalScheduledMessageMedia(scheduled) is Outcome.Ok) + assertEquals(3, requests.size) + assertEquals(37, scheduled.message.id.id) + } + + @Test + fun dismissingScheduledPreviewCancelsItsUnsharedTransfer() = runBlocking { + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + val repository = MediaRepository( + temporary.newFolder(), + telegramFetcher = TelegramMediaFetcher { _, id, _, _, _ -> + assertEquals(-38, id) + try { + started.complete(Unit); awaitCancellation() + } finally { + cancelled.complete(Unit) + } + }) + val waiter = launch { + repository.ensureLocalScheduledMessageMedia( + ScheduledMessage( + message( + 37, + "cancel-fixture" + ) + ) + ) + } + withTimeout(5000) { started.await() } + waiter.cancelAndJoin() + withTimeout(5000) { cancelled.await() } + } +} diff --git a/vendor/mono-rs b/vendor/mono-rs new file mode 160000 index 000000000..313d46b99 --- /dev/null +++ b/vendor/mono-rs @@ -0,0 +1 @@ +Subproject commit 313d46b99cbbb06eb3115259c446a54d6fac1f66 diff --git a/vendor/telers-mtproto-impl b/vendor/telers-mtproto-impl deleted file mode 160000 index 2c3abdfc1..000000000 --- a/vendor/telers-mtproto-impl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2c3abdfc1c0b416585bfb7b85d162128757fc006 diff --git a/vendor/tellers-tl b/vendor/tellers-tl deleted file mode 160000 index f2834fc37..000000000 --- a/vendor/tellers-tl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f2834fc3797a799d2e2f1cf626b292c40d3d1f90