diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..e5cc5fc5
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,470 @@
+# AGENTS.md
+
+Internal handbook for agents working in this repository.
+
+Use this file to avoid re-discovering the project, preserve the current architecture, and move quickly in a large Android codebase without introducing stack drift.
+
+Primary source of truth:
+
+- `README.md`
+- `settings.gradle.kts`
+- `gradle/libs.versions.toml`
+
+## Non-Negotiables
+
+- Preserve the current stack and architecture unless the user explicitly asks to change them.
+- Follow the existing MVI approach in `presentation`.
+- Keep non-trivial screen state and side effects in stores/components, not in scattered composable state.
+- Keep flavor-specific behavior aligned across `app`, `data`, and `presentation`.
+- Write or update tests when changes affect logic, parsing, mapping, repositories, stores, reducers, state transitions, or bug fixes.
+- Pure visual copy/layout tweaks may not need tests; behavior changes usually do.
+- If a meaningful test should exist and you do not add one, say so explicitly.
+- Do not replace core libraries with parallel stacks without explicit user approval.
+
+## Architecture
+
+Gradle modules:
+
+- `:app` = final Android application module
+- `:presentation` = Compose UI, navigation, MVI stores, feature logic
+- `:data` = repository implementations, TDLib integration, Room, push, services
+- `:domain` = shared contracts, domain models, managers
+- `:core` = low-level shared utilities
+- `:baselineprofile` = startup/performance benchmarking
+
+Dependency direction:
+
+- `app` depends on `presentation`, `data`, `domain`, `core`
+- `presentation` depends on `domain`, `core`
+- `data` depends on `domain`, `core`
+- `domain` and `core` should remain platform-light
+
+Typical feature flow:
+
+1. Android startup and app wiring happen in `app`
+2. `presentation/root` selects startup and navigation flow
+3. a `Default*Component` wires a `Store`
+4. the `Store` processes intents and uses domain contracts
+5. `data` implements those contracts and talks to TDLib / DB / push / platform services
+6. results are mapped into UI state rendered by `*Content.kt`
+
+## Module Details
+
+### `:app`
+
+Role:
+
+- Android entrypoint
+- build types, flavors, signing, APK naming/copying
+- app shell wiring and top-level Android integration
+
+Main areas:
+
+- `app/src/main/java/org/monogram/app/components`
+- `app/src/main/java/org/monogram/app/di`
+- `app/src/main/java/org/monogram/app/ui`
+- `app/src/main/java/org/monogram/app/ui/theme`
+
+Belongs here:
+
+- application startup
+- Android app shell concerns
+- theme/bootstrap wiring
+- flavor/runtime-specific app setup
+
+Does not belong here:
+
+- repository logic
+- TDLib implementation details
+- large reusable feature flows already living in `presentation`
+
+### `:presentation`
+
+Role:
+
+- main UI module
+- Compose screens, navigation, MVI state management, feature interaction logic
+
+Top-level areas:
+
+- `presentation/root` = app-wide startup/navigation
+- `presentation/di` = Koin wiring and presentation-level setup
+- `presentation/core` = shared UI/media/util infrastructure
+- `presentation/features` = product flows
+- `presentation/settings` = settings flows
+- `presentation/src/main/cpp` = native media/animation support
+
+Common file roles:
+
+- `*Content.kt` = UI rendering
+- `*Store.kt` / `*StoreFactory.kt` = state, intents, side effects
+- `Default*Component.kt` = concrete wiring and navigation integration
+
+Important subareas:
+
+- `features/chats`
+ - largest and most fragile feature area
+ - chat list, conversation, message rendering, input bar, scrolling, selection, editor flows
+- `features/auth`
+ - auth/login flow
+- `features/webapp`, `features/webview`
+ - mini apps and embedded web flows
+- `settings/privacy`, `settings/proxy`, `settings/storage`
+ - settings areas with substantial behavior/state
+
+Belongs here:
+
+- screen state
+- user intent handling
+- navigation state
+- Compose UI
+- presentation-level mapping and UI orchestration
+
+Does not belong here:
+
+- low-level TDLib calls
+- Room access
+- repository implementation details
+
+### `:data`
+
+Role:
+
+- concrete data layer
+- repository implementations and integration boundary with TDLib/platform/storage
+
+Main areas:
+
+- `data/chats`
+- `data/datasource/cache`
+- `data/datasource/remote`
+- `data/db/dao`
+- `data/db/model`
+- `data/di`
+- `data/gateway`
+- `data/infra`
+- `data/mapper`
+- `data/notifications`
+- `data/push`
+- `data/repository`
+- `data/service`
+- `data/stickers`
+- `data/src/official/jniLibs`
+- `data/src/telemt/jniLibs`
+
+Belongs here:
+
+- repository implementations
+- TDLib access
+- local cache and DB
+- push implementation
+- data mapping between external/raw and internal models
+
+Does not belong here:
+
+- Compose UI
+- screen state
+- app shell concerns
+
+### `:domain`
+
+Role:
+
+- shared contract/model layer between `presentation` and `data`
+
+Main areas:
+
+- `domain/models`
+- `domain/models/webapp`
+- `domain/repository`
+- `domain/managers`
+- `domain/proxy`
+
+Belongs here:
+
+- interfaces/contracts
+- stable domain models
+- business-level abstractions
+
+Does not belong here:
+
+- Android framework code
+- Room entities/DAOs
+- Compose/UI code
+- TDLib implementation details
+
+### `:core`
+
+Role:
+
+- low-level shared helpers with low coupling
+
+Known area:
+
+- `core/date`
+
+Belongs here:
+
+- generic reusable utilities
+
+Does not belong here:
+
+- feature-specific behavior
+- app-specific orchestration
+
+### `:baselineprofile`
+
+Role:
+
+- performance/startup benchmark module targeting `:app`
+
+Main files:
+
+- `BaselineProfileGenerator.kt`
+- `StartupBenchmarks.kt`
+
+## Task Recipes
+
+- UI issue on one screen:
+ - start at the nearest `*Content.kt`
+ - then inspect the related `Store`
+ - then inspect the `Default*Component` if navigation/wiring is involved
+- MVI/state bug:
+ - start from `*Store.kt` and `*StoreFactory.kt`
+ - check intents, state transitions, side effects, and any reducer/executor-style logic
+- Chat bug:
+ - start in `presentation/features/chats`
+ - if it smells like data/update/state source issue, follow into `data/chats`, `data/gateway`, `data/datasource/remote`
+- TDLib/API shape bug:
+ - inspect `data/gateway` and `data/datasource/remote`
+ - inspect `data/src/official/java/org/drinkless/tdlib/TdApi.java` or `data/src/telemt/java/org/drinkless/tdlib/TdApi.java`
+ - then check official TDLib docs
+- Mapper/parsing bug:
+ - start in `data/mapper`, then trace caller repository/datasource usage
+- Push or flavor bug:
+ - inspect `firebase/libre` source sets in `app`, `data`, and `presentation`
+ - confirm the same flavor assumption across modules before changing code
+- Storage/database bug:
+ - start in `data/db`, `data/datasource/cache`, and affected repository
+- Build or Android SDK bug:
+ - if errors look environment-related, assume sandbox may be the cause and escalate quickly
+
+## TDLib Reference
+
+The project uses `TdApi.java` as the generated TDLib Java API surface.
+
+Important local copies:
+
+- `data/src/official/java/org/drinkless/tdlib/TdApi.java`
+- `data/src/telemt/java/org/drinkless/tdlib/TdApi.java`
+
+Upstream/reference copies also exist in:
+
+- `td/example/android/tdlib/java/org/drinkless/tdlib/TdApi.java`
+- `td-telemt/example/android/tdlib/java/org/drinkless/tdlib/TdApi.java`
+
+When working on TDLib-related behavior:
+
+- inspect `TdApi.java` first for object/function types and field names
+- inspect `data/gateway` for the project’s TDLib boundary
+- inspect `data/datasource/remote` and repositories for call sites
+
+External TDLib references:
+
+- Telegram API docs: `https://core.telegram.org/api`
+- TDLib C++/API docs: `https://core.telegram.org/tdlib/docs/td__api_8h.html`
+
+IDE note from `README.md`:
+
+- `TdApi.java` is large enough that Android Studio indexing limits may need to be increased
+
+## Dependency Usage Guide
+
+All dependency versions are centralized in `gradle/libs.versions.toml`.
+
+Rules:
+
+- change versions in the version catalog, not ad hoc in module files
+- extend the current stack before adding a new library
+- do not introduce a parallel stack unless the user explicitly asks for it
+
+Current stack and intended use:
+
+- `Kotlin` + `kotlinx.coroutines` + `Flow`
+ - default async/concurrency model
+ - do not introduce `RxJava`
+- `Jetpack Compose` + `Material 3` + adaptive Compose libs
+ - main UI toolkit
+ - do not pivot new UI work to an XML-first architecture
+- `Decompose`
+ - navigation and component lifecycle in `presentation`
+ - do not replace with another navigation stack without request
+- `MVIKotlin`
+ - screen state management and MVI flow in `presentation`
+ - keep non-trivial presentation logic in the existing MVI shape
+- `Koin`
+ - dependency injection across `app`, `presentation`, and `data`
+ - do not replace with `Dagger` or `Hilt`
+- `kotlinx.serialization`
+ - serialization/parsing layer
+ - avoid introducing `Gson` or `Moshi` without explicit need
+- `Room` + `KSP`
+ - persistence and codegen in `data/db`
+ - do not replace with another persistence stack casually
+- `Coil 3`
+ - image, GIF, SVG, and video loading in UI
+ - do not replace with `Glide` or `Picasso`
+- `Media3 / ExoPlayer`
+ - media playback
+- `CameraX`
+ - camera capture and scanner flows
+- `ZXing`
+ - QR/barcode processing
+- `TDLib`
+ - Telegram core integration
+- `Firebase Messaging`
+ - push for `firebase` variants
+- `UnifiedPush`
+ - non-Firebase compatible push path and abstraction
+- `MapLibre Compose`
+ - map UI
+- `AndroidX Security Crypto` + `Biometric`
+ - local security and biometric flows
+- `libphonenumber`
+ - phone number parsing/formatting
+- `OSS Licenses plugin` + `play-services-oss-licenses`
+ - licenses screen support
+
+## Build, Flavors, Sandbox
+
+Flavor dimensions:
+
+- `tdlib`: `official`, `telemt`
+- `runtime`: `firebase`, `libre`
+
+Meaning:
+
+- `official` / `telemt` = TDLib source/binaries
+- `firebase` = Firebase-backed push/runtime path
+- `libre` = non-Firebase path
+
+Required local config in `local.properties`:
+
+```properties
+API_ID=12345678
+API_HASH=your_api_hash_here
+```
+
+Optional release signing:
+
+```properties
+RELEASE_STORE_FILE=keystore/release.jks
+RELEASE_STORE_PASSWORD=...
+RELEASE_KEY_ALIAS=...
+RELEASE_KEY_PASSWORD=...
+```
+
+Firebase variants also require:
+
+- `app/google-services.json`
+
+Native prerequisite for `presentation`:
+
+1. go to `presentation/src/main/cpp`
+2. set `ANDROID_NDK_HOME` in `build.sh`
+3. run `build.sh`
+
+TDLib build commands:
+
+```bash
+./build-tdlib.sh official
+./build-tdlib.sh telemt
+./build-tdlib.sh both
+```
+
+WSL examples from Windows PowerShell:
+
+```powershell
+wsl -d Ubuntu -- bash -lc "cd /mnt/z/GitHub/monogram && ./build-tdlib.sh official"
+wsl -d Ubuntu -- bash -lc "cd /mnt/z/GitHub/monogram && ./build-tdlib.sh telemt"
+```
+
+Useful Gradle tasks:
+
+```powershell
+.\gradlew.bat :app:assembleOfficialLibreDebug
+.\gradlew.bat :app:assembleTelemtLibreDebug
+.\gradlew.bat :data:testOfficialLibreDebugUnitTest
+.\gradlew.bat :presentation:testOfficialLibreDebugUnitTest
+.\gradlew.bat :core:test
+```
+
+Sandbox rule:
+
+- If Gradle, Android SDK, NDK, emulator, ADB, or related tooling fails because SDK paths, environment access, local components, or process spawning appear restricted, assume sandbox is the likely cause.
+- Do not waste time on random workaround guesses first.
+- Automatically request an out-of-sandbox build/test run with escalation.
+- This especially applies to Android SDK lookup errors, missing SDK components that are known to exist locally, emulator/ADB access issues, and Gradle failures that look environment-related rather than code-related.
+
+## Testing Expectations
+
+Add or update tests for:
+
+- bug fixes
+- store/reducer/side-effect logic changes
+- repository behavior changes
+- mapper/parser/utility changes
+- chat behavior changes
+- webapp/proxy logic changes
+- state machine or branching logic changes
+
+Check existing tests first in:
+
+- `presentation/src/test/java`
+- `data/src/test/java`
+- `data/src/officialUnitTest/java`
+- `data/src/telemtUnitTest/java`
+- `core/src/test`
+
+Verification rule:
+
+- run the narrowest relevant test task first
+- then run broader compile/build validation only if needed
+
+## Navigation Heuristics
+
+Usually start from:
+
+- `app` for startup/theme/bootstrap problems
+- `presentation` for UI, navigation, MVI state, and user interaction issues
+- `data` for TDLib, repository, cache, DB, push, and mapper issues
+- `domain` for interfaces/contracts/model boundaries
+- `core` for generic shared utility problems
+
+Usually ignore unless debugging build internals:
+
+- `.git/`
+- `.gradle/`
+- `.idea/`
+- `.kotlin/`
+- `build/`
+- `app/build/`
+- `data/build/`
+- `presentation/build/`
+- `presentation/.cxx/`
+- `domain/build/`
+- `core/build/`
+- `baselineprofile/build/`
+
+## Hotspots / Risks
+
+- `presentation/features/chats`
+ - largest UI/logic surface and easiest place to introduce regressions
+- flavor alignment across `app`, `data`, and `presentation`
+ - one-module-only fixes often break another variant
+- `data/gateway`
+ - TDLib boundary; small changes can fan out widely
+- `data/mapper`
+ - easy place for subtle model regressions
+- `presentation/src/main/cpp`
+ - native build/setup can fail for environment reasons unrelated to app code
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c7a48c3e..95bcccd5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -210,6 +210,8 @@ dependencies {
implementation(project(":core"))
baselineProfile(project(":baselineprofile"))
+
+ testImplementation(libs.junit)
}
tasks.withType(DependencyTask::class.java).configureEach {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d2250ece..c9ba1275 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -70,6 +70,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
PendingAttachment?
+) {
+ constructor(context: android.content.Context) : this(
+ copyUriToAttachment = { uri -> copyUriToAttachment(context, uri) }
+ )
+
+ suspend fun resolve(intent: Intent): IncomingShareRequest? = withContext(Dispatchers.IO) {
+ val payload = extractIncomingSharePayload(intent) ?: return@withContext null
+ val attachments = payload.uris.mapNotNull(copyUriToAttachment)
+
+ IncomingShareRequest(
+ requestId = System.currentTimeMillis(),
+ text = payload.text,
+ attachments = attachments
+ ).takeIf { it.text.isNotBlank() || it.attachments.isNotEmpty() }
+ }
+}
+
+internal data class ExtractedIncomingSharePayload(
+ val text: String,
+ val uris: List
+)
+
+internal data class IncomingSharePayloadInput(
+ val action: String?,
+ val text: String?,
+ val clipDataUris: List = emptyList(),
+ val extraStreamUris: List = emptyList()
+)
+
+internal data class NormalizedIncomingSharePayload(
+ val text: String,
+ val uriStrings: List
+)
+
+internal fun extractIncomingSharePayload(intent: Intent): ExtractedIncomingSharePayload? {
+ val extraStreamUris = when (intent.action) {
+ Intent.ACTION_SEND -> listOfNotNull(
+ intent.parcelableExtraCompat(Intent.EXTRA_STREAM)?.toString()
+ )
+
+ Intent.ACTION_SEND_MULTIPLE -> intent.parcelableArrayListExtraCompat(Intent.EXTRA_STREAM)
+ ?.map(Uri::toString)
+ .orEmpty()
+
+ else -> emptyList()
+ }
+
+ val normalized = normalizeIncomingSharePayload(
+ IncomingSharePayloadInput(
+ action = intent.action,
+ text = intent.getStringExtra(Intent.EXTRA_TEXT),
+ clipDataUris = intent.clipData?.collectUriStrings().orEmpty(),
+ extraStreamUris = extraStreamUris
+ )
+ ) ?: return null
+
+ return ExtractedIncomingSharePayload(
+ text = normalized.text,
+ uris = normalized.uriStrings.map(Uri::parse)
+ )
+}
+
+internal fun normalizeIncomingSharePayload(
+ input: IncomingSharePayloadInput
+): NormalizedIncomingSharePayload? {
+ if (input.action != Intent.ACTION_SEND && input.action != Intent.ACTION_SEND_MULTIPLE) return null
+
+ val uris = LinkedHashSet()
+ input.clipDataUris.asSequence()
+ .map(String::trim)
+ .filter(String::isNotBlank)
+ .forEach(uris::add)
+ input.extraStreamUris.asSequence()
+ .map(String::trim)
+ .filter(String::isNotBlank)
+ .forEach(uris::add)
+
+ val text = input.text.orEmpty().trim()
+ if (text.isBlank() && uris.isEmpty()) return null
+
+ return NormalizedIncomingSharePayload(
+ text = text,
+ uriStrings = uris.toList()
+ )
+}
+
+internal fun copyUriToAttachment(context: android.content.Context, uri: Uri): PendingAttachment? {
+ val mediaCopy = context.copyUriToTempMediaFile(uri)
+ val kind = mediaCopy?.classifyAsMediaKind()
+ if (kind != null) {
+ return PendingAttachment(
+ localPath = mediaCopy.localPath,
+ kind = kind,
+ deleteAfterUse = true
+ )
+ }
+
+ val documentCopy = context.copyUriToTempDocumentFile(uri) ?: return null
+ return PendingAttachment(
+ localPath = documentCopy.localPath,
+ kind = PendingAttachmentKind.DOCUMENT,
+ deleteAfterUse = true
+ )
+}
+
+private fun Intent.isShareIntent(): Boolean {
+ return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
+}
+
+private fun ClipData.collectUriStrings(): List {
+ return buildList {
+ for (index in 0 until itemCount) {
+ getItemAt(index)?.uri?.toString()?.let(::add)
+ }
+ }
+}
+
+@Suppress("DEPRECATION")
+private inline fun Intent.parcelableExtraCompat(name: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableExtra(name, T::class.java)
+ } else {
+ getParcelableExtra(name)
+ }
+}
+
+@Suppress("DEPRECATION")
+private inline fun Intent.parcelableArrayListExtraCompat(name: String): ArrayList? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ getParcelableArrayListExtra(name, T::class.java)
+ } else {
+ getParcelableArrayListExtra(name)
+ }
+}
+
+private fun CopiedUriFile.classifyAsMediaKind(): PendingAttachmentKind? {
+ val mime = mimeType.orEmpty().lowercase()
+ val name = (displayName ?: localPath).lowercase()
+ return when {
+ mime == "image/gif" || name.endsWith(".gif") -> PendingAttachmentKind.GIF
+ mime.startsWith("video/") -> PendingAttachmentKind.VIDEO
+ mime.startsWith("image/") -> PendingAttachmentKind.PHOTO
+ else -> null
+ }
+}
diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt
index 7c82a336..7c596d69 100644
--- a/app/src/main/java/org/monogram/app/MainActivity.kt
+++ b/app/src/main/java/org/monogram/app/MainActivity.kt
@@ -6,6 +6,7 @@ import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
+import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
@@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
import androidx.window.layout.WindowInfoTracker
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.retainedComponent
@@ -35,6 +37,7 @@ class MainActivity : FragmentActivity() {
private lateinit var root: RootComponent
private val appPreferences: AppPreferences by inject()
private val appForegroundTracker: AppForegroundTracker by inject()
+ private lateinit var incomingShareIntentResolver: IncomingShareIntentResolver
@Volatile
private var keepSplashOnScreen: Boolean = true
@@ -64,6 +67,7 @@ class MainActivity : FragmentActivity() {
)
)
}
+ incomingShareIntentResolver = IncomingShareIntentResolver(this)
handleIntent(intent)
@@ -98,10 +102,27 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
+ setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
+ if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
+ lifecycleScope.launchWhenStarted {
+ val request = incomingShareIntentResolver.resolve(intent)
+ if (request != null) {
+ root.handleIncomingShare(request)
+ } else {
+ Toast.makeText(
+ this@MainActivity,
+ "Unable to share this content",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ return
+ }
+
val data = intent.dataString
if (data != null) {
root.handleLink(data)
diff --git a/app/src/main/java/org/monogram/app/components/MobileLayout.kt b/app/src/main/java/org/monogram/app/components/MobileLayout.kt
index 7bbe1439..af939c21 100644
--- a/app/src/main/java/org/monogram/app/components/MobileLayout.kt
+++ b/app/src/main/java/org/monogram/app/components/MobileLayout.kt
@@ -38,6 +38,10 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.ChildStack
import kotlinx.coroutines.launch
+import org.monogram.presentation.core.ui.ScreenSwipeBackAction
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
+import org.monogram.presentation.features.chats.conversation.ChatRenderMode
+import org.monogram.presentation.features.chats.list.ChatListPreviewMode
import org.monogram.presentation.root.RootComponent
import kotlin.math.abs
@@ -46,11 +50,12 @@ import kotlin.math.abs
fun MobileLayout(root: RootComponent) {
val stack by root.childStack.subscribeAsState()
val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState()
+ val showAllChatsFolder by root.appPreferences.showAllChatsFolder.collectAsState()
val coroutineScope = rememberCoroutineScope()
val activeEntry = stack.active
val previousEntry = stack.items.dropLast(1).lastOrNull()
val stackKeysAreUnique = stack.items.map { it.key }.toSet().size == stack.items.size
- val canRenderSwipePreview =
+ val canRenderStackPreview =
stackKeysAreUnique &&
previousEntry != null &&
previousEntry.key != activeEntry.key &&
@@ -58,19 +63,30 @@ fun MobileLayout(root: RootComponent) {
var dragOffsetX by remember { mutableFloatStateOf(0f) }
var isCompletingSwipeBack by remember { mutableStateOf(false) }
var widthPx by remember { mutableFloatStateOf(0f) }
- var isSwipeBackBlocked by remember { mutableStateOf(false) }
var swipeBackActiveKey by remember { mutableStateOf(null) }
var suppressNextPopAnimation by remember { mutableStateOf(false) }
+ var activeScreenSwipeBackState by remember { mutableStateOf(ScreenSwipeBackState()) }
+ val archivePreviewMode = resolveArchivePreviewMode(activeEntry.instance, showAllChatsFolder)
+ val archivePreviewFolderId =
+ (archivePreviewMode as? ChatListPreviewMode.FolderPreview)?.folderId
+ val swipeBackResolution = resolveSwipeBack(
+ child = activeEntry.instance,
+ screenSwipeBackState = activeScreenSwipeBackState,
+ archivePreviewFolderId = archivePreviewFolderId,
+ )
+ val canRenderLocalPreview =
+ swipeBackResolution.preview == SwipeBackPreviewDescriptor.ChatForumList ||
+ swipeBackResolution.preview is SwipeBackPreviewDescriptor.ChatListFolder
val canUseDragToBack =
isDragToBackEnabled &&
- previousEntry != null &&
stackKeysAreUnique &&
- isSwipeBackSupported(activeEntry.instance) &&
- !isSwipeBackBlocked
+ swipeBackResolution.isSupported &&
+ !swipeBackResolution.isBlocked &&
+ (previousEntry != null || canRenderLocalPreview)
val dragProgress = if (widthPx > 0f) (dragOffsetX / widthPx).coerceIn(0f, 1f) else 0f
LaunchedEffect(activeEntry.key, stackKeysAreUnique) {
- isSwipeBackBlocked = false
+ activeScreenSwipeBackState = ScreenSwipeBackState()
swipeBackActiveKey = null
if (!stackKeysAreUnique) {
dragOffsetX = 0f
@@ -89,10 +105,15 @@ fun MobileLayout(root: RootComponent) {
val isSwipeBackActive =
dragOffsetX > 0f &&
- canRenderSwipePreview &&
+ (canRenderStackPreview || canRenderLocalPreview) &&
swipeBackActiveKey == activeEntry.key
val retainedPreviousKey =
- if (stackKeysAreUnique && previousEntry != null && previousEntry.key != activeEntry.key) {
+ if (
+ swipeBackResolution.preview == SwipeBackPreviewDescriptor.PreviousStackEntry &&
+ stackKeysAreUnique &&
+ previousEntry != null &&
+ previousEntry.key != activeEntry.key
+ ) {
previousEntry.key
} else {
null
@@ -106,7 +127,7 @@ fun MobileLayout(root: RootComponent) {
}
.then(
if (canUseDragToBack) {
- Modifier.pointerInput(canUseDragToBack, activeEntry.key) {
+ Modifier.pointerInput(canUseDragToBack, activeEntry.key, swipeBackResolution) {
awaitEachGesture {
if (size.width == 0) return@awaitEachGesture
@@ -152,8 +173,14 @@ fun MobileLayout(root: RootComponent) {
) { value, _ ->
dragOffsetX = value
}
- suppressNextPopAnimation = true
- root.onBack()
+ commitSwipeBack(
+ root = root,
+ activeChild = activeEntry.instance,
+ resolution = swipeBackResolution,
+ onSuppressStackPopAnimation = {
+ suppressNextPopAnimation = true
+ },
+ )
dragOffsetX = 0f
isCompletingSwipeBack = false
swipeBackActiveKey = null
@@ -185,7 +212,7 @@ fun MobileLayout(root: RootComponent) {
continue
}
- if (!canRenderSwipePreview) {
+ if (!canRenderStackPreview && !canRenderLocalPreview) {
dragOffsetX = 0f
break
}
@@ -220,6 +247,33 @@ fun MobileLayout(root: RootComponent) {
}
),
) {
+ if (isSwipeBackActive && swipeBackResolution.preview != SwipeBackPreviewDescriptor.PreviousStackEntry) {
+ Box(
+ modifier = Modifier.mobileStackLayerModifier(
+ role = StackLayerRole.LocalPreview,
+ dragOffsetX = dragOffsetX,
+ dragProgress = dragProgress,
+ transitionProgress = 1f,
+ isPop = false,
+ ),
+ ) {
+ RenderChild(
+ child = activeEntry.instance,
+ chatRenderModeOverride = when (swipeBackResolution.preview) {
+ SwipeBackPreviewDescriptor.ChatForumList -> ChatRenderMode.ForumTopicSwipePreview
+ else -> null
+ },
+ chatListPreviewMode = when (val preview = swipeBackResolution.preview) {
+ is SwipeBackPreviewDescriptor.ChatListFolder -> ChatListPreviewMode.FolderPreview(
+ preview.folderId
+ )
+
+ else -> ChatListPreviewMode.Active
+ },
+ )
+ }
+ }
+
Children(
stack = stack,
animation = mobileStackAnimation(
@@ -227,18 +281,31 @@ fun MobileLayout(root: RootComponent) {
dragProgress = dragProgress,
isSwipeBackActive = isSwipeBackActive,
stackKeysAreUnique = stackKeysAreUnique,
+ showRetainedPreviousChild = swipeBackResolution.preview == SwipeBackPreviewDescriptor.PreviousStackEntry,
+ showLocalPreviewBackdrop = swipeBackResolution.preview != SwipeBackPreviewDescriptor.PreviousStackEntry,
suppressNextPopAnimation = suppressNextPopAnimation,
onSuppressNextPopAnimationConsumed = {
suppressNextPopAnimation = false
},
),
) { child ->
+ val layerRole = resolveSwipeLayerRole(
+ child = child,
+ activeKey = activeEntry.key,
+ retainedPreviousKey = retainedPreviousKey,
+ )
+
RenderChild(
child = child.instance,
- isOverlay = child.key == retainedPreviousKey,
- onSwipeBackBlockedChanged = { blocked ->
+ isOverlay = layerRole == StackLayerRole.RetainedPrevious,
+ chatRenderModeOverride = when (layerRole) {
+ StackLayerRole.RetainedPrevious -> ChatRenderMode.SwipePreview
+ else -> null
+ },
+ chatListPreviewMode = ChatListPreviewMode.Active,
+ onScreenSwipeBackStateChanged = { swipeState ->
if (stack.active.instance === child.instance) {
- isSwipeBackBlocked = blocked
+ activeScreenSwipeBackState = swipeState
}
},
)
@@ -246,36 +313,48 @@ fun MobileLayout(root: RootComponent) {
}
}
-private fun isSwipeBackSupported(child: RootComponent.Child): Boolean =
- when (child) {
- is RootComponent.Child.ChatDetailChild,
- is RootComponent.Child.ProfileChild,
- is RootComponent.Child.SettingsChild,
- is RootComponent.Child.EditProfileChild,
- is RootComponent.Child.SessionsChild,
- is RootComponent.Child.FoldersChild,
- is RootComponent.Child.ChatSettingsChild,
- is RootComponent.Child.DataStorageChild,
- is RootComponent.Child.StorageUsageChild,
- is RootComponent.Child.NetworkUsageChild,
- is RootComponent.Child.PremiumChild,
- is RootComponent.Child.PrivacyChild,
- is RootComponent.Child.AdBlockChild,
- is RootComponent.Child.PowerSavingChild,
- is RootComponent.Child.NotificationsChild,
- is RootComponent.Child.ProxyChild,
- is RootComponent.Child.ProfileLogsChild,
- is RootComponent.Child.AdminManageChild,
- is RootComponent.Child.ChatEditChild,
- is RootComponent.Child.MemberListChild,
- is RootComponent.Child.ChatPermissionsChild,
- is RootComponent.Child.StickersChild,
- is RootComponent.Child.AboutChild,
- is RootComponent.Child.NewChatChild -> true
- is RootComponent.Child.DebugChild -> true
-
- else -> false
+private fun resolveSwipeLayerRole(
+ child: Child.Created<*, RootComponent.Child>,
+ activeKey: Any,
+ retainedPreviousKey: Any?,
+): StackLayerRole? {
+ return when {
+ child.key == retainedPreviousKey -> StackLayerRole.RetainedPrevious
+ child.key == activeKey -> StackLayerRole.Active
+ else -> null
+ }
+}
+
+private fun commitSwipeBack(
+ root: RootComponent,
+ activeChild: RootComponent.Child,
+ resolution: SwipeBackResolution,
+ onSuppressStackPopAnimation: () -> Unit,
+) {
+ when (resolution.action) {
+ ScreenSwipeBackAction.StackPop -> {
+ onSuppressStackPopAnimation()
+ when (activeChild) {
+ is RootComponent.Child.ChatDetailChild -> activeChild.component.onBackClicked()
+ else -> root.onBack()
+ }
+ }
+
+ ScreenSwipeBackAction.LocalChatTopicClose -> {
+ when (activeChild) {
+ is RootComponent.Child.ChatDetailChild -> activeChild.component.onTopicClick(0)
+ else -> root.onBack()
+ }
+ }
+
+ ScreenSwipeBackAction.LocalChatListArchiveReturn -> {
+ when (activeChild) {
+ is RootComponent.Child.ChatsChild -> activeChild.component.handleBack()
+ else -> root.onBack()
+ }
+ }
}
+}
@Composable
private fun mobileStackAnimation(
@@ -283,6 +362,8 @@ private fun mobileStackAnimation(
dragProgress: Float,
isSwipeBackActive: Boolean,
stackKeysAreUnique: Boolean,
+ showRetainedPreviousChild: Boolean,
+ showLocalPreviewBackdrop: Boolean,
suppressNextPopAnimation: Boolean,
onSuppressNextPopAnimationConsumed: () -> Unit,
): StackAnimation {
@@ -328,7 +409,7 @@ private fun mobileStackAnimation(
transition = null
},
)
- val retainedPreviousChild = if (stackKeysAreUnique) {
+ val retainedPreviousChild = if (stackKeysAreUnique && showRetainedPreviousChild) {
stack.items.dropLast(1).lastOrNull()
} else {
null
@@ -372,7 +453,7 @@ private fun mobileStackAnimation(
Box(modifier = modifier) {
layers.forEach { layer ->
- key(layer.child.key) {
+ key("${layer.child.key}:${layer.role}") {
StackLayerChild(
layer = layer,
dragOffsetX = if (isSwipeBackActive) dragOffsetX else 0f,
@@ -383,7 +464,9 @@ private fun mobileStackAnimation(
}
}
- if (isSwipeBackActive && retainedPreviousChild != null) {
+ if (isSwipeBackActive &&
+ (retainedPreviousChild != null || showLocalPreviewBackdrop)
+ ) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -437,7 +520,7 @@ private fun Modifier.mobileStackLayerModifier(
): Modifier =
fillMaxSize()
.then(
- if (role == StackLayerRole.RetainedPrevious) {
+ if (role == StackLayerRole.RetainedPrevious || role == StackLayerRole.LocalPreview) {
Modifier.clearAndSetSemantics {}
} else {
Modifier
@@ -448,6 +531,7 @@ private fun Modifier.mobileStackLayerModifier(
StackLayerRole.Active,
StackLayerRole.TransitionIncoming -> 1f
+ StackLayerRole.LocalPreview,
StackLayerRole.RetainedPrevious,
StackLayerRole.TransitionOutgoing -> 0f
},
@@ -459,7 +543,8 @@ private fun Modifier.mobileStackLayerModifier(
shadowElevation = if (dragOffsetX > 0f) 12f else 0f
}
- StackLayerRole.RetainedPrevious -> {
+ StackLayerRole.RetainedPrevious,
+ StackLayerRole.LocalPreview -> {
translationX =
if (dragOffsetX > 0f) {
(dragProgress - 1f) * size.width * 0.08f
@@ -504,6 +589,7 @@ private data class StackLayer(
private enum class StackLayerRole {
Active,
+ LocalPreview,
RetainedPrevious,
TransitionOutgoing,
TransitionIncoming,
diff --git a/app/src/main/java/org/monogram/app/components/RenderChild.kt b/app/src/main/java/org/monogram/app/components/RenderChild.kt
index 68c75382..8e17a79a 100644
--- a/app/src/main/java/org/monogram/app/components/RenderChild.kt
+++ b/app/src/main/java/org/monogram/app/components/RenderChild.kt
@@ -1,16 +1,19 @@
package org.monogram.app.components
import androidx.compose.runtime.Composable
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
import org.monogram.presentation.features.auth.AuthContent
import org.monogram.presentation.features.chats.conversation.ChatContent
import org.monogram.presentation.features.chats.conversation.ChatRenderMode
import org.monogram.presentation.features.chats.creation.NewChatContent
import org.monogram.presentation.features.chats.list.ChatListContent
+import org.monogram.presentation.features.chats.list.ChatListPreviewMode
import org.monogram.presentation.features.profile.ProfileContent
import org.monogram.presentation.features.profile.admin.AdminManageContent
import org.monogram.presentation.features.profile.admin.ChatEditContent
import org.monogram.presentation.features.profile.admin.ChatPermissionsContent
import org.monogram.presentation.features.profile.admin.MemberListContent
+import org.monogram.presentation.features.profile.contact.ContactEditContent
import org.monogram.presentation.features.profile.logs.ProfileLogsContent
import org.monogram.presentation.features.webview.InternalWebView
import org.monogram.presentation.root.RootComponent
@@ -38,17 +41,24 @@ import org.monogram.presentation.settings.storage.StorageUsageContent
fun RenderChild(
child: RootComponent.Child,
isOverlay: Boolean = false,
- onSwipeBackBlockedChanged: (Boolean) -> Unit = {},
+ chatRenderModeOverride: ChatRenderMode? = null,
+ chatListPreviewMode: ChatListPreviewMode = ChatListPreviewMode.Active,
+ onScreenSwipeBackStateChanged: (ScreenSwipeBackState) -> Unit = {},
) {
when (child) {
is RootComponent.Child.StartupChild -> StartupContent(child.component)
is RootComponent.Child.AuthChild -> AuthContent(child.component)
- is RootComponent.Child.ChatsChild -> ChatListContent(child.component)
+ is RootComponent.Child.ChatsChild -> ChatListContent(
+ component = child.component,
+ previewMode = chatListPreviewMode,
+ onSwipeBackStateChanged = onScreenSwipeBackStateChanged,
+ )
is RootComponent.Child.NewChatChild -> NewChatContent(child.component)
is RootComponent.Child.ChatDetailChild -> ChatContent(
component = child.component,
- renderMode = if (isOverlay) ChatRenderMode.SwipePreview else ChatRenderMode.Active,
- onSwipeBackBlockedChanged = onSwipeBackBlockedChanged,
+ renderMode = chatRenderModeOverride
+ ?: if (isOverlay) ChatRenderMode.SwipePreview else ChatRenderMode.Active,
+ onSwipeBackStateChanged = onScreenSwipeBackStateChanged,
)
is RootComponent.Child.SettingsChild -> SettingsContent(child.component)
@@ -69,6 +79,7 @@ fun RenderChild(
is RootComponent.Child.ProfileLogsChild -> ProfileLogsContent(child.component)
is RootComponent.Child.AdminManageChild -> AdminManageContent(child.component)
is RootComponent.Child.ChatEditChild -> ChatEditContent(child.component)
+ is RootComponent.Child.ContactEditChild -> ContactEditContent(child.component)
is RootComponent.Child.MemberListChild -> MemberListContent(child.component)
is RootComponent.Child.ChatPermissionsChild -> ChatPermissionsContent(child.component)
is RootComponent.Child.PasscodeChild -> PasscodeContent(child.component)
diff --git a/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt b/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt
new file mode 100644
index 00000000..2073a91a
--- /dev/null
+++ b/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt
@@ -0,0 +1,120 @@
+package org.monogram.app.components
+
+import androidx.compose.runtime.Immutable
+import org.monogram.presentation.core.ui.ScreenSwipeBackAction
+import org.monogram.presentation.core.ui.ScreenSwipeBackPreview
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
+import org.monogram.presentation.features.chats.list.ChatListPreviewMode
+import org.monogram.presentation.features.chats.list.resolveArchiveReturnFolderId
+import org.monogram.presentation.root.RootComponent
+
+@Immutable
+internal data class SwipeBackResolution(
+ val isSupported: Boolean,
+ val isBlocked: Boolean,
+ val action: ScreenSwipeBackAction = ScreenSwipeBackAction.StackPop,
+ val preview: SwipeBackPreviewDescriptor = SwipeBackPreviewDescriptor.PreviousStackEntry,
+)
+
+internal sealed interface SwipeBackPreviewDescriptor {
+ data object PreviousStackEntry : SwipeBackPreviewDescriptor
+ data object ChatForumList : SwipeBackPreviewDescriptor
+ data class ChatListFolder(val folderId: Int) : SwipeBackPreviewDescriptor
+}
+
+internal fun resolveSwipeBack(
+ child: RootComponent.Child,
+ screenSwipeBackState: ScreenSwipeBackState = ScreenSwipeBackState(),
+ archivePreviewFolderId: Int? = null,
+): SwipeBackResolution {
+ return when (child) {
+ is RootComponent.Child.ChatDetailChild -> {
+ SwipeBackResolution(
+ isSupported = screenSwipeBackState.isSupported,
+ isBlocked = screenSwipeBackState.isBlocked,
+ action = screenSwipeBackState.action,
+ preview = when (screenSwipeBackState.preview) {
+ ScreenSwipeBackPreview.ChatForumList -> SwipeBackPreviewDescriptor.ChatForumList
+ ScreenSwipeBackPreview.ChatListFolder -> {
+ archivePreviewFolderId?.let(SwipeBackPreviewDescriptor::ChatListFolder)
+ ?: SwipeBackPreviewDescriptor.PreviousStackEntry
+ }
+
+ ScreenSwipeBackPreview.PreviousStackEntry -> SwipeBackPreviewDescriptor.PreviousStackEntry
+ },
+ )
+ }
+
+ is RootComponent.Child.ChatsChild -> {
+ SwipeBackResolution(
+ isSupported = screenSwipeBackState.isSupported,
+ isBlocked = screenSwipeBackState.isBlocked,
+ action = screenSwipeBackState.action,
+ preview = when (screenSwipeBackState.preview) {
+ ScreenSwipeBackPreview.ChatListFolder -> {
+ archivePreviewFolderId?.let(SwipeBackPreviewDescriptor::ChatListFolder)
+ ?: SwipeBackPreviewDescriptor.PreviousStackEntry
+ }
+
+ ScreenSwipeBackPreview.ChatForumList -> SwipeBackPreviewDescriptor.ChatForumList
+ ScreenSwipeBackPreview.PreviousStackEntry -> SwipeBackPreviewDescriptor.PreviousStackEntry
+ },
+ )
+ }
+
+ else -> SwipeBackResolution(
+ isSupported = isGenericSwipeBackSupported(child),
+ isBlocked = false,
+ )
+ }
+}
+
+internal fun resolveArchivePreviewMode(
+ child: RootComponent.Child,
+ showAllChatsFolder: Boolean,
+): ChatListPreviewMode {
+ val chatsChild = child as? RootComponent.Child.ChatsChild ?: return ChatListPreviewMode.Active
+ val state = chatsChild.component.state.value
+ val targetFolderId = resolveArchiveReturnFolderId(
+ folders = state.folders,
+ showAllChatsFolder = showAllChatsFolder,
+ lastNonArchiveFolderId = state.lastNonArchiveFolderId,
+ )
+ return if (state.selectedFolderId == -2 && targetFolderId != -2) {
+ ChatListPreviewMode.FolderPreview(targetFolderId)
+ } else {
+ ChatListPreviewMode.Active
+ }
+}
+
+private fun isGenericSwipeBackSupported(child: RootComponent.Child): Boolean =
+ when (child) {
+ is RootComponent.Child.ChatDetailChild,
+ is RootComponent.Child.ProfileChild,
+ is RootComponent.Child.SettingsChild,
+ is RootComponent.Child.EditProfileChild,
+ is RootComponent.Child.SessionsChild,
+ is RootComponent.Child.FoldersChild,
+ is RootComponent.Child.ChatSettingsChild,
+ is RootComponent.Child.DataStorageChild,
+ is RootComponent.Child.StorageUsageChild,
+ is RootComponent.Child.NetworkUsageChild,
+ is RootComponent.Child.PremiumChild,
+ is RootComponent.Child.PrivacyChild,
+ is RootComponent.Child.AdBlockChild,
+ is RootComponent.Child.PowerSavingChild,
+ is RootComponent.Child.NotificationsChild,
+ is RootComponent.Child.ProxyChild,
+ is RootComponent.Child.ProfileLogsChild,
+ is RootComponent.Child.AdminManageChild,
+ is RootComponent.Child.ChatEditChild,
+ is RootComponent.Child.ContactEditChild,
+ is RootComponent.Child.MemberListChild,
+ is RootComponent.Child.ChatPermissionsChild,
+ is RootComponent.Child.StickersChild,
+ is RootComponent.Child.AboutChild,
+ is RootComponent.Child.NewChatChild,
+ is RootComponent.Child.DebugChild -> true
+
+ else -> false
+ }
diff --git a/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt b/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt
new file mode 100644
index 00000000..cd2ce500
--- /dev/null
+++ b/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt
@@ -0,0 +1,59 @@
+package org.monogram.app
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class IncomingShareIntentResolverTest {
+ @Test
+ fun `normalizeIncomingSharePayload returns text only share`() {
+ val payload = normalizeIncomingSharePayload(
+ IncomingSharePayloadInput(
+ action = "android.intent.action.SEND",
+ text = " hello "
+ )
+ )
+
+ assertEquals("hello", payload?.text)
+ assertEquals(emptyList(), payload?.uriStrings)
+ }
+
+ @Test
+ fun `normalizeIncomingSharePayload merges clipData and extra stream without duplicates`() {
+ val payload = normalizeIncomingSharePayload(
+ IncomingSharePayloadInput(
+ action = "android.intent.action.SEND_MULTIPLE",
+ text = "payload",
+ clipDataUris = listOf("content://share/1", "content://share/2"),
+ extraStreamUris = listOf("content://share/1", "content://share/2")
+ )
+ )
+
+ assertEquals("payload", payload?.text)
+ assertEquals(listOf("content://share/1", "content://share/2"), payload?.uriStrings)
+ }
+
+ @Test
+ fun `normalizeIncomingSharePayload returns null for unsupported intent`() {
+ val payload = normalizeIncomingSharePayload(
+ IncomingSharePayloadInput(
+ action = "android.intent.action.VIEW",
+ text = "ignored"
+ )
+ )
+
+ assertNull(payload)
+ }
+
+ @Test
+ fun `normalizeIncomingSharePayload returns null for empty share`() {
+ val payload = normalizeIncomingSharePayload(
+ IncomingSharePayloadInput(
+ action = "android.intent.action.SEND",
+ text = null
+ )
+ )
+
+ assertNull(payload)
+ }
+}
diff --git a/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt
new file mode 100644
index 00000000..a94555fe
--- /dev/null
+++ b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt
@@ -0,0 +1,231 @@
+package org.monogram.app.components
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.monogram.domain.models.FolderModel
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.repository.ForwardTarget
+import org.monogram.presentation.core.ui.ScreenSwipeBackAction
+import org.monogram.presentation.core.ui.ScreenSwipeBackPreview
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
+import org.monogram.presentation.core.util.AppPreferences
+import org.monogram.presentation.features.chats.conversation.ChatComponent
+import org.monogram.presentation.features.chats.list.ChatListComponent
+import org.monogram.presentation.features.chats.list.ChatListPreviewMode
+import org.monogram.presentation.root.RootComponent
+import java.lang.reflect.Proxy
+
+class SwipeBackResolverTest {
+ @Test
+ fun `normal chat resolves to stack pop`() {
+ val resolution = resolveSwipeBack(
+ child = RootComponent.Child.ChatDetailChild(fakeChatComponent()),
+ screenSwipeBackState = ScreenSwipeBackState(
+ isSupported = true,
+ isBlocked = false,
+ action = ScreenSwipeBackAction.StackPop,
+ preview = ScreenSwipeBackPreview.PreviousStackEntry,
+ ),
+ )
+
+ assertTrue(resolution.isSupported)
+ assertFalse(resolution.isBlocked)
+ assertEquals(ScreenSwipeBackAction.StackPop, resolution.action)
+ assertEquals(SwipeBackPreviewDescriptor.PreviousStackEntry, resolution.preview)
+ }
+
+ @Test
+ fun `topic chat resolves to local forum preview`() {
+ val resolution = resolveSwipeBack(
+ child = RootComponent.Child.ChatDetailChild(fakeChatComponent()),
+ screenSwipeBackState = ScreenSwipeBackState(
+ isSupported = true,
+ isBlocked = false,
+ action = ScreenSwipeBackAction.LocalChatTopicClose,
+ preview = ScreenSwipeBackPreview.ChatForumList,
+ ),
+ )
+
+ assertTrue(resolution.isSupported)
+ assertEquals(ScreenSwipeBackAction.LocalChatTopicClose, resolution.action)
+ assertEquals(SwipeBackPreviewDescriptor.ChatForumList, resolution.preview)
+ }
+
+ @Test
+ fun `archive chat list resolves to local folder preview`() {
+ val resolution = resolveSwipeBack(
+ child = RootComponent.Child.ChatsChild(fakeChatListComponent()),
+ screenSwipeBackState = ScreenSwipeBackState(
+ isSupported = true,
+ isBlocked = false,
+ action = ScreenSwipeBackAction.LocalChatListArchiveReturn,
+ preview = ScreenSwipeBackPreview.ChatListFolder,
+ ),
+ archivePreviewFolderId = 7,
+ )
+
+ assertTrue(resolution.isSupported)
+ assertEquals(ScreenSwipeBackAction.LocalChatListArchiveReturn, resolution.action)
+ assertEquals(SwipeBackPreviewDescriptor.ChatListFolder(7), resolution.preview)
+ }
+
+ @Test
+ fun `blocked state disables swipe`() {
+ val resolution = resolveSwipeBack(
+ child = RootComponent.Child.ChatDetailChild(fakeChatComponent()),
+ screenSwipeBackState = ScreenSwipeBackState(
+ isSupported = true,
+ isBlocked = true,
+ action = ScreenSwipeBackAction.StackPop,
+ preview = ScreenSwipeBackPreview.PreviousStackEntry,
+ ),
+ )
+
+ assertTrue(resolution.isSupported)
+ assertTrue(resolution.isBlocked)
+ }
+
+ @Test
+ fun `archive preview mode uses remembered folder`() {
+ val previewMode = resolveArchivePreviewMode(
+ child = RootComponent.Child.ChatsChild(
+ fakeChatListComponent(
+ ChatListComponent.State(
+ folders = listOf(
+ FolderModel(id = -1, title = "All chats"),
+ FolderModel(id = 3, title = "Work"),
+ FolderModel(id = 7, title = "Family"),
+ ),
+ selectedFolderId = -2,
+ lastNonArchiveFolderId = 7,
+ )
+ )
+ ),
+ showAllChatsFolder = true,
+ )
+
+ assertEquals(ChatListPreviewMode.FolderPreview(7), previewMode)
+ }
+
+ @Test
+ fun `blocked archive state disables swipe`() {
+ val resolution = resolveSwipeBack(
+ child = RootComponent.Child.ChatsChild(
+ fakeChatListComponent(
+ ChatListComponent.State(
+ folders = listOf(
+ FolderModel(id = -1, title = "All chats"),
+ FolderModel(id = 3, title = "Work"),
+ ),
+ selectedFolderId = -2,
+ lastNonArchiveFolderId = 3,
+ isSearchActive = true,
+ )
+ )
+ ),
+ screenSwipeBackState = ScreenSwipeBackState(
+ isSupported = true,
+ isBlocked = true,
+ action = ScreenSwipeBackAction.LocalChatListArchiveReturn,
+ preview = ScreenSwipeBackPreview.ChatListFolder,
+ ),
+ archivePreviewFolderId = 3,
+ )
+
+ assertTrue(resolution.isSupported)
+ assertTrue(resolution.isBlocked)
+ assertEquals(SwipeBackPreviewDescriptor.ChatListFolder(3), resolution.preview)
+ }
+
+ private fun fakeChatComponent(): ChatComponent {
+ return Proxy.newProxyInstance(
+ ChatComponent::class.java.classLoader,
+ arrayOf(ChatComponent::class.java),
+ ) { _, _, _ ->
+ throw UnsupportedOperationException("Not used by resolver tests")
+ } as ChatComponent
+ }
+
+ private fun fakeChatListComponent(
+ state: ChatListComponent.State = ChatListComponent.State()
+ ): ChatListComponent {
+ return object : ChatListComponent {
+ override val state: StateFlow = MutableStateFlow(state)
+ override val uiState: StateFlow =
+ MutableStateFlow(ChatListComponent.UiState())
+ override val foldersState: StateFlow =
+ MutableStateFlow(ChatListComponent.FoldersState())
+ override val chatsState: StateFlow =
+ MutableStateFlow(ChatListComponent.ChatsState())
+ override val selectionState: StateFlow =
+ MutableStateFlow(ChatListComponent.SelectionState())
+ override val searchState: StateFlow =
+ MutableStateFlow(ChatListComponent.SearchState())
+ override val appPreferences: AppPreferences
+ get() = throw UnsupportedOperationException("Not used by resolver tests")
+
+ override fun onChatClicked(id: Long) = unsupported()
+ override fun onProfileClicked(id: Long) = unsupported()
+ override fun onMessageClicked(chatId: Long, messageId: Long) = unsupported()
+ override fun onSettingsClicked() = unsupported()
+ override fun onFolderClicked(id: Int) = unsupported()
+ override fun loadMore(folderId: Int?) = unsupported()
+ override fun loadMoreMessages() = unsupported()
+ override fun onChatLongClicked(id: Long) = unsupported()
+ override fun clearSelection() = unsupported()
+ override fun retryConnection() = unsupported()
+ override fun onSearchToggle() = unsupported()
+ override fun onSearchQueryChange(query: String) = unsupported()
+ override fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) = unsupported()
+ override fun onClearSearchHistory() = unsupported()
+ override fun onRemoveSearchHistoryItem(chatId: Long) = unsupported()
+ override fun onMuteSelected(mute: Boolean) = unsupported()
+ override fun onArchiveSelected(archive: Boolean) = unsupported()
+ override fun onPinSelected() = unsupported()
+ override fun onToggleReadSelected() = unsupported()
+ override fun onDeleteSelected() = unsupported()
+ override fun onMarkCurrentFolderRead() = unsupported()
+ override fun onLeaveSelected() = unsupported()
+ override fun onClearHistorySelected(revoke: Boolean) = unsupported()
+ override fun onReportSelected(reason: String) = unsupported()
+ override fun onArchivePinToggle() = unsupported()
+ override fun onConfirmForwarding(
+ sendCopy: Boolean,
+ removeCaption: Boolean,
+ commentText: String,
+ commentEntities: List
+ ) = unsupported()
+
+ override fun onShareTopicSelected(chatId: Long, topicId: Int?) = unsupported()
+ override fun onDismissShareTopicPicker() = unsupported()
+ override fun onForwardTopicSelected(chatId: Long, topicId: Int?) = unsupported()
+ override fun onDismissForwardTopicPicker() = unsupported()
+ override fun onRemoveForwardTarget(target: ForwardTarget) = unsupported()
+ override fun onNewChatClicked() = unsupported()
+ override fun onProxySettingsClicked() = unsupported()
+ override fun onEditFoldersClicked() = unsupported()
+ override fun onDeleteFolder(folderId: Int) = unsupported()
+ override fun onEditFolder(folderId: Int) = unsupported()
+ override fun onOpenInstantView(url: String) = unsupported()
+ override fun onDismissInstantView() = unsupported()
+ override fun onOpenWebApp(url: String, botUserId: Long, botName: String) = unsupported()
+ override fun onDismissWebApp() = unsupported()
+ override fun onOpenWebView(url: String) = unsupported()
+ override fun onDismissWebView() = unsupported()
+ override fun onUpdateClicked() = unsupported()
+ override fun onProjectChannelSubscribe() = unsupported()
+ override fun onProjectChannelLater() = unsupported()
+ override fun handleBack(): Boolean = unsupported()
+ override fun onVisibleChatIdsChanged(ids: List) = unsupported()
+ override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) =
+ unsupported()
+
+ private fun unsupported(): Nothing =
+ throw UnsupportedOperationException("Not used by resolver tests")
+ }
+ }
+}
diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts
index f65cb4d4..912cf4d3 100644
--- a/baselineprofile/build.gradle.kts
+++ b/baselineprofile/build.gradle.kts
@@ -10,6 +10,8 @@ android {
defaultConfig {
minSdk = 25
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ missingDimensionStrategy("tdlib", "official")
+ missingDimensionStrategy("runtime", "libre")
}
compileOptions {
diff --git a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt
new file mode 100644
index 00000000..72204996
--- /dev/null
+++ b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt
@@ -0,0 +1,118 @@
+package org.monogram.core.perf
+
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+data class ChatOpenPerfSessionSnapshot(
+ val sessionId: String,
+ val source: String,
+ val target: String,
+ val requestCount: Int,
+ val replyFetchCount: Int,
+ val persistCount: Int,
+ val persistSkippedCount: Int
+)
+
+object ChatOpenPerfBridge {
+ private data class SessionRecord(
+ val sessionId: String,
+ var source: String,
+ var target: String,
+ var requestCount: Int = 0,
+ var replyFetchCount: Int = 0,
+ var persistCount: Int = 0,
+ var persistSkippedCount: Int = 0
+ )
+
+ private val sessions = ConcurrentHashMap()
+
+ fun startSession(
+ chatId: Long,
+ threadId: Long?,
+ source: String,
+ target: String
+ ): ChatOpenPerfSessionSnapshot {
+ val record = SessionRecord(
+ sessionId = UUID.randomUUID().toString().take(8),
+ source = source,
+ target = target
+ )
+ sessions[key(chatId, threadId)] = record
+ return record.snapshot()
+ }
+
+ fun updateTarget(chatId: Long, threadId: Long?, target: String): ChatOpenPerfSessionSnapshot? {
+ val record = findRecord(chatId, threadId) ?: return null
+ synchronized(record) {
+ record.target = target
+ return record.snapshot()
+ }
+ }
+
+ fun currentSession(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? {
+ val record = findRecord(chatId, threadId) ?: return null
+ synchronized(record) {
+ return record.snapshot()
+ }
+ }
+
+ fun recordHistoryRequest(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? {
+ val record = findRecord(chatId, threadId) ?: return null
+ synchronized(record) {
+ record.requestCount += 1
+ return record.snapshot()
+ }
+ }
+
+ fun recordReplyFetch(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? {
+ val record = findRecord(chatId, threadId) ?: return null
+ synchronized(record) {
+ record.requestCount += 1
+ record.replyFetchCount += 1
+ return record.snapshot()
+ }
+ }
+
+ fun recordPersist(
+ chatId: Long,
+ threadId: Long?,
+ persistedCount: Int,
+ skippedCount: Int
+ ): ChatOpenPerfSessionSnapshot? {
+ val record = findRecord(chatId, threadId) ?: return null
+ synchronized(record) {
+ record.persistCount += persistedCount
+ record.persistSkippedCount += skippedCount
+ return record.snapshot()
+ }
+ }
+
+ fun clearSession(chatId: Long, threadId: Long?, sessionId: String? = null) {
+ val key = key(chatId, threadId)
+ val record = sessions[key] ?: return
+ if (sessionId == null || record.sessionId == sessionId) {
+ sessions.remove(key, record)
+ }
+ }
+
+ private fun findRecord(chatId: Long, threadId: Long?): SessionRecord? {
+ sessions[key(chatId, threadId)]?.let { return it }
+ return sessions.entries.firstOrNull { entry ->
+ entry.key.startsWith("$chatId:")
+ }?.value
+ }
+
+ private fun key(chatId: Long, threadId: Long?): String = "$chatId:${threadId ?: 0L}"
+
+ private fun SessionRecord.snapshot(): ChatOpenPerfSessionSnapshot {
+ return ChatOpenPerfSessionSnapshot(
+ sessionId = sessionId,
+ source = source,
+ target = target,
+ requestCount = requestCount,
+ replyFetchCount = replyFetchCount,
+ persistCount = persistCount,
+ persistSkippedCount = persistSkippedCount
+ )
+ }
+}
diff --git a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt
new file mode 100644
index 00000000..5eb733e5
--- /dev/null
+++ b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt
@@ -0,0 +1,49 @@
+package org.monogram.core.perf
+
+object ChatOpenPerfDebug {
+ const val TAG = "ChatConversation"
+ const val STREAM_PERF = "perf"
+
+ fun buildLogMessage(
+ chatId: Long,
+ threadId: Long?,
+ event: String,
+ componentInstanceId: String? = null,
+ uiInstanceId: String? = null,
+ source: String? = null,
+ target: String? = null,
+ anchorId: Long? = null,
+ messagesBefore: Int? = null,
+ messagesAfter: Int? = null,
+ unreadCount: Int? = null,
+ requestCount: Int? = null,
+ replyFetchCount: Int? = null,
+ persistCount: Int? = null,
+ persistSkippedCount: Int? = null,
+ durationMs: Long? = null
+ ): String {
+ val session = ChatOpenPerfBridge.currentSession(chatId, threadId)
+ return buildString {
+ append("stream=").append(STREAM_PERF)
+ append(" event=").append(event)
+ append(" component=").append(componentInstanceId ?: "none")
+ append(" ui=").append(uiInstanceId ?: "none")
+ append(" chatId=").append(chatId)
+ append(" threadId=").append(threadId ?: 0L)
+ append(" sessionId=").append(session?.sessionId ?: "none")
+ append(" source=").append(source ?: session?.source ?: "none")
+ append(" target=").append(target ?: session?.target ?: "none")
+ append(" messagesBefore=").append(messagesBefore ?: 0)
+ append(" messagesAfter=").append(messagesAfter ?: 0)
+ append(" anchorId=").append(anchorId ?: 0L)
+ append(" unreadCount=").append(unreadCount ?: 0)
+ append(" requestCount=").append(requestCount ?: session?.requestCount ?: 0)
+ append(" replyFetchCount=").append(replyFetchCount ?: session?.replyFetchCount ?: 0)
+ append(" persistCount=").append(persistCount ?: session?.persistCount ?: 0)
+ append(" persistSkippedCount=").append(
+ persistSkippedCount ?: session?.persistSkippedCount ?: 0
+ )
+ append(" durationMs=").append(durationMs ?: 0L)
+ }
+ }
+}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 901d08a5..97e07ab4 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -18,7 +18,7 @@ android {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
- val localProperties: Properties by rootProject.extra
+ val localProperties = rootProject.extra["localProperties"] as Properties
val apiId = localProperties.getProperty("API_ID", "0")
val apiHash = localProperties.getProperty("API_HASH", "")
diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
index 267deb36..1114f074 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
@@ -79,6 +79,8 @@ class ChatModelFactory(
var inviteLink: String? = null
var hasAutomaticTranslation = false
var personalAvatarPath: String? = null
+ var signMessages = false
+ var joinToSendMessages = false
val isArchived = chat.positions.any { it.list is TdApi.ChatListArchive }
@@ -133,6 +135,8 @@ class ChatModelFactory(
username = it.usernames?.activeUsernames?.firstOrNull()
usernames = it.usernames?.toDomain()
hasAutomaticTranslation = it.hasAutomaticTranslation
+ signMessages = it.signMessages
+ joinToSendMessages = it.joinToSendMessages
} ?: lazyLoad(cache.pendingSupergroups, type.supergroupId) {
if (type.supergroupId == 0L) return@lazyLoad
val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId))
@@ -344,7 +348,9 @@ class ChatModelFactory(
description = description,
inviteLink = inviteLink,
hasAutomaticTranslation = hasAutomaticTranslation,
- personalAvatarPath = personalAvatarPath
+ personalAvatarPath = personalAvatarPath,
+ signMessages = signMessages,
+ joinToSendMessages = joinToSendMessages
)
}
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt
index a5d9c6a5..e18b237b 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt
@@ -18,13 +18,36 @@ interface ChatLocalDataSource {
suspend fun clearAll()
fun getMessagesForChat(chatId: Long): Flow>
- suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List
- suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List
- suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int): List
- suspend fun getLatestMessages(chatId: Long, limit: Int): List
+ suspend fun getMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): List
+
+ suspend fun getMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): List
+
+ suspend fun getMessagesAround(
+ chatId: Long,
+ messageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): List
+
+ suspend fun getLatestMessages(
+ chatId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): List
suspend fun getMessagesByIds(chatId: Long, messageIds: List): List
suspend fun insertMessage(message: MessageEntity)
suspend fun insertMessages(messages: List)
+ suspend fun replaceMessage(message: MessageEntity)
suspend fun replaceMessageId(chatId: Long, oldMessageId: Long, message: MessageEntity)
suspend fun markAsRead(chatId: Long, upToMessageId: Long)
suspend fun updateMessageContent(
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt
index f9af7931..fa9ad8c5 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt
@@ -74,15 +74,25 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
messages.getOrPut(chatId) { MutableStateFlow(emptyMap()) }
.map { it.values.sortedByDescending { msg -> msg.date } }
- override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List {
- val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
+ override suspend fun getMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ): List {
+ val chatMessages = scopedMessages(chatId, threadId)
return chatMessages.filter { it.id < fromMessageId }
.sortedByDescending { it.date }
.take(limit)
}
- override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List {
- val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
+ override suspend fun getMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ): List {
+ val chatMessages = scopedMessages(chatId, threadId)
return chatMessages.filter { it.id > fromMessageId }
.sortedBy { it.date }
.take(limit)
@@ -91,9 +101,10 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
override suspend fun getMessagesAround(
chatId: Long,
messageId: Long,
- limit: Int
+ limit: Int,
+ threadId: Long?
): List {
- val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
+ val chatMessages = scopedMessages(chatId, threadId)
val olderLimit = (limit + 1) / 2
val newerLimit = limit - olderLimit
val olderAndTarget = chatMessages.filter { it.id <= messageId }
@@ -106,8 +117,12 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
.sortedByDescending { it.date }
}
- override suspend fun getLatestMessages(chatId: Long, limit: Int): List {
- val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
+ override suspend fun getLatestMessages(
+ chatId: Long,
+ limit: Int,
+ threadId: Long?
+ ): List {
+ val chatMessages = scopedMessages(chatId, threadId)
return chatMessages.sortedByDescending { it.date }.take(limit)
}
@@ -128,6 +143,10 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
messages.forEach { insertMessage(it) }
}
+ override suspend fun replaceMessage(message: MessageEntity) {
+ insertMessage(message)
+ }
+
override suspend fun replaceMessageId(
chatId: Long,
oldMessageId: Long,
@@ -278,4 +297,9 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
flow.update { it.filterValues { msg -> msg.createdAt >= timestamp } }
}
}
+
+ private fun scopedMessages(chatId: Long, threadId: Long?): List {
+ val chatMessages = messages[chatId]?.value?.values ?: return emptyList()
+ return chatMessages.filter { message -> message.threadId == threadId }
+ }
}
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt
index 9bc10065..b1787e63 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt
@@ -47,19 +47,59 @@ class RoomChatLocalDataSource(
override fun getMessagesForChat(chatId: Long): Flow> = messageDao.getMessagesForChat(chatId)
- override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int) = messageDao.getMessagesOlder(chatId, fromMessageId, limit)
+ override suspend fun getMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ) =
+ if (threadId == null) {
+ messageDao.getMainChatMessagesOlder(chatId, fromMessageId, limit)
+ } else {
+ messageDao.getThreadMessagesOlder(chatId, threadId, fromMessageId, limit)
+ }
- override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int) = messageDao.getMessagesNewer(chatId, fromMessageId, limit)
+ override suspend fun getMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ) =
+ if (threadId == null) {
+ messageDao.getMainChatMessagesNewer(chatId, fromMessageId, limit)
+ } else {
+ messageDao.getThreadMessagesNewer(chatId, threadId, fromMessageId, limit)
+ }
- override suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int) =
- messageDao.getMessagesAround(
- chatId = chatId,
- messageId = messageId,
- olderLimit = (limit + 1) / 2,
- newerLimit = limit / 2
- )
+ override suspend fun getMessagesAround(
+ chatId: Long,
+ messageId: Long,
+ limit: Int,
+ threadId: Long?
+ ) =
+ if (threadId == null) {
+ messageDao.getMainChatMessagesAround(
+ chatId = chatId,
+ messageId = messageId,
+ olderLimit = (limit + 1) / 2,
+ newerLimit = limit / 2
+ )
+ } else {
+ messageDao.getThreadMessagesAround(
+ chatId = chatId,
+ threadId = threadId,
+ messageId = messageId,
+ olderLimit = (limit + 1) / 2,
+ newerLimit = limit / 2
+ )
+ }
- override suspend fun getLatestMessages(chatId: Long, limit: Int) = messageDao.getLatestMessages(chatId, limit)
+ override suspend fun getLatestMessages(chatId: Long, limit: Int, threadId: Long?) =
+ if (threadId == null) {
+ messageDao.getMainChatLatestMessages(chatId, limit)
+ } else {
+ messageDao.getThreadLatestMessages(chatId, threadId, limit)
+ }
override suspend fun getMessagesByIds(chatId: Long, messageIds: List) =
if (messageIds.isEmpty()) emptyList() else messageDao.getMessagesByIds(chatId, messageIds)
@@ -68,6 +108,8 @@ class RoomChatLocalDataSource(
override suspend fun insertMessages(messages: List) = messageDao.insertMessages(messages)
+ override suspend fun replaceMessage(message: MessageEntity) = messageDao.insertMessage(message)
+
override suspend fun replaceMessageId(
chatId: Long,
oldMessageId: Long,
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt
index 186bf783..448d96ef 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt
@@ -18,9 +18,12 @@ interface ChatRemoteSource {
suspend fun setChatDescription(chatId: Long, description: String)
suspend fun setChatUsername(chatId: Long, username: String)
suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel)
+ suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean)
+ suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean)
+ suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean)
+ suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List)
suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int)
suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean)
- suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean)
suspend fun getChatLink(chatId: Long): String?
suspend fun deleteFolder(folderId: Int)
suspend fun muteChat(chatId: Long, muteFor: Int)
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt
index 2f7d000c..f7286243 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt
@@ -2,6 +2,7 @@ package org.monogram.data.datasource.remote
interface MessageFileApi {
fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long)
+ fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long)
fun enqueueDownload(
fileId: Int,
priority: Int = 1,
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt
index c4f6f403..5188aeac 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt
@@ -11,6 +11,10 @@ class MessageFileCoordinator(
fileDownloadQueue.registry.register(fileId, chatId, messageId)
}
+ override fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) {
+ fileDownloadQueue.registry.registerSponsored(fileId, chatId, messageId)
+ }
+
override fun enqueueDownload(
fileId: Int,
priority: Int,
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt
index 7c88bb7e..d8740094 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt
@@ -45,6 +45,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource {
sendCopy: Boolean = false
)
suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message?
+ suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages?
+ suspend fun clickChatSponsoredMessage(
+ chatId: Long,
+ messageId: Long,
+ isMediaClick: Boolean,
+ fromFullscreen: Boolean = false
+ ): TdApi.Ok?
suspend fun getMessageThread(chatId: Long, messageId: Long): TdApi.MessageThreadInfo?
suspend fun getMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long?): TdApi.Messages?
suspend fun getChatHistory(chatId: Long, fromMessageId: Long, offset: Int, limit: Int): TdApi.Messages?
@@ -252,6 +259,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource {
threadId: Long? = null
): OlderMessagesPage
+ suspend fun getRemoteMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): RemoteOlderMessagesPage
+
suspend fun getMessagesNewer(
chatId: Long,
fromMessageId: Long,
@@ -259,6 +273,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource {
threadId: Long? = null
): List
+ suspend fun getRemoteMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): RemoteMessageBatch
+
suspend fun getMessagesAround(
chatId: Long,
messageId: Long,
@@ -266,6 +287,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource {
threadId: Long? = null
): List
+ suspend fun getRemoteMessagesAround(
+ chatId: Long,
+ messageId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): RemoteMessageBatch
+
suspend fun getPinnedMessageModel(chatId: Long, threadId: Long? = null): MessageModel?
suspend fun getAllPinnedMessages(chatId: Long, threadId: Long? = null): List
suspend fun getPinnedMessageCount(chatId: Long, threadId: Long? = null): Int
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt
new file mode 100644
index 00000000..836fefb0
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt
@@ -0,0 +1,21 @@
+package org.monogram.data.datasource.remote
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.MessageModel
+
+data class MessageMapOptions(
+ val resolveReplyPreviewFromNetwork: Boolean = true,
+ val allowAutoDownload: Boolean = true
+)
+
+data class RemoteMessageBatch(
+ val rawMessages: List,
+ val models: List
+)
+
+data class RemoteOlderMessagesPage(
+ val rawMessages: List,
+ val models: List,
+ val reachedOldest: Boolean,
+ val isRemote: Boolean = true
+)
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt
index c433b64c..c416c060 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt
@@ -84,13 +84,7 @@ class TdChatRemoteSource(
override suspend fun setChatDescription(chatId: Long, description: String) {
coRunCatching {
- val chat = gateway.execute(TdApi.GetChat(chatId))
- val groupId = when (val type = chat.type) {
- is TdApi.ChatTypeSupergroup -> type.supergroupId
- is TdApi.ChatTypeBasicGroup -> type.basicGroupId
- else -> return
- }
- gateway.execute(TdApi.SetChatDescription(groupId, description))
+ gateway.execute(TdApi.SetChatDescription(chatId, description))
}
}
@@ -108,31 +102,61 @@ class TdChatRemoteSource(
}
}
- override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) {
+ override suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) {
+ coRunCatching {
+ gateway.execute(TdApi.ToggleChatHasProtectedContent(chatId, hasProtectedContent))
+ }
+ }
+
+ override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) {
coRunCatching {
val chat = gateway.execute(TdApi.GetChat(chatId))
- val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: chatId
- gateway.execute(TdApi.SetChatSlowModeDelay(supergroupId, slowModeDelay))
+ val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return
+ gateway.execute(TdApi.ToggleSupergroupSignMessages(supergroupId, signMessages, true))
}
}
- override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) {
+ override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) {
coRunCatching {
val chat = gateway.execute(TdApi.GetChat(chatId))
- val type = chat.type as? TdApi.ChatTypeSupergroup ?: return
- gateway.execute(TdApi.ToggleSupergroupIsForum(type.supergroupId, isForum, isForum))
+ val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return
+ gateway.execute(
+ TdApi.ToggleSupergroupJoinToSendMessages(
+ supergroupId,
+ joinToSendMessages
+ )
+ )
}
}
- override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) {
+ override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) {
coRunCatching {
val chat = gateway.execute(TdApi.GetChat(chatId))
- if (chat.type is TdApi.ChatTypeSupergroup) {
- val supergroupId = (chat.type as TdApi.ChatTypeSupergroup).supergroupId
- gateway.execute(TdApi.ToggleSupergroupHasAutomaticTranslation(supergroupId, isTranslatable))
+ val reactionTypes =
+ availableReactions.map { TdApi.ReactionTypeEmoji(it) }.toTypedArray()
+ val reactions = if (availableReactions.isEmpty()) {
+ TdApi.ChatAvailableReactionsAll()
} else {
- gateway.execute(TdApi.ToggleChatIsTranslatable(chatId, isTranslatable))
+ TdApi.ChatAvailableReactionsSome(reactionTypes.map { it as TdApi.ReactionType }
+ .toTypedArray(), 3)
}
+ gateway.execute(TdApi.SetChatAvailableReactions(chatId, reactions))
+ }
+ }
+
+ override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) {
+ coRunCatching {
+ val chat = gateway.execute(TdApi.GetChat(chatId))
+ val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: chatId
+ gateway.execute(TdApi.SetChatSlowModeDelay(supergroupId, slowModeDelay))
+ }
+ }
+
+ override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) {
+ coRunCatching {
+ val chat = gateway.execute(TdApi.GetChat(chatId))
+ val type = chat.type as? TdApi.ChatTypeSupergroup ?: return
+ gateway.execute(TdApi.ToggleSupergroupIsForum(type.supergroupId, isForum, isForum))
}
}
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt
index e0c092c0..c129bad1 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt
@@ -18,6 +18,9 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
+import org.monogram.core.perf.ChatOpenPerfBridge
+import org.monogram.core.perf.ChatOpenPerfDebug
+import org.monogram.data.BuildConfig
import org.monogram.data.chats.ChatCache
import org.monogram.data.compat.buildInputAnimation
import org.monogram.data.compat.buildInputDocument
@@ -116,8 +119,14 @@ class TdMessageRemoteDataSource(
private val messageUpdateJobs = ConcurrentHashMap, Job>()
private val lastProgressMap = ConcurrentHashMap()
private val lastDownloadActiveMap = ConcurrentHashMap()
+ private val lastCancelledEmissionAt = ConcurrentHashMap()
init {
+ fileDownloadQueue.setObserver(object : FileDownloadQueue.Observer {
+ override fun onDownloadCancelled(fileId: Int) {
+ emitCancelledForFile(fileId)
+ }
+ })
scope.launch {
for (task in sendQueue) {
try {
@@ -160,6 +169,26 @@ class TdMessageRemoteDataSource(
}
}
+ override suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages? {
+ return safeExecute(TdApi.GetChatSponsoredMessages(chatId))
+ }
+
+ override suspend fun clickChatSponsoredMessage(
+ chatId: Long,
+ messageId: Long,
+ isMediaClick: Boolean,
+ fromFullscreen: Boolean
+ ): TdApi.Ok? {
+ return safeExecute(
+ TdApi.ClickChatSponsoredMessage(
+ chatId,
+ messageId,
+ isMediaClick,
+ fromFullscreen
+ )
+ )
+ }
+
suspend fun getChat(chatId: Long): TdApi.Chat? {
cache.getChat(chatId)?.let { return it }
val deferred = chatRequests.getOrPut(chatId) {
@@ -180,17 +209,29 @@ class TdMessageRemoteDataSource(
override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): OlderMessagesPage {
if (fromMessageId == 0L) {
- val messages = loadMessages(chatId, fromMessageId, 0, limit, threadId)
+ val batch = loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = fromMessageId,
+ offset = 0,
+ limit = limit,
+ threadId = threadId
+ )
val page = OlderMessagesPage(
- messages = messages,
- reachedOldest = messages.isEmpty(),
+ messages = batch.models,
+ reachedOldest = batch.models.isEmpty(),
isRemote = true
)
return page
}
- val messages = loadMessages(chatId, fromMessageId, 0, limit + 1, threadId)
- val filtered = messages.filter { it.id != fromMessageId }.take(limit)
+ val batch = loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = fromMessageId,
+ offset = 0,
+ limit = limit + 1,
+ threadId = threadId
+ )
+ val filtered = batch.models.filter { it.id != fromMessageId }.take(limit)
val page = OlderMessagesPage(
messages = filtered,
reachedOldest = filtered.isEmpty(),
@@ -199,12 +240,96 @@ class TdMessageRemoteDataSource(
return page
}
+ override suspend fun getRemoteMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ): RemoteOlderMessagesPage {
+ if (fromMessageId == 0L) {
+ val batch = loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = fromMessageId,
+ offset = 0,
+ limit = limit,
+ threadId = threadId,
+ options = MessageMapOptions(
+ resolveReplyPreviewFromNetwork = false,
+ allowAutoDownload = false
+ )
+ )
+ return RemoteOlderMessagesPage(
+ rawMessages = batch.rawMessages,
+ models = batch.models,
+ reachedOldest = batch.models.isEmpty()
+ )
+ }
+
+ val batch = loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = fromMessageId,
+ offset = 0,
+ limit = limit + 1,
+ threadId = threadId,
+ options = MessageMapOptions(
+ resolveReplyPreviewFromNetwork = false,
+ allowAutoDownload = false
+ )
+ )
+ val filteredPairs = batch.rawMessages.zip(batch.models)
+ .filter { (_, model) -> model.id != fromMessageId }
+ .take(limit)
+ return RemoteOlderMessagesPage(
+ rawMessages = filteredPairs.map { it.first },
+ models = filteredPairs.map { it.second },
+ reachedOldest = filteredPairs.isEmpty()
+ )
+ }
+
override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): List {
- return loadMessages(chatId, fromMessageId, -limit, limit, threadId)
+ return getRemoteMessagesNewer(chatId, fromMessageId, limit, threadId).models
}
override suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long?): List {
- return loadMessages(chatId, messageId, -limit / 2, limit, threadId)
+ return getRemoteMessagesAround(chatId, messageId, limit, threadId).models
+ }
+
+ override suspend fun getRemoteMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int,
+ threadId: Long?
+ ): RemoteMessageBatch {
+ return loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = fromMessageId,
+ offset = -limit,
+ limit = limit,
+ threadId = threadId,
+ options = MessageMapOptions(
+ resolveReplyPreviewFromNetwork = false,
+ allowAutoDownload = false
+ )
+ )
+ }
+
+ override suspend fun getRemoteMessagesAround(
+ chatId: Long,
+ messageId: Long,
+ limit: Int,
+ threadId: Long?
+ ): RemoteMessageBatch {
+ return loadMessageBatch(
+ chatId = chatId,
+ fromMessageId = messageId,
+ offset = -limit / 2,
+ limit = limit,
+ threadId = threadId,
+ options = MessageMapOptions(
+ resolveReplyPreviewFromNetwork = false,
+ allowAutoDownload = false
+ )
+ )
}
override suspend fun getChatPinnedMessage(chatId: Long): TdApi.Message? =
@@ -505,7 +630,26 @@ class TdMessageRemoteDataSource(
}
}
- private suspend fun loadMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): List = withContext(dispatcherProvider.io) {
+ private suspend fun loadMessageBatch(
+ chatId: Long,
+ fromMessageId: Long,
+ offset: Int,
+ limit: Int,
+ threadId: Long? = null,
+ options: MessageMapOptions = MessageMapOptions()
+ ): RemoteMessageBatch = withContext(dispatcherProvider.io) {
+ ChatOpenPerfBridge.recordHistoryRequest(chatId, threadId)
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ ChatOpenPerfDebug.TAG,
+ ChatOpenPerfDebug.buildLogMessage(
+ chatId = chatId,
+ threadId = threadId,
+ event = "chat_history_request_start",
+ anchorId = fromMessageId
+ )
+ )
+ }
val historyResult = getChatHistoryInternal(chatId, fromMessageId, offset, limit, threadId)
?: throw IllegalStateException(
"Failed to load history for chatId=$chatId fromMessageId=$fromMessageId offset=$offset limit=$limit threadId=$threadId"
@@ -518,11 +662,19 @@ class TdMessageRemoteDataSource(
is TdApi.MessageThreadInfo -> historyResult.messages
else -> emptyArray()
}
- messages.map { msg ->
+ val models = messages.map { msg ->
cache.putMessage(msg)
async {
try {
- withTimeout(5000) { messageMapper.mapMessageToModelSync(msg, lastReadInbox, lastReadOutbox, isChatOpen = true) }
+ withTimeout(5000) {
+ messageMapper.mapMessageToModelSync(
+ msg = msg,
+ inboxLimit = lastReadInbox,
+ outboxLimit = lastReadOutbox,
+ isChatOpen = true,
+ options = options
+ )
+ }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -531,6 +683,23 @@ class TdMessageRemoteDataSource(
}
}
}.awaitAll()
+ RemoteMessageBatch(
+ rawMessages = messages.toList(),
+ models = models
+ )
+ .also {
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ ChatOpenPerfDebug.TAG,
+ ChatOpenPerfDebug.buildLogMessage(
+ chatId = chatId,
+ threadId = threadId,
+ event = "chat_history_request_end",
+ anchorId = fromMessageId
+ )
+ )
+ }
+ }
}
private suspend fun getChatHistoryInternal(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): TdApi.Object? {
@@ -1880,20 +2049,7 @@ class TdMessageRemoteDataSource(
} else if (isCancelled) {
lastProgressMap.remove(file.id)
Log.d("DownloadDebug", "td.downloadCancelled.emit: fileId=${file.id}")
- val entries = fileIdToMessageMap[file.id]
- if (!entries.isNullOrEmpty()) {
- scope.launch {
- entries.forEach { (chatId, messageId) ->
- messageDownloadFlow.emit(
- MessageDownloadEvent.Cancelled(
- chatId = chatId,
- messageId = messageId,
- fileId = file.id
- )
- )
- }
- }
- }
+ emitCancelledForFile(file.id)
}
if (isUC) {
@@ -1979,6 +2135,31 @@ class TdMessageRemoteDataSource(
refreshJobs.values.forEach { it.cancel() }; refreshJobs.clear()
messageUpdateJobs.values.forEach { it.cancel() }; messageUpdateJobs.clear()
lastProgressMap.clear()
+ fileDownloadQueue.setObserver(null)
+ }
+
+ private fun emitCancelledForFile(fileId: Int) {
+ if (fileId == 0) return
+ lastProgressMap.remove(fileId)
+ val now = System.currentTimeMillis()
+ val previous = lastCancelledEmissionAt[fileId]
+ if (previous != null && now - previous < 250L) return
+ lastCancelledEmissionAt[fileId] = now
+
+ val entries = fileIdToMessageMap[fileId]
+ if (!entries.isNullOrEmpty()) {
+ scope.launch {
+ entries.forEach { (chatId, messageId) ->
+ messageDownloadFlow.emit(
+ MessageDownloadEvent.Cancelled(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = fileId
+ )
+ )
+ }
+ }
+ }
}
private companion object {
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt
index 0d74961b..fea29fad 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt
@@ -67,6 +67,17 @@ class TdUserRemoteDataSource(
}
}
+ override suspend fun getCloseFriendIds(): LongArray =
+ coRunCatching { gateway.execute(TdApi.GetCloseFriends()).userIds }.getOrDefault(longArrayOf())
+
+ override suspend fun setCloseFriendIds(userIds: LongArray) {
+ gateway.execute(TdApi.SetCloseFriends(userIds))
+ }
+
+ override suspend fun sharePhoneNumber(userId: Long) {
+ gateway.execute(TdApi.SharePhoneNumber(userId))
+ }
+
override suspend fun searchPublicChat(username: String): TdApi.Chat? =
coRunCatching { gateway.execute(TdApi.SearchPublicChat(username)) }.getOrNull()
@@ -127,6 +138,10 @@ class TdUserRemoteDataSource(
override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? =
coRunCatching { gateway.execute(TdApi.GetPremiumLimit(limitType)) }.getOrNull()
+ override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) {
+ coRunCatching { gateway.execute(TdApi.ToggleHasSponsoredMessagesEnabled(enabled)) }
+ }
+
override suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? {
if (userId == 0L) return null
return coRunCatching { gateway.execute(TdApi.GetUserFullInfo(userId)) }.getOrNull()
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt
index 59e8e4e1..51202f8a 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt
@@ -16,6 +16,9 @@ interface UserRemoteDataSource {
suspend fun searchContacts(query: String): TdApi.Users?
suspend fun addContact(userId: Long, contact: TdApi.ImportedContact, sharePhoneNumber: Boolean)
suspend fun removeContacts(userIds: LongArray)
+ suspend fun getCloseFriendIds(): LongArray
+ suspend fun setCloseFriendIds(userIds: LongArray)
+ suspend fun sharePhoneNumber(userId: Long)
suspend fun searchPublicChat(username: String): TdApi.Chat?
suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember?
suspend fun getSupergroupMembers(
@@ -28,6 +31,7 @@ interface UserRemoteDataSource {
suspend fun getPremiumState(): TdApi.PremiumState?
suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures?
suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit?
+ suspend fun setSponsoredMessagesEnabled(enabled: Boolean)
suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo?
suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics?
suspend fun getChatRevenueStatistics(chatId: Long, isDark: Boolean): TdApi.ChatRevenueStatistics?
diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt
index d21f2651..024c1cbc 100644
--- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt
+++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt
@@ -60,7 +60,7 @@ import org.monogram.data.db.model.WallpaperEntity
SponsorEntity::class,
TextCompositionStyleEntity::class
],
- version = 35,
+ version = 37,
exportSchema = false
)
abstract class MonogramDatabase : RoomDatabase() {
diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt
index fb5e1a5c..58cc0632 100644
--- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt
+++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt
@@ -349,6 +349,52 @@ object MonogramMigrations {
}
}
+ val MIGRATION_35_36 = object : Migration(35, 36) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.addColumn("messages", "replyMarkupData", "TEXT")
+ db.addColumn("messages", "reactionsData", "TEXT")
+ db.addColumn("messages", "threadId", "INTEGER")
+ db.addColumn("messages", "viaBotUserId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "viaBotName", "TEXT")
+ db.addColumn("messages", "senderPersonalAvatarPath", "TEXT")
+ db.addColumn("messages", "senderCustomTitle", "TEXT")
+ db.addColumn("messages", "isSenderAdmin", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "isSenderVerified", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "isSenderPremium", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "senderStatusEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "senderStatusEmojiPath", "TEXT")
+ db.addColumn("messages", "canBeEdited", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canBeForwarded", "INTEGER NOT NULL DEFAULT 1")
+ db.addColumn("messages", "canBeDeletedOnlyForSelf", "INTEGER NOT NULL DEFAULT 1")
+ db.addColumn("messages", "canBeDeletedForAllUsers", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canBeSaved", "INTEGER NOT NULL DEFAULT 1")
+ db.addColumn("messages", "canGetMessageThread", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canGetStatistics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canGetRevenueStatistics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canGetMediaStatistics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canGetReadReceipts", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "canGetViewers", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "readDate", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "hasUnreadMention", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "hasUnreadReactions", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "isPinned", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "containsUnreadPollVotes", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("messages", "suggestedPostInfoData", "TEXT")
+ db.addColumn("messages", "factCheckData", "TEXT")
+ }
+ }
+
+ val MIGRATION_36_37 = object : Migration(36, 37) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_date` ON `messages` (`chatId`, `threadId`, `date`)"
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_id` ON `messages` (`chatId`, `threadId`, `id`)"
+ )
+ }
+ }
+
private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) {
execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition")
}
diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt
index d2f6e189..47ef1c65 100644
--- a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt
+++ b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt
@@ -12,41 +12,112 @@ interface MessageDao {
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY date DESC")
fun getMessagesForChat(chatId: Long): Flow>
- @Query("SELECT * FROM messages WHERE chatId = :chatId AND id < :fromMessageId ORDER BY date DESC LIMIT :limit")
- suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List
+ @Query(
+ "SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL AND id < :fromMessageId ORDER BY date DESC LIMIT :limit"
+ )
+ suspend fun getMainChatMessagesOlder(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int
+ ): List
- @Query("SELECT * FROM messages WHERE chatId = :chatId AND id > :fromMessageId ORDER BY date ASC LIMIT :limit")
- suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List
+ @Query(
+ "SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId AND id < :fromMessageId ORDER BY date DESC LIMIT :limit"
+ )
+ suspend fun getThreadMessagesOlder(
+ chatId: Long,
+ threadId: Long,
+ fromMessageId: Long,
+ limit: Int
+ ): List
+
+ @Query(
+ "SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL AND id > :fromMessageId ORDER BY date ASC LIMIT :limit"
+ )
+ suspend fun getMainChatMessagesNewer(
+ chatId: Long,
+ fromMessageId: Long,
+ limit: Int
+ ): List
+
+ @Query(
+ "SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId AND id > :fromMessageId ORDER BY date ASC LIMIT :limit"
+ )
+ suspend fun getThreadMessagesNewer(
+ chatId: Long,
+ threadId: Long,
+ fromMessageId: Long,
+ limit: Int
+ ): List
@Query(
"""
SELECT * FROM messages
WHERE chatId = :chatId
+ AND threadId IS NULL
AND id IN (
SELECT id FROM messages
- WHERE chatId = :chatId AND id <= :messageId
+ WHERE chatId = :chatId AND threadId IS NULL AND id <= :messageId
ORDER BY date DESC LIMIT :olderLimit
)
UNION
SELECT * FROM messages
WHERE chatId = :chatId
+ AND threadId IS NULL
AND id IN (
SELECT id FROM messages
- WHERE chatId = :chatId AND id > :messageId
+ WHERE chatId = :chatId AND threadId IS NULL AND id > :messageId
ORDER BY date ASC LIMIT :newerLimit
)
ORDER BY date DESC
"""
)
- suspend fun getMessagesAround(
+ suspend fun getMainChatMessagesAround(
chatId: Long,
messageId: Long,
olderLimit: Int,
newerLimit: Int
): List
- @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :limit")
- suspend fun getLatestMessages(chatId: Long, limit: Int): List
+ @Query(
+ """
+ SELECT * FROM messages
+ WHERE chatId = :chatId
+ AND threadId = :threadId
+ AND id IN (
+ SELECT id FROM messages
+ WHERE chatId = :chatId AND threadId = :threadId AND id <= :messageId
+ ORDER BY date DESC LIMIT :olderLimit
+ )
+ UNION
+ SELECT * FROM messages
+ WHERE chatId = :chatId
+ AND threadId = :threadId
+ AND id IN (
+ SELECT id FROM messages
+ WHERE chatId = :chatId AND threadId = :threadId AND id > :messageId
+ ORDER BY date ASC LIMIT :newerLimit
+ )
+ ORDER BY date DESC
+ """
+ )
+ suspend fun getThreadMessagesAround(
+ chatId: Long,
+ threadId: Long,
+ messageId: Long,
+ olderLimit: Int,
+ newerLimit: Int
+ ): List
+
+ @Query("SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL ORDER BY date DESC LIMIT :limit")
+ suspend fun getMainChatLatestMessages(chatId: Long, limit: Int): List
+
+ @Query("SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId ORDER BY date DESC LIMIT :limit")
+ suspend fun getThreadLatestMessages(
+ chatId: Long,
+ threadId: Long,
+ limit: Int
+ ): List
@Query("SELECT * FROM messages WHERE chatId = :chatId AND id IN (:messageIds)")
suspend fun getMessagesByIds(chatId: Long, messageIds: List): List
diff --git a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt
index a1b5cf25..fc6a6f2e 100644
--- a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt
@@ -9,6 +9,8 @@ import androidx.room.Index
indices = [
Index(value = ["chatId", "date"]),
Index(value = ["chatId", "id"]),
+ Index(value = ["chatId", "threadId", "date"]),
+ Index(value = ["chatId", "threadId", "id"]),
Index(value = ["createdAt"])
]
)
@@ -27,18 +29,48 @@ data class MessageEntity(
val date: Int,
val isOutgoing: Boolean,
val isRead: Boolean,
+ val replyMarkupData: String? = null,
+ val reactionsData: String? = null,
val replyToMessageId: Long = 0L,
val replyToPreview: String? = null,
val replyToPreviewType: String? = null,
val replyToPreviewText: String? = null,
val replyToPreviewSenderName: String? = null,
val replyCount: Int = 0,
+ val threadId: Long? = null,
+ val viaBotUserId: Long = 0L,
+ val viaBotName: String? = null,
+ val senderPersonalAvatarPath: String? = null,
+ val senderCustomTitle: String? = null,
+ val isSenderAdmin: Boolean = false,
+ val isSenderVerified: Boolean = false,
+ val isSenderPremium: Boolean = false,
+ val senderStatusEmojiId: Long = 0L,
+ val senderStatusEmojiPath: String? = null,
val forwardFromName: String? = null,
val forwardFromId: Long = 0L,
val forwardOriginChatId: Long? = null,
val forwardOriginMessageId: Long? = null,
val forwardDate: Int = 0,
val editDate: Int = 0,
+ val canBeEdited: Boolean = false,
+ val canBeForwarded: Boolean = true,
+ val canBeDeletedOnlyForSelf: Boolean = true,
+ val canBeDeletedForAllUsers: Boolean = false,
+ val canBeSaved: Boolean = true,
+ val canGetMessageThread: Boolean = false,
+ val canGetStatistics: Boolean = false,
+ val canGetRevenueStatistics: Boolean = false,
+ val canGetMediaStatistics: Boolean = false,
+ val canGetReadReceipts: Boolean = false,
+ val canGetViewers: Boolean = false,
+ val readDate: Int = 0,
+ val hasUnreadMention: Boolean = false,
+ val hasUnreadReactions: Boolean = false,
+ val isPinned: Boolean = false,
+ val containsUnreadPollVotes: Boolean = false,
+ val suggestedPostInfoData: String? = null,
+ val factCheckData: String? = null,
val mediaAlbumId: Long = 0L,
val entities: String? = null,
val viewCount: Int = 0,
diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt
index 00a5af9d..59418283 100644
--- a/data/src/main/java/org/monogram/data/di/dataModule.kt
+++ b/data/src/main/java/org/monogram/data/di/dataModule.kt
@@ -78,6 +78,7 @@ import org.monogram.data.mapper.ChatMapper
import org.monogram.data.mapper.CustomEmojiLoader
import org.monogram.data.mapper.MessageMapper
import org.monogram.data.mapper.NetworkMapper
+import org.monogram.data.mapper.SponsoredMessageMapper
import org.monogram.data.mapper.StorageMapper
import org.monogram.data.mapper.TdFileHelper
import org.monogram.data.mapper.WebPageMapper
@@ -93,6 +94,7 @@ import org.monogram.data.repository.BotRepositoryImpl
import org.monogram.data.repository.ChatInfoRepositoryImpl
import org.monogram.data.repository.ChatStatisticsRepositoryImpl
import org.monogram.data.repository.ChatsListRepositoryImpl
+import org.monogram.data.repository.ContactEditRepositoryImpl
import org.monogram.data.repository.DraftLinkPreviewResolver
import org.monogram.data.repository.EmojiRepositoryImpl
import org.monogram.data.repository.GifRepositoryImpl
@@ -102,6 +104,7 @@ import org.monogram.data.repository.LocationRepositoryImpl
import org.monogram.data.repository.MessageRepositoryImpl
import org.monogram.data.repository.NetworkStatisticsRepositoryImpl
import org.monogram.data.repository.NotificationSettingsRepositoryImpl
+import org.monogram.data.repository.PinnedMessageVisibilityRepositoryImpl
import org.monogram.data.repository.PollRepositoryImpl
import org.monogram.data.repository.PremiumRepositoryImpl
import org.monogram.data.repository.PrivacyRepositoryImpl
@@ -131,6 +134,7 @@ import org.monogram.domain.repository.ChatOperationsRepository
import org.monogram.domain.repository.ChatSearchRepository
import org.monogram.domain.repository.ChatSettingsRepository
import org.monogram.domain.repository.ChatStatisticsRepository
+import org.monogram.domain.repository.ContactEditRepository
import org.monogram.domain.repository.EmojiRepository
import org.monogram.domain.repository.FileRepository
import org.monogram.domain.repository.ForumTopicsRepository
@@ -143,6 +147,7 @@ import org.monogram.domain.repository.MessageRepository
import org.monogram.domain.repository.NetworkStatisticsRepository
import org.monogram.domain.repository.NotificationSettingsRepository
import org.monogram.domain.repository.PaymentRepository
+import org.monogram.domain.repository.PinnedMessageVisibilityRepository
import org.monogram.domain.repository.PlayerDataSourceFactory
import org.monogram.domain.repository.PollRepository
import org.monogram.domain.repository.PremiumRepository
@@ -271,7 +276,9 @@ val dataModule = module {
MonogramMigrations.MIGRATION_31_32,
MonogramMigrations.MIGRATION_32_33,
MonogramMigrations.MIGRATION_33_34,
- MonogramMigrations.MIGRATION_34_35
+ MonogramMigrations.MIGRATION_34_35,
+ MonogramMigrations.MIGRATION_35_36,
+ MonogramMigrations.MIGRATION_36_37
)
.build()
}
@@ -341,6 +348,13 @@ val dataModule = module {
)
}
+ single {
+ ContactEditRepositoryImpl(
+ userRepository = get(),
+ userRemoteDataSource = get()
+ )
+ }
+
single {
ProfilePhotoRepositoryImpl(
remote = get(),
@@ -653,6 +667,7 @@ val dataModule = module {
messageRemoteDataSource = get(),
cache = get(),
fileHelper = get(),
+ sponsoredMessageMapper = get(),
fileDataSource = get(),
fxEmbedRemoteDataSource = get(),
draftLinkPreviewResolver = get(),
@@ -666,6 +681,19 @@ val dataModule = module {
)
}
+ single {
+ SponsoredMessageMapper(
+ fileHelper = get(),
+ contentMapper = get()
+ )
+ }
+
+ single {
+ PinnedMessageVisibilityRepositoryImpl(
+ keyValueDao = get()
+ )
+ }
+
single { get() }
single { get() }
single { get() }
diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt
index c2825403..0ab4bca7 100644
--- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt
+++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt
@@ -29,6 +29,10 @@ class FileDownloadQueue(
private val scope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider
) {
+ interface Observer {
+ fun onDownloadCancelled(fileId: Int)
+ }
+
enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT }
private data class DownloadRequest(
@@ -89,6 +93,9 @@ class FileDownloadQueue(
private val trigger = Channel(Channel.CONFLATED)
+ @Volatile
+ private var observer: Observer? = null
+
init {
scope.launch(dispatcherProvider.default) {
while (isActive) {
@@ -648,6 +655,10 @@ class FileDownloadQueue(
}
}
+ fun setObserver(observer: Observer?) {
+ this.observer = observer
+ }
+
fun clearSuppression(fileId: Int) {
if (suppressedAutoDownloadIds.remove(fileId)) {
Log.d("DownloadDebug", "queue.suppression.cleared: fileId=$fileId")
@@ -671,6 +682,7 @@ class FileDownloadQueue(
fun notifyDownloadCancelled(fileId: Int) {
downloadWaiters.remove(fileId)?.cancel()
+ observer?.onDownloadCancelled(fileId)
}
fun notifyUploadComplete(fileId: Int) {
@@ -683,6 +695,7 @@ class FileDownloadQueue(
private fun isStillRelevant(fileId: Int): Boolean {
if (manualDownloadIds.contains(fileId)) return true
+ if (registry.sponsoredFileIds.contains(fileId)) return true
if (registry.standaloneFileIds.contains(fileId)) return true
val type = fileDownloadTypes[fileId]
if (type == DownloadType.STICKER || type == DownloadType.VIDEO_NOTE) return true
diff --git a/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt b/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt
index d2d9fe3b..dfab839e 100644
--- a/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt
+++ b/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt
@@ -6,6 +6,7 @@ class FileMessageRegistry {
val fileIdToMessageMap = ConcurrentHashMap>>()
private val messageToFileMap = ConcurrentHashMap, MutableSet>()
val standaloneFileIds = ConcurrentHashMap.newKeySet()
+ val sponsoredFileIds = ConcurrentHashMap.newKeySet()
fun register(fileId: Int, chatId: Long, messageId: Long) {
val key = chatId to messageId
@@ -13,13 +14,21 @@ class FileMessageRegistry {
messageToFileMap.computeIfAbsent(key) { ConcurrentHashMap.newKeySet() }.add(fileId)
}
+ fun registerSponsored(fileId: Int, chatId: Long, messageId: Long) {
+ register(fileId, chatId, messageId)
+ sponsoredFileIds.add(fileId)
+ }
+
fun removeMessages(chatId: Long, messageIds: List) {
messageIds.forEach { messageId ->
val key = chatId to messageId
messageToFileMap.remove(key)?.forEach { fileId ->
fileIdToMessageMap[fileId]?.let { set ->
set.remove(key)
- if (set.isEmpty()) fileIdToMessageMap.remove(fileId)
+ if (set.isEmpty()) {
+ fileIdToMessageMap.remove(fileId)
+ sponsoredFileIds.remove(fileId)
+ }
}
}
}
@@ -35,7 +44,10 @@ class FileMessageRegistry {
fileIds.forEach { fileId ->
fileIdToMessageMap[fileId]?.let { set ->
set.remove(key)
- if (set.isEmpty()) fileIdToMessageMap.remove(fileId)
+ if (set.isEmpty()) {
+ fileIdToMessageMap.remove(fileId)
+ sponsoredFileIds.remove(fileId)
+ }
}
}
it.remove()
diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt
index ddbf1bb0..9891e905 100644
--- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt
+++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt
@@ -76,7 +76,7 @@ class OfflineWarmup(
userIds.add(chat.privateUserId)
}
chat.messageSenderId?.takeIf { it > 0 }?.let { userIds.add(it) }
- messageDao.getLatestMessages(chat.id, 20)
+ messageDao.getMainChatLatestMessages(chat.id, 20)
.asSequence()
.map { it.senderId }
.filter { it > 0L }
@@ -205,7 +205,7 @@ class OfflineWarmup(
private suspend fun warmupMessages(chats: List) {
val targetChats = chats.take(30)
for (chat in targetChats) {
- val alreadyCachedCount = messageDao.getLatestMessages(chat.id, 25).size
+ val alreadyCachedCount = messageDao.getMainChatLatestMessages(chat.id, 25).size
if (alreadyCachedCount >= 25) {
continue
}
diff --git a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt
index ffb2eb19..0d79a8d0 100644
--- a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt
+++ b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt
@@ -6,6 +6,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
@@ -16,6 +19,7 @@ import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.mapper.updateSponsorIds
import org.monogram.domain.repository.AuthRepository
import org.monogram.domain.repository.AuthStep
+import org.monogram.domain.repository.SponsorState
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "SponsorSync"
@@ -35,10 +39,13 @@ class SponsorSyncManager(
private val scope: CoroutineScope,
private val gateway: TelegramGateway,
private val sponsorDao: SponsorDao,
- private val authRepository: AuthRepository
+ private val authRepository: AuthRepository,
+ private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.IO
) {
private val started = AtomicBoolean(false)
private val syncInProgress = AtomicBoolean(false)
+ private val _sponsorState = MutableStateFlow(SponsorState())
+ val sponsorState: StateFlow = _sponsorState.asStateFlow()
@Volatile
private var sponsorChatId = SPONSOR_CHANNEL_ID
@@ -52,7 +59,7 @@ class SponsorSyncManager(
fun start() {
if (!started.compareAndSet(false, true)) return
- scope.launch(Dispatchers.IO) {
+ scope.launch(ioDispatcher) {
loadFromDatabase()
watchTelegramUpdates()
@@ -86,7 +93,7 @@ class SponsorSyncManager(
}
fun forceSync() {
- scope.launch(Dispatchers.IO) {
+ scope.launch(ioDispatcher) {
syncOnce(force = true, reason = "manual")
}
}
@@ -94,6 +101,11 @@ class SponsorSyncManager(
private suspend fun loadFromDatabase() {
val cachedIds = sponsorDao.getAllIds().toSet()
updateSponsorIds(cachedIds)
+ _sponsorState.value = _sponsorState.value.copy(
+ supporterIds = cachedIds,
+ supportersCount = cachedIds.size,
+ isLoaded = cachedIds.isNotEmpty()
+ )
Log.d(TAG, "Loaded ${cachedIds.size} sponsor ids from DB")
}
@@ -135,7 +147,7 @@ class SponsorSyncManager(
}
private fun watchTelegramUpdates() {
- scope.launch(Dispatchers.IO) {
+ scope.launch(ioDispatcher) {
gateway.updates.collect { update ->
when (update) {
is TdApi.UpdateConnectionState -> {
@@ -174,7 +186,7 @@ class SponsorSyncManager(
private fun requestEventSync(reason: String, force: Boolean) {
eventSyncJob?.cancel()
- eventSyncJob = scope.launch(Dispatchers.IO) {
+ eventSyncJob = scope.launch(ioDispatcher) {
delay(EVENT_DEBOUNCE_MS)
syncOnce(force = force, reason = reason)
}
@@ -184,6 +196,7 @@ class SponsorSyncManager(
if (!syncInProgress.compareAndSet(false, true)) return SyncOutcome.BUSY
try {
+ _sponsorState.value = _sponsorState.value.copy(isSyncInProgress = true)
if (authRepository.authState.value !is AuthStep.Ready) {
Log.d(TAG, "Skipping sponsor sync: user is not authorized")
return SyncOutcome.SKIPPED
@@ -233,6 +246,12 @@ class SponsorSyncManager(
if (parsedIds.isEmpty()) {
Log.w(TAG, "Parsed empty sponsor list, keeping existing ${oldIds.size} ids")
updateSponsorIds(oldIds)
+ _sponsorState.value = _sponsorState.value.copy(
+ supporterIds = oldIds,
+ supportersCount = oldIds.size,
+ isLoaded = true,
+ lastSyncAt = System.currentTimeMillis()
+ )
return SyncOutcome.SUCCESS
}
@@ -247,6 +266,12 @@ class SponsorSyncManager(
})
updateSponsorIds(actualIds)
+ _sponsorState.value = _sponsorState.value.copy(
+ supporterIds = actualIds,
+ supportersCount = actualIds.size,
+ isLoaded = true,
+ lastSyncAt = now
+ )
val added = actualIds - oldIds
Log.d(
@@ -260,6 +285,7 @@ class SponsorSyncManager(
Log.e(TAG, "Sponsor sync failed", t)
return SyncOutcome.FAILED
} finally {
+ _sponsorState.value = _sponsorState.value.copy(isSyncInProgress = false)
syncInProgress.set(false)
}
}
diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt
index 5369f192..bd59604d 100644
--- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt
@@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi
import org.monogram.core.date.DateFormatManager
import org.monogram.data.compat.extractTextDraft
import org.monogram.data.db.model.ChatEntity
+import org.monogram.domain.models.ChatAvailableReactionsModel
import org.monogram.domain.models.ChatModel
import org.monogram.domain.models.ChatType
import org.monogram.domain.models.MessageEntity
@@ -69,10 +70,20 @@ class ChatMapper(
description: String? = null,
inviteLink: String? = null,
hasAutomaticTranslation: Boolean = false,
- personalAvatarPath: String? = null
+ personalAvatarPath: String? = null,
+ signMessages: Boolean = false,
+ joinToSendMessages: Boolean = false
): ChatModel {
val permissions = chat.permissions.toDomainChatPermissions()
val isChannel = chat.type.isChannelType()
+ val availableReactions = when (val reactions = chat.availableReactions) {
+ is TdApi.ChatAvailableReactionsSome -> ChatAvailableReactionsModel.Some(
+ reactions = reactions.reactions.mapNotNull { (it as? TdApi.ReactionTypeEmoji)?.emoji },
+ maxReactionCount = reactions.maxReactionCount
+ )
+
+ else -> ChatAvailableReactionsModel.All
+ }
val draftText = chat.draftMessage?.extractTextDraft()
val draftEntities = emptyList()
@@ -118,6 +129,9 @@ class ChatMapper(
hasProtectedContent = chat.hasProtectedContent,
isTranslatable = chat.isTranslatable,
hasAutomaticTranslation = hasAutomaticTranslation,
+ signMessages = signMessages,
+ joinToSendMessages = joinToSendMessages,
+ availableReactions = availableReactions,
messageAutoDeleteTime = chat.messageAutoDeleteTime,
canBeDeletedOnlyForSelf = chat.canBeDeletedOnlyForSelf,
canBeDeletedForAllUsers = chat.canBeDeletedForAllUsers,
diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt
index 5e07c7bc..d5033313 100644
--- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt
@@ -1,5 +1,6 @@
package org.monogram.data.mapper
+import android.util.Log
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -7,12 +8,18 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.drinkless.tdlib.TdApi
+import org.monogram.core.perf.ChatOpenPerfBridge
+import org.monogram.core.perf.ChatOpenPerfDebug
+import org.monogram.data.BuildConfig
import org.monogram.data.chats.ChatCache
+import org.monogram.data.datasource.remote.MessageMapOptions
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.mapper.message.ContentMappingContext
import org.monogram.data.mapper.message.MessageContentMapper
import org.monogram.data.mapper.message.MessagePersistenceMapper
import org.monogram.data.mapper.message.MessageSenderResolver
+import org.monogram.data.mapper.message.toDomain
+import org.monogram.domain.models.FactCheckModel
import org.monogram.domain.models.ForwardInfo
import org.monogram.domain.models.ForwardOriginType
import org.monogram.domain.models.MessageContent
@@ -20,6 +27,7 @@ import org.monogram.domain.models.MessageModel
import org.monogram.domain.models.MessageReactionModel
import org.monogram.domain.models.MessageSendingState
import org.monogram.domain.models.ReactionSender
+import org.monogram.domain.models.SuggestedPostInfoModel
import org.monogram.domain.repository.StringProvider
import org.monogram.domain.repository.UserRepository
@@ -47,7 +55,8 @@ class MessageMapper internal constructor(
suspend fun mapMessageToModel(
msg: TdApi.Message,
isChatOpen: Boolean = false,
- isReply: Boolean = false
+ isReply: Boolean = false,
+ options: MessageMapOptions = MessageMapOptions()
): MessageModel = coroutineScope {
withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) {
val sender = senderResolver.resolveSender(msg)
@@ -55,16 +64,19 @@ class MessageMapper internal constructor(
val (replyToMsgId, replyToMsg) = resolveReplyInfo(
msg = msg,
isChatOpen = isChatOpen,
- isReply = isReply
+ isReply = isReply,
+ options = options
)
val forwardInfo = resolveForwardInfo(msg)
val views = msg.interactionInfo?.viewCount
val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0
val sendingState = resolveSendingState(msg)
- val reactions = resolveReactions(msg, isReply, isChatOpen)
+ val reactions = resolveReactions(msg, isReply, isChatOpen, options)
val threadId = resolveThreadId(msg)
val viaBotName = resolveViaBotName(msg)
+ val factCheck = msg.factCheck?.toDomain()
+ val suggestedPostInfo = msg.suggestedPostInfo?.toDomain()
createMessageModel(
msg = msg,
@@ -80,6 +92,7 @@ class MessageMapper internal constructor(
mediaAlbumId = msg.mediaAlbumId,
sendingState = sendingState,
isChatOpen = isChatOpen,
+ mapOptions = options,
readDate = 0,
reactions = reactions,
isSenderVerified = sender.isSenderVerified,
@@ -93,9 +106,11 @@ class MessageMapper internal constructor(
isSenderAdmin = sender.isSenderAdmin,
isSenderPremium = sender.isSenderPremium,
senderStatusEmojiId = sender.senderStatusEmojiId,
- senderStatusEmojiPath = sender.senderStatusEmojiPath
+ senderStatusEmojiPath = sender.senderStatusEmojiPath,
+ factCheck = factCheck,
+ suggestedPostInfo = suggestedPostInfo
)
- } ?: mapMessageToModelFallback(msg, isChatOpen, isReply)
+ } ?: mapMessageToModelFallback(msg, isChatOpen, isReply, options)
}
suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int {
@@ -126,10 +141,11 @@ class MessageMapper internal constructor(
inboxLimit: Long,
outboxLimit: Long,
isChatOpen: Boolean = false,
- isReply: Boolean = false
+ isReply: Boolean = false,
+ options: MessageMapOptions = MessageMapOptions()
): MessageModel {
val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit
- val baseModel = mapMessageToModel(msg, isChatOpen, isReply)
+ val baseModel = mapMessageToModel(msg, isChatOpen, isReply, options)
return baseModel.copy(isRead = isRead)
}
@@ -147,6 +163,7 @@ class MessageMapper internal constructor(
mediaAlbumId: Long = 0L,
sendingState: MessageSendingState? = null,
isChatOpen: Boolean = false,
+ mapOptions: MessageMapOptions = MessageMapOptions(),
readDate: Int = 0,
reactions: List = emptyList(),
isSenderVerified: Boolean = false,
@@ -160,9 +177,13 @@ class MessageMapper internal constructor(
isSenderAdmin: Boolean = false,
isSenderPremium: Boolean = false,
senderStatusEmojiId: Long = 0L,
- senderStatusEmojiPath: String? = null
+ senderStatusEmojiPath: String? = null,
+ factCheck: FactCheckModel? = null,
+ suggestedPostInfo: SuggestedPostInfoModel? = null
): MessageModel {
- val networkAutoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled()
+ val networkAutoDownload = isChatOpen &&
+ mapOptions.allowAutoDownload &&
+ fileHelper.isNetworkAutoDownloadEnabled()
val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending
val content = contentMapper.mapContent(
@@ -223,7 +244,13 @@ class MessageMapper internal constructor(
viaBotName = viaBotName,
isSenderPremium = isSenderPremium,
senderStatusEmojiId = senderStatusEmojiId,
- senderStatusEmojiPath = senderStatusEmojiPath
+ senderStatusEmojiPath = senderStatusEmojiPath,
+ isPinned = msg.isPinned,
+ hasUnreadMention = msg.containsUnreadMention,
+ hasUnreadReactions = !msg.unreadReactions.isNullOrEmpty(),
+ containsUnreadPollVotes = msg.containsUnreadPollVotes,
+ factCheck = factCheck,
+ suggestedPostInfo = suggestedPostInfo
)
}
@@ -245,15 +272,40 @@ class MessageMapper internal constructor(
private suspend fun resolveReplyInfo(
msg: TdApi.Message,
isChatOpen: Boolean,
- isReply: Boolean
+ isReply: Boolean,
+ options: MessageMapOptions
): Pair {
if (isReply || msg.replyTo == null) return null to null
val replyTo = msg.replyTo
if (replyTo !is TdApi.MessageReplyToMessage) return null to null
val replyToMsgId = replyTo.messageId
+ if (!options.resolveReplyPreviewFromNetwork) {
+ val cachedReply = cache.getMessage(msg.chatId, replyToMsgId)
+ val cachedReplyModel = cachedReply?.let {
+ mapMessageToModel(
+ msg = it,
+ isChatOpen = false,
+ isReply = true,
+ options = options
+ ).copy(replyToMsg = null, replyToMsgId = null)
+ }
+ return replyToMsgId to cachedReplyModel
+ }
val repliedMessage = try {
withTimeout(500) {
+ ChatOpenPerfBridge.recordReplyFetch(msg.chatId, resolveThreadId(msg))
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ ChatOpenPerfDebug.TAG,
+ ChatOpenPerfDebug.buildLogMessage(
+ chatId = msg.chatId,
+ threadId = resolveThreadId(msg),
+ event = "chat_reply_fetch",
+ anchorId = replyToMsgId
+ )
+ )
+ }
cache.getMessage(msg.chatId, replyToMsgId)
?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) }
}
@@ -265,7 +317,8 @@ class MessageMapper internal constructor(
mapMessageToModel(
msg = it,
isChatOpen = isChatOpen,
- isReply = true
+ isReply = true,
+ options = options
).copy(replyToMsg = null, replyToMsgId = null)
}
@@ -365,7 +418,8 @@ class MessageMapper internal constructor(
private suspend fun resolveReactions(
msg: TdApi.Message,
isReply: Boolean,
- isChatOpen: Boolean
+ isChatOpen: Boolean,
+ options: MessageMapOptions
): List {
if (isReply) return emptyList()
val reactionItems = msg.interactionInfo?.reactions?.reactions ?: return emptyList()
@@ -442,7 +496,9 @@ class MessageMapper internal constructor(
emojiId = emojiId,
chatId = msg.chatId,
messageId = msg.id,
- autoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled()
+ autoDownload = isChatOpen &&
+ options.allowAutoDownload &&
+ fileHelper.isNetworkAutoDownloadEnabled()
)
path = customEmojiLoader.getPathIfValid(emojiId)
}
@@ -492,7 +548,8 @@ class MessageMapper internal constructor(
private fun mapMessageToModelFallback(
msg: TdApi.Message,
isChatOpen: Boolean,
- isReply: Boolean
+ isReply: Boolean,
+ options: MessageMapOptions
): MessageModel {
val sender = senderResolver.resolveFallbackSender(msg)
return createMessageModel(
@@ -501,6 +558,7 @@ class MessageMapper internal constructor(
senderId = sender.senderId,
senderAvatar = sender.senderAvatar,
isChatOpen = isChatOpen,
+ mapOptions = options,
isReply = isReply,
senderPersonalAvatar = sender.senderPersonalAvatar,
senderCustomTitle = sender.senderCustomTitle,
@@ -515,4 +573,4 @@ class MessageMapper internal constructor(
private companion object {
private const val MESSAGE_MAP_TIMEOUT_MS = 2500L
}
-}
\ No newline at end of file
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt
index c173a813..c3e78871 100644
--- a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt
@@ -1,7 +1,14 @@
package org.monogram.data.mapper
import org.drinkless.tdlib.TdApi
-import org.monogram.domain.models.*
+import org.monogram.data.mapper.message.ReplyMarkupPayload
+import org.monogram.domain.models.InlineKeyboardButtonModel
+import org.monogram.domain.models.InlineKeyboardButtonType
+import org.monogram.domain.models.KeyboardButtonModel
+import org.monogram.domain.models.KeyboardButtonType
+import org.monogram.domain.models.ReplyMarkupModel
+import org.monogram.data.mapper.message.toDomain as payloadToDomain
+import org.monogram.data.mapper.message.toPayload as domainToPayload
internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? {
return when (this) {
@@ -69,4 +76,8 @@ internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? {
is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(isPersonal, inputFieldPlaceholder)
else -> null
}
-}
\ No newline at end of file
+}
+
+internal fun ReplyMarkupModel.toPayload(): ReplyMarkupPayload = domainToPayload()
+
+internal fun ReplyMarkupPayload.toDomainReplyMarkup(): ReplyMarkupModel = payloadToDomain()
diff --git a/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt
new file mode 100644
index 00000000..95976eac
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt
@@ -0,0 +1,106 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.mapper.message.ContentMappingContext
+import org.monogram.data.mapper.message.MessageContentMapper
+import org.monogram.domain.models.AdvertisementSponsorModel
+import org.monogram.domain.models.MessageContent
+import org.monogram.domain.models.SponsoredMessageModel
+
+internal class SponsoredMessageMapper(
+ private val fileHelper: TdFileHelper,
+ private val contentMapper: MessageContentMapper
+) {
+ fun map(chatId: Long, message: TdApi.SponsoredMessage): SponsoredMessageModel {
+ val sponsor = message.sponsor
+ val mappedContent = mapContent(chatId, message)
+ val sponsorPhotoPath = sponsor.photo
+ ?.sizes
+ ?.asSequence()
+ ?.map { it.photo }
+ ?.mapNotNull(fileHelper::resolveLocalFilePath)
+ ?.firstOrNull()
+ return SponsoredMessageModel(
+ messageId = message.messageId,
+ isRecommended = message.isRecommended,
+ canBeReported = message.canBeReported,
+ title = message.title.ifBlank { null },
+ buttonText = message.buttonText.ifBlank { null },
+ additionalInfo = message.additionalInfo.ifBlank { null },
+ accentColorId = message.accentColorId,
+ backgroundCustomEmojiId = message.backgroundCustomEmojiId,
+ content = mappedContent,
+ sponsor = AdvertisementSponsorModel(
+ url = sponsor.url,
+ photoPath = sponsorPhotoPath,
+ info = sponsor.info.ifBlank { null }
+ )
+ )
+ }
+
+ private fun mapContent(chatId: Long, message: TdApi.SponsoredMessage): MessageContent {
+ val tdMessage = TdApi.Message().apply {
+ id = message.messageId
+ this.chatId = chatId
+ date = 0
+ isOutgoing = false
+ content = message.content
+ }
+ val mappedContent = contentMapper.mapContent(
+ tdMessage,
+ ContentMappingContext(
+ chatId = chatId,
+ messageId = message.messageId,
+ senderId = 0L,
+ senderName = message.title.orEmpty(),
+ networkAutoDownload = true,
+ isActuallyUploading = false
+ )
+ )
+ enqueueSponsoredMediaDownload(chatId, message.messageId, mappedContent)
+ return mappedContent
+ }
+
+ private fun enqueueSponsoredMediaDownload(
+ chatId: Long,
+ messageId: Long,
+ content: MessageContent
+ ) {
+ when (content) {
+ is MessageContent.Photo -> {
+ if (content.path.isNullOrBlank() && content.fileId != 0) {
+ fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId)
+ fileHelper.enqueueDownload(
+ fileId = content.fileId,
+ priority = 16,
+ downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.DEFAULT
+ )
+ }
+ }
+
+ is MessageContent.Video -> {
+ if (content.path.isNullOrBlank() && content.fileId != 0 && !content.supportsStreaming) {
+ fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId)
+ fileHelper.enqueueDownload(
+ fileId = content.fileId,
+ priority = 16,
+ downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.VIDEO
+ )
+ }
+ }
+
+ is MessageContent.Gif -> {
+ if (content.path.isNullOrBlank() && content.fileId != 0) {
+ fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId)
+ fileHelper.enqueueDownload(
+ fileId = content.fileId,
+ priority = 16,
+ downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.GIF
+ )
+ }
+ }
+
+ else -> Unit
+ }
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt
index 7dcf0c6e..1ad17b70 100644
--- a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt
@@ -64,6 +64,12 @@ class TdFileHelper(
}
}
+ fun registerSponsoredCachedFile(fileId: Int, chatId: Long, messageId: Long) {
+ if (fileId != 0) {
+ fileApi.registerSponsoredFileForMessage(fileId, chatId, messageId)
+ }
+ }
+
fun enqueueDownload(
fileId: Int,
priority: Int,
diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt
index 665f7fd3..eab2c0a5 100644
--- a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt
@@ -4,14 +4,18 @@ import org.drinkless.tdlib.TdApi
import org.monogram.data.chats.ChatCache
import org.monogram.data.mapper.SenderNameResolver
import org.monogram.data.mapper.TdFileHelper
+import org.monogram.data.mapper.toDomainReplyMarkup
import org.monogram.data.mapper.toPageBlock
+import org.monogram.data.mapper.toPayload
import org.monogram.domain.models.ForwardInfo
import org.monogram.domain.models.ForwardOriginType
import org.monogram.domain.models.MessageContent
import org.monogram.domain.models.MessageEntity
import org.monogram.domain.models.MessageEntityType
import org.monogram.domain.models.MessageModel
+import org.monogram.domain.models.MessageReactionModel
import org.monogram.domain.models.PollType
+import org.monogram.domain.models.ReactionSender
import org.monogram.domain.models.webapp.PageBlock
import org.monogram.domain.models.webapp.RichText
import org.monogram.domain.repository.StringProvider
@@ -91,6 +95,29 @@ internal class MessagePersistenceMapper(
val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L
val replyToPreview = buildReplyPreview(msg)
val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin)
+ val cachedSenderUser = senderId.takeIf { it > 0L }?.let(cache::getUser)
+ val cachedSenderFullInfo = senderId.takeIf { it > 0L }?.let(cache::getUserFullInfo)
+ val senderPersonalAvatarPath = cachedSenderFullInfo
+ ?.personalPhoto
+ ?.sizes
+ ?.firstOrNull()
+ ?.photo
+ ?.let(fileHelper::resolveLocalFilePath)
+ val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) {
+ is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId
+ is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId
+ else -> 0L
+ }
+ val senderStatusEmojiPath: String? = null
+ val viaBotName = msg.viaBotUserId
+ .takeIf { it != 0L }
+ ?.let(cache::getUser)
+ ?.let { user ->
+ user.usernames?.editableUsername ?: user.firstName?.takeIf { it.isNotBlank() }
+ }
+ val isServiceMessage = content.type == "service"
+ val hasInteraction = msg.interactionInfo != null
+ val reactions = resolveReactions(msg)
return MessageDbEntity(
id = msg.id,
@@ -107,18 +134,53 @@ internal class MessagePersistenceMapper(
date = resolveMessageDate(msg),
isOutgoing = msg.isOutgoing,
isRead = false,
+ replyMarkupData = msg.replyMarkup
+ ?.toDomainReplyMarkup()
+ ?.toPayload()
+ ?.encode(),
+ reactionsData = reactions
+ ?.takeIf { it.reactions.isNotEmpty() }
+ ?.encode(),
replyToMessageId = replyToMessageId,
replyToPreview = replyToPreview?.let(::encodeReplyPreview),
replyToPreviewType = replyToPreview?.contentType,
replyToPreviewText = replyToPreview?.text,
replyToPreviewSenderName = replyToPreview?.senderName,
replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0,
+ threadId = resolveThreadId(msg),
+ viaBotUserId = msg.viaBotUserId,
+ viaBotName = viaBotName,
+ senderPersonalAvatarPath = senderPersonalAvatarPath,
+ senderCustomTitle = null,
+ isSenderAdmin = false,
+ isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false,
+ isSenderPremium = cachedSenderUser?.isPremium ?: false,
+ senderStatusEmojiId = senderStatusEmojiId,
+ senderStatusEmojiPath = senderStatusEmojiPath,
forwardFromName = forwardOrigin?.fromName,
forwardFromId = forwardOrigin?.fromId ?: 0L,
forwardOriginChatId = forwardOrigin?.originChatId,
forwardOriginMessageId = forwardOrigin?.originMessageId,
forwardDate = msg.forwardInfo?.date ?: 0,
editDate = msg.editDate,
+ canBeEdited = msg.isOutgoing && !isServiceMessage,
+ canBeForwarded = !isServiceMessage,
+ canBeDeletedOnlyForSelf = true,
+ canBeDeletedForAllUsers = msg.isOutgoing,
+ canBeSaved = msg.canBeSaved,
+ canGetMessageThread = msg.interactionInfo?.replyInfo != null,
+ canGetStatistics = hasInteraction,
+ canGetRevenueStatistics = false,
+ canGetMediaStatistics = false,
+ canGetReadReceipts = hasInteraction,
+ canGetViewers = hasInteraction,
+ readDate = 0,
+ hasUnreadMention = msg.containsUnreadMention,
+ hasUnreadReactions = !msg.unreadReactions.isNullOrEmpty(),
+ isPinned = msg.isPinned,
+ containsUnreadPollVotes = msg.containsUnreadPollVotes,
+ suggestedPostInfoData = msg.suggestedPostInfo?.toDomain()?.toPayload()?.encode(),
+ factCheckData = msg.factCheck?.toDomain()?.toPayload()?.encode(),
mediaAlbumId = msg.mediaAlbumId,
entities = entitiesEncoded,
viewCount = msg.interactionInfo?.viewCount ?: 0,
@@ -339,6 +401,11 @@ internal class MessagePersistenceMapper(
val meta = decodeMeta(entity.contentMeta)
val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: 0
entity.mediaPath?.takeIf { it.isNotBlank() }
+ val replyMarkup = decodeReplyMarkupPayload(entity.replyMarkupData)?.toDomain()
+ val reactions = decodeReactionsPayload(entity.reactionsData)?.toDomain().orEmpty()
+ val factCheck = decodeFactCheckPayload(entity.factCheckData)?.toDomain()
+ val suggestedPostInfo =
+ decodeSuggestedPostInfoPayload(entity.suggestedPostInfoData)?.toDomain()
val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L }
val replyPreview = resolveReplyPreview(entity)
val replyPreviewModel =
@@ -361,17 +428,19 @@ internal class MessagePersistenceMapper(
cachedSenderChat != null -> fileHelper.resolveLocalFilePath(cachedSenderChat.photo?.small)
else -> null
}
- val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId)
- ?.personalPhoto
- ?.sizes
- ?.firstOrNull()
- ?.photo
- ?.let { fileHelper.resolveLocalFilePath(it) }
+ val resolvedSenderPersonalAvatar = entity.senderPersonalAvatarPath
+ ?.takeIf { it.isNotBlank() }
+ ?: cache.getUserFullInfo(entity.senderId)
+ ?.personalPhoto
+ ?.sizes
+ ?.firstOrNull()
+ ?.photo
+ ?.let { fileHelper.resolveLocalFilePath(it) }
val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) {
is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId
is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId
- else -> 0L
+ else -> entity.senderStatusEmojiId
}
val forwardInfo = entity.forwardFromName
@@ -623,6 +692,8 @@ internal class MessagePersistenceMapper(
senderId = entity.senderId,
senderAvatar = resolvedSenderAvatar,
senderPersonalAvatar = resolvedSenderPersonalAvatar,
+ senderCustomTitle = entity.senderCustomTitle,
+ isSenderAdmin = entity.isSenderAdmin,
isRead = entity.isRead,
replyToMsgId = replyToMsgId,
replyToMsg = replyPreviewModel,
@@ -631,10 +702,34 @@ internal class MessagePersistenceMapper(
editDate = entity.editDate,
views = entity.viewCount,
viewCount = entity.viewCount,
+ readDate = entity.readDate,
+ reactions = reactions,
replyCount = entity.replyCount,
- isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false,
- isSenderPremium = cachedSenderUser?.isPremium ?: false,
- senderStatusEmojiId = senderStatusEmojiId
+ isSenderVerified = entity.isSenderVerified || (cachedSenderUser?.verificationStatus?.isVerified == true),
+ threadId = entity.threadId,
+ canBeEdited = entity.canBeEdited,
+ canBeForwarded = entity.canBeForwarded,
+ canBeDeletedOnlyForSelf = entity.canBeDeletedOnlyForSelf,
+ canBeDeletedForAllUsers = entity.canBeDeletedForAllUsers,
+ canBeSaved = entity.canBeSaved,
+ canGetMessageThread = entity.canGetMessageThread,
+ canGetStatistics = entity.canGetStatistics,
+ canGetRevenueStatistics = entity.canGetRevenueStatistics,
+ canGetMediaStatistics = entity.canGetMediaStatistics,
+ canGetReadReceipts = entity.canGetReadReceipts,
+ canGetViewers = entity.canGetViewers,
+ replyMarkup = replyMarkup,
+ viaBotUserId = entity.viaBotUserId,
+ viaBotName = entity.viaBotName,
+ isSenderPremium = entity.isSenderPremium || (cachedSenderUser?.isPremium == true),
+ senderStatusEmojiId = senderStatusEmojiId,
+ senderStatusEmojiPath = entity.senderStatusEmojiPath,
+ isPinned = entity.isPinned,
+ hasUnreadMention = entity.hasUnreadMention,
+ hasUnreadReactions = entity.hasUnreadReactions,
+ containsUnreadPollVotes = entity.containsUnreadPollVotes,
+ factCheck = factCheck,
+ suggestedPostInfo = suggestedPostInfo
)
}
@@ -914,6 +1009,65 @@ internal class MessagePersistenceMapper(
}
}
+ private fun resolveReactions(msg: TdApi.Message): ReactionsPayload? {
+ val reactions = msg.interactionInfo?.reactions?.reactions.orEmpty().mapNotNull { reaction ->
+ val recentSenders = reaction.recentSenderIds.orEmpty().map { sender ->
+ when (sender) {
+ is TdApi.MessageSenderUser -> {
+ val user = cache.getUser(sender.userId)
+ ReactionSender(
+ id = sender.userId,
+ name = SenderNameResolver.fromPartsOrBlank(
+ user?.firstName,
+ user?.lastName
+ ),
+ avatar = user?.profilePhoto?.small?.let(fileHelper::resolveLocalFilePath)
+ )
+ }
+
+ is TdApi.MessageSenderChat -> {
+ val chat = cache.getChat(sender.chatId)
+ ReactionSender(
+ id = sender.chatId,
+ name = chat?.title.orEmpty(),
+ avatar = chat?.photo?.small?.let(fileHelper::resolveLocalFilePath)
+ )
+ }
+
+ else -> ReactionSender(0L)
+ }
+ }
+
+ when (val type = reaction.type) {
+ is TdApi.ReactionTypeEmoji -> MessageReactionModel(
+ emoji = type.emoji,
+ count = reaction.totalCount,
+ isChosen = reaction.isChosen,
+ recentSenders = recentSenders
+ )
+
+ is TdApi.ReactionTypeCustomEmoji -> MessageReactionModel(
+ customEmojiId = type.customEmojiId,
+ count = reaction.totalCount,
+ isChosen = reaction.isChosen,
+ recentSenders = recentSenders
+ )
+
+ else -> null
+ }
+ }
+
+ return reactions.takeIf { it.isNotEmpty() }?.toPayload()
+ }
+
+ private fun resolveThreadId(msg: TdApi.Message): Long? {
+ return when (val topic = msg.topicId) {
+ is TdApi.MessageTopicForum -> topic.forumTopicId.toLong()
+ is TdApi.MessageTopicThread -> topic.messageThreadId
+ else -> null
+ }
+ }
+
private fun encodeEntities(content: TdApi.MessageContent): String? {
val formatted = when (content) {
is TdApi.MessageText -> content.text
@@ -1008,36 +1162,6 @@ internal class MessagePersistenceMapper(
}
}
- private fun Array?.toDomainEntities(): List {
- if (isNullOrEmpty()) return emptyList()
- return mapNotNull { entity ->
- val type = when (val entityType = entity.type) {
- is TdApi.TextEntityTypeBold -> MessageEntityType.Bold
- is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic
- is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline
- is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough
- is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler
- is TdApi.TextEntityTypeCode -> MessageEntityType.Code
- is TdApi.TextEntityTypePre -> MessageEntityType.Pre()
- is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language)
- is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url)
- is TdApi.TextEntityTypeMention -> MessageEntityType.Mention
- is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(entityType.userId)
- is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag
- is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand
- is TdApi.TextEntityTypeUrl -> MessageEntityType.Url
- is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email
- is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber
- is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber
- is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(entityType.customEmojiId)
- is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote
- is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable
- else -> MessageEntityType.Other(entityType.javaClass.simpleName)
- }
- MessageEntity(entity.offset, entity.length, type)
- }
- }
-
private fun mapPaidMediaPayload(media: TdApi.PaidMedia): PaidMediaItemPayload {
return when (media) {
is TdApi.PaidMediaPreview -> PaidMediaItemPayload.Preview(
diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt
new file mode 100644
index 00000000..565ca35f
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt
@@ -0,0 +1,61 @@
+package org.monogram.data.mapper.message
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.FactCheckModel
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageEntityType
+import org.monogram.domain.models.SuggestedPostInfoModel
+import org.monogram.domain.models.SuggestedPostPriceModel
+import org.monogram.domain.models.SuggestedPostStateModel
+
+internal fun TdApi.FactCheck.toDomain(): FactCheckModel = FactCheckModel(
+ text = text?.text.orEmpty(),
+ entities = text?.entities.toDomainEntities(),
+ countryCode = countryCode.takeIf { it.isNotBlank() }
+)
+
+internal fun TdApi.SuggestedPostInfo.toDomain(): SuggestedPostInfoModel = SuggestedPostInfoModel(
+ price = when (val value = price) {
+ is TdApi.SuggestedPostPriceStar -> SuggestedPostPriceModel.Star(value.starCount)
+ is TdApi.SuggestedPostPriceTon -> SuggestedPostPriceModel.Ton(value.toncoinCentCount)
+ else -> null
+ },
+ sendDate = sendDate,
+ state = when (state) {
+ is TdApi.SuggestedPostStateApproved -> SuggestedPostStateModel.APPROVED
+ is TdApi.SuggestedPostStateDeclined -> SuggestedPostStateModel.DECLINED
+ else -> SuggestedPostStateModel.PENDING
+ },
+ canBeApproved = canBeApproved,
+ canBeDeclined = canBeDeclined
+)
+
+internal fun Array?.toDomainEntities(): List {
+ if (isNullOrEmpty()) return emptyList()
+ return mapNotNull { entity ->
+ val type = when (val entityType = entity.type) {
+ is TdApi.TextEntityTypeBold -> MessageEntityType.Bold
+ is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic
+ is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline
+ is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough
+ is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler
+ is TdApi.TextEntityTypeCode -> MessageEntityType.Code
+ is TdApi.TextEntityTypePre -> MessageEntityType.Pre()
+ is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language)
+ is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url)
+ is TdApi.TextEntityTypeMention -> MessageEntityType.Mention
+ is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(entityType.userId)
+ is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag
+ is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand
+ is TdApi.TextEntityTypeUrl -> MessageEntityType.Url
+ is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email
+ is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber
+ is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber
+ is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(entityType.customEmojiId)
+ is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote
+ is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable
+ else -> MessageEntityType.Other(entityType.javaClass.simpleName)
+ }
+ MessageEntity(entity.offset, entity.length, type)
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt
new file mode 100644
index 00000000..309ec35d
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt
@@ -0,0 +1,420 @@
+package org.monogram.data.mapper.message
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import org.monogram.domain.models.FactCheckModel
+import org.monogram.domain.models.InlineKeyboardButtonModel
+import org.monogram.domain.models.InlineKeyboardButtonType
+import org.monogram.domain.models.KeyboardButtonModel
+import org.monogram.domain.models.KeyboardButtonType
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageReactionModel
+import org.monogram.domain.models.ReactionSender
+import org.monogram.domain.models.ReplyMarkupModel
+import org.monogram.domain.models.SuggestedPostInfoModel
+import org.monogram.domain.models.SuggestedPostPriceModel
+import org.monogram.domain.models.SuggestedPostStateModel
+
+private val messageUiPayloadJson = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
+
+internal fun ReplyMarkupPayload.encode(): String =
+ messageUiPayloadJson.encodeToString(ReplyMarkupPayload.serializer(), this)
+
+internal fun decodeReplyMarkupPayload(raw: String?): ReplyMarkupPayload? {
+ if (raw.isNullOrBlank()) return null
+ return runCatching {
+ messageUiPayloadJson.decodeFromString(ReplyMarkupPayload.serializer(), raw)
+ }.getOrNull()
+}
+
+internal fun ReactionsPayload.encode(): String =
+ messageUiPayloadJson.encodeToString(ReactionsPayload.serializer(), this)
+
+internal fun decodeReactionsPayload(raw: String?): ReactionsPayload? {
+ if (raw.isNullOrBlank()) return null
+ return runCatching {
+ messageUiPayloadJson.decodeFromString(ReactionsPayload.serializer(), raw)
+ }.getOrNull()
+}
+
+internal fun FactCheckPayload.encode(): String =
+ messageUiPayloadJson.encodeToString(FactCheckPayload.serializer(), this)
+
+internal fun decodeFactCheckPayload(raw: String?): FactCheckPayload? {
+ if (raw.isNullOrBlank()) return null
+ return runCatching {
+ messageUiPayloadJson.decodeFromString(FactCheckPayload.serializer(), raw)
+ }.getOrNull()
+}
+
+internal fun SuggestedPostInfoPayload.encode(): String =
+ messageUiPayloadJson.encodeToString(SuggestedPostInfoPayload.serializer(), this)
+
+internal fun decodeSuggestedPostInfoPayload(raw: String?): SuggestedPostInfoPayload? {
+ if (raw.isNullOrBlank()) return null
+ return runCatching {
+ messageUiPayloadJson.decodeFromString(SuggestedPostInfoPayload.serializer(), raw)
+ }.getOrNull()
+}
+
+@Serializable
+internal sealed interface ReplyMarkupPayload {
+ @Serializable
+ data class InlineKeyboard(
+ val rows: List> = emptyList()
+ ) : ReplyMarkupPayload
+
+ @Serializable
+ data class ShowKeyboard(
+ val rows: List> = emptyList(),
+ val isPersistent: Boolean = false,
+ val resizeKeyboard: Boolean = false,
+ val oneTime: Boolean = false,
+ val isPersonal: Boolean = false,
+ val inputFieldPlaceholder: String = ""
+ ) : ReplyMarkupPayload
+
+ @Serializable
+ data class RemoveKeyboard(val isPersonal: Boolean = false) : ReplyMarkupPayload
+
+ @Serializable
+ data class ForceReply(
+ val isPersonal: Boolean = false,
+ val inputFieldPlaceholder: String = ""
+ ) : ReplyMarkupPayload
+}
+
+@Serializable
+internal data class InlineKeyboardButtonPayload(
+ val text: String,
+ val type: InlineKeyboardButtonTypePayload
+)
+
+@Serializable
+internal sealed interface InlineKeyboardButtonTypePayload {
+ @Serializable
+ data class Url(val url: String) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class Callback(val data: ByteArray) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class WebApp(val url: String) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class LoginUrl(val url: String, val id: Long) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class SwitchInline(val query: String) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class Buy(val slug: String? = null) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data class User(val userId: Long) : InlineKeyboardButtonTypePayload
+
+ @Serializable
+ data object Unsupported : InlineKeyboardButtonTypePayload
+}
+
+@Serializable
+internal data class KeyboardButtonPayload(
+ val text: String,
+ val type: KeyboardButtonTypePayload
+)
+
+@Serializable
+internal sealed interface KeyboardButtonTypePayload {
+ @Serializable
+ data object Text : KeyboardButtonTypePayload
+
+ @Serializable
+ data object RequestPhoneNumber : KeyboardButtonTypePayload
+
+ @Serializable
+ data object RequestLocation : KeyboardButtonTypePayload
+
+ @Serializable
+ data class RequestPoll(
+ val forceQuiz: Boolean = false,
+ val forceRegular: Boolean = false
+ ) : KeyboardButtonTypePayload
+
+ @Serializable
+ data class WebApp(val url: String) : KeyboardButtonTypePayload
+
+ @Serializable
+ data class RequestUsers(val id: Int) : KeyboardButtonTypePayload
+
+ @Serializable
+ data class RequestChat(val id: Int) : KeyboardButtonTypePayload
+
+ @Serializable
+ data object Unsupported : KeyboardButtonTypePayload
+}
+
+@Serializable
+internal data class ReactionsPayload(
+ val reactions: List = emptyList()
+)
+
+@Serializable
+internal data class MessageReactionPayload(
+ val emoji: String? = null,
+ val customEmojiId: Long? = null,
+ val customEmojiPath: String? = null,
+ val count: Int,
+ val isChosen: Boolean,
+ val recentSenders: List = emptyList()
+)
+
+@Serializable
+internal data class ReactionSenderPayload(
+ val id: Long,
+ val name: String = "",
+ val avatar: String? = null
+)
+
+@Serializable
+internal data class FactCheckPayload(
+ val text: String,
+ val entities: List = emptyList(),
+ val countryCode: String? = null
+)
+
+@Serializable
+internal data class SuggestedPostInfoPayload(
+ val price: SuggestedPostPricePayload? = null,
+ val sendDate: Int = 0,
+ val state: SuggestedPostStatePayload = SuggestedPostStatePayload.PENDING,
+ val canBeApproved: Boolean = false,
+ val canBeDeclined: Boolean = false
+)
+
+@Serializable
+internal sealed interface SuggestedPostPricePayload {
+ @Serializable
+ data class Star(val starCount: Long) : SuggestedPostPricePayload
+
+ @Serializable
+ data class Ton(val toncoinCentCount: Long) : SuggestedPostPricePayload
+}
+
+@Serializable
+internal enum class SuggestedPostStatePayload {
+ PENDING,
+ APPROVED,
+ DECLINED
+}
+
+internal fun ReplyMarkupModel.toPayload(): ReplyMarkupPayload = when (this) {
+ is ReplyMarkupModel.InlineKeyboard -> ReplyMarkupPayload.InlineKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ InlineKeyboardButtonPayload(
+ text = button.text,
+ type = button.type.toPayload()
+ )
+ }
+ }
+ )
+
+ is ReplyMarkupModel.ShowKeyboard -> ReplyMarkupPayload.ShowKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ KeyboardButtonPayload(
+ text = button.text,
+ type = button.type.toPayload()
+ )
+ }
+ },
+ isPersistent = isPersistent,
+ resizeKeyboard = resizeKeyboard,
+ oneTime = oneTime,
+ isPersonal = isPersonal,
+ inputFieldPlaceholder = inputFieldPlaceholder
+ )
+
+ is ReplyMarkupModel.RemoveKeyboard -> ReplyMarkupPayload.RemoveKeyboard(isPersonal)
+ is ReplyMarkupModel.ForceReply -> ReplyMarkupPayload.ForceReply(
+ isPersonal,
+ inputFieldPlaceholder
+ )
+}
+
+internal fun ReplyMarkupPayload.toDomain(): ReplyMarkupModel = when (this) {
+ is ReplyMarkupPayload.InlineKeyboard -> ReplyMarkupModel.InlineKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ InlineKeyboardButtonModel(
+ text = button.text,
+ type = button.type.toDomain()
+ )
+ }
+ }
+ )
+
+ is ReplyMarkupPayload.ShowKeyboard -> ReplyMarkupModel.ShowKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ KeyboardButtonModel(
+ text = button.text,
+ type = button.type.toDomain()
+ )
+ }
+ },
+ isPersistent = isPersistent,
+ resizeKeyboard = resizeKeyboard,
+ oneTime = oneTime,
+ isPersonal = isPersonal,
+ inputFieldPlaceholder = inputFieldPlaceholder
+ )
+
+ is ReplyMarkupPayload.RemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(isPersonal)
+ is ReplyMarkupPayload.ForceReply -> ReplyMarkupModel.ForceReply(
+ isPersonal,
+ inputFieldPlaceholder
+ )
+}
+
+internal fun InlineKeyboardButtonType.toPayload(): InlineKeyboardButtonTypePayload = when (this) {
+ is InlineKeyboardButtonType.Url -> InlineKeyboardButtonTypePayload.Url(url)
+ is InlineKeyboardButtonType.Callback -> InlineKeyboardButtonTypePayload.Callback(data)
+ is InlineKeyboardButtonType.WebApp -> InlineKeyboardButtonTypePayload.WebApp(url)
+ is InlineKeyboardButtonType.LoginUrl -> InlineKeyboardButtonTypePayload.LoginUrl(url, id)
+ is InlineKeyboardButtonType.SwitchInline -> InlineKeyboardButtonTypePayload.SwitchInline(query)
+ is InlineKeyboardButtonType.Buy -> InlineKeyboardButtonTypePayload.Buy(slug)
+ is InlineKeyboardButtonType.User -> InlineKeyboardButtonTypePayload.User(userId)
+ InlineKeyboardButtonType.Unsupported -> InlineKeyboardButtonTypePayload.Unsupported
+}
+
+internal fun InlineKeyboardButtonTypePayload.toDomain(): InlineKeyboardButtonType = when (this) {
+ is InlineKeyboardButtonTypePayload.Url -> InlineKeyboardButtonType.Url(url)
+ is InlineKeyboardButtonTypePayload.Callback -> InlineKeyboardButtonType.Callback(data)
+ is InlineKeyboardButtonTypePayload.WebApp -> InlineKeyboardButtonType.WebApp(url)
+ is InlineKeyboardButtonTypePayload.LoginUrl -> InlineKeyboardButtonType.LoginUrl(url, id)
+ is InlineKeyboardButtonTypePayload.SwitchInline -> InlineKeyboardButtonType.SwitchInline(query)
+ is InlineKeyboardButtonTypePayload.Buy -> InlineKeyboardButtonType.Buy(slug)
+ is InlineKeyboardButtonTypePayload.User -> InlineKeyboardButtonType.User(userId)
+ InlineKeyboardButtonTypePayload.Unsupported -> InlineKeyboardButtonType.Unsupported
+}
+
+internal fun KeyboardButtonType.toPayload(): KeyboardButtonTypePayload = when (this) {
+ KeyboardButtonType.Text -> KeyboardButtonTypePayload.Text
+ KeyboardButtonType.RequestPhoneNumber -> KeyboardButtonTypePayload.RequestPhoneNumber
+ KeyboardButtonType.RequestLocation -> KeyboardButtonTypePayload.RequestLocation
+ is KeyboardButtonType.RequestPoll -> KeyboardButtonTypePayload.RequestPoll(
+ forceQuiz,
+ forceRegular
+ )
+
+ is KeyboardButtonType.WebApp -> KeyboardButtonTypePayload.WebApp(url)
+ is KeyboardButtonType.RequestUsers -> KeyboardButtonTypePayload.RequestUsers(id)
+ is KeyboardButtonType.RequestChat -> KeyboardButtonTypePayload.RequestChat(id)
+ KeyboardButtonType.Unsupported -> KeyboardButtonTypePayload.Unsupported
+}
+
+internal fun KeyboardButtonTypePayload.toDomain(): KeyboardButtonType = when (this) {
+ KeyboardButtonTypePayload.Text -> KeyboardButtonType.Text
+ KeyboardButtonTypePayload.RequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber
+ KeyboardButtonTypePayload.RequestLocation -> KeyboardButtonType.RequestLocation
+ is KeyboardButtonTypePayload.RequestPoll -> KeyboardButtonType.RequestPoll(
+ forceQuiz,
+ forceRegular
+ )
+
+ is KeyboardButtonTypePayload.WebApp -> KeyboardButtonType.WebApp(url)
+ is KeyboardButtonTypePayload.RequestUsers -> KeyboardButtonType.RequestUsers(id)
+ is KeyboardButtonTypePayload.RequestChat -> KeyboardButtonType.RequestChat(id)
+ KeyboardButtonTypePayload.Unsupported -> KeyboardButtonType.Unsupported
+}
+
+internal fun List.toPayload(): ReactionsPayload = ReactionsPayload(
+ reactions = map { reaction ->
+ MessageReactionPayload(
+ emoji = reaction.emoji,
+ customEmojiId = reaction.customEmojiId,
+ customEmojiPath = reaction.customEmojiPath,
+ count = reaction.count,
+ isChosen = reaction.isChosen,
+ recentSenders = reaction.recentSenders.map { sender ->
+ ReactionSenderPayload(
+ id = sender.id,
+ name = sender.name,
+ avatar = sender.avatar
+ )
+ }
+ )
+ }
+)
+
+internal fun ReactionsPayload.toDomain(): List = reactions.map { reaction ->
+ MessageReactionModel(
+ emoji = reaction.emoji,
+ customEmojiId = reaction.customEmojiId,
+ customEmojiPath = reaction.customEmojiPath,
+ count = reaction.count,
+ isChosen = reaction.isChosen,
+ recentSenders = reaction.recentSenders.map { sender ->
+ ReactionSender(
+ id = sender.id,
+ name = sender.name,
+ avatar = sender.avatar
+ )
+ }
+ )
+}
+
+internal fun FactCheckModel.toPayload(): FactCheckPayload = FactCheckPayload(
+ text = text,
+ entities = entities,
+ countryCode = countryCode
+)
+
+internal fun FactCheckPayload.toDomain(): FactCheckModel = FactCheckModel(
+ text = text,
+ entities = entities,
+ countryCode = countryCode
+)
+
+internal fun SuggestedPostInfoModel.toPayload(): SuggestedPostInfoPayload =
+ SuggestedPostInfoPayload(
+ price = price?.toPayload(),
+ sendDate = sendDate,
+ state = state.toPayload(),
+ canBeApproved = canBeApproved,
+ canBeDeclined = canBeDeclined
+ )
+
+internal fun SuggestedPostInfoPayload.toDomain(): SuggestedPostInfoModel = SuggestedPostInfoModel(
+ price = price?.toDomain(),
+ sendDate = sendDate,
+ state = state.toDomain(),
+ canBeApproved = canBeApproved,
+ canBeDeclined = canBeDeclined
+)
+
+internal fun SuggestedPostPriceModel.toPayload(): SuggestedPostPricePayload = when (this) {
+ is SuggestedPostPriceModel.Star -> SuggestedPostPricePayload.Star(starCount)
+ is SuggestedPostPriceModel.Ton -> SuggestedPostPricePayload.Ton(toncoinCentCount)
+}
+
+internal fun SuggestedPostPricePayload.toDomain(): SuggestedPostPriceModel = when (this) {
+ is SuggestedPostPricePayload.Star -> SuggestedPostPriceModel.Star(starCount)
+ is SuggestedPostPricePayload.Ton -> SuggestedPostPriceModel.Ton(toncoinCentCount)
+}
+
+internal fun SuggestedPostStateModel.toPayload(): SuggestedPostStatePayload = when (this) {
+ SuggestedPostStateModel.PENDING -> SuggestedPostStatePayload.PENDING
+ SuggestedPostStateModel.APPROVED -> SuggestedPostStatePayload.APPROVED
+ SuggestedPostStateModel.DECLINED -> SuggestedPostStatePayload.DECLINED
+}
+
+internal fun SuggestedPostStatePayload.toDomain(): SuggestedPostStateModel = when (this) {
+ SuggestedPostStatePayload.PENDING -> SuggestedPostStateModel.PENDING
+ SuggestedPostStatePayload.APPROVED -> SuggestedPostStateModel.APPROVED
+ SuggestedPostStatePayload.DECLINED -> SuggestedPostStateModel.DECLINED
+}
diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt
index 5a302898..c7608394 100644
--- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt
@@ -700,26 +700,22 @@ class ChatsListRepositoryImpl(
return searchManager.searchMessages(query, offset, limit)
}
- override fun toggleMuteChats(chatIds: Set, mute: Boolean) {
+ override suspend fun toggleMuteChats(chatIds: Set, mute: Boolean) {
val muteFor = if (mute) Int.MAX_VALUE else 0
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.muteChat(chatId, muteFor)
- refreshChat(chatId)
- }
+ chatRemoteSource.muteChat(chatId, muteFor)
+ refreshChat(chatId)
}
}
- override fun toggleArchiveChats(chatIds: Set, archive: Boolean) {
+ override suspend fun toggleArchiveChats(chatIds: Set, archive: Boolean) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.archiveChat(chatId, archive)
- refreshChat(chatId)
- }
+ chatRemoteSource.archiveChat(chatId, archive)
+ refreshChat(chatId)
}
}
- override fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) {
+ override suspend fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) {
val chatList: TdApi.ChatList = when (folderId) {
-1 -> TdApi.ChatListMain()
-2 -> TdApi.ChatListArchive()
@@ -727,23 +723,19 @@ class ChatsListRepositoryImpl(
}
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.toggleChatIsPinned(chatList, chatId, pin)
- refreshChat(chatId)
- }
+ chatRemoteSource.toggleChatIsPinned(chatList, chatId, pin)
+ refreshChat(chatId)
}
}
- override fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) {
+ override suspend fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- if (markAsUnread) {
- chatRemoteSource.toggleChatIsMarkedAsUnread(chatId, true)
- } else {
- chatRemoteSource.markChatAsRead(chatId)
- }
- refreshChat(chatId)
+ if (markAsUnread) {
+ chatRemoteSource.toggleChatIsMarkedAsUnread(chatId, true)
+ } else {
+ chatRemoteSource.markChatAsRead(chatId)
}
+ refreshChat(chatId)
}
}
@@ -760,31 +752,25 @@ class ChatsListRepositoryImpl(
markChatsAsRead(chatIds)
}
- override fun deleteChats(chatIds: Set) {
+ override suspend fun deleteChats(chatIds: Set) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.deleteChat(chatId)
- triggerUpdate(chatId)
- }
+ chatRemoteSource.deleteChat(chatId)
+ triggerUpdate(chatId)
}
}
- override fun leaveChats(chatIds: Set) {
+ override suspend fun leaveChats(chatIds: Set) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.leaveChat(chatId)
- refreshChat(chatId)
- }
- }
- }
-
- override fun leaveChat(chatId: Long) {
- scope.launch(dispatchers.io) {
chatRemoteSource.leaveChat(chatId)
refreshChat(chatId)
}
}
+ override suspend fun leaveChat(chatId: Long) {
+ chatRemoteSource.leaveChat(chatId)
+ refreshChat(chatId)
+ }
+
override fun setArchivePinned(pinned: Boolean) {
appPreferences.setArchivePinned(pinned)
}
@@ -834,19 +820,15 @@ class ChatsListRepositoryImpl(
refreshChat(chatId)
}
- override fun clearChatHistory(chatId: Long, revoke: Boolean) {
- scope.launch(dispatchers.io) {
- chatRemoteSource.clearChatHistory(chatId, revoke)
- refreshChat(chatId)
- }
+ override suspend fun clearChatHistory(chatId: Long, revoke: Boolean) {
+ chatRemoteSource.clearChatHistory(chatId, revoke)
+ refreshChat(chatId)
}
- override fun clearChatHistories(chatIds: Set, revoke: Boolean) {
+ override suspend fun clearChatHistories(chatIds: Set, revoke: Boolean) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.clearChatHistory(chatId, revoke)
- refreshChat(chatId)
- }
+ chatRemoteSource.clearChatHistory(chatId, revoke)
+ refreshChat(chatId)
}
}
@@ -854,17 +836,13 @@ class ChatsListRepositoryImpl(
return chatRemoteSource.getChatLink(chatId)
}
- override fun reportChat(chatId: Long, reason: String, messageIds: List) {
- scope.launch(dispatchers.io) {
- chatRemoteSource.reportChat(chatId, reason, messageIds)
- }
+ override suspend fun reportChat(chatId: Long, reason: String, messageIds: List) {
+ chatRemoteSource.reportChat(chatId, reason, messageIds)
}
- override fun reportChats(chatIds: Set, reason: String, messageIds: List) {
+ override suspend fun reportChats(chatIds: Set, reason: String, messageIds: List) {
chatIds.forEach { chatId ->
- scope.launch(dispatchers.io) {
- chatRemoteSource.reportChat(chatId, reason, messageIds)
- }
+ chatRemoteSource.reportChat(chatId, reason, messageIds)
}
}
@@ -913,6 +891,22 @@ class ChatsListRepositoryImpl(
chatRemoteSource.setChatPermissions(chatId, permissions)
}
+ override suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) {
+ chatRemoteSource.setChatHasProtectedContent(chatId, hasProtectedContent)
+ }
+
+ override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) {
+ chatRemoteSource.setChatSignMessages(chatId, signMessages)
+ }
+
+ override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) {
+ chatRemoteSource.setChatJoinToSendMessages(chatId, joinToSendMessages)
+ }
+
+ override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) {
+ chatRemoteSource.setChatAvailableReactions(chatId, availableReactions)
+ }
+
override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) {
chatRemoteSource.setChatSlowModeDelay(chatId, slowModeDelay)
}
@@ -921,10 +915,6 @@ class ChatsListRepositoryImpl(
chatRemoteSource.toggleChatIsForum(chatId, isForum)
}
- override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) {
- chatRemoteSource.toggleChatIsTranslatable(chatId, isTranslatable)
- }
-
override fun getDatabaseSize(): Long {
return if (databaseFile.exists()) databaseFile.length() else 0L
}
diff --git a/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt
new file mode 100644
index 00000000..a819d76d
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt
@@ -0,0 +1,57 @@
+package org.monogram.data.repository
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.datasource.remote.UserRemoteDataSource
+import org.monogram.domain.models.UserModel
+import org.monogram.domain.repository.ContactEditRepository
+import org.monogram.domain.repository.UserRepository
+
+class ContactEditRepositoryImpl(
+ private val userRepository: UserRepository,
+ private val userRemoteDataSource: UserRemoteDataSource
+) : ContactEditRepository {
+
+ override suspend fun getContact(userId: Long): UserModel? = userRepository.getUser(userId)
+
+ override suspend fun getNeedPhoneNumberPrivacyException(userId: Long): Boolean {
+ return userRepository.resolveUserChatFullInfo(userId)?.needPhoneNumberPrivacyException == true
+ }
+
+ override suspend fun upsertContact(
+ user: UserModel,
+ sharePhoneNumber: Boolean
+ ): UserModel? {
+ userRemoteDataSource.addContact(
+ userId = user.id,
+ contact = user.toImportedContact(),
+ sharePhoneNumber = sharePhoneNumber
+ )
+ userRepository.refreshUserFullInfo(user.id)
+ return userRepository.getUser(user.id)
+ }
+
+ override suspend fun removeContact(userId: Long) {
+ userRemoteDataSource.removeContacts(longArrayOf(userId))
+ }
+
+ override suspend fun setCloseFriend(
+ userId: Long,
+ isCloseFriend: Boolean
+ ): UserModel? {
+ val closeFriends = userRemoteDataSource.getCloseFriendIds().toMutableSet()
+ if (isCloseFriend) {
+ closeFriends += userId
+ } else {
+ closeFriends -= userId
+ }
+ userRemoteDataSource.setCloseFriendIds(closeFriends.toLongArray())
+ return userRepository.getUser(userId)
+ }
+}
+
+private fun UserModel.toImportedContact() = TdApi.ImportedContact(
+ phoneNumber.orEmpty(),
+ firstName,
+ lastName.orEmpty(),
+ null
+)
diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt
index c862d2a1..584241af 100644
--- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt
@@ -1,6 +1,7 @@
package org.monogram.data.repository
import android.content.Context
+import android.os.Trace
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,6 +17,9 @@ import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import org.monogram.core.DispatcherProvider
+import org.monogram.core.perf.ChatOpenPerfBridge
+import org.monogram.core.perf.ChatOpenPerfDebug
+import org.monogram.data.BuildConfig
import org.monogram.data.chats.ChatCache
import org.monogram.data.compat.buildDraftMessageTextContent
import org.monogram.data.compat.buildTdChatPermissions
@@ -33,6 +37,7 @@ import org.monogram.data.db.model.TextCompositionStyleEntity
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.mapper.MessageMapper
+import org.monogram.data.mapper.SponsoredMessageMapper
import org.monogram.data.mapper.TdFileHelper
import org.monogram.data.mapper.map
import org.monogram.data.mapper.toDomain
@@ -54,6 +59,7 @@ import org.monogram.domain.models.MessageSendOptions
import org.monogram.domain.models.MessageSenderModel
import org.monogram.domain.models.MessageViewerModel
import org.monogram.domain.models.PollDraft
+import org.monogram.domain.models.SponsoredMessagesFeedModel
import org.monogram.domain.models.UserModel
import org.monogram.domain.models.webapp.InstantViewModel
import org.monogram.domain.models.webapp.InvoiceModel
@@ -71,8 +77,9 @@ import org.monogram.domain.repository.ProfileMediaFilter
import org.monogram.domain.repository.SearchChatMessagesResult
import org.monogram.domain.repository.TextCompositionStyleModel
import java.io.File
+import java.util.concurrent.ConcurrentHashMap
-class MessageRepositoryImpl(
+internal class MessageRepositoryImpl(
private val context: Context,
private val gateway: TelegramGateway,
private val updates: UpdateDispatcher,
@@ -80,6 +87,7 @@ class MessageRepositoryImpl(
private val messageRemoteDataSource: MessageRemoteDataSource,
private val cache: ChatCache,
private val fileHelper: TdFileHelper,
+ private val sponsoredMessageMapper: SponsoredMessageMapper,
private val fileDataSource: FileDataSource,
private val fxEmbedRemoteDataSource: FxEmbedRemoteDataSource,
private val draftLinkPreviewResolver: DraftLinkPreviewResolver,
@@ -91,11 +99,15 @@ class MessageRepositoryImpl(
private val keyValueDao: KeyValueDao,
private val textCompositionStyleDao: TextCompositionStyleDao
) : MessageRepository {
+ private data class RichMessageCacheKey(val chatId: Long, val messageId: Long)
+
private val fixedDraftLinkPreviewFetcher = FixedDraftLinkPreviewFetcher(
draftLinkPreviewResolver = draftLinkPreviewResolver,
draftLinkPreviewRemoteDataSource = messageRemoteDataSource,
fixedPreviewRemoteDataSource = fxEmbedRemoteDataSource
)
+ private val fullRichMessageCache =
+ ConcurrentHashMap()
private val _textCompositionStyles = MutableStateFlow>(emptyList())
private val hardResetFlagKey = "cache_hard_reset_v2"
@@ -169,6 +181,18 @@ class MessageRepositoryImpl(
}
}
}
+
+ scope.launch(dispatcherProvider.io) {
+ messageEditedFlow.collect { message ->
+ val key = RichMessageCacheKey(chatId = message.chatId, messageId = message.id)
+ val richContent = message.content as? MessageContent.RichMessage
+ when {
+ richContent == null -> fullRichMessageCache.remove(key)
+ richContent.isFull -> fullRichMessageCache[key] = richContent
+ else -> fullRichMessageCache.remove(key)
+ }
+ }
+ }
}
private suspend fun performHardCacheResetIfNeeded() {
@@ -200,8 +224,7 @@ class MessageRepositoryImpl(
private suspend fun processCachedUpdate(update: TdApi.Update) {
when (update) {
is TdApi.UpdateNewMessage -> {
- val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName)
- chatLocalDataSource.insertMessage(entity)
+ persistMappedMessage(update.message)
}
is TdApi.UpdateMessageSendSucceeded -> {
@@ -213,22 +236,19 @@ class MessageRepositoryImpl(
)
}
- is TdApi.UpdateMessageContent -> {
- val extracted = messageMapper.extractCachedContent(update.newContent)
+ is TdApi.UpdateMessageSendFailed -> {
+ val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName)
+ chatLocalDataSource.replaceMessageId(
+ chatId = update.message.chatId,
+ oldMessageId = update.oldMessageId,
+ message = entity
+ )
+ }
- if (update.newContent is TdApi.MessagePhoto && extracted.text.isBlank()) {
- val refreshed = messageRemoteDataSource.getMessage(update.chatId, update.messageId)
- if (refreshed != null) {
- chatLocalDataSource.insertMessage(
- messageMapper.mapToEntity(
- refreshed,
- ::resolveSenderName
- )
- )
- return
- }
- }
+ is TdApi.UpdateMessageContent -> {
+ if (refreshPersistedMessage(update.chatId, update.messageId)) return
+ val extracted = messageMapper.extractCachedContent(update.newContent)
chatLocalDataSource.updateMessageContent(
chatId = update.chatId,
messageId = update.messageId,
@@ -242,18 +262,11 @@ class MessageRepositoryImpl(
}
is TdApi.UpdateMessageEdited -> {
- val updated = messageRemoteDataSource.getMessage(update.chatId, update.messageId)
- if (updated != null) {
- chatLocalDataSource.insertMessage(
- messageMapper.mapToEntity(
- updated,
- ::resolveSenderName
- )
- )
- }
+ refreshPersistedMessage(update.chatId, update.messageId)
}
is TdApi.UpdateMessageInteractionInfo -> {
+ if (refreshPersistedMessage(update.chatId, update.messageId)) return
chatLocalDataSource.updateInteractionInfo(
chatId = update.chatId,
messageId = update.messageId,
@@ -263,6 +276,42 @@ class MessageRepositoryImpl(
)
}
+ is TdApi.UpdateMessageReaction,
+ is TdApi.UpdateMessageReactions,
+ is TdApi.UpdateMessageUnreadReactions,
+ is TdApi.UpdateMessageFactCheck,
+ is TdApi.UpdateMessageSuggestedPostInfo,
+ is TdApi.UpdateMessageIsPinned,
+ is TdApi.UpdateMessageContainsUnreadPollVotes,
+ is TdApi.UpdateMessageLiveLocationViewed,
+ is TdApi.UpdateMessageContentOpened -> {
+ val chatId = when (update) {
+ is TdApi.UpdateMessageReaction -> update.chatId
+ is TdApi.UpdateMessageReactions -> update.chatId
+ is TdApi.UpdateMessageUnreadReactions -> update.chatId
+ is TdApi.UpdateMessageFactCheck -> update.chatId
+ is TdApi.UpdateMessageSuggestedPostInfo -> update.chatId
+ is TdApi.UpdateMessageIsPinned -> update.chatId
+ is TdApi.UpdateMessageContainsUnreadPollVotes -> update.chatId
+ is TdApi.UpdateMessageLiveLocationViewed -> update.chatId
+ is TdApi.UpdateMessageContentOpened -> update.chatId
+ else -> 0L
+ }
+ val messageId = when (update) {
+ is TdApi.UpdateMessageReaction -> update.messageId
+ is TdApi.UpdateMessageReactions -> update.messageId
+ is TdApi.UpdateMessageUnreadReactions -> update.messageId
+ is TdApi.UpdateMessageFactCheck -> update.messageId
+ is TdApi.UpdateMessageSuggestedPostInfo -> update.messageId
+ is TdApi.UpdateMessageIsPinned -> update.messageId
+ is TdApi.UpdateMessageContainsUnreadPollVotes -> update.messageId
+ is TdApi.UpdateMessageLiveLocationViewed -> update.messageId
+ is TdApi.UpdateMessageContentOpened -> update.messageId
+ else -> 0L
+ }
+ refreshPersistedMessage(chatId, messageId)
+ }
+
is TdApi.UpdateChatReadInbox -> {
chatLocalDataSource.markAsRead(update.chatId, update.lastReadInboxMessageId)
}
@@ -289,6 +338,18 @@ class MessageRepositoryImpl(
}
}
+ private suspend fun persistMappedMessage(message: TdApi.Message) {
+ chatLocalDataSource.replaceMessage(
+ messageMapper.mapToEntity(message, ::resolveSenderName)
+ )
+ }
+
+ private suspend fun refreshPersistedMessage(chatId: Long, messageId: Long): Boolean {
+ val refreshed = messageRemoteDataSource.getMessage(chatId, messageId) ?: return false
+ persistMappedMessage(refreshed)
+ return true
+ }
+
override suspend fun openChat(chatId: Long) {
messageRemoteDataSource.setChatOpened(chatId)
try {
@@ -615,21 +676,33 @@ class MessageRepositoryImpl(
): OlderMessagesPage =
withContext(dispatcherProvider.io) {
val cached = if (fromMessageId == 0L) {
- val cachedEntities = chatLocalDataSource.getLatestMessages(chatId, limit)
+ val cachedEntities = chatLocalDataSource.getLatestMessages(chatId, limit, threadId)
mapLocalMessages(cachedEntities)
} else {
emptyList()
}
try {
- val remotePage = messageRemoteDataSource.getMessagesOlder(chatId, fromMessageId, limit, threadId)
- persistRemoteMessages(chatId, remotePage.messages)
- remotePage
+ traceSection("chat_history_tdlib") {
+ val remotePage = messageRemoteDataSource.getRemoteMessagesOlder(
+ chatId,
+ fromMessageId,
+ limit,
+ threadId
+ )
+ persistRemoteBatch(chatId, remotePage.rawMessages, remotePage.models, threadId)
+ OlderMessagesPage(
+ messages = remotePage.models,
+ reachedOldest = remotePage.reachedOldest,
+ isRemote = remotePage.isRemote
+ )
+ }
} catch (e: Exception) {
val fallbackMessages = if (cached.isNotEmpty()) {
cached
} else {
- val local = chatLocalDataSource.getMessagesOlder(chatId, fromMessageId, limit)
+ val local =
+ chatLocalDataSource.getMessagesOlder(chatId, fromMessageId, limit, threadId)
mapLocalMessages(local)
}
OlderMessagesPage(
@@ -640,9 +713,13 @@ class MessageRepositoryImpl(
}
}
- override suspend fun getCachedMessages(chatId: Long, limit: Int): List =
+ override suspend fun getCachedMessages(
+ chatId: Long,
+ limit: Int,
+ threadId: Long?
+ ): List =
withContext(dispatcherProvider.io) {
- val local = chatLocalDataSource.getLatestMessages(chatId, limit)
+ val local = chatLocalDataSource.getLatestMessages(chatId, limit, threadId)
mapLocalMessages(local)
}
@@ -654,11 +731,23 @@ class MessageRepositoryImpl(
): List =
withContext(dispatcherProvider.io) {
try {
- val remoteMessages = messageRemoteDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId)
- persistRemoteMessages(chatId, remoteMessages)
- remoteMessages
+ traceSection("chat_history_tdlib") {
+ val remoteBatch = messageRemoteDataSource.getRemoteMessagesNewer(
+ chatId,
+ fromMessageId,
+ limit,
+ threadId
+ )
+ persistRemoteBatch(
+ chatId,
+ remoteBatch.rawMessages,
+ remoteBatch.models,
+ threadId
+ )
+ remoteBatch.models
+ }
} catch (e: Exception) {
- chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit)
+ chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId)
.let { mapLocalMessages(it) }
}
}
@@ -666,20 +755,22 @@ class MessageRepositoryImpl(
override suspend fun getCachedMessagesNewer(
chatId: Long,
fromMessageId: Long,
- limit: Int
+ limit: Int,
+ threadId: Long?
): List =
withContext(dispatcherProvider.io) {
- chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit)
+ chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId)
.let { mapLocalMessages(it) }
}
override suspend fun getCachedMessagesAround(
chatId: Long,
messageId: Long,
- limit: Int
+ limit: Int,
+ threadId: Long?
): List =
withContext(dispatcherProvider.io) {
- chatLocalDataSource.getMessagesAround(chatId, messageId, limit)
+ chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId)
.let { mapLocalMessages(it) }
}
@@ -691,11 +782,24 @@ class MessageRepositoryImpl(
): List =
withContext(dispatcherProvider.io) {
try {
- val remoteMessages = messageRemoteDataSource.getMessagesAround(chatId, messageId, limit, threadId)
- persistRemoteMessages(chatId, remoteMessages)
- remoteMessages
+ traceSection("chat_history_tdlib") {
+ val remoteBatch = messageRemoteDataSource.getRemoteMessagesAround(
+ chatId,
+ messageId,
+ limit,
+ threadId
+ )
+ persistRemoteBatch(
+ chatId,
+ remoteBatch.rawMessages,
+ remoteBatch.models,
+ threadId
+ )
+ remoteBatch.models
+ }
} catch (e: Exception) {
- val local = chatLocalDataSource.getMessagesAround(chatId, messageId, limit)
+ val local =
+ chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId)
mapLocalMessages(local)
}
}
@@ -996,8 +1100,14 @@ class MessageRepositoryImpl(
chatId: Long,
messageId: Long
): MessageContent.RichMessage? {
- return messageRemoteDataSource.getFullRichMessage(chatId, messageId)
+ val key = RichMessageCacheKey(chatId = chatId, messageId = messageId)
+ fullRichMessageCache[key]?.let { return it }
+ val resolved = messageRemoteDataSource.getFullRichMessage(chatId, messageId)
?.toDomainRichMessage(chatId = chatId, messageId = messageId)
+ if (resolved != null) {
+ fullRichMessageCache[key] = resolved
+ }
+ return resolved
}
override suspend fun getDraftLinkPreview(request: DraftLinkPreviewRequest): DraftLinkPreview? {
@@ -1039,6 +1149,35 @@ class MessageRepositoryImpl(
return preview
}
+ override suspend fun getChannelSponsoredMessages(chatId: Long): SponsoredMessagesFeedModel? {
+ return withContext(dispatcherProvider.io) {
+ val result = messageRemoteDataSource.getChatSponsoredMessages(chatId)
+ if (result == null) return@withContext null
+ val mapped = result.messages.map { sponsoredMessageMapper.map(chatId, it) }
+ if (mapped.isEmpty()) return@withContext null
+ SponsoredMessagesFeedModel(
+ messages = mapped,
+ messagesBetween = result.messagesBetween
+ )
+ }
+ }
+
+ override suspend fun clickChannelSponsoredMessage(
+ chatId: Long,
+ messageId: Long,
+ isMediaClick: Boolean,
+ fromFullscreen: Boolean
+ ) {
+ withContext(dispatcherProvider.io) {
+ messageRemoteDataSource.clickChatSponsoredMessage(
+ chatId = chatId,
+ messageId = messageId,
+ isMediaClick = isMediaClick,
+ fromFullscreen = fromFullscreen
+ )
+ }
+ }
+
override suspend fun searchMessages(
chatId: Long,
query: String,
@@ -1742,26 +1881,54 @@ class MessageRepositoryImpl(
}
}
- private fun persistRemoteMessages(chatId: Long, remoteMessages: List) {
+ private fun persistRemoteBatch(
+ chatId: Long,
+ rawMessages: List,
+ remoteMessages: List,
+ threadId: Long?
+ ) {
scope.launch(dispatcherProvider.io) {
+ if (remoteMessages.isEmpty() || rawMessages.isEmpty()) return@launch
val existingById = chatLocalDataSource
.getMessagesByIds(chatId, remoteMessages.map { it.id })
.associateBy { it.id }
- val entities = remoteMessages.mapNotNull { model ->
- val existing = existingById[model.id]
+ var skippedCount = 0
+ val entities = rawMessages.zip(remoteMessages).mapNotNull { (rawMessage, model) ->
+ val existing = existingById[rawMessage.id]
if (existing != null && existing.isSamePersistedMessage(model)) {
+ skippedCount += 1
return@mapNotNull null
}
- messageRemoteDataSource.getMessage(model.chatId, model.id)?.let { message ->
- messageMapper.mapToEntity(message, ::resolveSenderName)
- }
+ messageMapper.mapToEntity(rawMessage, ::resolveSenderName)
}
if (entities.isNotEmpty()) {
- chatLocalDataSource.insertMessages(entities)
+ traceSection("chat_history_persist") {
+ chatLocalDataSource.insertMessages(entities)
+ }
+ }
+ ChatOpenPerfBridge.recordPersist(chatId, threadId, entities.size, skippedCount)
+ if (BuildConfig.DEBUG) {
+ Log.d(
+ ChatOpenPerfDebug.TAG,
+ ChatOpenPerfDebug.buildLogMessage(
+ chatId = chatId,
+ threadId = threadId,
+ event = "chat_history_persist_end"
+ )
+ )
}
}
}
+ private inline fun traceSection(section: String, block: () -> T): T {
+ Trace.beginSection(section)
+ return try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
+ }
+
private fun org.monogram.data.db.model.MessageEntity.isSamePersistedMessage(model: MessageModel): Boolean {
return id == model.id &&
chatId == model.chatId &&
diff --git a/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt
new file mode 100644
index 00000000..8ea803be
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt
@@ -0,0 +1,34 @@
+package org.monogram.data.repository
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import org.monogram.data.db.dao.KeyValueDao
+import org.monogram.data.db.model.KeyValueEntity
+import org.monogram.domain.repository.PinnedMessageVisibilityRepository
+
+class PinnedMessageVisibilityRepositoryImpl(
+ private val keyValueDao: KeyValueDao
+) : PinnedMessageVisibilityRepository {
+ override fun observeHidden(chatId: Long): Flow =
+ keyValueDao.observeValue(key(chatId))
+ .map { it != null }
+ .distinctUntilChanged()
+
+ override suspend fun isHidden(chatId: Long): Boolean =
+ keyValueDao.getValue(key(chatId)) != null
+
+ override suspend fun hide(chatId: Long) {
+ keyValueDao.insertValue(KeyValueEntity(key(chatId), "1"))
+ }
+
+ override suspend fun show(chatId: Long) {
+ keyValueDao.deleteValue(key(chatId))
+ }
+
+ private fun key(chatId: Long): String = "$KEY_PREFIX$chatId"
+
+ private companion object {
+ const val KEY_PREFIX = "hidden_pinned_message_"
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt
index 8d8f2583..0fd27277 100644
--- a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt
@@ -27,4 +27,8 @@ class PremiumRepositoryImpl(
val tdType = limitType.toApi() ?: return 0
return remote.getPremiumLimit(tdType)?.premiumValue ?: 0
}
-}
\ No newline at end of file
+
+ override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) {
+ remote.setSponsoredMessagesEnabled(enabled)
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt
index ec18f228..dab375d4 100644
--- a/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt
@@ -6,6 +6,8 @@ import org.monogram.domain.repository.SponsorRepository
class SponsorRepositoryImpl(
private val sponsorSyncManager: SponsorSyncManager
) : SponsorRepository {
+ override val sponsorState = sponsorSyncManager.sponsorState
+
override fun forceSponsorSync() {
sponsorSyncManager.forceSync()
}
diff --git a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt
index 8a973e24..bf83bd70 100644
--- a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt
+++ b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt
@@ -2,6 +2,8 @@ package org.monogram.data.datasource.cache
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
import org.junit.Test
import org.monogram.data.db.model.ChatEntity
import org.monogram.data.db.model.MessageEntity
@@ -77,6 +79,48 @@ class InMemoryChatLocalDataSourceTest {
assertEquals(listOf(30L, 40L), result.map { it.id })
}
+ @Test
+ fun `replaceMessage preserves ordering and replaces rich fields`() = runBlocking {
+ val source = InMemoryChatLocalDataSource()
+ source.insertMessage(
+ message(
+ id = 100L,
+ chatId = 1L,
+ content = "old",
+ date = 100,
+ replyMarkupData = "markup-old",
+ reactionsData = "reactions-old",
+ isPinned = false,
+ hasUnreadReactions = false
+ )
+ )
+ source.insertMessage(message(id = 200L, chatId = 1L, content = "newer", date = 200))
+
+ source.replaceMessage(
+ message(
+ id = 100L,
+ chatId = 1L,
+ content = "updated",
+ date = 100,
+ replyMarkupData = "markup-new",
+ reactionsData = "reactions-new",
+ isPinned = true,
+ hasUnreadReactions = true
+ )
+ )
+
+ val result = source.getLatestMessages(chatId = 1L, limit = 10)
+ val replaced = result.last { it.id == 100L }
+
+ assertEquals(listOf(200L, 100L), result.map { it.id })
+ assertEquals("updated", replaced.content)
+ assertEquals("markup-new", replaced.replyMarkupData)
+ assertEquals("reactions-new", replaced.reactionsData)
+ assertTrue(replaced.isPinned)
+ assertTrue(replaced.hasUnreadReactions)
+ assertFalse(replaced.replyMarkupData == "markup-old")
+ }
+
private fun chat(id: Long, order: Long, isPinned: Boolean): ChatEntity =
ChatEntity(
id = id,
@@ -96,7 +140,16 @@ class InMemoryChatLocalDataSourceTest {
onlineCount = 0
)
- private fun message(id: Long, chatId: Long, content: String, date: Int = 100): MessageEntity =
+ private fun message(
+ id: Long,
+ chatId: Long,
+ content: String,
+ date: Int = 100,
+ replyMarkupData: String? = null,
+ reactionsData: String? = null,
+ isPinned: Boolean = false,
+ hasUnreadReactions: Boolean = false
+ ): MessageEntity =
MessageEntity(
id = id,
chatId = chatId,
@@ -105,6 +158,10 @@ class InMemoryChatLocalDataSourceTest {
content = content,
date = date,
isOutgoing = true,
- isRead = false
+ isRead = false,
+ replyMarkupData = replyMarkupData,
+ reactionsData = reactionsData,
+ isPinned = isPinned,
+ hasUnreadReactions = hasUnreadReactions
)
}
diff --git a/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt
new file mode 100644
index 00000000..14a51302
--- /dev/null
+++ b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt
@@ -0,0 +1,54 @@
+package org.monogram.data.datasource.remote
+
+import android.net.ConnectivityManager
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import org.drinkless.tdlib.TdApi
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.monogram.data.gateway.TelegramGateway
+import sun.misc.Unsafe
+import java.lang.reflect.Field
+
+class TdChatRemoteSourceTest {
+
+ @Test
+ fun `setChatDescription uses chat id directly`() = runBlocking {
+ val gateway = CapturingTelegramGateway()
+ val remote = TdChatRemoteSource(
+ gateway = gateway,
+ connectivityManager = unsafeConnectivityManager()
+ )
+
+ remote.setChatDescription(chatId = 123L, description = "New description")
+
+ assertEquals(123L, gateway.lastSetChatDescription?.chatId)
+ assertEquals("New description", gateway.lastSetChatDescription?.description)
+ }
+
+ private class CapturingTelegramGateway : TelegramGateway {
+ override val updates = MutableSharedFlow()
+ override val isAuthenticated = MutableStateFlow(false)
+ var lastSetChatDescription: TdApi.SetChatDescription? = null
+
+ @Suppress("UNCHECKED_CAST")
+ override suspend fun execute(function: TdApi.Function): T {
+ return when (function) {
+ is TdApi.SetChatDescription -> {
+ lastSetChatDescription = function
+ TdApi.Ok() as T
+ }
+
+ else -> error("Unexpected function: ${function.javaClass.simpleName}")
+ }
+ }
+ }
+
+ private fun unsafeConnectivityManager(): ConnectivityManager {
+ val field: Field = Unsafe::class.java.getDeclaredField("theUnsafe")
+ field.isAccessible = true
+ val unsafe = field.get(null) as Unsafe
+ return unsafe.allocateInstance(ConnectivityManager::class.java) as ConnectivityManager
+ }
+}
diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt
index f71c6f3d..b9210c65 100644
--- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt
+++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt
@@ -394,10 +394,21 @@ class ConnectionManagerTest {
chatId: Long,
permissions: org.monogram.domain.models.ChatPermissionsModel
) = Unit
+ override suspend fun setChatHasProtectedContent(
+ chatId: Long,
+ hasProtectedContent: Boolean
+ ) = Unit
+
+ override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) = Unit
+ override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) =
+ Unit
+ override suspend fun setChatAvailableReactions(
+ chatId: Long,
+ availableReactions: List
+ ) = Unit
override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) = Unit
override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) = Unit
- override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) = Unit
override suspend fun getChatLink(chatId: Long): String? = null
override suspend fun deleteFolder(folderId: Int) = Unit
override suspend fun muteChat(chatId: Long, muteFor: Int) = Unit
@@ -545,6 +556,7 @@ class ConnectionManagerTest {
override val chatListMessageLines = MutableStateFlow(2)
override val showChatListPhotos = MutableStateFlow(true)
override val showReactions = MutableStateFlow(true)
+ override val showSponsoredMessagesForPremium = MutableStateFlow(false)
override val privateChatsNotifications = MutableStateFlow(true)
override val groupsNotifications = MutableStateFlow(true)
override val channelsNotifications = MutableStateFlow(true)
@@ -593,6 +605,10 @@ class ConnectionManagerTest {
isAutoBestProxyEnabled.value = enabled
}
+ override fun setShowSponsoredMessagesForPremium(enabled: Boolean) {
+ showSponsoredMessagesForPremium.value = enabled
+ }
+
override fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) {
proxyNetworkRules.value = proxyNetworkRules.value.toMutableMap().apply {
this[networkType] = (this[networkType] ?: ProxyNetworkRule(mode)).copy(mode = mode)
diff --git a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt
new file mode 100644
index 00000000..6a287135
--- /dev/null
+++ b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt
@@ -0,0 +1,117 @@
+package org.monogram.data.infra
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.drinkless.tdlib.TdApi
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.monogram.core.DispatcherProvider
+import org.monogram.data.chats.ChatCache
+import org.monogram.data.gateway.TelegramGateway
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class FileDownloadQueueTest {
+
+ @Test
+ fun `cancel pending download notifies observer without waiting for update file`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val queue = createQueue(dispatcher, this)
+ val cancelled = mutableListOf()
+ queue.setObserver(object : FileDownloadQueue.Observer {
+ override fun onDownloadCancelled(fileId: Int) {
+ cancelled += fileId
+ }
+ })
+ queue.registry.register(fileId = 42, chatId = 1L, messageId = 2L)
+
+ queue.enqueue(fileId = 42, priority = 1, type = FileDownloadQueue.DownloadType.DEFAULT)
+ runCurrent()
+
+ queue.cancelDownload(fileId = 42, force = true, suppress = false)
+ runCurrent()
+
+ assertEquals(listOf(42), cancelled)
+ assertTrue(!queue.isFileQueued(42))
+ }
+
+ @Test
+ fun `manual enqueue after suppression is accepted`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val queue = createQueue(dispatcher, this)
+
+ queue.cancelDownload(fileId = 73, force = true, suppress = true)
+ runCurrent()
+
+ queue.enqueue(
+ fileId = 73,
+ priority = 32,
+ type = FileDownloadQueue.DownloadType.DEFAULT,
+ ignoreSuppression = true
+ )
+ runCurrent()
+
+ assertTrue(queue.isFileQueued(73))
+ }
+
+ private fun createQueue(
+ dispatcher: CoroutineDispatcher,
+ scope: TestScope
+ ): FileDownloadQueue {
+ return FileDownloadQueue(
+ gateway = FakeTelegramGateway(),
+ registry = FileMessageRegistry(),
+ cache = ChatCache(),
+ scope = scope.backgroundScope,
+ dispatcherProvider = TestDispatcherProvider(dispatcher)
+ )
+ }
+
+ private class TestDispatcherProvider(
+ private val dispatcher: CoroutineDispatcher
+ ) : DispatcherProvider {
+ override val main: CoroutineDispatcher = dispatcher
+ override val io: CoroutineDispatcher = dispatcher
+ override val default: CoroutineDispatcher = dispatcher
+ override val mainImmediate: CoroutineDispatcher = dispatcher
+ }
+
+ private class FakeTelegramGateway : TelegramGateway {
+ override val updates = MutableSharedFlow()
+ override val isAuthenticated = MutableStateFlow(false)
+
+ @Suppress("UNCHECKED_CAST")
+ override suspend fun execute(function: TdApi.Function): T {
+ return when (function) {
+ is TdApi.CancelDownloadFile -> TdApi.Ok() as T
+ is TdApi.GetFile -> TdApi.File().apply {
+ id = function.fileId
+ local = TdApi.LocalFile().apply {
+ path = ""
+ isDownloadingActive = false
+ isDownloadingCompleted = false
+ }
+ remote = TdApi.RemoteFile()
+ } as T
+
+ is TdApi.DownloadFile -> TdApi.File().apply {
+ id = function.fileId
+ local = TdApi.LocalFile().apply {
+ path = ""
+ isDownloadingActive = false
+ isDownloadingCompleted = false
+ }
+ remote = TdApi.RemoteFile()
+ } as T
+
+ else -> error("Unexpected function ${function.javaClass.simpleName}")
+ }
+ }
+ }
+}
diff --git a/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt
new file mode 100644
index 00000000..edfc53e2
--- /dev/null
+++ b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt
@@ -0,0 +1,200 @@
+package org.monogram.data.infra
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.drinkless.tdlib.TdApi
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.monogram.data.db.dao.SponsorDao
+import org.monogram.data.db.model.SponsorEntity
+import org.monogram.data.gateway.TelegramGateway
+import org.monogram.domain.repository.AuthError
+import org.monogram.domain.repository.AuthRepository
+import org.monogram.domain.repository.AuthStep
+import org.monogram.domain.repository.AuthUiStatus
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SponsorSyncManagerTest {
+
+ @Test
+ fun `loads cached sponsor ids into state on start`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val environment = TestEnvironment(
+ scope = TestScope(dispatcher),
+ dispatcher = dispatcher,
+ cachedIds = listOf(11L, 22L)
+ )
+
+ environment.createManager()
+ environment.flush()
+
+ val state = environment.manager.sponsorState.value
+ assertEquals(setOf(11L, 22L), state.supporterIds)
+ assertEquals(2, state.supportersCount)
+ assertTrue(state.isLoaded)
+ assertFalse(state.isSyncInProgress)
+ environment.close()
+ }
+
+ @Test
+ fun `successful empty sync marks state as loaded`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val environment = TestEnvironment(
+ scope = TestScope(dispatcher),
+ dispatcher = dispatcher,
+ cachedIds = emptyList()
+ ).apply {
+ historyResult = TdApi.Messages(0, emptyArray())
+ }
+
+ environment.createManager()
+ environment.flush()
+
+ environment.manager.forceSync()
+ environment.flush()
+
+ val state = environment.manager.sponsorState.value
+ assertTrue(state.isLoaded)
+ assertEquals(0, state.supportersCount)
+ assertTrue(state.supporterIds.isEmpty())
+ assertFalse(state.isSyncInProgress)
+ environment.close()
+ }
+
+ @Test
+ fun `manual sync publishes parsed supporter count`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val environment = TestEnvironment(
+ scope = TestScope(dispatcher),
+ dispatcher = dispatcher,
+ cachedIds = emptyList()
+ ).apply {
+ historyResult = TdApi.Messages(
+ 0,
+ arrayOf(
+ sponsorMessage(3L, "100, 200"),
+ sponsorMessage(2L, "300")
+ )
+ )
+ }
+
+ environment.createManager()
+ environment.flush()
+
+ environment.manager.forceSync()
+ environment.flush()
+
+ val state = environment.manager.sponsorState.value
+ assertTrue(state.isLoaded)
+ assertEquals(3, state.supportersCount)
+ assertEquals(setOf(100L, 200L, 300L), state.supporterIds)
+ assertTrue(state.lastSyncAt > 0L)
+ environment.close()
+ }
+
+ private fun sponsorMessage(id: Long, text: String): TdApi.Message {
+ return TdApi.Message().apply {
+ this.id = id
+ this.chatId = -1003640797855L
+ this.content = TdApi.MessageText(TdApi.FormattedText(text, emptyArray()), null, null)
+ }
+ }
+
+ private class TestEnvironment(
+ val scope: TestScope,
+ private val dispatcher: CoroutineDispatcher,
+ cachedIds: List
+ ) {
+ val sponsorDao = FakeSponsorDao(cachedIds)
+ val gateway = FakeTelegramGateway()
+ val authRepository = FakeAuthRepository()
+ lateinit var manager: SponsorSyncManager
+ var historyResult: TdApi.Messages = TdApi.Messages(0, emptyArray())
+
+ fun createManager() {
+ gateway.historyProvider = { historyResult }
+ manager = SponsorSyncManager(
+ scope = scope,
+ gateway = gateway,
+ sponsorDao = sponsorDao,
+ authRepository = authRepository,
+ ioDispatcher = dispatcher
+ )
+ }
+
+ fun flush() {
+ scope.testScheduler.runCurrent()
+ }
+
+ fun close() {
+ scope.cancel()
+ }
+ }
+
+ private class FakeSponsorDao(
+ cachedIds: List
+ ) : SponsorDao {
+ private val items = linkedMapOf().apply {
+ cachedIds.forEach { id ->
+ put(id, SponsorEntity(userId = id, sourceChannelId = 0L, updatedAt = 1L))
+ }
+ }
+
+ override suspend fun getAllIds(): List = items.keys.toList()
+
+ override suspend fun insertAll(items: List) {
+ items.forEach { this.items[it.userId] = it }
+ }
+
+ override suspend fun clearAll() {
+ items.clear()
+ }
+
+ override suspend fun deleteNotIn(ids: List) {
+ items.keys.retainAll(ids.toSet())
+ }
+
+ override suspend fun getLatestUpdatedAt(): Long? = items.values.maxOfOrNull { it.updatedAt }
+ }
+
+ private class FakeTelegramGateway : TelegramGateway {
+ private val _updates = MutableSharedFlow()
+ private val _isAuthenticated = MutableStateFlow(true)
+ var historyProvider: suspend () -> TdApi.Messages = { TdApi.Messages(0, emptyArray()) }
+
+ override suspend fun execute(function: TdApi.Function): T {
+ @Suppress("UNCHECKED_CAST")
+ return when (function) {
+ is TdApi.SearchPublicChat -> TdApi.Chat().apply { id = -1003640797855L } as T
+ is TdApi.GetChatHistory -> historyProvider() as T
+ else -> error("Unexpected function ${function::class.java.simpleName}")
+ }
+ }
+
+ override val updates: SharedFlow = _updates
+ override val isAuthenticated: StateFlow = _isAuthenticated
+ }
+
+ private class FakeAuthRepository : AuthRepository {
+ override val authState = MutableStateFlow(AuthStep.Ready)
+ override val authUiStatus = MutableStateFlow(AuthUiStatus.Idle)
+ override val errors = MutableSharedFlow()
+
+ override fun sendPhone(phone: String) = Unit
+ override fun resendCode() = Unit
+ override fun sendCode(code: String) = Unit
+ override fun sendPassword(password: String) = Unit
+ override fun retryLastAction() = Unit
+ override fun reset() = Unit
+ }
+}
diff --git a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt
new file mode 100644
index 00000000..236d26d8
--- /dev/null
+++ b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt
@@ -0,0 +1,196 @@
+package org.monogram.data.repository
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.runTest
+import org.drinkless.tdlib.TdApi
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.monogram.data.datasource.remote.UserRemoteDataSource
+import org.monogram.domain.models.ChatFullInfoModel
+import org.monogram.domain.models.UserModel
+import org.monogram.domain.repository.UserRepository
+
+class ContactEditRepositoryImplTest {
+
+ @Test
+ fun `upsertContact forwards explicit share phone flag`() = runTest {
+ val remote = FakeUserRemoteDataSource()
+ val repository = ContactEditRepositoryImpl(
+ userRepository = FakeUserRepository(),
+ userRemoteDataSource = remote
+ )
+
+ repository.upsertContact(
+ user = UserModel(
+ id = 7L,
+ firstName = "Ada",
+ lastName = "Lovelace",
+ phoneNumber = "+123"
+ ),
+ sharePhoneNumber = false
+ )
+
+ assertEquals(7L, remote.addContactUserId)
+ assertFalse(remote.addContactSharePhoneNumber)
+ assertEquals("Ada", remote.addContact?.firstName)
+ assertEquals("Lovelace", remote.addContact?.lastName)
+ }
+
+ @Test
+ fun `setCloseFriend appends user id when enabling`() = runTest {
+ val remote = FakeUserRemoteDataSource(closeFriendIds = longArrayOf(1L, 2L))
+ val repository = ContactEditRepositoryImpl(
+ userRepository = FakeUserRepository(),
+ userRemoteDataSource = remote
+ )
+
+ repository.setCloseFriend(userId = 7L, isCloseFriend = true)
+
+ assertArrayEquals(longArrayOf(1L, 2L, 7L), remote.recordedCloseFriendIds)
+ }
+
+ @Test
+ fun `setCloseFriend removes user id when disabling`() = runTest {
+ val remote = FakeUserRemoteDataSource(closeFriendIds = longArrayOf(1L, 7L, 2L))
+ val repository = ContactEditRepositoryImpl(
+ userRepository = FakeUserRepository(),
+ userRemoteDataSource = remote
+ )
+
+ repository.setCloseFriend(userId = 7L, isCloseFriend = false)
+
+ assertArrayEquals(longArrayOf(1L, 2L), remote.recordedCloseFriendIds)
+ }
+
+ @Test
+ fun `getNeedPhoneNumberPrivacyException delegates to user full info`() = runTest {
+ val repository = ContactEditRepositoryImpl(
+ userRepository = FakeUserRepository(
+ fullInfo = ChatFullInfoModel(needPhoneNumberPrivacyException = true)
+ ),
+ userRemoteDataSource = FakeUserRemoteDataSource()
+ )
+
+ assertTrue(repository.getNeedPhoneNumberPrivacyException(7L))
+ }
+}
+
+private class FakeUserRepository(
+ private val user: UserModel? = UserModel(id = 7L, firstName = "Ada"),
+ private val fullInfo: ChatFullInfoModel? = null
+) : UserRepository {
+ override val currentUserFlow = MutableStateFlow(null)
+ override val anyUserUpdateFlow: Flow = emptyFlow()
+
+ override suspend fun getMe(): UserModel = user ?: UserModel(0L, "Unknown")
+ override suspend fun getUser(userId: Long): UserModel? = user
+ override suspend fun getUserFullInfo(userId: Long): UserModel? = user
+ override suspend fun refreshUserFullInfo(userId: Long) = Unit
+ override suspend fun resolveUserChatFullInfo(userId: Long): ChatFullInfoModel? = fullInfo
+ override fun getUserFlow(userId: Long): Flow = emptyFlow()
+ override fun logOut() = Unit
+ override suspend fun getContacts(): List = emptyList()
+ override suspend fun searchContacts(query: String): List = emptyList()
+ override suspend fun addContact(user: UserModel) = Unit
+ override suspend fun removeContact(userId: Long) = Unit
+ override suspend fun setCachedSimCountryIso(iso: String?) = Unit
+}
+
+private class FakeUserRemoteDataSource(
+ private val closeFriendIds: LongArray = longArrayOf()
+) : UserRemoteDataSource {
+ var addContactUserId: Long = 0L
+ var addContact: TdApi.ImportedContact? = null
+ var addContactSharePhoneNumber: Boolean = true
+ var recordedCloseFriendIds: LongArray = longArrayOf()
+
+ override suspend fun getUser(userId: Long): TdApi.User? = null
+ override suspend fun getMe(): TdApi.User? = null
+ override suspend fun getUserFullInfo(userId: Long): TdApi.UserFullInfo? = null
+ override suspend fun getSupergroupFullInfo(supergroupId: Long): TdApi.SupergroupFullInfo? = null
+ override suspend fun getBasicGroupFullInfo(basicGroupId: Long): TdApi.BasicGroupFullInfo? = null
+ override suspend fun getSupergroup(supergroupId: Long): TdApi.Supergroup? = null
+ override suspend fun getChat(chatId: Long): TdApi.Chat? = null
+ override suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? = null
+ override suspend fun getUserProfilePhotos(
+ userId: Long,
+ offset: Int,
+ limit: Int
+ ): TdApi.ChatPhotos? = null
+
+ override suspend fun getContacts(): TdApi.Users? = null
+ override suspend fun searchContacts(query: String): TdApi.Users? = null
+ override suspend fun addContact(
+ userId: Long,
+ contact: TdApi.ImportedContact,
+ sharePhoneNumber: Boolean
+ ) {
+ addContactUserId = userId
+ addContact = contact
+ addContactSharePhoneNumber = sharePhoneNumber
+ }
+
+ override suspend fun removeContacts(userIds: LongArray) = Unit
+ override suspend fun getCloseFriendIds(): LongArray = closeFriendIds
+ override suspend fun setCloseFriendIds(userIds: LongArray) {
+ recordedCloseFriendIds = userIds
+ }
+
+ override suspend fun sharePhoneNumber(userId: Long) = Unit
+ override suspend fun searchPublicChat(username: String): TdApi.Chat? = null
+ override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? = null
+ override suspend fun getSupergroupMembers(
+ supergroupId: Long,
+ filter: TdApi.SupergroupMembersFilter,
+ offset: Int,
+ limit: Int
+ ): TdApi.ChatMembers? = null
+
+ override suspend fun getBasicGroupMembers(basicGroupId: Long): TdApi.BasicGroupFullInfo? = null
+ override suspend fun getPremiumState(): TdApi.PremiumState? = null
+ override suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? =
+ null
+
+ override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? =
+ null
+
+ override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) = Unit
+ override suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? = null
+ override suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics? =
+ null
+
+ override suspend fun getChatRevenueStatistics(
+ chatId: Long,
+ isDark: Boolean
+ ): TdApi.ChatRevenueStatistics? = null
+
+ override suspend fun getStatisticsGraph(
+ chatId: Long,
+ token: String,
+ x: Long
+ ): TdApi.StatisticalGraph? = null
+
+ override suspend fun logout() = Unit
+ override suspend fun setName(firstName: String, lastName: String) = Unit
+ override suspend fun setBio(bio: String) = Unit
+ override suspend fun setUsername(username: String) = Unit
+ override suspend fun setEmojiStatus(customEmojiId: Long?) = Unit
+ override suspend fun setProfilePhoto(path: String) = Unit
+ override suspend fun setBirthdate(birthdate: TdApi.Birthdate?) = Unit
+ override suspend fun setPersonalChat(chatId: Long) = Unit
+ override suspend fun setBusinessBio(bio: String) = Unit
+ override suspend fun setBusinessLocation(location: TdApi.BusinessLocation?) = Unit
+ override suspend fun setBusinessOpeningHours(hours: TdApi.BusinessOpeningHours?) = Unit
+ override suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) = Unit
+ override suspend fun reorderActiveUsernames(usernames: Array) = Unit
+ override suspend fun setChatMemberStatus(
+ chatId: Long,
+ userId: Long,
+ status: TdApi.ChatMemberStatus
+ ) = Unit
+}
diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt
index 20cdb36b..d01a2be2 100644
--- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt
@@ -1,5 +1,6 @@
package org.monogram.domain.models
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -32,6 +33,9 @@ data class ChatModel(
val hasProtectedContent: Boolean = false,
val isTranslatable: Boolean = false,
val hasAutomaticTranslation: Boolean = false,
+ val signMessages: Boolean = false,
+ val joinToSendMessages: Boolean = false,
+ val availableReactions: ChatAvailableReactionsModel = ChatAvailableReactionsModel.All,
val messageAutoDeleteTime: Int = 0,
val canBeDeletedOnlyForSelf: Boolean = false,
val canBeDeletedForAllUsers: Boolean = false,
@@ -84,6 +88,21 @@ data class ChatModel(
enum class ChatType {
PRIVATE, BASIC_GROUP, SUPERGROUP, SECRET
}
+
+@Serializable
+sealed class ChatAvailableReactionsModel {
+ @Serializable
+ @SerialName("all")
+ data object All : ChatAvailableReactionsModel()
+
+ @Serializable
+ @SerialName("some")
+ data class Some(
+ val reactions: List = emptyList(),
+ val maxReactionCount: Int = 1
+ ) : ChatAvailableReactionsModel()
+}
+
@Serializable
data class ChatPermissionsModel(
val canSendBasicMessages: Boolean = true,
diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt
index 19e69807..3f54c8f5 100644
--- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt
@@ -46,9 +46,40 @@ data class MessageModel(
val viaBotName: String? = null,
val isSenderPremium: Boolean = false,
val senderStatusEmojiId: Long = 0L,
- val senderStatusEmojiPath: String? = null
+ val senderStatusEmojiPath: String? = null,
+ val isPinned: Boolean = false,
+ val hasUnreadMention: Boolean = false,
+ val hasUnreadReactions: Boolean = false,
+ val containsUnreadPollVotes: Boolean = false,
+ val factCheck: FactCheckModel? = null,
+ val suggestedPostInfo: SuggestedPostInfoModel? = null
)
+data class FactCheckModel(
+ val text: String,
+ val entities: List = emptyList(),
+ val countryCode: String? = null
+)
+
+data class SuggestedPostInfoModel(
+ val price: SuggestedPostPriceModel? = null,
+ val sendDate: Int = 0,
+ val state: SuggestedPostStateModel = SuggestedPostStateModel.PENDING,
+ val canBeApproved: Boolean = false,
+ val canBeDeclined: Boolean = false
+)
+
+sealed interface SuggestedPostPriceModel {
+ data class Star(val starCount: Long) : SuggestedPostPriceModel
+ data class Ton(val toncoinCentCount: Long) : SuggestedPostPriceModel
+}
+
+enum class SuggestedPostStateModel {
+ PENDING,
+ APPROVED,
+ DECLINED
+}
+
data class ForwardInfo(
val date: Int,
val fromId: Long,
diff --git a/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt b/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt
new file mode 100644
index 00000000..41a4f057
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt
@@ -0,0 +1,25 @@
+package org.monogram.domain.models
+
+data class AdvertisementSponsorModel(
+ val url: String,
+ val photoPath: String? = null,
+ val info: String? = null
+)
+
+data class SponsoredMessageModel(
+ val messageId: Long,
+ val isRecommended: Boolean,
+ val canBeReported: Boolean,
+ val title: String? = null,
+ val buttonText: String? = null,
+ val additionalInfo: String? = null,
+ val accentColorId: Int = 0,
+ val backgroundCustomEmojiId: Long = 0L,
+ val content: MessageContent,
+ val sponsor: AdvertisementSponsorModel
+)
+
+data class SponsoredMessagesFeedModel(
+ val messages: List,
+ val messagesBetween: Int
+)
diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
index 97e52340..d33ebbf1 100644
--- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
@@ -72,6 +72,7 @@ interface AppPreferencesProvider {
val chatListMessageLines: StateFlow
val showChatListPhotos: StateFlow
val showReactions: StateFlow
+ val showSponsoredMessagesForPremium: StateFlow
val privateChatsNotifications: StateFlow
val groupsNotifications: StateFlow
@@ -128,6 +129,7 @@ interface AppPreferencesProvider {
fun setChatListMessageLines(lines: Int)
fun setShowChatListPhotos(enabled: Boolean)
fun setShowReactions(enabled: Boolean)
+ fun setShowSponsoredMessagesForPremium(enabled: Boolean)
fun setPrivateChatsNotifications(enabled: Boolean)
fun setGroupsNotifications(enabled: Boolean)
diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt
index 7c7b3c3b..de439ac1 100644
--- a/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt
@@ -6,20 +6,25 @@ interface ChatOperationsRepository {
val isArchivePinned: StateFlow
val isArchiveAlwaysVisible: StateFlow
- fun toggleMuteChats(chatIds: Set, mute: Boolean)
- fun toggleArchiveChats(chatIds: Set, archive: Boolean)
- fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int)
- fun toggleReadChats(chatIds: Set, markAsUnread: Boolean)
+ suspend fun toggleMuteChats(chatIds: Set, mute: Boolean)
+ suspend fun toggleArchiveChats(chatIds: Set, archive: Boolean)
+ suspend fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int)
+ suspend fun toggleReadChats(chatIds: Set, markAsUnread: Boolean)
fun markChatsAsRead(chatIds: Set)
fun markFolderAsRead(folderId: Int, chatIds: Set)
- fun deleteChats(chatIds: Set)
- fun leaveChats(chatIds: Set)
- fun leaveChat(chatId: Long)
+ suspend fun deleteChats(chatIds: Set)
+ suspend fun leaveChats(chatIds: Set)
+ suspend fun leaveChat(chatId: Long)
fun setArchivePinned(pinned: Boolean)
- fun clearChatHistories(chatIds: Set, revoke: Boolean)
- fun clearChatHistory(chatId: Long, revoke: Boolean)
+ suspend fun clearChatHistories(chatIds: Set, revoke: Boolean)
+ suspend fun clearChatHistory(chatId: Long, revoke: Boolean)
suspend fun getChatLink(chatId: Long): String?
- fun reportChats(chatIds: Set, reason: String, messageIds: List = emptyList())
- fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList())
-}
\ No newline at end of file
+ suspend fun reportChats(
+ chatIds: Set,
+ reason: String,
+ messageIds: List = emptyList()
+ )
+
+ suspend fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList())
+}
diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt
index 95dedd02..aee0552d 100644
--- a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt
@@ -8,7 +8,10 @@ interface ChatSettingsRepository {
suspend fun setChatDescription(chatId: Long, description: String)
suspend fun setChatUsername(chatId: Long, username: String)
suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel)
+ suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean)
+ suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean)
+ suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean)
+ suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List)
suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int)
suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean)
- suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean)
}
\ No newline at end of file
diff --git a/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt
new file mode 100644
index 00000000..648fc8b8
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt
@@ -0,0 +1,21 @@
+package org.monogram.domain.repository
+
+import org.monogram.domain.models.UserModel
+
+interface ContactEditRepository {
+ suspend fun getContact(userId: Long): UserModel?
+ suspend fun getNeedPhoneNumberPrivacyException(userId: Long): Boolean
+ suspend fun upsertContact(
+ user: UserModel,
+ sharePhoneNumber: Boolean
+ ): UserModel?
+
+ suspend fun removeContact(userId: Long)
+ suspend fun setCloseFriend(
+ userId: Long,
+ isCloseFriend: Boolean
+ ): UserModel?
+
+ val supportsPersonalPhotoEditing: Boolean
+ get() = false
+}
diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt
index ee6e2dc2..7972bfb0 100644
--- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt
@@ -13,6 +13,7 @@ import org.monogram.domain.models.MessageSendOptions
import org.monogram.domain.models.MessageUploadProgressEvent
import org.monogram.domain.models.MessageViewerModel
import org.monogram.domain.models.PollDraft
+import org.monogram.domain.models.SponsoredMessagesFeedModel
import org.monogram.domain.models.UserModel
import org.monogram.domain.models.webapp.InstantViewModel
@@ -131,7 +132,11 @@ interface MessageRepository :
threadId: Long? = null
): OlderMessagesPage
- suspend fun getCachedMessages(chatId: Long, limit: Int): List
+ suspend fun getCachedMessages(
+ chatId: Long,
+ limit: Int,
+ threadId: Long? = null
+ ): List
suspend fun getMessagesNewer(
chatId: Long,
@@ -143,13 +148,15 @@ interface MessageRepository :
suspend fun getCachedMessagesNewer(
chatId: Long,
fromMessageId: Long,
- limit: Int
+ limit: Int,
+ threadId: Long? = null
): List
suspend fun getCachedMessagesAround(
chatId: Long,
messageId: Long,
- limit: Int
+ limit: Int,
+ threadId: Long? = null
): List
suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long? = null): List
@@ -262,6 +269,13 @@ interface MessageRepository :
suspend fun getWebPageInstantView(url: String, forceFull: Boolean = false): InstantViewModel?
suspend fun getFullRichMessage(chatId: Long, messageId: Long): MessageContent.RichMessage?
suspend fun getDraftLinkPreview(request: DraftLinkPreviewRequest): DraftLinkPreview?
+ suspend fun getChannelSponsoredMessages(chatId: Long): SponsoredMessagesFeedModel?
+ suspend fun clickChannelSponsoredMessage(
+ chatId: Long,
+ messageId: Long,
+ isMediaClick: Boolean,
+ fromFullscreen: Boolean = false
+ )
suspend fun searchMessages(
chatId: Long,
diff --git a/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt
new file mode 100644
index 00000000..43f95a4b
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt
@@ -0,0 +1,10 @@
+package org.monogram.domain.repository
+
+import kotlinx.coroutines.flow.Flow
+
+interface PinnedMessageVisibilityRepository {
+ fun observeHidden(chatId: Long): Flow
+ suspend fun isHidden(chatId: Long): Boolean
+ suspend fun hide(chatId: Long)
+ suspend fun show(chatId: Long)
+}
diff --git a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt
index 3b25e541..b8de6ece 100644
--- a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt
@@ -9,4 +9,5 @@ interface PremiumRepository {
suspend fun getPremiumState(): PremiumStateModel?
suspend fun getPremiumFeatures(source: PremiumSource): List
suspend fun getPremiumLimit(limitType: PremiumLimitType): Int
+ suspend fun setSponsoredMessagesEnabled(enabled: Boolean)
}
diff --git a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
index cb0a34d1..4528778c 100644
--- a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt
@@ -12,7 +12,7 @@ enum class UnifiedPushDebugStatus {
data class PushDiagnostics(
val pushProvider: PushProvider = PushProvider.FCM,
- val backgroundServiceEnabled: Boolean = true,
+ val backgroundServiceEnabled: Boolean = false,
val hideForegroundNotification: Boolean = false,
val isPowerSavingMode: Boolean = false,
val isWakeLockEnabled: Boolean = false,
diff --git a/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt
index 89fa0bbf..12c4b16a 100644
--- a/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt
@@ -1,5 +1,17 @@
package org.monogram.domain.repository
+import kotlinx.coroutines.flow.StateFlow
+
interface SponsorRepository {
+ val sponsorState: StateFlow
+
fun forceSponsorSync()
}
+
+data class SponsorState(
+ val supporterIds: Set = emptySet(),
+ val supportersCount: Int = 0,
+ val isLoaded: Boolean = false,
+ val isSyncInProgress: Boolean = false,
+ val lastSyncAt: Long = 0L
+)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4305740b..9c94067f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,9 +1,9 @@
[versions]
# Plugins
-agp = "9.2.1"
+agp = "9.3.0-rc01"
exifinterface = "1.4.2"
kotlin = "2.3.21"
-google-services = "4.4.4"
+google-services = "4.5.0"
ossLicensesPlugin = "0.12.0"
ksp = "2.3.6"
baselineprofile = "1.5.0-alpha06"
@@ -14,10 +14,10 @@ javaToolchain = "21"
androidx-activityCompose = "1.13.0"
androidx-biometric = "1.4.0-alpha07"
androidx-camera = "1.6.1"
-androidx-compose-bom = "2026.05.01"
-androidx-compose-runtime = "1.11.2"
-androidx-compose-ui = "1.11.2"
-androidx-material3 = "1.5.0-alpha21"
+androidx-compose-bom = "2026.06.00"
+androidx-compose-runtime = "1.11.3"
+androidx-compose-ui = "1.11.3"
+androidx-material3 = "1.5.0-alpha22"
androidx-adaptive = "1.2.0"
androidx-media3 = "1.10.1"
androidx-securityCrypto = "1.1.0"
@@ -27,7 +27,7 @@ androidx-benchmark-macro-junit4 = "1.4.1"
androidx-test-ext-junit = "1.3.0"
androidx-window = "1.5.1"
androidx-core-splashscreen = "1.2.0"
-androidx-lifecycle = "2.10.0"
+androidx-lifecycle = "2.11.0"
# KotlinX
kotlinx-coroutines = "1.11.0"
@@ -36,20 +36,20 @@ kotlinx-serialization = "1.11.0"
# Architecture & DI
decompose = "3.5.0"
mvikotlin = "4.4.0"
-koin = "4.2.1"
+koin = "4.2.2"
# UI & Media
coil = "3.5.0"
maplibre = "1.7.0"
# Google & Firebase
-firebase-bom = "34.14.1"
+firebase-bom = "34.15.0"
playServices-ossLicenses = "17.5.1"
# Others
zxing = "3.5.4"
junit = "4.13.2"
-libphonenumber = "9.0.32"
+libphonenumber = "9.0.33"
unifiedpush-connector = "3.3.3"
json = "20260522"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index c61a118f..70688ca4 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,6 @@
+#Fri Jun 26 12:53:50 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt
new file mode 100644
index 00000000..d70f2aef
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt
@@ -0,0 +1,23 @@
+package org.monogram.presentation.core.ui
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class ScreenSwipeBackState(
+ val isSupported: Boolean = false,
+ val isBlocked: Boolean = false,
+ val action: ScreenSwipeBackAction = ScreenSwipeBackAction.StackPop,
+ val preview: ScreenSwipeBackPreview = ScreenSwipeBackPreview.PreviousStackEntry,
+)
+
+enum class ScreenSwipeBackAction {
+ StackPop,
+ LocalChatTopicClose,
+ LocalChatListArchiveReturn,
+}
+
+enum class ScreenSwipeBackPreview {
+ PreviousStackEntry,
+ ChatForumList,
+ ChatListFolder,
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
index 0ace0ba7..a2bc3f85 100644
--- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
+++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
@@ -270,7 +270,7 @@ class AppPreferences(
private val _pinnedMessagesNotifications = MutableStateFlow(prefs.getBoolean(KEY_PINNED_MESSAGES, true))
override val pinnedMessagesNotifications: StateFlow = _pinnedMessagesNotifications
- private val _backgroundServiceEnabled = MutableStateFlow(prefs.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED, true))
+ private val _backgroundServiceEnabled = MutableStateFlow(prefs.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED, false))
override val backgroundServiceEnabled: StateFlow = _backgroundServiceEnabled
private val _isPowerSavingMode = MutableStateFlow(prefs.getBoolean(KEY_POWER_SAVING_MODE, false))
@@ -334,6 +334,11 @@ class AppPreferences(
private val _showReactions = MutableStateFlow(prefs.getBoolean(KEY_SHOW_REACTIONS, true))
override val showReactions: StateFlow = _showReactions
+ private val _showSponsoredMessagesForPremium =
+ MutableStateFlow(prefs.getBoolean(KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM, false))
+ override val showSponsoredMessagesForPremium: StateFlow =
+ _showSponsoredMessagesForPremium
+
private val _showAllChatsFolder =
MutableStateFlow(prefs.getBoolean(KEY_SHOW_ALL_CHATS_FOLDER, true))
val showAllChatsFolder: StateFlow = _showAllChatsFolder
@@ -443,6 +448,14 @@ class AppPreferences(
private val _isSupportViewed = MutableStateFlow(prefs.getBoolean(KEY_SUPPORT_VIEWED, false))
override val isSupportViewed: StateFlow = _isSupportViewed
+ private val _isProjectChannelPromoDismissed =
+ MutableStateFlow(prefs.getBoolean(KEY_PROJECT_CHANNEL_PROMO_DISMISSED, false))
+ val isProjectChannelPromoDismissed: StateFlow = _isProjectChannelPromoDismissed
+
+ private val _isProjectChannelSubscribed =
+ MutableStateFlow(prefs.getBoolean(KEY_PROJECT_CHANNEL_SUBSCRIBED, false))
+ val isProjectChannelSubscribed: StateFlow = _isProjectChannelSubscribed
+
init {
if (_adBlockKeywords.value.isEmpty()) {
externalScope.launch {
@@ -990,6 +1003,11 @@ class AppPreferences(
_showReactions.value = enabled
}
+ override fun setShowSponsoredMessagesForPremium(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM, enabled).apply()
+ _showSponsoredMessagesForPremium.value = enabled
+ }
+
fun setShowAllChatsFolder(enabled: Boolean) {
prefs.edit().putBoolean(KEY_SHOW_ALL_CHATS_FOLDER, enabled).apply()
_showAllChatsFolder.value = enabled
@@ -1177,7 +1195,7 @@ class AppPreferences(
_inAppPreview.value = true
_contactJoinedNotifications.value = true
_pinnedMessagesNotifications.value = true
- _backgroundServiceEnabled.value = true
+ _backgroundServiceEnabled.value = false
_isPowerSavingMode.value = false
_isWakeLockEnabled.value = false
_hideForegroundNotification.value = false
@@ -1195,6 +1213,7 @@ class AppPreferences(
_isChatAnimationsEnabled.value = true
_chatListMessageLines.value = 1
_showChatListPhotos.value = true
+ _showSponsoredMessagesForPremium.value = false
_showAllChatsFolder.value = true
_isTabletInterfaceEnabled.value = true
_isAdBlockEnabled.value = false
@@ -1212,6 +1231,8 @@ class AppPreferences(
_proxyNetworkRules.value = readProxyNetworkRules()
_userProxyBackups.value = emptySet()
_isPermissionRequested.value = false
+ _isProjectChannelPromoDismissed.value = false
+ _isProjectChannelSubscribed.value = false
}
override fun clearSecurePreferences() {
@@ -1225,6 +1246,16 @@ class AppPreferences(
_isSupportViewed.value = viewed
}
+ fun setProjectChannelPromoDismissed(dismissed: Boolean) {
+ prefs.edit().putBoolean(KEY_PROJECT_CHANNEL_PROMO_DISMISSED, dismissed).apply()
+ _isProjectChannelPromoDismissed.value = dismissed
+ }
+
+ fun setProjectChannelSubscribed(subscribed: Boolean) {
+ prefs.edit().putBoolean(KEY_PROJECT_CHANNEL_SUBSCRIBED, subscribed).apply()
+ _isProjectChannelSubscribed.value = subscribed
+ }
+
private fun loadPushProvider(): PushProvider {
val byName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null)
?.let { stored -> PushProvider.entries.firstOrNull { it.name == stored } }
@@ -1337,6 +1368,8 @@ class AppPreferences(
private const val KEY_CHAT_LIST_MESSAGE_LINES = "chat_list_message_lines"
private const val KEY_SHOW_CHAT_LIST_PHOTOS = "show_chat_list_photos"
private const val KEY_SHOW_REACTIONS = "show_reactions"
+ private const val KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM =
+ "show_sponsored_messages_for_premium"
private const val KEY_SHOW_ALL_CHATS_FOLDER = "show_all_chats_folder"
private const val KEY_TABLET_INTERFACE_ENABLED = "tablet_interface_enabled"
@@ -1373,5 +1406,7 @@ class AppPreferences(
private const val KEY_PERMISSION_REQUESTED = "permission_requested"
private const val KEY_SUPPORT_VIEWED = "support_viewed"
+ private const val KEY_PROJECT_CHANNEL_PROMO_DISMISSED = "project_channel_promo_dismissed"
+ private const val KEY_PROJECT_CHANNEL_SUBSCRIBED = "project_channel_subscribed"
}
}
diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt
new file mode 100644
index 00000000..05bb4722
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt
@@ -0,0 +1,133 @@
+package org.monogram.presentation.core.util
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import java.io.File
+import java.io.FileOutputStream
+
+data class CopiedUriFile(
+ val localPath: String,
+ val mimeType: String?,
+ val displayName: String?
+)
+
+private val INVALID_FILE_CHARS = Regex("[\\\\/:*?\"<>|]")
+
+fun Context.copyUriToTempMediaFile(uri: Uri): CopiedUriFile? {
+ return copyUriToTempFile(
+ uri = uri,
+ directoryName = "shared_media",
+ fallbackFileName = { mimeType ->
+ val extension = resolveMediaExtension(mimeType)
+ "attach_${System.nanoTime()}.$extension"
+ }
+ )
+}
+
+fun Context.copyUriToTempDocumentFile(uri: Uri): CopiedUriFile? {
+ return copyUriToTempFile(
+ uri = uri,
+ directoryName = "shared_documents",
+ fallbackFileName = { mimeType ->
+ "document_${System.nanoTime()}.${resolveDocumentExtension(mimeType)}"
+ }
+ )
+}
+
+internal fun resolveMediaExtension(mimeType: String?): String {
+ val normalizedMimeType = mimeType?.substringBefore(';')?.trim()?.lowercase()
+ return when {
+ normalizedMimeType.isNullOrBlank() -> "jpg"
+ normalizedMimeType == "image/gif" -> "gif"
+ normalizedMimeType.startsWith("video/") -> resolveKnownSubtype(normalizedMimeType) ?: "mp4"
+ normalizedMimeType.startsWith("image/") -> resolveExtensionFromMimeType(normalizedMimeType)
+ ?: "jpg"
+
+ else -> resolveExtensionFromMimeType(normalizedMimeType) ?: "jpg"
+ }
+}
+
+internal fun resolveDocumentExtension(mimeType: String?): String {
+ if (mimeType.isNullOrBlank()) return "bin"
+ return resolveExtensionFromMimeType(mimeType) ?: "bin"
+}
+
+private fun resolveExtensionFromMimeType(mimeType: String): String? {
+ return when (val normalizedMimeType = mimeType.substringBefore(';').trim().lowercase()) {
+ "image/jpeg" -> "jpg"
+ "image/png" -> "png"
+ "image/webp" -> "webp"
+ "image/heic" -> "heic"
+ "image/heif" -> "heif"
+ "image/avif" -> "avif"
+ "application/pdf" -> "pdf"
+ else -> resolveKnownSubtype(normalizedMimeType)
+ }
+}
+
+private fun resolveKnownSubtype(mimeType: String): String? {
+ val normalizedMimeType = mimeType.substringBefore(';').trim().lowercase()
+ val subtype = normalizedMimeType.substringAfter('/', "")
+ if (subtype.isBlank()) return null
+ if (subtype == "unknown" || subtype == "octet-stream") return null
+ return when (subtype) {
+ "jpeg" -> "jpg"
+ "svg+xml" -> "svg"
+ else -> subtype.takeIf { candidate ->
+ candidate.all { it.isLetterOrDigit() || it == '+' || it == '-' || it == '.' }
+ }?.substringBefore('+')
+ }
+}
+
+fun Context.copyUriToTempFile(
+ uri: Uri,
+ directoryName: String = "shared",
+ fallbackFileName: (String?) -> String = { "file_${System.nanoTime()}" }
+): CopiedUriFile? {
+ return try {
+ if (uri.scheme == "file") {
+ val path = uri.path ?: return null
+ val name = File(path).name.takeIf { it.isNotBlank() }
+ return CopiedUriFile(
+ localPath = path,
+ mimeType = contentResolver.getType(uri),
+ displayName = name
+ )
+ }
+
+ val mimeType = contentResolver.getType(uri)
+ val displayName = queryDisplayName(uri)?.trim().takeUnless { it.isNullOrBlank() }
+ val safeName = displayName
+ ?.replace(INVALID_FILE_CHARS, "_")
+ ?: fallbackFileName(mimeType)
+
+ val tempDir = File(cacheDir, directoryName).apply { mkdirs() }
+ val targetFile = File(tempDir, "${System.nanoTime()}_$safeName")
+ contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(targetFile).use { output -> input.copyTo(output) }
+ } ?: return null
+
+ CopiedUriFile(
+ localPath = targetFile.absolutePath,
+ mimeType = mimeType,
+ displayName = displayName
+ )
+ } catch (_: Exception) {
+ null
+ }
+}
+
+private fun Context.queryDisplayName(uri: Uri): String? {
+ return contentResolver.query(
+ uri,
+ arrayOf(OpenableColumns.DISPLAY_NAME),
+ null,
+ null,
+ null
+ )?.use { cursor ->
+ if (!cursor.moveToFirst()) return@use null
+ val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (index >= 0) cursor.getString(index) else null
+ }
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
index 2bc8bb0e..5b45ea91 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
@@ -24,6 +24,7 @@ import org.monogram.domain.repository.ChatOperationsRepository
import org.monogram.domain.repository.ChatSearchRepository
import org.monogram.domain.repository.ChatSettingsRepository
import org.monogram.domain.repository.ChatStatisticsRepository
+import org.monogram.domain.repository.ContactEditRepository
import org.monogram.domain.repository.EditorSnippetProvider
import org.monogram.domain.repository.EmojiRepository
import org.monogram.domain.repository.ExternalNavigator
@@ -39,6 +40,7 @@ import org.monogram.domain.repository.MessageRepository
import org.monogram.domain.repository.NetworkStatisticsRepository
import org.monogram.domain.repository.NotificationSettingsRepository
import org.monogram.domain.repository.PaymentRepository
+import org.monogram.domain.repository.PinnedMessageVisibilityRepository
import org.monogram.domain.repository.PremiumRepository
import org.monogram.domain.repository.PrivacyRepository
import org.monogram.domain.repository.ProfilePhotoRepository
@@ -55,10 +57,10 @@ import org.monogram.domain.repository.UserProfileEditRepository
import org.monogram.domain.repository.UserRepository
import org.monogram.domain.repository.WallpaperRepository
import org.monogram.domain.repository.WebAppRepository
-import org.monogram.presentation.core.util.AppPreferences
-import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.core.media.ExoPlayerCache
import org.monogram.presentation.core.media.VideoPlayerPool
+import org.monogram.presentation.core.util.AppPreferences
+import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.settings.storage.CacheController
interface AppContainer {
@@ -89,10 +91,12 @@ interface RepositoriesContainer {
val chatEventLogRepository: ChatEventLogRepository
val messageAiRepository: MessageAiRepository
val paymentRepository: PaymentRepository
+ val pinnedMessageVisibilityRepository: PinnedMessageVisibilityRepository
val fileRepository: FileRepository
val webAppRepository: WebAppRepository
val userRepository: UserRepository
val userProfileEditRepository: UserProfileEditRepository
+ val contactEditRepository: ContactEditRepository
val profilePhotoRepository: ProfilePhotoRepository
val chatInfoRepository: ChatInfoRepository
val premiumRepository: PremiumRepository
diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
index ed731cb6..e523210d 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
@@ -25,6 +25,7 @@ import org.monogram.domain.repository.ChatOperationsRepository
import org.monogram.domain.repository.ChatSearchRepository
import org.monogram.domain.repository.ChatSettingsRepository
import org.monogram.domain.repository.ChatStatisticsRepository
+import org.monogram.domain.repository.ContactEditRepository
import org.monogram.domain.repository.EditorSnippetProvider
import org.monogram.domain.repository.EmojiRepository
import org.monogram.domain.repository.ExternalNavigator
@@ -40,6 +41,7 @@ import org.monogram.domain.repository.MessageRepository
import org.monogram.domain.repository.NetworkStatisticsRepository
import org.monogram.domain.repository.NotificationSettingsRepository
import org.monogram.domain.repository.PaymentRepository
+import org.monogram.domain.repository.PinnedMessageVisibilityRepository
import org.monogram.domain.repository.PremiumRepository
import org.monogram.domain.repository.PrivacyRepository
import org.monogram.domain.repository.ProfilePhotoRepository
@@ -56,10 +58,10 @@ import org.monogram.domain.repository.UserProfileEditRepository
import org.monogram.domain.repository.UserRepository
import org.monogram.domain.repository.WallpaperRepository
import org.monogram.domain.repository.WebAppRepository
-import org.monogram.presentation.core.util.AppPreferences
-import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.core.media.ExoPlayerCache
import org.monogram.presentation.core.media.VideoPlayerPool
+import org.monogram.presentation.core.util.AppPreferences
+import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.settings.storage.CacheController
class KoinAppContainer(koin: Koin) : AppContainer {
@@ -90,10 +92,12 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer
override val chatEventLogRepository: ChatEventLogRepository by lazy { koin.get() }
override val messageAiRepository: MessageAiRepository by lazy { koin.get() }
override val paymentRepository: PaymentRepository by lazy { koin.get() }
+ override val pinnedMessageVisibilityRepository: PinnedMessageVisibilityRepository by lazy { koin.get() }
override val fileRepository: FileRepository by lazy { koin.get() }
override val webAppRepository: WebAppRepository by lazy { koin.get() }
override val userRepository: UserRepository by lazy { koin.get() }
override val userProfileEditRepository: UserProfileEditRepository by lazy { koin.get() }
+ override val contactEditRepository: ContactEditRepository by lazy { koin.get() }
override val profilePhotoRepository: ProfilePhotoRepository by lazy { koin.get() }
override val chatInfoRepository: ChatInfoRepository by lazy { koin.get() }
override val premiumRepository: PremiumRepository by lazy { koin.get() }
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt
new file mode 100644
index 00000000..0ed62301
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt
@@ -0,0 +1,75 @@
+package org.monogram.presentation.features.chats.common
+
+enum class ChatActionScreenContext {
+ Chat,
+ Profile,
+ ListSelection
+}
+
+enum class ChatActionType {
+ Leave,
+ Delete,
+ ClearHistory,
+ Report,
+ Join,
+ BlockUser,
+ UnblockUser,
+ Mute,
+ Archive,
+ ToggleRead,
+ Pin
+}
+
+sealed interface ChatActionState {
+ data object Idle : ChatActionState
+ data class Pending(val action: ChatActionType) : ChatActionState
+ data class Success(val action: ChatActionType?) : ChatActionState
+ data class Failure(val action: ChatActionType, val message: String) : ChatActionState
+}
+
+data class ChatActionPolicy(
+ val exitAction: ChatExitAction = ChatExitAction.None,
+ val canClearHistory: Boolean = false,
+ val canReport: Boolean = false,
+ val canJoin: Boolean = false,
+ val canBlockOrUnblock: Boolean = false,
+ val canMute: Boolean = true,
+ val canArchive: Boolean = true,
+ val canToggleRead: Boolean = true,
+ val canPin: Boolean = false,
+ val closeOnExitSuccess: Boolean = false
+)
+
+fun resolveChatActionPolicy(
+ isMainChat: Boolean,
+ isGroup: Boolean,
+ isChannel: Boolean,
+ isMember: Boolean,
+ canDeleteChat: Boolean,
+ canReport: Boolean,
+ canJoin: Boolean,
+ canBlockOrUnblock: Boolean,
+ canPin: Boolean,
+ context: ChatActionScreenContext
+): ChatActionPolicy {
+ val exitAction = resolveChatExitAction(
+ isMainChat = isMainChat,
+ isGroup = isGroup,
+ isChannel = isChannel,
+ isMember = isMember,
+ canDeleteChat = canDeleteChat
+ )
+ val canClearHistory = isMainChat && ((!isGroup && !isChannel) || isMember)
+ return ChatActionPolicy(
+ exitAction = exitAction,
+ canClearHistory = canClearHistory,
+ canReport = isMainChat && canReport,
+ canJoin = isMainChat && canJoin,
+ canBlockOrUnblock = canBlockOrUnblock,
+ canMute = true,
+ canArchive = true,
+ canToggleRead = true,
+ canPin = canPin,
+ closeOnExitSuccess = context != ChatActionScreenContext.ListSelection && exitAction != ChatExitAction.None
+ )
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt
new file mode 100644
index 00000000..b78d3ef0
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt
@@ -0,0 +1,26 @@
+package org.monogram.presentation.features.chats.common
+
+enum class ChatExitAction {
+ None,
+ Leave,
+ Delete
+}
+
+fun resolveChatExitAction(
+ isMainChat: Boolean,
+ isGroup: Boolean,
+ isChannel: Boolean,
+ isMember: Boolean,
+ canDeleteChat: Boolean
+): ChatExitAction {
+ if (!isMainChat) return ChatExitAction.None
+
+ return when {
+ isGroup || isChannel -> {
+ if (isMember) ChatExitAction.Leave else ChatExitAction.None
+ }
+
+ canDeleteChat -> ChatExitAction.Delete
+ else -> ChatExitAction.None
+ }
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt
index 1f66b215..dcc99b25 100644
--- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt
@@ -18,6 +18,7 @@ import org.monogram.domain.models.MessageModel
import org.monogram.domain.models.MessageSendOptions
import org.monogram.domain.models.MessageViewerModel
import org.monogram.domain.models.PollDraft
+import org.monogram.domain.models.SponsoredMessagesFeedModel
import org.monogram.domain.models.StickerSetModel
import org.monogram.domain.models.TopicModel
import org.monogram.domain.models.UserModel
@@ -27,8 +28,14 @@ import org.monogram.domain.repository.ChecklistDraft
import org.monogram.domain.repository.InlineBotResultsModel
import org.monogram.domain.repository.MessageRepository
import org.monogram.domain.repository.StickerRepository
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.IDownloadUtils
+import org.monogram.presentation.features.chats.common.ChatActionState
+import org.monogram.presentation.features.share.IncomingShareRequest
+import org.monogram.presentation.features.share.PendingAttachment
+import org.monogram.presentation.features.share.PendingAttachmentKind
+import org.monogram.presentation.features.share.isDocument
import java.io.File
@Stable
@@ -36,6 +43,7 @@ interface ChatComponent {
val appPreferences: AppPreferences
val stickerRepository: StickerRepository
val state: StateFlow
+ val swipeBackState: StateFlow
val repositoryMessage: MessageRepository
val downloadUtils: IDownloadUtils
@@ -116,12 +124,15 @@ interface ChatComponent {
fun onRestoreDraftLinkPreview()
fun onPinMessage(message: MessageModel)
fun onUnpinMessage(message: MessageModel)
+ fun onHidePinnedMessage()
+ fun onShowPinnedMessage()
fun onPinnedMessageClick(message: MessageModel? = null)
fun onShowAllPinnedMessages()
fun onDismissPinnedMessages()
fun onScrollToMessageConsumed()
fun onScrollCommandConsumed()
fun onViewportSettled()
+ fun onMessageViewportChanged(visibleMessageIds: Set, nearbyMessageIds: Set)
fun onScrollToBottom()
fun onDownloadFile(fileId: Int)
fun onDownloadHighRes(messageId: Long)
@@ -193,6 +204,7 @@ interface ChatComponent {
fun onSearchSenderChange(user: UserModel?)
fun onSearchDateRangeChange(fromEpochSeconds: Int?, toEpochSeconds: Int?)
fun onClearHistory()
+ fun onLeaveChat()
fun onDeleteChat()
fun onReport()
fun onReportMessage(message: MessageModel)
@@ -212,6 +224,7 @@ interface ChatComponent {
fun onReplyMarkupButtonClick(messageId: Long, button: KeyboardButtonModel, botUserId: Long)
fun onLinkClick(url: String)
+ fun onChannelSponsoredMessageClick(messageId: Long, url: String, isMediaClick: Boolean)
fun onOpenInvoice(slug: String? = null, messageId: Long? = null)
fun onDismissInvoice(status: String)
@@ -230,6 +243,17 @@ interface ChatComponent {
fun onLoadMoreInlineResults(offset: String)
fun onSendInlineResult(resultId: String)
fun onOpenAttachBot(botUserId: Long, fallbackName: String)
+ fun onStageAttachments(attachments: List)
+ fun onClearPendingAttachments()
+ fun onConsumeInitialShare(requestId: Long)
+ fun onRemovePendingAttachment(path: String)
+ fun onReplacePendingAttachment(oldPath: String, attachment: PendingAttachment)
+ fun onSendPendingAttachments(
+ attachments: List,
+ caption: String,
+ captionEntities: List = emptyList(),
+ sendOptions: MessageSendOptions = MessageSendOptions()
+ )
@Stable
data class State(
@@ -278,10 +302,14 @@ interface ChatComponent {
val isDraftLinkPreviewLoading: Boolean = false,
val draftLinkPreviewError: String? = null,
val isDraftLinkPreviewDisabledForSend: Boolean = false,
+ val initialShare: IncomingShareRequest? = null,
+ val initialShareConsumed: Boolean = false,
+ val stagedAttachments: List = emptyList(),
val pinnedMessage: MessageModel? = null,
val allPinnedMessages: List = emptyList(),
val showPinnedMessagesList: Boolean = false,
val isLoadingPinnedMessages: Boolean = false,
+ val isPinnedMessageHidden: Boolean = false,
val pinnedMessageCount: Int = 0,
val pinnedMessageIndex: Int = 0,
val scrollToMessageId: Long? = null,
@@ -363,6 +391,7 @@ interface ChatComponent {
val searchDateFromEpochSeconds: Int? = null,
val searchDateToEpochSeconds: Int? = null,
val showReportDialog: Boolean = false,
+ val actionState: ChatActionState = ChatActionState.Idle,
val isBot: Boolean = false,
val botCommands: List = emptyList(),
val botMenuButton: BotMenuButtonModel = BotMenuButtonModel.Default,
@@ -373,6 +402,7 @@ interface ChatComponent {
val invoiceMessageId: Long? = null,
val mentionSuggestions: List = emptyList(),
val isMember: Boolean = true,
+ val canDeleteChat: Boolean = false,
val restrictUserId: Long? = null,
val inlineBotResults: InlineBotResultsModel? = null,
val currentInlineBotId: Long? = null,
@@ -384,5 +414,17 @@ interface ChatComponent {
val scheduledMessages: List = emptyList(),
val lastReadInboxMessageId: Long = 0L,
val unreadSeparatorLastReadInboxMessageId: Long = 0L,
- )
+ val channelSponsoredMessages: SponsoredMessagesFeedModel? = null,
+ val showSponsoredMessagesForPremium: Boolean = false,
+ ) {
+ val pendingMediaPaths: List
+ get() = stagedAttachments.filterNot(PendingAttachment::isDocument).map { it.localPath }
+
+ val pendingDocumentPaths: List
+ get() = stagedAttachments.filter(PendingAttachment::isDocument).map { it.localPath }
+
+ val hasMixedPendingAttachments: Boolean
+ get() = stagedAttachments.any { it.kind == PendingAttachmentKind.DOCUMENT } &&
+ stagedAttachments.any { it.kind != PendingAttachmentKind.DOCUMENT }
+ }
}
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt
index 4f7add49..45b21f16 100644
--- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt
@@ -45,6 +45,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
+import org.monogram.presentation.core.ui.ScreenSwipeBackState
import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled
import org.monogram.presentation.features.chats.conversation.ui.LocalVoicePlaybackController
import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentBackground
@@ -57,6 +58,7 @@ import org.monogram.presentation.features.chats.conversation.ui.content.extractT
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatChromeState
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentBodyUiState
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentPermissionState
+import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentPreviewState
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatMessageListState
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatMessagePresentationState
import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatSearchUiState
@@ -68,13 +70,20 @@ import org.monogram.presentation.features.chats.conversation.ui.message.PreviewI
import org.monogram.presentation.features.chats.conversation.ui.message.PreviewVideoViewerRequest
import org.monogram.presentation.features.chats.conversation.ui.message.rememberChatMessageRenderDependencies
import org.monogram.presentation.features.chats.conversation.ui.rememberVoicePlaybackController
+import org.monogram.presentation.features.share.PendingAttachment
+import org.monogram.presentation.features.share.PendingAttachmentKind
+import org.monogram.presentation.features.share.inferPendingAttachmentKind
-internal fun shouldHideChatContentForViewportTransition(
+internal fun updateChatContentVisibilityLatch(
+ previousVisible: Boolean,
renderMode: ChatRenderMode,
viewportPhase: ChatViewportPhase
): Boolean {
- return renderMode != ChatRenderMode.SwipePreview &&
- viewportPhase != ChatViewportPhase.Settled
+ return when (renderMode) {
+ ChatRenderMode.Active -> previousVisible || viewportPhase == ChatViewportPhase.Settled
+ ChatRenderMode.SwipePreview,
+ ChatRenderMode.ForumTopicSwipePreview -> true
+ }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -82,12 +91,20 @@ internal fun shouldHideChatContentForViewportTransition(
fun ChatContent(
component: ChatComponent,
renderMode: ChatRenderMode = ChatRenderMode.Active,
- onSwipeBackBlockedChanged: (Boolean) -> Unit = {},
+ onSwipeBackStateChanged: (ScreenSwipeBackState) -> Unit = {},
) {
val state by component.state.collectAsState()
+ val uiInstanceId = remember { ChatConversationLog.nextUiInstanceId() }
+ val componentInstanceId = (component as? DefaultChatComponent)?.componentInstanceId
+ val previewState = rememberChatContentPreviewState(state, renderMode)
val conversationKey =
- remember(state.chatId, state.currentTopicId, state.currentMessageThreadId) {
- "${state.chatId}:${state.currentMessageThreadId ?: state.currentTopicId ?: 0L}"
+ remember(
+ state.chatId,
+ previewState.currentTopicId,
+ state.currentMessageThreadId,
+ renderMode
+ ) {
+ "${state.chatId}:${state.currentMessageThreadId ?: previewState.currentTopicId ?: 0L}:$renderMode"
}
val scrollState = rememberSaveable(conversationKey, saver = LazyListState.Saver) {
LazyListState()
@@ -104,7 +121,13 @@ fun ChatContent(
isTabletInterfaceEnabled
var isVisible by remember(conversationKey) {
- mutableStateOf(!shouldHideChatContentForViewportTransition(renderMode, state.viewportPhase))
+ mutableStateOf(
+ updateChatContentVisibilityLatch(
+ previousVisible = false,
+ renderMode = renderMode,
+ viewportPhase = state.viewportPhase
+ )
+ )
}
var showInitialLoading by remember(conversationKey) { mutableStateOf(false) }
var isRecordingVideo by remember(conversationKey) { mutableStateOf(false) }
@@ -118,16 +141,6 @@ fun ChatContent(
var clickOffset by remember(conversationKey) { mutableStateOf(Offset.Zero) }
var contentRect by remember { mutableStateOf(Rect.Zero) }
- var pendingMediaPaths by rememberSaveable(conversationKey) {
- mutableStateOf>(
- emptyList()
- )
- }
- var pendingDocumentPaths by rememberSaveable(conversationKey) {
- mutableStateOf>(
- emptyList()
- )
- }
var editingPhotoPath by rememberSaveable(conversationKey) { mutableStateOf(null) }
var editingVideoPath by rememberSaveable(conversationKey) { mutableStateOf(null) }
var pendingBlockUserId by rememberSaveable(conversationKey) { mutableStateOf(null) }
@@ -166,13 +179,14 @@ fun ChatContent(
val isDragged by scrollState.interactionSource.collectIsDraggedAsState()
val messagePresentationState = rememberChatMessagePresentationState(
state = state,
+ previewState = previewState,
selectedMessageId = selectedMessageId,
transformedMessageTexts = transformedMessageTexts
)
- val bodyUiState = rememberChatContentBodyUiState(state)
+ val bodyUiState = rememberChatContentBodyUiState(state, previewState)
val searchUiState = rememberChatSearchUiState(state)
val permissionState = rememberChatContentPermissionState(state)
- val topBarUiState = rememberChatTopBarUiState(state)
+ val topBarUiState = rememberChatTopBarUiState(state, previewState)
val chromeState = rememberChatChromeState(
state = state,
isRecordingVideo = isRecordingVideo,
@@ -182,18 +196,12 @@ fun ChatContent(
)
val messageListState = rememberChatMessageListState(
state = state,
+ previewState = previewState,
displayMessages = messagePresentationState.displayMessages,
canSendAnything = permissionState.canSendAnything,
showInitialLoading = showInitialLoading
)
- val messageRenderDependencies by rememberChatMessageRenderDependencies(
- messages = remember(messagePresentationState.displayMessages, state.rootMessage) {
- buildList {
- addAll(messagePresentationState.displayMessages)
- state.rootMessage?.let(::add)
- }
- }
- )
+ val messageRenderDependencies by rememberChatMessageRenderDependencies()
val voicePlaybackController = rememberVoicePlaybackController()
val isAnyViewerOpen by remember(
@@ -253,25 +261,73 @@ fun ChatContent(
}
}
- LaunchedEffect(isAnyViewerOpen, onSwipeBackBlockedChanged) {
- onSwipeBackBlockedChanged(isAnyViewerOpen)
+ LaunchedEffect(state, isAnyViewerOpen, onSwipeBackStateChanged) {
+ onSwipeBackStateChanged(
+ resolveChatSwipeBackState(
+ state = state,
+ hasTransientBlockingOverlay = isAnyViewerOpen
+ )
+ )
}
- DisposableEffect(onSwipeBackBlockedChanged) {
- onDispose { onSwipeBackBlockedChanged(false) }
+ LaunchedEffect(state.initialShare?.requestId, conversationKey) {
+ val share = state.initialShare ?: return@LaunchedEffect
+ if (state.initialShareConsumed) return@LaunchedEffect
+
+ if (share.attachments.isNotEmpty()) {
+ component.onStageAttachments(share.attachments)
+ }
+ if (share.text.isNotBlank() && state.draftText != share.text) {
+ component.onDraftChange(share.text)
+ }
+ component.onConsumeInitialShare(share.requestId)
}
- LaunchedEffect(renderMode, state.viewportPhase, conversationKey) {
- if (renderMode == ChatRenderMode.SwipePreview) {
- isVisible = true
- } else if (shouldHideChatContentForViewportTransition(renderMode, state.viewportPhase)) {
- isVisible = false
+ DisposableEffect(onSwipeBackStateChanged) {
+ onDispose { onSwipeBackStateChanged(ScreenSwipeBackState()) }
+ }
+
+ DisposableEffect(conversationKey) {
+ ChatConversationLog.logState(
+ stream = ChatConversationLog.STREAM_VIEWPORT,
+ event = "ui_attach",
+ state = state,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
+ extra = "conversationKey=$conversationKey renderMode=$renderMode"
+ )
+ onDispose {
+ ChatConversationLog.logState(
+ stream = ChatConversationLog.STREAM_VIEWPORT,
+ event = "ui_dispose",
+ state = state,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
+ extra = "conversationKey=$conversationKey renderMode=$renderMode"
+ )
}
}
+ LaunchedEffect(renderMode, state.viewportPhase, conversationKey) {
+ val previousVisible = isVisible
+ isVisible = updateChatContentVisibilityLatch(
+ previousVisible = isVisible,
+ renderMode = renderMode,
+ viewportPhase = state.viewportPhase
+ )
+ ChatConversationLog.logState(
+ stream = ChatConversationLog.STREAM_VIEWPORT,
+ event = "visibility_latch",
+ state = state,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
+ extra = "conversationKey=$conversationKey renderMode=$renderMode previousVisible=$previousVisible nextVisible=$isVisible"
+ )
+ }
+
val effectsEnabled = renderMode == ChatRenderMode.Active
val shouldAnimateContentEntrance = effectsEnabled && state.isChatAnimationsEnabled
- val shouldShowContent = renderMode == ChatRenderMode.SwipePreview || isVisible
+ val shouldShowContent = renderMode != ChatRenderMode.Active || isVisible
val chromeAlpha = 1f
val messagesAlpha by animateFloatAsState(
targetValue = if (shouldShowContent) 1f else 0f,
@@ -293,6 +349,8 @@ fun ChatContent(
isComments = messagePresentationState.isComments,
isForumList = messagePresentationState.isForumList,
effectsEnabled = effectsEnabled,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
isDragged = isDragged,
isRecordingVideo = isRecordingVideo,
showInitialLoading = showInitialLoading,
@@ -394,14 +452,42 @@ fun ChatContent(
state = state,
component = component,
chromeState = chromeState,
- pendingMediaPaths = pendingMediaPaths,
- pendingDocumentPaths = pendingDocumentPaths,
+ pendingMediaPaths = state.pendingMediaPaths,
+ pendingDocumentPaths = state.pendingDocumentPaths,
onDraftLinkPreviewAction = onLinkPreviewAction,
- onPendingMediaPathsChanged = { pendingMediaPaths = it },
- onPendingDocumentPathsChanged = { pendingDocumentPaths = it },
+ onPendingMediaPathsChanged = { mediaPaths ->
+ val documents =
+ state.stagedAttachments.filter { it.kind == PendingAttachmentKind.DOCUMENT }
+ val media = mediaPaths.map { path ->
+ val existing =
+ state.stagedAttachments.firstOrNull { it.localPath == path }
+ existing ?: PendingAttachment(
+ localPath = path,
+ kind = inferPendingAttachmentKind(path)
+ )
+ }
+ component.onStageAttachments(media + documents)
+ },
+ onPendingDocumentPathsChanged = { documentPaths ->
+ val media =
+ state.stagedAttachments.filter { it.kind != PendingAttachmentKind.DOCUMENT }
+ val documents = documentPaths.map { path ->
+ val existing =
+ state.stagedAttachments.firstOrNull { it.localPath == path }
+ existing ?: PendingAttachment(
+ localPath = path,
+ kind = PendingAttachmentKind.DOCUMENT
+ )
+ }
+ component.onStageAttachments(media + documents)
+ },
onStartRecordingVideo = { isRecordingVideo = true },
onEditMediaPath = { path ->
- if (path.endsWith(".mp4")) {
+ val existingKind =
+ state.stagedAttachments.firstOrNull { it.localPath == path }?.kind
+ if ((existingKind
+ ?: inferPendingAttachmentKind(path)) == PendingAttachmentKind.VIDEO
+ ) {
editingVideoPath = path
} else {
editingPhotoPath = path
@@ -420,6 +506,7 @@ fun ChatContent(
state = bodyUiState,
component = component,
scrollState = scrollState,
+ isDragged = isDragged,
messageListState = messageListState,
groupedMessages = messagePresentationState.groupedMessages,
searchUiState = searchUiState,
@@ -515,11 +602,12 @@ fun ChatContent(
onClosePhotoEditor = { editingPhotoPath = null },
onSavePhotoEditor = { newPath ->
val path = editingPhotoPath ?: return@ChatContentOverlays
- val newList = pendingMediaPaths.toMutableList()
- val index = newList.indexOf(path)
- if (index != -1) {
- newList[index] = newPath
- pendingMediaPaths = newList
+ val previous = state.stagedAttachments.firstOrNull { it.localPath == path }
+ if (previous != null) {
+ component.onReplacePendingAttachment(
+ oldPath = path,
+ attachment = previous.copy(localPath = newPath)
+ )
}
editingPhotoPath = null
},
@@ -527,11 +615,12 @@ fun ChatContent(
onCloseVideoEditor = { editingVideoPath = null },
onSaveVideoEditor = { newPath ->
val path = editingVideoPath ?: return@ChatContentOverlays
- val newList = pendingMediaPaths.toMutableList()
- val index = newList.indexOf(path)
- if (index != -1) {
- newList[index] = newPath
- pendingMediaPaths = newList
+ val previous = state.stagedAttachments.firstOrNull { it.localPath == path }
+ if (previous != null) {
+ component.onReplacePendingAttachment(
+ oldPath = path,
+ attachment = previous.copy(localPath = newPath)
+ )
}
editingVideoPath = null
},
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt
new file mode 100644
index 00000000..ed976bc9
--- /dev/null
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt
@@ -0,0 +1,199 @@
+package org.monogram.presentation.features.chats.conversation
+
+import android.os.Trace
+import android.util.Log
+import org.monogram.core.perf.ChatOpenPerfDebug
+import org.monogram.presentation.BuildConfig
+import org.monogram.presentation.features.chats.conversation.logic.effectiveThreadId
+import java.util.concurrent.atomic.AtomicInteger
+
+internal data class ConversationLoadSession(
+ val sessionId: String,
+ val source: String,
+ val target: String,
+ val startedAtMs: Long = android.os.SystemClock.elapsedRealtime()
+)
+
+internal data class ChatInitialLoadKey(
+ val chatId: Long,
+ val effectiveThreadId: Long?,
+ val initialMessageId: Long?,
+ val savedViewportAnchorMessageId: Long?,
+ val firstUnreadMessageId: Long?,
+ val rootMessageId: Long?
+)
+
+internal object ChatConversationLog {
+ internal const val TAG = ChatOpenPerfDebug.TAG
+ internal const val STREAM_VIEWPORT = "viewport"
+ internal const val STREAM_PERF = ChatOpenPerfDebug.STREAM_PERF
+
+ private val componentCounter = AtomicInteger(1)
+ private val uiCounter = AtomicInteger(1)
+
+ fun isEnabled(): Boolean = BuildConfig.DEBUG
+
+ inline fun trace(section: String, block: () -> T): T {
+ if (!isEnabled()) return block()
+ Trace.beginSection(section)
+ return try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
+ }
+
+ suspend inline fun traceSuspend(section: String, crossinline block: suspend () -> T): T {
+ if (!isEnabled()) return block()
+ Trace.beginSection(section)
+ return try {
+ block()
+ } finally {
+ Trace.endSection()
+ }
+ }
+
+ fun nextComponentInstanceId(chatId: Long): String {
+ return "cmp${componentCounter.getAndIncrement()}@$chatId"
+ }
+
+ fun nextUiInstanceId(): String {
+ return "ui${uiCounter.getAndIncrement()}"
+ }
+
+ fun logState(
+ stream: String,
+ event: String,
+ state: ChatComponent.State,
+ componentInstanceId: String? = null,
+ uiInstanceId: String? = null,
+ extra: String? = null
+ ) {
+ if (!isEnabled()) return
+ Log.d(
+ TAG,
+ buildString {
+ append("stream=").append(stream)
+ append(" event=").append(event)
+ append(" component=").append(componentInstanceId ?: "none")
+ append(" ui=").append(uiInstanceId ?: "none")
+ append(" chatId=").append(state.chatId)
+ append(" threadId=").append(
+ state.currentMessageThreadId ?: state.currentTopicId ?: 0L
+ )
+ append(" viewport=").append(state.viewportPhase)
+ append(" pending=").append(
+ state.pendingScrollCommand?.javaClass?.simpleName ?: "none"
+ )
+ append(" loading=").append(state.isLoading)
+ append(" loadingOlder=").append(state.isLoadingOlder)
+ append(" loadingNewer=").append(state.isLoadingNewer)
+ append(" messages=").append(state.messages.size)
+ append(" topics=").append(state.topics.size)
+ append(" atBottom=").append(state.isAtBottom)
+ append(" latestLoaded=").append(state.isLatestLoaded)
+ append(" oldestLoaded=").append(state.isOldestLoaded)
+ append(" scrollToMessageId=").append(state.scrollToMessageId ?: 0L)
+ append(" savedAnchor=").append(state.lastSavedViewport?.anchorMessageId ?: 0L)
+ append(" currentTopicId=").append(state.currentTopicId ?: 0L)
+ append(" rootMessageId=").append(state.rootMessage?.id ?: 0L)
+ if (!extra.isNullOrBlank()) {
+ append(" ").append(extra)
+ }
+ }
+ )
+ }
+
+ fun logViewportState(
+ event: String,
+ state: ChatComponent.State,
+ componentInstanceId: String? = null,
+ uiInstanceId: String? = null,
+ extra: String? = null
+ ) {
+ logState(
+ stream = STREAM_VIEWPORT,
+ event = event,
+ state = state,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
+ extra = extra
+ )
+ }
+
+ fun log(
+ stream: String,
+ chatId: Long,
+ threadId: Long?,
+ event: String,
+ componentInstanceId: String? = null,
+ uiInstanceId: String? = null,
+ extra: String? = null
+ ) {
+ if (!isEnabled()) return
+ Log.d(
+ TAG,
+ buildString {
+ append("stream=").append(stream)
+ append(" event=").append(event)
+ append(" component=").append(componentInstanceId ?: "none")
+ append(" ui=").append(uiInstanceId ?: "none")
+ append(" chatId=").append(chatId)
+ append(" threadId=").append(threadId ?: 0L)
+ if (!extra.isNullOrBlank()) {
+ append(" ").append(extra)
+ }
+ }
+ )
+ }
+
+ fun logViewport(
+ chatId: Long,
+ threadId: Long?,
+ event: String,
+ componentInstanceId: String? = null,
+ uiInstanceId: String? = null,
+ extra: String? = null
+ ) {
+ log(
+ stream = STREAM_VIEWPORT,
+ chatId = chatId,
+ threadId = threadId,
+ event = event,
+ componentInstanceId = componentInstanceId,
+ uiInstanceId = uiInstanceId,
+ extra = extra
+ )
+ }
+
+ fun logPerf(
+ component: DefaultChatComponent,
+ phase: String,
+ source: String? = null,
+ target: String? = null,
+ anchorId: Long? = null,
+ messagesBefore: Int? = null,
+ messagesAfter: Int? = null,
+ durationMs: Long? = null
+ ) {
+ if (!isEnabled()) return
+ val state = component.state.value
+ val session = component.activeLoadSession
+ Log.d(
+ TAG,
+ ChatOpenPerfDebug.buildLogMessage(
+ chatId = component.chatId,
+ threadId = state.effectiveThreadId(),
+ event = phase,
+ componentInstanceId = component.componentInstanceId,
+ source = source ?: session?.source,
+ target = target ?: session?.target,
+ anchorId = anchorId ?: state.lastSavedViewport?.anchorMessageId,
+ messagesBefore = messagesBefore ?: state.messages.size,
+ messagesAfter = messagesAfter ?: state.messages.size,
+ unreadCount = state.unreadSeparatorCount,
+ durationMs = durationMs
+ )
+ )
+ }
+}
diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt
index 44569d02..a541c4b9 100644
--- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt
+++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt
@@ -18,7 +18,6 @@ import java.io.File
interface ChatStore : Store {
sealed class Intent {
- data class UpdateState(val state: ChatComponent.State) : Intent()
data class SendMessage(
val text: String,
val entities: List = emptyList(),
@@ -98,6 +97,8 @@ interface ChatStore : Store() {
+ private inner class ExecutorImpl :
+ CoroutineExecutor() {
override fun executeIntent(intent: Intent) {
when (intent) {
- is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state))
is Intent.SendMessage -> component.handleSendMessage(intent.text, intent.entities, intent.sendOptions)
is Intent.SendSticker -> component.handleSendSticker(intent.stickerPath)
is Intent.SendPhoto -> component.handleSendPhoto(
@@ -242,6 +245,10 @@ class ChatStoreFactory(
is Intent.UnpinMessage -> component.handleUnpinMessage(intent.message)
+ is Intent.HidePinnedMessage -> component.handleHidePinnedMessage()
+
+ is Intent.ShowPinnedMessage -> component.handleShowPinnedMessage()
+
is Intent.PinnedMessageClick -> component.handlePinnedMessageClick(intent.message)
is Intent.ShowAllPinnedMessages -> {
component._state.update {
@@ -384,6 +391,7 @@ class ChatStoreFactory(
intent.toEpochSeconds
)
is Intent.ClearHistory -> component.handleClearHistory()
+ is Intent.LeaveChat -> component.handleLeaveChat()
is Intent.DeleteChat -> component.handleDeleteChat()
@@ -463,14 +471,7 @@ class ChatStoreFactory(
.toList()
}
- private object ReducerImpl : Reducer