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