diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 00000000..87f7b56b
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,63 @@
+name: Android CI
+
+on:
+ pull_request:
+ branches: ["master", "develop"]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+ with:
+ submodules: recursive
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Make all scripts executable
+ run: |
+ chmod +x gradlew
+ chmod +x presentation/src/main/cpp/build.sh
+
+ - name: Build libvpx
+ run: cd presentation/src/main/cpp && ./build.sh
+
+ - name: Build Debug APK (without GMS)
+ run: ./gradlew assembleDebug
+
+ - name: Run Unit Tests
+ run: ./gradlew testDebugUnitTest
+
+ - name: Upload universal APK
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-universal-no-appid
+ path: app/build/outputs/apk/debug/app-universal-debug.apk
+
+ - name: Upload arm64-v8a APK
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-arm64-v8a-no-appid
+ path: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
+
+ - name: Upload armeabi-v7a APK
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-armeabi-v7a-no-appid
+ path: app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk
+
+ - name: Upload x86_64 APK
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-x86_64-no-appid
+ path: app/build/outputs/apk/debug/app-x86_64-debug.apk
diff --git a/README.md b/README.md
index 5944817d..ed38bf06 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
-
+
diff --git a/README_ES.md b/README_ES.md
index 107687b7..58e33ae5 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -15,7 +15,7 @@
-
+
diff --git a/README_KOR.md b/README_KOR.md
index bc0d3411..85f653ae 100644
--- a/README_KOR.md
+++ b/README_KOR.md
@@ -15,7 +15,7 @@
-
+
diff --git a/README_RU.md b/README_RU.md
index 772f13b1..8d5552cd 100644
--- a/README_RU.md
+++ b/README_RU.md
@@ -15,7 +15,7 @@
-
+
diff --git a/README_UR.md b/README_UR.md
index 9b0ddee8..6515691e 100644
--- a/README_UR.md
+++ b/README_UR.md
@@ -15,7 +15,7 @@
-
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8fcdcc0a..ff16f497 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,11 +1,11 @@
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.FilterConfiguration
+import com.android.build.api.variant.impl.VariantOutputImpl
import com.google.android.gms.oss.licenses.plugin.DependencyTask
import com.google.gms.googleservices.GoogleServicesPlugin
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.google.oss.licenses)
alias(libs.plugins.google.services)
@@ -20,8 +20,8 @@ android {
applicationId = "org.monogram"
minSdk = 25
targetSdk = 36
- versionCode = 6
- versionName = "0.0.6"
+ versionCode = 7
+ versionName = "0.0.7"
}
splits {
@@ -45,10 +45,16 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ buildFeatures {
+ resValues = true
+ }
signingConfig = signingConfigs.getByName("debug")
resValue("string", "app_name", "MonoGram")
}
debug {
+ buildFeatures {
+ resValues = true
+ }
applicationIdSuffix = ".debug"
isMinifyEnabled = false
resValue("string", "app_name", "MonoGram Debug")
@@ -58,9 +64,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
- kotlin {
- jvmToolchain(21)
- }
buildFeatures {
compose = true
}
@@ -68,37 +71,41 @@ android {
androidComponents {
onVariants { variant ->
+ if (variant.buildType != "release") return@onVariants
+
+ variant.outputs.forEach { output ->
+ val variantOutput = output as? VariantOutputImpl ?: return@forEach
+ val abi = variantOutput.filters.find {
+ it.filterType == FilterConfiguration.FilterType.ABI
+ }?.identifier ?: "universal"
+ val versionName = variantOutput.versionName.orNull ?: "unknown"
+
+ variantOutput.outputFileName.set(
+ "monogram-$abi-$versionName-${variant.buildType}.apk"
+ )
+ }
+
val apkDirProvider = variant.artifacts.get(SingleArtifact.APK)
- val artifactsLoader = variant.artifacts.getBuiltArtifactsLoader()
-
- val renameTask = tasks.register("rename${variant.name.capitalize()}Apk") {
- inputs.dir(apkDirProvider)
-
- doLast {
- val builtArtifacts = artifactsLoader.load(apkDirProvider.get())!!
- val targetDir = apkDirProvider.get().asFile
-
- builtArtifacts.elements.forEach { artifact ->
- val abi = artifact.filters.find {
- it.filterType == FilterConfiguration.FilterType.ABI
- }?.identifier ?: "universal"
- val versionName = artifact.versionName
- val versionCode = artifact.versionCode
- val buildType = variant.buildType
-
- val originalApk = File(artifact.outputFile)
- val targetFile = File(
- targetDir,
- "monogram-$abi-${versionName}(${versionCode})-${buildType}.apk"
- )
-
- originalApk.copyTo(targetFile, overwrite = true)
- }
+
+ val capitalizedVariantName = variant.name.replaceFirstChar {
+ if (it.isLowerCase()) it.titlecase() else it.toString()
+ }
+
+ val copyTask = tasks.register("copy${capitalizedVariantName}Apk") {
+ from(apkDirProvider)
+ include("*.apk")
+ into(layout.projectDirectory.dir("releases"))
+
+ doFirst {
+ destinationDir.mkdirs()
+ destinationDir.listFiles()
+ ?.filter { it.isFile && it.extension == "apk" && it.name.startsWith("monogram-") }
+ ?.forEach { it.delete() }
}
}
- project.tasks.matching { it.name == "assemble${variant.name.capitalize()}" }.configureEach {
- finalizedBy(renameTask)
+ project.tasks.matching { it.name == "assemble${capitalizedVariantName}" }.configureEach {
+ finalizedBy(copyTask)
}
}
}
@@ -106,6 +113,7 @@ androidComponents {
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.androidx.compose)
+ implementation(libs.androidx.core.splashscreen)
implementation(libs.bundles.decompose)
implementation(libs.bundles.koin)
@@ -131,14 +139,20 @@ dependencies {
tasks.withType(DependencyTask::class.java).configureEach {
if (name == "debugOssDependencyTask") {
- val releaseTaskProvider = project.tasks.named("releaseOssDependencyTask")
+ val releaseJsonProvider =
+ layout.buildDirectory.file("generated/third_party_licenses/release/dependencies.json")
+ val debugJsonProvider =
+ layout.buildDirectory.file("generated/third_party_licenses/debug/dependencies.json")
- dependsOn(releaseTaskProvider)
+ dependsOn("releaseOssDependencyTask")
doLast {
- val releaseJson = releaseTaskProvider.get().dependenciesJson.get().asFile
- val debugJson = dependenciesJson.get().asFile
- if (releaseJson.exists()) releaseJson.copyTo(debugJson, overwrite = true)
+ val releaseJson = releaseJsonProvider.get().asFile
+ val debugJson = debugJsonProvider.get().asFile
+ if (releaseJson.exists()) {
+ debugJson.parentFile?.mkdirs()
+ releaseJson.copyTo(debugJson, overwrite = true)
+ }
}
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4edad1af..d799dff8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,7 +42,7 @@
android:label="@string/app_name"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
- android:theme="@style/Theme.MonoGram"
+ android:theme="@style/Theme.MonoGram.Startup"
android:windowSoftInputMode="adjustResize"
tools:targetApi="33">
diff --git a/app/src/main/java/org/monogram/app/App.kt b/app/src/main/java/org/monogram/app/App.kt
index 3ec92b19..9e70a00a 100644
--- a/app/src/main/java/org/monogram/app/App.kt
+++ b/app/src/main/java/org/monogram/app/App.kt
@@ -12,6 +12,7 @@ import org.koin.core.context.startKoin
import org.maplibre.android.MapLibre
import org.maplibre.android.WellKnownTileServer
import org.monogram.app.di.appModule
+import org.monogram.data.infra.DataMemoryPressureHandler
import org.monogram.domain.managers.DistrManager
import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.PushProvider
@@ -33,6 +34,19 @@ class App : Application(), SingletonImageLoader.Factory {
checkPushAvailability()
}
+ @Suppress("DEPRECATION")
+ override fun onTrimMemory(level: Int) {
+ super.onTrimMemory(level)
+ if (level >= TRIM_MEMORY_RUNNING_LOW) {
+ trimInMemoryCaches("onTrimMemory:$level")
+ }
+ }
+
+ override fun onLowMemory() {
+ super.onLowMemory()
+ trimInMemoryCaches("onLowMemory")
+ }
+
private fun initCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -79,7 +93,26 @@ class App : Application(), SingletonImageLoader.Factory {
}
}
+ private fun trimInMemoryCaches(reason: String) {
+ if (!::container.isInitialized) return
+ runCatching {
+ get().clearDataCaches(reason)
+ }.onFailure { error ->
+ Log.w(TAG, "Failed to clear data caches for $reason", error)
+ }
+
+ runCatching {
+ get().memoryCache?.clear()
+ }.onFailure { error ->
+ Log.w(TAG, "Failed to clear Coil memory cache for $reason", error)
+ }
+ }
+
override fun newImageLoader(context: PlatformContext): ImageLoader {
return get()
}
+
+ companion object {
+ private const val TAG = "App"
+ }
}
diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt
index 4d4ec909..c051f5d3 100644
--- a/app/src/main/java/org/monogram/app/MainActivity.kt
+++ b/app/src/main/java/org/monogram/app/MainActivity.kt
@@ -3,29 +3,52 @@ package org.monogram.app
import android.content.Intent
import android.os.Build
import android.os.Bundle
+import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.window.layout.WindowInfoTracker
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.retainedComponent
import org.koin.android.ext.android.inject
import org.monogram.app.ui.theme.AppThemeContainer
import org.monogram.data.service.TdNotificationService
-import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.PushProvider
+import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.LocalVideoPlayerPool
+import org.monogram.presentation.core.util.NightMode
import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler
import org.monogram.presentation.root.DefaultAppComponentContext
import org.monogram.presentation.root.DefaultRootComponent
import org.monogram.presentation.root.RootComponent
+import java.util.Calendar
class MainActivity : FragmentActivity() {
private lateinit var root: RootComponent
- private val appPreferences: AppPreferencesProvider by inject()
+ private val appPreferences: AppPreferences by inject()
+
+ @Volatile
+ private var keepSplashOnScreen: Boolean = true
@OptIn(ExperimentalDecomposeApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
+ setTheme(resolveStartupTheme())
+ val splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ splashScreen.setOnExitAnimationListener { provider ->
+ provider.view.animate()
+ .alpha(0f)
+ .setDuration(220L)
+ .withEndAction { provider.remove() }
+ .start()
+ }
+ }
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -39,15 +62,27 @@ class MainActivity : FragmentActivity() {
}
handleIntent(intent)
- startNotificationService()
+
+ val windowInfoTracker = WindowInfoTracker.getOrCreate(this)
setContent {
+ LaunchedEffect(Unit) {
+ keepSplashOnScreen = false
+ startNotificationService()
+ }
+
+ val windowLayoutInfo by windowInfoTracker.windowLayoutInfo(this)
+ .collectAsStateWithLifecycle(initialValue = null)
+
AppThemeContainer(root.appPreferences) {
CompositionLocalProvider(
- LocalLinkHandler provides root::handleLink,
- LocalVideoPlayerPool provides root.videoPlayerPool
+ LocalLinkHandler provides root::handleLink,
+ LocalVideoPlayerPool provides root.videoPlayerPool
) {
- MainContent(root)
+ MainContent(
+ root = root,
+ windowLayoutInfo = windowLayoutInfo
+ )
}
}
}
@@ -80,4 +115,53 @@ class MainActivity : FragmentActivity() {
startService(intent)
}
}
-}
\ No newline at end of file
+
+ private fun resolveStartupTheme(): Int {
+ return when (appPreferences.nightMode.value) {
+ NightMode.SYSTEM -> R.style.Theme_MonoGram_Startup
+ NightMode.LIGHT -> R.style.Theme_MonoGram_Startup_Light
+ NightMode.DARK -> R.style.Theme_MonoGram_Startup_Dark
+ NightMode.SCHEDULED -> {
+ val calendar = Calendar.getInstance()
+ val now = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE)
+
+ val start = appPreferences.nightModeStartTime.value
+ .split(":")
+ .takeIf { it.size == 2 }
+ ?.let { it[0].toIntOrNull()?.times(60)?.plus(it[1].toIntOrNull() ?: 0) }
+ ?: 22 * 60
+
+ val end = appPreferences.nightModeEndTime.value
+ .split(":")
+ .takeIf { it.size == 2 }
+ ?.let { it[0].toIntOrNull()?.times(60)?.plus(it[1].toIntOrNull() ?: 0) }
+ ?: 7 * 60
+
+ if (start < end) {
+ if (now in start until end) {
+ R.style.Theme_MonoGram_Startup_Dark
+ } else {
+ R.style.Theme_MonoGram_Startup_Light
+ }
+ } else {
+ if (now >= start || now < end) {
+ R.style.Theme_MonoGram_Startup_Dark
+ } else {
+ R.style.Theme_MonoGram_Startup_Light
+ }
+ }
+ }
+
+ NightMode.BRIGHTNESS -> {
+ val brightness = runCatching {
+ Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 255)
+ }.getOrDefault(255)
+ if (brightness / 255f <= appPreferences.nightModeBrightnessThreshold.value) {
+ R.style.Theme_MonoGram_Startup_Dark
+ } else {
+ R.style.Theme_MonoGram_Startup_Light
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt
index f9152c95..968a6a4b 100644
--- a/app/src/main/java/org/monogram/app/MainContent.kt
+++ b/app/src/main/java/org/monogram/app/MainContent.kt
@@ -1,10 +1,14 @@
package org.monogram.app
-import androidx.compose.animation.*
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInBack
import androidx.compose.animation.core.EaseOutBack
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,32 +16,86 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.zIndex
-import androidx.window.core.layout.WindowWidthSizeClass
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowLayoutInfo
import com.arkivanov.decompose.extensions.compose.subscribeAsState
-import org.monogram.app.components.*
+import kotlinx.coroutines.delay
+import org.monogram.app.components.ChatConfirmJoinSheet
+import org.monogram.app.components.LockScreen
+import org.monogram.app.components.MobileLayout
+import org.monogram.app.components.ProxyConfirmSheet
+import org.monogram.app.components.TabletLayout
+import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled
+import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentViewers
import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet
+import org.monogram.presentation.features.profile.ProfileViewers
import org.monogram.presentation.features.stickers.core.toDomain
import org.monogram.presentation.root.RootComponent
+import org.monogram.presentation.root.StartupComponent
+import org.monogram.presentation.root.StartupContent
+import androidx.window.core.layout.WindowSizeClass as WindowSizeClassCore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun MainContent(root: RootComponent) {
+fun MainContent(
+ root: RootComponent,
+ windowLayoutInfo: WindowLayoutInfo?
+) {
val childStack by root.childStack.subscribeAsState()
val isLocked by root.isLocked.collectAsState()
- val adaptiveInfo = currentWindowAdaptiveInfo()
- val isExpanded = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
+ val isTabletInterfaceEnabled by root.appPreferences.isTabletInterfaceEnabled.collectAsState()
+ val localClipboard = LocalClipboard.current
+
+ val isExpanded = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClassCore.WIDTH_DP_MEDIUM_LOWER_BOUND) ||
+ windowLayoutInfo?.displayFeatures?.filterIsInstance()?.any {
+ it.orientation == FoldingFeature.Orientation.VERTICAL && it.isSeparating
+ } == true
+
val activeChild = childStack.active.instance
+ val isStartupActive = activeChild is RootComponent.Child.StartupChild
+ val startupChild = activeChild as? RootComponent.Child.StartupChild
+ var startupOverlayComponent by remember { mutableStateOf(null) }
+ var startupOverlayVisible by remember { mutableStateOf(false) }
+ var wasStartupActive by remember { mutableStateOf(isStartupActive) }
+
+ LaunchedEffect(isStartupActive, startupChild?.component) {
+ if (isStartupActive) {
+ startupOverlayComponent = startupChild?.component
+ startupOverlayVisible = false
+ wasStartupActive = true
+ return@LaunchedEffect
+ }
+
+ if (wasStartupActive && startupOverlayComponent != null) {
+ startupOverlayVisible = true
+ delay(90)
+ startupOverlayVisible = false
+ delay(320)
+ startupOverlayComponent = null
+ wasStartupActive = false
+ return@LaunchedEffect
+ }
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)
- ) {
+ wasStartupActive = false
+ }
+
+ CompositionLocalProvider(LocalTabletInterfaceEnabled provides isTabletInterfaceEnabled) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ ) {
val contentScale by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 300),
@@ -53,10 +111,11 @@ fun MainContent(root: RootComponent) {
},
) {
if (isExpanded &&
+ isTabletInterfaceEnabled &&
activeChild !is RootComponent.Child.AuthChild &&
activeChild !is RootComponent.Child.StartupChild
) {
- TabletLayout(root, childStack)
+ TabletLayout(childStack, windowLayoutInfo)
} else {
MobileLayout(root)
}
@@ -78,6 +137,22 @@ fun MainContent(root: RootComponent) {
ChatConfirmJoinSheet(root)
}
+ if (!isStartupActive && startupOverlayComponent != null) {
+ AnimatedVisibility(
+ visible = startupOverlayVisible,
+ enter = fadeIn(tween(80)),
+ exit = fadeOut(tween(260)),
+ modifier = Modifier
+ .fillMaxSize()
+ .zIndex(50f)
+ ) {
+ StartupContent(
+ component = startupOverlayComponent!!,
+ animateIn = false
+ )
+ }
+ }
+
AnimatedVisibility(
visible = isLocked,
enter = fadeIn(tween(400)) + scaleIn(
@@ -94,5 +169,25 @@ fun MainContent(root: RootComponent) {
) {
LockScreen(root)
}
+
+ when (activeChild) {
+ is RootComponent.Child.ChatDetailChild -> {
+ val chatState by activeChild.component.state.collectAsState()
+ ChatContentViewers(
+ state = chatState,
+ component = activeChild.component,
+ localClipboard = localClipboard
+ )
+ }
+ is RootComponent.Child.ProfileChild -> {
+ val profileState by activeChild.component.state.subscribeAsState()
+ ProfileViewers(
+ state = profileState,
+ component = activeChild.component
+ )
+ }
+ else -> {}
+ }
+ }
}
-}
\ No newline at end of file
+}
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 02fa8480..40f04f0d 100644
--- a/app/src/main/java/org/monogram/app/components/MobileLayout.kt
+++ b/app/src/main/java/org/monogram/app/components/MobileLayout.kt
@@ -29,11 +29,20 @@ import org.monogram.presentation.root.RootComponent
@Composable
fun MobileLayout(root: RootComponent) {
val stack by root.childStack.subscribeAsState()
+ val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState()
val coroutineScope = rememberCoroutineScope()
val dragOffsetX = remember { Animatable(0f) }
val previous = stack.items.dropLast(1).lastOrNull()?.instance
var swipeBackInProgress by remember { mutableStateOf(false) }
var widthPx by remember { mutableFloatStateOf(0f) }
+ val canUseDragToBack =
+ isDragToBackEnabled && stack.active.instance is RootComponent.Child.ChatDetailChild
+
+ LaunchedEffect(canUseDragToBack) {
+ if (!canUseDragToBack && dragOffsetX.value > 0f) {
+ dragOffsetX.snapTo(0f)
+ }
+ }
if (dragOffsetX.value > 0 && previous != null) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -57,8 +66,8 @@ fun MobileLayout(root: RootComponent) {
widthPx = it.width.toFloat()
}
.then(
- if (stack.active.instance is RootComponent.Child.ChatDetailChild) {
- Modifier.pointerInput(Unit) {
+ if (canUseDragToBack) {
+ Modifier.pointerInput(canUseDragToBack) {
var isDragging = false
detectHorizontalDragGestures(
onDragStart = { offset ->
diff --git a/app/src/main/java/org/monogram/app/components/TabletLayout.kt b/app/src/main/java/org/monogram/app/components/TabletLayout.kt
index fb5c0491..9f98a745 100644
--- a/app/src/main/java/org/monogram/app/components/TabletLayout.kt
+++ b/app/src/main/java/org/monogram/app/components/TabletLayout.kt
@@ -1,23 +1,85 @@
package org.monogram.app.components
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowLayoutInfo
import com.arkivanov.decompose.router.stack.ChildStack
import org.monogram.app.R
import org.monogram.presentation.root.RootComponent
+import android.os.Build
+import android.view.RoundedCorner
@Composable
-fun TabletLayout(root: RootComponent, childStack: ChildStack<*, RootComponent.Child>) {
+fun TabletLayout(
+ childStack: ChildStack<*, RootComponent.Child>,
+ windowLayoutInfo: WindowLayoutInfo?
+) {
val activeChild = childStack.active.instance
val isSettings = isSettingsSelected(childStack)
+ val density = LocalDensity.current
+ val windowInfo = LocalWindowInfo.current
+ val screenWidthDp = with(density) { windowInfo.containerSize.width.toDp() }
+
+ val view = LocalView.current
+ val deviceCornerRadius = remember(view, density, screenWidthDp) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val insets = view.rootWindowInsets
+ val corners = listOfNotNull(
+ insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT),
+ insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT),
+ insets?.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT),
+ insets?.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)
+ )
+
+ if (corners.isNotEmpty()) {
+ // Use the reported radius if available. On sharp devices, this is 0.
+ val radiusPx = corners.maxOf { it.radius }
+ with(density) { radiusPx.toDp() }
+ } else {
+ // Fallback only if the OS provides no corner information at all.
+ if (screenWidthDp > 600.dp) 28.dp else 16.dp
+ }
+ } else {
+ if (screenWidthDp > 600.dp) 28.dp else 16.dp
+ }
+ }
+
+ val foldingFeature = windowLayoutInfo?.displayFeatures
+ ?.filterIsInstance()
+ ?.find { it.orientation == FoldingFeature.Orientation.VERTICAL }
+
+ val targetListWidth = remember(foldingFeature, screenWidthDp) {
+ if (foldingFeature != null && foldingFeature.isSeparating) {
+ val hingeBounds = foldingFeature.bounds
+ if (hingeBounds.left > 0) {
+ with(density) { hingeBounds.left.toDp() }
+ } else {
+ screenWidthDp / 2
+ }
+ } else {
+ 350.dp
+ }
+ }
+
+ val animatedWidth by animateDpAsState(
+ targetValue = targetListWidth,
+ animationSpec = tween(durationMillis = 500),
+ label = "ListPaneWidth"
+ )
val listChild = remember(childStack) {
val settingsChild = childStack.backStack.find {
@@ -34,28 +96,38 @@ fun TabletLayout(root: RootComponent, childStack: ChildStack<*, RootComponent.Ch
}
}
- Row(Modifier.fillMaxSize()) {
+ val paneCornerRadius = remember(deviceCornerRadius) {
+ val opticalScaling = 1.5f
+ (deviceCornerRadius * opticalScaling - 6.dp).coerceAtLeast(0.dp)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ ) {
Box(
modifier = Modifier
- .width(350.dp)
- .fillMaxHeight(),
+ .width(animatedWidth)
+ .fillMaxHeight()
+ .padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp)
+ .clip(RoundedCornerShape(paneCornerRadius))
+ .background(MaterialTheme.colorScheme.surface),
) {
if (listChild != null) {
RenderChild(listChild)
}
}
- HorizontalDivider(
- modifier = Modifier
- .fillMaxHeight()
- .width(1.dp),
- color = MaterialTheme.colorScheme.outlineVariant,
- )
+ Spacer(modifier = Modifier.width(6.dp))
Box(
modifier = Modifier
.weight(1f)
- .fillMaxHeight(),
+ .fillMaxHeight()
+ .padding(end = 6.dp, top = 6.dp, bottom = 6.dp)
+ .clip(RoundedCornerShape(paneCornerRadius))
+ .background(MaterialTheme.colorScheme.surface),
) {
val isListOnly = activeChild == listChild
diff --git a/app/src/main/java/org/monogram/app/di/AppModule.kt b/app/src/main/java/org/monogram/app/di/AppModule.kt
index e3d35c20..cf75fc9b 100644
--- a/app/src/main/java/org/monogram/app/di/AppModule.kt
+++ b/app/src/main/java/org/monogram/app/di/AppModule.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ClipboardManager
import android.content.Context
import android.telephony.TelephonyManager
+import android.text.format.DateFormat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -11,9 +12,27 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.monogram.core.Logger
import org.monogram.data.di.dataModule
-import org.monogram.domain.managers.*
-import org.monogram.domain.repository.*
-import org.monogram.presentation.core.util.*
+import org.monogram.domain.managers.AssetsManager
+import org.monogram.domain.managers.ClipManager
+import org.monogram.domain.managers.DistrManager
+import org.monogram.domain.managers.DomainManager
+import org.monogram.domain.managers.PhoneManager
+import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.BotPreferencesProvider
+import org.monogram.domain.repository.CacheProvider
+import org.monogram.domain.repository.EditorSnippetProvider
+import org.monogram.domain.repository.ExternalNavigator
+import org.monogram.domain.repository.MessageDisplayer
+import org.monogram.presentation.core.util.AppPreferences
+import org.monogram.presentation.core.util.BotPreferences
+import org.monogram.presentation.core.util.CachePreferences
+import org.monogram.presentation.core.util.DateFormatManager
+import org.monogram.presentation.core.util.DateFormatManagerImpl
+import org.monogram.presentation.core.util.DownloadUtils
+import org.monogram.presentation.core.util.EditorSnippetPreferences
+import org.monogram.presentation.core.util.ExternalNavigatorImpl
+import org.monogram.presentation.core.util.IDownloadUtils
+import org.monogram.presentation.core.util.ToastMessageDisplayer
import org.monogram.presentation.di.uiModule
import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache
import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool
@@ -41,6 +60,8 @@ val appModule = module {
}
single { LoggerImpl() }
+ single { DateFormatManagerImpl(DateFormat.is24HourFormat(androidContext())) }
+
factory {
PhoneManagerImpl(
androidContext().getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager,
diff --git a/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml b/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml
new file mode 100644
index 00000000..7e99aef7
--- /dev/null
+++ b/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable-night/splash_logo_monochrome.xml b/app/src/main/res/drawable-night/splash_logo_monochrome.xml
new file mode 100644
index 00000000..3056482e
--- /dev/null
+++ b/app/src/main/res/drawable-night/splash_logo_monochrome.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable-night/startup_background.xml b/app/src/main/res/drawable-night/startup_background.xml
new file mode 100644
index 00000000..11158fb3
--- /dev/null
+++ b/app/src/main/res/drawable-night/startup_background.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable-v31/splash_logo_monochrome.xml b/app/src/main/res/drawable-v31/splash_logo_monochrome.xml
new file mode 100644
index 00000000..ed6f6d97
--- /dev/null
+++ b/app/src/main/res/drawable-v31/splash_logo_monochrome.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml
new file mode 100644
index 00000000..ae2adee2
--- /dev/null
+++ b/app/src/main/res/drawable/splash_icon.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/app/src/main/res/drawable/splash_logo_monochrome.xml b/app/src/main/res/drawable/splash_logo_monochrome.xml
new file mode 100644
index 00000000..55042a6c
--- /dev/null
+++ b/app/src/main/res/drawable/splash_logo_monochrome.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/startup_background.xml b/app/src/main/res/drawable/startup_background.xml
new file mode 100644
index 00000000..bea5d321
--- /dev/null
+++ b/app/src/main/res/drawable/startup_background.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/startup_background_dark.xml b/app/src/main/res/drawable/startup_background_dark.xml
new file mode 100644
index 00000000..11158fb3
--- /dev/null
+++ b/app/src/main/res/drawable/startup_background_dark.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/startup_background_light.xml b/app/src/main/res/drawable/startup_background_light.xml
new file mode 100644
index 00000000..bea5d321
--- /dev/null
+++ b/app/src/main/res/drawable/startup_background_light.xml
@@ -0,0 +1,14 @@
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml
new file mode 100644
index 00000000..5739f671
--- /dev/null
+++ b/app/src/main/res/raw/keep.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 00000000..25443c79
--- /dev/null
+++ b/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..cf266e2e
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 00000000..072a8603
--- /dev/null
+++ b/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 3a110bdb..01da7a2c 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,4 +1,16 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts
index 86766edd..bdb81339 100644
--- a/baselineprofile/build.gradle.kts
+++ b/baselineprofile/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
id("com.android.test")
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.androidx.baselineprofile)
}
diff --git a/build.gradle.kts b/build.gradle.kts
index c0dbc224..17faf427 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,7 +3,6 @@ import java.util.Properties
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.lint) apply false
alias(libs.plugins.android.library) apply false
diff --git a/core/src/main/kotlin/org/monogram/core/Mapper.kt b/core/src/main/kotlin/org/monogram/core/Mapper.kt
deleted file mode 100644
index c7a496da..00000000
--- a/core/src/main/kotlin/org/monogram/core/Mapper.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.monogram.core
-
-interface Mapper {
- fun map(input: I): O
-}
\ No newline at end of file
diff --git a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt b/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt
deleted file mode 100644
index 8a62a55d..00000000
--- a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.monogram.core
-
-import kotlinx.coroutines.CoroutineScope
-
-interface ScopeProvider {
- val appScope: CoroutineScope
-}
\ No newline at end of file
diff --git a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt b/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt
deleted file mode 100644
index c059a41f..00000000
--- a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.monogram.core
-
-interface SuspendMapper {
- suspend fun map(input: I): O
-}
\ No newline at end of file
diff --git a/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt b/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt
new file mode 100644
index 00000000..4e2aeadd
--- /dev/null
+++ b/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt
@@ -0,0 +1,23 @@
+package org.monogram.core.date
+
+interface DateFormatManager {
+ fun is24HourFormat(): Boolean
+ fun getHourMinuteFormat(): String
+}
+
+class DateFormatManagerImpl(
+ private val use24HourFormat: Boolean
+) : DateFormatManager {
+ override fun is24HourFormat(): Boolean = use24HourFormat
+ override fun getHourMinuteFormat(): String = if (use24HourFormat) "HH:mm" else "h:mm a"
+}
+
+class Fake12HourDateFormatManagerImpl : DateFormatManager {
+ override fun is24HourFormat(): Boolean = false
+ override fun getHourMinuteFormat(): String = "h:mm a"
+}
+
+class Fake24HourDateFormatManagerImpl : DateFormatManager {
+ override fun is24HourFormat(): Boolean = true
+ override fun getHourMinuteFormat(): String = "HH:mm"
+}
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index 4672bba9..96b96568 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -1,8 +1,7 @@
-import java.util.*
+import java.util.Properties
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
@@ -16,7 +15,7 @@ android {
consumerProguardFiles("consumer-rules.pro")
ndk {
- abiFilters += listOf("arm64-v8a")
+ abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
val localProperties: Properties by rootProject.extra
diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt
index a7385306..2e0c8de5 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt
@@ -3,7 +3,6 @@ package org.monogram.data.chats
import org.drinkless.tdlib.TdApi
import org.monogram.data.datasource.cache.ChatsCacheDataSource
import org.monogram.data.datasource.cache.UserCacheDataSource
-import java.io.File
import java.util.concurrent.ConcurrentHashMap
class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
@@ -147,10 +146,18 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
if (user.profilePhoto != null || existing.profilePhoto == null) {
existing.profilePhoto = user.profilePhoto
}
+ existing.accentColorId = user.accentColorId
+ existing.backgroundCustomEmojiId = user.backgroundCustomEmojiId
+ existing.profileAccentColorId = user.profileAccentColorId
+ existing.profileBackgroundCustomEmojiId = user.profileBackgroundCustomEmojiId
existing.emojiStatus = user.emojiStatus
existing.isPremium = user.isPremium
existing.verificationStatus = user.verificationStatus
existing.isSupport = user.isSupport
+ existing.restrictionInfo = user.restrictionInfo
+ existing.activeStoryState = user.activeStoryState
+ existing.restrictsNewChats = user.restrictsNewChats
+ existing.paidMessageStarCount = user.paidMessageStarCount
existing.haveAccess = user.haveAccess
existing.type = user.type
existing.languageCode = user.languageCode
@@ -425,17 +432,6 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource {
)
clientData = "mc:${entity.memberCount};oc:${entity.onlineCount}"
}
- if (entity.photoId != 0 && !entity.avatarPath.isNullOrEmpty()) {
- val avatarFile = File(entity.avatarPath)
- if (avatarFile.exists()) {
- fileCache[entity.photoId] = TdApi.File().apply {
- id = entity.photoId
- local = TdApi.LocalFile().apply {
- this.path = entity.avatarPath
- }
- }
- }
- }
chatPermissionsCache[entity.id] = chat.permissions
onlineMemberCount[entity.id] = entity.onlineCount
putChat(chat)
diff --git a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt
index 04eb3b01..0966f341 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt
@@ -1,9 +1,9 @@
package org.monogram.data.chats
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.FileDownloadQueue
@@ -17,11 +17,9 @@ class ChatFileManager(
private val dispatchers: DispatcherProvider,
private val fileQueue: FileDownloadQueue,
private val fileUpdateHandler: FileUpdateHandler,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val onUpdate: () -> Unit
) {
- private val scope = scopeProvider.appScope
-
private val downloadingFiles: MutableSet = Collections.newSetFromMap(ConcurrentHashMap())
private val loadingEmojis: MutableSet = Collections.newSetFromMap(ConcurrentHashMap())
private val filePaths = ConcurrentHashMap()
diff --git a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt
index 2212df88..230d0ab7 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt
@@ -1,13 +1,13 @@
package org.monogram.data.chats
import android.util.Log
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.db.dao.ChatFolderDao
import org.monogram.data.db.model.ChatFolderEntity
@@ -21,13 +21,11 @@ private const val TAG = "ChatFolderManager"
class ChatFolderManager(
private val gateway: TelegramGateway,
private val dispatchers: DispatcherProvider,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val foldersFlow: MutableStateFlow>,
private val cacheProvider: CacheProvider,
private val chatFolderDao: ChatFolderDao
) {
- private val scope = scopeProvider.appScope
-
private val chatUnreadCounts = ConcurrentHashMap()
private val folderChatIds = ConcurrentHashMap>()
private val folderPinnedChatIds = ConcurrentHashMap>()
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 1e61ae07..8bf1710a 100644
--- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
+++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt
@@ -1,28 +1,26 @@
package org.monogram.data.chats
-import org.monogram.data.core.coRunCatching
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
+import org.monogram.data.core.coRunCatching
import org.monogram.data.db.dao.UserFullInfoDao
import org.monogram.data.gateway.TelegramGateway
-import org.monogram.data.mapper.ChatMapper
-import org.monogram.data.mapper.isForcedVerifiedChat
-import org.monogram.data.mapper.isForcedVerifiedUser
-import org.monogram.data.mapper.isSponsoredUser
+import org.monogram.data.mapper.*
import org.monogram.data.mapper.user.toEntity
import org.monogram.data.mapper.user.toTdApi
import org.monogram.domain.models.ChatModel
import org.monogram.domain.models.UsernamesModel
import org.monogram.domain.repository.AppPreferencesProvider
-import java.io.File
import java.util.concurrent.ConcurrentHashMap
class ChatModelFactory(
private val gateway: TelegramGateway,
private val dispatchers: DispatcherProvider,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val cache: ChatCache,
private val chatMapper: ChatMapper,
private val fileManager: ChatFileManager,
@@ -32,8 +30,8 @@ class ChatModelFactory(
private val triggerUpdate: (Long?) -> Unit,
private val fetchUser: (Long) -> Unit
) {
- private val scope = scopeProvider.appScope
private val missingUserFullInfoUntilMs = ConcurrentHashMap()
+ private val userFullInfoSemaphore = Semaphore(permits = 3)
fun mapChatToModel(
chat: TdApi.Chat,
@@ -51,6 +49,17 @@ class ChatModelFactory(
var isOnline = false
var userStatus = ""
var isVerified = isForcedVerifiedChat(chat.id)
+ var isScam = false
+ var isFake = false
+ var botVerificationIconCustomEmojiId = 0L
+ var restrictionReason: String? = null
+ var hasSensitiveContent = false
+ var activeStoryStateType: String? = null
+ var activeStoryId = 0
+ var boostLevel = 0
+ var hasForumTabs = false
+ var isAdministeredDirectMessagesGroup = false
+ var paidMessageStarCount = 0L
var isForum = false
var isBot = false
var isMember = true
@@ -98,6 +107,17 @@ class ChatModelFactory(
supergroup?.let {
memberCount = it.memberCount
isVerified = (it.verificationStatus?.isVerified ?: false) || isForcedVerifiedChat(chat.id)
+ isScam = it.verificationStatus?.isScam ?: false
+ isFake = it.verificationStatus?.isFake ?: false
+ botVerificationIconCustomEmojiId = it.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L
+ restrictionReason = it.restrictionInfo?.restrictionReason?.ifEmpty { null }
+ hasSensitiveContent = it.restrictionInfo?.hasSensitiveContent ?: false
+ activeStoryStateType = it.activeStoryState.toTypeString()
+ activeStoryId = (it.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0
+ boostLevel = it.boostLevel
+ hasForumTabs = it.hasForumTabs
+ isAdministeredDirectMessagesGroup = it.isAdministeredDirectMessagesGroup
+ paidMessageStarCount = it.paidMessageStarCount
isForum = it.isForum
isMember = it.status !is TdApi.ChatMemberStatusLeft
isAdmin = it.status is TdApi.ChatMemberStatusAdministrator ||
@@ -138,6 +158,14 @@ class ChatModelFactory(
if (isOnline) onlineCount = 1
userStatus = chatMapper.formatUserStatus(user.status, isBot)
isVerified = (user.verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(user.id)
+ isScam = user.verificationStatus?.isScam ?: false
+ isFake = user.verificationStatus?.isFake ?: false
+ botVerificationIconCustomEmojiId = user.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L
+ restrictionReason = user.restrictionInfo?.restrictionReason?.ifEmpty { null }
+ hasSensitiveContent = user.restrictionInfo?.hasSensitiveContent ?: false
+ activeStoryStateType = user.activeStoryState.toTypeString()
+ activeStoryId = (user.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0
+ paidMessageStarCount = user.paidMessageStarCount
isSponsor = isSponsoredUser(user.id)
username = user.usernames?.activeUsernames?.firstOrNull()
usernames = user.usernames?.toDomain()
@@ -155,6 +183,10 @@ class ChatModelFactory(
if (!isUserFullInfoTemporarilyMissing(type.userId)) {
lazyLoad(cache.pendingUserFullInfo, type.userId) {
if (type.userId == 0L) return@lazyLoad
+ cache.userFullInfoCache[type.userId]?.let {
+ triggerUpdate(chat.id)
+ return@lazyLoad
+ }
val cachedInfo = coRunCatching {
userFullInfoDao.getUserFullInfo(type.userId)?.toTdApi()
}.getOrNull()
@@ -164,7 +196,11 @@ class ChatModelFactory(
triggerUpdate(chat.id)
return@lazyLoad
}
- val result = coRunCatching { gateway.execute(TdApi.GetUserFullInfo(type.userId)) }.getOrNull()
+ val result = userFullInfoSemaphore.withPermit {
+ cache.userFullInfoCache[type.userId] ?: coRunCatching {
+ gateway.execute(TdApi.GetUserFullInfo(type.userId))
+ }.getOrNull()
+ }
if (result != null) {
cache.putUserFullInfo(type.userId, result)
coRunCatching { userFullInfoDao.insertUserFullInfo(result.toEntity(type.userId)) }
@@ -244,6 +280,17 @@ class ChatModelFactory(
isOnline = isOnline,
userStatus = userStatus,
isVerified = isVerified,
+ isScam = isScam,
+ isFake = isFake,
+ botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId,
+ restrictionReason = restrictionReason,
+ hasSensitiveContent = hasSensitiveContent,
+ activeStoryStateType = activeStoryStateType,
+ activeStoryId = activeStoryId,
+ boostLevel = boostLevel,
+ hasForumTabs = hasForumTabs,
+ isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup,
+ paidMessageStarCount = paidMessageStarCount,
isSponsor = isSponsor,
isForum = isForum,
isBot = isBot,
@@ -296,12 +343,12 @@ class ChatModelFactory(
}
val localPath = photoFile.local.path
- if (isValidPath(localPath)) {
+ if (isValidFilePath(localPath)) {
return localPath
}
val cachedPath = photoFile.id.takeIf { it != 0 }?.let { fileManager.getFilePath(it) }
- if (isValidPath(cachedPath)) {
+ if (isValidFilePath(cachedPath)) {
return cachedPath
}
@@ -331,11 +378,16 @@ class ChatModelFactory(
return (memberCount ?: 0) to (onlineCount ?: 0)
}
- private fun isValidPath(path: String?): Boolean {
- return !path.isNullOrBlank() && File(path).exists()
- }
-
companion object {
private const val USER_FULL_INFO_RETRY_TTL_MS = 5 * 60 * 1000L
}
}
+
+private fun TdApi.ActiveStoryState?.toTypeString(): String? {
+ return when (this) {
+ is TdApi.ActiveStoryStateLive -> "LIVE"
+ is TdApi.ActiveStoryStateUnread -> "UNREAD"
+ is TdApi.ActiveStoryStateRead -> "READ"
+ else -> null
+ }
+}
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 43f8fffe..e917ff5a 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
@@ -23,6 +23,7 @@ interface ChatLocalDataSource {
suspend fun insertMessages(messages: List)
suspend fun markAsRead(chatId: Long, upToMessageId: Long)
suspend fun updateMessageContent(
+ chatId: Long,
messageId: Long,
content: String,
contentType: String,
@@ -32,10 +33,19 @@ interface ChatLocalDataSource {
editDate: Int
)
- suspend fun updateMediaPath(fileId: Int, path: String)
+ suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String)
+ suspend fun clearCachedMediaPaths()
+ suspend fun clearCachedChatAvatarPaths()
- suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int)
- suspend fun deleteMessage(messageId: Long)
+ suspend fun updateInteractionInfo(
+ chatId: Long,
+ messageId: Long,
+ viewCount: Int,
+ forwardCount: Int,
+ replyCount: Int
+ )
+
+ suspend fun deleteMessage(chatId: Long, messageId: Long)
suspend fun clearMessagesForChat(chatId: Long)
suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity?
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 c591c47c..1b85e338 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
@@ -90,6 +90,7 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
}
override suspend fun updateMessageContent(
+ chatId: Long,
messageId: Long,
content: String,
contentType: String,
@@ -98,27 +99,36 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
mediaPath: String?,
editDate: Int
) {
- messages.values.forEach { flow ->
- val current = flow.value[messageId] ?: return@forEach
- flow.update {
- it + (messageId to current.copy(
- content = content,
- contentType = contentType,
- contentMeta = contentMeta,
- mediaFileId = mediaFileId,
- mediaPath = mediaPath,
- editDate = editDate
- ))
- }
+ val flow = messages[chatId] ?: return
+ val current = flow.value[messageId] ?: return
+ flow.update {
+ it + (messageId to current.copy(
+ content = content,
+ contentType = contentType,
+ contentMeta = contentMeta,
+ mediaFileId = mediaFileId,
+ mediaPath = mediaPath,
+ editDate = editDate
+ ))
}
}
- override suspend fun updateMediaPath(fileId: Int, path: String) {
+ override suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String) {
+ val flow = messages[chatId] ?: return
+ val current = flow.value[messageId] ?: return
+ if (current.mediaFileId != fileId || fileId == 0) return
+ flow.update {
+ it + (messageId to current.copy(mediaPath = path))
+ }
+ }
+
+ override suspend fun clearCachedMediaPaths() {
+ val mediaTypes = setOf("photo", "video", "video_note", "document", "gif", "voice", "sticker", "audio")
messages.values.forEach { flow ->
flow.update { current ->
current.mapValues { (_, message) ->
- if (message.mediaFileId == fileId) {
- message.copy(mediaPath = path)
+ if (message.contentType in mediaTypes && (message.mediaPath != null || message.mediaThumbnailPath != null)) {
+ message.copy(mediaPath = null, mediaThumbnailPath = null)
} else {
message
}
@@ -127,17 +137,38 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource {
}
}
- override suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) {
- messages.values.forEach { flow ->
- val current = flow.value[messageId] ?: return@forEach
- flow.update {
- it + (messageId to current.copy(viewCount = viewCount, forwardCount = forwardCount, replyCount = replyCount))
+ override suspend fun clearCachedChatAvatarPaths() {
+ chats.update { current ->
+ current.mapValues { (_, chat) ->
+ if (chat.avatarPath != null) {
+ chat.copy(avatarPath = null)
+ } else {
+ chat
+ }
}
}
}
- override suspend fun deleteMessage(messageId: Long) {
- messages.values.forEach { flow ->
+ override suspend fun updateInteractionInfo(
+ chatId: Long,
+ messageId: Long,
+ viewCount: Int,
+ forwardCount: Int,
+ replyCount: Int
+ ) {
+ val flow = messages[chatId] ?: return
+ val current = flow.value[messageId] ?: return
+ flow.update {
+ it + (messageId to current.copy(
+ viewCount = viewCount,
+ forwardCount = forwardCount,
+ replyCount = replyCount
+ ))
+ }
+ }
+
+ override suspend fun deleteMessage(chatId: Long, messageId: Long) {
+ messages[chatId]?.let { flow ->
if (flow.value.containsKey(messageId)) {
flow.update { it - messageId }
}
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt
index e201eb3f..bc157fa2 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt
@@ -38,4 +38,14 @@ class InMemoryUserLocalDataSource : UserLocalDataSource {
override suspend fun deleteExpired(timestamp: Long) {
fullInfoEntities.values.removeIf { it.createdAt < timestamp }
}
-}
\ No newline at end of file
+
+ override suspend fun clearCachedAvatarPaths() {
+ users.values.forEach { user ->
+ user.profilePhoto = null
+ }
+ }
+
+ override suspend fun clearDatabase() {
+ clearAll()
+ }
+}
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 8f810893..613e8c01 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
@@ -55,6 +55,7 @@ class RoomChatLocalDataSource(
override suspend fun markAsRead(chatId: Long, upToMessageId: Long) = messageDao.markAsRead(chatId, upToMessageId)
override suspend fun updateMessageContent(
+ chatId: Long,
messageId: Long,
content: String,
contentType: String,
@@ -62,14 +63,36 @@ class RoomChatLocalDataSource(
mediaFileId: Int,
mediaPath: String?,
editDate: Int
- ) = messageDao.updateContent(messageId, content, contentType, contentMeta, mediaFileId, mediaPath, editDate)
+ ) = messageDao.updateContent(
+ chatId,
+ messageId,
+ content,
+ contentType,
+ contentMeta,
+ mediaFileId,
+ mediaPath,
+ editDate
+ )
+
+ override suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String) {
+ messageDao.updateMediaPathForMessage(chatId = chatId, messageId = messageId, fileId = fileId, path = path)
+ }
- override suspend fun updateMediaPath(fileId: Int, path: String) = messageDao.updateMediaPath(fileId, path)
+ override suspend fun clearCachedMediaPaths() = messageDao.clearCachedMediaPaths()
- override suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) =
- messageDao.updateInteractionInfo(messageId, viewCount, forwardCount, replyCount)
+ override suspend fun clearCachedChatAvatarPaths() = chatDao.clearAvatarPaths()
- override suspend fun deleteMessage(messageId: Long) = messageDao.deleteMessage(messageId)
+ override suspend fun updateInteractionInfo(
+ chatId: Long,
+ messageId: Long,
+ viewCount: Int,
+ forwardCount: Int,
+ replyCount: Int
+ ) =
+ messageDao.updateInteractionInfo(chatId, messageId, viewCount, forwardCount, replyCount)
+
+ override suspend fun deleteMessage(chatId: Long, messageId: Long) =
+ messageDao.deleteMessage(chatId, messageId)
override suspend fun clearMessagesForChat(chatId: Long) = messageDao.clearMessagesForChat(chatId)
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt
index 2a684860..084a5c8c 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt
@@ -85,4 +85,11 @@ class RoomUserLocalDataSource(
userDao.clearAll()
userFullInfoDao.clearAll()
}
+
+ override suspend fun clearCachedAvatarPaths() {
+ userDao.clearAvatarPaths()
+ users.values.forEach { user ->
+ user.profilePhoto = null
+ }
+ }
}
diff --git a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt
index c831bfdc..9aef0942 100644
--- a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt
@@ -19,4 +19,5 @@ interface UserLocalDataSource {
suspend fun saveUser(user: UserEntity) {}
suspend fun loadUser(userId: Long): UserEntity? = null
suspend fun clearDatabase() {}
+ suspend fun clearCachedAvatarPaths() {}
}
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt
index bcb6c7c4..f068a3ea 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt
@@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi
interface AuthRemoteDataSource {
suspend fun setTdlibParameters(parameters: TdApi.SetTdlibParameters)
+ suspend fun getAuthorizationState(): TdApi.AuthorizationState
suspend fun setPhoneNumber(phone: String)
suspend fun resendCode()
suspend fun setAuthCode(code: String)
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt
deleted file mode 100644
index c8de2a0e..00000000
--- a/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt
+++ /dev/null
@@ -1,5 +0,0 @@
-package org.monogram.data.datasource.remote
-
-interface ExternalProxyDataSource {
- suspend fun fetchProxyUrls(): List
-}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt
deleted file mode 100644
index 060e8896..00000000
--- a/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package org.monogram.data.datasource.remote
-
-import org.monogram.core.DispatcherProvider
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.InternalSerializationApi
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import java.net.HttpURLConnection
-import java.net.URL
-import java.util.zip.GZIPInputStream
-
-class HttpExternalProxyDataSource(
- private val dispatchers: DispatcherProvider
-) : ExternalProxyDataSource {
-
- private val json = Json {
- ignoreUnknownKeys = true
- coerceInputValues = true
- isLenient = true
- }
-
- @OptIn(InternalSerializationApi::class)
- override suspend fun fetchProxyUrls(): List = withContext(dispatchers.io) {
- var connection: HttpURLConnection? = null
- try {
- val url = URL("https://api.telega.info/v1/auth/proxy")
- connection = (url.openConnection() as HttpURLConnection).apply {
- requestMethod = "GET"
- connectTimeout = 15_000
- readTimeout = 15_000
- instanceFollowRedirects = true
- setRequestProperty("User-Agent", "DAHL-Mobile-App")
- setRequestProperty("Accept", "application/json")
- setRequestProperty("Accept-Encoding", "gzip")
- }
-
- if (connection.responseCode != HttpURLConnection.HTTP_OK) return@withContext emptyList()
-
- val stream = if ("gzip".equals(connection.contentEncoding, ignoreCase = true)) {
- GZIPInputStream(connection.inputStream)
- } else {
- connection.inputStream
- }
-
- json.decodeFromString(stream.bufferedReader().use { it.readText() }).proxies
- } catch (e: Exception) {
- emptyList()
- } finally {
- connection?.disconnect()
- }
- }
-}
-@Serializable
-@InternalSerializationApi
-private data class ProxyResponse(val proxies: List = emptyList())
\ No newline at end of file
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 74100ccf..896ecf28 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
@@ -3,7 +3,16 @@ package org.monogram.data.datasource.remote
import kotlinx.coroutines.flow.Flow
import org.drinkless.tdlib.TdApi
import org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType
-import org.monogram.domain.models.*
+import org.monogram.domain.models.FileDownloadEvent
+import org.monogram.domain.models.MessageDeletedEvent
+import org.monogram.domain.models.MessageDownloadEvent
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageIdUpdatedEvent
+import org.monogram.domain.models.MessageModel
+import org.monogram.domain.models.MessageSendOptions
+import org.monogram.domain.models.MessageUploadProgressEvent
+import org.monogram.domain.models.MessageViewerModel
+import org.monogram.domain.models.UserModel
import org.monogram.domain.models.webapp.ThemeParams
import org.monogram.domain.models.webapp.WebAppInfoModel
import org.monogram.domain.repository.OlderMessagesPage
@@ -11,15 +20,14 @@ import org.monogram.domain.repository.ReadUpdate
import org.monogram.domain.repository.SearchChatMessagesResult
interface MessageRemoteDataSource {
+ val fileDownloadFlow: Flow
val newMessageFlow: Flow
val messageEditedFlow: Flow
val messageReadFlow: Flow
- val messageUploadProgressFlow: Flow>
- val messageDownloadProgressFlow: Flow>
- val messageDownloadCancelledFlow: Flow
- val messageDeletedFlow: Flow>>
- val messageIdUpdateFlow: Flow>
- val messageDownloadCompletedFlow: Flow>
+ val messageUploadProgressFlow: Flow
+ val messageDownloadFlow: Flow
+ val messageDeletedFlow: Flow
+ val messageIdUpdateFlow: Flow
val pinnedMessageFlow: Flow
val mediaUpdateFlow: Flow
fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long)
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt
index 6503514b..4a06c2b0 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt
@@ -14,6 +14,11 @@ interface SettingsRemoteDataSource {
scope: TdApi.NotificationSettingsScope,
compareSound: Boolean
): TdApi.Chats?
+ suspend fun setDefaultBackground(
+ background: TdApi.InputBackground?,
+ type: TdApi.BackgroundType?,
+ forDarkTheme: Boolean
+ ): TdApi.Background?
// Setters
suspend fun setScopeNotificationSettings(
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt
index e494abf1..bf4350d8 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt
@@ -11,6 +11,10 @@ class TdAuthRemoteDataSource(
gateway.execute(parameters)
}
+ override suspend fun getAuthorizationState(): TdApi.AuthorizationState {
+ return gateway.execute(TdApi.GetAuthorizationState())
+ }
+
override suspend fun setPhoneNumber(phone: String) {
val settings = TdApi.PhoneNumberAuthenticationSettings().apply {
isCurrentPhoneNumber = false
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 5911c70a..6a2e37b9 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
@@ -2,12 +2,22 @@ package org.monogram.data.datasource.remote
import android.os.Build
import android.util.Log
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
import org.monogram.data.gateway.TdLibException
import org.monogram.data.gateway.TelegramGateway
@@ -15,10 +25,26 @@ import org.monogram.data.infra.FileDownloadQueue
import org.monogram.data.infra.FileUpdateHandler
import org.monogram.data.mapper.MessageMapper
import org.monogram.data.mapper.toApi
-import org.monogram.domain.models.*
+import org.monogram.domain.models.FileDownloadEvent
+import org.monogram.domain.models.MessageContent
+import org.monogram.domain.models.MessageDeletedEvent
+import org.monogram.domain.models.MessageDownloadEvent
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageEntityType
+import org.monogram.domain.models.MessageIdUpdatedEvent
+import org.monogram.domain.models.MessageModel
+import org.monogram.domain.models.MessageSendOptions
+import org.monogram.domain.models.MessageUploadProgressEvent
+import org.monogram.domain.models.MessageViewerModel
+import org.monogram.domain.models.UserModel
import org.monogram.domain.models.webapp.ThemeParams
import org.monogram.domain.models.webapp.WebAppInfoModel
-import org.monogram.domain.repository.*
+import org.monogram.domain.repository.ChatListRepository
+import org.monogram.domain.repository.OlderMessagesPage
+import org.monogram.domain.repository.PollRepository
+import org.monogram.domain.repository.ReadUpdate
+import org.monogram.domain.repository.SearchChatMessagesResult
+import org.monogram.domain.repository.UserRepository
import java.util.concurrent.ConcurrentHashMap
class TdMessageRemoteDataSource(
@@ -31,10 +57,9 @@ class TdMessageRemoteDataSource(
private val fileDownloadQueue: FileDownloadQueue,
private val fileUpdateHandler: FileUpdateHandler,
private val dispatcherProvider: DispatcherProvider,
- scopeProvider: ScopeProvider
+ val scope: CoroutineScope
) : MessageRemoteDataSource {
- val scope = scopeProvider.appScope
private val chatRequests = ConcurrentHashMap>()
private val messageRequests = ConcurrentHashMap, Deferred>()
private val refreshJobs = ConcurrentHashMap, Job>()
@@ -43,29 +68,31 @@ class TdMessageRemoteDataSource(
override val messageEditedFlow = MutableSharedFlow()
override val messageReadFlow = MutableSharedFlow(
replay = 1,
- extraBufferCapacity = 100,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
)
- override val messageUploadProgressFlow = MutableSharedFlow>()
- override val messageDownloadProgressFlow = MutableSharedFlow>()
- override val messageDownloadCancelledFlow = MutableSharedFlow()
- override val messageDeletedFlow = MutableSharedFlow>>(
- extraBufferCapacity = 100,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND
+ override val messageUploadProgressFlow = MutableSharedFlow()
+ override val fileDownloadFlow = MutableSharedFlow(
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
)
- override val messageIdUpdateFlow = MutableSharedFlow>(
- extraBufferCapacity = 100,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND
+ override val messageDownloadFlow = MutableSharedFlow()
+ override val messageDeletedFlow = MutableSharedFlow(
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ override val messageIdUpdateFlow = MutableSharedFlow(
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
)
- override val messageDownloadCompletedFlow = MutableSharedFlow>()
override val pinnedMessageFlow = MutableSharedFlow(
extraBufferCapacity = 10,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val mediaUpdateFlow = MutableSharedFlow(
replay = 0,
extraBufferCapacity = 10,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
)
enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT }
@@ -483,7 +510,7 @@ class TdMessageRemoteDataSource(
this.clearDraft = true
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -508,7 +535,7 @@ class TdMessageRemoteDataSource(
this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption))
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -541,7 +568,7 @@ class TdMessageRemoteDataSource(
this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption))
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -572,7 +599,7 @@ class TdMessageRemoteDataSource(
this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption))
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -596,7 +623,7 @@ class TdMessageRemoteDataSource(
this.height = 512
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -621,7 +648,7 @@ class TdMessageRemoteDataSource(
this.animation = TdApi.InputFileId(gifId.toInt())
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -646,7 +673,7 @@ class TdMessageRemoteDataSource(
this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption))
}
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessage().apply {
this.chatId = chatId
this.topicId = topicId
@@ -685,7 +712,7 @@ class TdMessageRemoteDataSource(
}
}.toTypedArray()
val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null
- val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null
+ val topicId = resolveTopicId(chatId, threadId)
val req = TdApi.SendMessageAlbum().apply {
this.chatId = chatId
this.topicId = topicId
@@ -829,6 +856,16 @@ class TdMessageRemoteDataSource(
return TdApi.TextEntity(start, safeLength, tdType)
}
+ private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? {
+ if (threadId == null || threadId == 0L) return null
+ val chat = cache.getChat(chatId) ?: getChat(chatId)
+ return if (chat?.viewAsTopics == true) {
+ TdApi.MessageTopicForum(threadId.toInt())
+ } else {
+ TdApi.MessageTopicThread(threadId)
+ }
+ }
+
private fun MessageSendOptions.toTdMessageSendOptions(): TdApi.MessageSendOptions {
return TdApi.MessageSendOptions().apply {
this.disableNotification = silent
@@ -929,7 +966,7 @@ class TdMessageRemoteDataSource(
val req = TdApi.SendChatAction().apply {
this.chatId = chatId
this.action = action
- this.topicId = if (messageThreadId != 0L) TdApi.MessageTopicThread(messageThreadId) else null
+ this.topicId = resolveTopicId(chatId, messageThreadId.takeIf { it != 0L })
}
return safeExecute(req)
}
@@ -1088,9 +1125,7 @@ class TdMessageRemoteDataSource(
val request = TdApi.SetChatDraftMessage().apply {
this.chatId = chatId
this.draftMessage = draft
- if (threadId != null && threadId != 0L) {
- this.topicId = TdApi.MessageTopicThread(threadId)
- }
+ this.topicId = resolveTopicId(chatId, threadId)
}
safeExecute(request)
}
@@ -1174,7 +1209,13 @@ class TdMessageRemoteDataSource(
scope.launch(dispatcherProvider.io) {
try {
val model = mapMessageToModel(message)
- messageIdUpdateFlow.emit(Triple(message.chatId, update.oldMessageId, model))
+ messageIdUpdateFlow.emit(
+ MessageIdUpdatedEvent(
+ chatId = message.chatId,
+ oldMessageId = update.oldMessageId,
+ message = model
+ )
+ )
} catch (e: Exception) { Log.e("TdMessageRemote", "Error handling SendSucceeded", e) }
}
}
@@ -1241,7 +1282,12 @@ class TdMessageRemoteDataSource(
scope.launch(dispatcherProvider.io) {
messageIds.forEach { cache.removeMessage(update.chatId, it) }
removeMessagesFromCache(update.chatId, messageIds)
- messageDeletedFlow.emit(update.chatId to messageIds)
+ messageDeletedFlow.emit(
+ MessageDeletedEvent(
+ chatId = update.chatId,
+ messageIds = messageIds
+ )
+ )
}
}
}
@@ -1357,6 +1403,20 @@ class TdMessageRemoteDataSource(
if (isDC) {
fileDownloadQueue.notifyDownloadComplete(file.id)
lastProgressMap.remove(file.id)
+ scope.launch {
+ fileDownloadFlow.emit(
+ FileDownloadEvent.Completed(
+ fileId = file.id,
+ path = file.local?.path ?: ""
+ )
+ )
+ fileDownloadFlow.emit(
+ FileDownloadEvent.Progress(
+ fileId = file.id,
+ progress = 1.0f
+ )
+ )
+ }
fileUpdateHandler.fileIdToCustomEmojiId[file.id]?.let { customEmojiId ->
fileUpdateHandler.customEmojiPaths[customEmojiId] = file.local?.path ?: ""
}
@@ -1364,20 +1424,26 @@ class TdMessageRemoteDataSource(
val entries = fileIdToMessageMap[file.id]
if (!entries.isNullOrEmpty()) {
scope.launch {
- entries.forEach { (_, messageId) ->
- messageDownloadCompletedFlow.emit(
- Triple(messageId, file.id, file.local?.path ?: "")
+ entries.forEach { (chatId, messageId) ->
+ messageDownloadFlow.emit(
+ MessageDownloadEvent.Completed(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id,
+ path = file.local?.path ?: ""
+ )
+ )
+ messageDownloadFlow.emit(
+ MessageDownloadEvent.Progress(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id,
+ progress = 1.0f
+ )
)
- messageDownloadProgressFlow.emit(messageId to 1.0f)
}
}
} else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) {
- scope.launch {
- messageDownloadCompletedFlow.emit(
- Triple(file.id.toLong(), file.id, file.local?.path ?: "")
- )
- messageDownloadProgressFlow.emit(file.id.toLong() to 1.0f)
- }
fileDownloadQueue.registry.standaloneFileIds.remove(file.id)
}
updateMessageWithFile(file.id)
@@ -1387,15 +1453,28 @@ class TdMessageRemoteDataSource(
val pInt = (p * 100).toInt()
if (lastProgressMap[file.id] != pInt) {
lastProgressMap[file.id] = pInt
+ scope.launch {
+ fileDownloadFlow.emit(
+ FileDownloadEvent.Progress(
+ fileId = file.id,
+ progress = p
+ )
+ )
+ }
val entries = fileIdToMessageMap[file.id]
if (!entries.isNullOrEmpty()) {
scope.launch {
- entries.forEach { (_, messageId) ->
- messageDownloadProgressFlow.emit(messageId to p)
+ entries.forEach { (chatId, messageId) ->
+ messageDownloadFlow.emit(
+ MessageDownloadEvent.Progress(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id,
+ progress = p
+ )
+ )
}
}
- } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) {
- scope.launch { messageDownloadProgressFlow.emit(file.id.toLong() to p) }
}
}
} else if (isCancelled) {
@@ -1404,12 +1483,16 @@ class TdMessageRemoteDataSource(
val entries = fileIdToMessageMap[file.id]
if (!entries.isNullOrEmpty()) {
scope.launch {
- entries.forEach { (_, messageId) ->
- messageDownloadCancelledFlow.emit(messageId)
+ entries.forEach { (chatId, messageId) ->
+ messageDownloadFlow.emit(
+ MessageDownloadEvent.Cancelled(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id
+ )
+ )
}
}
- } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) {
- scope.launch { messageDownloadCancelledFlow.emit(file.id.toLong()) }
}
}
@@ -1419,8 +1502,15 @@ class TdMessageRemoteDataSource(
val entries = fileIdToMessageMap[file.id]
if (!entries.isNullOrEmpty()) {
scope.launch {
- entries.forEach { (_, messageId) ->
- messageUploadProgressFlow.emit(messageId to 1.0f)
+ entries.forEach { (chatId, messageId) ->
+ messageUploadProgressFlow.emit(
+ MessageUploadProgressEvent(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id,
+ progress = 1.0f
+ )
+ )
}
}
}
@@ -1434,8 +1524,15 @@ class TdMessageRemoteDataSource(
val entries = fileIdToMessageMap[file.id]
if (!entries.isNullOrEmpty()) {
scope.launch {
- entries.forEach { (_, messageId) ->
- messageUploadProgressFlow.emit(messageId to p)
+ entries.forEach { (chatId, messageId) ->
+ messageUploadProgressFlow.emit(
+ MessageUploadProgressEvent(
+ chatId = chatId,
+ messageId = messageId,
+ fileId = file.id,
+ progress = p
+ )
+ )
}
}
}
diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt
index 56d27828..fcef9c0a 100644
--- a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt
+++ b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt
@@ -1,8 +1,8 @@
package org.monogram.data.datasource.remote
-import org.monogram.data.core.coRunCatching
import android.util.Log
import org.drinkless.tdlib.TdApi
+import org.monogram.data.core.coRunCatching
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.FileDownloadQueue
@@ -32,6 +32,21 @@ class TdSettingsRemoteDataSource(
result
}.getOrNull()
+ override suspend fun setDefaultBackground(
+ background: TdApi.InputBackground?,
+ type: TdApi.BackgroundType?,
+ forDarkTheme: Boolean
+ ): TdApi.Background? =
+ coRunCatching {
+ val result = gateway.execute(TdApi.SetDefaultBackground(background, type, forDarkTheme))
+ result.document?.thumbnail?.file?.let { file ->
+ if (file.local.path.isEmpty()) {
+ fileQueue.enqueue(file.id, 1, FileDownloadQueue.DownloadType.DEFAULT)
+ }
+ }
+ result
+ }.getOrNull()
+
override suspend fun getStorageStatistics(chatLimit: Int): TdApi.StorageStatistics? =
coRunCatching { gateway.execute(TdApi.GetStorageStatistics(chatLimit)) }.getOrNull()
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 012a2834..48f61d90 100644
--- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt
+++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt
@@ -2,8 +2,42 @@ package org.monogram.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
-import org.monogram.data.db.dao.*
-import org.monogram.data.db.model.*
+import org.monogram.data.db.dao.AttachBotDao
+import org.monogram.data.db.dao.ChatDao
+import org.monogram.data.db.dao.ChatFolderDao
+import org.monogram.data.db.dao.ChatFullInfoDao
+import org.monogram.data.db.dao.KeyValueDao
+import org.monogram.data.db.dao.MessageDao
+import org.monogram.data.db.dao.NotificationExceptionDao
+import org.monogram.data.db.dao.NotificationSettingDao
+import org.monogram.data.db.dao.RecentEmojiDao
+import org.monogram.data.db.dao.SearchHistoryDao
+import org.monogram.data.db.dao.SponsorDao
+import org.monogram.data.db.dao.StickerPathDao
+import org.monogram.data.db.dao.StickerSetDao
+import org.monogram.data.db.dao.TextCompositionStyleDao
+import org.monogram.data.db.dao.TopicDao
+import org.monogram.data.db.dao.UserDao
+import org.monogram.data.db.dao.UserFullInfoDao
+import org.monogram.data.db.dao.WallpaperDao
+import org.monogram.data.db.model.AttachBotEntity
+import org.monogram.data.db.model.ChatEntity
+import org.monogram.data.db.model.ChatFolderEntity
+import org.monogram.data.db.model.ChatFullInfoEntity
+import org.monogram.data.db.model.KeyValueEntity
+import org.monogram.data.db.model.MessageEntity
+import org.monogram.data.db.model.NotificationExceptionEntity
+import org.monogram.data.db.model.NotificationSettingEntity
+import org.monogram.data.db.model.RecentEmojiEntity
+import org.monogram.data.db.model.SearchHistoryEntity
+import org.monogram.data.db.model.SponsorEntity
+import org.monogram.data.db.model.StickerPathEntity
+import org.monogram.data.db.model.StickerSetEntity
+import org.monogram.data.db.model.TextCompositionStyleEntity
+import org.monogram.data.db.model.TopicEntity
+import org.monogram.data.db.model.UserEntity
+import org.monogram.data.db.model.UserFullInfoEntity
+import org.monogram.data.db.model.WallpaperEntity
@Database(
entities = [
@@ -20,12 +54,13 @@ import org.monogram.data.db.model.*
AttachBotEntity::class,
KeyValueEntity::class,
NotificationSettingEntity::class,
+ NotificationExceptionEntity::class,
WallpaperEntity::class,
StickerPathEntity::class,
SponsorEntity::class,
TextCompositionStyleEntity::class
],
- version = 27,
+ version = 31,
exportSchema = false
)
abstract class MonogramDatabase : RoomDatabase() {
@@ -42,6 +77,7 @@ abstract class MonogramDatabase : RoomDatabase() {
abstract fun attachBotDao(): AttachBotDao
abstract fun keyValueDao(): KeyValueDao
abstract fun notificationSettingDao(): NotificationSettingDao
+ abstract fun notificationExceptionDao(): NotificationExceptionDao
abstract fun wallpaperDao(): WallpaperDao
abstract fun stickerPathDao(): StickerPathDao
abstract fun sponsorDao(): SponsorDao
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 be1ff163..2cf92556 100644
--- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt
+++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt
@@ -18,4 +18,290 @@ object MonogramMigrations {
)
}
}
+
+ val MIGRATION_27_28 = object : Migration(27, 28) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.addColumn("users", "isScam", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "isFake", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanBeEdited", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanJoinGroups", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanReadAllGroupMessages", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeHasMainWebApp", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeHasTopics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeAllowsUsersToCreateTopics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanManageBots", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeIsInline", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeInlineQueryPlaceholder", "TEXT")
+ db.addColumn("users", "botTypeNeedLocation", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanConnectToBusiness", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeCanBeAddedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "botTypeActiveUserCount", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "userType", "TEXT NOT NULL DEFAULT 'UNKNOWN'")
+ db.addColumn("users", "restrictionReason", "TEXT")
+ db.addColumn("users", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "activeStoryStateType", "TEXT")
+ db.addColumn("users", "activeStoryId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "restrictsNewChats", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "backgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "profileBackgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("users", "addedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0")
+
+ db.addColumn("chats", "isScam", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "isFake", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "restrictionReason", "TEXT")
+ db.addColumn("chats", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "activeStoryStateType", "TEXT")
+ db.addColumn("chats", "activeStoryId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "boostLevel", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "hasForumTabs", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "isAdministeredDirectMessagesGroup", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chats", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0")
+
+ db.addColumn("user_full_info", "botInfoShortDescription", "TEXT")
+ db.addColumn("user_full_info", "botInfoPhotoFileId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botInfoPhotoPath", "TEXT")
+ db.addColumn("user_full_info", "botInfoAnimationFileId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botInfoAnimationPath", "TEXT")
+ db.addColumn("user_full_info", "botInfoManagerBotUserId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botInfoMenuButtonText", "TEXT")
+ db.addColumn("user_full_info", "botInfoMenuButtonUrl", "TEXT")
+ db.addColumn("user_full_info", "botInfoCommandsData", "TEXT")
+ db.addColumn("user_full_info", "botInfoPrivacyPolicyUrl", "TEXT")
+ db.addColumn("user_full_info", "botInfoDefaultGroupRightsData", "TEXT")
+ db.addColumn("user_full_info", "botInfoDefaultChannelRightsData", "TEXT")
+ db.addColumn("user_full_info", "botInfoAffiliateProgramData", "TEXT")
+ db.addColumn("user_full_info", "botInfoWebAppBackgroundLightColor", "INTEGER NOT NULL DEFAULT -1")
+ db.addColumn("user_full_info", "botInfoWebAppBackgroundDarkColor", "INTEGER NOT NULL DEFAULT -1")
+ db.addColumn("user_full_info", "botInfoWebAppHeaderLightColor", "INTEGER NOT NULL DEFAULT -1")
+ db.addColumn("user_full_info", "botInfoWebAppHeaderDarkColor", "INTEGER NOT NULL DEFAULT -1")
+ db.addColumn(
+ "user_full_info",
+ "botInfoVerificationParametersIconCustomEmojiId",
+ "INTEGER NOT NULL DEFAULT 0"
+ )
+ db.addColumn("user_full_info", "botInfoVerificationParametersOrganizationName", "TEXT")
+ db.addColumn("user_full_info", "botInfoVerificationParametersDefaultCustomDescription", "TEXT")
+ db.addColumn(
+ "user_full_info",
+ "botInfoVerificationParametersCanSetCustomDescription",
+ "INTEGER NOT NULL DEFAULT 0"
+ )
+ db.addColumn("user_full_info", "botInfoCanManageEmojiStatus", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botInfoHasMediaPreviews", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botInfoEditCommandsLinkType", "TEXT")
+ db.addColumn("user_full_info", "botInfoEditDescriptionLinkType", "TEXT")
+ db.addColumn("user_full_info", "botInfoEditDescriptionMediaLinkType", "TEXT")
+ db.addColumn("user_full_info", "botInfoEditSettingsLinkType", "TEXT")
+ db.addColumn("user_full_info", "publicPhotoPath", "TEXT")
+ db.addColumn("user_full_info", "blockListType", "TEXT")
+ db.addColumn("user_full_info", "note", "TEXT")
+ db.addColumn("user_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "botVerificationCustomDescription", "TEXT")
+ db.addColumn("user_full_info", "mainProfileTab", "TEXT")
+ db.addColumn("user_full_info", "firstProfileAudioDuration", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "firstProfileAudioTitle", "TEXT")
+ db.addColumn("user_full_info", "firstProfileAudioPerformer", "TEXT")
+ db.addColumn("user_full_info", "firstProfileAudioFileName", "TEXT")
+ db.addColumn("user_full_info", "firstProfileAudioMimeType", "TEXT")
+ db.addColumn("user_full_info", "firstProfileAudioFileId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "firstProfileAudioPath", "TEXT")
+ db.addColumn("user_full_info", "ratingLevel", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "ratingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "ratingValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "ratingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "ratingNextLevelValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingLevel", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingNextLevelValue", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("user_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0")
+
+ db.addColumn("chat_full_info", "directMessagesChatId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "botInfoData", "TEXT")
+ db.addColumn("chat_full_info", "blockListType", "TEXT")
+ db.addColumn("chat_full_info", "publicPhotoPath", "TEXT")
+ db.addColumn("chat_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "botVerificationCustomDescription", "TEXT")
+ db.addColumn("chat_full_info", "mainProfileTab", "TEXT")
+ db.addColumn("chat_full_info", "firstProfileAudioData", "TEXT")
+ db.addColumn("chat_full_info", "ratingData", "TEXT")
+ db.addColumn("chat_full_info", "pendingRatingData", "TEXT")
+ db.addColumn("chat_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "slowModeDelayExpiresIn", "REAL NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canEnablePaidMessages", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canEnablePaidReaction", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "hasHiddenMembers", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canHideMembers", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canGetStarRevenueStatistics", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canToggleAggressiveAntiSpam", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "isAllHistoryAvailable", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "canHaveSponsoredMessages", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "hasAggressiveAntiSpamEnabled", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "hasPaidMediaAllowed", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "hasPinnedStories", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "myBoostCount", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "unrestrictBoostCount", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "stickerSetId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "customEmojiStickerSetId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "botCommandsData", "TEXT")
+ db.addColumn("chat_full_info", "upgradedFromBasicGroupId", "INTEGER NOT NULL DEFAULT 0")
+ db.addColumn("chat_full_info", "upgradedFromMaxMessageId", "INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
+ val MIGRATION_28_29 = object : Migration(28, 29) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `notification_exceptions` (
+ `chatId` INTEGER NOT NULL,
+ `scope` TEXT NOT NULL,
+ `title` TEXT NOT NULL,
+ `avatarPath` TEXT,
+ `personalAvatarPath` TEXT,
+ `isMuted` INTEGER NOT NULL,
+ `isGroup` INTEGER NOT NULL,
+ `isChannel` INTEGER NOT NULL,
+ `type` TEXT NOT NULL,
+ `updatedAt` INTEGER NOT NULL,
+ PRIMARY KEY(`chatId`)
+ )
+ """.trimIndent()
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_notification_exceptions_scope` ON `notification_exceptions` (`scope`)"
+ )
+ }
+ }
+
+ val MIGRATION_29_30 = object : Migration(29, 30) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `messages_new` (
+ `id` INTEGER NOT NULL,
+ `chatId` INTEGER NOT NULL,
+ `senderId` INTEGER NOT NULL,
+ `senderName` TEXT NOT NULL,
+ `content` TEXT NOT NULL,
+ `contentType` TEXT NOT NULL,
+ `contentMeta` TEXT,
+ `mediaFileId` INTEGER NOT NULL,
+ `mediaPath` TEXT,
+ `mediaThumbnailPath` TEXT,
+ `minithumbnail` BLOB,
+ `date` INTEGER NOT NULL,
+ `isOutgoing` INTEGER NOT NULL,
+ `isRead` INTEGER NOT NULL,
+ `replyToMessageId` INTEGER NOT NULL,
+ `replyToPreview` TEXT,
+ `replyToPreviewType` TEXT,
+ `replyToPreviewText` TEXT,
+ `replyToPreviewSenderName` TEXT,
+ `replyCount` INTEGER NOT NULL,
+ `forwardFromName` TEXT,
+ `forwardFromId` INTEGER NOT NULL,
+ `forwardOriginChatId` INTEGER,
+ `forwardOriginMessageId` INTEGER,
+ `forwardDate` INTEGER NOT NULL,
+ `editDate` INTEGER NOT NULL,
+ `mediaAlbumId` INTEGER NOT NULL,
+ `entities` TEXT,
+ `viewCount` INTEGER NOT NULL,
+ `forwardCount` INTEGER NOT NULL,
+ `createdAt` INTEGER NOT NULL,
+ PRIMARY KEY(`chatId`, `id`)
+ )
+ """.trimIndent()
+ )
+
+ db.execSQL(
+ """
+ INSERT INTO `messages_new` (
+ `id`, `chatId`, `senderId`, `senderName`, `content`, `contentType`, `contentMeta`,
+ `mediaFileId`, `mediaPath`, `mediaThumbnailPath`, `minithumbnail`, `date`,
+ `isOutgoing`, `isRead`, `replyToMessageId`, `replyToPreview`, `replyToPreviewType`,
+ `replyToPreviewText`, `replyToPreviewSenderName`, `replyCount`, `forwardFromName`,
+ `forwardFromId`, `forwardOriginChatId`, `forwardOriginMessageId`, `forwardDate`,
+ `editDate`, `mediaAlbumId`, `entities`, `viewCount`, `forwardCount`, `createdAt`
+ )
+ SELECT
+ `id`, `chatId`, `senderId`, `senderName`, `content`, `contentType`, `contentMeta`,
+ `mediaFileId`, `mediaPath`, `mediaThumbnailPath`, `minithumbnail`, `date`,
+ `isOutgoing`, `isRead`, `replyToMessageId`, `replyToPreview`, `replyToPreviewType`,
+ `replyToPreviewText`, `replyToPreviewSenderName`, `replyCount`, `forwardFromName`,
+ `forwardFromId`, `forwardOriginChatId`, `forwardOriginMessageId`, `forwardDate`,
+ `editDate`, `mediaAlbumId`, `entities`, `viewCount`, `forwardCount`, `createdAt`
+ FROM `messages`
+ """.trimIndent()
+ )
+
+ db.execSQL(
+ """
+ UPDATE `messages_new`
+ SET `mediaPath` = NULL
+ WHERE `mediaPath` IS NOT NULL
+ AND `contentType` IN ('photo', 'video', 'video_note', 'document', 'gif', 'voice', 'sticker', 'audio')
+ """.trimIndent()
+ )
+
+ db.execSQL("DROP TABLE `messages`")
+ db.execSQL("ALTER TABLE `messages_new` RENAME TO `messages`")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_chatId_date` ON `messages` (`chatId`, `date`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_chatId_id` ON `messages` (`chatId`, `id`)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `messages` (`createdAt`)")
+ }
+ }
+
+ val MIGRATION_30_31 = object : Migration(30, 31) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("DROP TABLE IF EXISTS `wallpapers`")
+ db.execSQL(
+ """
+ CREATE TABLE IF NOT EXISTS `wallpapers` (
+ `id` INTEGER NOT NULL,
+ `slug` TEXT NOT NULL,
+ `title` TEXT NOT NULL,
+ `type` TEXT NOT NULL,
+ `pattern` INTEGER NOT NULL,
+ `documentId` INTEGER NOT NULL,
+ `thumbnailFileId` INTEGER,
+ `thumbnailWidth` INTEGER,
+ `thumbnailHeight` INTEGER,
+ `thumbnailLocalPath` TEXT,
+ `backgroundColor` INTEGER,
+ `secondBackgroundColor` INTEGER,
+ `thirdBackgroundColor` INTEGER,
+ `fourthBackgroundColor` INTEGER,
+ `intensity` INTEGER,
+ `rotation` INTEGER,
+ `isInverted` INTEGER,
+ `settingsIsMoving` INTEGER,
+ `settingsIsBlurred` INTEGER,
+ `themeName` TEXT,
+ `isDownloaded` INTEGER NOT NULL,
+ `localPath` TEXT,
+ `isDefault` INTEGER NOT NULL,
+ PRIMARY KEY(`id`)
+ )
+ """.trimIndent()
+ )
+ }
+ }
+
+ 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/ChatDao.kt b/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt
index 3d5ad8ce..c55883ec 100644
--- a/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt
+++ b/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt
@@ -35,4 +35,7 @@ interface ChatDao {
@Query("DELETE FROM chats WHERE createdAt < :timestamp")
suspend fun deleteExpired(timestamp: Long)
-}
\ No newline at end of file
+
+ @Query("UPDATE chats SET avatarPath = NULL WHERE avatarPath IS NOT NULL")
+ suspend fun clearAvatarPaths()
+}
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 520e3da3..daae21bb 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
@@ -25,9 +25,10 @@ interface MessageDao {
suspend fun markAsRead(chatId: Long, upToMessageId: Long)
@Query(
- "UPDATE messages SET content = :content, contentType = :contentType, contentMeta = :contentMeta, mediaFileId = :mediaFileId, mediaPath = :mediaPath, editDate = :editDate WHERE id = :messageId"
+ "UPDATE messages SET content = :content, contentType = :contentType, contentMeta = :contentMeta, mediaFileId = :mediaFileId, mediaPath = :mediaPath, editDate = :editDate WHERE chatId = :chatId AND id = :messageId"
)
suspend fun updateContent(
+ chatId: Long,
messageId: Long,
content: String,
contentType: String,
@@ -37,11 +38,24 @@ interface MessageDao {
editDate: Int
)
- @Query("UPDATE messages SET mediaPath = :path WHERE mediaFileId = :fileId AND mediaFileId != 0")
- suspend fun updateMediaPath(fileId: Int, path: String)
+ @Query(
+ "UPDATE messages SET mediaPath = :path WHERE chatId = :chatId AND id = :messageId AND mediaFileId = :fileId AND mediaFileId != 0"
+ )
+ suspend fun updateMediaPathForMessage(chatId: Long, messageId: Long, fileId: Int, path: String)
- @Query("UPDATE messages SET viewCount = :viewCount, forwardCount = :forwardCount, replyCount = :replyCount WHERE id = :messageId")
- suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int)
+ @Query(
+ "UPDATE messages SET mediaPath = NULL, mediaThumbnailPath = NULL WHERE (mediaPath IS NOT NULL OR mediaThumbnailPath IS NOT NULL) AND contentType IN ('photo', 'video', 'video_note', 'document', 'gif', 'voice', 'sticker', 'audio')"
+ )
+ suspend fun clearCachedMediaPaths()
+
+ @Query("UPDATE messages SET viewCount = :viewCount, forwardCount = :forwardCount, replyCount = :replyCount WHERE chatId = :chatId AND id = :messageId")
+ suspend fun updateInteractionInfo(
+ chatId: Long,
+ messageId: Long,
+ viewCount: Int,
+ forwardCount: Int,
+ replyCount: Int
+ )
@Query(
"""
@@ -61,8 +75,8 @@ interface MessageDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List)
- @Query("DELETE FROM messages WHERE id = :messageId")
- suspend fun deleteMessage(messageId: Long)
+ @Query("DELETE FROM messages WHERE chatId = :chatId AND id = :messageId")
+ suspend fun deleteMessage(chatId: Long, messageId: Long)
@Query("DELETE FROM messages WHERE chatId = :chatId")
suspend fun clearMessagesForChat(chatId: Long)
diff --git a/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt
new file mode 100644
index 00000000..4f7826dd
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt
@@ -0,0 +1,40 @@
+package org.monogram.data.db.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import org.monogram.data.db.model.NotificationExceptionEntity
+
+@Dao
+interface NotificationExceptionDao {
+ @Query("SELECT * FROM notification_exceptions WHERE scope = :scope ORDER BY updatedAt DESC")
+ suspend fun getByScope(scope: String): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(entity: NotificationExceptionEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(entities: List)
+
+ @Query("DELETE FROM notification_exceptions WHERE scope = :scope")
+ suspend fun deleteByScope(scope: String)
+
+ @Query("DELETE FROM notification_exceptions WHERE chatId = :chatId")
+ suspend fun deleteByChatId(chatId: Long)
+
+ @Query("UPDATE notification_exceptions SET isMuted = :isMuted, updatedAt = :updatedAt WHERE chatId = :chatId")
+ suspend fun updateMute(chatId: Long, isMuted: Boolean, updatedAt: Long = System.currentTimeMillis())
+
+ @Transaction
+ suspend fun replaceForScope(scope: String, entities: List) {
+ deleteByScope(scope)
+ if (entities.isNotEmpty()) {
+ insertAll(entities)
+ }
+ }
+
+ @Query("DELETE FROM notification_exceptions")
+ suspend fun clearAll()
+}
diff --git a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt
index 9ad1a9f4..bb2af930 100644
--- a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt
+++ b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt
@@ -28,4 +28,7 @@ interface UserDao {
@Query("DELETE FROM users WHERE createdAt < :timestamp")
suspend fun deleteExpired(timestamp: Long)
-}
\ No newline at end of file
+
+ @Query("UPDATE users SET avatarPath = NULL, personalAvatarPath = NULL WHERE avatarPath IS NOT NULL OR personalAvatarPath IS NOT NULL")
+ suspend fun clearAvatarPaths()
+}
diff --git a/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt b/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt
index d3f0f77b..12c32f54 100644
--- a/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt
+++ b/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt
@@ -9,11 +9,14 @@ import org.monogram.data.db.model.WallpaperEntity
@Dao
interface WallpaperDao {
- @Query("SELECT * FROM wallpapers")
- fun getWallpapers(): Flow>
+ @Query("SELECT * FROM wallpapers ORDER BY isDefault DESC, id ASC")
+ fun observeWallpapers(): Flow>
@Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertWallpapers(wallpapers: List)
+ suspend fun upsertWallpapers(wallpapers: List)
+
+ @Query("DELETE FROM wallpapers WHERE id NOT IN (:ids)")
+ suspend fun deleteNotIn(ids: List)
@Query("DELETE FROM wallpapers")
suspend fun clearAll()
diff --git a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt
index 67c65702..aadceb8e 100644
--- a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt
@@ -54,6 +54,17 @@ data class ChatEntity(
val typingAction: String? = null,
val draftMessage: String? = null,
val isVerified: Boolean = false,
+ val isScam: Boolean = false,
+ val isFake: Boolean = false,
+ val botVerificationIconCustomEmojiId: Long = 0L,
+ val restrictionReason: String? = null,
+ val hasSensitiveContent: Boolean = false,
+ val activeStoryStateType: String? = null,
+ val activeStoryId: Int = 0,
+ val boostLevel: Int = 0,
+ val hasForumTabs: Boolean = false,
+ val isAdministeredDirectMessagesGroup: Boolean = false,
+ val paidMessageStarCount: Long = 0L,
val isSponsor: Boolean = false,
val viewAsTopics: Boolean = false,
val isForum: Boolean = false,
diff --git a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt
index 1a72d4a1..71f57fb2 100644
--- a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt
@@ -13,17 +13,51 @@ data class ChatFullInfoEntity(
val administratorCount: Int,
val restrictedCount: Int,
val bannedCount: Int,
+ val directMessagesChatId: Long = 0L,
val commonGroupsCount: Int,
val giftCount: Int = 0,
val isBlocked: Boolean,
val botInfo: String?,
+ val botInfoData: String? = null,
+ val blockListType: String? = null,
+ val publicPhotoPath: String? = null,
+ val usesUnofficialApp: Boolean = false,
+ val hasSponsoredMessagesEnabled: Boolean = false,
+ val needPhoneNumberPrivacyException: Boolean = false,
+ val botVerificationBotUserId: Long = 0L,
+ val botVerificationIconCustomEmojiId: Long = 0L,
+ val botVerificationCustomDescription: String? = null,
+ val mainProfileTab: String? = null,
+ val firstProfileAudioData: String? = null,
+ val ratingData: String? = null,
+ val pendingRatingData: String? = null,
+ val pendingRatingDate: Int = 0,
val slowModeDelay: Int,
+ val slowModeDelayExpiresIn: Double = 0.0,
val locationAddress: String?,
+ val canEnablePaidMessages: Boolean = false,
+ val canEnablePaidReaction: Boolean = false,
+ val hasHiddenMembers: Boolean = false,
+ val canHideMembers: Boolean = false,
val canSetStickerSet: Boolean,
val canSetLocation: Boolean,
val canGetMembers: Boolean,
val canGetStatistics: Boolean,
val canGetRevenueStatistics: Boolean = false,
+ val canGetStarRevenueStatistics: Boolean = false,
+ val canToggleAggressiveAntiSpam: Boolean = false,
+ val isAllHistoryAvailable: Boolean = false,
+ val canHaveSponsoredMessages: Boolean = false,
+ val hasAggressiveAntiSpamEnabled: Boolean = false,
+ val hasPaidMediaAllowed: Boolean = false,
+ val hasPinnedStories: Boolean = false,
+ val myBoostCount: Int = 0,
+ val unrestrictBoostCount: Int = 0,
+ val stickerSetId: Long = 0L,
+ val customEmojiStickerSetId: Long = 0L,
+ val botCommandsData: String? = null,
+ val upgradedFromBasicGroupId: Long = 0L,
+ val upgradedFromMaxMessageId: Long = 0L,
val linkedChatId: Long,
val note: String?,
val canBeCalled: Boolean,
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 8cf3fb5c..a1b5cf25 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
@@ -2,10 +2,10 @@ package org.monogram.data.db.model
import androidx.room.Entity
import androidx.room.Index
-import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
+ primaryKeys = ["chatId", "id"],
indices = [
Index(value = ["chatId", "date"]),
Index(value = ["chatId", "id"]),
@@ -13,7 +13,7 @@ import androidx.room.PrimaryKey
]
)
data class MessageEntity(
- @PrimaryKey val id: Long,
+ val id: Long,
val chatId: Long,
val senderId: Long,
val senderName: String = "",
diff --git a/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt
new file mode 100644
index 00000000..4fdd3f5b
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt
@@ -0,0 +1,22 @@
+package org.monogram.data.db.model
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "notification_exceptions",
+ indices = [Index(value = ["scope"])]
+)
+data class NotificationExceptionEntity(
+ @PrimaryKey val chatId: Long,
+ val scope: String,
+ val title: String,
+ val avatarPath: String?,
+ val personalAvatarPath: String?,
+ val isMuted: Boolean,
+ val isGroup: Boolean,
+ val isChannel: Boolean,
+ val type: String,
+ val updatedAt: Long = System.currentTimeMillis()
+)
diff --git a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt
index 1f8959eb..65e1be7e 100644
--- a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt
@@ -20,18 +20,44 @@ data class UserEntity(
val personalAvatarPath: String? = null,
val isPremium: Boolean,
val isVerified: Boolean,
+ val isScam: Boolean = false,
+ val isFake: Boolean = false,
+ val botVerificationIconCustomEmojiId: Long = 0L,
val isSupport: Boolean = false,
val isContact: Boolean = false,
val isMutualContact: Boolean = false,
val isCloseFriend: Boolean = false,
+ val botTypeCanBeEdited: Boolean = false,
+ val botTypeCanJoinGroups: Boolean = false,
+ val botTypeCanReadAllGroupMessages: Boolean = false,
+ val botTypeHasMainWebApp: Boolean = false,
+ val botTypeHasTopics: Boolean = false,
+ val botTypeAllowsUsersToCreateTopics: Boolean = false,
+ val botTypeCanManageBots: Boolean = false,
+ val botTypeIsInline: Boolean = false,
+ val botTypeInlineQueryPlaceholder: String? = null,
+ val botTypeNeedLocation: Boolean = false,
+ val botTypeCanConnectToBusiness: Boolean = false,
+ val botTypeCanBeAddedToAttachmentMenu: Boolean = false,
+ val botTypeActiveUserCount: Int = 0,
+ val userType: String = "UNKNOWN",
+ val restrictionReason: String? = null,
+ val hasSensitiveContent: Boolean = false,
+ val activeStoryStateType: String? = null,
+ val activeStoryId: Int = 0,
+ val restrictsNewChats: Boolean = false,
+ val paidMessageStarCount: Long = 0L,
val haveAccess: Boolean = true,
val username: String?,
val usernamesData: String? = null,
val statusType: String = "OFFLINE",
val accentColorId: Int = 0,
+ val backgroundCustomEmojiId: Long = 0L,
val profileAccentColorId: Int = -1,
+ val profileBackgroundCustomEmojiId: Long = 0L,
val statusEmojiId: Long = 0L,
val languageCode: String? = null,
+ val addedToAttachmentMenu: Boolean = false,
val lastSeen: Long,
val createdAt: Long = System.currentTimeMillis()
)
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt
index 7278a176..5f44a1ea 100644
--- a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt
@@ -10,10 +10,39 @@ data class UserFullInfoEntity(
val commonGroupsCount: Int,
val giftCount: Int = 0,
val botInfoDescription: String? = null,
+ val botInfoShortDescription: String? = null,
+ val botInfoPhotoFileId: Int = 0,
+ val botInfoPhotoPath: String? = null,
+ val botInfoAnimationFileId: Int = 0,
+ val botInfoAnimationPath: String? = null,
+ val botInfoManagerBotUserId: Long = 0L,
+ val botInfoMenuButtonText: String? = null,
+ val botInfoMenuButtonUrl: String? = null,
+ val botInfoCommandsData: String? = null,
+ val botInfoPrivacyPolicyUrl: String? = null,
+ val botInfoDefaultGroupRightsData: String? = null,
+ val botInfoDefaultChannelRightsData: String? = null,
+ val botInfoAffiliateProgramData: String? = null,
+ val botInfoWebAppBackgroundLightColor: Int = -1,
+ val botInfoWebAppBackgroundDarkColor: Int = -1,
+ val botInfoWebAppHeaderLightColor: Int = -1,
+ val botInfoWebAppHeaderDarkColor: Int = -1,
+ val botInfoVerificationParametersIconCustomEmojiId: Long = 0L,
+ val botInfoVerificationParametersOrganizationName: String? = null,
+ val botInfoVerificationParametersDefaultCustomDescription: String? = null,
+ val botInfoVerificationParametersCanSetCustomDescription: Boolean = false,
+ val botInfoCanManageEmojiStatus: Boolean = false,
+ val botInfoHasMediaPreviews: Boolean = false,
+ val botInfoEditCommandsLinkType: String? = null,
+ val botInfoEditDescriptionLinkType: String? = null,
+ val botInfoEditDescriptionMediaLinkType: String? = null,
+ val botInfoEditSettingsLinkType: String? = null,
val personalChatId: Long = 0L,
val birthdateDay: Int = 0,
val birthdateMonth: Int = 0,
val birthdateYear: Int = 0,
+ val publicPhotoPath: String? = null,
+ val blockListType: String? = null,
val businessLocationAddress: String? = null,
val businessLocationLatitude: Double = 0.0,
val businessLocationLongitude: Double = 0.0,
@@ -22,8 +51,34 @@ data class UserFullInfoEntity(
val businessNextCloseIn: Int = 0,
val businessStartPageTitle: String? = null,
val businessStartPageMessage: String? = null,
+ val note: String? = null,
val personalPhotoPath: String? = null,
val isBlocked: Boolean,
+ val hasSponsoredMessagesEnabled: Boolean = false,
+ val needPhoneNumberPrivacyException: Boolean = false,
+ val usesUnofficialApp: Boolean = false,
+ val botVerificationBotUserId: Long = 0L,
+ val botVerificationIconCustomEmojiId: Long = 0L,
+ val botVerificationCustomDescription: String? = null,
+ val mainProfileTab: String? = null,
+ val firstProfileAudioDuration: Int = 0,
+ val firstProfileAudioTitle: String? = null,
+ val firstProfileAudioPerformer: String? = null,
+ val firstProfileAudioFileName: String? = null,
+ val firstProfileAudioMimeType: String? = null,
+ val firstProfileAudioFileId: Int = 0,
+ val firstProfileAudioPath: String? = null,
+ val ratingLevel: Int = 0,
+ val ratingIsMaximumLevelReached: Boolean = false,
+ val ratingValue: Long = 0L,
+ val ratingCurrentLevelValue: Long = 0L,
+ val ratingNextLevelValue: Long = 0L,
+ val pendingRatingLevel: Int = 0,
+ val pendingRatingIsMaximumLevelReached: Boolean = false,
+ val pendingRatingValue: Long = 0L,
+ val pendingRatingCurrentLevelValue: Long = 0L,
+ val pendingRatingNextLevelValue: Long = 0L,
+ val pendingRatingDate: Int = 0,
val canBeCalled: Boolean,
val supportsVideoCalls: Boolean,
val hasPrivateCalls: Boolean,
diff --git a/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt b/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt
index 77782767..d6476a3a 100644
--- a/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt
+++ b/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt
@@ -6,5 +6,26 @@ import androidx.room.PrimaryKey
@Entity(tableName = "wallpapers")
data class WallpaperEntity(
@PrimaryKey val id: Long,
- val data: String
+ val slug: String,
+ val title: String,
+ val type: String,
+ val pattern: Boolean,
+ val documentId: Long,
+ val thumbnailFileId: Int?,
+ val thumbnailWidth: Int?,
+ val thumbnailHeight: Int?,
+ val thumbnailLocalPath: String?,
+ val backgroundColor: Int?,
+ val secondBackgroundColor: Int?,
+ val thirdBackgroundColor: Int?,
+ val fourthBackgroundColor: Int?,
+ val intensity: Int?,
+ val rotation: Int?,
+ val isInverted: Boolean?,
+ val settingsIsMoving: Boolean?,
+ val settingsIsBlurred: Boolean?,
+ val themeName: String?,
+ val isDownloaded: Boolean,
+ val localPath: String?,
+ val isDefault: Boolean
)
diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt
index d4f3fba7..95395530 100644
--- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt
+++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine
import org.drinkless.tdlib.Client
import org.drinkless.tdlib.TdApi
@@ -18,14 +19,17 @@ internal class TdLibClient {
private val TAG = "TdLibClient"
private val globalRetryAfterUntilMs = AtomicLong(0L)
private val _updates = MutableSharedFlow(
- replay = 10,
- extraBufferCapacity = 1000,
+ replay = 3,
+ extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated = _isAuthenticated.asStateFlow()
+ private val _isInitialized = MutableStateFlow(false)
+ val isInitialized = _isInitialized.asStateFlow()
+
init {
try {
Client.execute(TdApi.SetLogVerbosityLevel(0))
@@ -41,7 +45,9 @@ internal class TdLibClient {
{ result ->
if (result is TdApi.Update) {
if (result is TdApi.UpdateAuthorizationState) {
- _isAuthenticated.value = result.authorizationState is TdApi.AuthorizationStateReady
+ val state = result.authorizationState
+ _isInitialized.value = state !is TdApi.AuthorizationStateWaitTdlibParameters
+ _isAuthenticated.value = state is TdApi.AuthorizationStateReady
}
_updates.tryEmit(result)
}
@@ -68,6 +74,16 @@ internal class TdLibClient {
}
suspend fun sendSuspend(function: TdApi.Function): T {
+ if (function !is TdApi.SetTdlibParameters &&
+ function !is TdApi.SetLogVerbosityLevel &&
+ function !is TdApi.GetOption &&
+ function !is TdApi.GetAuthorizationState) {
+ if (!_isInitialized.value) {
+ Log.d(TAG, "Waiting for TDLib initialization before sending $function")
+ isInitialized.first { it }
+ }
+ }
+
var retries = 0
while (true) {
waitForGlobalRetryWindow()
@@ -81,8 +97,12 @@ internal class TdLibClient {
if (result.code == 429 && retries < 3) {
retries++
val retryAfterMs = parseRetryAfterMs(result.message)
- updateGlobalRetryWindow(retryAfterMs)
Log.w(TAG, "Rate limited for $function, retrying in ${retryAfterMs}ms (attempt $retries)")
+ if (function is TdApi.GetUserFullInfo) {
+ delay(retryAfterMs)
+ } else {
+ updateGlobalRetryWindow(retryAfterMs)
+ }
continue
}
diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
index 31b46b38..62f55b52 100644
--- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
+++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt
@@ -35,6 +35,7 @@ import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.NotificationSettingsRepository
import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
import org.monogram.domain.repository.PushProvider
+import org.monogram.domain.repository.StringProvider
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.min
@@ -44,7 +45,8 @@ class TdNotificationManager(
private val appPreferences: AppPreferencesProvider,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val notificationSettingDao: NotificationSettingDao,
- private val fileQueue: FileDownloadQueue
+ private val fileQueue: FileDownloadQueue,
+ private val stringProvider: StringProvider
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val notificationManager = NotificationManagerCompat.from(context)
@@ -118,14 +120,16 @@ class TdNotificationManager(
is TdApi.UpdateUser -> userCache[update.user.id] = update.user
is TdApi.UpdateFile -> {
val file = update.file
- if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) {
+ val local = file.local
+ val localPath = local?.path
+ if (local?.isDownloadingCompleted == true && !localPath.isNullOrEmpty()) {
val callbacks = synchronized(activeDownloads) {
activeDownloads.remove(file.id)
}
if (callbacks != null) {
scope.launch(Dispatchers.IO) {
val bitmap = try {
- BitmapFactory.decodeFile(file.local.path)
+ BitmapFactory.decodeFile(localPath)
} catch (e: Exception) {
null
}
@@ -222,10 +226,9 @@ class TdNotificationManager(
coRunCatching {
val result = gateway.execute(TdApi.GetChatNotificationSettingsExceptions(scope, true))
if (result is TdApi.Chats) {
- result.chatIds.forEach { chatId ->
- getChat(chatId) { chat ->
- updateChatNotificationSettings(chat.id, chat.notificationSettings)
- }
+ for (chatId in result.chatIds.distinct()) {
+ val chat = getChatSuspend(chatId) ?: continue
+ updateChatNotificationSettings(chat.id, chat.notificationSettings)
}
}
}.onFailure {
@@ -262,15 +265,17 @@ class TdNotificationManager(
fun isChatMuted(chat: TdApi.Chat): Boolean {
val cached = notificationSettingsCache[chat.id]
- val muteFor = cached?.muteFor ?: chat.notificationSettings.muteFor
- val useDefault = cached?.useDefault ?: chat.notificationSettings.useDefaultMuteFor
+ val chatSettings = chat.notificationSettings
+ val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: return true
+ val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: return true
return if (useDefault) {
- val scopeKey = when (chat.type) {
+ val chatType = chat.type ?: return true
+ val scopeKey = when (chatType) {
is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE
is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS
is TdApi.ChatTypeSupergroup -> {
- if ((chat.type as TdApi.ChatTypeSupergroup).isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS
+ if (chatType.isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS
}
else -> null
@@ -320,6 +325,18 @@ class TdNotificationManager(
private fun handleNewMessage(message: TdApi.Message) {
if (message.isOutgoing) return
+ val messageContent = message.content
+ if (messageContent == null) {
+ Log.w(TAG, "Skipping notification for message ${message.id}: content is null")
+ return
+ }
+
+ val senderId = message.senderId
+ if (senderId == null) {
+ Log.w(TAG, "Skipping notification for message ${message.id}: senderId is null")
+ return
+ }
+
val lastId = lastMessageIds[message.chatId]
if (lastId != null && message.id <= lastId) {
return
@@ -328,6 +345,12 @@ class TdNotificationManager(
getChat(message.chatId) { chat ->
scope.launch {
+ val chatType = chat.type
+ if (chatType == null) {
+ Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null")
+ return@launch
+ }
+
val isMember = checkMembership(chat)
if (!isMember) {
Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member")
@@ -337,7 +360,7 @@ class TdNotificationManager(
if (isChatMuted(chat)) return@launch
val contentText =
- if (appPreferences.showSenderOnly.value) "Новое сообщение" else getMessageText(message.content)
+ if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(messageContent)
if (contentText.isBlank()) return@launch
@@ -346,13 +369,13 @@ class TdNotificationManager(
val shouldDownloadAvatar =
!appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value
- resolveSender(message.senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap ->
+ resolveSender(senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap ->
if (shouldDownloadAvatar) {
downloadAvatar(chat.photo, false) { chatIcon ->
appendMessageToNotification(
chatId = chat.id,
messageId = message.id,
- chatType = chat.type,
+ chatType = chatType,
senderName = senderName,
senderBitmap = senderBitmap,
chatIcon = chatIcon ?: senderBitmap,
@@ -364,7 +387,7 @@ class TdNotificationManager(
appendMessageToNotification(
chatId = chat.id,
messageId = message.id,
- chatType = chat.type,
+ chatType = chatType,
senderName = senderName,
senderBitmap = senderBitmap,
chatIcon = senderBitmap,
@@ -378,25 +401,26 @@ class TdNotificationManager(
}
private suspend fun checkMembership(chat: TdApi.Chat): Boolean {
- return when (val type = chat.type) {
+ val chatType = chat.type ?: return true
+ return when (chatType) {
is TdApi.ChatTypePrivate -> true
is TdApi.ChatTypeBasicGroup -> {
- if (type.basicGroupId == 0L) {
+ if (chatType.basicGroupId == 0L) {
return true
}
coRunCatching {
- val result = gateway.execute(TdApi.GetBasicGroup(type.basicGroupId))
+ val result = gateway.execute(TdApi.GetBasicGroup(chatType.basicGroupId))
result.status is TdApi.ChatMemberStatusMember ||
result.status is TdApi.ChatMemberStatusCreator ||
result.status is TdApi.ChatMemberStatusAdministrator
}.getOrDefault(true)
}
is TdApi.ChatTypeSupergroup -> {
- if (type.supergroupId == 0L) {
+ if (chatType.supergroupId == 0L) {
return true
}
coRunCatching {
- val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId))
+ val result = gateway.execute(TdApi.GetSupergroup(chatType.supergroupId))
result.status is TdApi.ChatMemberStatusMember ||
result.status is TdApi.ChatMemberStatusCreator ||
result.status is TdApi.ChatMemberStatusAdministrator
@@ -478,7 +502,7 @@ class TdNotificationManager(
)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
- .setLabel("Ответить")
+ .setLabel(stringProvider.getString("menu_reply"))
.build()
val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply {
@@ -506,18 +530,18 @@ class TdNotificationManager(
val replyAction = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
- "Ответить",
+ stringProvider.getString("menu_reply"),
replyPendingIntent
).addRemoteInput(remoteInput).build()
val readAction = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_view,
- "Прочитано",
+ stringProvider.getString("action_mark_as_read"),
readPendingIntent
).build()
- val myself = Person.Builder().setName("Я").build()
+ val myself = Person.Builder().setName(stringProvider.getString("notification_person_me")).build()
val messagingStyle = NotificationCompat.MessagingStyle(myself)
history.forEach { (_, msg) ->
messagingStyle.addMessage(msg)
@@ -571,7 +595,7 @@ class TdNotificationManager(
}
if (!appPreferences.inAppPreview.value) {
- builder.setContentText("Новое сообщение")
+ builder.setContentText(stringProvider.getString("notification_new_message"))
}
if (chatIcon != null) {
@@ -631,7 +655,7 @@ class TdNotificationManager(
allMessages.take(5).forEach { (chatId, message, _) ->
val chat = chatCache[chatId]
- val senderName = message.person?.name ?: "Unknown"
+ val senderName = message.person?.name ?: stringProvider.getString("unknown_user")
val chatTitle = chat?.title ?: senderName
val sb = SpannableStringBuilder()
@@ -644,8 +668,12 @@ class TdNotificationManager(
inboxStyle.addLine(sb)
}
- val summaryTitle = "$totalMessagesCount сообщений из $activeChatsCount чатов"
- inboxStyle.setSummaryText("$activeChatsCount чатов")
+ val summaryTitle = stringProvider.getString(
+ "notification_summary_title_format",
+ totalMessagesCount,
+ activeChatsCount
+ )
+ inboxStyle.setSummaryText(stringProvider.getString("notification_summary_text_format", activeChatsCount))
inboxStyle.setBigContentTitle(summaryTitle)
val builder = NotificationCompat.Builder(context, CHANNEL_PRIVATE)
@@ -678,34 +706,34 @@ class TdNotificationManager(
manager.createNotificationChannelGroups(
listOf(
- NotificationChannelGroup(GROUP_CHATS, "Чаты"),
- NotificationChannelGroup(GROUP_OTHER, "Прочее")
+ NotificationChannelGroup(GROUP_CHATS, stringProvider.getString("notification_group_chats")),
+ NotificationChannelGroup(GROUP_OTHER, stringProvider.getString("notification_group_other"))
)
)
val channels = listOf(
- NotificationChannel(CHANNEL_PRIVATE, "Личные чаты", NotificationManager.IMPORTANCE_HIGH).apply {
- description = "Уведомления из личных переписок"
+ NotificationChannel(CHANNEL_PRIVATE, stringProvider.getString("notification_channel_private_name"), NotificationManager.IMPORTANCE_HIGH).apply {
+ description = stringProvider.getString("notification_channel_private_description")
group = GROUP_CHATS
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
},
- NotificationChannel(CHANNEL_GROUPS, "Группы", NotificationManager.IMPORTANCE_DEFAULT).apply {
- description = "Уведомления из групп"
+ NotificationChannel(CHANNEL_GROUPS, stringProvider.getString("notification_channel_groups_name"), NotificationManager.IMPORTANCE_DEFAULT).apply {
+ description = stringProvider.getString("notification_channel_groups_description")
group = GROUP_CHATS
enableVibration(true)
setShowBadge(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
},
- NotificationChannel(CHANNEL_CHANNELS, "Каналы", NotificationManager.IMPORTANCE_LOW).apply {
- description = "Уведомления из каналов"
+ NotificationChannel(CHANNEL_CHANNELS, stringProvider.getString("notification_channel_channels_name"), NotificationManager.IMPORTANCE_LOW).apply {
+ description = stringProvider.getString("notification_channel_channels_description")
group = GROUP_CHATS
enableVibration(false)
setShowBadge(true)
},
- NotificationChannel(CHANNEL_OTHER, "Другое", NotificationManager.IMPORTANCE_LOW).apply {
- description = "Прочие уведомления"
+ NotificationChannel(CHANNEL_OTHER, stringProvider.getString("notification_channel_other_name"), NotificationManager.IMPORTANCE_LOW).apply {
+ description = stringProvider.getString("notification_channel_other_description")
group = GROUP_OTHER
}
)
@@ -714,20 +742,42 @@ class TdNotificationManager(
}
}
- private fun getMessageText(content: TdApi.MessageContent): String {
+ private fun getMessageText(content: TdApi.MessageContent?): String {
+ fun withDetails(base: String, details: String?): String {
+ val cleanDetails = details?.trim().orEmpty()
+ return if (cleanDetails.isEmpty()) base else "$base $cleanDetails"
+ }
+
+ if (content == null) {
+ return stringProvider.getString("reply_content_message")
+ }
+
return when (content) {
is TdApi.MessageText -> sanitizeSpoilers(content.text)
- is TdApi.MessagePhoto -> "📷 Фотография ${sanitizeSpoilers(content.caption)}"
- is TdApi.MessageVideo -> "📹 Видео ${sanitizeSpoilers(content.caption)}"
- is TdApi.MessageVoiceNote -> "🎤 Голосовое сообщение"
- is TdApi.MessageSticker -> "Стикер"
- is TdApi.MessageAnimation -> "GIF"
- is TdApi.MessageAudio -> "🎵 Аудио ${content.audio.title}"
- is TdApi.MessageDocument -> "📄 Файл ${content.document.fileName}"
- is TdApi.MessageLocation -> "📍 Локация ${content.location.latitude}, ${content.location.longitude}"
- is TdApi.MessageContact -> "👤 Контакт ${content.contact.firstName} ${content.contact.lastName}"
- is TdApi.MessagePoll -> "📊 Опрос ${content.poll.question.text}"
- else -> "Сообщение"
+ is TdApi.MessagePhoto -> withDetails("📷 ${stringProvider.getString("logs_media_photo")}", sanitizeSpoilers(content.caption))
+ is TdApi.MessageVideo -> withDetails("📹 ${stringProvider.getString("logs_media_video")}", sanitizeSpoilers(content.caption))
+ is TdApi.MessageVoiceNote -> "🎤 ${stringProvider.getString("logs_media_voice")}"
+ is TdApi.MessageSticker -> stringProvider.getString("reply_content_sticker")
+ is TdApi.MessageAnimation -> stringProvider.getString("reply_content_gif")
+ is TdApi.MessageAudio -> withDetails("🎵 ${stringProvider.getString("logs_media_audio")}", content.audio?.title)
+ is TdApi.MessageDocument -> withDetails("📄 ${stringProvider.getString("logs_media_document")}", content.document?.fileName)
+ is TdApi.MessageLocation -> {
+ val location = content.location
+ if (location != null) {
+ "📍 ${stringProvider.getString("location_label")} ${location.latitude}, ${location.longitude}"
+ } else {
+ "📍 ${stringProvider.getString("location_label")}"
+ }
+ }
+ is TdApi.MessageContact -> withDetails(
+ "👤 ${stringProvider.getString("logs_media_contact")}",
+ listOf(content.contact?.firstName, content.contact?.lastName)
+ .filterNotNull()
+ .filter { it.isNotBlank() }
+ .joinToString(" ")
+ )
+ is TdApi.MessagePoll -> withDetails("📊 ${stringProvider.getString("logs_media_poll")}", content.poll?.question?.text)
+ else -> stringProvider.getString("reply_content_message")
}
}
@@ -760,12 +810,19 @@ class TdNotificationManager(
return
}
scope.launch {
- try {
- val result = gateway.execute(TdApi.GetChat(chatId))
- chatCache[chatId] = result
- callback(result)
- } catch (_: Exception) {
+ getChatSuspend(chatId)?.let(callback)
+ }
+ }
+
+ private suspend fun getChatSuspend(chatId: Long): TdApi.Chat? {
+ chatCache[chatId]?.let { return it }
+
+ return try {
+ gateway.execute(TdApi.GetChat(chatId)).also { chat ->
+ chatCache[chat.id] = chat
}
+ } catch (_: Exception) {
+ null
}
}
@@ -787,16 +844,29 @@ class TdNotificationManager(
private fun resolveSender(
- senderId: TdApi.MessageSender,
+ senderId: TdApi.MessageSender?,
chat: TdApi.Chat,
onlyIfLocal: Boolean = false,
callback: (String, Bitmap?) -> Unit
) {
+ val fallbackName = chat.title?.takeIf { it.isNotBlank() } ?: stringProvider.getString("unknown_user")
+
+ if (senderId == null) {
+ downloadFile(chat.photo?.small, onlyIfLocal) { bitmap ->
+ callback(fallbackName, bitmap)
+ }
+ return
+ }
+
when (senderId) {
is TdApi.MessageSenderUser -> {
getUser(senderId.userId) { user ->
+ val fullName = listOf(user.firstName, user.lastName)
+ .filterNotNull()
+ .filter { it.isNotBlank() }
+ .joinToString(" ")
val name =
- if (chat.type is TdApi.ChatTypePrivate) chat.title else "${user.firstName} ${user.lastName}".trim()
+ if (chat.type is TdApi.ChatTypePrivate) fallbackName else fullName.ifBlank { fallbackName }
val file =
user.profilePhoto?.small ?: if (chat.type is TdApi.ChatTypePrivate) chat.photo?.small else null
downloadFile(file, onlyIfLocal) { bitmap ->
@@ -807,7 +877,7 @@ class TdNotificationManager(
is TdApi.MessageSenderChat -> {
getChat(senderId.chatId) { senderChat ->
- val name = senderChat.title
+ val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName
downloadFile(senderChat.photo?.small, onlyIfLocal) { bitmap ->
callback(name, bitmap)
}
@@ -815,9 +885,8 @@ class TdNotificationManager(
}
else -> {
- val name = chat.title
downloadFile(chat.photo?.small, onlyIfLocal) { bitmap ->
- callback(name, bitmap)
+ callback(fallbackName, bitmap)
}
}
}
@@ -843,11 +912,13 @@ class TdNotificationManager(
return
}
- if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) {
+ val local = file.local
+ val localPath = local?.path
+ if (local?.isDownloadingCompleted == true && !localPath.isNullOrEmpty()) {
val bitmap = try {
- BitmapFactory.decodeFile(file.local.path)
+ BitmapFactory.decodeFile(localPath)
} catch (e: Exception) {
- Log.e(TAG, "Error decoding file: ${file.local.path}", e)
+ Log.e(TAG, "Error decoding file: $localPath", e)
null
}
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 93d924e8..d1c19d29 100644
--- a/data/src/main/java/org/monogram/data/di/dataModule.kt
+++ b/data/src/main/java/org/monogram/data/di/dataModule.kt
@@ -5,46 +5,164 @@ import android.net.ConnectivityManager
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
+import org.monogram.data.BuildConfig
import org.monogram.data.chats.ChatCache
import org.monogram.data.datasource.FileDataSource
import org.monogram.data.datasource.PlayerDataSourceFactoryImpl
import org.monogram.data.datasource.TdFileDataSource
-import org.monogram.data.datasource.cache.*
-import org.monogram.data.datasource.remote.*
+import org.monogram.data.datasource.cache.ChatLocalDataSource
+import org.monogram.data.datasource.cache.ChatsCacheDataSource
+import org.monogram.data.datasource.cache.InMemorySettingsCacheDataSource
+import org.monogram.data.datasource.cache.RoomChatLocalDataSource
+import org.monogram.data.datasource.cache.RoomStickerLocalDataSource
+import org.monogram.data.datasource.cache.RoomUserLocalDataSource
+import org.monogram.data.datasource.cache.SettingsCacheDataSource
+import org.monogram.data.datasource.cache.StickerLocalDataSource
+import org.monogram.data.datasource.cache.UserCacheDataSource
+import org.monogram.data.datasource.cache.UserLocalDataSource
+import org.monogram.data.datasource.remote.AuthRemoteDataSource
+import org.monogram.data.datasource.remote.ChatRemoteSource
+import org.monogram.data.datasource.remote.ChatsRemoteDataSource
+import org.monogram.data.datasource.remote.EmojiRemoteSource
+import org.monogram.data.datasource.remote.GifRemoteSource
+import org.monogram.data.datasource.remote.LinkRemoteDataSource
+import org.monogram.data.datasource.remote.MessageFileApi
+import org.monogram.data.datasource.remote.MessageFileCoordinator
+import org.monogram.data.datasource.remote.MessageRemoteDataSource
+import org.monogram.data.datasource.remote.NominatimRemoteDataSource
+import org.monogram.data.datasource.remote.PrivacyRemoteDataSource
+import org.monogram.data.datasource.remote.ProxyRemoteDataSource
+import org.monogram.data.datasource.remote.SettingsRemoteDataSource
+import org.monogram.data.datasource.remote.StickerRemoteSource
+import org.monogram.data.datasource.remote.TdAuthRemoteDataSource
+import org.monogram.data.datasource.remote.TdChatRemoteSource
+import org.monogram.data.datasource.remote.TdChatsRemoteDataSource
+import org.monogram.data.datasource.remote.TdEmojiRemoteSource
+import org.monogram.data.datasource.remote.TdGifRemoteSource
+import org.monogram.data.datasource.remote.TdLinkRemoteDataSource
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.data.datasource.remote.TdPrivacyRemoteDataSource
+import org.monogram.data.datasource.remote.TdProxyRemoteDataSource
+import org.monogram.data.datasource.remote.TdSettingsRemoteDataSource
+import org.monogram.data.datasource.remote.TdStickerRemoteSource
+import org.monogram.data.datasource.remote.TdUpdateRemoteDataSource
+import org.monogram.data.datasource.remote.TdUserRemoteDataSource
+import org.monogram.data.datasource.remote.UpdateRemoteDateSource
+import org.monogram.data.datasource.remote.UserRemoteDataSource
import org.monogram.data.db.MonogramDatabase
import org.monogram.data.db.MonogramMigrations
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.gateway.TelegramGatewayImpl
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.gateway.UpdateDispatcherImpl
-import org.monogram.data.infra.*
+import org.monogram.data.infra.AndroidStringProvider
+import org.monogram.data.infra.ConnectionManager
+import org.monogram.data.infra.DataMemoryDiagnostics
+import org.monogram.data.infra.DataMemoryPressureHandler
+import org.monogram.data.infra.DefaultDispatcherProvider
+import org.monogram.data.infra.FileDownloadQueue
+import org.monogram.data.infra.FileMessageRegistry
+import org.monogram.data.infra.FileObserverHub
+import org.monogram.data.infra.FileUpdateHandler
+import org.monogram.data.infra.OfflineWarmup
+import org.monogram.data.infra.SponsorSyncManager
+import org.monogram.data.infra.TdLibParametersProvider
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.StorageMapper
-import org.monogram.data.repository.*
+import org.monogram.data.mapper.TdFileHelper
+import org.monogram.data.mapper.WebPageMapper
+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.repository.AttachMenuBotRepositoryImpl
+import org.monogram.data.repository.AuthRepositoryImpl
+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.EmojiRepositoryImpl
+import org.monogram.data.repository.ExternalProxyRepositoryImpl
+import org.monogram.data.repository.GifRepositoryImpl
+import org.monogram.data.repository.LinkHandlerRepositoryImpl
+import org.monogram.data.repository.LinkParser
+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.PollRepositoryImpl
+import org.monogram.data.repository.PremiumRepositoryImpl
+import org.monogram.data.repository.PrivacyRepositoryImpl
+import org.monogram.data.repository.ProfilePhotoRepositoryImpl
+import org.monogram.data.repository.SessionRepositoryImpl
+import org.monogram.data.repository.SponsorRepositoryImpl
+import org.monogram.data.repository.StickerRepositoryImpl
+import org.monogram.data.repository.StorageRepositoryImpl
+import org.monogram.data.repository.StreamingRepositoryImpl
+import org.monogram.data.repository.UpdateRepositoryImpl
+import org.monogram.data.repository.UserProfileEditRepositoryImpl
+import org.monogram.data.repository.WallpaperRepositoryImpl
import org.monogram.data.repository.user.UserRepositoryImpl
import org.monogram.data.stickers.StickerFileManager
-import org.monogram.domain.repository.*
+import org.monogram.domain.repository.AttachMenuBotRepository
+import org.monogram.domain.repository.AuthRepository
+import org.monogram.domain.repository.BotRepository
+import org.monogram.domain.repository.ChatCreationRepository
+import org.monogram.domain.repository.ChatEventLogRepository
+import org.monogram.domain.repository.ChatFolderRepository
+import org.monogram.domain.repository.ChatInfoRepository
+import org.monogram.domain.repository.ChatListRepository
+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.EmojiRepository
+import org.monogram.domain.repository.ExternalProxyRepository
+import org.monogram.domain.repository.FileRepository
+import org.monogram.domain.repository.ForumTopicsRepository
+import org.monogram.domain.repository.GifRepository
+import org.monogram.domain.repository.InlineBotRepository
+import org.monogram.domain.repository.LinkHandlerRepository
+import org.monogram.domain.repository.LocationRepository
+import org.monogram.domain.repository.MessageAiRepository
+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.PlayerDataSourceFactory
+import org.monogram.domain.repository.PollRepository
+import org.monogram.domain.repository.PremiumRepository
+import org.monogram.domain.repository.PrivacyRepository
+import org.monogram.domain.repository.ProfilePhotoRepository
+import org.monogram.domain.repository.SessionRepository
+import org.monogram.domain.repository.SponsorRepository
+import org.monogram.domain.repository.StickerRepository
+import org.monogram.domain.repository.StorageRepository
+import org.monogram.domain.repository.StreamingRepository
+import org.monogram.domain.repository.StringProvider
+import org.monogram.domain.repository.UpdateRepository
+import org.monogram.domain.repository.UserProfileEditRepository
+import org.monogram.domain.repository.UserRepository
+import org.monogram.domain.repository.WallpaperRepository
+import org.monogram.domain.repository.WebAppRepository
val dataModule = module {
- single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
+ single { CoroutineScope(SupervisorJob() + get().default) }
single(createdAtStart = true) { TdLibClient() }
single { DefaultDispatcherProvider() }
- single { DefaultScopeProvider(get()) }
single { AndroidStringProvider(androidContext()) }
- single { TdLibParametersProvider(androidContext()) }
+ single(createdAtStart = true) { TdLibParametersProvider(androidContext()) }
single(createdAtStart = true) {
OfflineWarmup(
- scopeProvider = get(),
+ scope = get(),
dispatchers = get(),
gateway = get(),
chatDao = get(),
@@ -59,7 +177,7 @@ val dataModule = module {
}
single(createdAtStart = true) {
SponsorSyncManager(
- scopeProvider = get(),
+ scope = get(),
gateway = get(),
sponsorDao = get(),
authRepository = get()
@@ -103,7 +221,7 @@ val dataModule = module {
parametersProvider = get(),
remote = get(),
updates = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -127,7 +245,13 @@ val dataModule = module {
"monogram_db"
)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
- .addMigrations(MonogramMigrations.MIGRATION_26_27)
+ .addMigrations(
+ MonogramMigrations.MIGRATION_26_27,
+ MonogramMigrations.MIGRATION_27_28,
+ MonogramMigrations.MIGRATION_28_29,
+ MonogramMigrations.MIGRATION_29_30,
+ MonogramMigrations.MIGRATION_30_31
+ )
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
}
@@ -144,6 +268,7 @@ val dataModule = module {
single { get().attachBotDao() }
single { get().keyValueDao() }
single { get().notificationSettingDao() }
+ single { get().notificationExceptionDao() }
single { get().wallpaperDao() }
single { get().stickerPathDao() }
single { get().sponsorDao() }
@@ -181,9 +306,10 @@ val dataModule = module {
chatLocal = get(),
chatCache = get(),
updates = get(),
- scopeProvider = get(),
+ scope = get(),
gateway = get(),
fileQueue = get(),
+ fileObserverHub = get(),
keyValueDao = get(),
cacheProvider = get()
)
@@ -200,8 +326,8 @@ val dataModule = module {
remote = get(),
chatLocal = get(),
gateway = get(),
- updates = get(),
- fileQueue = get()
+ fileQueue = get(),
+ fileObserverHub = get()
)
}
@@ -261,7 +387,7 @@ val dataModule = module {
}
single {
- ChatMapper(get())
+ ChatMapper(get(), get())
}
single {
@@ -283,16 +409,67 @@ val dataModule = module {
}
single {
- MessageMapper(
+ TdFileHelper(
connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+ fileApi = get(),
+ appPreferences = get(),
+ cache = get()
+ )
+ }
+
+ single {
+ CustomEmojiLoader(
gateway = get(),
- userRepository = get(),
- chatInfoRepository = get(),
- fileUpdateHandler = get(),
fileApi = get(),
+ fileUpdateHandler = get(),
+ fileHelper = get()
+ )
+ }
+
+ single {
+ WebPageMapper(
+ fileHelper = get(),
+ appPreferences = get()
+ )
+ }
+
+ single {
+ MessageContentMapper(
+ fileHelper = get(),
appPreferences = get(),
+ customEmojiLoader = get(),
+ webPageMapper = get(),
+ scope = get()
+ )
+ }
+
+ single {
+ MessageSenderResolver(
+ gateway = get(),
+ userRepository = get(),
+ chatInfoRepository = get(),
+ cache = get(),
+ fileHelper = get()
+ )
+ }
+
+ single {
+ MessagePersistenceMapper(
cache = get(),
- scopeProvider = get()
+ fileHelper = get()
+ )
+ }
+
+ single {
+ MessageMapper(
+ gateway = get(),
+ userRepository = get(),
+ cache = get(),
+ fileHelper = get(),
+ senderResolver = get(),
+ contentMapper = get(),
+ persistenceMapper = get(),
+ customEmojiLoader = get()
)
}
@@ -304,7 +481,7 @@ val dataModule = module {
appPreferences = get(),
dispatchers = get(),
connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
- scopeProvider = get()
+ scope = get()
)
}
@@ -320,7 +497,7 @@ val dataModule = module {
chatMapper = get(),
messageMapper = get(),
gateway = get(),
- scopeProvider = get(),
+ scope = get(),
chatLocalDataSource = get(),
connectionManager = get(),
databaseFile = androidContext().getDatabasePath("monogram_db"),
@@ -356,8 +533,9 @@ val dataModule = module {
remote = get(),
cache = get(),
chatsRemote = get(),
+ notificationExceptionDao = get(),
updates = get(),
- scopeProvider = get(),
+ scope = get(),
dispatchers = get()
)
}
@@ -371,10 +549,10 @@ val dataModule = module {
single {
WallpaperRepositoryImpl(
remote = get(),
- updates = get(),
wallpaperDao = get(),
+ fileObserverHub = get(),
dispatchers = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -402,9 +580,10 @@ val dataModule = module {
cache = get(),
cacheProvider = get(),
updates = get(),
+ fileObserverHub = get(),
dispatchers = get(),
attachBotDao = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -423,7 +602,7 @@ val dataModule = module {
fileDownloadQueue = get(),
fileUpdateHandler = get(),
dispatcherProvider = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -435,12 +614,14 @@ val dataModule = module {
messageMapper = get(),
messageRemoteDataSource = get(),
cache = get(),
+ fileHelper = get(),
dispatcherProvider = get(),
- scopeProvider = get(),
+ scope = get(),
fileDataSource = get(),
chatLocalDataSource = get(),
userLocalDataSource = get(),
- fileUpdateHandler = get(),
+ stickerPathDao = get(),
+ keyValueDao = get(),
textCompositionStyleDao = get()
)
}
@@ -493,13 +674,36 @@ val dataModule = module {
)
}
+ single {
+ FileObserverHub(
+ queue = get(),
+ fileUpdateHandler = get()
+ )
+ }
+
+ single {
+ DataMemoryPressureHandler(
+ chatsListRepository = get(),
+ fileUpdateHandler = get()
+ )
+ }
+
+ if (BuildConfig.DEBUG) {
+ single(createdAtStart = true) {
+ DataMemoryDiagnostics(
+ scope = get(),
+ memoryPressureHandler = get()
+ )
+ }
+ }
+
single {
StickerFileManager(
localDataSource = get(),
fileQueue = get(),
fileUpdateHandler = get(),
dispatchers = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -511,7 +715,7 @@ val dataModule = module {
cacheProvider = get(),
dispatchers = get(),
localDataSource = get(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -530,7 +734,7 @@ val dataModule = module {
cacheProvider = get(),
dispatchers = get(),
context = androidContext(),
- scopeProvider = get()
+ scope = get()
)
}
@@ -543,8 +747,7 @@ val dataModule = module {
single {
PrivacyRepositoryImpl(
remote = get(),
- updates = get(),
- scopeProvider = get()
+ updates = get()
)
}
@@ -559,22 +762,13 @@ val dataModule = module {
single {
StreamingRepositoryImpl(
fileDataSource = get(),
- updates = get(),
- scopeProvider = get()
- )
- }
-
- factory {
- HttpExternalProxyDataSource(
- dispatchers = get()
+ fileObserverHub = get()
)
}
single {
ExternalProxyRepositoryImpl(
remote = get(),
- externalSource = get(),
- dispatchers = get(),
appPreferences = get()
)
}
@@ -598,9 +792,9 @@ val dataModule = module {
fileQueue = get(),
fileUpdateHandler = get(),
authRepository = get(),
- scopeProvider = get(),
+ scope = get(),
)
}
- single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get()) }
+ single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get()) }
}
diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
index df929ed2..e1116048 100644
--- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
+++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
@@ -1,25 +1,38 @@
package org.monogram.data.infra
-import org.monogram.data.core.coRunCatching
import android.net.ConnectivityManager
import android.net.Network
+import android.net.NetworkCapabilities
import android.net.NetworkRequest
-import android.net.Uri
import android.os.Build
import android.util.Log
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
+import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.remote.ChatRemoteSource
import org.monogram.data.datasource.remote.ProxyRemoteDataSource
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.ConnectionStatus
+import org.monogram.domain.repository.ProxyNetworkMode
+import org.monogram.domain.repository.ProxyNetworkRule
+import org.monogram.domain.repository.ProxyNetworkType
+import org.monogram.domain.repository.ProxyUnavailableFallback
+import org.monogram.domain.repository.defaultProxyNetworkMode
import kotlin.random.Random
class ConnectionManager(
@@ -29,10 +42,9 @@ class ConnectionManager(
private val appPreferences: AppPreferencesProvider,
private val dispatchers: DispatcherProvider,
private val connectivityManager: ConnectivityManager,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) {
- private val TAG = "ConnectionManager"
- private val scope = scopeProvider.appScope
+ private val tag = "ConnectionManager"
private val _connectionStateFlow = MutableStateFlow(ConnectionStatus.Connecting)
val connectionStateFlow = _connectionStateFlow.asStateFlow()
@@ -40,12 +52,12 @@ class ConnectionManager(
private var retryJob: Job? = null
private var proxyModeWatcherJob: Job? = null
private var autoBestJob: Job? = null
- private var telegaSwitchJob: Job? = null
private var watchdogJob: Job? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var reconnectAttempts = 0
private var lastRetryAtMs = 0L
private var lastStateChangeAtMs = System.currentTimeMillis()
+ private val proxyRuleMutex = Mutex()
private val minRetryIntervalMs = 1_200L
private val maxRetryDelayMs = 60_000L
@@ -87,7 +99,7 @@ class ConnectionManager(
val previous = _connectionStateFlow.value
if (previous != status) {
lastStateChangeAtMs = System.currentTimeMillis()
- Log.d(TAG, "Connection state changed: $previous -> $status ($source)")
+ Log.d(tag, "Connection state changed: $previous -> $status ($source)")
}
_connectionStateFlow.value = status
@@ -135,19 +147,22 @@ class ConnectionManager(
lastRetryAtMs = now
reconnectAttempts++
- Log.d(TAG, "Reconnect attempt #$reconnectAttempts ($reason), state=${_connectionStateFlow.value}")
+ Log.d(
+ tag,
+ "Reconnect attempt #$reconnectAttempts ($reason), state=${_connectionStateFlow.value}"
+ )
val networkTypeUpdated = coRunCatching {
withContext(dispatchers.io) {
chatRemoteSource.setNetworkType()
}
}.getOrElse { error ->
- Log.e(TAG, "Reconnect attempt failed", error)
+ Log.e(tag, "Reconnect attempt failed", error)
false
}
if (!networkTypeUpdated) {
- Log.w(TAG, "Reconnect attempt did not update network type")
+ Log.w(tag, "Reconnect attempt did not update network type")
}
coRunCatching {
@@ -177,17 +192,16 @@ class ConnectionManager(
}
private suspend fun maybeAdjustProxyOnFailures(force: Boolean = false) {
- val isTelegaEnabled = appPreferences.isTelegaProxyEnabled.value
val isAutoBestEnabled = appPreferences.isAutoBestProxyEnabled.value
- if (!isAutoBestEnabled && !isTelegaEnabled) return
+ if (!isAutoBestEnabled) return
if (!force) {
if (reconnectAttempts < 4) return
if (reconnectAttempts % 3 != 0) return
}
- coRunCatching { selectBestProxy(telegaOnly = isTelegaEnabled) }
- .onFailure { Log.e(TAG, "Proxy fallback failed during reconnect", it) }
+ coRunCatching { applyNetworkProxyRule("reconnect_failures") }
+ .onFailure { Log.e(tag, "Proxy fallback failed during reconnect", it) }
}
private fun calculateRetryDelayMs(status: ConnectionStatus, attempts: Int): Long {
@@ -207,56 +221,103 @@ class ConnectionManager(
proxyModeWatcherJob?.cancel()
proxyModeWatcherJob = scope.launch {
appPreferences.enabledProxyId.value?.let { proxyId ->
- if (!proxyRemoteSource.enableProxy(proxyId)) {
+ if (!enableProxy(proxyId, getCurrentNetworkType(), "startup_restore")) {
appPreferences.setEnabledProxyId(null)
- coRunCatching { selectBestProxy(telegaOnly = appPreferences.isTelegaProxyEnabled.value) }
}
}
- combine(
- appPreferences.isAutoBestProxyEnabled,
- appPreferences.isTelegaProxyEnabled
- ) { autoBest, telega -> autoBest to telega }
- .distinctUntilChanged()
- .collect { (autoBest, telega) ->
+ applyNetworkProxyRule("startup")
+
+ launch {
+ appPreferences.proxyNetworkRules.collect {
+ applyNetworkProxyRule("rules_changed")
+ }
+ }
+
+ launch {
+ appPreferences.proxyUnavailableFallback.collect {
+ applyNetworkProxyRule("fallback_changed")
+ }
+ }
+
+ launch {
+ appPreferences.isAutoBestProxyEnabled.collect { autoBest ->
autoBestJob?.cancel()
- telegaSwitchJob?.cancel()
- if (telega) {
- telegaSwitchJob = launchTelegaSwitchLoop()
- } else if (autoBest) {
+ if (autoBest) {
autoBestJob = launchAutoBestLoop()
}
}
+ }
}
}
private fun launchAutoBestLoop(): Job = scope.launch(dispatchers.default) {
while (isActive) {
- coRunCatching { selectBestProxy(telegaOnly = false) }
- .onFailure { Log.e(TAG, "Error selecting best proxy", it) }
+ coRunCatching { applyNetworkProxyRule("auto_best_loop") }
+ .onFailure { Log.e(tag, "Error applying proxy rule in auto loop", it) }
delay(300_000L)
}
}
- private fun launchTelegaSwitchLoop(): Job = scope.launch(dispatchers.default) {
- while (isActive) {
- coRunCatching { selectBestProxy(telegaOnly = true) }
- .onFailure { Log.e(TAG, "Error selecting telega proxy", it) }
- delay(60_000L)
+ private suspend fun applyNetworkProxyRule(reason: String) {
+ proxyRuleMutex.withLock {
+ val networkType = getCurrentNetworkType()
+ val rule = appPreferences.proxyNetworkRules.value[networkType]
+ ?: ProxyNetworkRule(defaultProxyNetworkMode(networkType))
+
+ when (rule.mode) {
+ ProxyNetworkMode.DIRECT -> {
+ disableProxyIfNeeded("$reason:direct")
+ }
+
+ ProxyNetworkMode.BEST_PROXY -> {
+ selectBestProxy(networkType, "$reason:best")
+ }
+
+ ProxyNetworkMode.LAST_USED -> {
+ val target = rule.lastUsedProxyId
+ if (target != null && enableProxy(
+ target,
+ networkType,
+ "$reason:last_used"
+ )
+ ) return
+ handleUnavailableFallback(networkType, "$reason:last_used")
+ }
+
+ ProxyNetworkMode.SPECIFIC_PROXY -> {
+ val target = rule.specificProxyId
+ if (target != null && enableProxy(
+ target,
+ networkType,
+ "$reason:specific"
+ )
+ ) return
+ handleUnavailableFallback(networkType, "$reason:specific")
+ }
+ }
}
}
- private suspend fun selectBestProxy(telegaOnly: Boolean = false) {
- val allProxies = proxyRemoteSource.getProxies()
- val proxies = if (telegaOnly) {
- val telegaIds = getTelegaIdentifiers()
- allProxies.filter { "${it.server}:${it.port}" in telegaIds }
- } else {
- allProxies
+ private suspend fun handleUnavailableFallback(networkType: ProxyNetworkType, reason: String) {
+ when (appPreferences.proxyUnavailableFallback.value) {
+ ProxyUnavailableFallback.BEST_PROXY -> selectBestProxy(
+ networkType,
+ "$reason:fallback_best"
+ )
+
+ ProxyUnavailableFallback.DIRECT -> disableProxyIfNeeded("$reason:fallback_direct")
+ ProxyUnavailableFallback.KEEP_CURRENT -> Unit
}
+ }
- if (proxies.isEmpty()) return
+ private suspend fun selectBestProxy(networkType: ProxyNetworkType, reason: String): Boolean {
+ val proxies = proxyRemoteSource.getProxies()
+ if (proxies.isEmpty()) {
+ disableProxyIfNeeded("$reason:no_proxies")
+ return false
+ }
val best = coroutineScope {
proxies.map { proxy ->
@@ -267,40 +328,70 @@ class ConnectionManager(
proxy to ping
}
}.awaitAll()
- }.minByOrNull { it.second } ?: return
+ }.minByOrNull { it.second } ?: return false
if (best.second == Long.MAX_VALUE) {
- Log.w(TAG, "All candidate proxies are unreachable, switching to direct connection")
- coRunCatching {
- proxyRemoteSource.disableProxy()
- appPreferences.setEnabledProxyId(null)
- }.onFailure { Log.e(TAG, "Failed to switch to direct connection", it) }
- return
+ Log.w(tag, "All proxies are unreachable, switching to direct connection")
+ disableProxyIfNeeded("$reason:all_unreachable")
+ return false
}
val currentEnabled = proxies.find { it.isEnabled }
if (best.first.id != currentEnabled?.id) {
- Log.d(TAG, "Switching to better proxy: ${best.first.server}:${best.first.port} (ping: ${best.second}ms)")
- if (proxyRemoteSource.enableProxy(best.first.id)) {
- appPreferences.setEnabledProxyId(best.first.id)
- }
+ Log.d(
+ tag,
+ "Switching to best proxy ${best.first.server}:${best.first.port} (${best.second}ms) ($reason)"
+ )
+ return enableProxy(best.first.id, networkType, "$reason:switch")
}
+
+ appPreferences.setLastUsedProxyIdForNetwork(networkType, best.first.id)
+ return true
}
- private fun getTelegaIdentifiers(): Set {
- return appPreferences.telegaProxyUrls.value.mapNotNull { parseTelegaIdentifier(it) }.toSet()
+ private suspend fun enableProxy(
+ proxyId: Int,
+ networkType: ProxyNetworkType,
+ reason: String
+ ): Boolean {
+ if (appPreferences.enabledProxyId.value == proxyId) {
+ appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId)
+ return true
+ }
+
+ val enabled = coRunCatching {
+ withContext(dispatchers.io) {
+ proxyRemoteSource.enableProxy(proxyId)
+ }
+ }.getOrDefault(false)
+
+ if (enabled) {
+ appPreferences.setEnabledProxyId(proxyId)
+ appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId)
+ } else {
+ Log.w(tag, "Failed to enable proxy $proxyId ($reason)")
+ }
+
+ return enabled
}
- private fun parseTelegaIdentifier(url: String): String? {
- val normalized = url.replace("t.me/proxy", "tg://proxy")
- val uri = runCatching { Uri.parse(normalized) }.getOrNull()
- val server = uri?.getQueryParameter("server")
- val port = uri?.getQueryParameter("port") ?: "443"
- if (!server.isNullOrBlank()) return "$server:$port"
+ private suspend fun disableProxyIfNeeded(reason: String): Boolean {
+ if (appPreferences.enabledProxyId.value == null) return true
+
+ val disabled = coRunCatching {
+ withContext(dispatchers.io) {
+ proxyRemoteSource.disableProxy()
+ }
+ true
+ }.getOrDefault(false)
+
+ if (disabled) {
+ appPreferences.setEnabledProxyId(null)
+ } else {
+ Log.w(tag, "Failed to disable proxy ($reason)")
+ }
- val serverMatch = Regex("server=([^&]+)").find(url)?.groupValues?.get(1)
- val regexPort = Regex("port=([^&]+)").find(url)?.groupValues?.get(1) ?: "443"
- return serverMatch?.let { "$it:$regexPort" }
+ return disabled
}
private fun startWatchdog() {
@@ -344,7 +435,7 @@ class ConnectionManager(
}
true
}.getOrElse {
- Log.w(TAG, "Failed to register network callback", it)
+ Log.w(tag, "Failed to register network callback", it)
false
}
@@ -355,16 +446,39 @@ class ConnectionManager(
private fun onNetworkChanged(reason: String) {
scope.launch(dispatchers.default) {
+ applyNetworkProxyRule("network_$reason")
runReconnectAttempt("network_$reason", force = true)
syncConnectionStateFromTdlib("network_$reason")
}
}
+ private fun getCurrentNetworkType(): ProxyNetworkType {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val active = connectivityManager.activeNetwork ?: return ProxyNetworkType.OTHER
+ val capabilities =
+ connectivityManager.getNetworkCapabilities(active) ?: return ProxyNetworkType.OTHER
+ when {
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> ProxyNetworkType.VPN
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> ProxyNetworkType.WIFI
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> ProxyNetworkType.MOBILE
+ else -> ProxyNetworkType.OTHER
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ when (connectivityManager.activeNetworkInfo?.type) {
+ ConnectivityManager.TYPE_VPN -> ProxyNetworkType.VPN
+ ConnectivityManager.TYPE_WIFI -> ProxyNetworkType.WIFI
+ ConnectivityManager.TYPE_MOBILE -> ProxyNetworkType.MOBILE
+ else -> ProxyNetworkType.OTHER
+ }
+ }
+ }
+
private fun hasActiveNetwork(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val active = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(active) ?: return false
- capabilities.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
connectivityManager.activeNetworkInfo?.isConnected == true
diff --git a/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt
new file mode 100644
index 00000000..d90dfe72
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt
@@ -0,0 +1,64 @@
+package org.monogram.data.infra
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.monogram.data.BuildConfig
+import org.monogram.data.repository.ChatsListRepositoryImpl
+
+class DataMemoryPressureHandler(
+ private val chatsListRepository: ChatsListRepositoryImpl,
+ private val fileUpdateHandler: FileUpdateHandler
+) {
+ fun clearDataCaches(reason: String) {
+ chatsListRepository.clearMemoryCaches()
+ fileUpdateHandler.clearMemoryCaches()
+ if (BuildConfig.DEBUG) {
+ logSnapshot("after_clear:$reason")
+ }
+ }
+
+ fun logSnapshot(reason: String) {
+ if (!BuildConfig.DEBUG) return
+ val runtime = Runtime.getRuntime()
+ val usedMb = (runtime.totalMemory() - runtime.freeMemory()) / MB
+ val maxMb = runtime.maxMemory() / MB
+ val chatSnapshot = chatsListRepository.memoryCacheSnapshot()
+ val fileSnapshot = fileUpdateHandler.memoryCacheSnapshot()
+ Log.d(
+ TAG,
+ "reason=$reason heap=${usedMb}MB/${maxMb}MB " +
+ "chatModelCache=${chatSnapshot.modelCacheSize} " +
+ "invalidatedModels=${chatSnapshot.invalidatedModelsSize} " +
+ "customEmojiPaths=${fileSnapshot.customEmojiPathsSize} " +
+ "fileToEmoji=${fileSnapshot.fileToEmojiSize}"
+ )
+ }
+
+ companion object {
+ private const val TAG = "DataMemoryPressure"
+ private const val MB = 1024L * 1024L
+ }
+}
+
+class DataMemoryDiagnostics(
+ scope: CoroutineScope,
+ private val memoryPressureHandler: DataMemoryPressureHandler
+) {
+ init {
+ if (BuildConfig.DEBUG) {
+ scope.launch {
+ while (isActive) {
+ delay(LOG_INTERVAL_MS)
+ memoryPressureHandler.logSnapshot("periodic")
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val LOG_INTERVAL_MS = 60_000L
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt b/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt
deleted file mode 100644
index ae4b3fd8..00000000
--- a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.monogram.data.infra
-
-import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-
-class DefaultScopeProvider(
- dispatcherProvider: DispatcherProvider
-) : ScopeProvider {
- override val appScope: CoroutineScope = CoroutineScope(
- SupervisorJob() + dispatcherProvider.default
- )
-}
\ No newline at end of file
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 cbec4832..5fba84e2 100644
--- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt
+++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt
@@ -1,13 +1,19 @@
package org.monogram.data.infra
import android.util.Log
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
import org.monogram.data.core.coRunCatching
import org.monogram.data.gateway.TdLibException
@@ -20,7 +26,7 @@ class FileDownloadQueue(
private val gateway: TelegramGateway,
val registry: FileMessageRegistry,
private val cache: ChatCache,
- private val scope: ScopeProvider,
+ private val scope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider
) {
enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT }
@@ -84,7 +90,7 @@ class FileDownloadQueue(
private val trigger = Channel(Channel.CONFLATED)
init {
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
while (isActive) {
trigger.receive()
coRunCatching { dispatchTasks() }
@@ -92,7 +98,7 @@ class FileDownloadQueue(
}
}
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
while (isActive) {
delay(TimeUnit.MINUTES.toMillis(1))
coRunCatching { retryFailedDownloads() }
@@ -100,7 +106,7 @@ class FileDownloadQueue(
}
}
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
while (isActive) {
delay(15_000)
coRunCatching { recoverStalledDownloads() }
@@ -108,7 +114,7 @@ class FileDownloadQueue(
}
}
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
while (isActive) {
delay(TimeUnit.MINUTES.toMillis(5))
coRunCatching { cleanupDeadState() }
@@ -165,7 +171,7 @@ class FileDownloadQueue(
for (task in tasksToStart) {
throttleTaskStart()
- scope.appScope.launch(dispatcherProvider.io) {
+ scope.launch(dispatcherProvider.io) {
processDownload(task)
}
}
@@ -279,7 +285,7 @@ class FileDownloadQueue(
failedRequests.remove(req.fileId)
}
trigger.trySend(Unit)
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
delay(backoffMs)
trigger.trySend(Unit)
}
@@ -304,7 +310,7 @@ class FileDownloadQueue(
failedRequests.remove(req.fileId)
}
trigger.trySend(Unit)
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
delay(cooldownMs)
trigger.trySend(Unit)
}
@@ -375,7 +381,7 @@ class FileDownloadQueue(
if (now - recoveredAt < stalledRecoveryCooldownMs) return@forEach
stalledRecoveryAt[req.fileId] = now
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
val recovered = stateMutex.withLock {
val active = activeRequests[req.fileId] ?: return@withLock false
if (active.createdAt != req.createdAt || active.availableAt != req.availableAt) return@withLock false
@@ -431,14 +437,14 @@ class FileDownloadQueue(
failedRequests.remove(file.id)
stalledRecoveryAt.remove(file.id)
lastProgressAt.remove(file.id)
- scope.appScope.launch {
+ scope.launch {
stateMutex.withLock { pendingRequests.remove(file.id) }
}
notifyDownloadComplete(file.id)
} else if (oldFile?.local?.isDownloadingActive == true && !file.local.isDownloadingActive) {
val type = fileDownloadTypes[file.id]
if (type == DownloadType.STICKER || manualDownloadIds.contains(file.id)) {
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
enqueue(
fileId = file.id,
priority = if (type == DownloadType.STICKER) 32 else calculatePriority(file.id),
@@ -457,6 +463,11 @@ class FileDownloadQueue(
fun isFileQueued(fileId: Int) = pendingRequests.containsKey(fileId) || activeRequests.containsKey(fileId)
+ fun getCachedFile(fileId: Int): TdApi.File? = cache.fileCache[fileId]
+
+ fun getCachedPath(fileId: Int): String? =
+ cache.fileCache[fileId]?.local?.path?.takeIf { it.isNotEmpty() }
+
fun setChatOpened(chatId: Long) {
openChatIds.add(chatId)
activeChatId = chatId
@@ -477,7 +488,7 @@ class FileDownloadQueue(
nearbyMessageIds[chatId] = nearby.toSet()
activeChatId = chatId
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
cancelIrrelevantDownloads()
(visible + nearby).forEach { messageId ->
registry.getFileIdsForMessage(chatId, messageId).forEach { fileId ->
@@ -498,7 +509,7 @@ class FileDownloadQueue(
synchronous: Boolean = false,
ignoreSuppression: Boolean = false
) {
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) {
return@launch
}
@@ -556,7 +567,7 @@ class FileDownloadQueue(
val shouldKick = merged != active || cache.fileCache[fileId]?.local?.isDownloadingActive != true
if (shouldKick) {
activeRequests[fileId] = merged
- scope.appScope.launch(dispatcherProvider.io) {
+ scope.launch(dispatcherProvider.io) {
try {
gateway.execute(
TdApi.DownloadFile(
@@ -600,7 +611,7 @@ class FileDownloadQueue(
suppressedAutoDownloadIds.add(fileId)
}
- scope.appScope.launch(dispatcherProvider.io) {
+ scope.launch(dispatcherProvider.io) {
try {
gateway.execute(TdApi.CancelDownloadFile(fileId, false))
} catch (_: Exception) {
@@ -709,7 +720,7 @@ class FileDownloadQueue(
}
private fun cancelIrrelevantDownloads() {
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
val toCancel = mutableListOf()
for ((fileId, _) in pendingRequests) {
@@ -721,7 +732,7 @@ class FileDownloadQueue(
}
private fun flushIrrelevantBackgroundDownloads() {
- scope.appScope.launch(dispatcherProvider.default) {
+ scope.launch(dispatcherProvider.default) {
val toCancel = mutableListOf()
stateMutex.withLock {
diff --git a/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt
new file mode 100644
index 00000000..d2990c49
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt
@@ -0,0 +1,92 @@
+package org.monogram.data.infra
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withTimeoutOrNull
+import org.drinkless.tdlib.TdApi
+
+class FileObserverHub(
+ private val queue: FileDownloadQueue,
+ private val fileUpdateHandler: FileUpdateHandler
+) {
+
+ data class FileState(
+ val fileId: Int,
+ val path: String?,
+ val isDownloading: Boolean,
+ val isDownloaded: Boolean,
+ val downloadProgress: Float,
+ val isUploading: Boolean,
+ val isUploaded: Boolean,
+ val uploadProgress: Float
+ )
+
+ val fileStates: Flow = fileUpdateHandler.fileUpdates
+ .map { it.toState() }
+ .distinctUntilChanged()
+
+ fun observeFile(fileId: Int): Flow = flow {
+ getCachedFile(fileId)?.let { emit(it.toState()) }
+ fileStates
+ .filter { it.fileId == fileId }
+ .collect { emit(it) }
+ }.distinctUntilChanged()
+
+ fun observeFiles(fileIds: Set): Flow {
+ if (fileIds.isEmpty()) return flow { }
+ return flow {
+ fileIds.forEach { fileId ->
+ getCachedFile(fileId)?.let { emit(it.toState()) }
+ }
+ fileStates
+ .filter { it.fileId in fileIds }
+ .collect { emit(it) }
+ }.distinctUntilChanged()
+ }
+
+ fun getCachedFile(fileId: Int): TdApi.File? = queue.getCachedFile(fileId)
+
+ fun getCachedPath(fileId: Int): String? = queue.getCachedPath(fileId)
+
+ suspend fun awaitDownload(fileId: Int, timeoutMs: Long? = null): Boolean {
+ if (queue.getCachedFile(fileId)?.local?.isDownloadingCompleted == true) {
+ return true
+ }
+ return if (timeoutMs == null) {
+ runCatching {
+ queue.waitForDownload(fileId).await()
+ true
+ }.getOrDefault(false)
+ } else {
+ withTimeoutOrNull(timeoutMs) {
+ runCatching {
+ queue.waitForDownload(fileId).await()
+ true
+ }.getOrDefault(false)
+ } ?: false
+ }
+ }
+
+ private fun TdApi.File.toState(): FileState {
+ val localFile = local
+ val remoteFile = remote
+ val downloadProgress =
+ if (size > 0) localFile.downloadedSize.toFloat() / size.toFloat() else 0f
+ val uploadProgress =
+ if (size > 0) (remoteFile?.uploadedSize ?: 0L).toFloat() / size.toFloat() else 0f
+ val path = localFile.path.takeIf { it.isNotEmpty() }
+ return FileState(
+ fileId = id,
+ path = path,
+ isDownloading = localFile.isDownloadingActive,
+ isDownloaded = localFile.isDownloadingCompleted,
+ downloadProgress = downloadProgress,
+ isUploading = remoteFile?.isUploadingActive == true,
+ isUploaded = remoteFile?.isUploadingCompleted == true,
+ uploadProgress = uploadProgress
+ )
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt
index 9002a3e9..8b9e5407 100644
--- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt
+++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt
@@ -1,22 +1,21 @@
package org.monogram.data.infra
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
-import org.monogram.core.ScopeProvider
import org.monogram.data.gateway.UpdateDispatcher
-import java.util.concurrent.ConcurrentHashMap
class FileUpdateHandler(
private val registry: FileMessageRegistry,
private val queue: FileDownloadQueue,
private val updates: UpdateDispatcher,
- private val scope: ScopeProvider
+ private val scope: CoroutineScope
) {
- val customEmojiPaths = ConcurrentHashMap()
- val fileIdToCustomEmojiId = ConcurrentHashMap()
+ val customEmojiPaths = SynchronizedLruMap(CUSTOM_EMOJI_CACHE_SIZE)
+ val fileIdToCustomEmojiId = SynchronizedLruMap(FILE_TO_EMOJI_CACHE_SIZE)
private val _downloadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val _downloadCompleted = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -25,21 +24,27 @@ class FileUpdateHandler(
private val _fileDownloadCompleted =
MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val _uploadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+ private val _fileUpdates = MutableSharedFlow(
+ extraBufferCapacity = 256,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
val downloadProgress = _downloadProgress.asSharedFlow()
val downloadCompleted = _downloadCompleted.asSharedFlow()
val fileDownloadProgress = _fileDownloadProgress.asSharedFlow()
val fileDownloadCompleted = _fileDownloadCompleted.asSharedFlow()
val uploadProgress = _uploadProgress.asSharedFlow()
+ val fileUpdates = _fileUpdates.asSharedFlow()
init {
- scope.appScope.launch {
+ scope.launch {
updates.file.collect { update -> handle(update.file) }
}
}
private fun handle(file: TdApi.File) {
queue.updateFileCache(file)
+ _fileUpdates.tryEmit(file)
val downloading = file.local?.isDownloadingActive == true
val downloadDone = file.local?.isDownloadingCompleted == true
@@ -52,7 +57,7 @@ class FileUpdateHandler(
val entries = registry.getMessages(file.id)
if (entries.isNotEmpty()) {
- scope.appScope.launch {
+ scope.launch {
if (downloadDone) {
handleCustomEmoji(file.id, file.local?.path ?: "")
_fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: ""))
@@ -74,7 +79,7 @@ class FileUpdateHandler(
}
}
} else if (registry.standaloneFileIds.contains(file.id)) {
- scope.appScope.launch {
+ scope.launch {
if (downloadDone) {
_fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: ""))
_fileDownloadProgress.emit(file.id.toLong() to 1f)
@@ -88,7 +93,7 @@ class FileUpdateHandler(
}
}
} else {
- scope.appScope.launch {
+ scope.launch {
if (downloadDone) {
_fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: ""))
_fileDownloadProgress.emit(file.id.toLong() to 1f)
@@ -104,4 +109,26 @@ class FileUpdateHandler(
val emojiId = fileIdToCustomEmojiId[fileId] ?: return
customEmojiPaths[emojiId] = path
}
-}
\ No newline at end of file
+
+ fun clearMemoryCaches() {
+ customEmojiPaths.clear()
+ fileIdToCustomEmojiId.clear()
+ }
+
+ fun memoryCacheSnapshot(): MemoryCacheSnapshot {
+ return MemoryCacheSnapshot(
+ customEmojiPathsSize = customEmojiPaths.size(),
+ fileToEmojiSize = fileIdToCustomEmojiId.size()
+ )
+ }
+
+ data class MemoryCacheSnapshot(
+ val customEmojiPathsSize: Int,
+ val fileToEmojiSize: Int
+ )
+
+ companion object {
+ private const val CUSTOM_EMOJI_CACHE_SIZE = 512
+ private const val FILE_TO_EMOJI_CACHE_SIZE = 512
+ }
+}
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 5c5e2827..0830bb81 100644
--- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt
+++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt
@@ -1,11 +1,11 @@
package org.monogram.data.infra
import android.util.Log
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
import org.monogram.data.core.coRunCatching
import org.monogram.data.db.dao.*
@@ -20,7 +20,7 @@ import org.monogram.domain.repository.StickerRepository
private const val TAG = "OfflineWarmup"
class OfflineWarmup(
- private val scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val dispatchers: DispatcherProvider,
private val gateway: TelegramGateway,
private val chatDao: ChatDao,
@@ -32,8 +32,6 @@ class OfflineWarmup(
private val chatCache: ChatCache,
private val stickerRepository: StickerRepository
) {
- private val scope = scopeProvider.appScope
-
@Volatile
private var warmupStarted = false
@@ -262,6 +260,8 @@ class OfflineWarmup(
else -> 0L
}
+ val botType = type as? TdApi.UserTypeBot
+
return UserEntity(
id = id,
firstName = firstName,
@@ -271,18 +271,44 @@ class OfflineWarmup(
personalAvatarPath = personalAvatarPath,
isPremium = isPremium,
isVerified = verificationStatus?.isVerified ?: false,
+ isScam = verificationStatus?.isScam ?: false,
+ isFake = verificationStatus?.isFake ?: false,
+ botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L,
isSupport = isSupport,
isContact = isContact,
isMutualContact = isMutualContact,
isCloseFriend = isCloseFriend,
+ botTypeCanBeEdited = botType?.canBeEdited ?: false,
+ botTypeCanJoinGroups = botType?.canJoinGroups ?: false,
+ botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false,
+ botTypeHasMainWebApp = botType?.hasMainWebApp ?: false,
+ botTypeHasTopics = botType?.hasTopics ?: false,
+ botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false,
+ botTypeCanManageBots = botType?.canManageBots ?: false,
+ botTypeIsInline = botType?.isInline ?: false,
+ botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null },
+ botTypeNeedLocation = botType?.needLocation ?: false,
+ botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false,
+ botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false,
+ botTypeActiveUserCount = botType?.activeUserCount ?: 0,
+ userType = type.toTypeString(),
+ restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null },
+ hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false,
+ activeStoryStateType = activeStoryState.toTypeString(),
+ activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0,
+ restrictsNewChats = restrictsNewChats,
+ paidMessageStarCount = paidMessageStarCount,
haveAccess = haveAccess,
username = usernames?.activeUsernames?.firstOrNull(),
usernamesData = usernamesData,
statusType = statusType,
accentColorId = accentColorId,
+ backgroundCustomEmojiId = backgroundCustomEmojiId,
profileAccentColorId = profileAccentColorId,
+ profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId,
statusEmojiId = statusEmojiId,
languageCode = languageCode.ifEmpty { null },
+ addedToAttachmentMenu = addedToAttachmentMenu,
lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L,
createdAt = System.currentTimeMillis()
)
@@ -295,9 +321,27 @@ class OfflineWarmup(
?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null }
}
+ private fun TdApi.ActiveStoryState?.toTypeString(): String? {
+ return when (this) {
+ is TdApi.ActiveStoryStateLive -> "LIVE"
+ is TdApi.ActiveStoryStateUnread -> "UNREAD"
+ is TdApi.ActiveStoryStateRead -> "READ"
+ else -> null
+ }
+ }
+
+ private fun TdApi.UserType?.toTypeString(): String {
+ return when (this) {
+ is TdApi.UserTypeRegular -> "REGULAR"
+ is TdApi.UserTypeBot -> "BOT"
+ is TdApi.UserTypeDeleted -> "DELETED"
+ else -> "UNKNOWN"
+ }
+ }
+
private companion object {
- private const val USER_WARMUP_LIMIT = 30
- private const val USER_WARMUP_DELAY_MS = 75L
+ private const val USER_WARMUP_LIMIT = 15
+ private const val USER_WARMUP_DELAY_MS = 150L
private const val ONE_DAY_MS = 24L * 60 * 60 * 1000
private const val SEVEN_DAYS_MS = 7L * ONE_DAY_MS
}
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 d6f20302..c7c96cb8 100644
--- a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt
+++ b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt
@@ -1,12 +1,12 @@
package org.monogram.data.infra
-import org.monogram.data.core.coRunCatching
import android.util.Log
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
-import org.monogram.core.ScopeProvider
+import org.monogram.data.core.coRunCatching
import org.monogram.data.db.dao.SponsorDao
import org.monogram.data.db.model.SponsorEntity
import org.monogram.data.gateway.TelegramGateway
@@ -26,7 +26,7 @@ private const val POST_LOGIN_SYNC_DELAY_MS = 60L * 1000L
private const val ONE_DAY_MS = 24L * 60L * 60L * 1000L
class SponsorSyncManager(
- private val scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val gateway: TelegramGateway,
private val sponsorDao: SponsorDao,
private val authRepository: AuthRepository
@@ -41,7 +41,7 @@ class SponsorSyncManager(
fun start() {
if (!started.compareAndSet(false, true)) return
- scopeProvider.appScope.launch(Dispatchers.IO) {
+ scope.launch(Dispatchers.IO) {
loadFromDatabase()
var wasAuthorized = authRepository.authState.value is AuthStep.Ready
@@ -78,7 +78,7 @@ class SponsorSyncManager(
}
fun forceSync() {
- scopeProvider.appScope.launch(Dispatchers.IO) {
+ scope.launch(Dispatchers.IO) {
syncOnce(force = true)
}
}
diff --git a/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt
new file mode 100644
index 00000000..d9d5210c
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt
@@ -0,0 +1,40 @@
+package org.monogram.data.infra
+
+class SynchronizedLruMap(
+ private val maxSize: Int
+) {
+ init {
+ require(maxSize > 0) { "maxSize must be > 0" }
+ }
+
+ private val lock = Any()
+ private val map = object : LinkedHashMap(maxSize, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > maxSize
+ }
+ }
+
+ operator fun get(key: K): V? {
+ return synchronized(lock) { map[key] }
+ }
+
+ operator fun set(key: K, value: V) {
+ synchronized(lock) {
+ map[key] = value
+ }
+ }
+
+ fun containsKey(key: K): Boolean {
+ return synchronized(lock) { map.containsKey(key) }
+ }
+
+ fun clear() {
+ synchronized(lock) {
+ map.clear()
+ }
+ }
+
+ fun size(): Int {
+ return synchronized(lock) { map.size }
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt
index 66cbe9d3..c0235ffe 100644
--- a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt
@@ -4,19 +4,17 @@ import org.drinkless.tdlib.TdApi
import org.monogram.data.db.model.ChatEntity
fun TdApi.Chat.toEntity(): ChatEntity {
- val isChannel = (type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false
+ val isChannel = type.isChannelType()
val isArchived = positions.any { it.list is TdApi.ChatListArchive }
- val permissions = permissions ?: TdApi.ChatPermissions()
val cachedCounts = parseCachedCounts(clientData)
+ val typeIds = type.extractTypeIds()
+ val chatPermissions = permissions.toDomainChatPermissions()
val senderId = when (val sender = messageSenderId) {
is TdApi.MessageSenderUser -> sender.userId
is TdApi.MessageSenderChat -> sender.chatId
else -> null
}
- val privateUserId = (type as? TdApi.ChatTypePrivate)?.userId ?: 0L
- val basicGroupId = (type as? TdApi.ChatTypeBasicGroup)?.basicGroupId ?: 0L
- val supergroupId = (type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: 0L
- val secretChatId = (type as? TdApi.ChatTypeSecret)?.secretChatId ?: 0
+
return ChatEntity(
id = id,
title = title,
@@ -29,19 +27,13 @@ fun TdApi.Chat.toEntity(): ChatEntity {
isPinned = positions.firstOrNull()?.isPinned ?: false,
isMuted = notificationSettings.muteFor > 0,
isChannel = isChannel,
- isGroup = type is TdApi.ChatTypeBasicGroup || (type is TdApi.ChatTypeSupergroup && !isChannel),
- type = when (type) {
- is TdApi.ChatTypePrivate -> "PRIVATE"
- is TdApi.ChatTypeBasicGroup -> "BASIC_GROUP"
- is TdApi.ChatTypeSupergroup -> "SUPERGROUP"
- is TdApi.ChatTypeSecret -> "SECRET"
- else -> "PRIVATE"
- },
- privateUserId = privateUserId,
- basicGroupId = basicGroupId,
- supergroupId = supergroupId,
- secretChatId = secretChatId,
- positionsCache = encodePositions(positions),
+ isGroup = type.isGroupType(),
+ type = type.toEntityChatType(),
+ privateUserId = typeIds.privateUserId,
+ basicGroupId = typeIds.basicGroupId,
+ supergroupId = typeIds.supergroupId,
+ secretChatId = typeIds.secretChatId,
+ positionsCache = encodeChatPositions(positions),
isArchived = isArchived,
memberCount = cachedCounts.first,
onlineCount = cachedCounts.second,
@@ -80,38 +72,8 @@ fun TdApi.Chat.toEntity(): ChatEntity {
username = null,
description = null,
inviteLink = null,
- permissionCanSendBasicMessages = permissions.canSendBasicMessages,
- permissionCanSendAudios = permissions.canSendAudios,
- permissionCanSendDocuments = permissions.canSendDocuments,
- permissionCanSendPhotos = permissions.canSendPhotos,
- permissionCanSendVideos = permissions.canSendVideos,
- permissionCanSendVideoNotes = permissions.canSendVideoNotes,
- permissionCanSendVoiceNotes = permissions.canSendVoiceNotes,
- permissionCanSendPolls = permissions.canSendPolls,
- permissionCanSendOtherMessages = permissions.canSendOtherMessages,
- permissionCanAddLinkPreviews = permissions.canAddLinkPreviews,
- permissionCanEditTag = permissions.canEditTag,
- permissionCanChangeInfo = permissions.canChangeInfo,
- permissionCanInviteUsers = permissions.canInviteUsers,
- permissionCanPinMessages = permissions.canPinMessages,
- permissionCanCreateTopics = permissions.canCreateTopics,
createdAt = System.currentTimeMillis()
- )
-}
-
-private fun encodePositions(positions: Array): String? {
- if (positions.isEmpty()) return null
- val encoded = positions.mapNotNull { pos ->
- if (pos.order == 0L) return@mapNotNull null
- val pinned = if (pos.isPinned) 1 else 0
- when (val list = pos.list) {
- is TdApi.ChatListMain -> "m:${pos.order}:$pinned"
- is TdApi.ChatListArchive -> "a:${pos.order}:$pinned"
- is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned"
- else -> null
- }
- }
- return if (encoded.isEmpty()) null else encoded.joinToString("|")
+ ).withPermissions(chatPermissions)
}
private fun parseCachedCounts(clientData: String?): Pair {
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 5d7dbe27..035d5afc 100644
--- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt
@@ -1,14 +1,22 @@
package org.monogram.data.mapper
-import android.text.format.DateUtils
import org.drinkless.tdlib.TdApi
+import org.monogram.core.date.DateFormatManager
import org.monogram.data.db.model.ChatEntity
-import org.monogram.domain.models.*
+import org.monogram.domain.models.ChatModel
+import org.monogram.domain.models.ChatType
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageEntityType
+import org.monogram.domain.models.UsernamesModel
import org.monogram.domain.repository.StringProvider
import java.text.SimpleDateFormat
-import java.util.*
+import java.util.Date
+import java.util.Locale
-class ChatMapper(private val stringProvider: StringProvider) {
+class ChatMapper(
+ private val stringProvider: StringProvider,
+ private val dateFormatManager: DateFormatManager
+) {
fun mapChatToModel(
chat: TdApi.Chat,
order: Long,
@@ -19,6 +27,17 @@ class ChatMapper(private val stringProvider: StringProvider) {
isOnline: Boolean,
userStatus: String,
isVerified: Boolean,
+ isScam: Boolean,
+ isFake: Boolean,
+ botVerificationIconCustomEmojiId: Long,
+ restrictionReason: String?,
+ hasSensitiveContent: Boolean,
+ activeStoryStateType: String?,
+ activeStoryId: Int,
+ boostLevel: Int,
+ hasForumTabs: Boolean,
+ isAdministeredDirectMessagesGroup: Boolean,
+ paidMessageStarCount: Long,
isSponsor: Boolean,
isForum: Boolean,
isBot: Boolean,
@@ -40,30 +59,14 @@ class ChatMapper(private val stringProvider: StringProvider) {
hasAutomaticTranslation: Boolean = false,
personalAvatarPath: String? = null
): ChatModel {
- val p = chat.permissions ?: TdApi.ChatPermissions()
- val permissions = ChatPermissionsModel(
- canSendBasicMessages = p.canSendBasicMessages,
- canSendAudios = p.canSendAudios,
- canSendDocuments = p.canSendDocuments,
- canSendPhotos = p.canSendPhotos,
- canSendVideos = p.canSendVideos,
- canSendVideoNotes = p.canSendVideoNotes,
- canSendVoiceNotes = p.canSendVoiceNotes,
- canSendPolls = p.canSendPolls,
- canSendOtherMessages = p.canSendOtherMessages,
- canAddLinkPreviews = p.canAddLinkPreviews,
- canEditTag = p.canEditTag,
- canChangeInfo = p.canChangeInfo,
- canInviteUsers = p.canInviteUsers,
- canPinMessages = p.canPinMessages,
- canCreateTopics = p.canCreateTopics,
- )
-
- val isChannel = (chat.type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false
+ val permissions = chat.permissions.toDomainChatPermissions()
+ val isChannel = chat.type.isChannelType()
val draft = chat.draftMessage?.inputMessageText as? TdApi.InputMessageText
val draftText = draft?.text?.text
- val draftEntities = draft?.text?.entities?.map { mapEntity(it) } ?: emptyList()
+ val draftEntities = draft?.text?.entities
+ ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) }
+ ?: emptyList()
return ChatModel(
id = chat.id,
@@ -78,7 +81,7 @@ class ChatMapper(private val stringProvider: StringProvider) {
lastMessageTime = lastMessageTime,
lastMessageDate = lastMessageDate,
order = order,
- isGroup = chat.type is TdApi.ChatTypeBasicGroup || (chat.type is TdApi.ChatTypeSupergroup && !isChannel),
+ isGroup = chat.type.isGroupType(),
isSupergroup = chat.type is TdApi.ChatTypeSupergroup,
isChannel = isChannel,
memberCount = memberCount,
@@ -118,6 +121,17 @@ class ChatMapper(private val stringProvider: StringProvider) {
},
blockList = chat.blockList != null,
isVerified = isVerified || isForcedVerifiedChat(chat.id),
+ isScam = isScam,
+ isFake = isFake,
+ botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId,
+ restrictionReason = restrictionReason,
+ hasSensitiveContent = hasSensitiveContent,
+ activeStoryStateType = activeStoryStateType,
+ activeStoryId = activeStoryId,
+ boostLevel = boostLevel,
+ hasForumTabs = hasForumTabs,
+ isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup,
+ paidMessageStarCount = paidMessageStarCount,
isSponsor = isSponsor,
viewAsTopics = chat.viewAsTopics,
isForum = isForum,
@@ -126,13 +140,7 @@ class ChatMapper(private val stringProvider: StringProvider) {
usernames = usernames,
description = description,
inviteLink = inviteLink,
- type = when (chat.type) {
- is TdApi.ChatTypePrivate -> ChatType.PRIVATE
- is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP
- is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP
- is TdApi.ChatTypeSecret -> ChatType.SECRET
- else -> ChatType.PRIVATE
- },
+ type = chat.type.toDomainChatType(),
permissions = permissions,
isMember = isMember
)
@@ -184,6 +192,17 @@ class ChatMapper(private val stringProvider: StringProvider) {
typingAction = entity.typingAction,
draftMessage = entity.draftMessage,
isVerified = entity.isVerified || isForcedVerifiedChat(entity.id),
+ isScam = entity.isScam,
+ isFake = entity.isFake,
+ botVerificationIconCustomEmojiId = entity.botVerificationIconCustomEmojiId,
+ restrictionReason = entity.restrictionReason,
+ hasSensitiveContent = entity.hasSensitiveContent,
+ activeStoryStateType = entity.activeStoryStateType,
+ activeStoryId = entity.activeStoryId,
+ boostLevel = entity.boostLevel,
+ hasForumTabs = entity.hasForumTabs,
+ isAdministeredDirectMessagesGroup = entity.isAdministeredDirectMessagesGroup,
+ paidMessageStarCount = entity.paidMessageStarCount,
isSponsor = entity.isSponsor || (entity.privateUserId != 0L && isSponsoredUser(entity.privateUserId)),
viewAsTopics = entity.viewAsTopics,
isForum = entity.isForum,
@@ -192,23 +211,7 @@ class ChatMapper(private val stringProvider: StringProvider) {
username = entity.username,
description = entity.description,
inviteLink = entity.inviteLink,
- permissions = ChatPermissionsModel(
- canSendBasicMessages = entity.permissionCanSendBasicMessages,
- canSendAudios = entity.permissionCanSendAudios,
- canSendDocuments = entity.permissionCanSendDocuments,
- canSendPhotos = entity.permissionCanSendPhotos,
- canSendVideos = entity.permissionCanSendVideos,
- canSendVideoNotes = entity.permissionCanSendVideoNotes,
- canSendVoiceNotes = entity.permissionCanSendVoiceNotes,
- canSendPolls = entity.permissionCanSendPolls,
- canSendOtherMessages = entity.permissionCanSendOtherMessages,
- canAddLinkPreviews = entity.permissionCanAddLinkPreviews,
- canEditTag = entity.permissionCanEditTag,
- canChangeInfo = entity.permissionCanChangeInfo,
- canInviteUsers = entity.permissionCanInviteUsers,
- canPinMessages = entity.permissionCanPinMessages,
- canCreateTopics = entity.permissionCanCreateTopics
- )
+ permissions = entity.toDomainChatPermissionsModel()
)
}
@@ -263,6 +266,17 @@ class ChatMapper(private val stringProvider: StringProvider) {
typingAction = domain.typingAction,
draftMessage = domain.draftMessage,
isVerified = domain.isVerified || isForcedVerifiedChat(domain.id),
+ isScam = domain.isScam,
+ isFake = domain.isFake,
+ botVerificationIconCustomEmojiId = domain.botVerificationIconCustomEmojiId,
+ restrictionReason = domain.restrictionReason,
+ hasSensitiveContent = domain.hasSensitiveContent,
+ activeStoryStateType = domain.activeStoryStateType,
+ activeStoryId = domain.activeStoryId,
+ boostLevel = domain.boostLevel,
+ hasForumTabs = domain.hasForumTabs,
+ isAdministeredDirectMessagesGroup = domain.isAdministeredDirectMessagesGroup,
+ paidMessageStarCount = domain.paidMessageStarCount,
isSponsor = domain.isSponsor,
viewAsTopics = domain.viewAsTopics,
isForum = domain.isForum,
@@ -271,65 +285,15 @@ class ChatMapper(private val stringProvider: StringProvider) {
username = domain.username,
description = domain.description,
inviteLink = domain.inviteLink,
- permissionCanSendBasicMessages = domain.permissions.canSendBasicMessages,
- permissionCanSendAudios = domain.permissions.canSendAudios,
- permissionCanSendDocuments = domain.permissions.canSendDocuments,
- permissionCanSendPhotos = domain.permissions.canSendPhotos,
- permissionCanSendVideos = domain.permissions.canSendVideos,
- permissionCanSendVideoNotes = domain.permissions.canSendVideoNotes,
- permissionCanSendVoiceNotes = domain.permissions.canSendVoiceNotes,
- permissionCanSendPolls = domain.permissions.canSendPolls,
- permissionCanSendOtherMessages = domain.permissions.canSendOtherMessages,
- permissionCanAddLinkPreviews = domain.permissions.canAddLinkPreviews,
- permissionCanEditTag = domain.permissions.canEditTag,
- permissionCanChangeInfo = domain.permissions.canChangeInfo,
- permissionCanInviteUsers = domain.permissions.canInviteUsers,
- permissionCanPinMessages = domain.permissions.canPinMessages,
- permissionCanCreateTopics = domain.permissions.canCreateTopics,
lastMessageContentType = "text",
lastMessageSenderName = "",
createdAt = System.currentTimeMillis()
- )
+ ).withPermissions(domain.permissions)
}
fun mapToEntity(chat: TdApi.Chat, domain: ChatModel): ChatEntity {
- val privateUserId: Long
- val basicGroupId: Long
- val supergroupId: Long
- val secretChatId: Int
- when (val t = chat.type) {
- is TdApi.ChatTypePrivate -> {
- privateUserId = t.userId
- basicGroupId = 0L
- supergroupId = 0L
- secretChatId = 0
- }
- is TdApi.ChatTypeBasicGroup -> {
- privateUserId = 0L
- basicGroupId = t.basicGroupId
- supergroupId = 0L
- secretChatId = 0
- }
- is TdApi.ChatTypeSupergroup -> {
- privateUserId = 0L
- basicGroupId = 0L
- supergroupId = t.supergroupId
- secretChatId = 0
- }
- is TdApi.ChatTypeSecret -> {
- privateUserId = 0L
- basicGroupId = 0L
- supergroupId = 0L
- secretChatId = t.secretChatId
- }
- else -> {
- privateUserId = 0L
- basicGroupId = 0L
- supergroupId = 0L
- secretChatId = 0
- }
- }
- val encodedPositions = encodePositions(chat.positions)
+ val typeIds = chat.type.extractTypeIds()
+ val encodedPositions = encodeChatPositions(chat.positions)
val (lastMessageContentType, lastMessageSenderName) = chat.lastMessage?.let { message ->
val type = when (message.content) {
is TdApi.MessageText -> "text"
@@ -357,10 +321,10 @@ class ChatMapper(private val stringProvider: StringProvider) {
} ?: ("text" to "")
return mapToEntity(domain).copy(
- privateUserId = privateUserId,
- basicGroupId = basicGroupId,
- supergroupId = supergroupId,
- secretChatId = secretChatId,
+ privateUserId = typeIds.privateUserId,
+ basicGroupId = typeIds.basicGroupId,
+ supergroupId = typeIds.supergroupId,
+ secretChatId = typeIds.secretChatId,
positionsCache = encodedPositions,
lastMessageDate = chat.lastMessage?.date ?: domain.lastMessageDate,
lastMessageContentType = lastMessageContentType,
@@ -368,23 +332,6 @@ class ChatMapper(private val stringProvider: StringProvider) {
)
}
- private fun encodePositions(positions: Array): String? {
- if (positions.isEmpty()) return null
-
- val encoded = positions.mapNotNull { pos ->
- if (pos.order == 0L) return@mapNotNull null
- val pinned = if (pos.isPinned) 1 else 0
- when (val list = pos.list) {
- is TdApi.ChatListMain -> "m:${pos.order}:$pinned"
- is TdApi.ChatListArchive -> "a:${pos.order}:$pinned"
- is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned"
- else -> null
- }
- }
-
- return if (encoded.isEmpty()) null else encoded.joinToString("|")
- }
-
fun formatMessageInfo(
lastMsg: TdApi.Message?,
chat: TdApi.Chat?,
@@ -396,7 +343,9 @@ class ChatMapper(private val stringProvider: StringProvider) {
fun captionOrFallback(caption: TdApi.FormattedText?, emojiPrefix: String, fallbackKey: String): String {
val text = caption?.text?.trim().orEmpty()
if (text.isNotEmpty()) {
- entities = caption?.entities?.map { mapEntity(it) } ?: emptyList()
+ entities = caption?.entities
+ ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) }
+ ?: emptyList()
return "$emojiPrefix$text"
}
return stringProvider.getString(fallbackKey)
@@ -404,7 +353,8 @@ class ChatMapper(private val stringProvider: StringProvider) {
var txt = when (val c = lastMsg.content) {
is TdApi.MessageText -> {
- entities = c.text.entities.map { mapEntity(it) }
+ entities = c.text.entities
+ .mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) }
c.text.text
}
is TdApi.MessagePhoto -> captionOrFallback(c.caption, "📷 ", "chat_mapper_photo")
@@ -490,7 +440,8 @@ class ChatMapper(private val stringProvider: StringProvider) {
}
val date = lastMsg.date
- val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+ val timeFormat =
+ SimpleDateFormat(dateFormatManager.getHourMinuteFormat(), Locale.getDefault())
val time = if (date > 0) timeFormat.format(Date(date.toLong() * 1000)) else ""
return Triple(txt, entities, time)
}
@@ -510,79 +461,12 @@ class ChatMapper(private val stringProvider: StringProvider) {
return String(chars)
}
- private fun mapEntity(entity: TdApi.TextEntity): MessageEntity {
- return MessageEntity(
- offset = entity.offset,
- length = entity.length,
- type = when (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.TextEntityTypeTextUrl -> MessageEntityType.TextUrl((entity.type as TdApi.TextEntityTypeTextUrl).url)
- is TdApi.TextEntityTypeMention -> MessageEntityType.Mention
- is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention((entity.type as TdApi.TextEntityTypeMentionName).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((entity.type as TdApi.TextEntityTypeCustomEmoji).customEmojiId)
- is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote
- is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable
- else -> MessageEntityType.Other(entity.type.javaClass.simpleName)
- }
- )
- }
-
fun formatUserStatus(status: TdApi.UserStatus, isBot: Boolean = false): String {
- if (isBot) return stringProvider.getString("chat_mapper_bot")
- return when (status) {
- is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online")
- is TdApi.UserStatusOffline -> {
- val wasOnline = status.wasOnline.toLong() * 1000L
- if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline")
- val now = System.currentTimeMillis()
- val diff = now - wasOnline
- when {
- diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now")
- diff < 60 * 60 * 1000 -> {
- val minutes = diff / (60 * 1000L)
- if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1)
- else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes)
- }
- DateUtils.isToday(wasOnline) -> {
- val date = Date(wasOnline)
- val format = SimpleDateFormat("HH:mm", Locale.getDefault())
- stringProvider.getString("chat_mapper_seen_at", format.format(date))
- }
-
- isYesterday(wasOnline) -> {
- val date = Date(wasOnline)
- val format = SimpleDateFormat("HH:mm", Locale.getDefault())
- stringProvider.getString("chat_mapper_seen_yesterday", format.format(date))
- }
- else -> {
- val date = Date(wasOnline)
- val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault())
- stringProvider.getString("chat_mapper_seen_date", format.format(date))
- }
- }
- }
-
- is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently")
- is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week")
- is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month")
- is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline")
- else -> ""
- }
- }
-
- private fun isYesterday(timestamp: Long): Boolean {
- return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS)
+ return formatChatUserStatus(
+ status = status,
+ stringProvider = stringProvider,
+ dateFormatManager = dateFormatManager,
+ isBot = isBot
+ )
}
}
diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt
new file mode 100644
index 00000000..fb93b8dd
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt
@@ -0,0 +1,125 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.db.model.ChatEntity
+import org.monogram.domain.models.ChatPermissionsModel
+
+internal data class ChatEntityPermissionValues(
+ val canSendBasicMessages: Boolean,
+ val canSendAudios: Boolean,
+ val canSendDocuments: Boolean,
+ val canSendPhotos: Boolean,
+ val canSendVideos: Boolean,
+ val canSendVideoNotes: Boolean,
+ val canSendVoiceNotes: Boolean,
+ val canSendPolls: Boolean,
+ val canSendOtherMessages: Boolean,
+ val canAddLinkPreviews: Boolean,
+ val canEditTag: Boolean,
+ val canChangeInfo: Boolean,
+ val canInviteUsers: Boolean,
+ val canPinMessages: Boolean,
+ val canCreateTopics: Boolean
+)
+
+internal fun TdApi.ChatPermissions?.toDomainChatPermissions(): ChatPermissionsModel {
+ val permissions = this ?: TdApi.ChatPermissions()
+ return ChatPermissionsModel(
+ canSendBasicMessages = permissions.canSendBasicMessages,
+ canSendAudios = permissions.canSendAudios,
+ canSendDocuments = permissions.canSendDocuments,
+ canSendPhotos = permissions.canSendPhotos,
+ canSendVideos = permissions.canSendVideos,
+ canSendVideoNotes = permissions.canSendVideoNotes,
+ canSendVoiceNotes = permissions.canSendVoiceNotes,
+ canSendPolls = permissions.canSendPolls,
+ canSendOtherMessages = permissions.canSendOtherMessages,
+ canAddLinkPreviews = permissions.canAddLinkPreviews,
+ canEditTag = permissions.canEditTag,
+ canChangeInfo = permissions.canChangeInfo,
+ canInviteUsers = permissions.canInviteUsers,
+ canPinMessages = permissions.canPinMessages,
+ canCreateTopics = permissions.canCreateTopics,
+ )
+}
+
+internal fun ChatPermissionsModel.toTdApiChatPermissions(): TdApi.ChatPermissions {
+ return TdApi.ChatPermissions(
+ canSendBasicMessages,
+ canSendAudios,
+ canSendDocuments,
+ canSendPhotos,
+ canSendVideos,
+ canSendVideoNotes,
+ canSendVoiceNotes,
+ canSendPolls,
+ canSendOtherMessages,
+ canAddLinkPreviews,
+ canEditTag,
+ canChangeInfo,
+ canInviteUsers,
+ canPinMessages,
+ canCreateTopics
+ )
+}
+
+internal fun ChatEntity.toDomainChatPermissionsModel(): ChatPermissionsModel {
+ return ChatPermissionsModel(
+ canSendBasicMessages = permissionCanSendBasicMessages,
+ canSendAudios = permissionCanSendAudios,
+ canSendDocuments = permissionCanSendDocuments,
+ canSendPhotos = permissionCanSendPhotos,
+ canSendVideos = permissionCanSendVideos,
+ canSendVideoNotes = permissionCanSendVideoNotes,
+ canSendVoiceNotes = permissionCanSendVoiceNotes,
+ canSendPolls = permissionCanSendPolls,
+ canSendOtherMessages = permissionCanSendOtherMessages,
+ canAddLinkPreviews = permissionCanAddLinkPreviews,
+ canEditTag = permissionCanEditTag,
+ canChangeInfo = permissionCanChangeInfo,
+ canInviteUsers = permissionCanInviteUsers,
+ canPinMessages = permissionCanPinMessages,
+ canCreateTopics = permissionCanCreateTopics
+ )
+}
+
+internal fun ChatPermissionsModel.toEntityPermissionValues(): ChatEntityPermissionValues {
+ return ChatEntityPermissionValues(
+ canSendBasicMessages = canSendBasicMessages,
+ canSendAudios = canSendAudios,
+ canSendDocuments = canSendDocuments,
+ canSendPhotos = canSendPhotos,
+ canSendVideos = canSendVideos,
+ canSendVideoNotes = canSendVideoNotes,
+ canSendVoiceNotes = canSendVoiceNotes,
+ canSendPolls = canSendPolls,
+ canSendOtherMessages = canSendOtherMessages,
+ canAddLinkPreviews = canAddLinkPreviews,
+ canEditTag = canEditTag,
+ canChangeInfo = canChangeInfo,
+ canInviteUsers = canInviteUsers,
+ canPinMessages = canPinMessages,
+ canCreateTopics = canCreateTopics
+ )
+}
+
+internal fun ChatEntity.withPermissions(permissions: ChatPermissionsModel): ChatEntity {
+ val values = permissions.toEntityPermissionValues()
+ return copy(
+ permissionCanSendBasicMessages = values.canSendBasicMessages,
+ permissionCanSendAudios = values.canSendAudios,
+ permissionCanSendDocuments = values.canSendDocuments,
+ permissionCanSendPhotos = values.canSendPhotos,
+ permissionCanSendVideos = values.canSendVideos,
+ permissionCanSendVideoNotes = values.canSendVideoNotes,
+ permissionCanSendVoiceNotes = values.canSendVoiceNotes,
+ permissionCanSendPolls = values.canSendPolls,
+ permissionCanSendOtherMessages = values.canSendOtherMessages,
+ permissionCanAddLinkPreviews = values.canAddLinkPreviews,
+ permissionCanEditTag = values.canEditTag,
+ permissionCanChangeInfo = values.canChangeInfo,
+ permissionCanInviteUsers = values.canInviteUsers,
+ permissionCanPinMessages = values.canPinMessages,
+ permissionCanCreateTopics = values.canCreateTopics
+ )
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt
new file mode 100644
index 00000000..825ed0e5
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt
@@ -0,0 +1,20 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+
+internal fun encodeChatPositions(positions: Array): String? {
+ if (positions.isEmpty()) return null
+
+ val encoded = positions.mapNotNull { position ->
+ if (position.order == 0L) return@mapNotNull null
+ val pinned = if (position.isPinned) 1 else 0
+ when (val list = position.list) {
+ is TdApi.ChatListMain -> "m:${position.order}:$pinned"
+ is TdApi.ChatListArchive -> "a:${position.order}:$pinned"
+ is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${position.order}:$pinned"
+ else -> null
+ }
+ }
+
+ return if (encoded.isEmpty()) null else encoded.joinToString("|")
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt
new file mode 100644
index 00000000..041b92f4
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt
@@ -0,0 +1,41 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.ChatType
+
+internal data class TdChatTypeIds(
+ val privateUserId: Long = 0L,
+ val basicGroupId: Long = 0L,
+ val supergroupId: Long = 0L,
+ val secretChatId: Int = 0
+)
+
+internal fun TdApi.ChatType.toDomainChatType(): ChatType {
+ return when (this) {
+ is TdApi.ChatTypePrivate -> ChatType.PRIVATE
+ is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP
+ is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP
+ is TdApi.ChatTypeSecret -> ChatType.SECRET
+ else -> ChatType.PRIVATE
+ }
+}
+
+internal fun TdApi.ChatType.toEntityChatType(): String = toDomainChatType().name
+
+internal fun TdApi.ChatType.isChannelType(): Boolean {
+ return (this as? TdApi.ChatTypeSupergroup)?.isChannel ?: false
+}
+
+internal fun TdApi.ChatType.isGroupType(): Boolean {
+ return this is TdApi.ChatTypeBasicGroup || (this is TdApi.ChatTypeSupergroup && !isChannel)
+}
+
+internal fun TdApi.ChatType.extractTypeIds(): TdChatTypeIds {
+ return when (this) {
+ is TdApi.ChatTypePrivate -> TdChatTypeIds(privateUserId = userId)
+ is TdApi.ChatTypeBasicGroup -> TdChatTypeIds(basicGroupId = basicGroupId)
+ is TdApi.ChatTypeSupergroup -> TdChatTypeIds(supergroupId = supergroupId)
+ is TdApi.ChatTypeSecret -> TdChatTypeIds(secretChatId = secretChatId)
+ else -> TdChatTypeIds()
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt
new file mode 100644
index 00000000..a3169cf6
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt
@@ -0,0 +1,46 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.datasource.remote.MessageFileApi
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.data.gateway.TelegramGateway
+import org.monogram.data.infra.FileUpdateHandler
+
+internal class CustomEmojiLoader(
+ private val gateway: TelegramGateway,
+ private val fileApi: MessageFileApi,
+ private val fileUpdateHandler: FileUpdateHandler,
+ private val fileHelper: TdFileHelper
+) {
+ fun getPathIfValid(emojiId: Long): String? {
+ return fileUpdateHandler.customEmojiPaths[emojiId]
+ ?.takeIf { fileHelper.isValidPath(it) }
+ }
+
+ suspend fun loadIfNeeded(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) {
+ if (getPathIfValid(emojiId) != null) return
+
+ val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId)))
+ if (result is TdApi.Stickers && result.stickers.isNotEmpty()) {
+ val fileToUse = result.stickers.first().sticker
+
+ fileUpdateHandler.fileIdToCustomEmojiId[fileToUse.id] = emojiId
+ fileApi.registerFileForMessage(fileToUse.id, chatId, messageId)
+
+ if (!fileHelper.isValidPath(fileToUse.local.path)) {
+ if (autoDownload) {
+ fileApi.enqueueDownload(
+ fileToUse.id,
+ 32,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ } else {
+ fileUpdateHandler.customEmojiPaths[emojiId] = fileToUse.local.path
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt
new file mode 100644
index 00000000..b4b401ac
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt
@@ -0,0 +1,7 @@
+package org.monogram.data.mapper
+
+import java.io.File
+
+internal fun isValidFilePath(path: String?): Boolean {
+ return !path.isNullOrEmpty() && File(path).exists()
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt
index 5651929b..7c1c4f9e 100644
--- a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt
@@ -144,7 +144,7 @@ private fun TdApi.PageBlockRelatedArticle.toRelatedArticle() = PageBlockRelatedA
private fun TdApi.Photo.toPhoto(): WebPage.Photo {
val size = sizes.lastOrNull()
return WebPage.Photo(
- path = size?.photo?.local?.path?.ifEmpty { null },
+ path = size?.photo?.local?.path?.takeIf { isValidFilePath(it) },
width = size?.width ?: 0,
height = size?.height ?: 0,
fileId = size?.photo?.id ?: 0,
@@ -153,7 +153,7 @@ private fun TdApi.Photo.toPhoto(): WebPage.Photo {
}
private fun TdApi.Animation.toAnimation() = WebPage.Animation(
- path = animation.local.path.ifEmpty { null },
+ path = animation.local.path.takeIf { isValidFilePath(it) },
width = width,
height = height,
duration = duration,
@@ -161,7 +161,7 @@ private fun TdApi.Animation.toAnimation() = WebPage.Animation(
)
private fun TdApi.Audio.toAudio() = WebPage.Audio(
- path = audio.local.path.ifEmpty { null },
+ path = audio.local.path.takeIf { isValidFilePath(it) },
duration = duration,
title = title,
performer = performer,
@@ -169,7 +169,7 @@ private fun TdApi.Audio.toAudio() = WebPage.Audio(
)
private fun TdApi.Video.toVideo() = WebPage.Video(
- path = video.local.path.ifEmpty { null },
+ path = video.local.path.takeIf { isValidFilePath(it) },
width = width,
height = height,
duration = duration,
@@ -177,7 +177,7 @@ private fun TdApi.Video.toVideo() = WebPage.Video(
)
private fun TdApi.Document.toDocument() = WebPage.Document(
- path = document.local.path.ifEmpty { null },
+ path = document.local.path.takeIf { isValidFilePath(it) },
fileName = fileName,
mimeType = mimeType,
size = document.size,
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 0139f67a..8e8a9caf 100644
--- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt
@@ -1,491 +1,344 @@
package org.monogram.data.mapper
-import android.net.ConnectivityManager
-import android.net.NetworkCapabilities
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import org.drinkless.tdlib.TdApi
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
-import org.monogram.data.datasource.remote.MessageFileApi
-import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
import org.monogram.data.gateway.TelegramGateway
-import org.monogram.data.infra.FileUpdateHandler
+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.domain.models.*
-import org.monogram.domain.repository.AppPreferencesProvider
-import org.monogram.domain.repository.ChatInfoRepository
import org.monogram.domain.repository.UserRepository
-import java.io.File
-import java.util.concurrent.ConcurrentHashMap
-class MessageMapper(
- private val connectivityManager: ConnectivityManager,
+class MessageMapper internal constructor(
private val gateway: TelegramGateway,
private val userRepository: UserRepository,
- private val chatInfoRepository: ChatInfoRepository,
- private val fileUpdateHandler: FileUpdateHandler,
- private val fileApi: MessageFileApi,
- private val appPreferences: AppPreferencesProvider,
private val cache: ChatCache,
- scopeProvider: ScopeProvider
+ private val fileHelper: TdFileHelper,
+ private val senderResolver: MessageSenderResolver,
+ private val contentMapper: MessageContentMapper,
+ private val persistenceMapper: MessagePersistenceMapper,
+ private val customEmojiLoader: CustomEmojiLoader
) {
- val scope = scopeProvider.appScope
- private val customEmojiPaths = fileUpdateHandler.customEmojiPaths
- private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId
-
- private data class SenderUserSnapshot(
- val name: String,
- val avatar: String?,
- val personalAvatar: String?,
- val isVerified: Boolean,
- val isPremium: Boolean,
- val statusEmojiId: Long,
- val statusEmojiPath: String?
- )
-
- private data class SenderChatSnapshot(
- val name: String,
- val avatar: String?
- )
-
- private val senderUserSnapshotCache = ConcurrentHashMap()
- private val senderChatSnapshotCache = ConcurrentHashMap()
- private val senderRankCache = ConcurrentHashMap()
- private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet()
-
val senderUpdateFlow: Flow
- get() = userRepository.anyUserUpdateFlow
+ get() = senderResolver.senderUpdateFlow
fun invalidateSenderCache(userId: Long) {
- if (userId <= 0L) return
- senderUserSnapshotCache.remove(userId)
- senderChatSnapshotCache.remove(userId)
- senderRankCache.entries.removeIf { it.key.endsWith(":$userId") }
- }
-
- private companion object {
- private const val NO_RANK_SENTINEL = "__NO_RANK__"
- private const val META_SEPARATOR = '\u001F'
- private const val MESSAGE_MAP_TIMEOUT_MS = 2500L
- }
-
- private fun getCurrentNetworkType(): TdApi.NetworkType {
- val activeNetwork = connectivityManager.activeNetwork
- val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
-
- return when {
- capabilities == null -> TdApi.NetworkTypeNone()
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi()
- capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
- if (connectivityManager.isDefaultNetworkActive && capabilities.hasCapability(
- NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
- .not()
- ) {
- TdApi.NetworkTypeMobileRoaming()
- } else {
- TdApi.NetworkTypeMobile()
- }
- }
- else -> TdApi.NetworkTypeNone()
- }
- }
-
- private fun isNetworkAutoDownloadEnabled(): Boolean {
- return when (getCurrentNetworkType()) {
- is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value
- is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value
- is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value
- else -> appPreferences.autoDownloadWifi.value
- }
- }
-
- private fun isValidPath(path: String?): Boolean {
- return !path.isNullOrEmpty() && File(path).exists()
- }
-
- private fun encodeMeta(vararg parts: Any?): String {
- return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() }
- }
-
- private fun decodeMeta(raw: String?): List {
- if (raw.isNullOrBlank()) return emptyList()
- return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|')
- }
-
- private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair {
- return when (contentType) {
- "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3)
- "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
- "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2)
- "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3)
- "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6)
- "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
- "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5)
- "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
- else -> 0 to null
- }
+ senderResolver.invalidateCache(userId)
}
- private fun resolveCachedPath(fileId: Int, storedPath: String?): String? {
- val fromStored = storedPath
- ?.takeIf { it.isNotBlank() }
- ?.takeIf { isValidPath(it) }
- if (fromStored != null) return fromStored
-
- return fileId.takeIf { it != 0 }
- ?.let { cache.fileCache[it]?.local?.path }
- ?.takeIf { isValidPath(it) }
- }
+ suspend fun mapMessageToModel(
+ msg: TdApi.Message,
+ isChatOpen: Boolean = false,
+ isReply: Boolean = false
+ ): MessageModel = coroutineScope {
+ withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) {
+ val sender = senderResolver.resolveSender(msg)
- private fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) {
- if (fileId != 0) {
- fileApi.registerFileForMessage(fileId, chatId, messageId)
- }
- }
+ val (replyToMsgId, replyToMsg) = resolveReplyInfo(
+ msg = msg,
+ isChatOpen = isChatOpen,
+ isReply = isReply
+ )
- private fun resolveLocalFilePath(file: TdApi.File?): String? {
- if (file == null) return null
- val directPath = file.local.path.takeIf { isValidPath(it) }
- if (directPath != null) return directPath
+ 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 threadId = resolveThreadId(msg)
+ val viaBotName = resolveViaBotName(msg)
- return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) }
+ createMessageModel(
+ msg = msg,
+ senderName = sender.senderName,
+ senderId = sender.senderId,
+ senderAvatar = sender.senderAvatar,
+ isReadOverride = false,
+ replyToMsgId = replyToMsgId,
+ replyToMsg = replyToMsg,
+ forwardInfo = forwardInfo,
+ views = views,
+ viewCount = views,
+ mediaAlbumId = msg.mediaAlbumId,
+ sendingState = sendingState,
+ isChatOpen = isChatOpen,
+ readDate = 0,
+ reactions = reactions,
+ isSenderVerified = sender.isSenderVerified,
+ threadId = threadId,
+ replyCount = replyCount,
+ isReply = isReply,
+ viaBotUserId = msg.viaBotUserId,
+ viaBotName = viaBotName,
+ senderPersonalAvatar = sender.senderPersonalAvatar,
+ senderCustomTitle = sender.senderCustomTitle,
+ isSenderPremium = sender.isSenderPremium,
+ senderStatusEmojiId = sender.senderStatusEmojiId,
+ senderStatusEmojiPath = sender.senderStatusEmojiPath
+ )
+ } ?: mapMessageToModelFallback(msg, isChatOpen, isReply)
}
- private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String {
- val user = cache.getUser(senderId)
- if (user != null) {
- return listOfNotNull(
- user.firstName.takeIf { it.isNotBlank() },
- user.lastName?.takeIf { it.isNotBlank() }
- ).joinToString(" ").ifBlank { fallback.ifBlank { "User" } }
- }
-
- val chat = cache.getChat(senderId)
- if (chat != null) {
- return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" }
+ suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int {
+ val chat = cache.getChat(chatId)
+ if (chat?.type !is TdApi.ChatTypePrivate) {
+ return 0
}
- return fallback.ifBlank { "User" }
- }
-
- private fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? {
- if (mainFile != null && isValidPath(mainFile.local.path)) {
- return mainFile.local.path
+ val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60)
+ if (messageDate < sevenDaysAgo) {
+ return 0
}
- if (sizes != null) {
- return sizes.sortedByDescending { it.width }
- .map { getUpdatedFile(it.photo) }
- .firstOrNull { isValidPath(it.local.path) }
- ?.local?.path
+ return try {
+ val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId))
+ if (result is TdApi.MessageReadDateRead) {
+ result.readDate
+ } else {
+ 0
+ }
+ } catch (_: Exception) {
+ 0
}
- return null
}
- private fun resolveFallbackSender(msg: TdApi.Message): Triple {
- return when (val sender = msg.senderId) {
- is TdApi.MessageSenderUser -> {
- val senderId = sender.userId
- val snapshot = senderUserSnapshotCache[senderId]
- if (snapshot != null) {
- val avatar = snapshot.avatar ?: snapshot.personalAvatar
- Triple(senderId, snapshot.name.ifBlank { "User" }, avatar)
- } else {
- val user = cache.getUser(senderId)
- val fallbackName = if (user != null) {
- listOfNotNull(
- user.firstName.takeIf { it.isNotBlank() },
- user.lastName?.takeIf { it.isNotBlank() }
- ).joinToString(" ").ifBlank { "User" }
- } else {
- "User"
- }
- val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { isValidPath(it) }
- ?: user?.profilePhoto?.big?.local?.path?.takeIf { isValidPath(it) }
- Triple(senderId, fallbackName, avatar)
- }
- }
-
- is TdApi.MessageSenderChat -> {
- val senderId = sender.chatId
- val snapshot = senderChatSnapshotCache[senderId]
- if (snapshot != null) {
- Triple(senderId, snapshot.name.ifBlank { "User" }, snapshot.avatar)
- } else {
- val chat = cache.getChat(senderId)
- val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User"
- val avatar = chat?.photo?.small?.local?.path?.takeIf { isValidPath(it) }
- Triple(senderId, fallbackName, avatar)
- }
- }
-
- else -> Triple(0L, "User", null)
- }
+ suspend fun mapMessageToModelSync(
+ msg: TdApi.Message,
+ inboxLimit: Long,
+ outboxLimit: Long,
+ isChatOpen: Boolean = false,
+ isReply: Boolean = false
+ ): MessageModel {
+ val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit
+ val baseModel = mapMessageToModel(msg, isChatOpen, isReply)
+ return baseModel.copy(isRead = isRead)
}
- private fun mapMessageToModelFallback(
+ internal fun createMessageModel(
msg: TdApi.Message,
- isChatOpen: Boolean,
- isReply: Boolean
+ senderName: String,
+ senderId: Long,
+ senderAvatar: String?,
+ isReadOverride: Boolean = false,
+ replyToMsgId: Long? = null,
+ replyToMsg: MessageModel? = null,
+ forwardInfo: ForwardInfo? = null,
+ views: Int? = null,
+ viewCount: Int? = null,
+ mediaAlbumId: Long = 0L,
+ sendingState: MessageSendingState? = null,
+ isChatOpen: Boolean = false,
+ readDate: Int = 0,
+ reactions: List = emptyList(),
+ isSenderVerified: Boolean = false,
+ threadId: Long? = null,
+ replyCount: Int = 0,
+ isReply: Boolean = false,
+ viaBotUserId: Long = 0L,
+ viaBotName: String? = null,
+ senderPersonalAvatar: String? = null,
+ senderCustomTitle: String? = null,
+ isSenderPremium: Boolean = false,
+ senderStatusEmojiId: Long = 0L,
+ senderStatusEmojiPath: String? = null
): MessageModel {
- val (senderId, senderName, senderAvatar) = resolveFallbackSender(msg)
- return createMessageModel(
+ val networkAutoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled()
+ val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending
+
+ val content = contentMapper.mapContent(
msg = msg,
+ context = ContentMappingContext(
+ chatId = msg.chatId,
+ messageId = msg.id,
+ senderName = senderName,
+ networkAutoDownload = networkAutoDownload,
+ isActuallyUploading = isActuallyUploading
+ )
+ )
+
+ val isServiceMessage = content is MessageContent.Service
+ val canEdit = msg.isOutgoing && !isServiceMessage
+ val canForward = !isServiceMessage
+ val canSave = !isServiceMessage
+ val hasInteraction = msg.interactionInfo != null
+
+ return MessageModel(
+ id = msg.id,
+ date = contentMapper.resolveMessageDate(msg),
+ isOutgoing = msg.isOutgoing,
senderName = senderName,
+ chatId = msg.chatId,
+ content = content,
senderId = senderId,
senderAvatar = senderAvatar,
- isChatOpen = isChatOpen,
- isReply = isReply
+ senderPersonalAvatar = senderPersonalAvatar,
+ senderCustomTitle = senderCustomTitle,
+ isRead = isReadOverride,
+ replyToMsgId = replyToMsgId,
+ replyToMsg = replyToMsg,
+ forwardInfo = forwardInfo,
+ views = views,
+ viewCount = viewCount,
+ mediaAlbumId = mediaAlbumId,
+ editDate = msg.editDate,
+ sendingState = sendingState,
+ readDate = readDate,
+ reactions = reactions,
+ isSenderVerified = isSenderVerified,
+ threadId = threadId,
+ replyCount = replyCount,
+ canBeEdited = canEdit,
+ canBeForwarded = canForward,
+ canBeDeletedOnlyForSelf = true,
+ canBeDeletedForAllUsers = msg.isOutgoing,
+ canBeSaved = canSave,
+ canGetMessageThread = msg.interactionInfo?.replyInfo != null,
+ canGetStatistics = hasInteraction,
+ canGetReadReceipts = hasInteraction,
+ canGetViewers = hasInteraction,
+ replyMarkup = if (isReply) null else msg.replyMarkup.toDomainReplyMarkup(),
+ viaBotUserId = viaBotUserId,
+ viaBotName = viaBotName,
+ isSenderPremium = isSenderPremium,
+ senderStatusEmojiId = senderStatusEmojiId,
+ senderStatusEmojiPath = senderStatusEmojiPath
)
}
- suspend fun mapMessageToModel(
+ fun mapToEntity(
msg: TdApi.Message,
- isChatOpen: Boolean = false,
- isReply: Boolean = false
- ): MessageModel = coroutineScope {
- withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) {
- var senderName = "User"
- var senderAvatar: String? = null
- var senderPersonalAvatar: String? = null
- var senderCustomTitle: String? = null
- var isSenderVerified = false
- var isSenderPremium = false
- var senderStatusEmojiId = 0L
- var senderStatusEmojiPath: String? = null
- val senderId: Long
+ getSenderName: ((Long) -> String?)? = null
+ ): org.monogram.data.db.model.MessageEntity {
+ return persistenceMapper.mapToEntity(msg, getSenderName)
+ }
- when (val sender = msg.senderId) {
- is TdApi.MessageSenderUser -> {
- senderId = sender.userId
- val cachedSnapshot = senderUserSnapshotCache[senderId]
- if (cachedSnapshot != null) {
- senderName = cachedSnapshot.name
- senderAvatar = cachedSnapshot.avatar
- senderPersonalAvatar = cachedSnapshot.personalAvatar
- isSenderVerified = cachedSnapshot.isVerified
- isSenderPremium = cachedSnapshot.isPremium
- senderStatusEmojiId = cachedSnapshot.statusEmojiId
- senderStatusEmojiPath = cachedSnapshot.statusEmojiPath
- } else {
- val user = try {
- withTimeout(500) { userRepository.getUser(senderId) }
- } catch (e: Exception) {
- null
- }
- if (user != null) {
- senderName = listOfNotNull(
- user.firstName.takeIf { it.isNotBlank() },
- user.lastName?.takeIf { it.isNotBlank() }
- ).joinToString(" ")
+ internal fun extractCachedContent(content: TdApi.MessageContent): MessagePersistenceMapper.CachedMessageContent {
+ return persistenceMapper.extractCachedContent(content)
+ }
- if (senderName.isBlank()) senderName = "User"
+ fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel {
+ return persistenceMapper.mapEntityToModel(entity)
+ }
- senderAvatar = user.avatarPath.takeIf { isValidPath(it) }
- senderPersonalAvatar = user.personalAvatarPath.takeIf { isValidPath(it) }
- isSenderVerified = user.isVerified
- isSenderPremium = user.isPremium
- senderStatusEmojiId = user.statusEmojiId
- senderStatusEmojiPath = user.statusEmojiPath
+ private suspend fun resolveReplyInfo(
+ msg: TdApi.Message,
+ isChatOpen: Boolean,
+ isReply: Boolean
+ ): 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
+ val repliedMessage = try {
+ withTimeout(500) {
+ cache.getMessage(msg.chatId, replyToMsgId)
+ ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) }
+ }
+ } catch (_: Exception) {
+ null
+ }
- senderUserSnapshotCache[senderId] = SenderUserSnapshot(
- name = senderName,
- avatar = senderAvatar,
- personalAvatar = senderPersonalAvatar,
- isVerified = isSenderVerified,
- isPremium = isSenderPremium,
- statusEmojiId = senderStatusEmojiId,
- statusEmojiPath = senderStatusEmojiPath
- )
- }
- }
+ val replyToMsg = repliedMessage?.let {
+ mapMessageToModel(
+ msg = it,
+ isChatOpen = isChatOpen,
+ isReply = true
+ ).copy(replyToMsg = null, replyToMsgId = null)
+ }
- val chat = cache.getChat(msg.chatId)
- val canGetMember = when (chat?.type) {
- is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true
- is TdApi.ChatTypeBasicGroup -> true
- is TdApi.ChatTypeSupergroup -> {
- val supergroup = (chat.type as TdApi.ChatTypeSupergroup)
- val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId)
- !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false)
- }
- else -> false
- }
+ return replyToMsgId to replyToMsg
+ }
- if (canGetMember) {
- val rankKey = "${msg.chatId}:$senderId"
- val cachedRank = senderRankCache[rankKey]
- if (cachedRank != null) {
- senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL }
- } else {
- val member = try {
- withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) }
- } catch (e: Exception) {
- null
- }
- senderCustomTitle = member?.rank
- senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL
- }
- }
- }
+ private suspend fun resolveForwardInfo(msg: TdApi.Message): ForwardInfo? {
+ val fwd = msg.forwardInfo ?: return null
+ val origin = fwd.origin
+ var originName = "Unknown"
+ var originPeerId = 0L
+ var originChatId: Long? = null
+ var originMessageId: Long? = null
- is TdApi.MessageSenderChat -> {
- senderId = sender.chatId
- val cachedSnapshot = senderChatSnapshotCache[senderId]
- if (cachedSnapshot != null) {
- senderName = cachedSnapshot.name
- senderAvatar = cachedSnapshot.avatar
- } else {
- val chat = try {
- withTimeout(500) {
- cache.getChat(senderId) ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) }
- }
- } catch (e: Exception) {
- null
- }
- if (chat != null) {
- senderName = chat.title
- val photo = chat.photo?.small
- if (photo != null) {
- senderAvatar = photo.local.path.takeIf { isValidPath(it) }
- if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) {
- fileApi.enqueueDownload(
- photo.id,
- 16,
- TdMessageRemoteDataSource.DownloadType.DEFAULT,
- 0,
- 0,
- false
- )
- }
- }
+ when (origin) {
+ is TdApi.MessageOriginUser -> {
+ originPeerId = origin.senderUserId
+ val user = try {
+ withTimeout(500) { userRepository.getUser(originPeerId) }
+ } catch (_: Exception) {
+ null
+ }
- senderChatSnapshotCache[senderId] = SenderChatSnapshot(
- name = senderName,
- avatar = senderAvatar
- )
+ if (user != null) {
+ val username = user.username?.takeIf { it.isNotBlank() }
+ val baseName = SenderNameResolver.fromPartsOrBlank(user.firstName, user.lastName)
+ originName = if (baseName.isNotBlank()) {
+ if (username != null) "$baseName (@$username)" else baseName
+ } else {
+ username?.let { "@$it" } ?: "Unknown"
}
}
}
- else -> senderId = 0L
- }
-
- var replyToMsgId: Long? = null
- var replyToMsg: MessageModel? = null
-
- if (!isReply && msg.replyTo != null) {
- val replyTo = msg.replyTo
- if (replyTo is TdApi.MessageReplyToMessage) {
- replyToMsgId = replyTo.messageId
-
- val repliedMessage = try {
+ is TdApi.MessageOriginChat -> {
+ originPeerId = origin.senderChatId
+ val chat = try {
withTimeout(500) {
- cache.getMessage(msg.chatId, replyToMsgId)
- ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) }
+ cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId))
+ .also { cache.putChat(it) }
}
- } catch (e: Exception) {
+ } catch (_: Exception) {
null
}
- if (repliedMessage != null) {
- replyToMsg =
- mapMessageToModel(
- repliedMessage,
- isChatOpen,
- isReply = true
- ).copy(replyToMsg = null, replyToMsgId = null)
+ if (chat != null) {
+ originName = chat.title
}
}
- }
-
- var forwardInfo: ForwardInfo? = null
- if (msg.forwardInfo != null) {
- val fwd = msg.forwardInfo
- val origin = fwd?.origin
- var originName = "Unknown"
- var originPeerId = 0L
- var originChatId: Long? = null
- var originMessageId: Long? = null
-
- when (origin) {
- is TdApi.MessageOriginUser -> {
- originPeerId = origin.senderUserId
- val user = try {
- withTimeout(500) { userRepository.getUser(originPeerId) }
- } catch (e: Exception) {
- null
- }
-
- if (user != null) {
- val first = user.firstName.takeIf { it.isNotBlank() }
- val last = user.lastName?.takeIf { it.isNotBlank() }
- val username = user.username?.takeIf { it.isNotBlank() }
-
- val baseName = listOfNotNull(first, last).joinToString(" ")
-
- originName = if (baseName.isNotBlank()) {
- if (username != null) "$baseName (@$username)" else baseName
- } else {
- username?.let { "@$it" } ?: "Unknown"
- }
- }
- }
-
- is TdApi.MessageOriginChat -> {
- originPeerId = origin.senderChatId
- val chat = try {
- withTimeout(500) {
- cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId))
- .also { cache.putChat(it) }
- }
- } catch (e: Exception) {
- null
- }
- if (chat != null) {
- originName = chat.title
- }
- }
- is TdApi.MessageOriginChannel -> {
- originPeerId = origin.chatId
- originChatId = origin.chatId
- originMessageId = origin.messageId
- val chat = try {
- withTimeout(500) {
- cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId))
- .also { cache.putChat(it) }
- }
- } catch (e: Exception) {
- null
- }
- if (chat != null) {
- originName = chat.title
+ is TdApi.MessageOriginChannel -> {
+ originPeerId = origin.chatId
+ originChatId = origin.chatId
+ originMessageId = origin.messageId
+ val chat = try {
+ withTimeout(500) {
+ cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId))
+ .also { cache.putChat(it) }
}
+ } catch (_: Exception) {
+ null
}
-
- is TdApi.MessageOriginHiddenUser -> {
- originName = origin.senderName
+ if (chat != null) {
+ originName = chat.title
}
}
- forwardInfo =
- ForwardInfo(fwd?.date ?: 0, originPeerId, originName, originChatId, originMessageId)
- }
- val views = msg.interactionInfo?.viewCount
- val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0
-
- val sendingState = when (val state = msg.sendingState) {
- is TdApi.MessageSendingStatePending -> MessageSendingState.Pending
- is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed(
- state.error.code,
- state.error.message
- )
+ is TdApi.MessageOriginHiddenUser -> {
+ originName = origin.senderName
+ }
- else -> null
+ null -> Unit
}
- val reactions =
- if (isReply) emptyList() else msg.interactionInfo?.reactions?.reactions?.map { reaction ->
+ return ForwardInfo(
+ date = fwd.date,
+ fromId = originPeerId,
+ fromName = originName,
+ originChatId = originChatId,
+ originMessageId = originMessageId
+ )
+ }
+
+ private suspend fun resolveReactions(
+ msg: TdApi.Message,
+ isReply: Boolean,
+ isChatOpen: Boolean
+ ): List {
+ if (isReply) return emptyList()
+ val reactionItems = msg.interactionInfo?.reactions?.reactions ?: return emptyList()
+
+ return coroutineScope {
+ reactionItems.map { reaction ->
async {
val recentSenders = try {
withTimeout(1000) {
@@ -495,35 +348,37 @@ class MessageMapper(
is TdApi.MessageSenderUser -> {
val user = try {
withTimeout(500) { userRepository.getUser(senderId.userId) }
- } catch (e: Exception) {
+ } catch (_: Exception) {
null
}
ReactionSender(
id = senderId.userId,
- name = listOfNotNull(
- user?.firstName,
- user?.lastName
- ).joinToString(" "),
- avatar = user?.avatarPath.takeIf { isValidPath(it) }
+ name = SenderNameResolver.fromPartsOrBlank(
+ firstName = user?.firstName,
+ lastName = user?.lastName
+ ),
+ avatar = user?.avatarPath.takeIf { fileHelper.isValidPath(it) }
)
}
is TdApi.MessageSenderChat -> {
val chat = try {
withTimeout(500) {
- cache.getChat(senderId.chatId) ?: gateway.execute(
- TdApi.GetChat(
- senderId.chatId
- )
- ).also { cache.putChat(it) }
+ cache.getChat(senderId.chatId)
+ ?: gateway.execute(TdApi.GetChat(senderId.chatId))
+ .also { cache.putChat(it) }
}
- } catch (e: Exception) {
+ } catch (_: Exception) {
null
}
ReactionSender(
id = senderId.chatId,
name = chat?.title ?: "",
- avatar = chat?.photo?.small?.local?.path.takeIf { isValidPath(it) }
+ avatar = chat?.photo?.small?.local?.path.takeIf {
+ fileHelper.isValidPath(
+ it
+ )
+ }
)
}
@@ -532,7 +387,7 @@ class MessageMapper(
}
}.awaitAll()
}
- } catch (e: Exception) {
+ } catch (_: Exception) {
emptyList()
}
@@ -548,15 +403,17 @@ class MessageMapper(
is TdApi.ReactionTypeCustomEmoji -> {
val emojiId = type.customEmojiId
- val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) }
+ var path = customEmojiLoader.getPathIfValid(emojiId)
if (path == null) {
- loadCustomEmoji(
- emojiId,
- msg.chatId,
- msg.id,
- isChatOpen && isNetworkAutoDownloadEnabled()
+ customEmojiLoader.loadIfNeeded(
+ emojiId = emojiId,
+ chatId = msg.chatId,
+ messageId = msg.id,
+ autoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled()
)
+ path = customEmojiLoader.getPathIfValid(emojiId)
}
+
MessageReactionModel(
customEmojiId = emojiId,
customEmojiPath = path,
@@ -569,1594 +426,59 @@ class MessageMapper(
else -> null
}
}
- }?.awaitAll()?.filterNotNull() ?: emptyList()
+ }.awaitAll().filterNotNull()
+ }
+ }
- val threadId = when (val topic = msg.topicId) {
+ 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
}
+ }
- var viaBotName: String? = null
- if (msg.viaBotUserId != 0L) {
- val bot = try {
- withTimeout(500) { userRepository.getUser(msg.viaBotUserId) }
- } catch (e: Exception) {
- null
- }
- viaBotName = bot?.username ?: bot?.firstName
+ private suspend fun resolveViaBotName(msg: TdApi.Message): String? {
+ if (msg.viaBotUserId == 0L) return null
+ val bot = try {
+ withTimeout(500) { userRepository.getUser(msg.viaBotUserId) }
+ } catch (_: Exception) {
+ null
}
+ return bot?.username ?: bot?.firstName
+ }
- createMessageModel(
- msg,
- senderName,
- senderId,
- senderAvatar,
- isReadOverride = false,
- replyToMsgId = replyToMsgId,
- replyToMsg = replyToMsg,
- forwardInfo = forwardInfo,
- views = views,
- viewCount = views,
- mediaAlbumId = msg.mediaAlbumId,
- sendingState = sendingState,
- isChatOpen = isChatOpen,
- readDate = 0,
- reactions = reactions,
- isSenderVerified = isSenderVerified,
- threadId = threadId,
- replyCount = replyCount,
- isReply = isReply,
- viaBotUserId = msg.viaBotUserId,
- viaBotName = viaBotName,
- senderPersonalAvatar = senderPersonalAvatar,
- senderCustomTitle = senderCustomTitle,
- isSenderPremium = isSenderPremium,
- senderStatusEmojiId = senderStatusEmojiId,
- senderStatusEmojiPath = senderStatusEmojiPath
- )
- } ?: mapMessageToModelFallback(msg, isChatOpen, isReply)
- }
-
- suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int {
- val chat = cache.getChat(chatId)
- if (chat?.type !is TdApi.ChatTypePrivate) {
- return 0
- }
-
- val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60)
- if (messageDate < sevenDaysAgo) {
- return 0
- }
-
- return try {
- val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId))
- if (result is TdApi.MessageReadDateRead) {
- result.readDate
- } else {
- 0
- }
- } catch (e: Exception) {
- 0
- }
- }
-
- suspend fun mapMessageToModelSync(
- msg: TdApi.Message,
- inboxLimit: Long,
- outboxLimit: Long,
- isChatOpen: Boolean = false,
- isReply: Boolean = false
- ): MessageModel {
- val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit
- val baseModel = mapMessageToModel(msg, isChatOpen, isReply)
- return baseModel.copy(isRead = isRead)
- }
-
- private fun getUpdatedFile(file: TdApi.File): TdApi.File {
- return cache.fileCache[file.id] ?: file
- }
-
- private fun mapEntities(
- entities: Array,
- chatId: Long,
- messageId: Long,
- networkAutoDownload: Boolean
- ): List {
- return entities.map { 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.TextEntityTypeUrl -> MessageEntityType.Url
- is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url)
- is TdApi.TextEntityTypeMention -> MessageEntityType.Mention
- is TdApi.TextEntityTypeMentionName -> MessageEntityType.Mention
- is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag
- is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand
- is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email
- is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber
- is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber
- is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote
- is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable
- is TdApi.TextEntityTypeCustomEmoji -> {
- val emojiId = entityType.customEmojiId
- val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) }
- if (path == null) {
- scope.launch {
- loadCustomEmoji(emojiId, chatId, messageId, networkAutoDownload)
- }
- }
- MessageEntityType.CustomEmoji(emojiId, path)
- }
-
- else -> MessageEntityType.Other(entityType.javaClass.simpleName)
- }
- MessageEntity(entity.offset, entity.length, type)
- }
- }
-
- private fun mapWebPage(
- webPage: TdApi.LinkPreview?,
- chatId: Long,
- messageId: Long,
- networkAutoDownload: Boolean
- ): WebPage? {
- if (webPage == null) return null
-
- var photoObj: TdApi.Photo? = null
- var videoObj: TdApi.Video? = null
- var audioObj: TdApi.Audio? = null
- var documentObj: TdApi.Document? = null
- var stickerObj: TdApi.Sticker? = null
- var animationObj: TdApi.Animation? = null
- var duration = 0
-
- val linkPreviewType = when (val t = webPage.type) {
- is TdApi.LinkPreviewTypePhoto -> {
- photoObj = t.photo
- WebPage.LinkPreviewType.Photo
- }
-
- is TdApi.LinkPreviewTypeVideo -> {
- videoObj = t.video
- WebPage.LinkPreviewType.Video
- }
-
- is TdApi.LinkPreviewTypeAnimation -> {
- animationObj = t.animation
- WebPage.LinkPreviewType.Animation
- }
-
- is TdApi.LinkPreviewTypeAudio -> {
- audioObj = t.audio
- WebPage.LinkPreviewType.Audio
- }
-
- is TdApi.LinkPreviewTypeDocument -> {
- documentObj = t.document
- WebPage.LinkPreviewType.Document
- }
-
- is TdApi.LinkPreviewTypeSticker -> {
- stickerObj = t.sticker
- WebPage.LinkPreviewType.Sticker
- }
-
- is TdApi.LinkPreviewTypeVideoNote -> WebPage.LinkPreviewType.VideoNote
- is TdApi.LinkPreviewTypeVoiceNote -> WebPage.LinkPreviewType.VoiceNote
- is TdApi.LinkPreviewTypeAlbum -> WebPage.LinkPreviewType.Album
- is TdApi.LinkPreviewTypeArticle -> WebPage.LinkPreviewType.Article
- is TdApi.LinkPreviewTypeApp -> WebPage.LinkPreviewType.App
- is TdApi.LinkPreviewTypeExternalVideo -> {
- duration = t.duration
- WebPage.LinkPreviewType.ExternalVideo(t.url)
- }
-
- is TdApi.LinkPreviewTypeExternalAudio -> {
- duration = t.duration
- WebPage.LinkPreviewType.ExternalAudio(t.url)
- }
-
- is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> {
- duration = t.duration
- WebPage.LinkPreviewType.EmbeddedVideo(t.url)
- }
-
- is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> {
- duration = t.duration
- WebPage.LinkPreviewType.EmbeddedAudio(t.url)
- }
-
- is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> {
- duration = t.duration
- WebPage.LinkPreviewType.EmbeddedAnimation(t.url)
- }
- is TdApi.LinkPreviewTypeUser -> WebPage.LinkPreviewType.User(0)
- is TdApi.LinkPreviewTypeChat -> WebPage.LinkPreviewType.Chat(0)
- is TdApi.LinkPreviewTypeStory -> WebPage.LinkPreviewType.Story(t.storyPosterChatId, t.storyId)
- is TdApi.LinkPreviewTypeTheme -> WebPage.LinkPreviewType.Theme
- is TdApi.LinkPreviewTypeBackground -> WebPage.LinkPreviewType.Background
- is TdApi.LinkPreviewTypeInvoice -> WebPage.LinkPreviewType.Invoice
- is TdApi.LinkPreviewTypeMessage -> WebPage.LinkPreviewType.Message
- else -> WebPage.LinkPreviewType.Unknown
- }
-
- fun processTdFile(
- file: TdApi.File,
- downloadType: TdMessageRemoteDataSource.DownloadType,
- supportsStreaming: Boolean = false
- ): TdApi.File {
- val updatedFile = getUpdatedFile(file)
- fileApi.registerFileForMessage(updatedFile.id, chatId, messageId)
-
- val autoDownload = when (downloadType) {
- TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload
- TdMessageRemoteDataSource.DownloadType.DEFAULT -> {
- if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload
- }
- TdMessageRemoteDataSource.DownloadType.STICKER -> networkAutoDownload && appPreferences.autoDownloadStickers.value
- TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> networkAutoDownload && appPreferences.autoDownloadVideoNotes.value
- else -> networkAutoDownload
- }
-
- if (!isValidPath(updatedFile.local.path) && autoDownload) {
- fileApi.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false)
- }
- return updatedFile
- }
-
- val photo = photoObj?.let { p ->
- val size = p.sizes.firstOrNull()
- if (size != null) {
- val f = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT)
- val bestPath = findBestAvailablePath(f, p.sizes)
-
- WebPage.Photo(
- path = bestPath,
- width = size.width,
- height = size.height,
- fileId = f.id,
- minithumbnail = p.minithumbnail?.data
- )
- } else null
- }
-
- val video = videoObj?.let { v ->
- val f = processTdFile(v.video, TdMessageRemoteDataSource.DownloadType.VIDEO, v.supportsStreaming)
- WebPage.Video(f.local.path.takeIf { isValidPath(it) }, v.width, v.height, v.duration, f.id, v.supportsStreaming)
- }
-
- val audio = audioObj?.let { a ->
- val f = processTdFile(a.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT)
- WebPage.Audio(a.audio.local.path.takeIf { isValidPath(it) }, a.duration, a.title, a.performer, f.id)
- }
-
- val document = documentObj?.let { d ->
- val f = processTdFile(d.document, TdMessageRemoteDataSource.DownloadType.DEFAULT)
- WebPage.Document(d.document.local.path.takeIf { isValidPath(it) }, d.fileName, d.mimeType, f.size, f.id)
- }
-
- val sticker = stickerObj?.let { s ->
- val f = processTdFile(s.sticker, TdMessageRemoteDataSource.DownloadType.STICKER)
- WebPage.Sticker(s.sticker.local.path.takeIf { isValidPath(it) }, s.width, s.height, s.emoji, f.id)
- }
-
- val animation = animationObj?.let { anim ->
- val f = processTdFile(anim.animation, TdMessageRemoteDataSource.DownloadType.GIF)
- WebPage.Animation(anim.animation.local.path.takeIf { isValidPath(it) }, anim.width, anim.height, anim.duration, f.id)
- }
-
- return WebPage(
- url = webPage.url,
- displayUrl = webPage.displayUrl,
- type = linkPreviewType,
- siteName = webPage.siteName,
- title = webPage.title,
- description = webPage.description?.text,
- photo = photo,
- embedUrl = null,
- embedType = null,
- embedWidth = 0,
- embedHeight = 0,
- duration = duration,
- author = webPage.author,
- video = video,
- audio = audio,
- document = document,
- sticker = sticker,
- animation = animation,
- instantViewVersion = webPage.instantViewVersion
- )
- }
-
- private fun mapReplyMarkup(markup: TdApi.ReplyMarkup?): ReplyMarkupModel? {
- return when (markup) {
- is TdApi.ReplyMarkupInlineKeyboard -> {
- ReplyMarkupModel.InlineKeyboard(
- rows = markup.rows.map { row ->
- row.map { button ->
- InlineKeyboardButtonModel(
- text = button.text,
- type = when (val type = button.type) {
- is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url(
- type.url
- )
-
- is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback(
- type.data
- )
-
- is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp(
- type.url
- )
-
- is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl(
- type.url,
- type.id
- )
-
- is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline(
- query = type.query
- )
-
- is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy()
- is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User(
- type.userId
- )
-
- else -> InlineKeyboardButtonType.Unsupported
- }
- )
- }
- }
- )
- }
-
- is TdApi.ReplyMarkupShowKeyboard -> {
- ReplyMarkupModel.ShowKeyboard(
- rows = markup.rows.map { row ->
- row.map { button ->
- KeyboardButtonModel(
- text = button.text,
- type = when (val type = button.type) {
- is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text
- is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber
- is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation
- is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll(
- type.forceQuiz,
- type.forceRegular
- )
-
- is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp(
- type.url
- )
-
- is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers(
- type.id
- )
-
- is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat(
- type.id
- )
-
- else -> KeyboardButtonType.Unsupported
- }
- )
- }
- },
- isPersistent = markup.isPersistent,
- resizeKeyboard = markup.resizeKeyboard,
- oneTime = markup.oneTime,
- isPersonal = markup.isPersonal,
- inputFieldPlaceholder = markup.inputFieldPlaceholder
- )
- }
-
- is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(markup.isPersonal)
- is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(
- markup.isPersonal,
- markup.inputFieldPlaceholder
- )
-
+ private fun resolveSendingState(msg: TdApi.Message): MessageSendingState? {
+ return when (val state = msg.sendingState) {
+ is TdApi.MessageSendingStatePending -> MessageSendingState.Pending
+ is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed(state.error.code, state.error.message)
else -> null
}
}
- fun createMessageModel(
- msg: TdApi.Message,
- senderName: String,
- senderId: Long,
- senderAvatar: String?,
- isReadOverride: Boolean = false,
- replyToMsgId: Long? = null,
- replyToMsg: MessageModel? = null,
- forwardInfo: ForwardInfo? = null,
- views: Int? = null,
- viewCount: Int? = null,
- mediaAlbumId: Long = 0L,
- sendingState: MessageSendingState? = null,
- isChatOpen: Boolean = false,
- readDate: Int = 0,
- reactions: List = emptyList(),
- isSenderVerified: Boolean = false,
- threadId: Long? = null,
- replyCount: Int = 0,
- isReply: Boolean = false,
- viaBotUserId: Long = 0L,
- viaBotName: String? = null,
- senderPersonalAvatar: String? = null,
- senderCustomTitle: String? = null,
- isSenderPremium: Boolean = false,
- senderStatusEmojiId: Long = 0L,
- senderStatusEmojiPath: String? = null
- ): MessageModel {
- val networkAutoDownload = isChatOpen && isNetworkAutoDownloadEnabled()
- val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending
-
- val content = when (val c = msg.content) {
- is TdApi.MessageText -> {
- val entities = mapEntities(c.text.entities, msg.chatId, msg.id, networkAutoDownload)
- val webPage = mapWebPage(c.linkPreview, msg.chatId, msg.id, networkAutoDownload)
- MessageContent.Text(c.text.text, entities, webPage)
- }
-
- is TdApi.MessagePhoto -> {
-
- val sizes = c.photo.sizes
- val photoSize = sizes.find { it.type == "x" }
- ?: sizes.find { it.type == "m" }
- ?: sizes.getOrNull(sizes.size / 2)
- ?: sizes.lastOrNull()
- val thumbnailSize = sizes.find { it.type == "m" }
- ?: sizes.find { it.type == "s" }
- ?: sizes.firstOrNull()
-
- val photoFile = photoSize?.photo?.let { getUpdatedFile(it) }
- val thumbnailFile = thumbnailSize?.photo?.let { getUpdatedFile(it) }
-
- val path = findBestAvailablePath(photoFile, sizes)
- val thumbnailPath = resolveLocalFilePath(thumbnailFile)
-
- if (photoFile != null) {
- fileApi.registerFileForMessage(photoFile.id, msg.chatId, msg.id)
- if (path == null && networkAutoDownload) {
- fileApi.enqueueDownload(photoFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- }
- if (thumbnailFile != null) {
- fileApi.registerFileForMessage(thumbnailFile.id, msg.chatId, msg.id)
- if (thumbnailPath == null && networkAutoDownload) {
- fileApi.enqueueDownload(
- thumbnailFile.id,
- 1,
- TdMessageRemoteDataSource.DownloadType.DEFAULT,
- 0,
- 0,
- false
- )
- }
- }
- val isDownloading = photoFile?.local?.isDownloadingActive ?: false
- val isQueued = photoFile?.let { fileApi.isFileQueued(it.id) } ?: false
- val downloadProgress = if ((photoFile?.size ?: 0) > 0) {
- photoFile!!.local.downloadedSize.toFloat() / photoFile.size.toFloat()
- } else 0f
-
- MessageContent.Photo(
- path = path,
- thumbnailPath = thumbnailPath,
- width = photoSize?.width ?: 0,
- height = photoSize?.height ?: 0,
- caption = c.caption.text,
- entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload),
- isUploading = isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false),
- uploadProgress = if ((photoFile?.size ?: 0) > 0) photoFile!!.remote.uploadedSize.toFloat() / photoFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = photoFile?.id ?: 0,
- minithumbnail = c.photo.minithumbnail?.data
- )
- }
-
- is TdApi.MessageVideo -> {
- val video = c.video
- val videoFile = getUpdatedFile(video.video)
- val path = videoFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id)
-
- val thumbFile = video.thumbnail?.file?.let { getUpdatedFile(it) }
- val thumbnailPath = resolveLocalFilePath(thumbFile)
-
- if (thumbFile != null) {
- fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id)
- if (thumbnailPath == null && networkAutoDownload) {
- fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- }
-
- if (path == null && networkAutoDownload && video.supportsStreaming) {
- fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO, 0, 0, false)
- }
-
- val isDownloading = videoFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(videoFile.id)
- val downloadProgress = if (videoFile.size > 0) {
- videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat()
- } else 0f
-
- MessageContent.Video(
- path = path,
- thumbnailPath = thumbnailPath,
- width = video.width,
- height = video.height,
- duration = video.duration,
- caption = c.caption.text,
- entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload),
- isUploading = isActuallyUploading && videoFile.remote.isUploadingActive,
- uploadProgress = if (videoFile.size > 0) videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = videoFile.id,
- minithumbnail = video.minithumbnail?.data,
- supportsStreaming = video.supportsStreaming
- )
- }
-
- is TdApi.MessageVoiceNote -> {
- val voice = c.voiceNote
- val voiceFile = getUpdatedFile(voice.voice)
- val path = voiceFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(voiceFile.id, msg.chatId, msg.id)
- if (path == null && networkAutoDownload) {
- fileApi.enqueueDownload(voiceFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- val isDownloading = voiceFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(voiceFile.id)
- val downloadProgress = if (voiceFile.size > 0) {
- voiceFile.local.downloadedSize.toFloat() / voiceFile.size.toFloat()
- } else 0f
-
- MessageContent.Voice(
- path = path,
- duration = voice.duration,
- waveform = voice.waveform,
- isUploading = isActuallyUploading && voiceFile.remote.isUploadingActive,
- uploadProgress = if (voiceFile.size > 0) voiceFile.remote.uploadedSize.toFloat() / voiceFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = voiceFile.id
- )
- }
-
- is TdApi.MessageVideoNote -> {
- val note = c.videoNote
- val videoFile = getUpdatedFile(note.video)
- val videoPath = videoFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id)
-
- if (videoPath == null && networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) {
- fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, 0, 0, false)
- }
-
- val thumbFile = note.thumbnail?.file?.let { getUpdatedFile(it) }
- val thumbPath = thumbFile?.local?.path?.takeIf { isValidPath(it) }
- if (thumbFile != null) {
- fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id)
- if (thumbPath == null && networkAutoDownload) {
- fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- }
- val isUploading = isActuallyUploading && videoFile.remote.isUploadingActive
- val progress = if (videoFile.size > 0) {
- videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat()
- } else 0f
-
- val isDownloading = videoFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(videoFile.id)
- val downloadProgress = if (videoFile.size > 0) {
- videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat()
- } else 0f
-
- MessageContent.VideoNote(
- path = videoPath,
- thumbnail = thumbPath,
- duration = note.duration,
- length = note.length,
- isUploading = isUploading,
- uploadProgress = progress,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = videoFile.id
- )
- }
-
- is TdApi.MessageSticker -> {
- val sticker = c.sticker
- val stickerFile = getUpdatedFile(sticker.sticker)
- val path = stickerFile.local.path.takeIf { isValidPath(it) }
-
- fileApi.registerFileForMessage(stickerFile.id, msg.chatId, msg.id)
- if (path == null && networkAutoDownload && appPreferences.autoDownloadStickers.value) {
- fileApi.enqueueDownload(stickerFile.id, 1, TdMessageRemoteDataSource.DownloadType.STICKER, 0, 0, false)
- }
-
- val format = when (sticker.format) {
- is TdApi.StickerFormatWebp -> StickerFormat.STATIC
- is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED
- is TdApi.StickerFormatWebm -> StickerFormat.VIDEO
- else -> StickerFormat.UNKNOWN
- }
-
- val isDownloading = stickerFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(stickerFile.id)
- val downloadProgress = if (stickerFile.size > 0) {
- stickerFile.local.downloadedSize.toFloat() / stickerFile.size.toFloat()
- } else 0f
-
- MessageContent.Sticker(
- id = sticker.sticker.id.toLong(),
- setId = sticker.setId,
- path = path,
- width = sticker.width,
- height = sticker.height,
- emoji = sticker.emoji,
- format = format,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = stickerFile.id
- )
- }
-
- is TdApi.MessageAnimation -> {
- val animation = c.animation
- val animationFile = getUpdatedFile(animation.animation)
- val path = animationFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(animationFile.id, msg.chatId, msg.id)
- if (path == null && networkAutoDownload) {
- fileApi.enqueueDownload(animationFile.id, 1, TdMessageRemoteDataSource.DownloadType.GIF, 0, 0, false)
- }
-
- val thumbFile = animation.thumbnail?.file?.let { getUpdatedFile(it) }
- if (thumbFile != null) {
- fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id)
- if (!isValidPath(thumbFile.local.path) && networkAutoDownload) {
- fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- }
-
- val isDownloading = animationFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(animationFile.id)
- val downloadProgress = if (animationFile.size > 0) {
- animationFile.local.downloadedSize.toFloat() / animationFile.size.toFloat()
- } else 0f
-
- MessageContent.Gif(
- path = path,
- width = animation.width,
- height = animation.height,
- caption = c.caption.text,
- entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload),
- isUploading = isActuallyUploading && animationFile.remote.isUploadingActive,
- uploadProgress = if (animationFile.size > 0) animationFile.remote.uploadedSize.toFloat() / animationFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = animationFile.id,
- minithumbnail = animation.minithumbnail?.data
- )
- }
-
- is TdApi.MessageAnimatedEmoji -> MessageContent.Text(c.emoji)
- is TdApi.MessageDice -> {
- val valueStr = if (c.value != 0) " (Result: ${c.value})" else ""
- MessageContent.Text("${c.emoji}$valueStr")
- }
-
- is TdApi.MessageDocument -> {
- val doc = c.document
- val docFile = getUpdatedFile(doc.document)
- val path = docFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(docFile.id, msg.chatId, msg.id)
-
- val thumbFile = doc.thumbnail?.file?.let { getUpdatedFile(it) }
- if (thumbFile != null) {
- fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id)
- if (!isValidPath(thumbFile.local.path) && networkAutoDownload) {
- fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- }
-
- val isDownloading = docFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(docFile.id)
- val downloadProgress = if (docFile.size > 0) {
- docFile.local.downloadedSize.toFloat() / docFile.size.toFloat()
- } else 0f
-
- MessageContent.Document(
- path = path,
- fileName = doc.fileName,
- mimeType = doc.mimeType,
- size = docFile.size,
- caption = c.caption.text,
- entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload),
- isUploading = isActuallyUploading && docFile.remote.isUploadingActive,
- uploadProgress = if (docFile.size > 0) docFile.remote.uploadedSize.toFloat() / docFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = docFile.id
- )
- }
-
- is TdApi.MessageAudio -> {
- val audio = c.audio
- val audioFile = getUpdatedFile(audio.audio)
- val path = audioFile.local.path.takeIf { isValidPath(it) }
- fileApi.registerFileForMessage(audioFile.id, msg.chatId, msg.id)
-
- if (path == null && networkAutoDownload) {
- fileApi.enqueueDownload(
- audioFile.id,
- 1,
- TdMessageRemoteDataSource.DownloadType.DEFAULT,
- 0,
- 0,
- false
- )
- }
-
- val isDownloading = audioFile.local.isDownloadingActive
- val isQueued = fileApi.isFileQueued(audioFile.id)
- val downloadProgress = if (audioFile.size > 0) {
- audioFile.local.downloadedSize.toFloat() / audioFile.size.toFloat()
- } else 0f
-
- MessageContent.Audio(
- path = path,
- duration = audio.duration,
- title = audio.title ?: "Unknown",
- performer = audio.performer ?: "Unknown",
- fileName = audio.fileName ?: "audio.mp3",
- mimeType = audio.mimeType ?: "audio/mpeg",
- size = audioFile.size,
- caption = c.caption.text,
- entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload),
- isUploading = isActuallyUploading && audioFile.remote.isUploadingActive,
- uploadProgress = if (audioFile.size > 0) audioFile.remote.uploadedSize.toFloat() / audioFile.size.toFloat() else 0f,
- isDownloading = isDownloading || isQueued,
- downloadProgress = downloadProgress,
- fileId = audioFile.id
- )
- }
-
- is TdApi.MessageCall -> MessageContent.Text("📞 Call (${c.duration}s)")
- is TdApi.MessageContact -> {
- val contact = c.contact
- MessageContent.Contact(
- phoneNumber = contact.phoneNumber,
- firstName = contact.firstName,
- lastName = contact.lastName,
- vcard = contact.vcard,
- userId = contact.userId
- )
- }
- is TdApi.MessageLocation -> {
- val loc = c.location
- MessageContent.Location(
- latitude = loc.latitude,
- longitude = loc.longitude,
- horizontalAccuracy = loc.horizontalAccuracy,
- livePeriod = c.livePeriod,
- heading = c.heading,
- proximityAlertRadius = c.proximityAlertRadius
- )
- }
-
- is TdApi.MessageVenue -> {
- val v = c.venue
- MessageContent.Venue(
- latitude = v.location.latitude,
- longitude = v.location.longitude,
- title = v.title,
- address = v.address,
- provider = v.provider,
- venueId = v.id,
- venueType = v.type
- )
- }
- is TdApi.MessagePoll -> {
- val poll = c.poll
- val type = when (val pollType = poll.type) {
- is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers)
- is TdApi.PollTypeQuiz -> PollType.Quiz(pollType.correctOptionIds.firstOrNull() ?: -1, pollType.explanation?.text)
- else -> PollType.Regular(poll.allowsMultipleAnswers)
- }
- MessageContent.Poll(
- id = poll.id,
- question = poll.question.text,
- options = poll.options.map { option ->
- PollOption(
- text = option.text.text,
- voterCount = option.voterCount,
- votePercentage = option.votePercentage,
- isChosen = option.isChosen,
- isBeingChosen = false
- )
- },
- totalVoterCount = poll.totalVoterCount,
- isClosed = poll.isClosed,
- isAnonymous = poll.isAnonymous,
- type = type,
- openPeriod = poll.openPeriod,
- closeDate = poll.closeDate
- )
- }
- is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${c.game.title}")
- is TdApi.MessageInvoice -> {
- val productInfo = c.productInfo
- MessageContent.Text("💳 Invoice: ${productInfo.title}")
- }
- is TdApi.MessageStory -> MessageContent.Text("📖 Story")
- is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired")
- is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired")
-
- is TdApi.MessageChatJoinByLink -> MessageContent.Service("$senderName has joined the group via invite link")
- is TdApi.MessageChatAddMembers -> MessageContent.Service("$senderName added members")
- is TdApi.MessageChatDeleteMember -> MessageContent.Service("$senderName left the chat")
- is TdApi.MessagePinMessage -> MessageContent.Service("$senderName pinned a message")
- is TdApi.MessageChatChangeTitle -> MessageContent.Service("$senderName changed group name to \"${c.title}\"")
- is TdApi.MessageChatChangePhoto -> MessageContent.Service("$senderName changed group photo")
- is TdApi.MessageChatDeletePhoto -> MessageContent.Service("$senderName removed group photo")
- is TdApi.MessageScreenshotTaken -> MessageContent.Service("$senderName took a screenshot")
- is TdApi.MessageContactRegistered -> MessageContent.Service("$senderName joined Telegram!")
- is TdApi.MessageChatUpgradeTo -> MessageContent.Service("$senderName upgraded to supergroup")
- is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created")
- is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${c.title}\"")
- is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${c.title}\"")
- is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${c.currency} ${c.totalAmount}")
- is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful")
- is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent")
- is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received")
- is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${c.distance}m")
- is TdApi.MessageForumTopicCreated -> MessageContent.Service("$senderName created topic \"${c.name}\"")
- is TdApi.MessageForumTopicEdited -> MessageContent.Service("$senderName edited topic")
- is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("$senderName toggled topic closed status")
- is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("$senderName toggled topic hidden status")
- is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("$senderName suggested a profile photo")
- is TdApi.MessageCustomServiceAction -> MessageContent.Service(c.text)
- is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${c.boostCount}")
- is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${c.theme}")
- is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${c.score}")
- is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${c.startDate}")
- is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started")
- is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended")
- is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed")
- else -> MessageContent.Text("ℹ️ Unsupported message type: ${c.javaClass.simpleName}")
- }
-
- val isServiceMessage = content is MessageContent.Service
-
- val canEdit = msg.isOutgoing && !isServiceMessage
- val canForward = !isServiceMessage
- val canSave = !isServiceMessage
-
- val hasInteraction = msg.interactionInfo != null
-
- return MessageModel(
- id = msg.id,
- date = resolveMessageDate(msg),
- isOutgoing = msg.isOutgoing,
- senderName = senderName,
- chatId = msg.chatId,
- content = content,
- senderId = senderId,
- senderAvatar = senderAvatar,
- senderPersonalAvatar = senderPersonalAvatar,
- senderCustomTitle = senderCustomTitle,
- isRead = isReadOverride,
- replyToMsgId = replyToMsgId,
- replyToMsg = replyToMsg,
- forwardInfo = forwardInfo,
- views = views,
- viewCount = views,
- mediaAlbumId = msg.mediaAlbumId,
- editDate = msg.editDate,
- sendingState = sendingState,
- readDate = readDate,
- reactions = reactions,
- isSenderVerified = isSenderVerified,
- threadId = threadId,
- replyCount = replyCount,
- canBeEdited = canEdit,
- canBeForwarded = canForward,
- canBeDeletedOnlyForSelf = true,
- canBeDeletedForAllUsers = msg.isOutgoing,
- canBeSaved = canSave,
- canGetMessageThread = msg.interactionInfo?.replyInfo != null,
- canGetStatistics = hasInteraction,
- canGetReadReceipts = hasInteraction,
- canGetViewers = hasInteraction,
- replyMarkup = if (isReply) null else mapReplyMarkup(msg.replyMarkup),
- viaBotUserId = viaBotUserId,
- viaBotName = viaBotName,
- isSenderPremium = isSenderPremium,
- senderStatusEmojiId = senderStatusEmojiId,
- senderStatusEmojiPath = senderStatusEmojiPath
- )
- }
-
- data class CachedMessageContent(
- val type: String,
- val text: String,
- val meta: String?,
- val fileId: Int = 0,
- val path: String? = null,
- val thumbnailPath: String? = null,
- val minithumbnail: ByteArray? = null
- )
-
- private data class CachedReplyPreview(
- val senderName: String,
- val contentType: String,
- val text: String
- )
-
- private data class CachedForwardOrigin(
- val fromName: String,
- val fromId: Long,
- val originChatId: Long? = null,
- val originMessageId: Long? = null
- )
-
- fun mapToEntity(
+ private fun mapMessageToModelFallback(
msg: TdApi.Message,
- getSenderName: ((Long) -> String?)? = null
- ): org.monogram.data.db.model.MessageEntity {
- val senderId = when (val sender = msg.senderId) {
- is TdApi.MessageSenderUser -> sender.userId
- is TdApi.MessageSenderChat -> sender.chatId
- else -> 0L
- }
- val senderName = getSenderName?.invoke(senderId).orEmpty()
- val content = extractCachedContent(msg.content)
- val entitiesEncoded = encodeEntities(msg.content)
- val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L
- val replyToPreview = buildReplyPreview(msg)
- val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin)
-
- return org.monogram.data.db.model.MessageEntity(
- id = msg.id,
- chatId = msg.chatId,
- senderId = senderId,
- senderName = senderName,
- content = content.text,
- contentType = content.type,
- contentMeta = content.meta,
- mediaFileId = content.fileId,
- mediaPath = content.path,
- mediaThumbnailPath = content.thumbnailPath,
- minithumbnail = content.minithumbnail,
- date = resolveMessageDate(msg),
- isOutgoing = msg.isOutgoing,
- isRead = false,
- replyToMessageId = replyToMessageId,
- replyToPreview = replyToPreview?.let(::encodeReplyPreview),
- replyToPreviewType = replyToPreview?.contentType,
- replyToPreviewText = replyToPreview?.text,
- replyToPreviewSenderName = replyToPreview?.senderName,
- replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0,
- forwardFromName = forwardOrigin?.fromName,
- forwardFromId = forwardOrigin?.fromId ?: 0L,
- forwardOriginChatId = forwardOrigin?.originChatId,
- forwardOriginMessageId = forwardOrigin?.originMessageId,
- forwardDate = msg.forwardInfo?.date ?: 0,
- editDate = msg.editDate,
- mediaAlbumId = msg.mediaAlbumId,
- entities = entitiesEncoded,
- viewCount = msg.interactionInfo?.viewCount ?: 0,
- forwardCount = msg.interactionInfo?.forwardCount ?: 0,
- createdAt = System.currentTimeMillis()
- )
- }
-
- fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent {
- return when (content) {
- is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null)
- is TdApi.MessagePhoto -> {
- val sizes = content.photo.sizes
- val best = sizes.find { it.type == "x" }
- ?: sizes.find { it.type == "m" }
- ?: sizes.getOrNull(sizes.size / 2)
- ?: sizes.lastOrNull()
- val thumbnail = sizes.find { it.type == "m" }
- ?: sizes.find { it.type == "s" }
- val fileId = best?.photo?.id ?: 0
- val path = best?.photo?.local?.path?.takeIf { it.isNotBlank() }
- val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { it.isNotBlank() }
- CachedMessageContent(
- "photo",
- content.caption?.text.orEmpty(),
- encodeMeta(best?.width ?: 0, best?.height ?: 0),
- fileId = fileId,
- path = path,
- thumbnailPath = thumbnailPath,
- minithumbnail = content.photo.minithumbnail?.data
- )
- }
-
- is TdApi.MessageVideo -> {
- val fileId = content.video.video.id
- val path = content.video.video.local?.path?.takeIf { it.isNotBlank() }
- CachedMessageContent(
- "video",
- content.caption?.text.orEmpty(),
- encodeMeta(
- content.video.width,
- content.video.height,
- content.video.duration,
- content.video.thumbnail?.file?.local?.path.orEmpty(),
- if (content.video.supportsStreaming) 1 else 0
- ),
- fileId = fileId,
- path = path,
- thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { it.isNotBlank() },
- minithumbnail = content.video.minithumbnail?.data
- )
- }
-
- is TdApi.MessageVoiceNote -> CachedMessageContent(
- "voice",
- content.caption?.text.orEmpty(),
- encodeMeta(content.voiceNote.duration),
- fileId = content.voiceNote.voice.id,
- path = content.voiceNote.voice.local?.path?.takeIf { it.isNotBlank() }
- )
-
- is TdApi.MessageVideoNote -> CachedMessageContent(
- "video_note",
- "",
- encodeMeta(
- content.videoNote.duration,
- content.videoNote.length,
- content.videoNote.thumbnail?.file?.local?.path.orEmpty()
- ),
- fileId = content.videoNote.video.id,
- path = content.videoNote.video.local?.path?.takeIf { it.isNotBlank() }
- )
-
- is TdApi.MessageSticker -> {
- val format = when (content.sticker.format) {
- is TdApi.StickerFormatWebp -> "webp"
- is TdApi.StickerFormatTgs -> "tgs"
- is TdApi.StickerFormatWebm -> "webm"
- else -> "unknown"
- }
- CachedMessageContent(
- "sticker",
- content.sticker.emoji,
- encodeMeta(
- content.sticker.setId,
- content.sticker.emoji,
- content.sticker.width,
- content.sticker.height,
- format
- ),
- fileId = content.sticker.sticker.id,
- path = content.sticker.sticker.local?.path?.takeIf { it.isNotBlank() }
- )
- }
-
- is TdApi.MessageDocument -> CachedMessageContent(
- "document",
- content.caption?.text.orEmpty(),
- encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size),
- fileId = content.document.document.id,
- path = content.document.document.local?.path?.takeIf { it.isNotBlank() }
- )
-
- is TdApi.MessageAudio -> CachedMessageContent(
- "audio",
- content.caption?.text.orEmpty(),
- encodeMeta(
- content.audio.duration,
- content.audio.title.orEmpty(),
- content.audio.performer.orEmpty(),
- content.audio.fileName.orEmpty()
- ),
- fileId = content.audio.audio.id,
- path = content.audio.audio.local?.path?.takeIf { it.isNotBlank() }
- )
-
- is TdApi.MessageAnimation -> CachedMessageContent(
- "gif",
- content.caption?.text.orEmpty(),
- encodeMeta(
- content.animation.width,
- content.animation.height,
- content.animation.duration,
- content.animation.thumbnail?.file?.local?.path.orEmpty()
- ),
- fileId = content.animation.animation.id,
- path = content.animation.animation.local?.path?.takeIf { it.isNotBlank() }
- )
-
- is TdApi.MessagePoll -> CachedMessageContent(
- "poll",
- content.poll.question.text,
- encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0)
- )
-
- is TdApi.MessageContact -> CachedMessageContent(
- "contact",
- listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() }
- .joinToString(" "),
- encodeMeta(
- content.contact.phoneNumber,
- content.contact.firstName,
- content.contact.lastName,
- content.contact.userId
- )
- )
-
- is TdApi.MessageLocation -> CachedMessageContent(
- "location",
- "",
- encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod)
- )
-
- is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null)
- is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null)
- is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null)
- is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null)
- is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null)
- is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null)
- is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null)
- else -> CachedMessageContent("unsupported", "", null)
- }
- }
-
- fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel {
- val meta = decodeMeta(entity.contentMeta)
- val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank()
- val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) {
- resolveLegacyMediaFromMeta(entity.contentType, meta)
- } else {
- 0 to null
- }
- val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId
- val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath
- val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L }
- val replyPreview = resolveReplyPreview(entity)
- val replyPreviewModel =
- if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel(entity, replyToMsgId, replyPreview) else null
-
- val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) }
- val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) {
- cache.getChat(entity.senderId)
- } else {
- null
- }
-
- val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName)
- val resolvedSenderAvatar = when {
- cachedSenderUser != null -> resolveLocalFilePath(cachedSenderUser.profilePhoto?.small)
- cachedSenderChat != null -> resolveLocalFilePath(cachedSenderChat.photo?.small)
- else -> null
- }
- val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId)
- ?.personalPhoto
- ?.sizes
- ?.firstOrNull()
- ?.photo
- ?.let { resolveLocalFilePath(it) }
-
- val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) {
- is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId
- is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId
- else -> 0L
- }
-
- val forwardInfo = entity.forwardFromName
- ?.takeIf { it.isNotBlank() }
- ?.let { fromName ->
- ForwardInfo(
- date = entity.forwardDate.takeIf { it > 0 } ?: entity.date,
- fromId = entity.forwardFromId,
- fromName = fromName,
- originChatId = entity.forwardOriginChatId,
- originMessageId = entity.forwardOriginMessageId
- )
- }
-
- val content: MessageContent = when (entity.contentType) {
- "text" -> MessageContent.Text(entity.content)
-
- "photo" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Photo(
- path = resolveCachedPath(fileId, mediaPath),
- thumbnailPath = entity.mediaThumbnailPath?.takeIf { isValidPath(it) },
- width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
- caption = entity.content,
- fileId = fileId,
- minithumbnail = entity.minithumbnail
- )
- }
-
- "video" -> {
- val fileId = mediaFileId
- val supportsStreaming = if (usesLegacyEmbeddedMedia) {
- (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1
- } else {
- (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1
- }
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Video(
- path = resolveCachedPath(fileId, mediaPath),
- thumbnailPath = (
- entity.mediaThumbnailPath?.takeIf { isValidPath(it) }
- ?: meta.getOrNull(3)
- )?.takeIf { isValidPath(it) },
- width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
- duration = meta.getOrNull(2)?.toIntOrNull() ?: 0,
- caption = entity.content,
- fileId = fileId,
- supportsStreaming = supportsStreaming,
- minithumbnail = entity.minithumbnail
- )
- }
-
- "voice" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Voice(
- path = resolveCachedPath(fileId, mediaPath),
- duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- fileId = fileId
- )
- }
-
- "video_note" -> {
- val fileId = mediaFileId
- val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2)
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.VideoNote(
- path = resolveCachedPath(fileId, mediaPath),
- thumbnail = storedThumbPath?.takeIf { isValidPath(it) },
- duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- length = meta.getOrNull(1)?.toIntOrNull() ?: 0,
- fileId = fileId
- )
- }
-
- "sticker" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Sticker(
- id = 0L,
- setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L,
- path = resolveCachedPath(fileId, mediaPath),
- width = meta.getOrNull(2)?.toIntOrNull() ?: 0,
- height = meta.getOrNull(3)?.toIntOrNull() ?: 0,
- emoji = entity.content,
- fileId = fileId
- )
- }
-
- "document" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Document(
- path = resolveCachedPath(fileId, mediaPath),
- fileName = meta.getOrNull(0).orEmpty(),
- mimeType = meta.getOrNull(1).orEmpty(),
- size = meta.getOrNull(2)?.toLongOrNull() ?: 0L,
- caption = entity.content,
- fileId = fileId
- )
- }
-
- "audio" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Audio(
- path = resolveCachedPath(fileId, mediaPath),
- duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- title = meta.getOrNull(1).orEmpty(),
- performer = meta.getOrNull(2).orEmpty(),
- fileName = meta.getOrNull(3).orEmpty(),
- mimeType = "",
- size = 0L,
- caption = entity.content,
- fileId = fileId
- )
- }
-
- "gif" -> {
- val fileId = mediaFileId
- registerCachedFile(fileId, entity.chatId, entity.id)
- MessageContent.Gif(
- path = resolveCachedPath(fileId, mediaPath),
- width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
- height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
- caption = entity.content,
- fileId = fileId
- )
- }
-
- "poll" -> MessageContent.Poll(
- id = 0L,
- question = entity.content,
- options = emptyList(),
- totalVoterCount = 0,
- isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1,
- isAnonymous = true,
- type = PollType.Regular(false),
- openPeriod = 0,
- closeDate = 0
- )
-
- "contact" -> MessageContent.Contact(
- phoneNumber = meta.getOrNull(0).orEmpty(),
- firstName = meta.getOrNull(1).orEmpty(),
- lastName = meta.getOrNull(2).orEmpty(),
- vcard = "",
- userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L
- )
-
- "location" -> MessageContent.Location(
- latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0,
- longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0,
- livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0
- )
-
- "service" -> MessageContent.Service(entity.content)
- else -> MessageContent.Text(entity.content)
- }
-
- return MessageModel(
- id = entity.id,
- date = entity.date,
- isOutgoing = entity.isOutgoing,
- senderName = resolvedSenderName,
- chatId = entity.chatId,
- content = content,
- senderId = entity.senderId,
- senderAvatar = resolvedSenderAvatar,
- senderPersonalAvatar = resolvedSenderPersonalAvatar,
- isRead = entity.isRead,
- replyToMsgId = replyToMsgId,
- replyToMsg = replyPreviewModel,
- forwardInfo = forwardInfo,
- mediaAlbumId = entity.mediaAlbumId,
- editDate = entity.editDate,
- views = entity.viewCount,
- viewCount = entity.viewCount,
- replyCount = entity.replyCount,
- isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false,
- isSenderPremium = cachedSenderUser?.isPremium ?: false,
- senderStatusEmojiId = senderStatusEmojiId
- )
- }
-
- private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? {
- val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null
- val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null
- val replySenderName = when (val sender = replied.senderId) {
- is TdApi.MessageSenderUser -> {
- val user = cache.getUser(sender.userId)
- listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() })
- .joinToString(" ")
- }
-
- is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty()
- else -> ""
- }
- val extracted = extractCachedContent(replied.content)
- return CachedReplyPreview(
- senderName = replySenderName,
- contentType = extracted.type,
- text = extracted.text.take(100)
- )
- }
-
- private fun encodeReplyPreview(preview: CachedReplyPreview): String {
- return "${preview.senderName}|${preview.contentType}|${preview.text}"
- }
-
- private fun parseReplyPreview(raw: String?): CachedReplyPreview? {
- if (raw.isNullOrBlank()) return null
- val firstSeparator = raw.indexOf('|')
- val secondSeparator = raw.indexOf('|', firstSeparator + 1)
- if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null
-
- val senderName = raw.substring(0, firstSeparator)
- val contentType = raw.substring(firstSeparator + 1, secondSeparator)
- val text = raw.substring(secondSeparator + 1)
- if (contentType.isBlank()) return null
-
- return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text)
- }
-
- private fun resolveReplyPreview(entity: org.monogram.data.db.model.MessageEntity): CachedReplyPreview? {
- val encodedPreview = parseReplyPreview(entity.replyToPreview)
- val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName
- val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType
- val text = entity.replyToPreviewText ?: encodedPreview?.text ?: ""
-
- if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) {
- return null
- }
-
- return CachedReplyPreview(
- senderName = senderName.orEmpty(),
- contentType = contentType?.ifBlank { "text" } ?: "text",
- text = text
- )
- }
-
- private fun createReplyPreviewModel(
- entity: org.monogram.data.db.model.MessageEntity,
- replyToMsgId: Long,
- preview: CachedReplyPreview
+ isChatOpen: Boolean,
+ isReply: Boolean
): MessageModel {
- return MessageModel(
- id = replyToMsgId,
- date = entity.date,
- isOutgoing = false,
- senderName = preview.senderName.ifBlank { "Unknown" },
- chatId = entity.chatId,
- content = mapReplyPreviewContent(preview),
- senderId = 0L,
- isRead = true
+ val sender = senderResolver.resolveFallbackSender(msg)
+ return createMessageModel(
+ msg = msg,
+ senderName = sender.senderName,
+ senderId = sender.senderId,
+ senderAvatar = sender.senderAvatar,
+ isChatOpen = isChatOpen,
+ isReply = isReply,
+ senderPersonalAvatar = sender.senderPersonalAvatar,
+ senderCustomTitle = sender.senderCustomTitle,
+ isSenderVerified = sender.isSenderVerified,
+ isSenderPremium = sender.isSenderPremium,
+ senderStatusEmojiId = sender.senderStatusEmojiId,
+ senderStatusEmojiPath = sender.senderStatusEmojiPath
)
}
- private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent {
- return when (preview.contentType) {
- "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text)
- "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text)
- "voice" -> MessageContent.Voice(path = null, duration = 0)
- "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0)
- "sticker" -> MessageContent.Sticker(id = 0L, setId = 0L, path = null, width = 0, height = 0, emoji = preview.text)
- "document" -> MessageContent.Document(path = null, fileName = "", mimeType = "", size = 0L, caption = preview.text)
- "audio" -> MessageContent.Audio(
- path = null,
- duration = 0,
- title = "",
- performer = "",
- fileName = "",
- mimeType = "",
- size = 0L,
- caption = preview.text
- )
- "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text)
- "poll" -> MessageContent.Poll(
- id = 0L,
- question = preview.text,
- options = emptyList(),
- totalVoterCount = 0,
- isClosed = false,
- isAnonymous = true,
- type = PollType.Regular(false),
- openPeriod = 0,
- closeDate = 0
- )
- "contact" -> MessageContent.Contact(
- phoneNumber = "",
- firstName = preview.text,
- lastName = "",
- vcard = "",
- userId = 0L
- )
- "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0)
- "service" -> MessageContent.Service(preview.text)
- else -> MessageContent.Text(preview.text)
- }
- }
-
- private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin {
- return when (origin) {
- is TdApi.MessageOriginUser -> {
- val user = cache.getUser(origin.senderUserId)
- val name = listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() })
- .joinToString(" ")
- .ifBlank { "User" }
- CachedForwardOrigin(fromName = name, fromId = origin.senderUserId)
- }
-
- is TdApi.MessageOriginChat -> CachedForwardOrigin(
- fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat",
- fromId = origin.senderChatId
- )
-
- is TdApi.MessageOriginChannel -> CachedForwardOrigin(
- fromName = cache.getChat(origin.chatId)?.title ?: "Channel",
- fromId = origin.chatId,
- originChatId = origin.chatId,
- originMessageId = origin.messageId
- )
-
- is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin(
- fromName = origin.senderName.ifBlank { "Hidden user" },
- fromId = 0L
- )
-
- else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L)
- }
- }
-
- private fun encodeEntities(content: TdApi.MessageContent): String? {
- val formatted = when (content) {
- is TdApi.MessageText -> content.text
- is TdApi.MessagePhoto -> content.caption
- is TdApi.MessageVideo -> content.caption
- is TdApi.MessageDocument -> content.caption
- is TdApi.MessageAudio -> content.caption
- is TdApi.MessageAnimation -> content.caption
- is TdApi.MessageVoiceNote -> content.caption
- else -> null
- } ?: return null
-
- if (formatted.entities.isNullOrEmpty()) return null
-
- return buildString {
- formatted.entities.forEachIndexed { index, entity ->
- if (index > 0) append('|')
- append(entity.offset).append(',').append(entity.length).append(',')
- when (val type = entity.type) {
- is TdApi.TextEntityTypeBold -> append("b")
- is TdApi.TextEntityTypeItalic -> append("i")
- is TdApi.TextEntityTypeUnderline -> append("u")
- is TdApi.TextEntityTypeStrikethrough -> append("s")
- is TdApi.TextEntityTypeSpoiler -> append("sp")
- is TdApi.TextEntityTypeCode -> append("c")
- is TdApi.TextEntityTypePre -> append("p")
- is TdApi.TextEntityTypeUrl -> append("url")
- is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url)
- is TdApi.TextEntityTypeMention -> append("m")
- is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId)
- is TdApi.TextEntityTypeHashtag -> append("h")
- is TdApi.TextEntityTypeBotCommand -> append("bc")
- is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId)
- is TdApi.TextEntityTypeEmailAddress -> append("em")
- is TdApi.TextEntityTypePhoneNumber -> append("ph")
- else -> append("?")
- }
- }
- }
- }
-
- private fun resolveMessageDate(msg: TdApi.Message): Int {
- return when (val schedulingState = msg.schedulingState) {
- is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate
- else -> msg.date
- }
- }
-
- private suspend fun loadCustomEmoji(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) {
- val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId)))
-
- if (result is TdApi.Stickers && result.stickers.isNotEmpty()) {
- val fileToUse = result.stickers.first().sticker
-
- fileIdToCustomEmojiId[fileToUse.id] = emojiId
- fileApi.registerFileForMessage(fileToUse.id, chatId, messageId)
-
- if (!isValidPath(fileToUse.local.path)) {
- if (autoDownload) {
- fileApi.enqueueDownload(fileToUse.id, 32, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false)
- }
- } else {
- customEmojiPaths[emojiId] = fileToUse.local.path
- }
- }
+ 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
new file mode 100644
index 00000000..c173a813
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt
@@ -0,0 +1,72 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.*
+
+internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? {
+ return when (this) {
+ is TdApi.ReplyMarkupInlineKeyboard -> {
+ ReplyMarkupModel.InlineKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ InlineKeyboardButtonModel(
+ text = button.text,
+ type = when (val type = button.type) {
+ is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url(type.url)
+ is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback(type.data)
+ is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp(type.url)
+ is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl(
+ type.url,
+ type.id
+ )
+
+ is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline(
+ query = type.query
+ )
+
+ is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy()
+ is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User(type.userId)
+ else -> InlineKeyboardButtonType.Unsupported
+ }
+ )
+ }
+ }
+ )
+ }
+
+ is TdApi.ReplyMarkupShowKeyboard -> {
+ ReplyMarkupModel.ShowKeyboard(
+ rows = rows.map { row ->
+ row.map { button ->
+ KeyboardButtonModel(
+ text = button.text,
+ type = when (val type = button.type) {
+ is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text
+ is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber
+ is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation
+ is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll(
+ type.forceQuiz,
+ type.forceRegular
+ )
+
+ is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp(type.url)
+ is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers(type.id)
+ is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat(type.id)
+ else -> KeyboardButtonType.Unsupported
+ }
+ )
+ }
+ },
+ isPersistent = isPersistent,
+ resizeKeyboard = resizeKeyboard,
+ oneTime = oneTime,
+ isPersonal = isPersonal,
+ inputFieldPlaceholder = inputFieldPlaceholder
+ )
+ }
+
+ is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(isPersonal)
+ is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(isPersonal, inputFieldPlaceholder)
+ else -> null
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt
new file mode 100644
index 00000000..72534374
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt
@@ -0,0 +1,15 @@
+package org.monogram.data.mapper
+
+internal object SenderNameResolver {
+ fun fromPartsOrBlank(firstName: String?, lastName: String?): String {
+ return listOfNotNull(
+ firstName?.takeIf { it.isNotBlank() },
+ lastName?.takeIf { it.isNotBlank() }
+ ).joinToString(" ")
+ }
+
+ fun fromParts(firstName: String?, lastName: String?, fallback: String = "User"): String {
+ val normalizedFallback = fallback.ifBlank { "User" }
+ return fromPartsOrBlank(firstName, lastName).ifBlank { normalizedFallback }
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt
index 5ff71c4e..e9fc03d1 100644
--- a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt
@@ -12,7 +12,7 @@ fun TdApi.Sticker.toDomain(): StickerModel = StickerModel(
width = width,
height = height,
emoji = emoji,
- path = sticker.local.path.ifEmpty { null },
+ path = sticker.local.path.takeIf { isValidFilePath(it) },
format = format.toDomain()
)
@@ -27,7 +27,7 @@ fun TdApi.StickerSet.toDomain(): StickerSetModel = StickerSetModel(
width = thumb.width,
height = thumb.height,
emoji = "",
- path = thumb.file.local.path.ifEmpty { null },
+ path = thumb.file.local.path.takeIf { isValidFilePath(it) },
format = stickers.firstOrNull()?.format.toDomain()
)
},
diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt
new file mode 100644
index 00000000..8895912e
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt
@@ -0,0 +1,123 @@
+package org.monogram.data.mapper
+
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.chats.ChatCache
+import org.monogram.data.datasource.remote.MessageFileApi
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.domain.repository.AppPreferencesProvider
+
+class TdFileHelper(
+ private val connectivityManager: ConnectivityManager,
+ private val fileApi: MessageFileApi,
+ private val appPreferences: AppPreferencesProvider,
+ private val cache: ChatCache
+) {
+ fun isValidPath(path: String?): Boolean {
+ return isValidFilePath(path)
+ }
+
+ fun getUpdatedFile(file: TdApi.File): TdApi.File {
+ return cache.fileCache[file.id] ?: file
+ }
+
+ fun resolveLocalFilePath(file: TdApi.File?): String? {
+ if (file == null) return null
+ val directPath = file.local.path.takeIf { isValidPath(it) }
+ if (directPath != null) return directPath
+ return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) }
+ }
+
+ fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? {
+ if (mainFile != null && isValidPath(mainFile.local.path)) {
+ return mainFile.local.path
+ }
+
+ if (sizes != null) {
+ return sizes.sortedByDescending { it.width }
+ .map { getUpdatedFile(it.photo) }
+ .firstOrNull { isValidPath(it.local.path) }
+ ?.local?.path
+ }
+
+ return null
+ }
+
+ fun resolveCachedPath(fileId: Int, storedPath: String?): String? {
+ val fromCache = fileId.takeIf { it != 0 }
+ ?.let { cache.fileCache[it]?.local?.path }
+ ?.takeIf { isValidPath(it) }
+ if (fromCache != null) return fromCache
+
+ val fromStored = storedPath
+ ?.takeIf { it.isNotBlank() }
+ ?.takeIf { isValidPath(it) }
+ if (fromStored != null) return fromStored
+
+ return null
+ }
+
+ fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) {
+ if (fileId != 0) {
+ fileApi.registerFileForMessage(fileId, chatId, messageId)
+ }
+ }
+
+ fun enqueueDownload(
+ fileId: Int,
+ priority: Int,
+ downloadType: TdMessageRemoteDataSource.DownloadType,
+ offset: Int = 0,
+ limit: Int = 0,
+ synchronous: Boolean = false
+ ) {
+ fileApi.enqueueDownload(fileId, priority, downloadType, offset.toLong(), limit.toLong(), synchronous)
+ }
+
+ fun isFileQueued(fileId: Int): Boolean = fileApi.isFileQueued(fileId)
+
+ fun computeDownloadProgress(file: TdApi.File): Float {
+ return if (file.size > 0) {
+ file.local.downloadedSize.toFloat() / file.size.toFloat()
+ } else {
+ 0f
+ }
+ }
+
+ fun computeUploadProgress(file: TdApi.File): Float {
+ return if (file.size > 0) {
+ file.remote.uploadedSize.toFloat() / file.size.toFloat()
+ } else {
+ 0f
+ }
+ }
+
+ fun isNetworkAutoDownloadEnabled(): Boolean {
+ return when (getCurrentNetworkType()) {
+ is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value
+ is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value
+ is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value
+ else -> appPreferences.autoDownloadWifi.value
+ }
+ }
+
+ private fun getCurrentNetworkType(): TdApi.NetworkType {
+ val activeNetwork = connectivityManager.activeNetwork
+ val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
+
+ return when {
+ capabilities == null -> TdApi.NetworkTypeNone()
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi()
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
+ if (connectivityManager.isDefaultNetworkActive && !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)) {
+ TdApi.NetworkTypeMobileRoaming()
+ } else {
+ TdApi.NetworkTypeMobile()
+ }
+ }
+
+ else -> TdApi.NetworkTypeNone()
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt
new file mode 100644
index 00000000..debacc5f
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt
@@ -0,0 +1,58 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageEntityType
+
+internal fun TdApi.TextEntity.toMessageEntityOrNull(
+ mapUnsupportedToOther: Boolean = false,
+ mentionNameAsMention: Boolean = false,
+ customEmojiPathResolver: ((Long) -> String?)? = null,
+ onMissingCustomEmoji: ((Long) -> Unit)? = null
+): MessageEntity? {
+ val mappedType = when (val entityType = 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 -> {
+ if (mentionNameAsMention) {
+ MessageEntityType.Mention
+ } else {
+ 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.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote
+ is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable
+ is TdApi.TextEntityTypeCustomEmoji -> {
+ val path = customEmojiPathResolver?.invoke(entityType.customEmojiId)
+ if (path == null) {
+ onMissingCustomEmoji?.invoke(entityType.customEmojiId)
+ }
+ MessageEntityType.CustomEmoji(entityType.customEmojiId, path)
+ }
+
+ else -> {
+ if (mapUnsupportedToOther) {
+ MessageEntityType.Other(entityType.javaClass.simpleName)
+ } else {
+ null
+ }
+ }
+ } ?: return null
+
+ return MessageEntity(offset = offset, length = length, type = mappedType)
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt
index 906d43db..b8804329 100644
--- a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt
@@ -2,7 +2,6 @@ package org.monogram.data.mapper
import org.drinkless.tdlib.TdApi
import org.monogram.domain.models.MessageEntity
-import org.monogram.domain.models.MessageEntityType
import org.monogram.domain.models.RichText
import org.monogram.domain.models.UpdateInfo
@@ -45,27 +44,7 @@ fun TdApi.FormattedText.toChangelog(): List {
}
fun TdApi.TextEntity.toDomain(): MessageEntity? {
- val type = when (val t = this.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.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(t.url)
- is TdApi.TextEntityTypeMention -> MessageEntityType.Mention
- is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(t.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(t.customEmojiId)
- else -> return null
- }
- return MessageEntity(this.offset, this.length, type)
+ return toMessageEntityOrNull()
}
fun TdApi.MessageDocument.toUpdateInfo(): UpdateInfo? {
diff --git a/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt
new file mode 100644
index 00000000..fd101f1d
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt
@@ -0,0 +1,69 @@
+package org.monogram.data.mapper
+
+import android.text.format.DateUtils
+import org.drinkless.tdlib.TdApi
+import org.monogram.core.date.DateFormatManager
+import org.monogram.domain.repository.StringProvider
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+internal fun formatChatUserStatus(
+ status: TdApi.UserStatus,
+ stringProvider: StringProvider,
+ dateFormatManager: DateFormatManager,
+ isBot: Boolean = false
+): String {
+ if (isBot) return stringProvider.getString("chat_mapper_bot")
+ return when (status) {
+ is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online")
+ is TdApi.UserStatusOffline -> {
+ val wasOnline = status.wasOnline.toLong() * 1000L
+ if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline")
+ val now = System.currentTimeMillis()
+ val diff = now - wasOnline
+ when {
+ diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now")
+ diff < 60 * 60 * 1000 -> {
+ val minutes = diff / (60 * 1000L)
+ if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1)
+ else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes)
+ }
+
+ DateUtils.isToday(wasOnline) -> {
+ val date = Date(wasOnline)
+ val format = SimpleDateFormat(
+ dateFormatManager.getHourMinuteFormat(),
+ Locale.getDefault()
+ )
+ stringProvider.getString("chat_mapper_seen_at", format.format(date))
+ }
+
+ isYesterday(wasOnline) -> {
+ val date = Date(wasOnline)
+ val format = SimpleDateFormat(
+ dateFormatManager.getHourMinuteFormat(),
+ Locale.getDefault()
+ )
+ stringProvider.getString("chat_mapper_seen_yesterday", format.format(date))
+ }
+
+ else -> {
+ val date = Date(wasOnline)
+ val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault())
+ stringProvider.getString("chat_mapper_seen_date", format.format(date))
+ }
+ }
+ }
+
+ is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently")
+ is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week")
+ is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month")
+ is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline")
+ else -> ""
+ }
+}
+
+private fun isYesterday(timestamp: Long): Boolean {
+ return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS)
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt
index d4bfee65..fa2890f1 100644
--- a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt
@@ -1,9 +1,11 @@
package org.monogram.data.mapper
import org.drinkless.tdlib.TdApi
+import org.monogram.data.db.model.WallpaperEntity
import org.monogram.domain.models.ThumbnailModel
import org.monogram.domain.models.WallpaperModel
import org.monogram.domain.models.WallpaperSettings
+import org.monogram.domain.models.WallpaperType
fun mapBackgrounds(backgrounds: Array): List {
val defaultWallpapers = listOf(
@@ -11,6 +13,7 @@ fun mapBackgrounds(backgrounds: Array): List {
id = -1,
slug = "default_blue",
title = "Default Blue",
+ type = WallpaperType.FILL,
pattern = false,
documentId = 0,
thumbnail = null,
@@ -21,8 +24,11 @@ fun mapBackgrounds(backgrounds: Array): List {
fourthBackgroundColor = null,
intensity = null,
rotation = 45,
- isInverted = null
+ isInverted = null,
+ isMoving = null,
+ isBlurred = null
),
+ themeName = null,
isDownloaded = true,
localPath = null,
isDefault = true
@@ -38,16 +44,86 @@ fun TdApi.Background.toDomain(): WallpaperModel {
id = this.id,
slug = this.name,
title = this.name,
+ type = this.type.toWallpaperType(),
pattern = this.type is TdApi.BackgroundTypePattern,
documentId = doc?.document?.id?.toLong() ?: 0L,
thumbnail = doc?.thumbnail?.toDomain(),
settings = this.type.toWallpaperSettings(),
+ themeName = this.type.toThemeName(),
isDownloaded = file?.local?.isDownloadingCompleted == true,
localPath = file?.local?.path?.ifEmpty { null },
isDefault = this.isDefault
)
}
+fun WallpaperModel.toEntity(): WallpaperEntity = WallpaperEntity(
+ id = id,
+ slug = slug,
+ title = title,
+ type = type.name,
+ pattern = pattern,
+ documentId = documentId,
+ thumbnailFileId = thumbnail?.fileId,
+ thumbnailWidth = thumbnail?.width,
+ thumbnailHeight = thumbnail?.height,
+ thumbnailLocalPath = thumbnail?.localPath,
+ backgroundColor = settings?.backgroundColor,
+ secondBackgroundColor = settings?.secondBackgroundColor,
+ thirdBackgroundColor = settings?.thirdBackgroundColor,
+ fourthBackgroundColor = settings?.fourthBackgroundColor,
+ intensity = settings?.intensity,
+ rotation = settings?.rotation,
+ isInverted = settings?.isInverted,
+ settingsIsMoving = settings?.isMoving,
+ settingsIsBlurred = settings?.isBlurred,
+ themeName = themeName,
+ isDownloaded = isDownloaded,
+ localPath = localPath,
+ isDefault = isDefault
+)
+
+fun WallpaperEntity.toDomain(): WallpaperModel = WallpaperModel(
+ id = id,
+ slug = slug,
+ title = title,
+ type = runCatching { WallpaperType.valueOf(type) }.getOrDefault(WallpaperType.WALLPAPER),
+ pattern = pattern,
+ documentId = documentId,
+ thumbnail = thumbnailFileId?.let {
+ ThumbnailModel(
+ fileId = it,
+ width = thumbnailWidth ?: 0,
+ height = thumbnailHeight ?: 0,
+ localPath = thumbnailLocalPath
+ )
+ },
+ settings = WallpaperSettings(
+ backgroundColor = backgroundColor,
+ secondBackgroundColor = secondBackgroundColor,
+ thirdBackgroundColor = thirdBackgroundColor,
+ fourthBackgroundColor = fourthBackgroundColor,
+ intensity = intensity,
+ rotation = rotation,
+ isInverted = isInverted,
+ isMoving = settingsIsMoving,
+ isBlurred = settingsIsBlurred
+ ).takeIf {
+ backgroundColor != null ||
+ secondBackgroundColor != null ||
+ thirdBackgroundColor != null ||
+ fourthBackgroundColor != null ||
+ intensity != null ||
+ rotation != null ||
+ isInverted != null ||
+ settingsIsMoving != null ||
+ settingsIsBlurred != null
+ },
+ themeName = themeName,
+ isDownloaded = isDownloaded,
+ localPath = localPath,
+ isDefault = isDefault
+)
+
fun TdApi.Thumbnail.toDomain(): ThumbnailModel = ThumbnailModel(
fileId = this.file.id,
width = this.width,
@@ -55,10 +131,36 @@ fun TdApi.Thumbnail.toDomain(): ThumbnailModel = ThumbnailModel(
localPath = this.file.local.path
)
+fun TdApi.BackgroundType.toWallpaperType(): WallpaperType = when (this) {
+ is TdApi.BackgroundTypeWallpaper -> WallpaperType.WALLPAPER
+ is TdApi.BackgroundTypePattern -> WallpaperType.PATTERN
+ is TdApi.BackgroundTypeFill -> WallpaperType.FILL
+ is TdApi.BackgroundTypeChatTheme -> WallpaperType.CHAT_THEME
+ else -> WallpaperType.WALLPAPER
+}
+
+fun TdApi.BackgroundType.toThemeName(): String? = when (this) {
+ is TdApi.BackgroundTypeChatTheme -> themeName
+ else -> null
+}
+
fun TdApi.BackgroundType.toWallpaperSettings(): WallpaperSettings? = when (this) {
is TdApi.BackgroundTypePattern -> fill.toWallpaperSettings()
- ?.copy(intensity = intensity, isInverted = isInverted)
+ ?.copy(intensity = intensity, isInverted = isInverted, isMoving = isMoving)
is TdApi.BackgroundTypeFill -> fill.toWallpaperSettings()
+ is TdApi.BackgroundTypeWallpaper -> WallpaperSettings(
+ backgroundColor = null,
+ secondBackgroundColor = null,
+ thirdBackgroundColor = null,
+ fourthBackgroundColor = null,
+ intensity = null,
+ rotation = null,
+ isInverted = null,
+ isMoving = isMoving,
+ isBlurred = isBlurred
+ )
+
+ is TdApi.BackgroundTypeChatTheme -> null
else -> null
}
@@ -70,7 +172,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this)
fourthBackgroundColor = null,
intensity = null,
rotation = null,
- isInverted = null
+ isInverted = null,
+ isMoving = null,
+ isBlurred = null
)
is TdApi.BackgroundFillGradient -> WallpaperSettings(
backgroundColor = topColor,
@@ -79,7 +183,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this)
fourthBackgroundColor = null,
intensity = null,
rotation = rotationAngle,
- isInverted = null
+ isInverted = null,
+ isMoving = null,
+ isBlurred = null
)
is TdApi.BackgroundFillFreeformGradient -> WallpaperSettings(
backgroundColor = colors.getOrNull(0),
@@ -88,7 +194,83 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this)
fourthBackgroundColor = colors.getOrNull(3),
intensity = null,
rotation = null,
- isInverted = null
+ isInverted = null,
+ isMoving = null,
+ isBlurred = null
)
else -> null
-}
\ No newline at end of file
+}
+
+fun WallpaperModel.toInputBackground(): TdApi.InputBackground? = when (resolveWallpaperType()) {
+ WallpaperType.WALLPAPER -> when {
+ id > 0L -> TdApi.InputBackgroundRemote(id)
+ !localPath.isNullOrBlank() -> TdApi.InputBackgroundLocal(TdApi.InputFileLocal(localPath))
+ else -> null
+ }
+
+ WallpaperType.PATTERN,
+ WallpaperType.FILL,
+ WallpaperType.CHAT_THEME -> if (id > 0L) TdApi.InputBackgroundRemote(id) else null
+}
+
+fun WallpaperModel.toBackgroundType(isBlurred: Boolean, isMoving: Boolean): TdApi.BackgroundType? =
+ when (resolveWallpaperType()) {
+ WallpaperType.WALLPAPER -> TdApi.BackgroundTypeWallpaper(isBlurred, isMoving)
+
+ WallpaperType.PATTERN -> {
+ val wallpaperSettings = settings ?: return null
+ val fill = wallpaperSettings.toBackgroundFill() ?: return null
+ TdApi.BackgroundTypePattern(
+ fill,
+ wallpaperSettings.intensity ?: 50,
+ wallpaperSettings.isInverted == true,
+ isMoving
+ )
+ }
+
+ WallpaperType.FILL -> {
+ val wallpaperSettings = settings ?: return null
+ val fill = wallpaperSettings.toBackgroundFill() ?: return null
+ TdApi.BackgroundTypeFill(fill)
+ }
+
+ WallpaperType.CHAT_THEME -> {
+ val name = themeName?.takeIf { it.isNotBlank() } ?: slug.takeIf { it.isNotBlank() }
+ name?.let { TdApi.BackgroundTypeChatTheme(it) }
+ }
+ }
+
+private fun WallpaperSettings.toBackgroundFill(): TdApi.BackgroundFill? {
+ val first = backgroundColor?.toTdColor()
+ val second = secondBackgroundColor?.toTdColor()
+ val third = thirdBackgroundColor?.toTdColor()
+ val fourth = fourthBackgroundColor?.toTdColor()
+
+ val freeform = intArrayOfNotNull(first, second, third, fourth)
+ if (freeform.size >= 3) {
+ return TdApi.BackgroundFillFreeformGradient(freeform)
+ }
+
+ if (first != null && second != null) {
+ return TdApi.BackgroundFillGradient(first, second, rotation ?: 0)
+ }
+
+ if (first != null) {
+ return TdApi.BackgroundFillSolid(first)
+ }
+
+ return null
+}
+
+private fun WallpaperModel.resolveWallpaperType(): WallpaperType = when {
+ type == WallpaperType.PATTERN || pattern -> WallpaperType.PATTERN
+ type == WallpaperType.CHAT_THEME || slug.startsWith("emoji") -> WallpaperType.CHAT_THEME
+ type == WallpaperType.FILL -> WallpaperType.FILL
+ documentId != 0L || slug == "built-in" -> WallpaperType.WALLPAPER
+ else -> WallpaperType.FILL
+}
+
+private fun intArrayOfNotNull(vararg values: Int?): IntArray =
+ values.filterNotNull().toIntArray()
+
+private fun Int.toTdColor(): Int = this and 0x00FFFFFF
diff --git a/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt
new file mode 100644
index 00000000..ab1a68e9
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt
@@ -0,0 +1,267 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.domain.models.WebPage
+import org.monogram.domain.repository.AppPreferencesProvider
+
+internal class WebPageMapper(
+ private val fileHelper: TdFileHelper,
+ private val appPreferences: AppPreferencesProvider
+) {
+ fun map(
+ webPage: TdApi.LinkPreview?,
+ chatId: Long,
+ messageId: Long,
+ networkAutoDownload: Boolean
+ ): WebPage? {
+ if (webPage == null) return null
+
+ var photoObj: TdApi.Photo? = null
+ var videoObj: TdApi.Video? = null
+ var audioObj: TdApi.Audio? = null
+ var documentObj: TdApi.Document? = null
+ var stickerObj: TdApi.Sticker? = null
+ var animationObj: TdApi.Animation? = null
+ var duration = 0
+
+ val linkPreviewType = when (val type = webPage.type) {
+ is TdApi.LinkPreviewTypePhoto -> {
+ photoObj = type.photo
+ WebPage.LinkPreviewType.Photo
+ }
+
+ is TdApi.LinkPreviewTypeVideo -> {
+ videoObj = type.video
+ WebPage.LinkPreviewType.Video
+ }
+
+ is TdApi.LinkPreviewTypeAnimation -> {
+ animationObj = type.animation
+ WebPage.LinkPreviewType.Animation
+ }
+
+ is TdApi.LinkPreviewTypeAudio -> {
+ audioObj = type.audio
+ WebPage.LinkPreviewType.Audio
+ }
+
+ is TdApi.LinkPreviewTypeDocument -> {
+ documentObj = type.document
+ WebPage.LinkPreviewType.Document
+ }
+
+ is TdApi.LinkPreviewTypeSticker -> {
+ stickerObj = type.sticker
+ WebPage.LinkPreviewType.Sticker
+ }
+
+ is TdApi.LinkPreviewTypeVideoNote -> {
+ WebPage.LinkPreviewType.VideoNote
+ }
+
+ is TdApi.LinkPreviewTypeVoiceNote -> {
+ WebPage.LinkPreviewType.VoiceNote
+ }
+
+ is TdApi.LinkPreviewTypeAlbum -> {
+ WebPage.LinkPreviewType.Album
+ }
+
+ is TdApi.LinkPreviewTypeArticle -> {
+ WebPage.LinkPreviewType.Article
+ }
+
+ is TdApi.LinkPreviewTypeApp -> {
+ WebPage.LinkPreviewType.App
+ }
+
+ is TdApi.LinkPreviewTypeExternalVideo -> {
+ duration = type.duration
+ WebPage.LinkPreviewType.ExternalVideo(type.url)
+ }
+
+ is TdApi.LinkPreviewTypeExternalAudio -> {
+ duration = type.duration
+ WebPage.LinkPreviewType.ExternalAudio(type.url)
+ }
+
+ is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> {
+ duration = type.duration
+ WebPage.LinkPreviewType.EmbeddedVideo(type.url)
+ }
+
+ is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> {
+ duration = type.duration
+ WebPage.LinkPreviewType.EmbeddedAudio(type.url)
+ }
+
+ is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> {
+ duration = type.duration
+ WebPage.LinkPreviewType.EmbeddedAnimation(type.url)
+ }
+
+ is TdApi.LinkPreviewTypeUser -> {
+ WebPage.LinkPreviewType.User(0)
+ }
+
+ is TdApi.LinkPreviewTypeChat -> {
+ WebPage.LinkPreviewType.Chat(0)
+ }
+
+ is TdApi.LinkPreviewTypeStory -> {
+ WebPage.LinkPreviewType.Story(type.storyPosterChatId, type.storyId)
+ }
+
+ is TdApi.LinkPreviewTypeTheme -> {
+ WebPage.LinkPreviewType.Theme
+ }
+
+ is TdApi.LinkPreviewTypeBackground -> {
+ WebPage.LinkPreviewType.Background
+ }
+
+ is TdApi.LinkPreviewTypeInvoice -> {
+ WebPage.LinkPreviewType.Invoice
+ }
+
+ is TdApi.LinkPreviewTypeMessage -> {
+ WebPage.LinkPreviewType.Message
+ }
+
+ else -> WebPage.LinkPreviewType.Unknown
+ }
+
+ fun processTdFile(
+ file: TdApi.File,
+ downloadType: TdMessageRemoteDataSource.DownloadType,
+ supportsStreaming: Boolean = false
+ ): TdApi.File {
+ val updatedFile = fileHelper.getUpdatedFile(file)
+ fileHelper.registerCachedFile(updatedFile.id, chatId, messageId)
+
+ val autoDownload = when (downloadType) {
+ TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload
+ TdMessageRemoteDataSource.DownloadType.DEFAULT -> {
+ if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload
+ }
+
+ TdMessageRemoteDataSource.DownloadType.STICKER -> {
+ networkAutoDownload && appPreferences.autoDownloadStickers.value
+ }
+
+ TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> {
+ networkAutoDownload && appPreferences.autoDownloadVideoNotes.value
+ }
+
+ else -> networkAutoDownload
+ }
+
+ if (!fileHelper.isValidPath(updatedFile.local.path) && autoDownload) {
+ fileHelper.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false)
+ }
+
+ return updatedFile
+ }
+
+ val photo = photoObj?.let { photoObject ->
+ val size = photoObject.sizes.firstOrNull()
+ if (size != null) {
+ val file = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT)
+ val bestPath = fileHelper.findBestAvailablePath(file, photoObject.sizes)
+
+ WebPage.Photo(
+ path = bestPath,
+ width = size.width,
+ height = size.height,
+ fileId = file.id,
+ minithumbnail = photoObject.minithumbnail?.data
+ )
+ } else {
+ null
+ }
+ }
+
+ val video = videoObj?.let { videoObject ->
+ val file = processTdFile(
+ videoObject.video,
+ TdMessageRemoteDataSource.DownloadType.VIDEO,
+ videoObject.supportsStreaming
+ )
+ WebPage.Video(
+ path = fileHelper.resolveLocalFilePath(file),
+ width = videoObject.width,
+ height = videoObject.height,
+ duration = videoObject.duration,
+ fileId = file.id,
+ supportsStreaming = videoObject.supportsStreaming
+ )
+ }
+
+ val audio = audioObj?.let { audioObject ->
+ val file = processTdFile(audioObject.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT)
+ WebPage.Audio(
+ path = fileHelper.resolveLocalFilePath(file),
+ duration = audioObject.duration,
+ title = audioObject.title,
+ performer = audioObject.performer,
+ fileId = file.id
+ )
+ }
+
+ val document = documentObj?.let { documentObject ->
+ val file = processTdFile(documentObject.document, TdMessageRemoteDataSource.DownloadType.DEFAULT)
+ WebPage.Document(
+ path = fileHelper.resolveLocalFilePath(file),
+ fileName = documentObject.fileName,
+ mimeType = documentObject.mimeType,
+ size = file.size,
+ fileId = file.id
+ )
+ }
+
+ val sticker = stickerObj?.let { stickerObject ->
+ val file = processTdFile(stickerObject.sticker, TdMessageRemoteDataSource.DownloadType.STICKER)
+ WebPage.Sticker(
+ path = fileHelper.resolveLocalFilePath(file),
+ width = stickerObject.width,
+ height = stickerObject.height,
+ emoji = stickerObject.emoji,
+ fileId = file.id
+ )
+ }
+
+ val animation = animationObj?.let { animationObject ->
+ val file = processTdFile(animationObject.animation, TdMessageRemoteDataSource.DownloadType.GIF)
+ WebPage.Animation(
+ path = fileHelper.resolveLocalFilePath(file),
+ width = animationObject.width,
+ height = animationObject.height,
+ duration = animationObject.duration,
+ fileId = file.id
+ )
+ }
+
+ return WebPage(
+ url = webPage.url,
+ displayUrl = webPage.displayUrl,
+ type = linkPreviewType,
+ siteName = webPage.siteName,
+ title = webPage.title,
+ description = webPage.description?.text,
+ photo = photo,
+ embedUrl = null,
+ embedType = null,
+ embedWidth = 0,
+ embedHeight = 0,
+ duration = duration,
+ author = webPage.author,
+ video = video,
+ audio = audio,
+ document = document,
+ sticker = sticker,
+ animation = animation,
+ instantViewVersion = webPage.instantViewVersion
+ )
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt
new file mode 100644
index 00000000..3f4c382a
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt
@@ -0,0 +1,593 @@
+package org.monogram.data.mapper.message
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.data.mapper.CustomEmojiLoader
+import org.monogram.data.mapper.TdFileHelper
+import org.monogram.data.mapper.WebPageMapper
+import org.monogram.data.mapper.toMessageEntityOrNull
+import org.monogram.domain.models.*
+import org.monogram.domain.repository.AppPreferencesProvider
+
+internal data class ContentMappingContext(
+ val chatId: Long,
+ val messageId: Long,
+ val senderName: String,
+ val networkAutoDownload: Boolean,
+ val isActuallyUploading: Boolean
+)
+
+internal class MessageContentMapper(
+ private val fileHelper: TdFileHelper,
+ private val appPreferences: AppPreferencesProvider,
+ private val customEmojiLoader: CustomEmojiLoader,
+ private val webPageMapper: WebPageMapper,
+ private val scope: CoroutineScope
+) {
+ fun mapContent(msg: TdApi.Message, context: ContentMappingContext): MessageContent {
+ return when (val content = msg.content) {
+ is TdApi.MessageText -> {
+ val entities = mapEntities(
+ entities = content.text.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ )
+ val webPage = webPageMapper.map(
+ webPage = content.linkPreview,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ )
+ MessageContent.Text(content.text.text, entities, webPage)
+ }
+
+ is TdApi.MessagePhoto -> {
+ val sizes = content.photo.sizes
+ val photoSize = sizes.find { it.type == "x" }
+ ?: sizes.find { it.type == "m" }
+ ?: sizes.getOrNull(sizes.size / 2)
+ ?: sizes.lastOrNull()
+ val thumbnailSize = sizes.find { it.type == "m" }
+ ?: sizes.find { it.type == "s" }
+ ?: sizes.firstOrNull()
+
+ val photoFile = photoSize?.photo?.let(fileHelper::getUpdatedFile)
+ val thumbnailFile = thumbnailSize?.photo?.let(fileHelper::getUpdatedFile)
+
+ val path = fileHelper.findBestAvailablePath(photoFile, sizes)
+ val thumbnailPath = fileHelper.resolveLocalFilePath(thumbnailFile)
+
+ if (photoFile != null) {
+ fileHelper.registerCachedFile(photoFile.id, context.chatId, context.messageId)
+ if (path == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ photoFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ if (thumbnailFile != null) {
+ fileHelper.registerCachedFile(thumbnailFile.id, context.chatId, context.messageId)
+ if (thumbnailPath == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ thumbnailFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ val isDownloading = photoFile?.local?.isDownloadingActive ?: false
+ val isQueued = photoFile?.let { fileHelper.isFileQueued(it.id) } ?: false
+ val downloadProgress = photoFile?.let(fileHelper::computeDownloadProgress) ?: 0f
+
+ MessageContent.Photo(
+ path = path,
+ thumbnailPath = thumbnailPath,
+ width = photoSize?.width ?: 0,
+ height = photoSize?.height ?: 0,
+ caption = content.caption.text,
+ entities = mapEntities(
+ entities = content.caption.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ ),
+ isUploading = context.isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false),
+ uploadProgress = photoFile?.let(fileHelper::computeUploadProgress) ?: 0f,
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = photoFile?.id ?: 0,
+ minithumbnail = content.photo.minithumbnail?.data
+ )
+ }
+
+ is TdApi.MessageVideo -> {
+ val video = content.video
+ val videoFile = fileHelper.getUpdatedFile(video.video)
+ val path = fileHelper.resolveLocalFilePath(videoFile)
+ fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId)
+
+ val thumbFile = video.thumbnail?.file?.let(fileHelper::getUpdatedFile)
+ val thumbnailPath = fileHelper.resolveLocalFilePath(thumbFile)
+
+ if (thumbFile != null) {
+ fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId)
+ if (thumbnailPath == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ thumbFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ if (path == null && context.networkAutoDownload && video.supportsStreaming) {
+ fileHelper.enqueueDownload(
+ videoFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.VIDEO,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val isDownloading = videoFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(videoFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(videoFile)
+
+ MessageContent.Video(
+ path = path,
+ thumbnailPath = thumbnailPath,
+ width = video.width,
+ height = video.height,
+ duration = video.duration,
+ caption = content.caption.text,
+ entities = mapEntities(
+ entities = content.caption.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ ),
+ isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive,
+ uploadProgress = fileHelper.computeUploadProgress(videoFile),
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = videoFile.id,
+ minithumbnail = video.minithumbnail?.data,
+ supportsStreaming = video.supportsStreaming
+ )
+ }
+
+ is TdApi.MessageVoiceNote -> {
+ val voice = content.voiceNote
+ val voiceFile = fileHelper.getUpdatedFile(voice.voice)
+ val path = fileHelper.resolveLocalFilePath(voiceFile)
+ fileHelper.registerCachedFile(voiceFile.id, context.chatId, context.messageId)
+
+ if (path == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ voiceFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val isDownloading = voiceFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(voiceFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(voiceFile)
+
+ MessageContent.Voice(
+ path = path,
+ duration = voice.duration,
+ waveform = voice.waveform,
+ isUploading = context.isActuallyUploading && voiceFile.remote.isUploadingActive,
+ uploadProgress = fileHelper.computeUploadProgress(voiceFile),
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = voiceFile.id
+ )
+ }
+
+ is TdApi.MessageVideoNote -> {
+ val note = content.videoNote
+ val videoFile = fileHelper.getUpdatedFile(note.video)
+ val videoPath = fileHelper.resolveLocalFilePath(videoFile)
+ fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId)
+
+ if (videoPath == null && context.networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) {
+ fileHelper.enqueueDownload(
+ videoFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val thumbFile = note.thumbnail?.file?.let(fileHelper::getUpdatedFile)
+ val thumbPath = fileHelper.resolveLocalFilePath(thumbFile)
+ if (thumbFile != null) {
+ fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId)
+ if (thumbPath == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ thumbFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ val isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive
+ val uploadProgress = fileHelper.computeUploadProgress(videoFile)
+ val isDownloading = videoFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(videoFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(videoFile)
+
+ MessageContent.VideoNote(
+ path = videoPath,
+ thumbnail = thumbPath,
+ duration = note.duration,
+ length = note.length,
+ isUploading = isUploading,
+ uploadProgress = uploadProgress,
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = videoFile.id
+ )
+ }
+
+ is TdApi.MessageSticker -> {
+ val sticker = content.sticker
+ val stickerFile = fileHelper.getUpdatedFile(sticker.sticker)
+ val path = fileHelper.resolveLocalFilePath(stickerFile)
+
+ fileHelper.registerCachedFile(stickerFile.id, context.chatId, context.messageId)
+ if (path == null && context.networkAutoDownload && appPreferences.autoDownloadStickers.value) {
+ fileHelper.enqueueDownload(
+ stickerFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.STICKER,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val format = when (sticker.format) {
+ is TdApi.StickerFormatWebp -> StickerFormat.STATIC
+ is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED
+ is TdApi.StickerFormatWebm -> StickerFormat.VIDEO
+ else -> StickerFormat.UNKNOWN
+ }
+
+ val isDownloading = stickerFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(stickerFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(stickerFile)
+
+ MessageContent.Sticker(
+ id = sticker.sticker.id.toLong(),
+ setId = sticker.setId,
+ path = path,
+ width = sticker.width,
+ height = sticker.height,
+ emoji = sticker.emoji,
+ format = format,
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = stickerFile.id
+ )
+ }
+
+ is TdApi.MessageAnimation -> {
+ val animation = content.animation
+ val animationFile = fileHelper.getUpdatedFile(animation.animation)
+ val path = fileHelper.resolveLocalFilePath(animationFile)
+ fileHelper.registerCachedFile(animationFile.id, context.chatId, context.messageId)
+
+ if (path == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ animationFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.GIF,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val thumbFile = animation.thumbnail?.file?.let(fileHelper::getUpdatedFile)
+ if (thumbFile != null) {
+ fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId)
+ if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ thumbFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ val isDownloading = animationFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(animationFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(animationFile)
+
+ MessageContent.Gif(
+ path = path,
+ width = animation.width,
+ height = animation.height,
+ caption = content.caption.text,
+ entities = mapEntities(
+ entities = content.caption.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ ),
+ isUploading = context.isActuallyUploading && animationFile.remote.isUploadingActive,
+ uploadProgress = fileHelper.computeUploadProgress(animationFile),
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = animationFile.id,
+ minithumbnail = animation.minithumbnail?.data
+ )
+ }
+
+ is TdApi.MessageAnimatedEmoji -> MessageContent.Text(content.emoji)
+ is TdApi.MessageDice -> {
+ val valueStr = if (content.value != 0) " (Result: ${content.value})" else ""
+ MessageContent.Text("${content.emoji}$valueStr")
+ }
+
+ is TdApi.MessageDocument -> {
+ val document = content.document
+ val documentFile = fileHelper.getUpdatedFile(document.document)
+ val path = fileHelper.resolveLocalFilePath(documentFile)
+ fileHelper.registerCachedFile(documentFile.id, context.chatId, context.messageId)
+
+ val thumbFile = document.thumbnail?.file?.let(fileHelper::getUpdatedFile)
+ if (thumbFile != null) {
+ fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId)
+ if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ thumbFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ val isDownloading = documentFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(documentFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(documentFile)
+
+ MessageContent.Document(
+ path = path,
+ fileName = document.fileName,
+ mimeType = document.mimeType,
+ size = documentFile.size,
+ caption = content.caption.text,
+ entities = mapEntities(
+ entities = content.caption.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ ),
+ isUploading = context.isActuallyUploading && documentFile.remote.isUploadingActive,
+ uploadProgress = fileHelper.computeUploadProgress(documentFile),
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = documentFile.id
+ )
+ }
+
+ is TdApi.MessageAudio -> {
+ val audio = content.audio
+ val audioFile = fileHelper.getUpdatedFile(audio.audio)
+ val path = fileHelper.resolveLocalFilePath(audioFile)
+ fileHelper.registerCachedFile(audioFile.id, context.chatId, context.messageId)
+
+ if (path == null && context.networkAutoDownload) {
+ fileHelper.enqueueDownload(
+ audioFile.id,
+ 1,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+
+ val isDownloading = audioFile.local.isDownloadingActive
+ val isQueued = fileHelper.isFileQueued(audioFile.id)
+ val downloadProgress = fileHelper.computeDownloadProgress(audioFile)
+
+ MessageContent.Audio(
+ path = path,
+ duration = audio.duration,
+ title = audio.title ?: "Unknown",
+ performer = audio.performer ?: "Unknown",
+ fileName = audio.fileName ?: "audio.mp3",
+ mimeType = audio.mimeType ?: "audio/mpeg",
+ size = audioFile.size,
+ caption = content.caption.text,
+ entities = mapEntities(
+ entities = content.caption.entities,
+ chatId = context.chatId,
+ messageId = context.messageId,
+ networkAutoDownload = context.networkAutoDownload
+ ),
+ isUploading = context.isActuallyUploading && audioFile.remote.isUploadingActive,
+ uploadProgress = fileHelper.computeUploadProgress(audioFile),
+ isDownloading = isDownloading || isQueued,
+ downloadProgress = downloadProgress,
+ fileId = audioFile.id
+ )
+ }
+
+ is TdApi.MessageCall -> MessageContent.Text("📞 Call (${content.duration}s)")
+ is TdApi.MessageContact -> {
+ val contact = content.contact
+ MessageContent.Contact(
+ phoneNumber = contact.phoneNumber,
+ firstName = contact.firstName,
+ lastName = contact.lastName,
+ vcard = contact.vcard,
+ userId = contact.userId
+ )
+ }
+
+ is TdApi.MessageLocation -> {
+ val location = content.location
+ MessageContent.Location(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ horizontalAccuracy = location.horizontalAccuracy,
+ livePeriod = content.livePeriod,
+ heading = content.heading,
+ proximityAlertRadius = content.proximityAlertRadius
+ )
+ }
+
+ is TdApi.MessageVenue -> {
+ val venue = content.venue
+ MessageContent.Venue(
+ latitude = venue.location.latitude,
+ longitude = venue.location.longitude,
+ title = venue.title,
+ address = venue.address,
+ provider = venue.provider,
+ venueId = venue.id,
+ venueType = venue.type
+ )
+ }
+
+ is TdApi.MessagePoll -> {
+ val poll = content.poll
+ val pollType = when (val type = poll.type) {
+ is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers)
+ is TdApi.PollTypeQuiz -> {
+ PollType.Quiz(type.correctOptionIds.firstOrNull() ?: -1, type.explanation?.text)
+ }
+
+ else -> PollType.Regular(poll.allowsMultipleAnswers)
+ }
+
+ MessageContent.Poll(
+ id = poll.id,
+ question = poll.question.text,
+ options = poll.options.map { option ->
+ PollOption(
+ text = option.text.text,
+ voterCount = option.voterCount,
+ votePercentage = option.votePercentage,
+ isChosen = option.isChosen,
+ isBeingChosen = false
+ )
+ },
+ totalVoterCount = poll.totalVoterCount,
+ isClosed = poll.isClosed,
+ isAnonymous = poll.isAnonymous,
+ type = pollType,
+ openPeriod = poll.openPeriod,
+ closeDate = poll.closeDate
+ )
+ }
+
+ is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${content.game.title}")
+ is TdApi.MessageInvoice -> MessageContent.Text("💳 Invoice: ${content.productInfo.title}")
+ is TdApi.MessageStory -> MessageContent.Text("📖 Story")
+ is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired")
+ is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired")
+
+ is TdApi.MessageChatJoinByLink -> MessageContent.Service("${context.senderName} has joined the group via invite link")
+ is TdApi.MessageChatAddMembers -> MessageContent.Service("${context.senderName} added members")
+ is TdApi.MessageChatDeleteMember -> MessageContent.Service("${context.senderName} left the chat")
+ is TdApi.MessagePinMessage -> MessageContent.Service("${context.senderName} pinned a message")
+ is TdApi.MessageChatChangeTitle -> MessageContent.Service("${context.senderName} changed group name to \"${content.title}\"")
+ is TdApi.MessageChatChangePhoto -> MessageContent.Service("${context.senderName} changed group photo")
+ is TdApi.MessageChatDeletePhoto -> MessageContent.Service("${context.senderName} removed group photo")
+ is TdApi.MessageScreenshotTaken -> MessageContent.Service("${context.senderName} took a screenshot")
+ is TdApi.MessageContactRegistered -> MessageContent.Service("${context.senderName} joined Telegram!")
+ is TdApi.MessageChatUpgradeTo -> MessageContent.Service("${context.senderName} upgraded to supergroup")
+ is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created")
+ is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${content.title}\"")
+ is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${content.title}\"")
+ is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${content.currency} ${content.totalAmount}")
+ is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful")
+ is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent")
+ is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received")
+ is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${content.distance}m")
+ is TdApi.MessageForumTopicCreated -> MessageContent.Service("${context.senderName} created topic \"${content.name}\"")
+ is TdApi.MessageForumTopicEdited -> MessageContent.Service("${context.senderName} edited topic")
+ is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("${context.senderName} toggled topic closed status")
+ is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("${context.senderName} toggled topic hidden status")
+ is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("${context.senderName} suggested a profile photo")
+ is TdApi.MessageCustomServiceAction -> MessageContent.Service(content.text)
+ is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${content.boostCount}")
+ is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${content.theme}")
+ is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${content.score}")
+ is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${content.startDate}")
+ is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started")
+ is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended")
+ is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed")
+ else -> MessageContent.Text("ℹ️ Unsupported message type: ${content.javaClass.simpleName}")
+ }
+ }
+
+ fun mapEntities(
+ entities: Array,
+ chatId: Long,
+ messageId: Long,
+ networkAutoDownload: Boolean
+ ): List {
+ return entities.mapNotNull { entity ->
+ entity.toMessageEntityOrNull(
+ mapUnsupportedToOther = true,
+ mentionNameAsMention = true,
+ customEmojiPathResolver = { emojiId ->
+ customEmojiLoader.getPathIfValid(emojiId)
+ },
+ onMissingCustomEmoji = { emojiId ->
+ scope.launch {
+ customEmojiLoader.loadIfNeeded(emojiId, chatId, messageId, networkAutoDownload)
+ }
+ }
+ )
+ }
+ }
+
+ fun resolveMessageDate(msg: TdApi.Message): Int {
+ return when (val schedulingState = msg.schedulingState) {
+ is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate
+ else -> msg.date
+ }
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..f82170ec
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt
@@ -0,0 +1,746 @@
+package org.monogram.data.mapper.message
+
+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.domain.models.ForwardInfo
+import org.monogram.domain.models.MessageContent
+import org.monogram.domain.models.MessageModel
+import org.monogram.domain.models.PollType
+import org.monogram.data.db.model.MessageEntity as MessageDbEntity
+
+internal class MessagePersistenceMapper(
+ private val cache: ChatCache,
+ private val fileHelper: TdFileHelper
+) {
+ data class CachedMessageContent(
+ val type: String,
+ val text: String,
+ val meta: String?,
+ val fileId: Int = 0,
+ val path: String? = null,
+ val thumbnailPath: String? = null,
+ val minithumbnail: ByteArray? = null
+ )
+
+ private data class CachedReplyPreview(
+ val senderName: String,
+ val contentType: String,
+ val text: String
+ )
+
+ private data class CachedForwardOrigin(
+ val fromName: String,
+ val fromId: Long,
+ val originChatId: Long? = null,
+ val originMessageId: Long? = null
+ )
+
+ fun mapToEntity(
+ msg: TdApi.Message,
+ getSenderName: ((Long) -> String?)? = null
+ ): MessageDbEntity {
+ val senderId = when (val sender = msg.senderId) {
+ is TdApi.MessageSenderUser -> sender.userId
+ is TdApi.MessageSenderChat -> sender.chatId
+ else -> 0L
+ }
+ val senderName = getSenderName?.invoke(senderId).orEmpty()
+ val content = extractCachedContent(msg.content)
+ val entitiesEncoded = encodeEntities(msg.content)
+ val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L
+ val replyToPreview = buildReplyPreview(msg)
+ val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin)
+
+ return MessageDbEntity(
+ id = msg.id,
+ chatId = msg.chatId,
+ senderId = senderId,
+ senderName = senderName,
+ content = content.text,
+ contentType = content.type,
+ contentMeta = content.meta,
+ mediaFileId = content.fileId,
+ mediaPath = content.path,
+ mediaThumbnailPath = content.thumbnailPath,
+ minithumbnail = content.minithumbnail,
+ date = resolveMessageDate(msg),
+ isOutgoing = msg.isOutgoing,
+ isRead = false,
+ replyToMessageId = replyToMessageId,
+ replyToPreview = replyToPreview?.let(::encodeReplyPreview),
+ replyToPreviewType = replyToPreview?.contentType,
+ replyToPreviewText = replyToPreview?.text,
+ replyToPreviewSenderName = replyToPreview?.senderName,
+ replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0,
+ forwardFromName = forwardOrigin?.fromName,
+ forwardFromId = forwardOrigin?.fromId ?: 0L,
+ forwardOriginChatId = forwardOrigin?.originChatId,
+ forwardOriginMessageId = forwardOrigin?.originMessageId,
+ forwardDate = msg.forwardInfo?.date ?: 0,
+ editDate = msg.editDate,
+ mediaAlbumId = msg.mediaAlbumId,
+ entities = entitiesEncoded,
+ viewCount = msg.interactionInfo?.viewCount ?: 0,
+ forwardCount = msg.interactionInfo?.forwardCount ?: 0,
+ createdAt = System.currentTimeMillis()
+ )
+ }
+
+ fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent {
+ return when (content) {
+ is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null)
+ is TdApi.MessagePhoto -> {
+ val sizes = content.photo.sizes
+ val best = sizes.find { it.type == "x" }
+ ?: sizes.find { it.type == "m" }
+ ?: sizes.getOrNull(sizes.size / 2)
+ ?: sizes.lastOrNull()
+ val thumbnail = sizes.find { it.type == "m" }
+ ?: sizes.find { it.type == "s" }
+ val fileId = best?.photo?.id ?: 0
+ val path = best?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) }
+ val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) }
+ CachedMessageContent(
+ "photo",
+ content.caption.text,
+ encodeMeta(best?.width ?: 0, best?.height ?: 0),
+ fileId = fileId,
+ path = path,
+ thumbnailPath = thumbnailPath,
+ minithumbnail = content.photo.minithumbnail?.data
+ )
+ }
+
+ is TdApi.MessageVideo -> {
+ val fileId = content.video.video.id
+ val path = content.video.video.local.path.takeIf { fileHelper.isValidPath(it) }
+ CachedMessageContent(
+ "video",
+ content.caption.text,
+ encodeMeta(
+ content.video.width,
+ content.video.height,
+ content.video.duration,
+ content.video.thumbnail?.file?.local?.path
+ ?.takeIf { fileHelper.isValidPath(it) }
+ .orEmpty(),
+ if (content.video.supportsStreaming) 1 else 0
+ ),
+ fileId = fileId,
+ path = path,
+ thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { fileHelper.isValidPath(it) },
+ minithumbnail = content.video.minithumbnail?.data
+ )
+ }
+
+ is TdApi.MessageVoiceNote -> CachedMessageContent(
+ "voice",
+ content.caption.text,
+ encodeMeta(content.voiceNote.duration),
+ fileId = content.voiceNote.voice.id,
+ path = content.voiceNote.voice.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+
+ is TdApi.MessageVideoNote -> CachedMessageContent(
+ "video_note",
+ "",
+ encodeMeta(
+ content.videoNote.duration,
+ content.videoNote.length,
+ content.videoNote.thumbnail?.file?.local?.path
+ ?.takeIf { fileHelper.isValidPath(it) }
+ .orEmpty()
+ ),
+ fileId = content.videoNote.video.id,
+ path = content.videoNote.video.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+
+ is TdApi.MessageSticker -> {
+ val format = when (content.sticker.format) {
+ is TdApi.StickerFormatWebp -> "webp"
+ is TdApi.StickerFormatTgs -> "tgs"
+ is TdApi.StickerFormatWebm -> "webm"
+ else -> "unknown"
+ }
+ CachedMessageContent(
+ "sticker",
+ content.sticker.emoji,
+ encodeMeta(
+ content.sticker.setId,
+ content.sticker.emoji,
+ content.sticker.width,
+ content.sticker.height,
+ format
+ ),
+ fileId = content.sticker.sticker.id,
+ path = content.sticker.sticker.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+ }
+
+ is TdApi.MessageDocument -> CachedMessageContent(
+ "document",
+ content.caption.text,
+ encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size),
+ fileId = content.document.document.id,
+ path = content.document.document.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+
+ is TdApi.MessageAudio -> CachedMessageContent(
+ "audio",
+ content.caption.text,
+ encodeMeta(
+ content.audio.duration,
+ content.audio.title.orEmpty(),
+ content.audio.performer.orEmpty(),
+ content.audio.fileName.orEmpty()
+ ),
+ fileId = content.audio.audio.id,
+ path = content.audio.audio.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+
+ is TdApi.MessageAnimation -> CachedMessageContent(
+ "gif",
+ content.caption.text,
+ encodeMeta(
+ content.animation.width,
+ content.animation.height,
+ content.animation.duration,
+ content.animation.thumbnail?.file?.local?.path
+ ?.takeIf { fileHelper.isValidPath(it) }
+ .orEmpty()
+ ),
+ fileId = content.animation.animation.id,
+ path = content.animation.animation.local.path.takeIf { fileHelper.isValidPath(it) }
+ )
+
+ is TdApi.MessagePoll -> CachedMessageContent(
+ "poll",
+ content.poll.question.text,
+ encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0)
+ )
+
+ is TdApi.MessageContact -> CachedMessageContent(
+ "contact",
+ listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() }
+ .joinToString(" "),
+ encodeMeta(
+ content.contact.phoneNumber,
+ content.contact.firstName,
+ content.contact.lastName,
+ content.contact.userId
+ )
+ )
+
+ is TdApi.MessageLocation -> CachedMessageContent(
+ "location",
+ "",
+ encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod)
+ )
+
+ is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null)
+ is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null)
+ is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null)
+ is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null)
+ is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null)
+ is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null)
+ is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null)
+ else -> CachedMessageContent("unsupported", "", null)
+ }
+ }
+
+ fun mapEntityToModel(entity: MessageDbEntity): MessageModel {
+ val meta = decodeMeta(entity.contentMeta)
+ val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank()
+ val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) {
+ resolveLegacyMediaFromMeta(entity.contentType, meta)
+ } else {
+ 0 to null
+ }
+ val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId
+ val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath
+ val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L }
+ val replyPreview = resolveReplyPreview(entity)
+ val replyPreviewModel =
+ if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel(
+ entity,
+ replyToMsgId,
+ replyPreview
+ ) else null
+
+ val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) }
+ val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) {
+ cache.getChat(entity.senderId)
+ } else {
+ null
+ }
+
+ val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName)
+ val resolvedSenderAvatar = when {
+ cachedSenderUser != null -> fileHelper.resolveLocalFilePath(cachedSenderUser.profilePhoto?.small)
+ cachedSenderChat != null -> fileHelper.resolveLocalFilePath(cachedSenderChat.photo?.small)
+ else -> null
+ }
+ val resolvedSenderPersonalAvatar = 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
+ }
+
+ val forwardInfo = entity.forwardFromName
+ ?.takeIf { it.isNotBlank() }
+ ?.let { fromName ->
+ ForwardInfo(
+ date = entity.forwardDate.takeIf { it > 0 } ?: entity.date,
+ fromId = entity.forwardFromId,
+ fromName = fromName,
+ originChatId = entity.forwardOriginChatId,
+ originMessageId = entity.forwardOriginMessageId
+ )
+ }
+
+ val content: MessageContent = when (entity.contentType) {
+ "text" -> MessageContent.Text(entity.content)
+
+ "photo" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Photo(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ thumbnailPath = entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) },
+ width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
+ caption = entity.content,
+ fileId = fileId,
+ minithumbnail = entity.minithumbnail
+ )
+ }
+
+ "video" -> {
+ val fileId = mediaFileId
+ val supportsStreaming = if (usesLegacyEmbeddedMedia) {
+ (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1
+ } else {
+ (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1
+ }
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Video(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ thumbnailPath = (
+ entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) }
+ ?: meta.getOrNull(3)
+ )?.takeIf { fileHelper.isValidPath(it) },
+ width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
+ duration = meta.getOrNull(2)?.toIntOrNull() ?: 0,
+ caption = entity.content,
+ fileId = fileId,
+ supportsStreaming = supportsStreaming,
+ minithumbnail = entity.minithumbnail
+ )
+ }
+
+ "voice" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Voice(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ fileId = fileId
+ )
+ }
+
+ "video_note" -> {
+ val fileId = mediaFileId
+ val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2)
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.VideoNote(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ thumbnail = storedThumbPath?.takeIf { fileHelper.isValidPath(it) },
+ duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ length = meta.getOrNull(1)?.toIntOrNull() ?: 0,
+ fileId = fileId
+ )
+ }
+
+ "sticker" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Sticker(
+ id = 0L,
+ setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L,
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ width = meta.getOrNull(2)?.toIntOrNull() ?: 0,
+ height = meta.getOrNull(3)?.toIntOrNull() ?: 0,
+ emoji = entity.content,
+ fileId = fileId
+ )
+ }
+
+ "document" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Document(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ fileName = meta.getOrNull(0).orEmpty(),
+ mimeType = meta.getOrNull(1).orEmpty(),
+ size = meta.getOrNull(2)?.toLongOrNull() ?: 0L,
+ caption = entity.content,
+ fileId = fileId
+ )
+ }
+
+ "audio" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Audio(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ duration = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ title = meta.getOrNull(1).orEmpty(),
+ performer = meta.getOrNull(2).orEmpty(),
+ fileName = meta.getOrNull(3).orEmpty(),
+ mimeType = "",
+ size = 0L,
+ caption = entity.content,
+ fileId = fileId
+ )
+ }
+
+ "gif" -> {
+ val fileId = mediaFileId
+ fileHelper.registerCachedFile(fileId, entity.chatId, entity.id)
+ MessageContent.Gif(
+ path = fileHelper.resolveCachedPath(fileId, mediaPath),
+ width = meta.getOrNull(0)?.toIntOrNull() ?: 0,
+ height = meta.getOrNull(1)?.toIntOrNull() ?: 0,
+ caption = entity.content,
+ fileId = fileId
+ )
+ }
+
+ "poll" -> MessageContent.Poll(
+ id = 0L,
+ question = entity.content,
+ options = emptyList(),
+ totalVoterCount = 0,
+ isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1,
+ isAnonymous = true,
+ type = PollType.Regular(false),
+ openPeriod = 0,
+ closeDate = 0
+ )
+
+ "contact" -> MessageContent.Contact(
+ phoneNumber = meta.getOrNull(0).orEmpty(),
+ firstName = meta.getOrNull(1).orEmpty(),
+ lastName = meta.getOrNull(2).orEmpty(),
+ vcard = "",
+ userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L
+ )
+
+ "location" -> MessageContent.Location(
+ latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0,
+ longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0,
+ livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0
+ )
+
+ "service" -> MessageContent.Service(entity.content)
+ else -> MessageContent.Text(entity.content)
+ }
+
+ return MessageModel(
+ id = entity.id,
+ date = entity.date,
+ isOutgoing = entity.isOutgoing,
+ senderName = resolvedSenderName,
+ chatId = entity.chatId,
+ content = content,
+ senderId = entity.senderId,
+ senderAvatar = resolvedSenderAvatar,
+ senderPersonalAvatar = resolvedSenderPersonalAvatar,
+ isRead = entity.isRead,
+ replyToMsgId = replyToMsgId,
+ replyToMsg = replyPreviewModel,
+ forwardInfo = forwardInfo,
+ mediaAlbumId = entity.mediaAlbumId,
+ editDate = entity.editDate,
+ views = entity.viewCount,
+ viewCount = entity.viewCount,
+ replyCount = entity.replyCount,
+ isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false,
+ isSenderPremium = cachedSenderUser?.isPremium ?: false,
+ senderStatusEmojiId = senderStatusEmojiId
+ )
+ }
+
+ private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String {
+ val user = cache.getUser(senderId)
+ if (user != null) {
+ return SenderNameResolver.fromParts(
+ firstName = user.firstName,
+ lastName = user.lastName,
+ fallback = fallback.ifBlank { "User" }
+ )
+ }
+
+ val chat = cache.getChat(senderId)
+ if (chat != null) {
+ return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" }
+ }
+
+ return fallback.ifBlank { "User" }
+ }
+
+ private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? {
+ val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null
+ val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null
+ val replySenderName = when (val sender = replied.senderId) {
+ is TdApi.MessageSenderUser -> {
+ val user = cache.getUser(sender.userId)
+ SenderNameResolver.fromParts(
+ firstName = user?.firstName,
+ lastName = user?.lastName,
+ fallback = "User"
+ )
+ }
+
+ is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty()
+ else -> ""
+ }
+ val extracted = extractCachedContent(replied.content)
+ return CachedReplyPreview(
+ senderName = replySenderName,
+ contentType = extracted.type,
+ text = extracted.text.take(100)
+ )
+ }
+
+ private fun encodeReplyPreview(preview: CachedReplyPreview): String {
+ return "${preview.senderName}|${preview.contentType}|${preview.text}"
+ }
+
+ private fun parseReplyPreview(raw: String?): CachedReplyPreview? {
+ if (raw.isNullOrBlank()) return null
+ val firstSeparator = raw.indexOf('|')
+ val secondSeparator = raw.indexOf('|', firstSeparator + 1)
+ if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null
+
+ val senderName = raw.substring(0, firstSeparator)
+ val contentType = raw.substring(firstSeparator + 1, secondSeparator)
+ val text = raw.substring(secondSeparator + 1)
+ if (contentType.isBlank()) return null
+
+ return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text)
+ }
+
+ private fun resolveReplyPreview(entity: MessageDbEntity): CachedReplyPreview? {
+ val encodedPreview = parseReplyPreview(entity.replyToPreview)
+ val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName
+ val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType
+ val text = entity.replyToPreviewText ?: encodedPreview?.text ?: ""
+
+ if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) {
+ return null
+ }
+
+ return CachedReplyPreview(
+ senderName = senderName.orEmpty(),
+ contentType = contentType?.ifBlank { "text" } ?: "text",
+ text = text
+ )
+ }
+
+ private fun createReplyPreviewModel(
+ entity: MessageDbEntity,
+ replyToMsgId: Long,
+ preview: CachedReplyPreview
+ ): MessageModel {
+ return MessageModel(
+ id = replyToMsgId,
+ date = entity.date,
+ isOutgoing = false,
+ senderName = preview.senderName.ifBlank { "Unknown" },
+ chatId = entity.chatId,
+ content = mapReplyPreviewContent(preview),
+ senderId = 0L,
+ isRead = true
+ )
+ }
+
+ private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent {
+ return when (preview.contentType) {
+ "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text)
+ "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text)
+ "voice" -> MessageContent.Voice(path = null, duration = 0)
+ "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0)
+ "sticker" -> MessageContent.Sticker(
+ id = 0L,
+ setId = 0L,
+ path = null,
+ width = 0,
+ height = 0,
+ emoji = preview.text
+ )
+
+ "document" -> MessageContent.Document(
+ path = null,
+ fileName = "",
+ mimeType = "",
+ size = 0L,
+ caption = preview.text
+ )
+
+ "audio" -> MessageContent.Audio(
+ path = null,
+ duration = 0,
+ title = "",
+ performer = "",
+ fileName = "",
+ mimeType = "",
+ size = 0L,
+ caption = preview.text
+ )
+
+ "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text)
+ "poll" -> MessageContent.Poll(
+ id = 0L,
+ question = preview.text,
+ options = emptyList(),
+ totalVoterCount = 0,
+ isClosed = false,
+ isAnonymous = true,
+ type = PollType.Regular(false),
+ openPeriod = 0,
+ closeDate = 0
+ )
+
+ "contact" -> MessageContent.Contact(
+ phoneNumber = "",
+ firstName = preview.text,
+ lastName = "",
+ vcard = "",
+ userId = 0L
+ )
+
+ "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0)
+ "service" -> MessageContent.Service(preview.text)
+ else -> MessageContent.Text(preview.text)
+ }
+ }
+
+ private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin {
+ return when (origin) {
+ is TdApi.MessageOriginUser -> {
+ val user = cache.getUser(origin.senderUserId)
+ val name = SenderNameResolver.fromParts(
+ firstName = user?.firstName,
+ lastName = user?.lastName,
+ fallback = "User"
+ )
+ CachedForwardOrigin(fromName = name, fromId = origin.senderUserId)
+ }
+
+ is TdApi.MessageOriginChat -> CachedForwardOrigin(
+ fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat",
+ fromId = origin.senderChatId
+ )
+
+ is TdApi.MessageOriginChannel -> CachedForwardOrigin(
+ fromName = cache.getChat(origin.chatId)?.title ?: "Channel",
+ fromId = origin.chatId,
+ originChatId = origin.chatId,
+ originMessageId = origin.messageId
+ )
+
+ is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin(
+ fromName = origin.senderName.ifBlank { "Hidden user" },
+ fromId = 0L
+ )
+
+ else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L)
+ }
+ }
+
+ private fun encodeEntities(content: TdApi.MessageContent): String? {
+ val formatted = when (content) {
+ is TdApi.MessageText -> content.text
+ is TdApi.MessagePhoto -> content.caption
+ is TdApi.MessageVideo -> content.caption
+ is TdApi.MessageDocument -> content.caption
+ is TdApi.MessageAudio -> content.caption
+ is TdApi.MessageAnimation -> content.caption
+ is TdApi.MessageVoiceNote -> content.caption
+ else -> null
+ } ?: return null
+
+ if (formatted.entities.isNullOrEmpty()) return null
+
+ return buildString {
+ formatted.entities.forEachIndexed { index, entity ->
+ if (index > 0) append('|')
+ append(entity.offset).append(',').append(entity.length).append(',')
+ when (val type = entity.type) {
+ is TdApi.TextEntityTypeBold -> append("b")
+ is TdApi.TextEntityTypeItalic -> append("i")
+ is TdApi.TextEntityTypeUnderline -> append("u")
+ is TdApi.TextEntityTypeStrikethrough -> append("s")
+ is TdApi.TextEntityTypeSpoiler -> append("sp")
+ is TdApi.TextEntityTypeCode -> append("c")
+ is TdApi.TextEntityTypePre -> append("p")
+ is TdApi.TextEntityTypeUrl -> append("url")
+ is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url)
+ is TdApi.TextEntityTypeMention -> append("m")
+ is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId)
+ is TdApi.TextEntityTypeHashtag -> append("h")
+ is TdApi.TextEntityTypeBotCommand -> append("bc")
+ is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId)
+ is TdApi.TextEntityTypeEmailAddress -> append("em")
+ is TdApi.TextEntityTypePhoneNumber -> append("ph")
+ else -> append("?")
+ }
+ }
+ }
+ }
+
+ private fun encodeMeta(vararg parts: Any?): String {
+ return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() }
+ }
+
+ private fun decodeMeta(raw: String?): List {
+ if (raw.isNullOrBlank()) return emptyList()
+ return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|')
+ }
+
+ private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair {
+ return when (contentType) {
+ "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3)
+ "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
+ "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2)
+ "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3)
+ "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6)
+ "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
+ "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5)
+ "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4)
+ else -> 0 to null
+ }
+ }
+
+ private fun resolveMessageDate(msg: TdApi.Message): Int {
+ return when (val schedulingState = msg.schedulingState) {
+ is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate
+ else -> msg.date
+ }
+ }
+
+ private companion object {
+ private const val META_SEPARATOR = '\u001F'
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt
new file mode 100644
index 00000000..e25f046d
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt
@@ -0,0 +1,279 @@
+package org.monogram.data.mapper.message
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withTimeout
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.chats.ChatCache
+import org.monogram.data.datasource.remote.TdMessageRemoteDataSource
+import org.monogram.data.gateway.TelegramGateway
+import org.monogram.data.mapper.SenderNameResolver
+import org.monogram.data.mapper.TdFileHelper
+import org.monogram.domain.repository.ChatInfoRepository
+import org.monogram.domain.repository.UserRepository
+import java.util.concurrent.ConcurrentHashMap
+
+internal data class ResolvedSender(
+ val senderId: Long,
+ val senderName: String,
+ val senderAvatar: String? = null,
+ val senderPersonalAvatar: String? = null,
+ val senderCustomTitle: String? = null,
+ val isSenderVerified: Boolean = false,
+ val isSenderPremium: Boolean = false,
+ val senderStatusEmojiId: Long = 0L,
+ val senderStatusEmojiPath: String? = null
+)
+
+internal class MessageSenderResolver(
+ private val gateway: TelegramGateway,
+ private val userRepository: UserRepository,
+ private val chatInfoRepository: ChatInfoRepository,
+ private val cache: ChatCache,
+ private val fileHelper: TdFileHelper
+) {
+ private data class SenderUserSnapshot(
+ val name: String,
+ val avatar: String?,
+ val personalAvatar: String?,
+ val isVerified: Boolean,
+ val isPremium: Boolean,
+ val statusEmojiId: Long,
+ val statusEmojiPath: String?
+ )
+
+ private data class SenderChatSnapshot(
+ val name: String,
+ val avatar: String?
+ )
+
+ private val senderUserSnapshotCache = ConcurrentHashMap()
+ private val senderChatSnapshotCache = ConcurrentHashMap()
+ private val senderRankCache = ConcurrentHashMap()
+ private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet()
+
+ val senderUpdateFlow: Flow
+ get() = userRepository.anyUserUpdateFlow
+
+ fun invalidateCache(userId: Long) {
+ if (userId <= 0L) return
+ senderUserSnapshotCache.remove(userId)
+ senderChatSnapshotCache.remove(userId)
+ senderRankCache.entries.removeIf { it.key.endsWith(":$userId") }
+ }
+
+ fun resolveNameFromCache(senderId: Long, fallback: String): String {
+ val user = cache.getUser(senderId)
+ if (user != null) {
+ return SenderNameResolver.fromParts(
+ firstName = user.firstName,
+ lastName = user.lastName,
+ fallback = fallback.ifBlank { "User" }
+ )
+ }
+
+ val chat = cache.getChat(senderId)
+ if (chat != null) {
+ return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" }
+ }
+
+ return fallback.ifBlank { "User" }
+ }
+
+ fun resolveFallbackSender(msg: TdApi.Message): ResolvedSender {
+ return when (val sender = msg.senderId) {
+ is TdApi.MessageSenderUser -> {
+ val senderId = sender.userId
+ val snapshot = senderUserSnapshotCache[senderId]
+ if (snapshot != null) {
+ ResolvedSender(
+ senderId = senderId,
+ senderName = snapshot.name.ifBlank { "User" },
+ senderAvatar = snapshot.avatar ?: snapshot.personalAvatar,
+ senderPersonalAvatar = snapshot.personalAvatar,
+ isSenderVerified = snapshot.isVerified,
+ isSenderPremium = snapshot.isPremium,
+ senderStatusEmojiId = snapshot.statusEmojiId,
+ senderStatusEmojiPath = snapshot.statusEmojiPath
+ )
+ } else {
+ val user = cache.getUser(senderId)
+ val fallbackName = if (user != null) {
+ SenderNameResolver.fromParts(user.firstName, user.lastName, "User")
+ } else {
+ "User"
+ }
+ val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) }
+ ?: user?.profilePhoto?.big?.local?.path?.takeIf { fileHelper.isValidPath(it) }
+ ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar)
+ }
+ }
+
+ is TdApi.MessageSenderChat -> {
+ val senderId = sender.chatId
+ val snapshot = senderChatSnapshotCache[senderId]
+ if (snapshot != null) {
+ ResolvedSender(
+ senderId = senderId,
+ senderName = snapshot.name.ifBlank { "User" },
+ senderAvatar = snapshot.avatar
+ )
+ } else {
+ val chat = cache.getChat(senderId)
+ val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User"
+ val avatar = chat?.photo?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) }
+ ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar)
+ }
+ }
+
+ else -> ResolvedSender(senderId = 0L, senderName = "User")
+ }
+ }
+
+ suspend fun resolveSender(msg: TdApi.Message): ResolvedSender {
+ var senderName = "User"
+ var senderAvatar: String? = null
+ var senderPersonalAvatar: String? = null
+ var senderCustomTitle: String? = null
+ var isSenderVerified = false
+ var isSenderPremium = false
+ var senderStatusEmojiId = 0L
+ var senderStatusEmojiPath: String? = null
+ val senderId: Long
+
+ when (val sender = msg.senderId) {
+ is TdApi.MessageSenderUser -> {
+ senderId = sender.userId
+ val cachedSnapshot = senderUserSnapshotCache[senderId]
+ if (cachedSnapshot != null) {
+ senderName = cachedSnapshot.name
+ senderAvatar = cachedSnapshot.avatar
+ senderPersonalAvatar = cachedSnapshot.personalAvatar
+ isSenderVerified = cachedSnapshot.isVerified
+ isSenderPremium = cachedSnapshot.isPremium
+ senderStatusEmojiId = cachedSnapshot.statusEmojiId
+ senderStatusEmojiPath = cachedSnapshot.statusEmojiPath
+ } else {
+ val user = try {
+ withTimeout(500) { userRepository.getUser(senderId) }
+ } catch (_: Exception) {
+ null
+ }
+
+ if (user != null) {
+ senderName = SenderNameResolver.fromParts(
+ firstName = user.firstName,
+ lastName = user.lastName,
+ fallback = "User"
+ )
+
+ senderAvatar = user.avatarPath.takeIf { fileHelper.isValidPath(it) }
+ senderPersonalAvatar = user.personalAvatarPath.takeIf { fileHelper.isValidPath(it) }
+ isSenderVerified = user.isVerified
+ isSenderPremium = user.isPremium
+ senderStatusEmojiId = user.statusEmojiId
+ senderStatusEmojiPath = user.statusEmojiPath
+
+ senderUserSnapshotCache[senderId] = SenderUserSnapshot(
+ name = senderName,
+ avatar = senderAvatar,
+ personalAvatar = senderPersonalAvatar,
+ isVerified = isSenderVerified,
+ isPremium = isSenderPremium,
+ statusEmojiId = senderStatusEmojiId,
+ statusEmojiPath = senderStatusEmojiPath
+ )
+ }
+ }
+
+ val chat = cache.getChat(msg.chatId)
+ val canGetMember = when (chat?.type) {
+ is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true
+ is TdApi.ChatTypeBasicGroup -> true
+ is TdApi.ChatTypeSupergroup -> {
+ val supergroup = chat.type as TdApi.ChatTypeSupergroup
+ val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId)
+ !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false)
+ }
+
+ else -> false
+ }
+
+ if (canGetMember) {
+ val rankKey = "${msg.chatId}:$senderId"
+ val cachedRank = senderRankCache[rankKey]
+ if (cachedRank != null) {
+ senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL }
+ } else {
+ val member = try {
+ withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) }
+ } catch (_: Exception) {
+ null
+ }
+
+ senderCustomTitle = member?.rank
+ senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL
+ }
+ }
+ }
+
+ is TdApi.MessageSenderChat -> {
+ senderId = sender.chatId
+ val cachedSnapshot = senderChatSnapshotCache[senderId]
+ if (cachedSnapshot != null) {
+ senderName = cachedSnapshot.name
+ senderAvatar = cachedSnapshot.avatar
+ } else {
+ val chat = try {
+ withTimeout(500) {
+ cache.getChat(senderId)
+ ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) }
+ }
+ } catch (_: Exception) {
+ null
+ }
+
+ if (chat != null) {
+ senderName = chat.title
+ val photo = chat.photo?.small
+ if (photo != null) {
+ senderAvatar = photo.local.path.takeIf { fileHelper.isValidPath(it) }
+ if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) {
+ fileHelper.enqueueDownload(
+ photo.id,
+ 16,
+ TdMessageRemoteDataSource.DownloadType.DEFAULT,
+ 0,
+ 0,
+ false
+ )
+ }
+ }
+
+ senderChatSnapshotCache[senderId] = SenderChatSnapshot(
+ name = senderName,
+ avatar = senderAvatar
+ )
+ }
+ }
+ }
+
+ else -> senderId = 0L
+ }
+
+ return ResolvedSender(
+ senderId = senderId,
+ senderName = senderName,
+ senderAvatar = senderAvatar,
+ senderPersonalAvatar = senderPersonalAvatar,
+ senderCustomTitle = senderCustomTitle,
+ isSenderVerified = isSenderVerified,
+ isSenderPremium = isSenderPremium,
+ senderStatusEmojiId = senderStatusEmojiId,
+ senderStatusEmojiPath = senderStatusEmojiPath
+ )
+ }
+
+ private companion object {
+ private const val NO_RANK_SENTINEL = "__NO_RANK__"
+ }
+}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt
new file mode 100644
index 00000000..20272a6e
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt
@@ -0,0 +1,271 @@
+package org.monogram.data.mapper.user
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.db.model.ChatEntity
+import org.monogram.data.db.model.ChatFullInfoEntity
+import org.monogram.domain.models.BotVerificationModel
+import org.monogram.domain.models.ChatFullInfoModel
+
+fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity {
+ return ChatFullInfoEntity(
+ chatId = chatId,
+ description = description.ifEmpty { null },
+ inviteLink = inviteLink?.inviteLink,
+ memberCount = memberCount,
+ onlineCount = 0,
+ administratorCount = administratorCount,
+ restrictedCount = restrictedCount,
+ bannedCount = bannedCount,
+ directMessagesChatId = directMessagesChatId,
+ commonGroupsCount = 0,
+ giftCount = giftCount,
+ isBlocked = false,
+ botInfo = null,
+ botInfoData = null,
+ blockListType = null,
+ publicPhotoPath = null,
+ usesUnofficialApp = false,
+ hasSponsoredMessagesEnabled = false,
+ needPhoneNumberPrivacyException = false,
+ botVerificationBotUserId = botVerification?.botUserId ?: 0L,
+ botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L,
+ botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null },
+ mainProfileTab = mainProfileTab.toTypeString(),
+ firstProfileAudioData = null,
+ ratingData = null,
+ pendingRatingData = null,
+ pendingRatingDate = 0,
+ slowModeDelay = slowModeDelay,
+ slowModeDelayExpiresIn = slowModeDelayExpiresIn,
+ locationAddress = location?.address?.ifEmpty { null },
+ canEnablePaidMessages = canEnablePaidMessages,
+ canEnablePaidReaction = canEnablePaidReaction,
+ hasHiddenMembers = hasHiddenMembers,
+ canHideMembers = canHideMembers,
+ canSetStickerSet = canSetStickerSet,
+ canSetLocation = canSetLocation,
+ canGetMembers = canGetMembers,
+ canGetStatistics = canGetStatistics,
+ canGetRevenueStatistics = canGetRevenueStatistics,
+ canGetStarRevenueStatistics = canGetStarRevenueStatistics,
+ canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam,
+ isAllHistoryAvailable = isAllHistoryAvailable,
+ canHaveSponsoredMessages = canHaveSponsoredMessages,
+ hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled,
+ hasPaidMediaAllowed = hasPaidMediaAllowed,
+ hasPinnedStories = hasPinnedStories,
+ myBoostCount = myBoostCount,
+ unrestrictBoostCount = unrestrictBoostCount,
+ stickerSetId = stickerSetId,
+ customEmojiStickerSetId = customEmojiStickerSetId,
+ botCommandsData = encodeBotCommands(botCommands),
+ upgradedFromBasicGroupId = upgradedFromBasicGroupId,
+ upgradedFromMaxMessageId = upgradedFromMaxMessageId,
+ linkedChatId = linkedChatId,
+ note = null,
+ canBeCalled = false,
+ supportsVideoCalls = false,
+ hasPrivateCalls = false,
+ hasPrivateForwards = false,
+ hasRestrictedVoiceAndVideoNoteMessages = false,
+ hasPostedToProfileStories = false,
+ setChatBackground = false,
+ incomingPaidMessageStarCount = 0,
+ outgoingPaidMessageStarCount = outgoingPaidMessageStarCount,
+ createdAt = System.currentTimeMillis()
+ )
+}
+
+fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity {
+ return ChatFullInfoEntity(
+ chatId = chatId,
+ description = description.ifEmpty { null },
+ inviteLink = inviteLink?.inviteLink,
+ memberCount = members.size,
+ onlineCount = 0,
+ administratorCount = 0,
+ restrictedCount = 0,
+ bannedCount = 0,
+ commonGroupsCount = 0,
+ giftCount = 0,
+ isBlocked = false,
+ botInfo = null,
+ slowModeDelay = 0,
+ locationAddress = null,
+ canSetStickerSet = false,
+ canSetLocation = false,
+ canGetMembers = false,
+ canGetStatistics = false,
+ canGetRevenueStatistics = false,
+ linkedChatId = 0,
+ note = null,
+ canBeCalled = false,
+ supportsVideoCalls = false,
+ hasPrivateCalls = false,
+ hasPrivateForwards = false,
+ hasRestrictedVoiceAndVideoNoteMessages = false,
+ hasPostedToProfileStories = false,
+ setChatBackground = false,
+ incomingPaidMessageStarCount = 0,
+ outgoingPaidMessageStarCount = 0,
+ createdAt = System.currentTimeMillis()
+ )
+}
+
+fun ChatEntity.toTdApiChat(): TdApi.Chat {
+ return TdApi.Chat().apply {
+ id = this@toTdApiChat.id
+ title = this@toTdApiChat.title
+ unreadCount = this@toTdApiChat.unreadCount
+ unreadMentionCount = this@toTdApiChat.unreadMentionCount
+ unreadReactionCount = this@toTdApiChat.unreadReactionCount
+ photo = avatarPath?.let { path ->
+ TdApi.ChatPhotoInfo().apply {
+ small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } }
+ }
+ }
+ lastMessage = TdApi.Message().apply {
+ content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) }
+ date = lastMessageTime.toIntOrNull() ?: 0
+ id = this@toTdApiChat.lastMessageId
+ isOutgoing = this@toTdApiChat.isLastMessageOutgoing
+ }
+ positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null))
+ notificationSettings = TdApi.ChatNotificationSettings().apply {
+ muteFor = if (isMuted) Int.MAX_VALUE else 0
+ }
+ type = when (this@toTdApiChat.type) {
+ "PRIVATE" -> TdApi.ChatTypePrivate().apply {
+ userId =
+ if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId
+ ?: 0L)
+ }
+
+ "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply {
+ basicGroupId = this@toTdApiChat.basicGroupId
+ }
+
+ "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel)
+ "SECRET" -> TdApi.ChatTypeSecret().apply {
+ secretChatId = this@toTdApiChat.secretChatId
+ }
+
+ else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId }
+ }
+ isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread
+ hasProtectedContent = this@toTdApiChat.hasProtectedContent
+ isTranslatable = this@toTdApiChat.isTranslatable
+ viewAsTopics = this@toTdApiChat.viewAsTopics
+ accentColorId = this@toTdApiChat.accentColorId
+ profileAccentColorId = this@toTdApiChat.profileAccentColorId
+ backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId
+ messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime
+ canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf
+ canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers
+ canBeReported = this@toTdApiChat.canBeReported
+ lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId
+ lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId
+ replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId
+ messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) }
+ blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null
+ permissions = TdApi.ChatPermissions(
+ this@toTdApiChat.permissionCanSendBasicMessages,
+ this@toTdApiChat.permissionCanSendAudios,
+ this@toTdApiChat.permissionCanSendDocuments,
+ this@toTdApiChat.permissionCanSendPhotos,
+ this@toTdApiChat.permissionCanSendVideos,
+ this@toTdApiChat.permissionCanSendVideoNotes,
+ this@toTdApiChat.permissionCanSendVoiceNotes,
+ this@toTdApiChat.permissionCanSendPolls,
+ this@toTdApiChat.permissionCanSendOtherMessages,
+ this@toTdApiChat.permissionCanAddLinkPreviews,
+ this@toTdApiChat.permissionCanEditTag,
+ this@toTdApiChat.permissionCanChangeInfo,
+ this@toTdApiChat.permissionCanInviteUsers,
+ this@toTdApiChat.permissionCanPinMessages,
+ this@toTdApiChat.permissionCanCreateTopics
+ )
+ clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}"
+ }
+}
+
+fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel {
+ val botVerificationModel = if (
+ botVerificationBotUserId != 0L ||
+ botVerificationIconCustomEmojiId != 0L ||
+ !botVerificationCustomDescription.isNullOrEmpty()
+ ) {
+ BotVerificationModel(
+ botUserId = botVerificationBotUserId,
+ iconCustomEmojiId = botVerificationIconCustomEmojiId,
+ customDescription = botVerificationCustomDescription
+ )
+ } else {
+ null
+ }
+
+ return ChatFullInfoModel(
+ description = description,
+ inviteLink = inviteLink,
+ memberCount = memberCount,
+ onlineCount = onlineCount,
+ administratorCount = administratorCount,
+ restrictedCount = restrictedCount,
+ bannedCount = bannedCount,
+ directMessagesChatId = directMessagesChatId,
+ commonGroupsCount = commonGroupsCount,
+ giftCount = giftCount,
+ isBlocked = isBlocked,
+ botInfo = botInfo,
+ botInfoModel = null,
+ blockListType = blockListType,
+ canGetRevenueStatistics = canGetRevenueStatistics,
+ canGetStarRevenueStatistics = canGetStarRevenueStatistics,
+ canEnablePaidMessages = canEnablePaidMessages,
+ canEnablePaidReaction = canEnablePaidReaction,
+ hasHiddenMembers = hasHiddenMembers,
+ canHideMembers = canHideMembers,
+ canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam,
+ isAllHistoryAvailable = isAllHistoryAvailable,
+ canHaveSponsoredMessages = canHaveSponsoredMessages,
+ hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled,
+ hasPaidMediaAllowed = hasPaidMediaAllowed,
+ hasPinnedStories = hasPinnedStories,
+ linkedChatId = linkedChatId,
+ businessInfo = null,
+ publicPhotoPath = publicPhotoPath,
+ note = note,
+ usesUnofficialApp = usesUnofficialApp,
+ hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled,
+ needPhoneNumberPrivacyException = needPhoneNumberPrivacyException,
+ botVerification = botVerificationModel,
+ mainProfileTab = mainProfileTab.toProfileTabType(),
+ firstProfileAudio = decodeProfileAudio(firstProfileAudioData),
+ rating = decodeUserRating(ratingData),
+ pendingRating = decodeUserRating(pendingRatingData),
+ pendingRatingDate = pendingRatingDate,
+ canBeCalled = canBeCalled,
+ supportsVideoCalls = supportsVideoCalls,
+ hasPrivateCalls = hasPrivateCalls,
+ hasPrivateForwards = hasPrivateForwards,
+ hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
+ hasPostedToProfileStories = hasPostedToProfileStories,
+ setChatBackground = setChatBackground,
+ slowModeDelay = slowModeDelay,
+ slowModeDelayExpiresIn = slowModeDelayExpiresIn,
+ locationAddress = locationAddress,
+ canSetStickerSet = canSetStickerSet,
+ canSetLocation = canSetLocation,
+ canGetMembers = canGetMembers,
+ canGetStatistics = canGetStatistics,
+ myBoostCount = myBoostCount,
+ unrestrictBoostCount = unrestrictBoostCount,
+ stickerSetId = stickerSetId,
+ customEmojiStickerSetId = customEmojiStickerSetId,
+ botCommands = decodeBotCommands(botCommandsData),
+ upgradedFromBasicGroupId = upgradedFromBasicGroupId,
+ upgradedFromMaxMessageId = upgradedFromMaxMessageId,
+ incomingPaidMessageStarCount = incomingPaidMessageStarCount,
+ outgoingPaidMessageStarCount = outgoingPaidMessageStarCount
+ )
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt
new file mode 100644
index 00000000..7bffb8a8
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt
@@ -0,0 +1,292 @@
+package org.monogram.data.mapper.user
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.mapper.isChannelType
+import org.monogram.data.mapper.isGroupType
+import org.monogram.data.mapper.isValidFilePath
+import org.monogram.data.mapper.toDomainChatType
+import org.monogram.domain.models.*
+
+fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel {
+ val birthdate = birthdate?.let { date ->
+ BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null)
+ }
+ return ChatFullInfoModel(
+ description = bio?.text?.ifEmpty { null },
+ commonGroupsCount = groupInCommonCount,
+ giftCount = giftCount,
+ birthdate = birthdate,
+ isBlocked = blockList != null,
+ blockListType = blockList.toTypeString(),
+ botInfo = botInfo?.description?.ifEmpty { null },
+ botInfoModel = botInfo?.toDomain(),
+ canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false,
+ linkedChatId = personalChatId,
+ businessInfo = businessInfo?.toDomain(),
+ publicPhotoPath = publicPhoto.resolveChatPhotoPath(),
+ usesUnofficialApp = usesUnofficialApp,
+ hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled,
+ needPhoneNumberPrivacyException = needPhoneNumberPrivacyException,
+ botVerification = botVerification?.toDomain(),
+ mainProfileTab = mainProfileTab?.toDomain(),
+ firstProfileAudio = firstProfileAudio?.toDomain(),
+ rating = rating?.toDomain(),
+ pendingRating = pendingRating?.toDomain(),
+ pendingRatingDate = pendingRatingDate,
+ note = note?.text?.ifEmpty { null },
+ canBeCalled = canBeCalled,
+ supportsVideoCalls = supportsVideoCalls,
+ hasPrivateCalls = hasPrivateCalls,
+ hasPrivateForwards = hasPrivateForwards,
+ hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
+ hasPostedToProfileStories = hasPostedToProfileStories,
+ setChatBackground = setChatBackground,
+ incomingPaidMessageStarCount = incomingPaidMessageStarCount,
+ outgoingPaidMessageStarCount = outgoingPaidMessageStarCount
+ )
+}
+
+fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat(
+ supergroup: TdApi.Supergroup?
+): ChatFullInfoModel {
+ val link = inviteLink?.inviteLink
+ ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" }
+ return ChatFullInfoModel(
+ description = description.ifEmpty { null },
+ inviteLink = link,
+ memberCount = memberCount,
+ administratorCount = administratorCount,
+ restrictedCount = restrictedCount,
+ bannedCount = bannedCount,
+ directMessagesChatId = directMessagesChatId,
+ slowModeDelay = slowModeDelay,
+ slowModeDelayExpiresIn = slowModeDelayExpiresIn,
+ locationAddress = location?.address?.ifEmpty { null },
+ giftCount = giftCount,
+ canEnablePaidMessages = canEnablePaidMessages,
+ canEnablePaidReaction = canEnablePaidReaction,
+ hasHiddenMembers = hasHiddenMembers,
+ canHideMembers = canHideMembers,
+ canSetStickerSet = canSetStickerSet,
+ canSetLocation = canSetLocation,
+ canGetMembers = canGetMembers,
+ canGetStatistics = canGetStatistics,
+ canGetRevenueStatistics = canGetRevenueStatistics,
+ canGetStarRevenueStatistics = canGetStarRevenueStatistics,
+ canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam,
+ isAllHistoryAvailable = isAllHistoryAvailable,
+ canHaveSponsoredMessages = canHaveSponsoredMessages,
+ hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled,
+ hasPaidMediaAllowed = hasPaidMediaAllowed,
+ hasPinnedStories = hasPinnedStories,
+ linkedChatId = linkedChatId,
+ botVerification = botVerification?.toDomain(),
+ mainProfileTab = mainProfileTab?.toDomain(),
+ myBoostCount = myBoostCount,
+ unrestrictBoostCount = unrestrictBoostCount,
+ stickerSetId = stickerSetId,
+ customEmojiStickerSetId = customEmojiStickerSetId,
+ botCommands = botCommands?.map { it.toDomain() } ?: emptyList(),
+ upgradedFromBasicGroupId = upgradedFromBasicGroupId,
+ upgradedFromMaxMessageId = upgradedFromMaxMessageId,
+ outgoingPaidMessageStarCount = outgoingPaidMessageStarCount
+ )
+}
+
+fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel {
+ return ChatFullInfoModel(
+ description = description.ifEmpty { null },
+ inviteLink = inviteLink?.inviteLink,
+ memberCount = members.size
+ )
+}
+
+fun TdApi.Chat.toDomain(): ChatModel {
+ val isChannel = type.isChannelType()
+ return ChatModel(
+ id = id,
+ title = title,
+ avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) },
+ unreadCount = unreadCount,
+ isMuted = notificationSettings.muteFor > 0,
+ isChannel = isChannel,
+ isGroup = type.isGroupType(),
+ type = type.toDomainChatType(),
+ lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: ""
+ )
+}
+
+private fun TdApi.BotVerification.toDomain(): BotVerificationModel {
+ return BotVerificationModel(
+ botUserId = botUserId,
+ iconCustomEmojiId = iconCustomEmojiId,
+ customDescription = customDescription.text.ifEmpty { null }
+ )
+}
+
+private fun TdApi.BotVerificationParameters.toDomain(): BotVerificationParametersModel {
+ return BotVerificationParametersModel(
+ iconCustomEmojiId = iconCustomEmojiId,
+ organizationName = organizationName.ifEmpty { null },
+ defaultCustomDescription = defaultCustomDescription?.text?.ifEmpty { null },
+ canSetCustomDescription = canSetCustomDescription
+ )
+}
+
+private fun TdApi.UserRating.toDomain(): UserRatingModel {
+ return UserRatingModel(
+ level = level,
+ isMaximumLevelReached = isMaximumLevelReached,
+ rating = rating,
+ currentLevelRating = currentLevelRating,
+ nextLevelRating = nextLevelRating
+ )
+}
+
+private fun TdApi.Audio.toDomain(): ProfileAudioModel {
+ val filePath = audio.local.path.takeIf { isValidFilePath(it) }
+ return ProfileAudioModel(
+ duration = duration,
+ title = title.ifEmpty { null },
+ performer = performer.ifEmpty { null },
+ fileName = fileName.ifEmpty { null },
+ mimeType = mimeType.ifEmpty { null },
+ fileId = audio.id,
+ filePath = filePath
+ )
+}
+
+private fun TdApi.ProfileTab.toDomain(): ProfileTabType {
+ return when (this) {
+ is TdApi.ProfileTabPosts -> ProfileTabType.POSTS
+ is TdApi.ProfileTabGifts -> ProfileTabType.GIFTS
+ is TdApi.ProfileTabMedia -> ProfileTabType.MEDIA
+ is TdApi.ProfileTabFiles -> ProfileTabType.FILES
+ is TdApi.ProfileTabLinks -> ProfileTabType.LINKS
+ is TdApi.ProfileTabMusic -> ProfileTabType.MUSIC
+ is TdApi.ProfileTabVoice -> ProfileTabType.VOICE
+ is TdApi.ProfileTabGifs -> ProfileTabType.GIFS
+ else -> ProfileTabType.UNKNOWN
+ }
+}
+
+private fun TdApi.BotCommand.toDomain(): BotCommandModel {
+ return BotCommandModel(
+ command = command,
+ description = description
+ )
+}
+
+private fun TdApi.BotCommands.toDomain(): SupergroupBotCommandsModel {
+ return SupergroupBotCommandsModel(
+ botUserId = botUserId,
+ commands = commands?.map { it.toDomain() } ?: emptyList()
+ )
+}
+
+private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel {
+ return ChatAdministratorRightsModel(
+ canManageChat = canManageChat,
+ canChangeInfo = canChangeInfo,
+ canPostMessages = canPostMessages,
+ canEditMessages = canEditMessages,
+ canDeleteMessages = canDeleteMessages,
+ canInviteUsers = canInviteUsers,
+ canRestrictMembers = canRestrictMembers,
+ canPinMessages = canPinMessages,
+ canManageTopics = canManageTopics,
+ canPromoteMembers = canPromoteMembers,
+ canManageVideoChats = canManageVideoChats,
+ canPostStories = canPostStories,
+ canEditStories = canEditStories,
+ canDeleteStories = canDeleteStories,
+ canManageDirectMessages = canManageDirectMessages,
+ canManageTags = canManageTags,
+ isAnonymous = isAnonymous
+ )
+}
+
+private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel {
+ return AffiliateProgramInfoModel(
+ commissionPerMille = parameters.commissionPerMille,
+ monthCount = parameters.monthCount,
+ endDate = endDate,
+ dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount,
+ dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount
+ )
+}
+
+private fun TdApi.BotInfo.toDomain(): BotInfoModel {
+ return BotInfoModel(
+ commands = commands?.map { it.toDomain() } ?: emptyList(),
+ menuButton = when (val button = menuButton) {
+ is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(button.text, button.url)
+ null -> BotMenuButtonModel.Commands
+ else -> BotMenuButtonModel.Default
+ },
+ shortDescription = shortDescription.ifEmpty { null },
+ description = description.ifEmpty { null },
+ photoFileId = photo?.sizes?.lastOrNull()?.photo?.id ?: 0,
+ photoPath = photo?.resolvePhotoPath(),
+ animationFileId = animation?.animation?.id ?: 0,
+ animationPath = animation?.animation?.local?.path?.takeIf { isValidFilePath(it) },
+ managerBotUserId = managerBotUserId,
+ privacyPolicyUrl = privacyPolicyUrl.ifEmpty { null },
+ defaultGroupAdministratorRights = defaultGroupAdministratorRights?.toDomain(),
+ defaultChannelAdministratorRights = defaultChannelAdministratorRights?.toDomain(),
+ affiliateProgram = affiliateProgram?.toDomain(),
+ webAppBackgroundLightColor = webAppBackgroundLightColor,
+ webAppBackgroundDarkColor = webAppBackgroundDarkColor,
+ webAppHeaderLightColor = webAppHeaderLightColor,
+ webAppHeaderDarkColor = webAppHeaderDarkColor,
+ verificationParameters = verificationParameters?.toDomain(),
+ canGetRevenueStatistics = canGetRevenueStatistics,
+ canManageEmojiStatus = canManageEmojiStatus,
+ hasMediaPreviews = hasMediaPreviews,
+ editCommandsLinkType = editCommandsLink?.javaClass?.simpleName,
+ editDescriptionLinkType = editDescriptionLink?.javaClass?.simpleName,
+ editDescriptionMediaLinkType = editDescriptionMediaLink?.javaClass?.simpleName,
+ editSettingsLinkType = editSettingsLink?.javaClass?.simpleName
+ )
+}
+
+private fun TdApi.Photo.resolvePhotoPath(): String? {
+ val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull()
+ return bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+}
+
+private fun TdApi.ChatPhoto?.resolveChatPhotoPath(): String? {
+ if (this == null) return null
+ val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull()
+ return animation?.file?.local?.path?.takeIf { isValidFilePath(it) }
+ ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+}
+
+private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel {
+ return BusinessInfoModel(
+ location = location?.let {
+ BusinessLocationModel(
+ it.location!!.latitude,
+ it.location!!.longitude,
+ it.address
+ )
+ },
+ openingHours = openingHours?.let {
+ BusinessOpeningHoursModel(
+ it.timeZoneId,
+ it.openingHours.map { interval ->
+ BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute)
+ }
+ )
+ },
+ startPage = startPage?.let {
+ BusinessStartPageModel(
+ title = it.title,
+ message = it.message,
+ stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) }
+ )
+ },
+ nextOpenIn = nextOpenIn,
+ nextCloseIn = nextCloseIn
+ )
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt
new file mode 100644
index 00000000..d85bfc45
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt
@@ -0,0 +1,107 @@
+package org.monogram.data.mapper.user
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.mapper.toDomainChatPermissions
+import org.monogram.data.mapper.toTdApiChatPermissions
+import org.monogram.domain.models.GroupMemberModel
+import org.monogram.domain.models.UserModel
+import org.monogram.domain.repository.ChatMemberStatus
+import org.monogram.domain.repository.ChatMembersFilter
+
+fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel {
+ val rank = when (this.status) {
+ is TdApi.ChatMemberStatusCreator -> "Owner"
+ is TdApi.ChatMemberStatusAdministrator -> "Admin"
+ else -> null
+ }
+ return GroupMemberModel(
+ user = user,
+ rank = rank,
+ status = this.status.toDomain()
+ )
+}
+
+fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus {
+ return when (this) {
+ is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator
+ is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator(
+ customTitle = "",
+ canBeEdited = canBeEdited,
+ canManageChat = rights.canManageChat,
+ canChangeInfo = rights.canChangeInfo,
+ canPostMessages = rights.canPostMessages,
+ canEditMessages = rights.canEditMessages,
+ canDeleteMessages = rights.canDeleteMessages,
+ canInviteUsers = rights.canInviteUsers,
+ canRestrictMembers = rights.canRestrictMembers,
+ canPinMessages = rights.canPinMessages,
+ canManageTopics = rights.canManageTopics,
+ canPromoteMembers = rights.canPromoteMembers,
+ canManageVideoChats = rights.canManageVideoChats,
+ canPostStories = rights.canPostStories,
+ canEditStories = rights.canEditStories,
+ canDeleteStories = rights.canDeleteStories,
+ canManageDirectMessages = rights.canManageDirectMessages,
+ isAnonymous = rights.isAnonymous
+ )
+
+ is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted(
+ isMember = isMember,
+ restrictedUntilDate = restrictedUntilDate,
+ permissions = permissions.toDomainChatPermissions()
+ )
+
+ is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate)
+ is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left
+ else -> ChatMemberStatus.Member
+ }
+}
+
+fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter {
+ return when (this) {
+ is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent()
+ is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators()
+ is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned()
+ is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted()
+ is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots()
+ is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query)
+ }
+}
+
+fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus {
+ return when (this) {
+ is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember()
+ is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator(
+ canBeEdited,
+ TdApi.ChatAdministratorRights(
+ canManageChat,
+ canChangeInfo,
+ canPostMessages,
+ canEditMessages,
+ canDeleteMessages,
+ canInviteUsers,
+ canRestrictMembers,
+ canPinMessages,
+ canManageTopics,
+ canPromoteMembers,
+ canManageVideoChats,
+ canPostStories,
+ canEditStories,
+ canDeleteStories,
+ canManageDirectMessages,
+ false,
+ isAnonymous
+ )
+ )
+
+ is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted(
+ isMember,
+ restrictedUntilDate,
+ permissions.toTdApiChatPermissions()
+ )
+
+ is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft()
+ is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate)
+ is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true)
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt
new file mode 100644
index 00000000..e750e83b
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt
@@ -0,0 +1,264 @@
+package org.monogram.data.mapper.user
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.domain.models.*
+
+internal fun encodeChatAdministratorRights(rights: TdApi.ChatAdministratorRights?): String? {
+ if (rights == null) return null
+ return listOf(
+ rights.canManageChat,
+ rights.canChangeInfo,
+ rights.canPostMessages,
+ rights.canEditMessages,
+ rights.canDeleteMessages,
+ rights.canInviteUsers,
+ rights.canRestrictMembers,
+ rights.canPinMessages,
+ rights.canManageTopics,
+ rights.canPromoteMembers,
+ rights.canManageVideoChats,
+ rights.canPostStories,
+ rights.canEditStories,
+ rights.canDeleteStories,
+ rights.canManageDirectMessages,
+ rights.canManageTags,
+ rights.isAnonymous
+ ).joinToString("|") { if (it) "1" else "0" }
+}
+
+internal fun decodeChatAdministratorRights(data: String?): TdApi.ChatAdministratorRights? {
+ if (data.isNullOrBlank()) return null
+ val values = data.split('|')
+ fun bit(index: Int): Boolean = values.getOrNull(index) == "1"
+ return TdApi.ChatAdministratorRights(
+ bit(0),
+ bit(1),
+ bit(2),
+ bit(3),
+ bit(4),
+ bit(5),
+ bit(6),
+ bit(7),
+ bit(8),
+ bit(9),
+ bit(10),
+ bit(11),
+ bit(12),
+ bit(13),
+ bit(14),
+ bit(15),
+ bit(16)
+ )
+}
+
+internal fun encodeAffiliateProgramInfo(affiliateProgram: TdApi.AffiliateProgramInfo?): String? {
+ if (affiliateProgram == null) return null
+ val params = affiliateProgram.parameters
+ val amount = affiliateProgram.dailyRevenuePerUserAmount
+ return listOf(
+ params.commissionPerMille.toString(),
+ params.monthCount.toString(),
+ affiliateProgram.endDate.toString(),
+ amount.starCount.toString(),
+ amount.nanostarCount.toString()
+ ).joinToString("|")
+}
+
+internal fun decodeAffiliateProgramInfo(data: String?): TdApi.AffiliateProgramInfo? {
+ if (data.isNullOrBlank()) return null
+ val values = data.split('|')
+ val commissionPerMille = values.getOrNull(0)?.toIntOrNull() ?: return null
+ val monthCount = values.getOrNull(1)?.toIntOrNull() ?: return null
+ val endDate = values.getOrNull(2)?.toIntOrNull() ?: return null
+ val starCount = values.getOrNull(3)?.toLongOrNull() ?: return null
+ val nanostarCount = values.getOrNull(4)?.toIntOrNull() ?: return null
+ return TdApi.AffiliateProgramInfo(
+ TdApi.AffiliateProgramParameters(commissionPerMille, monthCount),
+ endDate,
+ TdApi.StarAmount(starCount, nanostarCount)
+ )
+}
+
+internal fun encodeProfileAudio(audio: ProfileAudioModel?): String? {
+ if (audio == null) return null
+ return listOf(
+ audio.duration.toString(),
+ audio.title.orEmpty().escapeStorage(),
+ audio.performer.orEmpty().escapeStorage(),
+ audio.fileName.orEmpty().escapeStorage(),
+ audio.mimeType.orEmpty().escapeStorage(),
+ audio.fileId.toString(),
+ audio.filePath.orEmpty().escapeStorage()
+ ).joinToString("|")
+}
+
+internal fun decodeProfileAudio(data: String?): ProfileAudioModel? {
+ if (data.isNullOrBlank()) return null
+ val parts = data.split('|')
+ return ProfileAudioModel(
+ duration = parts.getOrNull(0)?.toIntOrNull() ?: 0,
+ title = parts.getOrNull(1)?.unescapeStorage()?.ifEmpty { null },
+ performer = parts.getOrNull(2)?.unescapeStorage()?.ifEmpty { null },
+ fileName = parts.getOrNull(3)?.unescapeStorage()?.ifEmpty { null },
+ mimeType = parts.getOrNull(4)?.unescapeStorage()?.ifEmpty { null },
+ fileId = parts.getOrNull(5)?.toIntOrNull() ?: 0,
+ filePath = parts.getOrNull(6)?.unescapeStorage()?.ifEmpty { null }
+ )
+}
+
+internal fun encodeUserRating(rating: UserRatingModel?): String? {
+ if (rating == null) return null
+ return listOf(
+ rating.level.toString(),
+ if (rating.isMaximumLevelReached) "1" else "0",
+ rating.rating.toString(),
+ rating.currentLevelRating.toString(),
+ rating.nextLevelRating.toString()
+ ).joinToString("|")
+}
+
+internal fun decodeUserRating(data: String?): UserRatingModel? {
+ if (data.isNullOrBlank()) return null
+ val parts = data.split('|')
+ return UserRatingModel(
+ level = parts.getOrNull(0)?.toIntOrNull() ?: 0,
+ isMaximumLevelReached = parts.getOrNull(1) == "1",
+ rating = parts.getOrNull(2)?.toLongOrNull() ?: 0L,
+ currentLevelRating = parts.getOrNull(3)?.toLongOrNull() ?: 0L,
+ nextLevelRating = parts.getOrNull(4)?.toLongOrNull() ?: 0L
+ )
+}
+
+internal fun encodeBotCommands(commands: Array?): String? {
+ if (commands.isNullOrEmpty()) return null
+ return commands.joinToString("\n") { botCommands ->
+ val serializedCommands = (botCommands.commands ?: emptyArray()).joinToString(";") { command ->
+ "${command.command.escapeStorage()},${command.description.escapeStorage()}"
+ }
+ "${botCommands.botUserId}:$serializedCommands"
+ }
+}
+
+internal fun encodeBotInfoCommands(commands: Array?): String? {
+ if (commands.isNullOrEmpty()) return null
+ return commands.joinToString(";") { command ->
+ "${command.command.escapeStorage()},${command.description.escapeStorage()}"
+ }
+}
+
+internal fun decodeBotInfoCommands(data: String?): Array {
+ if (data.isNullOrBlank()) return emptyArray()
+ return data.split(';').mapNotNull { item ->
+ val commandSeparator = item.indexOf(',')
+ if (commandSeparator < 0) return@mapNotNull null
+ val command = item.substring(0, commandSeparator).unescapeStorage()
+ val description = item.substring(commandSeparator + 1).unescapeStorage()
+ TdApi.BotCommand(command, description)
+ }.toTypedArray()
+}
+
+internal fun decodeBotCommands(data: String?): List {
+ if (data.isNullOrBlank()) return emptyList()
+ return data.split('\n').mapNotNull { line ->
+ val separator = line.indexOf(':')
+ if (separator <= 0) return@mapNotNull null
+ val botUserId = line.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
+ val commandsRaw = line.substring(separator + 1)
+ val commands = if (commandsRaw.isBlank()) {
+ emptyList()
+ } else {
+ commandsRaw.split(';').mapNotNull { item ->
+ val commandSeparator = item.indexOf(',')
+ if (commandSeparator < 0) return@mapNotNull null
+ val command = item.substring(0, commandSeparator).unescapeStorage()
+ val description = item.substring(commandSeparator + 1).unescapeStorage()
+ BotCommandModel(command, description)
+ }
+ }
+ SupergroupBotCommandsModel(botUserId = botUserId, commands = commands)
+ }
+}
+
+internal fun TdApi.ProfileTab?.toTypeString(): String? {
+ return when (this) {
+ is TdApi.ProfileTabPosts -> "POSTS"
+ is TdApi.ProfileTabGifts -> "GIFTS"
+ is TdApi.ProfileTabMedia -> "MEDIA"
+ is TdApi.ProfileTabFiles -> "FILES"
+ is TdApi.ProfileTabLinks -> "LINKS"
+ is TdApi.ProfileTabMusic -> "MUSIC"
+ is TdApi.ProfileTabVoice -> "VOICE"
+ is TdApi.ProfileTabGifs -> "GIFS"
+ else -> null
+ }
+}
+
+internal fun String?.toTdApiProfileTab(): TdApi.ProfileTab? {
+ return when (this) {
+ "POSTS" -> TdApi.ProfileTabPosts()
+ "GIFTS" -> TdApi.ProfileTabGifts()
+ "MEDIA" -> TdApi.ProfileTabMedia()
+ "FILES" -> TdApi.ProfileTabFiles()
+ "LINKS" -> TdApi.ProfileTabLinks()
+ "MUSIC" -> TdApi.ProfileTabMusic()
+ "VOICE" -> TdApi.ProfileTabVoice()
+ "GIFS" -> TdApi.ProfileTabGifs()
+ else -> null
+ }
+}
+
+internal fun String?.toProfileTabType(): ProfileTabType? {
+ return when (this) {
+ "POSTS" -> ProfileTabType.POSTS
+ "GIFTS" -> ProfileTabType.GIFTS
+ "MEDIA" -> ProfileTabType.MEDIA
+ "FILES" -> ProfileTabType.FILES
+ "LINKS" -> ProfileTabType.LINKS
+ "MUSIC" -> ProfileTabType.MUSIC
+ "VOICE" -> ProfileTabType.VOICE
+ "GIFS" -> ProfileTabType.GIFS
+ else -> null
+ }
+}
+
+internal fun String?.toTdApiChatPhoto(): TdApi.ChatPhoto? {
+ if (this.isNullOrBlank()) return null
+ return TdApi.ChatPhoto().apply {
+ sizes = arrayOf(
+ TdApi.PhotoSize().apply {
+ type = "x"
+ width = 0
+ height = 0
+ photo = TdApi.File().apply {
+ local = TdApi.LocalFile().apply { path = this@toTdApiChatPhoto }
+ }
+ }
+ )
+ }
+}
+
+internal fun TdApi.BlockList?.toTypeString(): String? {
+ return when (this) {
+ is TdApi.BlockListMain -> "MAIN"
+ is TdApi.BlockListStories -> "STORIES"
+ else -> null
+ }
+}
+
+internal fun String.escapeStorage(): String {
+ return this
+ .replace("\\", "\\\\")
+ .replace("\n", "\\n")
+ .replace("|", "\\p")
+ .replace(",", "\\c")
+ .replace(";", "\\s")
+}
+
+internal fun String.unescapeStorage(): String {
+ return this
+ .replace("\\s", ";")
+ .replace("\\c", ",")
+ .replace("\\p", "|")
+ .replace("\\n", "\n")
+ .replace("\\\\", "\\")
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt
index 6a02a256..80c55722 100644
--- a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt
@@ -31,6 +31,7 @@ fun TdApi.PremiumFeature.toDomain() : PremiumFeatureType = when (this) {
is TdApi.PremiumFeatureAdvancedChatManagement -> PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT
is TdApi.PremiumFeatureDisabledAds -> PremiumFeatureType.NO_ADS
is TdApi.PremiumFeatureUniqueReactions -> PremiumFeatureType.INFINITE_REACTIONS
+ is TdApi.PremiumFeatureProfileBadge -> PremiumFeatureType.BADGE
is TdApi.PremiumFeatureAppIcons -> PremiumFeatureType.APP_ICONS
is TdApi.PremiumFeatureEmojiStatus -> PremiumFeatureType.PROFILE_BADGE
else -> PremiumFeatureType.UNKNOWN
diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt
index d309e9ae..51620923 100644
--- a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt
@@ -28,6 +28,8 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity {
else -> 0L
}
+ val botType = type as? TdApi.UserTypeBot
+
return UserEntity(
id = id,
firstName = firstName,
@@ -38,18 +40,44 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity {
personalAvatarPath = personalAvatarPath,
isPremium = isPremium,
isVerified = verificationStatus?.isVerified ?: false,
+ isScam = verificationStatus?.isScam ?: false,
+ isFake = verificationStatus?.isFake ?: false,
+ botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L,
isSupport = isSupport,
isContact = isContact,
isMutualContact = isMutualContact,
isCloseFriend = isCloseFriend,
+ botTypeCanBeEdited = botType?.canBeEdited ?: false,
+ botTypeCanJoinGroups = botType?.canJoinGroups ?: false,
+ botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false,
+ botTypeHasMainWebApp = botType?.hasMainWebApp ?: false,
+ botTypeHasTopics = botType?.hasTopics ?: false,
+ botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false,
+ botTypeCanManageBots = botType?.canManageBots ?: false,
+ botTypeIsInline = botType?.isInline ?: false,
+ botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null },
+ botTypeNeedLocation = botType?.needLocation ?: false,
+ botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false,
+ botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false,
+ botTypeActiveUserCount = botType?.activeUserCount ?: 0,
+ userType = type.toTypeString(),
+ restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null },
+ hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false,
+ activeStoryStateType = activeStoryState.toTypeString(),
+ activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0,
+ restrictsNewChats = restrictsNewChats,
+ paidMessageStarCount = paidMessageStarCount,
haveAccess = haveAccess,
username = usernames?.activeUsernames?.firstOrNull(),
usernamesData = usernamesData,
statusType = statusType,
accentColorId = accentColorId,
+ backgroundCustomEmojiId = backgroundCustomEmojiId,
profileAccentColorId = profileAccentColorId,
+ profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId,
statusEmojiId = statusEmojiId,
languageCode = languageCode.ifEmpty { null },
+ addedToAttachmentMenu = addedToAttachmentMenu,
lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L,
createdAt = System.currentTimeMillis()
)
@@ -60,4 +88,135 @@ fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? {
?: personalPhoto?.sizes?.lastOrNull()
return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null }
?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null }
-}
\ No newline at end of file
+}
+
+fun UserEntity.toTdApi(): TdApi.User {
+ return TdApi.User().apply {
+ id = this@toTdApi.id
+ firstName = this@toTdApi.firstName
+ lastName = this@toTdApi.lastName ?: ""
+ phoneNumber = this@toTdApi.phoneNumber ?: ""
+ isPremium = this@toTdApi.isPremium
+ isSupport = this@toTdApi.isSupport
+ isContact = this@toTdApi.isContact
+ isMutualContact = this@toTdApi.isMutualContact
+ isCloseFriend = this@toTdApi.isCloseFriend
+ haveAccess = this@toTdApi.haveAccess
+ languageCode = this@toTdApi.languageCode ?: ""
+ accentColorId = this@toTdApi.accentColorId
+ backgroundCustomEmojiId = this@toTdApi.backgroundCustomEmojiId
+ profileAccentColorId = this@toTdApi.profileAccentColorId
+ profileBackgroundCustomEmojiId = this@toTdApi.profileBackgroundCustomEmojiId
+ verificationStatus = if (
+ isVerified ||
+ isScam ||
+ isFake ||
+ botVerificationIconCustomEmojiId != 0L
+ ) {
+ TdApi.VerificationStatus(
+ isVerified,
+ isScam,
+ isFake,
+ botVerificationIconCustomEmojiId
+ )
+ } else {
+ null
+ }
+ val (active, disabled, editable, collectible) = decodeUsernames(
+ this@toTdApi.usernamesData,
+ this@toTdApi.username
+ )
+ usernames = TdApi.Usernames(active, disabled, editable, collectible)
+ emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let {
+ TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0)
+ }
+ status = when (this@toTdApi.statusType) {
+ "ONLINE" -> TdApi.UserStatusOnline(0)
+ "RECENTLY" -> TdApi.UserStatusRecently()
+ "LAST_WEEK" -> TdApi.UserStatusLastWeek()
+ "LAST_MONTH" -> TdApi.UserStatusLastMonth()
+ else -> TdApi.UserStatusOffline(lastSeen.toInt())
+ }
+ type = when (this@toTdApi.userType) {
+ "REGULAR" -> TdApi.UserTypeRegular()
+ "BOT" -> TdApi.UserTypeBot(
+ botTypeCanBeEdited,
+ botTypeCanJoinGroups,
+ botTypeCanReadAllGroupMessages,
+ botTypeHasMainWebApp,
+ botTypeHasTopics,
+ botTypeAllowsUsersToCreateTopics,
+ botTypeCanManageBots,
+ botTypeIsInline,
+ botTypeInlineQueryPlaceholder.orEmpty(),
+ botTypeNeedLocation,
+ botTypeCanConnectToBusiness,
+ botTypeCanBeAddedToAttachmentMenu,
+ botTypeActiveUserCount
+ )
+
+ "DELETED" -> TdApi.UserTypeDeleted()
+ else -> TdApi.UserTypeUnknown()
+ }
+ restrictionInfo = if (!restrictionReason.isNullOrBlank() || hasSensitiveContent) {
+ TdApi.RestrictionInfo(
+ restrictionReason.orEmpty(),
+ hasSensitiveContent
+ )
+ } else {
+ null
+ }
+ activeStoryState = when (activeStoryStateType) {
+ "LIVE" -> TdApi.ActiveStoryStateLive(activeStoryId)
+ "UNREAD" -> TdApi.ActiveStoryStateUnread()
+ "READ" -> TdApi.ActiveStoryStateRead()
+ else -> null
+ }
+ restrictsNewChats = this@toTdApi.restrictsNewChats
+ paidMessageStarCount = this@toTdApi.paidMessageStarCount
+ addedToAttachmentMenu = this@toTdApi.addedToAttachmentMenu
+ profilePhoto = avatarPath?.let { path ->
+ TdApi.ProfilePhoto().apply {
+ small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } }
+ }
+ }
+ }
+}
+
+private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames {
+ if (data.isNullOrEmpty()) {
+ val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray()
+ return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray())
+ }
+ val parts = data.split("\n", limit = 4)
+ val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
+ val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
+ val editable = parts.getOrNull(2).orEmpty()
+ val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
+ return QuadUsernames(active, disabled, editable, collectible)
+}
+
+private data class QuadUsernames(
+ val active: Array,
+ val disabled: Array,
+ val editable: String,
+ val collectible: Array
+)
+
+private fun TdApi.ActiveStoryState?.toTypeString(): String? {
+ return when (this) {
+ is TdApi.ActiveStoryStateLive -> "LIVE"
+ is TdApi.ActiveStoryStateUnread -> "UNREAD"
+ is TdApi.ActiveStoryStateRead -> "READ"
+ else -> null
+ }
+}
+
+private fun TdApi.UserType?.toTypeString(): String {
+ return when (this) {
+ is TdApi.UserTypeRegular -> "REGULAR"
+ is TdApi.UserTypeBot -> "BOT"
+ is TdApi.UserTypeDeleted -> "DELETED"
+ else -> "UNKNOWN"
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt
new file mode 100644
index 00000000..ab52de08
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt
@@ -0,0 +1,338 @@
+package org.monogram.data.mapper.user
+
+import org.drinkless.tdlib.TdApi
+import org.monogram.data.db.model.UserFullInfoEntity
+import org.monogram.data.mapper.isValidFilePath
+
+fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity {
+ val businessLocation = businessInfo?.location
+ val businessOpeningHours = businessInfo?.openingHours
+ val businessStartPage = businessInfo?.startPage
+ val birth = birthdate
+ val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) }
+ ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() }
+ ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+ val publicPhotoPath = publicPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) }
+ ?: (publicPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() }
+ ?: publicPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+ val botInfoPhotoPath = botInfo?.photo?.let { photo ->
+ val bestPhotoSize = photo.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() }
+ ?: photo.sizes.lastOrNull()
+ bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+ }
+ val botInfoPhotoFileId = botInfo?.photo?.sizes?.lastOrNull()?.photo?.id ?: 0
+ val botInfoAnimationFileId = botInfo?.animation?.animation?.id ?: 0
+ val botInfoAnimationPath = botInfo?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) }
+ val botVerification = botVerification
+ val firstProfileAudio = firstProfileAudio
+ val rating = rating
+ val pendingRating = pendingRating
+ val botInfoVerificationParams = botInfo?.verificationParameters
+
+ return UserFullInfoEntity(
+ userId = userId,
+ bio = bio?.text?.ifEmpty { null },
+ commonGroupsCount = groupInCommonCount,
+ giftCount = giftCount,
+ botInfoDescription = botInfo?.description?.ifEmpty { null },
+ botInfoShortDescription = botInfo?.shortDescription?.ifEmpty { null },
+ botInfoPhotoFileId = botInfoPhotoFileId,
+ botInfoPhotoPath = botInfoPhotoPath,
+ botInfoAnimationFileId = botInfoAnimationFileId,
+ botInfoAnimationPath = botInfoAnimationPath,
+ botInfoManagerBotUserId = botInfo?.managerBotUserId ?: 0L,
+ botInfoMenuButtonText = botInfo?.menuButton?.text?.ifEmpty { null },
+ botInfoMenuButtonUrl = botInfo?.menuButton?.url?.ifEmpty { null },
+ botInfoCommandsData = encodeBotInfoCommands(botInfo?.commands),
+ botInfoPrivacyPolicyUrl = botInfo?.privacyPolicyUrl?.ifEmpty { null },
+ botInfoDefaultGroupRightsData = encodeChatAdministratorRights(botInfo?.defaultGroupAdministratorRights),
+ botInfoDefaultChannelRightsData = encodeChatAdministratorRights(botInfo?.defaultChannelAdministratorRights),
+ botInfoAffiliateProgramData = encodeAffiliateProgramInfo(botInfo?.affiliateProgram),
+ botInfoWebAppBackgroundLightColor = botInfo?.webAppBackgroundLightColor ?: -1,
+ botInfoWebAppBackgroundDarkColor = botInfo?.webAppBackgroundDarkColor ?: -1,
+ botInfoWebAppHeaderLightColor = botInfo?.webAppHeaderLightColor ?: -1,
+ botInfoWebAppHeaderDarkColor = botInfo?.webAppHeaderDarkColor ?: -1,
+ botInfoVerificationParametersIconCustomEmojiId = botInfoVerificationParams?.iconCustomEmojiId ?: 0L,
+ botInfoVerificationParametersOrganizationName = botInfoVerificationParams?.organizationName?.ifEmpty { null },
+ botInfoVerificationParametersDefaultCustomDescription = botInfoVerificationParams?.defaultCustomDescription?.text?.ifEmpty { null },
+ botInfoVerificationParametersCanSetCustomDescription = botInfoVerificationParams?.canSetCustomDescription
+ ?: false,
+ botInfoCanManageEmojiStatus = botInfo?.canManageEmojiStatus ?: false,
+ botInfoHasMediaPreviews = botInfo?.hasMediaPreviews ?: false,
+ botInfoEditCommandsLinkType = botInfo?.editCommandsLink?.javaClass?.simpleName,
+ botInfoEditDescriptionLinkType = botInfo?.editDescriptionLink?.javaClass?.simpleName,
+ botInfoEditDescriptionMediaLinkType = botInfo?.editDescriptionMediaLink?.javaClass?.simpleName,
+ botInfoEditSettingsLinkType = botInfo?.editSettingsLink?.javaClass?.simpleName,
+ personalChatId = personalChatId,
+ birthdateDay = birth?.day ?: 0,
+ birthdateMonth = birth?.month ?: 0,
+ birthdateYear = birth?.year ?: 0,
+ publicPhotoPath = publicPhotoPath,
+ blockListType = blockList.toTypeString(),
+ businessLocationAddress = businessLocation?.address?.ifEmpty { null },
+ businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0,
+ businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0,
+ businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId,
+ businessNextOpenIn = businessInfo?.nextOpenIn ?: 0,
+ businessNextCloseIn = businessInfo?.nextCloseIn ?: 0,
+ businessStartPageTitle = businessStartPage?.title?.ifEmpty { null },
+ businessStartPageMessage = businessStartPage?.message?.ifEmpty { null },
+ note = note?.text?.ifEmpty { null },
+ personalPhotoPath = personalPhotoPath,
+ isBlocked = blockList != null,
+ hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled,
+ needPhoneNumberPrivacyException = needPhoneNumberPrivacyException,
+ usesUnofficialApp = usesUnofficialApp,
+ botVerificationBotUserId = botVerification?.botUserId ?: 0L,
+ botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L,
+ botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null },
+ mainProfileTab = mainProfileTab.toTypeString(),
+ firstProfileAudioDuration = firstProfileAudio?.duration ?: 0,
+ firstProfileAudioTitle = firstProfileAudio?.title?.ifEmpty { null },
+ firstProfileAudioPerformer = firstProfileAudio?.performer?.ifEmpty { null },
+ firstProfileAudioFileName = firstProfileAudio?.fileName?.ifEmpty { null },
+ firstProfileAudioMimeType = firstProfileAudio?.mimeType?.ifEmpty { null },
+ firstProfileAudioFileId = firstProfileAudio?.audio?.id ?: 0,
+ firstProfileAudioPath = firstProfileAudio?.audio?.local?.path?.takeIf { isValidFilePath(it) },
+ ratingLevel = rating?.level ?: 0,
+ ratingIsMaximumLevelReached = rating?.isMaximumLevelReached ?: false,
+ ratingValue = rating?.rating ?: 0L,
+ ratingCurrentLevelValue = rating?.currentLevelRating ?: 0L,
+ ratingNextLevelValue = rating?.nextLevelRating ?: 0L,
+ pendingRatingLevel = pendingRating?.level ?: 0,
+ pendingRatingIsMaximumLevelReached = pendingRating?.isMaximumLevelReached ?: false,
+ pendingRatingValue = pendingRating?.rating ?: 0L,
+ pendingRatingCurrentLevelValue = pendingRating?.currentLevelRating ?: 0L,
+ pendingRatingNextLevelValue = pendingRating?.nextLevelRating ?: 0L,
+ pendingRatingDate = pendingRatingDate,
+ canBeCalled = canBeCalled,
+ supportsVideoCalls = supportsVideoCalls,
+ hasPrivateCalls = hasPrivateCalls,
+ hasPrivateForwards = hasPrivateForwards,
+ hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
+ hasPostedToProfileStories = hasPostedToProfileStories,
+ setChatBackground = setChatBackground,
+ canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false,
+ createdAt = System.currentTimeMillis()
+ )
+}
+
+fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo {
+ return TdApi.UserFullInfo().apply {
+ bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) }
+ groupInCommonCount = commonGroupsCount
+ giftCount = this@toTdApi.giftCount
+ personalChatId = this@toTdApi.personalChatId
+ birthdate = if (birthdateDay > 0 && birthdateMonth > 0) {
+ TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear)
+ } else {
+ null
+ }
+ botInfo = if (
+ botInfoDescription != null ||
+ botInfoShortDescription != null ||
+ botInfoManagerBotUserId != 0L ||
+ botInfoPrivacyPolicyUrl != null
+ ) {
+ TdApi.BotInfo().apply {
+ shortDescription = botInfoShortDescription.orEmpty()
+ description = botInfoDescription.orEmpty()
+ photo = if (botInfoPhotoFileId != 0 || !botInfoPhotoPath.isNullOrEmpty()) {
+ TdApi.Photo().apply {
+ sizes = arrayOf(
+ TdApi.PhotoSize().apply {
+ type = "x"
+ width = 0
+ height = 0
+ photo = TdApi.File().apply {
+ id = botInfoPhotoFileId
+ local = TdApi.LocalFile().apply { path = botInfoPhotoPath.orEmpty() }
+ }
+ }
+ )
+ }
+ } else {
+ null
+ }
+ animation = if (botInfoAnimationFileId != 0 || !botInfoAnimationPath.isNullOrEmpty()) {
+ TdApi.Animation().apply {
+ animation = TdApi.File().apply {
+ id = botInfoAnimationFileId
+ local = TdApi.LocalFile().apply { path = botInfoAnimationPath.orEmpty() }
+ }
+ }
+ } else {
+ null
+ }
+ managerBotUserId = botInfoManagerBotUserId
+ menuButton = if (!botInfoMenuButtonText.isNullOrEmpty() || !botInfoMenuButtonUrl.isNullOrEmpty()) {
+ TdApi.BotMenuButton(
+ botInfoMenuButtonText.orEmpty(),
+ botInfoMenuButtonUrl.orEmpty()
+ )
+ } else {
+ null
+ }
+ commands = decodeBotInfoCommands(botInfoCommandsData)
+ privacyPolicyUrl = botInfoPrivacyPolicyUrl.orEmpty()
+ defaultGroupAdministratorRights = decodeChatAdministratorRights(botInfoDefaultGroupRightsData)
+ defaultChannelAdministratorRights = decodeChatAdministratorRights(botInfoDefaultChannelRightsData)
+ affiliateProgram = decodeAffiliateProgramInfo(botInfoAffiliateProgramData)
+ webAppBackgroundLightColor = botInfoWebAppBackgroundLightColor
+ webAppBackgroundDarkColor = botInfoWebAppBackgroundDarkColor
+ webAppHeaderLightColor = botInfoWebAppHeaderLightColor
+ webAppHeaderDarkColor = botInfoWebAppHeaderDarkColor
+ verificationParameters = if (
+ botInfoVerificationParametersIconCustomEmojiId != 0L ||
+ !botInfoVerificationParametersOrganizationName.isNullOrEmpty() ||
+ !botInfoVerificationParametersDefaultCustomDescription.isNullOrEmpty()
+ ) {
+ TdApi.BotVerificationParameters(
+ botInfoVerificationParametersIconCustomEmojiId,
+ botInfoVerificationParametersOrganizationName.orEmpty(),
+ botInfoVerificationParametersDefaultCustomDescription?.let {
+ TdApi.FormattedText(it, emptyArray())
+ },
+ botInfoVerificationParametersCanSetCustomDescription
+ )
+ } else {
+ null
+ }
+ canGetRevenueStatistics = canGetRevenueStatistics
+ canManageEmojiStatus = botInfoCanManageEmojiStatus
+ hasMediaPreviews = botInfoHasMediaPreviews
+ editCommandsLink = null
+ editDescriptionLink = null
+ editDescriptionMediaLink = null
+ editSettingsLink = null
+ }
+ } else {
+ null
+ }
+ businessInfo = if (
+ businessLocationAddress != null ||
+ businessOpeningHoursTimeZone != null ||
+ businessStartPageTitle != null ||
+ businessStartPageMessage != null
+ ) {
+ TdApi.BusinessInfo().apply {
+ location = businessLocationAddress?.let { address ->
+ TdApi.BusinessLocation(
+ TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0),
+ address
+ )
+ }
+ openingHours = businessOpeningHoursTimeZone?.let { tz ->
+ TdApi.BusinessOpeningHours(tz, emptyArray())
+ }
+ startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) {
+ TdApi.BusinessStartPage(
+ businessStartPageTitle.orEmpty(),
+ businessStartPageMessage.orEmpty(),
+ null
+ )
+ } else {
+ null
+ }
+ nextOpenIn = businessNextOpenIn
+ nextCloseIn = businessNextCloseIn
+ }
+ } else {
+ null
+ }
+ personalPhoto = personalPhotoPath.toTdApiChatPhoto()
+ photo = null
+ publicPhoto = publicPhotoPath.toTdApiChatPhoto()
+ blockList = when (blockListType) {
+ "STORIES" -> TdApi.BlockListStories()
+ "MAIN" -> TdApi.BlockListMain()
+ else -> if (isBlocked) TdApi.BlockListMain() else null
+ }
+ canBeCalled = this@toTdApi.canBeCalled
+ supportsVideoCalls = this@toTdApi.supportsVideoCalls
+ hasPrivateCalls = this@toTdApi.hasPrivateCalls
+ hasPrivateForwards = this@toTdApi.hasPrivateForwards
+ hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages
+ hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories
+ hasSponsoredMessagesEnabled = this@toTdApi.hasSponsoredMessagesEnabled
+ needPhoneNumberPrivacyException = this@toTdApi.needPhoneNumberPrivacyException
+ setChatBackground = this@toTdApi.setChatBackground
+ usesUnofficialApp = this@toTdApi.usesUnofficialApp
+ incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount
+ outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount
+ botVerification = if (
+ botVerificationBotUserId != 0L ||
+ botVerificationIconCustomEmojiId != 0L ||
+ !botVerificationCustomDescription.isNullOrEmpty()
+ ) {
+ TdApi.BotVerification(
+ botVerificationBotUserId,
+ botVerificationIconCustomEmojiId,
+ TdApi.FormattedText(botVerificationCustomDescription.orEmpty(), emptyArray())
+ )
+ } else {
+ null
+ }
+ mainProfileTab = this@toTdApi.mainProfileTab.toTdApiProfileTab()
+ firstProfileAudio = if (
+ firstProfileAudioFileId != 0 ||
+ !firstProfileAudioPath.isNullOrEmpty() ||
+ firstProfileAudioDuration > 0 ||
+ !firstProfileAudioTitle.isNullOrEmpty() ||
+ !firstProfileAudioPerformer.isNullOrEmpty()
+ ) {
+ TdApi.Audio(
+ firstProfileAudioDuration,
+ firstProfileAudioTitle.orEmpty(),
+ firstProfileAudioPerformer.orEmpty(),
+ firstProfileAudioFileName.orEmpty(),
+ firstProfileAudioMimeType.orEmpty(),
+ null,
+ null,
+ emptyArray(),
+ TdApi.File().apply {
+ id = firstProfileAudioFileId
+ local = TdApi.LocalFile().apply { path = firstProfileAudioPath.orEmpty() }
+ }
+ )
+ } else {
+ null
+ }
+ rating = if (
+ ratingLevel != 0 ||
+ ratingIsMaximumLevelReached ||
+ ratingValue != 0L ||
+ ratingCurrentLevelValue != 0L ||
+ ratingNextLevelValue != 0L
+ ) {
+ TdApi.UserRating(
+ ratingLevel,
+ ratingIsMaximumLevelReached,
+ ratingValue,
+ ratingCurrentLevelValue,
+ ratingNextLevelValue
+ )
+ } else {
+ null
+ }
+ pendingRating = if (
+ pendingRatingLevel != 0 ||
+ pendingRatingIsMaximumLevelReached ||
+ pendingRatingValue != 0L ||
+ pendingRatingCurrentLevelValue != 0L ||
+ pendingRatingNextLevelValue != 0L
+ ) {
+ TdApi.UserRating(
+ pendingRatingLevel,
+ pendingRatingIsMaximumLevelReached,
+ pendingRatingValue,
+ pendingRatingCurrentLevelValue,
+ pendingRatingNextLevelValue
+ )
+ } else {
+ null
+ }
+ pendingRatingDate = this@toTdApi.pendingRatingDate
+ note = this@toTdApi.note?.let { TdApi.FormattedText(it, emptyArray()) }
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt
index 652e2d39..20afafd0 100644
--- a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt
+++ b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt
@@ -1,15 +1,10 @@
package org.monogram.data.mapper.user
import org.drinkless.tdlib.TdApi
-import org.monogram.data.db.model.ChatEntity
-import org.monogram.data.db.model.ChatFullInfoEntity
-import org.monogram.data.db.model.UserEntity
-import org.monogram.data.db.model.UserFullInfoEntity
import org.monogram.data.mapper.isForcedVerifiedUser
import org.monogram.data.mapper.isSponsoredUser
+import org.monogram.data.mapper.isValidFilePath
import org.monogram.domain.models.*
-import org.monogram.domain.repository.ChatMemberStatus
-import org.monogram.domain.repository.ChatMembersFilter
fun TdApi.User.toDomain(
fullInfo: TdApi.UserFullInfo? = null,
@@ -21,8 +16,8 @@ fun TdApi.User.toDomain(
val personalAvatarPath = fullInfo?.personalPhoto?.let { personalPhoto ->
val bestPhotoSize = personalPhoto.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() }
?: personalPhoto.sizes.lastOrNull()
- personalPhoto.animation?.file?.local?.path?.ifEmpty { null }
- ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null }
+ personalPhoto.animation?.file?.local?.path?.takeIf { isValidFilePath(it) }
+ ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) }
}
val lastSeen = (status as? TdApi.UserStatusOffline)
@@ -37,9 +32,17 @@ fun TdApi.User.toDomain(
personalAvatarPath = personalAvatarPath,
isPremium = isPremium,
isVerified = (verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(id),
+ isScam = verificationStatus?.isScam ?: false,
+ isFake = verificationStatus?.isFake ?: false,
+ botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L,
isSponsor = isSponsoredUser(id),
isSupport = isSupport,
type = type.toDomain(),
+ botTypeInfo = (type as? TdApi.UserTypeBot)?.toDomain(),
+ restrictionInfo = restrictionInfo?.toDomain(),
+ activeStoryState = activeStoryState?.toDomain(),
+ restrictsNewChats = restrictsNewChats,
+ paidMessageStarCount = paidMessageStarCount,
statusEmojiId = emojiStatusId,
statusEmojiPath = customEmojiPath,
username = username,
@@ -51,188 +54,16 @@ fun TdApi.User.toDomain(
isCloseFriend = isCloseFriend,
haveAccess = haveAccess,
languageCode = languageCode,
- lastSeen = lastSeen
+ lastSeen = lastSeen,
+ backgroundCustomEmojiId = backgroundCustomEmojiId,
+ profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId,
+ addedToAttachmentMenu = addedToAttachmentMenu
)
}
-fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel {
- val rank = when (this.status) {
- is TdApi.ChatMemberStatusCreator -> "Owner"
- is TdApi.ChatMemberStatusAdministrator -> "Admin"
- else -> null
- }
- return GroupMemberModel(
- user = user,
- rank = rank,
- status = this.status.toDomain()
- )
-}
-
-fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel {
- val birthdate = birthdate?.let { date ->
- BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null)
- }
- return ChatFullInfoModel(
- description = bio?.text?.ifEmpty { null },
- commonGroupsCount = groupInCommonCount,
- giftCount = giftCount,
- birthdate = birthdate,
- isBlocked = blockList != null,
- botInfo = botInfo?.description?.ifEmpty { null },
- canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false,
- linkedChatId = personalChatId,
- businessInfo = businessInfo?.let { businessInfo!!.toDomain() },
- canBeCalled = canBeCalled,
- supportsVideoCalls = supportsVideoCalls,
- hasPrivateCalls = hasPrivateCalls,
- hasPrivateForwards = hasPrivateForwards,
- hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
- hasPostedToProfileStories = hasPostedToProfileStories,
- setChatBackground = setChatBackground
- )
-}
-
-fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat(
- supergroup: TdApi.Supergroup?
-): ChatFullInfoModel {
- val link = inviteLink?.inviteLink
- ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" }
- return ChatFullInfoModel(
- description = description.ifEmpty { null },
- inviteLink = link,
- memberCount = memberCount,
- administratorCount = administratorCount,
- restrictedCount = restrictedCount,
- bannedCount = bannedCount,
- slowModeDelay = slowModeDelay,
- locationAddress = location?.address?.ifEmpty { null },
- giftCount = giftCount,
- canSetStickerSet = canSetStickerSet,
- canSetLocation = canSetLocation,
- canGetMembers = canGetMembers,
- canGetStatistics = canGetStatistics,
- canGetRevenueStatistics = canGetRevenueStatistics,
- linkedChatId = linkedChatId
- )
-}
-
-fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel {
- return ChatFullInfoModel(
- description = description.ifEmpty { null },
- inviteLink = inviteLink?.inviteLink,
- memberCount = members.size
- )
-}
-
-fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus {
- return when (this) {
- is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator
- is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator(
- customTitle = "",
- canBeEdited = canBeEdited,
- canManageChat = rights.canManageChat,
- canChangeInfo = rights.canChangeInfo,
- canPostMessages = rights.canPostMessages,
- canEditMessages = rights.canEditMessages,
- canDeleteMessages = rights.canDeleteMessages,
- canInviteUsers = rights.canInviteUsers,
- canRestrictMembers = rights.canRestrictMembers,
- canPinMessages = rights.canPinMessages,
- canManageTopics = rights.canManageTopics,
- canPromoteMembers = rights.canPromoteMembers,
- canManageVideoChats = rights.canManageVideoChats,
- canPostStories = rights.canPostStories,
- canEditStories = rights.canEditStories,
- canDeleteStories = rights.canDeleteStories,
- canManageDirectMessages = rights.canManageDirectMessages,
- isAnonymous = rights.isAnonymous
- )
- is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted(
- isMember = isMember,
- restrictedUntilDate = restrictedUntilDate,
- permissions = permissions.toDomain()
- )
- is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate)
- is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left
- else -> ChatMemberStatus.Member
- }
-}
-
-fun TdApi.Chat.toDomain(): ChatModel {
- val isChannel = type is TdApi.ChatTypeSupergroup &&
- (type as TdApi.ChatTypeSupergroup).isChannel
- return ChatModel(
- id = id,
- title = title,
- avatarPath = photo?.small?.local?.path?.ifEmpty { null },
- unreadCount = unreadCount,
- isMuted = notificationSettings.muteFor > 0,
- isChannel = isChannel,
- isGroup = type is TdApi.ChatTypeBasicGroup ||
- (type is TdApi.ChatTypeSupergroup && !isChannel),
- type = type.toDomain(),
- lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: ""
- )
-}
-
-fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter {
- return when (this) {
- is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent()
- is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators()
- is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned()
- is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted()
- is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots()
- is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query)
- }
-}
-
-fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus {
- return when (this) {
- is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember()
- is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator(
- canBeEdited,
- TdApi.ChatAdministratorRights(
- canManageChat,
- canChangeInfo,
- canPostMessages,
- canEditMessages,
- canDeleteMessages,
- canInviteUsers,
- canRestrictMembers,
- canPinMessages,
- canManageTopics,
- canPromoteMembers,
- canManageVideoChats,
- canPostStories,
- canEditStories,
- canDeleteStories,
- canManageDirectMessages,
- false,
- isAnonymous
- )
- )
- is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted(
- isMember,
- restrictedUntilDate,
- permissions.toApi()
- )
- is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft()
- is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate)
- is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true)
- }
-}
-
-private fun TdApi.ChatType.toDomain(): ChatType = when (this) {
- is TdApi.ChatTypePrivate -> ChatType.PRIVATE
- is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP
- is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP
- is TdApi.ChatTypeSecret -> ChatType.SECRET
- else -> ChatType.PRIVATE
-}
-
private fun TdApi.User.resolveAvatarPath(): String? {
- val big = profilePhoto?.big?.local?.path?.ifEmpty { null }
- val small = profilePhoto?.small?.local?.path?.ifEmpty { null }
+ val big = profilePhoto?.big?.local?.path?.takeIf { isValidFilePath(it) }
+ val small = profilePhoto?.small?.local?.path?.takeIf { isValidFilePath(it) }
return big ?: small
}
@@ -271,418 +102,36 @@ private fun TdApi.UserStatus?.toDomain(): UserStatusType {
}
}
-private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel {
- return BusinessInfoModel(
- location = location?.let {
- BusinessLocationModel(
- it.location!!.latitude,
- it.location!!.longitude,
- it.address
- )
- },
- openingHours = openingHours?.let {
- BusinessOpeningHoursModel(
- it.timeZoneId,
- it.openingHours.map { interval ->
- BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute)
- }
- )
- },
- startPage = startPage?.let {
- BusinessStartPageModel(it.title, it.message, it.sticker?.sticker?.local?.path)
- },
- nextOpenIn = nextOpenIn,
- nextCloseIn = nextCloseIn
- )
-}
-
-private fun TdApi.ChatPermissions.toDomain(): ChatPermissionsModel {
- return ChatPermissionsModel(
- canSendBasicMessages = canSendBasicMessages,
- canSendAudios = canSendAudios,
- canSendDocuments = canSendDocuments,
- canSendPhotos = canSendPhotos,
- canSendVideos = canSendVideos,
- canSendVideoNotes = canSendVideoNotes,
- canSendVoiceNotes = canSendVoiceNotes,
- canSendPolls = canSendPolls,
- canSendOtherMessages = canSendOtherMessages,
- canAddLinkPreviews = canAddLinkPreviews,
- canChangeInfo = canChangeInfo,
- canInviteUsers = canInviteUsers,
- canPinMessages = canPinMessages,
- canCreateTopics = canCreateTopics
+private fun TdApi.UserTypeBot.toDomain(): UserTypeBotInfoModel {
+ return UserTypeBotInfoModel(
+ canBeEdited = canBeEdited,
+ canJoinGroups = canJoinGroups,
+ canReadAllGroupMessages = canReadAllGroupMessages,
+ hasMainWebApp = hasMainWebApp,
+ hasTopics = hasTopics,
+ allowsUsersToCreateTopics = allowsUsersToCreateTopics,
+ canManageBots = canManageBots,
+ isInline = isInline,
+ inlineQueryPlaceholder = inlineQueryPlaceholder.ifEmpty { null },
+ needLocation = needLocation,
+ canConnectToBusiness = canConnectToBusiness,
+ canBeAddedToAttachmentMenu = canBeAddedToAttachmentMenu,
+ activeUserCount = activeUserCount
)
}
-private fun ChatPermissionsModel.toApi(): TdApi.ChatPermissions {
- return TdApi.ChatPermissions(
- canSendBasicMessages,
- canSendAudios,
- canSendDocuments,
- canSendPhotos,
- canSendVideos,
- canSendVideoNotes,
- canSendVoiceNotes,
- canSendPolls,
- canSendOtherMessages,
- canAddLinkPreviews,
- canEditTag,
- canChangeInfo,
- canInviteUsers,
- canPinMessages,
- canCreateTopics
+private fun TdApi.RestrictionInfo.toDomain(): RestrictionInfoModel {
+ return RestrictionInfoModel(
+ restrictionReason = restrictionReason.ifEmpty { null },
+ hasSensitiveContent = hasSensitiveContent
)
}
-fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity {
- val businessLocation = businessInfo?.location
- val businessOpeningHours = businessInfo?.openingHours
- val businessStartPage = businessInfo?.startPage
- val birth = birthdate
- val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.ifEmpty { null }
- ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() }
- ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.ifEmpty { null }
-
- return UserFullInfoEntity(
- userId = userId,
- bio = bio?.text?.ifEmpty { null },
- commonGroupsCount = groupInCommonCount,
- giftCount = giftCount,
- botInfoDescription = botInfo?.description?.ifEmpty { null },
- personalChatId = personalChatId,
- birthdateDay = birth?.day ?: 0,
- birthdateMonth = birth?.month ?: 0,
- birthdateYear = birth?.year ?: 0,
- businessLocationAddress = businessLocation?.address?.ifEmpty { null },
- businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0,
- businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0,
- businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId,
- businessNextOpenIn = businessInfo?.nextOpenIn ?: 0,
- businessNextCloseIn = businessInfo?.nextCloseIn ?: 0,
- businessStartPageTitle = businessStartPage?.title?.ifEmpty { null },
- businessStartPageMessage = businessStartPage?.message?.ifEmpty { null },
- personalPhotoPath = personalPhotoPath,
- isBlocked = blockList != null,
- canBeCalled = canBeCalled,
- supportsVideoCalls = supportsVideoCalls,
- hasPrivateCalls = hasPrivateCalls,
- hasPrivateForwards = hasPrivateForwards,
- hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
- hasPostedToProfileStories = hasPostedToProfileStories,
- setChatBackground = setChatBackground,
- canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false,
- createdAt = System.currentTimeMillis()
- )
-}
-
-fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity {
- return ChatFullInfoEntity(
- chatId = chatId,
- description = description.ifEmpty { null },
- inviteLink = inviteLink?.inviteLink,
- memberCount = memberCount,
- onlineCount = 0,
- administratorCount = administratorCount,
- restrictedCount = restrictedCount,
- bannedCount = bannedCount,
- commonGroupsCount = 0,
- giftCount = giftCount,
- isBlocked = false,
- botInfo = null,
- slowModeDelay = slowModeDelay,
- locationAddress = location?.address?.ifEmpty { null },
- canSetStickerSet = canSetStickerSet,
- canSetLocation = canSetLocation,
- canGetMembers = canGetMembers,
- canGetStatistics = canGetStatistics,
- canGetRevenueStatistics = canGetRevenueStatistics,
- linkedChatId = linkedChatId,
- note = null,
- canBeCalled = false,
- supportsVideoCalls = false,
- hasPrivateCalls = false,
- hasPrivateForwards = false,
- hasRestrictedVoiceAndVideoNoteMessages = false,
- hasPostedToProfileStories = false,
- setChatBackground = false,
- incomingPaidMessageStarCount = 0,
- outgoingPaidMessageStarCount = outgoingPaidMessageStarCount,
- createdAt = System.currentTimeMillis()
- )
-}
-
-fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity {
- return ChatFullInfoEntity(
- chatId = chatId,
- description = description.ifEmpty { null },
- inviteLink = inviteLink?.inviteLink,
- memberCount = members.size,
- onlineCount = 0,
- administratorCount = 0,
- restrictedCount = 0,
- bannedCount = 0,
- commonGroupsCount = 0,
- giftCount = 0,
- isBlocked = false,
- botInfo = null,
- slowModeDelay = 0,
- locationAddress = null,
- canSetStickerSet = false,
- canSetLocation = false,
- canGetMembers = false,
- canGetStatistics = false,
- canGetRevenueStatistics = false,
- linkedChatId = 0,
- note = null,
- canBeCalled = false,
- supportsVideoCalls = false,
- hasPrivateCalls = false,
- hasPrivateForwards = false,
- hasRestrictedVoiceAndVideoNoteMessages = false,
- hasPostedToProfileStories = false,
- setChatBackground = false,
- incomingPaidMessageStarCount = 0,
- outgoingPaidMessageStarCount = 0,
- createdAt = System.currentTimeMillis()
- )
-}
-
-fun UserEntity.toTdApi(): TdApi.User {
- return TdApi.User().apply {
- id = this@toTdApi.id
- firstName = this@toTdApi.firstName
- lastName = this@toTdApi.lastName ?: ""
- phoneNumber = this@toTdApi.phoneNumber ?: ""
- isPremium = this@toTdApi.isPremium
- isSupport = this@toTdApi.isSupport
- isContact = this@toTdApi.isContact
- isMutualContact = this@toTdApi.isMutualContact
- isCloseFriend = this@toTdApi.isCloseFriend
- haveAccess = this@toTdApi.haveAccess
- languageCode = this@toTdApi.languageCode ?: ""
- accentColorId = this@toTdApi.accentColorId
- profileAccentColorId = this@toTdApi.profileAccentColorId
- verificationStatus = if (isVerified) TdApi.VerificationStatus(true, false, false, 0L) else null
- val (active, disabled, editable, collectible) = decodeUsernames(
- this@toTdApi.usernamesData,
- this@toTdApi.username
- )
- usernames = TdApi.Usernames(active, disabled, editable, collectible)
- emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let {
- TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0)
- }
- status = when (this@toTdApi.statusType) {
- "ONLINE" -> TdApi.UserStatusOnline(0)
- "RECENTLY" -> TdApi.UserStatusRecently()
- "LAST_WEEK" -> TdApi.UserStatusLastWeek()
- "LAST_MONTH" -> TdApi.UserStatusLastMonth()
- else -> TdApi.UserStatusOffline(lastSeen.toInt())
- }
- profilePhoto = avatarPath?.let { path ->
- TdApi.ProfilePhoto().apply {
- small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } }
- }
- }
- }
-}
-
-fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo {
- return TdApi.UserFullInfo().apply {
- bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) }
- groupInCommonCount = commonGroupsCount
- giftCount = this@toTdApi.giftCount
- personalChatId = this@toTdApi.personalChatId
- birthdate = if (birthdateDay > 0 && birthdateMonth > 0) {
- TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear)
- } else {
- null
- }
- botInfo = botInfoDescription?.let { text ->
- TdApi.BotInfo().apply {
- description = text
- canGetRevenueStatistics = canGetRevenueStatistics
- }
- }
- businessInfo = if (
- businessLocationAddress != null ||
- businessOpeningHoursTimeZone != null ||
- businessStartPageTitle != null ||
- businessStartPageMessage != null
- ) {
- TdApi.BusinessInfo().apply {
- location = businessLocationAddress?.let { address ->
- TdApi.BusinessLocation(
- TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0),
- address
- )
- }
- openingHours = businessOpeningHoursTimeZone?.let { tz ->
- TdApi.BusinessOpeningHours(tz, emptyArray())
- }
- startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) {
- TdApi.BusinessStartPage(
- businessStartPageTitle.orEmpty(),
- businessStartPageMessage.orEmpty(),
- null
- )
- } else {
- null
- }
- nextOpenIn = businessNextOpenIn
- nextCloseIn = businessNextCloseIn
- }
- } else {
- null
- }
- personalPhoto = personalPhotoPath?.let { path ->
- TdApi.ChatPhoto().apply {
- sizes = arrayOf(
- TdApi.PhotoSize().apply {
- type = "x"
- width = 0
- height = 0
- photo = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } }
- }
- )
- }
- }
- blockList = if (isBlocked) TdApi.BlockListMain() else null
- canBeCalled = this@toTdApi.canBeCalled
- supportsVideoCalls = this@toTdApi.supportsVideoCalls
- hasPrivateCalls = this@toTdApi.hasPrivateCalls
- hasPrivateForwards = this@toTdApi.hasPrivateForwards
- hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages
- hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories
- setChatBackground = this@toTdApi.setChatBackground
- incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount
- outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount
- }
-}
-
-private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames {
- if (data.isNullOrEmpty()) {
- val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray()
- return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray())
- }
- val parts = data.split("\n", limit = 4)
- val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
- val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
- val editable = parts.getOrNull(2).orEmpty()
- val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray()
- return QuadUsernames(active, disabled, editable, collectible)
-}
-
-private data class QuadUsernames(
- val active: Array,
- val disabled: Array,
- val editable: String,
- val collectible: Array
-)
-
-fun ChatEntity.toTdApiChat(): TdApi.Chat {
- return TdApi.Chat().apply {
- id = this@toTdApiChat.id
- title = this@toTdApiChat.title
- unreadCount = this@toTdApiChat.unreadCount
- unreadMentionCount = this@toTdApiChat.unreadMentionCount
- unreadReactionCount = this@toTdApiChat.unreadReactionCount
- photo = avatarPath?.let { path ->
- TdApi.ChatPhotoInfo().apply {
- small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } }
- }
- }
- lastMessage = TdApi.Message().apply {
- content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) }
- date = lastMessageTime.toIntOrNull() ?: 0
- id = this@toTdApiChat.lastMessageId
- isOutgoing = this@toTdApiChat.isLastMessageOutgoing
- }
- positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null))
- notificationSettings = TdApi.ChatNotificationSettings().apply {
- muteFor = if (isMuted) Int.MAX_VALUE else 0
- }
- type = when (this@toTdApiChat.type) {
- "PRIVATE" -> TdApi.ChatTypePrivate().apply {
- userId = if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId ?: 0L)
- }
- "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply {
- basicGroupId = this@toTdApiChat.basicGroupId
- }
- "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel)
- "SECRET" -> TdApi.ChatTypeSecret().apply {
- secretChatId = this@toTdApiChat.secretChatId
- }
- else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId }
- }
- isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread
- hasProtectedContent = this@toTdApiChat.hasProtectedContent
- isTranslatable = this@toTdApiChat.isTranslatable
- viewAsTopics = this@toTdApiChat.viewAsTopics
- accentColorId = this@toTdApiChat.accentColorId
- profileAccentColorId = this@toTdApiChat.profileAccentColorId
- backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId
- messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime
- canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf
- canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers
- canBeReported = this@toTdApiChat.canBeReported
- lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId
- lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId
- replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId
- messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) }
- blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null
- permissions = TdApi.ChatPermissions(
- this@toTdApiChat.permissionCanSendBasicMessages,
- this@toTdApiChat.permissionCanSendAudios,
- this@toTdApiChat.permissionCanSendDocuments,
- this@toTdApiChat.permissionCanSendPhotos,
- this@toTdApiChat.permissionCanSendVideos,
- this@toTdApiChat.permissionCanSendVideoNotes,
- this@toTdApiChat.permissionCanSendVoiceNotes,
- this@toTdApiChat.permissionCanSendPolls,
- this@toTdApiChat.permissionCanSendOtherMessages,
- this@toTdApiChat.permissionCanAddLinkPreviews,
- this@toTdApiChat.permissionCanEditTag,
- this@toTdApiChat.permissionCanChangeInfo,
- this@toTdApiChat.permissionCanInviteUsers,
- this@toTdApiChat.permissionCanPinMessages,
- this@toTdApiChat.permissionCanCreateTopics
- )
- clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}"
+private fun TdApi.ActiveStoryState.toDomain(): ActiveStoryStateModel {
+ return when (this) {
+ is TdApi.ActiveStoryStateLive -> ActiveStoryStateModel(ActiveStoryStateType.LIVE, storyId)
+ is TdApi.ActiveStoryStateUnread -> ActiveStoryStateModel(ActiveStoryStateType.UNREAD, 0)
+ is TdApi.ActiveStoryStateRead -> ActiveStoryStateModel(ActiveStoryStateType.READ, 0)
+ else -> ActiveStoryStateModel(ActiveStoryStateType.UNKNOWN, 0)
}
}
-
-fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel {
- return ChatFullInfoModel(
- description = description,
- inviteLink = inviteLink,
- memberCount = memberCount,
- onlineCount = onlineCount,
- administratorCount = administratorCount,
- restrictedCount = restrictedCount,
- bannedCount = bannedCount,
- commonGroupsCount = commonGroupsCount,
- giftCount = giftCount,
- isBlocked = isBlocked,
- botInfo = botInfo,
- canGetRevenueStatistics = canGetRevenueStatistics,
- linkedChatId = linkedChatId,
- businessInfo = null,
- note = note,
- canBeCalled = canBeCalled,
- supportsVideoCalls = supportsVideoCalls,
- hasPrivateCalls = hasPrivateCalls,
- hasPrivateForwards = hasPrivateForwards,
- hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages,
- hasPostedToProfileStories = hasPostedToProfileStories,
- setChatBackground = setChatBackground,
- slowModeDelay = slowModeDelay,
- locationAddress = locationAddress,
- canSetStickerSet = canSetStickerSet,
- canSetLocation = canSetLocation,
- canGetMembers = canGetMembers,
- canGetStatistics = canGetStatistics,
- incomingPaidMessageStarCount = incomingPaidMessageStarCount,
- outgoingPaidMessageStarCount = outgoingPaidMessageStarCount
- )
-}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt
index ddd86da2..5e8020a4 100644
--- a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt
@@ -1,34 +1,37 @@
package org.monogram.data.repository
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.datasource.cache.SettingsCacheDataSource
import org.monogram.data.datasource.remote.SettingsRemoteDataSource
import org.monogram.data.db.dao.AttachBotDao
import org.monogram.data.db.model.AttachBotEntity
import org.monogram.data.gateway.UpdateDispatcher
+import org.monogram.data.infra.FileObserverHub
import org.monogram.data.mapper.toDomain
import org.monogram.domain.models.AttachMenuBotModel
+import org.monogram.domain.models.FileLocalModel
import org.monogram.domain.repository.AttachMenuBotRepository
import org.monogram.domain.repository.CacheProvider
+import java.util.concurrent.ConcurrentHashMap
class AttachMenuBotRepositoryImpl(
private val remote: SettingsRemoteDataSource,
private val cache: SettingsCacheDataSource,
private val cacheProvider: CacheProvider,
private val updates: UpdateDispatcher,
+ private val fileObserverHub: FileObserverHub,
private val dispatchers: DispatcherProvider,
private val attachBotDao: AttachBotDao,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : AttachMenuBotRepository {
-
- private val scope = scopeProvider.appScope
private val attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value)
+ private val sideMenuIconFileToBotId = ConcurrentHashMap()
init {
scope.launch {
@@ -37,6 +40,7 @@ class AttachMenuBotRepositoryImpl(
val bots = update.bots.map { it.toDomain() }
attachMenuBots.value = bots
cacheProvider.setAttachBots(bots)
+ rebuildTrackedIcons(bots)
saveAttachBotsToDb(bots)
@@ -51,16 +55,10 @@ class AttachMenuBotRepositoryImpl(
}
scope.launch {
- updates.file.collect { update ->
- val currentBots = attachMenuBots.value
- if (currentBots.any { it.icon?.icon?.id == update.file.id }) {
- cache.getAttachMenuBots()?.let { bots ->
- val domainBots = bots.map { it.toDomain() }
- attachMenuBots.value = domainBots
- cacheProvider.setAttachBots(domainBots)
- saveAttachBotsToDb(domainBots)
- }
- }
+ fileObserverHub.fileStates.collect { state ->
+ if (!state.isDownloaded || state.path.isNullOrBlank()) return@collect
+ val botId = sideMenuIconFileToBotId[state.fileId] ?: return@collect
+ applyBotIconPath(botId, state.fileId, state.path)
}
}
@@ -76,6 +74,7 @@ class AttachMenuBotRepositoryImpl(
if (bots.isNotEmpty()) {
attachMenuBots.value = bots
cacheProvider.setAttachBots(bots)
+ rebuildTrackedIcons(bots)
}
}
}
@@ -85,6 +84,53 @@ class AttachMenuBotRepositoryImpl(
return attachMenuBots
}
+ private fun rebuildTrackedIcons(bots: List) {
+ sideMenuIconFileToBotId.clear()
+ bots.forEach { bot ->
+ bot.icon?.icon?.id?.takeIf { it != 0 }?.let { fileId ->
+ sideMenuIconFileToBotId[fileId] = bot.botUserId
+ }
+ }
+ }
+
+ private suspend fun applyBotIconPath(botId: Long, fileId: Int, path: String) {
+ val current = attachMenuBots.value
+ if (current.isEmpty()) return
+
+ var changed = false
+ val updated = current.map { bot ->
+ if (bot.botUserId != botId) return@map bot
+ val iconContainer = bot.icon ?: return@map bot
+ val iconModel = iconContainer.icon ?: return@map bot
+ if (iconModel.id != fileId) return@map bot
+ if (iconModel.local.path == path && iconModel.local.isDownloadingCompleted) return@map bot
+
+ changed = true
+ bot.copy(
+ icon = iconContainer.copy(
+ icon = iconModel.copy(
+ local = FileLocalModel(
+ path = path,
+ isDownloadingActive = false,
+ canBeDownloaded = iconModel.local.canBeDownloaded,
+ isDownloadingCompleted = true,
+ canBeDeleted = iconModel.local.canBeDeleted,
+ downloadOffset = iconModel.local.downloadOffset,
+ downloadedPrefixSize = iconModel.local.downloadedPrefixSize,
+ downloadedSize = iconModel.size
+ )
+ )
+ )
+ )
+ }
+
+ if (!changed) return
+
+ attachMenuBots.value = updated
+ cacheProvider.setAttachBots(updated)
+ saveAttachBotsToDb(updated)
+ }
+
private suspend fun saveAttachBotsToDb(bots: List) {
withContext(dispatchers.io) {
attachBotDao.clearAll()
diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt
index 79b307c8..58525fd0 100644
--- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt
@@ -1,9 +1,11 @@
package org.monogram.data.repository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
import org.drinkless.tdlib.TdApi
-import org.monogram.core.ScopeProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.remote.AuthRemoteDataSource
import org.monogram.data.gateway.UpdateDispatcher
@@ -17,31 +19,74 @@ class AuthRepositoryImpl(
private val parametersProvider: TdLibParametersProvider,
private val remote: AuthRemoteDataSource,
private val updates: UpdateDispatcher,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : AuthRepository {
- private val scope = scopeProvider.appScope
-
private val _authState = MutableStateFlow(AuthStep.Loading)
override val authState = _authState.asStateFlow()
private val _errors = MutableSharedFlow(extraBufferCapacity = 1)
override val errors = _errors.asSharedFlow()
+ private val initMutex = Mutex()
+
init {
scope.launch {
+ // Proactively check current state in case we missed the update
+ launchAuthAction {
+ val state = remote.getAuthorizationState()
+ handleUpdate(state)
+ }
+
updates.authorizationState.collect { update ->
- if (update.authorizationState is TdApi.AuthorizationStateWaitTdlibParameters) {
- sendTdLibParameters()
- }
- val domainState = update.authorizationState.toDomain()
- _authState.update { domainState }
+ handleUpdate(update.authorizationState)
}
}
}
- private suspend fun sendTdLibParameters() {
- coRunCatching { remote.setTdlibParameters(parametersProvider.create()) }
- .onFailure { emitError(it) }
+ private fun handleUpdate(state: TdApi.AuthorizationState) {
+ if (state is TdApi.AuthorizationStateWaitTdlibParameters) {
+ sendTdLibParameters()
+ }
+ val domainState = state.toDomain()
+ _authState.update { domainState }
+ }
+
+ private fun sendTdLibParameters() {
+ if (!initMutex.tryLock()) return
+
+ scope.launch {
+ try {
+ var attempts = 0
+ while (true) {
+ // Double check if we still need to send parameters
+ val currentState = coRunCatching { remote.getAuthorizationState() }.getOrNull()
+ if (currentState != null && currentState !is TdApi.AuthorizationStateWaitTdlibParameters) {
+ break
+ }
+
+ val result = coRunCatching { remote.setTdlibParameters(parametersProvider.create()) }
+ if (result.isSuccess) {
+ // After success, immediately re-check state to move past WaitParameters
+ val nextState = coRunCatching { remote.getAuthorizationState() }.getOrNull()
+ if (nextState != null) {
+ handleUpdate(nextState)
+ }
+ break
+ }
+
+ val error = result.exceptionOrNull()
+ if (error?.message?.contains("Parameters are already set", ignoreCase = true) == true) {
+ break
+ }
+
+ attempts++
+ val delayMs = (1000L * attempts).coerceAtMost(10_000L)
+ delay(delayMs)
+ }
+ } finally {
+ initMutex.unlock()
+ }
+ }
}
private fun launchAuthAction(action: suspend () -> Unit) {
diff --git a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt
index a4661e4c..5fcc1789 100644
--- a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt
@@ -2,9 +2,8 @@ package org.monogram.data.repository
import org.drinkless.tdlib.TdApi
import org.monogram.data.datasource.remote.UserRemoteDataSource
-import org.monogram.domain.models.BotCommandModel
-import org.monogram.domain.models.BotInfoModel
-import org.monogram.domain.models.BotMenuButtonModel
+import org.monogram.data.mapper.isValidFilePath
+import org.monogram.domain.models.*
import org.monogram.domain.repository.BotRepository
class BotRepositoryImpl(
@@ -20,13 +19,84 @@ class BotRepositoryImpl(
override suspend fun getBotInfo(botId: Long): BotInfoModel? {
val fullInfo = remote.getBotFullInfo(botId) ?: return null
- val commands = fullInfo.botInfo?.commands?.map {
+ val info = fullInfo.botInfo
+ val commands = info?.commands?.map {
BotCommandModel(it.command, it.description)
} ?: emptyList()
- val menuButton = when (val btn = fullInfo.botInfo?.menuButton) {
+ val menuButton = when (val btn = info?.menuButton) {
is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(btn.text, btn.url)
else -> BotMenuButtonModel.Default
}
- return BotInfoModel(commands, menuButton)
+ val bestPhoto = info?.photo?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() }
+ ?: info?.photo?.sizes?.lastOrNull()
+ val photoPath = bestPhoto?.photo?.local?.path?.takeIf { isValidFilePath(it) }
+ val animationPath = info?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) }
+
+ return BotInfoModel(
+ commands = commands,
+ menuButton = menuButton,
+ shortDescription = info?.shortDescription?.ifEmpty { null },
+ description = info?.description?.ifEmpty { null },
+ photoFileId = bestPhoto?.photo?.id ?: 0,
+ photoPath = photoPath,
+ animationFileId = info?.animation?.animation?.id ?: 0,
+ animationPath = animationPath,
+ managerBotUserId = info?.managerBotUserId ?: 0L,
+ privacyPolicyUrl = info?.privacyPolicyUrl?.ifEmpty { null },
+ defaultGroupAdministratorRights = info?.defaultGroupAdministratorRights?.toDomain(),
+ defaultChannelAdministratorRights = info?.defaultChannelAdministratorRights?.toDomain(),
+ affiliateProgram = info?.affiliateProgram?.toDomain(),
+ webAppBackgroundLightColor = info?.webAppBackgroundLightColor ?: -1,
+ webAppBackgroundDarkColor = info?.webAppBackgroundDarkColor ?: -1,
+ webAppHeaderLightColor = info?.webAppHeaderLightColor ?: -1,
+ webAppHeaderDarkColor = info?.webAppHeaderDarkColor ?: -1,
+ verificationParameters = info?.verificationParameters?.let {
+ BotVerificationParametersModel(
+ iconCustomEmojiId = it.iconCustomEmojiId,
+ organizationName = it.organizationName.ifEmpty { null },
+ defaultCustomDescription = it.defaultCustomDescription?.text?.ifEmpty { null },
+ canSetCustomDescription = it.canSetCustomDescription
+ )
+ },
+ canGetRevenueStatistics = info?.canGetRevenueStatistics ?: false,
+ canManageEmojiStatus = info?.canManageEmojiStatus ?: false,
+ hasMediaPreviews = info?.hasMediaPreviews ?: false,
+ editCommandsLinkType = info?.editCommandsLink?.javaClass?.simpleName,
+ editDescriptionLinkType = info?.editDescriptionLink?.javaClass?.simpleName,
+ editDescriptionMediaLinkType = info?.editDescriptionMediaLink?.javaClass?.simpleName,
+ editSettingsLinkType = info?.editSettingsLink?.javaClass?.simpleName
+ )
}
-}
\ No newline at end of file
+}
+
+private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel {
+ return ChatAdministratorRightsModel(
+ canManageChat = canManageChat,
+ canChangeInfo = canChangeInfo,
+ canPostMessages = canPostMessages,
+ canEditMessages = canEditMessages,
+ canDeleteMessages = canDeleteMessages,
+ canInviteUsers = canInviteUsers,
+ canRestrictMembers = canRestrictMembers,
+ canPinMessages = canPinMessages,
+ canManageTopics = canManageTopics,
+ canPromoteMembers = canPromoteMembers,
+ canManageVideoChats = canManageVideoChats,
+ canPostStories = canPostStories,
+ canEditStories = canEditStories,
+ canDeleteStories = canDeleteStories,
+ canManageDirectMessages = canManageDirectMessages,
+ canManageTags = canManageTags,
+ isAnonymous = isAnonymous
+ )
+}
+
+private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel {
+ return AffiliateProgramInfoModel(
+ commissionPerMille = parameters.commissionPerMille,
+ monthCount = parameters.monthCount,
+ endDate = endDate,
+ dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount,
+ dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount
+ )
+}
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 980e9888..64848ef5 100644
--- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt
@@ -1,16 +1,12 @@
package org.monogram.data.repository
import android.util.Log
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Job
+import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.*
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.cache.ChatLocalDataSource
@@ -24,6 +20,7 @@ import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.infra.ConnectionManager
import org.monogram.data.infra.FileDownloadQueue
import org.monogram.data.infra.FileUpdateHandler
+import org.monogram.data.infra.SynchronizedLruMap
import org.monogram.data.mapper.ChatMapper
import org.monogram.data.mapper.MessageMapper
import org.monogram.domain.models.ChatModel
@@ -46,7 +43,7 @@ class ChatsListRepositoryImpl(
private val chatMapper: ChatMapper,
private val messageMapper: MessageMapper,
private val gateway: TelegramGateway,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val chatLocalDataSource: ChatLocalDataSource,
private val connectionManager: ConnectionManager,
private val databaseFile: File,
@@ -64,8 +61,6 @@ class ChatsListRepositoryImpl(
ChatSettingsRepository,
ChatCreationRepository {
- private val scope = scopeProvider.appScope
-
private val _chatListFlow = MutableStateFlow>(emptyList())
override val chatListFlow: StateFlow> = _chatListFlow.asStateFlow()
@@ -96,7 +91,7 @@ class ChatsListRepositoryImpl(
private val fileManager = ChatFileManager(
gateway = gateway,
dispatchers = dispatchers,
- scopeProvider = scopeProvider,
+ scope = scope,
fileQueue = fileQueue,
fileUpdateHandler = fileUpdateHandler,
onUpdate = {
@@ -126,7 +121,7 @@ class ChatsListRepositoryImpl(
private val modelFactory = ChatModelFactory(
gateway = gateway,
dispatchers = dispatchers,
- scopeProvider = scopeProvider,
+ scope = scope,
cache = cache,
chatMapper = chatMapper,
fileManager = fileManager,
@@ -151,7 +146,7 @@ class ChatsListRepositoryImpl(
private val folderManager = ChatFolderManager(
gateway = gateway,
dispatchers = dispatchers,
- scopeProvider = scopeProvider,
+ scope = scope,
foldersFlow = _foldersFlow,
cacheProvider = cacheProvider,
chatFolderDao = chatFolderDao
@@ -200,7 +195,7 @@ class ChatsListRepositoryImpl(
private val initialChatListLimit = 50
private var currentLimit = initialChatListLimit
- private val modelCache = ConcurrentHashMap()
+ private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE)
private val invalidatedModels = ConcurrentHashMap.newKeySet()
private var lastList: List? = null
private var lastListFolderId: Int = -1
@@ -707,6 +702,23 @@ class ChatsListRepositoryImpl(
}
}
+ fun clearMemoryCaches() {
+ modelCache.clear()
+ invalidatedModels.clear()
+ }
+
+ fun memoryCacheSnapshot(): MemoryCacheSnapshot {
+ return MemoryCacheSnapshot(
+ modelCacheSize = modelCache.size(),
+ invalidatedModelsSize = invalidatedModels.size
+ )
+ }
+
+ data class MemoryCacheSnapshot(
+ val modelCacheSize: Int,
+ val invalidatedModelsSize: Int
+ )
+
private fun fetchUser(userId: Long) {
if (userId == 0L) return
if (cache.pendingUsers.add(userId)) {
@@ -737,5 +749,6 @@ class ChatsListRepositoryImpl(
companion object {
private const val TAG = "ChatsListRepository"
private const val REBUILD_THROTTLE_MS = 250L
+ private const val MODEL_CACHE_SIZE = 256
}
}
diff --git a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt
index a61a6123..0b8b9c13 100644
--- a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt
@@ -1,11 +1,11 @@
package org.monogram.data.repository
import android.content.Context
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.datasource.cache.StickerLocalDataSource
import org.monogram.data.datasource.remote.EmojiRemoteSource
import org.monogram.data.infra.EmojiLoader
@@ -20,11 +20,9 @@ class EmojiRepositoryImpl(
private val cacheProvider: CacheProvider,
private val dispatchers: DispatcherProvider,
private val context: Context,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : EmojiRepository {
- private val scope = scopeProvider.appScope
-
override val recentEmojis: Flow> = cacheProvider.recentEmojis
private var cachedEmojis: List? = null
diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
index 95ec6f75..3e622906 100644
--- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
@@ -1,56 +1,18 @@
package org.monogram.data.repository
+import kotlinx.coroutines.*
import org.monogram.data.core.coRunCatching
+import org.monogram.data.datasource.remote.ProxyRemoteDataSource
import org.monogram.domain.models.ProxyModel
import org.monogram.domain.models.ProxyTypeModel
import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.ExternalProxyRepository
-import kotlinx.coroutines.*
-import androidx.core.net.toUri
-import org.monogram.core.DispatcherProvider
-import org.monogram.data.datasource.remote.ExternalProxyDataSource
-import org.monogram.data.datasource.remote.ProxyRemoteDataSource
class ExternalProxyRepositoryImpl(
private val remote: ProxyRemoteDataSource,
- private val externalSource: ExternalProxyDataSource,
- private val appPreferences: AppPreferencesProvider,
- private val dispatchers: DispatcherProvider
+ private val appPreferences: AppPreferencesProvider
) : ExternalProxyRepository {
- override suspend fun fetchExternalProxies(): List = withContext(dispatchers.io) {
- if (!appPreferences.isTelegaProxyEnabled.value) return@withContext emptyList()
-
- val urls = externalSource.fetchProxyUrls().distinct()
- if (urls.isEmpty()) return@withContext emptyList()
-
- val parsed = urls.mapNotNull { url ->
- parseProxyUrl(url)?.let { (server, port, secret) ->
- Triple(url, server to port, ProxyTypeModel.Mtproto(secret))
- }
- }
-
- val oldIdentifiers = appPreferences.telegaProxyUrls.value
- .mapNotNull { parseProxyUrl(it)?.let { (s, p, _) -> "$s:$p" } }
- .toSet()
-
- val newIdentifiers = parsed.map { (_, sp, _) -> "${sp.first}:${sp.second}" }.toSet()
- appPreferences.setTelegaProxyUrls(parsed.map { it.first }.toSet())
-
- val added = parsed.mapNotNull { (_, sp, type) ->
- coRunCatching { remote.addProxy(sp.first, sp.second, false, type) }.getOrNull()
- }
-
- remote.getProxies().forEach { proxy ->
- val iden = "${proxy.server}:${proxy.port}"
- if (iden in oldIdentifiers && iden !in newIdentifiers && !proxy.isEnabled) {
- coRunCatching { remote.removeProxy(proxy.id) }
- }
- }
-
- added
- }
-
override suspend fun getProxies(): List = remote.getProxies()
override suspend fun addProxy(
@@ -103,12 +65,4 @@ class ExternalProxyRepositoryImpl(
appPreferences.setPreferIpv6(enabled)
}
- private fun parseProxyUrl(url: String): Triple? =
- coRunCatching {
- val uri = url.toUri()
- val server = uri.getQueryParameter("server") ?: return null
- val port = uri.getQueryParameter("port")?.toIntOrNull() ?: 443
- val secret = uri.getQueryParameter("secret") ?: ""
- Triple(server, port, secret)
- }.getOrNull()
}
\ No newline at end of file
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 10e2905d..870949ea 100644
--- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt
@@ -2,32 +2,60 @@ package org.monogram.data.repository
import android.content.Context
import android.util.Log
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.FileDataSource
import org.monogram.data.datasource.cache.ChatLocalDataSource
import org.monogram.data.datasource.cache.UserLocalDataSource
import org.monogram.data.datasource.remote.MessageRemoteDataSource
+import org.monogram.data.db.dao.KeyValueDao
+import org.monogram.data.db.dao.StickerPathDao
import org.monogram.data.db.dao.TextCompositionStyleDao
+import org.monogram.data.db.model.KeyValueEntity
import org.monogram.data.db.model.TextCompositionStyleEntity
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.gateway.UpdateDispatcher
-import org.monogram.data.infra.FileUpdateHandler
import org.monogram.data.mapper.MessageMapper
+import org.monogram.data.mapper.TdFileHelper
import org.monogram.data.mapper.map
import org.monogram.data.mapper.toDomain
-import org.monogram.domain.models.*
+import org.monogram.domain.models.ChatEventActionModel
+import org.monogram.domain.models.ChatEventLogFiltersModel
+import org.monogram.domain.models.ChatEventModel
+import org.monogram.domain.models.ChatPermissionsModel
+import org.monogram.domain.models.FileModel
+import org.monogram.domain.models.InlineQueryResultModel
+import org.monogram.domain.models.MessageEntity
+import org.monogram.domain.models.MessageEntityType
+import org.monogram.domain.models.MessageDownloadEvent
+import org.monogram.domain.models.MessageModel
+import org.monogram.domain.models.MessageSendOptions
+import org.monogram.domain.models.MessageSenderModel
+import org.monogram.domain.models.MessageViewerModel
+import org.monogram.domain.models.UserModel
import org.monogram.domain.models.webapp.InstantViewModel
import org.monogram.domain.models.webapp.InvoiceModel
import org.monogram.domain.models.webapp.ThemeParams
import org.monogram.domain.models.webapp.WebAppInfoModel
-import org.monogram.domain.repository.*
+import org.monogram.domain.repository.FixedTextResult
+import org.monogram.domain.repository.FormattedTextResult
+import org.monogram.domain.repository.InlineBotResultsModel
+import org.monogram.domain.repository.MessageRepository
+import org.monogram.domain.repository.OlderMessagesPage
+import org.monogram.domain.repository.ProfileMediaFilter
+import org.monogram.domain.repository.SearchChatMessagesResult
+import org.monogram.domain.repository.TextCompositionStyleModel
import java.io.File
class MessageRepositoryImpl(
@@ -37,25 +65,26 @@ class MessageRepositoryImpl(
private val messageMapper: MessageMapper,
private val messageRemoteDataSource: MessageRemoteDataSource,
private val cache: ChatCache,
+ private val fileHelper: TdFileHelper,
private val fileDataSource: FileDataSource,
private val dispatcherProvider: DispatcherProvider,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val chatLocalDataSource: ChatLocalDataSource,
private val userLocalDataSource: UserLocalDataSource,
- private val fileUpdateHandler: FileUpdateHandler,
+ private val stickerPathDao: StickerPathDao,
+ private val keyValueDao: KeyValueDao,
private val textCompositionStyleDao: TextCompositionStyleDao
) : MessageRepository {
- private val scope = scopeProvider.appScope
private val _textCompositionStyles = MutableStateFlow>(emptyList())
+ private val hardResetFlagKey = "cache_hard_reset_v2"
override val newMessageFlow = messageRemoteDataSource.newMessageFlow
override val senderUpdateFlow = messageMapper.senderUpdateFlow
override val messageEditedFlow = messageRemoteDataSource.messageEditedFlow
override val messageUploadProgressFlow = messageRemoteDataSource.messageUploadProgressFlow
- override val messageDownloadProgressFlow = messageRemoteDataSource.messageDownloadProgressFlow
- override val messageDownloadCancelledFlow = messageRemoteDataSource.messageDownloadCancelledFlow
+ override val fileDownloadFlow = messageRemoteDataSource.fileDownloadFlow
+ override val messageDownloadFlow = messageRemoteDataSource.messageDownloadFlow
override val messageReadFlow = messageRemoteDataSource.messageReadFlow
- override val messageDownloadCompletedFlow = messageRemoteDataSource.messageDownloadCompletedFlow
override val messageDeletedFlow = messageRemoteDataSource.messageDeletedFlow
override val messageIdUpdateFlow = messageRemoteDataSource.messageIdUpdateFlow
override val pinnedMessageFlow = messageRemoteDataSource.pinnedMessageFlow
@@ -93,16 +122,40 @@ class MessageRepositoryImpl(
chatLocalDataSource.deleteExpired(ninetyDaysAgo)
}
- scope.launch {
- fileUpdateHandler.fileDownloadCompleted.collect { (fileIdLong, path) ->
- val fileId = fileIdLong.toInt()
- if (fileId != 0 && path.isNotBlank()) {
- chatLocalDataSource.updateMediaPath(fileId, path)
+ scope.launch(dispatcherProvider.io) {
+ performHardCacheResetIfNeeded()
+ }
+
+ scope.launch(dispatcherProvider.io) {
+ messageDownloadFlow.collect { event ->
+ if (event is MessageDownloadEvent.Completed && event.fileId != 0 && event.path.isNotBlank()) {
+ chatLocalDataSource.updateMediaPath(
+ chatId = event.chatId,
+ messageId = event.messageId,
+ fileId = event.fileId,
+ path = event.path
+ )
}
}
}
}
+ private suspend fun performHardCacheResetIfNeeded() {
+ val alreadyCleared = keyValueDao.getValue(hardResetFlagKey)?.value == "1"
+ if (alreadyCleared) return
+
+ coRunCatching {
+ chatLocalDataSource.clearAll()
+ userLocalDataSource.clearDatabase()
+ stickerPathDao.clearAll()
+ cache.clearAll()
+ keyValueDao.insertValue(KeyValueEntity(hardResetFlagKey, "1"))
+ Log.i("MessageRepository", "One-shot hard cache reset completed")
+ }.onFailure { error ->
+ Log.e("MessageRepository", "Failed to perform hard cache reset", error)
+ }
+ }
+
private suspend fun processCachedUpdate(update: TdApi.Update) {
when (update) {
is TdApi.UpdateNewMessage -> {
@@ -112,7 +165,22 @@ class MessageRepositoryImpl(
is TdApi.UpdateMessageContent -> {
val extracted = messageMapper.extractCachedContent(update.newContent)
+
+ 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
+ }
+ }
+
chatLocalDataSource.updateMessageContent(
+ chatId = update.chatId,
messageId = update.messageId,
content = extracted.text,
contentType = extracted.type,
@@ -137,6 +205,7 @@ class MessageRepositoryImpl(
is TdApi.UpdateMessageInteractionInfo -> {
chatLocalDataSource.updateInteractionInfo(
+ chatId = update.chatId,
messageId = update.messageId,
viewCount = update.interactionInfo?.viewCount ?: 0,
forwardCount = update.interactionInfo?.forwardCount ?: 0,
@@ -151,7 +220,7 @@ class MessageRepositoryImpl(
is TdApi.UpdateDeleteMessages -> {
if (update.isPermanent) {
update.messageIds.forEach { messageId ->
- chatLocalDataSource.deleteMessage(messageId)
+ chatLocalDataSource.deleteMessage(update.chatId, messageId)
}
}
}
@@ -335,7 +404,7 @@ class MessageRepositoryImpl(
override suspend fun deleteMessage(chatId: Long, messageIds: List, revoke: Boolean) {
messageRemoteDataSource.deleteMessages(chatId, messageIds.toLongArray(), revoke)
- messageIds.forEach { chatLocalDataSource.deleteMessage(it) }
+ messageIds.forEach { chatLocalDataSource.deleteMessage(chatId, it) }
}
override suspend fun editMessage(chatId: Long, messageId: Long, newText: String, entities: List) {
@@ -790,7 +859,7 @@ class MessageRepositoryImpl(
override suspend fun getFilePath(fileId: Int): String? {
val result = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull()
return if (result is TdApi.File) {
- result.local.path.ifEmpty { null }
+ result.local.path.takeIf { fileHelper.isValidPath(it) }
} else {
null
}
@@ -918,7 +987,7 @@ class MessageRepositoryImpl(
if (thumbnail == null) return null
val file = thumbnail.file
val updated = cache.fileCache[file.id] ?: file
- if (updated.local.path.isNotEmpty()) return updated.local.path
+ if (fileHelper.isValidPath(updated.local.path)) return updated.local.path
scope.launch {
fileDataSource.downloadFile(updated.id, 32, 0, 0, false)
}
@@ -1072,11 +1141,7 @@ class MessageRepositoryImpl(
TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "")
else null
- val topicId = if (threadId != null) {
- TdApi.MessageTopicForum(threadId.toInt())
- } else {
- null
- }
+ val topicId = resolveTopicId(chatId, threadId)
gateway.execute(
TdApi.SendInlineQueryResultMessage(
@@ -1091,6 +1156,20 @@ class MessageRepositoryImpl(
)
}
+ private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? {
+ if (threadId == null || threadId == 0L) return null
+
+ val chat = cache.getChat(chatId)
+ ?: coRunCatching { gateway.execute(TdApi.GetChat(chatId)) }.getOrNull()
+ ?.also { cache.putChat(it) }
+
+ return if (chat?.viewAsTopics == true) {
+ TdApi.MessageTopicForum(threadId.toInt())
+ } else {
+ TdApi.MessageTopicThread(threadId)
+ }
+ }
+
override suspend fun getChatEventLog(
chatId: Long,
query: String,
@@ -1393,7 +1472,7 @@ class MessageRepositoryImpl(
cachedUser.lastName?.takeIf { it.isNotBlank() }
).joinToString(" ").ifBlank { model.senderName }
- val resolvedAvatar = resolveFilePath(cachedUser.profilePhoto?.small)
+ val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedUser.profilePhoto?.small)
if (resolvedAvatar == null) {
cachedUser.profilePhoto?.small?.id?.takeIf { it != 0 }?.let { avatarFileId ->
messageRemoteDataSource.enqueueDownload(avatarFileId, priority = 16)
@@ -1417,7 +1496,7 @@ class MessageRepositoryImpl(
val cachedChat = cache.getChat(senderId)
if (cachedChat != null) {
val resolvedName = cachedChat.title.takeIf { it.isNotBlank() } ?: model.senderName
- val resolvedAvatar = resolveFilePath(cachedChat.photo?.small)
+ val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedChat.photo?.small)
return model.copy(
senderName = resolvedName,
senderAvatar = resolvedAvatar ?: model.senderAvatar
@@ -1427,15 +1506,6 @@ class MessageRepositoryImpl(
return model
}
- private fun resolveFilePath(file: TdApi.File?): String? {
- if (file == null) return null
- val directPath = file.local.path.takeIf { it.isNotBlank() && File(it).exists() }
- if (directPath != null) return directPath
-
- val cachedPath = cache.fileCache[file.id]?.local?.path
- return cachedPath?.takeIf { it.isNotBlank() && File(it).exists() }
- }
-
private fun TextCompositionStyleModel.toEntity(): TextCompositionStyleEntity {
return TextCompositionStyleEntity(
name = name,
diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt
index 74cad092..c3ff6166 100644
--- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt
@@ -1,37 +1,44 @@
package org.monogram.data.repository
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.datasource.cache.SettingsCacheDataSource
import org.monogram.data.datasource.remote.ChatsRemoteDataSource
import org.monogram.data.datasource.remote.SettingsRemoteDataSource
+import org.monogram.data.db.dao.NotificationExceptionDao
+import org.monogram.data.db.model.NotificationExceptionEntity
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.mapper.toApi
import org.monogram.data.mapper.user.toDomain
import org.monogram.domain.models.ChatModel
+import org.monogram.domain.models.ChatType
import org.monogram.domain.repository.NotificationSettingsRepository
import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope
+import java.util.concurrent.ConcurrentHashMap
class NotificationSettingsRepositoryImpl(
private val remote: SettingsRemoteDataSource,
private val cache: SettingsCacheDataSource,
private val chatsRemote: ChatsRemoteDataSource,
+ private val notificationExceptionDao: NotificationExceptionDao,
private val updates: UpdateDispatcher,
- scopeProvider: ScopeProvider,
+ private val scope: CoroutineScope,
private val dispatchers: DispatcherProvider
) : NotificationSettingsRepository {
- private val scope = scopeProvider.appScope
+ private val exceptionsCache = ConcurrentHashMap>()
+ private val exceptionsCacheMutex = Mutex()
init {
scope.launch {
updates.newChat.collect { update ->
cache.putChat(update.chat)
+ syncChatWithExceptionsCache(update.chat)
}
}
@@ -41,6 +48,7 @@ class NotificationSettingsRepositoryImpl(
synchronized(chat) {
chat.title = update.title
}
+ syncChatWithExceptionsCache(chat)
}
}
}
@@ -51,6 +59,7 @@ class NotificationSettingsRepositoryImpl(
synchronized(chat) {
chat.photo = update.photo
}
+ syncChatWithExceptionsCache(chat)
}
}
}
@@ -61,6 +70,13 @@ class NotificationSettingsRepositoryImpl(
synchronized(chat) {
chat.notificationSettings = update.notificationSettings
}
+ syncChatWithExceptionsCache(chat)
+ } ?: run {
+ if (update.notificationSettings.isException(compareSound = true)) {
+ invalidateExceptionsCache()
+ } else {
+ removeFromExceptionsCache(update.chatId)
+ }
}
}
}
@@ -79,14 +95,22 @@ class NotificationSettingsRepositoryImpl(
remote.setScopeNotificationSettings(scope.toApi(), settings)
}
- override suspend fun getExceptions(scope: TdNotificationScope): List = coroutineScope {
- val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true)
- chats?.chatIds?.map { chatId ->
- async(dispatchers.io) {
- cache.getChat(chatId)?.toDomain()
- ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }?.toDomain()
+ override suspend fun getExceptions(scope: TdNotificationScope): List {
+ exceptionsCache[scope]?.let { return it }
+
+ return exceptionsCacheMutex.withLock {
+ exceptionsCache[scope]?.let { return@withLock it }
+
+ loadExceptionsFromRoom(scope)?.let { roomCached ->
+ exceptionsCache[scope] = roomCached
+ return@withLock roomCached
}
- }?.awaitAll()?.filterNotNull() ?: emptyList()
+
+ val remoteLoaded = loadExceptionsFromApi(scope)
+ exceptionsCache[scope] = remoteLoaded
+ persistScopeToRoom(scope, remoteLoaded)
+ remoteLoaded
+ }
}
override suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) {
@@ -98,6 +122,7 @@ class NotificationSettingsRepositoryImpl(
useDefaultMuteStories = true
}
remote.setChatNotificationSettings(chatId, settings)
+ updateCachedChatMute(chatId, isMuted = !enabled)
}
override suspend fun resetChatNotificationSettings(chatId: Long) {
@@ -108,5 +133,157 @@ class NotificationSettingsRepositoryImpl(
useDefaultMuteStories = true
}
remote.setChatNotificationSettings(chatId, settings)
+ removeFromExceptionsCache(chatId)
+ }
+
+ private suspend fun loadExceptionsFromRoom(scope: TdNotificationScope): List? =
+ withContext(dispatchers.io) {
+ val cached = notificationExceptionDao.getByScope(scope.name)
+ if (cached.isEmpty()) null else cached.map { it.toDomainChatModel() }
+ }
+
+ private suspend fun loadExceptionsFromApi(scope: TdNotificationScope): List =
+ withContext(dispatchers.io) {
+ val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true)
+ val result = mutableListOf()
+
+ chats?.chatIds?.distinct()?.forEach { chatId ->
+ val chat = cache.getChat(chatId)
+ ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }
+
+ chat?.toDomain()?.let(result::add)
+ }
+
+ result
+ }
+
+ private suspend fun persistScopeToRoom(scope: TdNotificationScope, chats: List) {
+ withContext(dispatchers.io) {
+ notificationExceptionDao.replaceForScope(
+ scope = scope.name,
+ entities = chats.map { it.toExceptionEntity(scope) }
+ )
+ }
+ }
+
+ private fun syncChatWithExceptionsCache(chat: TdApi.Chat) {
+ val notificationScope = chat.toNotificationScope() ?: return
+ val isException = chat.notificationSettings.isException(compareSound = true)
+ val mappedChat = if (isException) chat.toDomain() else null
+
+ exceptionsCache[notificationScope]?.let { existing ->
+ val updated = if (isException && mappedChat != null) {
+ if (existing.any { it.id == chat.id }) {
+ existing.map { cached ->
+ if (cached.id == chat.id) mappedChat else cached
+ }
+ } else {
+ existing + mappedChat
+ }
+ } else {
+ existing.filterNot { it.id == chat.id }
+ }
+
+ exceptionsCache[notificationScope] = updated
+ }
+
+ scope.launch(dispatchers.io) {
+ if (isException && mappedChat != null) {
+ notificationExceptionDao.insert(mappedChat.toExceptionEntity(notificationScope))
+ } else {
+ notificationExceptionDao.deleteByChatId(chat.id)
+ }
+ }
+ }
+
+ private fun updateCachedChatMute(chatId: Long, isMuted: Boolean) {
+ if (exceptionsCache.isNotEmpty()) {
+ exceptionsCache.keys.forEach { notificationScope ->
+ val existing = exceptionsCache[notificationScope] ?: return@forEach
+ exceptionsCache[notificationScope] = existing.map { chat ->
+ if (chat.id == chatId) chat.copy(isMuted = isMuted) else chat
+ }
+ }
+ }
+
+ scope.launch(dispatchers.io) {
+ notificationExceptionDao.updateMute(chatId = chatId, isMuted = isMuted)
+ }
+ }
+
+ private fun removeFromExceptionsCache(chatId: Long) {
+ if (exceptionsCache.isNotEmpty()) {
+ exceptionsCache.keys.forEach { notificationScope ->
+ val existing = exceptionsCache[notificationScope] ?: return@forEach
+ exceptionsCache[notificationScope] = existing.filterNot { it.id == chatId }
+ }
+ }
+
+ scope.launch(dispatchers.io) {
+ notificationExceptionDao.deleteByChatId(chatId)
+ }
+ }
+
+ private fun invalidateExceptionsCache() {
+ exceptionsCache.clear()
+ scope.launch(dispatchers.io) {
+ notificationExceptionDao.clearAll()
+ }
+ }
+
+ private fun TdApi.Chat.toNotificationScope(): TdNotificationScope? = when (val chatType = type) {
+ is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS
+ is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS
+ is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS
+ else -> null
+ }
+
+ private fun TdApi.ChatNotificationSettings.isException(compareSound: Boolean): Boolean {
+ if (!useDefaultMuteFor) return true
+ if (!useDefaultShowPreview) return true
+ if (!useDefaultMuteStories) return true
+ if (!useDefaultShowStoryPoster) return true
+ if (!useDefaultDisablePinnedMessageNotifications) return true
+ if (!useDefaultDisableMentionNotifications) return true
+
+ if (compareSound) {
+ if (!useDefaultSound) return true
+ if (!useDefaultStorySound) return true
+ }
+
+ return false
+ }
+
+ private fun ChatModel.toExceptionEntity(scope: TdNotificationScope): NotificationExceptionEntity {
+ return NotificationExceptionEntity(
+ chatId = id,
+ scope = scope.name,
+ title = title,
+ avatarPath = avatarPath,
+ personalAvatarPath = personalAvatarPath,
+ isMuted = isMuted,
+ isGroup = isGroup,
+ isChannel = isChannel,
+ type = type.name
+ )
+ }
+
+ private fun NotificationExceptionEntity.toDomainChatModel(): ChatModel {
+ return ChatModel(
+ id = chatId,
+ title = title,
+ unreadCount = 0,
+ avatarPath = avatarPath,
+ personalAvatarPath = personalAvatarPath,
+ isMuted = isMuted,
+ isGroup = isGroup,
+ isChannel = isChannel,
+ type = type.toDomainChatType()
+ )
+ }
+
+ private fun String.toDomainChatType(): ChatType {
+ return runCatching { ChatType.valueOf(this) }
+ .getOrDefault(ChatType.PRIVATE)
}
}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt
index 14f52fe2..6d212661 100644
--- a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt
@@ -1,9 +1,5 @@
package org.monogram.data.repository
-import org.monogram.core.ScopeProvider
-import org.monogram.domain.models.PrivacyRule
-import org.monogram.domain.repository.PrivacyKey
-import org.monogram.domain.repository.PrivacyRepository
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import org.drinkless.tdlib.TdApi
@@ -11,15 +7,15 @@ import org.monogram.data.datasource.remote.PrivacyRemoteDataSource
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.mapper.toApi
import org.monogram.data.mapper.toDomain
+import org.monogram.domain.models.PrivacyRule
+import org.monogram.domain.repository.PrivacyKey
+import org.monogram.domain.repository.PrivacyRepository
class PrivacyRepositoryImpl(
private val remote: PrivacyRemoteDataSource,
- private val updates: UpdateDispatcher,
- scopeProvider: ScopeProvider
+ private val updates: UpdateDispatcher
) : PrivacyRepository {
- private val scope = scopeProvider.appScope
-
override fun getPrivacyRules(key: PrivacyKey): Flow> = callbackFlow {
val setting = key.toApi()
diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt
index 4a910a0a..6b50a377 100644
--- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt
@@ -4,15 +4,17 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.withTimeoutOrNull
import org.drinkless.tdlib.TdApi
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.cache.ChatLocalDataSource
import org.monogram.data.datasource.remote.UserRemoteDataSource
import org.monogram.data.gateway.TelegramGateway
-import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.infra.FileDownloadQueue
+import org.monogram.data.infra.FileObserverHub
+import org.monogram.data.mapper.isValidFilePath
import org.monogram.data.mapper.toEntity
import org.monogram.data.mapper.user.toTdApiChat
import org.monogram.domain.repository.ProfilePhotoRepository
@@ -21,8 +23,8 @@ class ProfilePhotoRepositoryImpl(
private val remote: UserRemoteDataSource,
private val chatLocal: ChatLocalDataSource,
private val gateway: TelegramGateway,
- private val updates: UpdateDispatcher,
- private val fileQueue: FileDownloadQueue
+ private val fileQueue: FileDownloadQueue,
+ private val fileObserverHub: FileObserverHub
) : ProfilePhotoRepository {
private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY
private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY
@@ -57,29 +59,100 @@ class ProfilePhotoRepositoryImpl(
return listOfNotNull(currentPath)
}
- override fun getUserProfilePhotosFlow(userId: Long): Flow> = flow {
+ override fun getUserProfilePhotosFlow(userId: Long): Flow> = channelFlow {
if (userId <= 0) {
- emit(emptyList())
- return@flow
+ send(emptyList())
+ return@channelFlow
+ }
+
+ var trackedFileIds = emptySet()
+
+ suspend fun reload() {
+ val loaded = getUserProfilePhotosWithTracking(userId)
+ trackedFileIds = loaded.second
+ send(loaded.first)
+ }
+
+ reload()
+
+ fileObserverHub.fileStates.collectLatest { state ->
+ if (state.fileId in trackedFileIds) {
+ reload()
+ }
}
- emit(getUserProfilePhotos(userId))
- updates.file.collect { emit(getUserProfilePhotos(userId)) }
}
- override fun getChatProfilePhotosFlow(chatId: Long): Flow> = flow {
+ override fun getChatProfilePhotosFlow(chatId: Long): Flow> = channelFlow {
if (chatId == 0L) {
- emit(emptyList())
- return@flow
+ send(emptyList())
+ return@channelFlow
+ }
+
+ var trackedFileIds = emptySet()
+
+ suspend fun reload() {
+ val loaded = getChatProfilePhotosWithTracking(chatId)
+ trackedFileIds = loaded.second
+ send(loaded.first)
+ }
+
+ reload()
+
+ fileObserverHub.fileStates.collectLatest { state ->
+ if (state.fileId in trackedFileIds) {
+ reload()
+ }
+ }
+ }
+
+ private suspend fun getUserProfilePhotosWithTracking(
+ userId: Long,
+ offset: Int = 0,
+ limit: Int = 10,
+ ensureFullRes: Boolean = false
+ ): Pair, Set> {
+ if (userId <= 0) return emptyList() to emptySet()
+ val trackedFileIds = linkedSetOf()
+ val result = remote.getUserProfilePhotos(userId, offset, limit)
+ ?: return emptyList() to emptySet()
+ val paths = coroutineScope {
+ result.photos
+ .map { photo ->
+ async {
+ resolveUserProfilePhotoPath(
+ photo,
+ ensureFullRes,
+ trackedFileIds
+ )
+ }
+ }
+ .awaitAll()
+ .filterNotNull()
}
- emit(getChatProfilePhotos(chatId))
- updates.file.collect { emit(getChatProfilePhotos(chatId)) }
+ return paths to trackedFileIds
+ }
+
+ private suspend fun getChatProfilePhotosWithTracking(
+ chatId: Long,
+ offset: Int = 0,
+ limit: Int = 10,
+ ensureFullRes: Boolean = false
+ ): Pair, Set> {
+ if (chatId == 0L) return emptyList() to emptySet()
+ val trackedFileIds = linkedSetOf()
+ val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes, trackedFileIds)
+ if (paths.isNotEmpty()) return paths to trackedFileIds
+
+ val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes, trackedFileIds)
+ return listOfNotNull(currentPath) to trackedFileIds
}
private suspend fun loadChatPhotoHistoryPaths(
chatId: Long,
offset: Int,
limit: Int,
- ensureFullRes: Boolean
+ ensureFullRes: Boolean,
+ trackedFileIds: MutableSet? = null
): List {
if (limit <= 0) return emptyList()
@@ -108,33 +181,48 @@ class ProfilePhotoRepositoryImpl(
return coroutineScope {
chatPhotos
- .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } }
+ .map { photo ->
+ async {
+ resolveUserProfilePhotoPath(
+ photo,
+ ensureFullRes,
+ trackedFileIds
+ )
+ }
+ }
.awaitAll()
.filterNotNull()
.distinct()
}
}
- private suspend fun resolveCurrentChatPhotoPath(chatId: Long, ensureFullRes: Boolean): String? {
+ private suspend fun resolveCurrentChatPhotoPath(
+ chatId: Long,
+ ensureFullRes: Boolean,
+ trackedFileIds: MutableSet? = null
+ ): String? {
val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) }
?: chatLocal.getChat(chatId)?.toTdApiChat()
?: return null
- return resolveChatPhotoInfoPath(chat.photo, ensureFullRes)
+ return resolveChatPhotoInfoPath(chat.photo, ensureFullRes, trackedFileIds)
}
private suspend fun resolveChatPhotoInfoPath(
photoInfo: TdApi.ChatPhotoInfo?,
- ensureFullRes: Boolean
+ ensureFullRes: Boolean,
+ trackedFileIds: MutableSet? = null
): String? {
val smallId = photoInfo?.small?.id?.takeIf { it != 0 }
val bigId = photoInfo?.big?.id?.takeIf { it != 0 }
+ smallId?.let { trackedFileIds?.add(it) }
+ bigId?.let { trackedFileIds?.add(it) }
val preferredFile = if (ensureFullRes) {
photoInfo?.big ?: photoInfo?.small
} else {
photoInfo?.small ?: photoInfo?.big
} ?: return null
- val directPath = preferredFile.local.path.ifEmpty { null }
+ val directPath = preferredFile.local.path.takeIf { isValidFilePath(it) }
if (directPath != null) {
if (!ensureFullRes && bigId != null && bigId != preferredFile.id) {
fileQueue.enqueue(
@@ -203,19 +291,25 @@ class ProfilePhotoRepositoryImpl(
private suspend fun resolveUserProfilePhotoPath(
photo: TdApi.ChatPhoto,
- ensureFullRes: Boolean
+ ensureFullRes: Boolean,
+ trackedFileIds: MutableSet? = null
): String? {
val animationFile = photo.animation?.file
- val animationPath = animationFile?.local?.path?.ifEmpty { null }
+ animationFile?.id?.takeIf { it != 0 }?.let { trackedFileIds?.add(it) }
+ val animationPath = animationFile?.local?.path?.takeIf { isValidFilePath(it) }
if (animationPath != null) return animationPath
+ photo.sizes.forEach { size ->
+ size.photo.id.takeIf { it != 0 }?.let { trackedFileIds?.add(it) }
+ }
+
val bestPhotoFile = photo.sizes
.maxByOrNull { it.width.toLong() * it.height.toLong() }
?.photo
?: photo.sizes.lastOrNull()?.photo
?: return null
- val directPath = bestPhotoFile.local.path.ifEmpty { null }
+ val directPath = bestPhotoFile.local.path.takeIf { isValidFilePath(it) }
if (directPath != null) return directPath
if (!ensureFullRes) {
@@ -226,7 +320,7 @@ class ProfilePhotoRepositoryImpl(
?: photo.sizes.find { it.type == "a" }?.photo
?: photo.sizes.firstOrNull()?.photo
- val fallbackDirectPath = fallbackFile?.local?.path?.ifEmpty { null }
+ val fallbackDirectPath = fallbackFile?.local?.path?.takeIf { isValidFilePath(it) }
if (fallbackDirectPath != null) return fallbackDirectPath
val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id)
@@ -251,8 +345,14 @@ class ProfilePhotoRepositoryImpl(
private suspend fun resolveDownloadedFilePath(fileId: Int?): String? {
if (fileId == null || fileId == 0) return null
- val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null
- return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null
+ val file = fileObserverHub.getCachedFile(fileId)
+ ?: coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull()
+ ?: return null
+ return if (file.local.isDownloadingCompleted) {
+ file.local.path.takeIf { isValidFilePath(it) }
+ } else {
+ null
+ }
}
companion object {
@@ -261,4 +361,4 @@ class ProfilePhotoRepositoryImpl(
private const val FULL_RES_DOWNLOAD_PRIORITY = 32
private const val FILE_DOWNLOAD_TIMEOUT_MS = 15_000L
}
-}
\ No newline at end of file
+}
diff --git a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt
index ac9f9edc..38833a69 100644
--- a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt
@@ -1,6 +1,7 @@
package org.monogram.data.repository
import android.util.Log
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
@@ -9,7 +10,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.cache.StickerLocalDataSource
import org.monogram.data.datasource.remote.StickerRemoteSource
@@ -28,11 +28,9 @@ class StickerRepositoryImpl(
private val cacheProvider: CacheProvider,
private val dispatchers: DispatcherProvider,
private val localDataSource: StickerLocalDataSource,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : StickerRepository {
- private val scope = scopeProvider.appScope
-
override val installedStickerSets: StateFlow> = cacheProvider.installedStickerSets
override val customEmojiStickerSets: StateFlow> = cacheProvider.customEmojiStickerSets
diff --git a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt
index 9544d3f1..f01f7041 100644
--- a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt
@@ -1,49 +1,33 @@
package org.monogram.data.repository
import androidx.media3.datasource.DataSource
-import org.monogram.core.ScopeProvider
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import org.monogram.data.datasource.FileDataSource
-import kotlinx.coroutines.launch
import org.monogram.data.datasource.TelegramStreamingDataSource
-import org.monogram.data.gateway.UpdateDispatcher
+import org.monogram.data.infra.FileObserverHub
import org.monogram.domain.repository.PlayerDataSourceFactory
import org.monogram.domain.repository.StreamingRepository
class StreamingRepositoryImpl(
private val fileDataSource: FileDataSource,
- private val updates: UpdateDispatcher,
- scopeProvider: ScopeProvider
+ private val fileObserverHub: FileObserverHub
) : StreamingRepository, PlayerDataSourceFactory {
- private val scope = scopeProvider.appScope
-
- private val _fileProgressFlow = MutableSharedFlow>(
- replay = 1,
- extraBufferCapacity = 100,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
-
- init {
- scope.launch {
- updates.file.collect { update ->
- val file = update.file
- if (file.size > 0) {
- val progress = file.local.downloadedSize.toFloat() / file.size.toFloat()
- _fileProgressFlow.emit(file.id to progress)
- }
- }
- }
- }
-
override fun createPayload(fileId: Int): DataSource.Factory {
return TelegramStreamingDataSource.Factory(fileDataSource, fileId)
}
override fun getDownloadProgress(fileId: Int): Flow {
- return _fileProgressFlow
- .filter { it.first == fileId }
- .map { it.second }
+ val cachedProgress = fileObserverHub.getCachedFile(fileId)?.let { file ->
+ if (file.size > 0) file.local.downloadedSize.toFloat() / file.size.toFloat() else 0f
+ } ?: 0f
+
+ return fileObserverHub.observeFile(fileId)
+ .map { it.downloadProgress.coerceIn(0f, 1f) }
+ .onStart { emit(cachedProgress) }
+ .distinctUntilChanged()
}
}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt
index 4efbb88a..4d9c4e8a 100644
--- a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt
@@ -1,6 +1,5 @@
package org.monogram.data.repository
-import org.monogram.data.core.coRunCatching
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -8,11 +7,12 @@ import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
import androidx.core.content.FileProvider
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-import org.monogram.core.ScopeProvider
+import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.remote.UpdateRemoteDateSource
import org.monogram.data.infra.FileDownloadQueue
import org.monogram.data.infra.FileUpdateHandler
@@ -31,11 +31,9 @@ class UpdateRepositoryImpl(
private val fileQueue: FileDownloadQueue,
private val fileUpdateHandler: FileUpdateHandler,
private val authRepository: AuthRepository,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : UpdateRepository {
- private val scope = scopeProvider.appScope
-
private val _updateState = MutableStateFlow(UpdateState.Idle)
override val updateState: StateFlow = _updateState.asStateFlow()
diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt
index eb1a3771..b76758a5 100644
--- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt
@@ -1,90 +1,196 @@
package org.monogram.data.repository
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.Json
+import org.drinkless.tdlib.TdApi
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.datasource.remote.SettingsRemoteDataSource
import org.monogram.data.db.dao.WallpaperDao
-import org.monogram.data.db.model.WallpaperEntity
-import org.monogram.data.gateway.UpdateDispatcher
+import org.monogram.data.infra.FileObserverHub
import org.monogram.data.mapper.mapBackgrounds
+import org.monogram.data.mapper.toBackgroundType
+import org.monogram.data.mapper.toDomain
+import org.monogram.data.mapper.toEntity
+import org.monogram.data.mapper.toInputBackground
import org.monogram.domain.models.WallpaperModel
+import org.monogram.domain.models.WallpaperType
import org.monogram.domain.repository.WallpaperRepository
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
class WallpaperRepositoryImpl(
private val remote: SettingsRemoteDataSource,
- private val updates: UpdateDispatcher,
private val wallpaperDao: WallpaperDao,
+ private val fileObserverHub: FileObserverHub,
private val dispatchers: DispatcherProvider,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : WallpaperRepository {
- private val scope = scopeProvider.appScope
-
- private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1)
private val wallpapers = MutableStateFlow>(emptyList())
+ private val thumbnailFileToWallpaperId = ConcurrentHashMap()
+ private val documentFileToWallpaperId = ConcurrentHashMap()
+ private val refreshMutex = Mutex()
+ private val initialLoadRequested = AtomicBoolean(false)
init {
scope.launch {
- updates.file.collect {
- wallpaperUpdates.emit(Unit)
+ wallpaperDao.observeWallpapers().collect { entities ->
+ val models = entities.map { it.toDomain() }
+ wallpapers.value = models
+ rebuildTrackedFiles(models)
}
}
scope.launch {
- wallpaperDao.getWallpapers().collect { entities ->
- val models = entities.mapNotNull {
- try {
- Json.decodeFromString(it.data)
- } catch (_: Exception) {
- null
- }
- }
- if (models.isNotEmpty()) {
- wallpapers.value = models
- }
+ fileObserverHub.fileStates.collect { fileState ->
+ if (!fileState.isDownloaded || fileState.path.isNullOrBlank()) return@collect
+ val wallpaperId = documentFileToWallpaperId[fileState.fileId]
+ ?: thumbnailFileToWallpaperId[fileState.fileId]
+ ?: return@collect
+ applyFileUpdate(wallpaperId, fileState.fileId, fileState.path)
}
}
}
- override fun getWallpapers() = callbackFlow {
- suspend fun fetch() {
+ override fun getWallpapers(): Flow> = wallpapers.onStart {
+ ensureInitialLoad()
+ }
+
+ override suspend fun downloadWallpaper(fileId: Int) {
+ remote.downloadFile(fileId, 1)
+ }
+
+ override suspend fun setDefaultWallpaper(
+ wallpaper: WallpaperModel,
+ isBlurred: Boolean,
+ isMoving: Boolean
+ ): WallpaperModel? {
+ val background = wallpaper.toInputBackground()
+ val type = wallpaper.toBackgroundType(isBlurred = isBlurred, isMoving = isMoving)
+ val result = remote.setDefaultBackground(
+ background = background,
+ type = type,
+ forDarkTheme = false
+ ) ?: return null
+
+ refreshFromRemote()
+ return result.toDomain()
+ }
+
+ override suspend fun uploadWallpaper(
+ filePath: String,
+ isBlurred: Boolean,
+ isMoving: Boolean
+ ): WallpaperModel? {
+ val result = remote.setDefaultBackground(
+ background = TdApi.InputBackgroundLocal(TdApi.InputFileLocal(filePath)),
+ type = TdApi.BackgroundTypeWallpaper(isBlurred, isMoving),
+ forDarkTheme = false
+ ) ?: return null
+
+ refreshFromRemote()
+ return result.toDomain()
+ }
+
+ private fun ensureInitialLoad() {
+ if (initialLoadRequested.compareAndSet(false, true)) {
+ scope.launch {
+ refreshFromRemote()
+ }
+ }
+ }
+
+ private suspend fun refreshFromRemote() {
+ refreshMutex.withLock {
val result = remote.getInstalledBackgrounds(false)
val mappedWallpapers = mapBackgrounds(result?.backgrounds ?: emptyArray())
wallpapers.value = mappedWallpapers
+ rebuildTrackedFiles(mappedWallpapers)
saveWallpapersToDb(mappedWallpapers)
- trySend(mappedWallpapers)
}
+ }
- val wallpaperJob = wallpaperUpdates
- .onEach { fetch() }
- .launchIn(this)
+ private fun rebuildTrackedFiles(models: List) {
+ thumbnailFileToWallpaperId.clear()
+ documentFileToWallpaperId.clear()
- if (wallpapers.value.isNotEmpty()) {
- trySend(wallpapers.value)
- } else {
- fetch()
+ models.forEach { wallpaper ->
+ wallpaper.thumbnail?.fileId?.takeIf { it != 0 }?.let { fileId ->
+ thumbnailFileToWallpaperId[fileId] = wallpaper.id
+ }
+ if (wallpaper.type == WallpaperType.WALLPAPER && wallpaper.documentId != 0L) {
+ wallpaper.documentId.toInt().takeIf { it != 0 }?.let { fileId ->
+ documentFileToWallpaperId[fileId] = wallpaper.id
+ }
+ }
}
-
- awaitClose { wallpaperJob.cancel() }
}
- override suspend fun downloadWallpaper(fileId: Int) {
- remote.downloadFile(fileId, 1)
+ private suspend fun applyFileUpdate(wallpaperId: Long, fileId: Int, path: String) {
+ val current = wallpapers.value
+ if (current.isEmpty()) return
+
+ var changed = false
+ val updated = current.map { wallpaper ->
+ if (wallpaper.id != wallpaperId) return@map wallpaper
+
+ val next = when {
+ wallpaper.thumbnail?.fileId == fileId -> {
+ val thumbnail = wallpaper.thumbnail ?: return@map wallpaper
+ val currentThumbPath = thumbnail.localPath
+ if (currentThumbPath == path) {
+ wallpaper
+ } else {
+ wallpaper.copy(
+ thumbnail = thumbnail.copy(localPath = path)
+ )
+ }
+ }
+
+ wallpaper.documentId == fileId.toLong() -> {
+ if (wallpaper.localPath == path && wallpaper.isDownloaded) {
+ wallpaper
+ } else {
+ wallpaper.copy(
+ localPath = path,
+ isDownloaded = true
+ )
+ }
+ }
+
+ else -> wallpaper
+ }
+
+ if (next != wallpaper) {
+ changed = true
+ }
+ next
+ }
+
+ if (!changed) return
+
+ wallpapers.value = updated
+ withContext(dispatchers.io) {
+ wallpaperDao.upsertWallpapers(updated.filter { it.id == wallpaperId }
+ .map { it.toEntity() })
+ }
}
private suspend fun saveWallpapersToDb(wallpapers: List) {
withContext(dispatchers.io) {
- wallpaperDao.clearAll()
- wallpaperDao.insertWallpapers(
- wallpapers.map {
- WallpaperEntity(it.id, Json.encodeToString(it))
- }
- )
+ val entities = wallpapers.map { it.toEntity() }
+ wallpaperDao.upsertWallpapers(entities)
+ if (entities.isEmpty()) {
+ wallpaperDao.clearAll()
+ } else {
+ wallpaperDao.deleteNotIn(entities.map { it.id })
+ }
}
}
}
\ No newline at end of file
diff --git a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt
index a1ef60f3..1d8808f5 100644
--- a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt
+++ b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt
@@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi
import org.monogram.data.core.coRunCatching
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.infra.FileDownloadQueue
+import org.monogram.data.mapper.isValidFilePath
import java.util.concurrent.ConcurrentHashMap
internal class UserMediaResolver(
@@ -25,7 +26,7 @@ internal class UserMediaResolver(
val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId)))
if (result is TdApi.Stickers && result.stickers.isNotEmpty()) {
val file = result.stickers.first().sticker
- if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) {
+ if (file.local.isDownloadingCompleted && isValidFilePath(file.local.path)) {
emojiPathCache[emojiId] = file.local.path
file.local.path
} else {
@@ -37,7 +38,7 @@ internal class UserMediaResolver(
(gateway.execute(TdApi.GetFile(file.id)) as? TdApi.File)
?.local
?.path
- ?.takeIf { it.isNotEmpty() }
+ ?.takeIf { isValidFilePath(it) }
}.getOrNull()
if (refreshedPath != null) {
emojiPathCache[emojiId] = refreshedPath
@@ -55,10 +56,10 @@ internal class UserMediaResolver(
suspend fun resolveAvatarPath(user: TdApi.User): String? {
val bigPhoto = user.profilePhoto?.big
val smallPhoto = user.profilePhoto?.small
- val bigDirectPath = bigPhoto?.local?.path?.ifEmpty { null }
+ val bigDirectPath = bigPhoto?.local?.path?.takeIf { isValidFilePath(it) }
if (bigDirectPath != null) return bigDirectPath
- val smallDirectPath = smallPhoto?.local?.path?.ifEmpty { null }
+ val smallDirectPath = smallPhoto?.local?.path?.takeIf { isValidFilePath(it) }
if (smallDirectPath != null) {
val bigId = bigPhoto?.id?.takeIf { it != 0 }
if (bigId != null && bigId != smallPhoto.id) {
@@ -121,7 +122,11 @@ internal class UserMediaResolver(
private suspend fun resolveDownloadedFilePath(fileId: Int?): String? {
if (fileId == null || fileId == 0) return null
val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null
- return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null
+ return if (file.local.isDownloadingCompleted) {
+ file.local.path.takeIf { isValidFilePath(it) }
+ } else {
+ null
+ }
}
companion object {
diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt
index c0828556..42912698 100644
--- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt
@@ -1,13 +1,20 @@
package org.monogram.data.repository.user
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.drinkless.tdlib.TdApi
-import org.monogram.core.ScopeProvider
import org.monogram.data.chats.ChatCache
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.cache.ChatLocalDataSource
@@ -18,7 +25,12 @@ import org.monogram.data.db.model.KeyValueEntity
import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.infra.FileDownloadQueue
-import org.monogram.data.mapper.user.*
+import org.monogram.data.infra.FileObserverHub
+import org.monogram.data.mapper.user.extractPersonalAvatarPath
+import org.monogram.data.mapper.user.mapUserFullInfoToChat
+import org.monogram.data.mapper.user.toDomain
+import org.monogram.data.mapper.user.toEntity
+import org.monogram.data.mapper.user.toTdApi
import org.monogram.domain.models.ChatFullInfoModel
import org.monogram.domain.models.UserModel
import org.monogram.domain.repository.CacheProvider
@@ -33,12 +45,11 @@ class UserRepositoryImpl(
gateway: TelegramGateway,
private val updates: UpdateDispatcher,
fileQueue: FileDownloadQueue,
+ private val fileObserverHub: FileObserverHub,
private val keyValueDao: KeyValueDao,
private val cacheProvider: CacheProvider,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) : UserRepository {
-
- private val scope = scopeProvider.appScope
private val mediaResolver = UserMediaResolver(gateway = gateway, fileQueue = fileQueue)
private var currentUserId: Long = 0L
private val userRequests = ConcurrentHashMap>()
@@ -63,6 +74,7 @@ class UserRepositoryImpl(
UserUpdateSynchronizer(
scope = scope,
updates = updates,
+ fileObserverHub = fileObserverHub,
userLocal = userLocal,
keyValueDao = keyValueDao,
emojiPathCache = mediaResolver.emojiPathCache,
@@ -132,6 +144,7 @@ class UserRepositoryImpl(
}
return try {
deferred.await()?.let { user ->
+ handleUserIdUpdated(user.id)
mapUserModel(user, userLocal.getUserFullInfo(userId))
}
} finally {
@@ -170,6 +183,9 @@ class UserRepositoryImpl(
}
return try {
val fullInfo = deferred.await()
+ if (fullInfo != null) {
+ handleUserIdUpdated(userId)
+ }
mapUserModel(user, fullInfo)
} finally {
fullInfoRequests.remove(userId)
diff --git a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt
index a92cda70..d664b273 100644
--- a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt
+++ b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt
@@ -6,11 +6,13 @@ import org.drinkless.tdlib.TdApi
import org.monogram.data.datasource.cache.UserLocalDataSource
import org.monogram.data.db.dao.KeyValueDao
import org.monogram.data.gateway.UpdateDispatcher
+import org.monogram.data.infra.FileObserverHub
import java.util.concurrent.ConcurrentHashMap
internal class UserUpdateSynchronizer(
private val scope: CoroutineScope,
private val updates: UpdateDispatcher,
+ private val fileObserverHub: FileObserverHub,
private val userLocal: UserLocalDataSource,
private val keyValueDao: KeyValueDao,
private val emojiPathCache: ConcurrentHashMap,
@@ -19,9 +21,19 @@ internal class UserUpdateSynchronizer(
private val onUserIdChanged: suspend (Long) -> Unit,
private val onCachedSimCountryIsoChanged: suspend (String?) -> Unit
) {
+ private val avatarFileIdToUserIds = ConcurrentHashMap>()
+ private val userIdToAvatarFileIds = ConcurrentHashMap>()
+
fun start() {
+ scope.launch {
+ userLocal.getAllUsers().forEach { user ->
+ updateAvatarIndex(user)
+ }
+ }
+
scope.launch {
updates.user.collect { update ->
+ updateAvatarIndex(update.user)
onUserUpdated(update.user)
}
}
@@ -30,35 +42,31 @@ internal class UserUpdateSynchronizer(
updates.userStatus.collect { update ->
userLocal.getUser(update.userId)?.let { cached ->
cached.status = update.status
+ updateAvatarIndex(cached)
onUserUpdated(cached)
}
}
}
scope.launch {
- updates.file.collect { update ->
- val file = update.file
- if (!file.local.isDownloadingCompleted) return@collect
+ fileObserverHub.fileStates.collect { state ->
+ if (!state.isDownloaded) return@collect
+ val fileId = state.fileId
+ val path = state.path ?: return@collect
- userLocal.getAllUsers().forEach { user ->
- val small = user.profilePhoto?.small
- val big = user.profilePhoto?.big
- if (small?.id == file.id || big?.id == file.id) {
- onUserIdChanged(user.id)
- }
+ avatarFileIdToUserIds[fileId]?.forEach { userId ->
+ onUserIdChanged(userId)
}
- if (file.local.path.isNotEmpty()) {
- val userId = fileIdToUserIdMap.remove(file.id)
- if (userId != null) {
- userLocal.getUser(userId)?.let { user ->
- val emojiId = user.extractEmojiStatusId()
- if (emojiId != 0L) {
- emojiPathCache[emojiId] = file.local.path
- }
+ val userId = fileIdToUserIdMap.remove(fileId)
+ if (userId != null) {
+ userLocal.getUser(userId)?.let { user ->
+ val emojiId = user.extractEmojiStatusId()
+ if (emojiId != 0L) {
+ emojiPathCache[emojiId] = path
}
- onUserIdChanged(userId)
}
+ onUserIdChanged(userId)
}
}
}
@@ -73,4 +81,26 @@ internal class UserUpdateSynchronizer(
companion object {
private const val KEY_CACHED_SIM_COUNTRY_ISO = "cached_sim_country_iso"
}
-}
\ No newline at end of file
+
+ private fun updateAvatarIndex(user: TdApi.User) {
+ val newFileIds = buildSet {
+ user.profilePhoto?.small?.id?.takeIf { it != 0 }?.let(::add)
+ user.profilePhoto?.big?.id?.takeIf { it != 0 }?.let(::add)
+ }
+
+ val previousFileIds = userIdToAvatarFileIds.put(user.id, newFileIds) ?: emptySet()
+
+ (previousFileIds - newFileIds).forEach { fileId ->
+ avatarFileIdToUserIds[fileId]?.let { userIds ->
+ userIds.remove(user.id)
+ if (userIds.isEmpty()) {
+ avatarFileIdToUserIds.remove(fileId)
+ }
+ }
+ }
+
+ (newFileIds - previousFileIds).forEach { fileId ->
+ avatarFileIdToUserIds.getOrPut(fileId) { ConcurrentHashMap.newKeySet() }.add(user.id)
+ }
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt
index 2128a522..adc8e7f1 100644
--- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt
+++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt
@@ -10,11 +10,13 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.monogram.data.di.TdNotificationManager
import org.monogram.data.gateway.TelegramGateway
+import org.monogram.domain.repository.StringProvider
class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent {
private val gateway: TelegramGateway by inject()
private val notificationManager: TdNotificationManager by inject()
+ private val stringProvider: StringProvider by inject()
override fun onReceive(context: Context, intent: Intent) {
val chatId = intent.getLongExtra("chat_id", 0L)
@@ -63,7 +65,7 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent {
chatId = chatId,
messageId = System.currentTimeMillis(),
chatType = chat.type,
- senderName = "Вы",
+ senderName = stringProvider.getString("notification_person_me"),
senderBitmap = null,
chatIcon = null,
text = replyText,
diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
index 1fd15fd8..ada8254f 100644
--- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
+++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt
@@ -42,17 +42,23 @@ class TdNotificationService : Service() {
return START_NOT_STICKY
}
- if (appPreferences.pushProvider.value == PushProvider.FCM) {
- stopForegroundService()
- return START_NOT_STICKY
- }
-
if (!isServiceRunning) {
isServiceRunning = true
- acquireWakeLock()
+ // Call startForeground as soon as possible to satisfy
+ // startForegroundService() timing requirements on Android 8+.
startForegroundNotification()
+
+ if (appPreferences.pushProvider.value == PushProvider.FCM) {
+ stopForegroundService()
+ return START_NOT_STICKY
+ }
+
+ acquireWakeLock()
startListeningUpdates()
startPeriodicCheck()
+ } else if (appPreferences.pushProvider.value == PushProvider.FCM) {
+ stopForegroundService()
+ return START_NOT_STICKY
}
return START_STICKY
}
diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt
index 5fa219f3..0a32176b 100644
--- a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt
+++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt
@@ -1,16 +1,17 @@
package org.monogram.data.stickers
import android.util.Log
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.monogram.core.DispatcherProvider
-import org.monogram.core.ScopeProvider
import org.monogram.data.core.coRunCatching
import org.monogram.data.datasource.cache.StickerLocalDataSource
import org.monogram.data.infra.FileDownloadQueue
import org.monogram.data.infra.FileUpdateHandler
+import org.monogram.data.mapper.isValidFilePath
import org.monogram.domain.models.StickerModel
import org.monogram.domain.models.StickerSetModel
import java.io.File
@@ -23,10 +24,8 @@ class StickerFileManager(
private val fileQueue: FileDownloadQueue,
private val fileUpdateHandler: FileUpdateHandler,
private val dispatchers: DispatcherProvider,
- scopeProvider: ScopeProvider
+ private val scope: CoroutineScope
) {
- private val scope = scopeProvider.appScope
-
private val tgsCache = mutableMapOf()
private val filePathsCache = ConcurrentHashMap()
@@ -41,7 +40,7 @@ class StickerFileManager(
val firstPath = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) {
fileUpdateHandler.fileDownloadCompleted
.filter { it.first == fileId }
- .mapNotNull { (_, path) -> path.takeIf(::isPathValid) }
+ .mapNotNull { (_, path) -> path.takeIf(::isValidFilePath) }
.first()
}
@@ -127,7 +126,7 @@ class StickerFileManager(
private suspend fun resolveAvailablePath(fileId: Long): String? {
filePathsCache[fileId]?.let { path ->
- if (isPathValid(path)) {
+ if (isValidFilePath(path)) {
return path
}
filePathsCache.remove(fileId)
@@ -136,7 +135,7 @@ class StickerFileManager(
val dbPath = localDataSource.getPath(fileId)
if (!dbPath.isNullOrEmpty()) {
- if (isPathValid(dbPath)) {
+ if (isValidFilePath(dbPath)) {
filePathsCache[fileId] = dbPath
return dbPath
}
@@ -145,7 +144,7 @@ class StickerFileManager(
val completedPath = fileUpdateHandler.fileDownloadCompleted
.replayCache
- .firstOrNull { it.first == fileId && isPathValid(it.second) }
+ .firstOrNull { it.first == fileId && isValidFilePath(it.second) }
?.second
if (!completedPath.isNullOrEmpty()) {
@@ -161,10 +160,6 @@ class StickerFileManager(
fileQueue.enqueue(fileId.toInt(), priority, FileDownloadQueue.DownloadType.STICKER)
}
- private fun isPathValid(path: String): Boolean {
- return path.isNotEmpty() && File(path).exists()
- }
-
companion object {
private const val TAG = "StickerFileManager"
private const val DOWNLOAD_TIMEOUT_MS = 90_000L
diff --git a/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt
new file mode 100644
index 00000000..fd3fcec8
--- /dev/null
+++ b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt
@@ -0,0 +1,61 @@
+package org.monogram.data.mapper
+
+import org.drinkless.tdlib.TdApi
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.monogram.core.date.Fake12HourDateFormatManagerImpl
+import org.monogram.core.date.Fake24HourDateFormatManagerImpl
+import org.monogram.domain.repository.StringProvider
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class ChatMapperTest {
+
+ @Test
+ fun `formatMessageInfo uses 12 hour time format`() {
+ val mapper = ChatMapper(FakeStringProvider(), Fake12HourDateFormatManagerImpl())
+ val timestampSeconds = 1710948000
+ val message = createTextMessage(timestampSeconds)
+
+ val (_, _, time) = mapper.formatMessageInfo(message, null) { null }
+
+ val expected =
+ SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestampSeconds * 1000L))
+ assertEquals(expected, time)
+ }
+
+ @Test
+ fun `formatMessageInfo uses 24 hour time format`() {
+ val mapper = ChatMapper(FakeStringProvider(), Fake24HourDateFormatManagerImpl())
+ val timestampSeconds = 1710948000
+ val message = createTextMessage(timestampSeconds)
+
+ val (_, _, time) = mapper.formatMessageInfo(message, null) { null }
+
+ val expected =
+ SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestampSeconds * 1000L))
+ assertEquals(expected, time)
+ }
+
+ private fun createTextMessage(timestampSeconds: Int): TdApi.Message {
+ return TdApi.Message().apply {
+ date = timestampSeconds
+ content = TdApi.MessageText().apply {
+ text = TdApi.FormattedText("test", emptyArray())
+ }
+ }
+ }
+
+ private class FakeStringProvider : StringProvider {
+ override fun getString(resName: String): String = resName
+
+ override fun getString(resName: String, vararg formatArgs: Any): String = resName
+
+ override fun getQuantityString(
+ resName: String,
+ quantity: Int,
+ vararg formatArgs: Any
+ ): String = resName
+ }
+}
diff --git a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt
index a513fb19..bf4a7f72 100644
--- a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt
@@ -13,7 +13,30 @@ sealed interface BotMenuButtonModel {
data class BotInfoModel(
val commands: List,
- val menuButton: BotMenuButtonModel
+ val menuButton: BotMenuButtonModel,
+ val shortDescription: String? = null,
+ val description: String? = null,
+ val photoFileId: Int = 0,
+ val photoPath: String? = null,
+ val animationFileId: Int = 0,
+ val animationPath: String? = null,
+ val managerBotUserId: Long = 0L,
+ val privacyPolicyUrl: String? = null,
+ val defaultGroupAdministratorRights: ChatAdministratorRightsModel? = null,
+ val defaultChannelAdministratorRights: ChatAdministratorRightsModel? = null,
+ val affiliateProgram: AffiliateProgramInfoModel? = null,
+ val webAppBackgroundLightColor: Int = -1,
+ val webAppBackgroundDarkColor: Int = -1,
+ val webAppHeaderLightColor: Int = -1,
+ val webAppHeaderDarkColor: Int = -1,
+ val verificationParameters: BotVerificationParametersModel? = null,
+ val canGetRevenueStatistics: Boolean = false,
+ val canManageEmojiStatus: Boolean = false,
+ val hasMediaPreviews: Boolean = false,
+ val editCommandsLinkType: String? = null,
+ val editDescriptionLinkType: String? = null,
+ val editDescriptionMediaLinkType: String? = null,
+ val editSettingsLinkType: String? = null
)
data class InlineQueryResultModel(
diff --git a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt
index f863f9a4..18d502ba 100644
--- a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt
@@ -13,16 +13,41 @@ data class ChatFullInfoModel(
val giftCount: Int = 0,
val isBlocked: Boolean = false,
val botInfo: String? = null,
+ val botInfoModel: BotInfoModel? = null,
+ val blockListType: String? = null,
val slowModeDelay: Int = 0,
+ val slowModeDelayExpiresIn: Double = 0.0,
val locationAddress: String? = null,
+ val directMessagesChatId: Long = 0L,
val canSetStickerSet: Boolean = false,
val canSetLocation: Boolean = false,
val canGetMembers: Boolean = false,
val canGetStatistics: Boolean = false,
val canGetRevenueStatistics: Boolean = false,
+ val canGetStarRevenueStatistics: Boolean = false,
+ val canEnablePaidMessages: Boolean = false,
+ val canEnablePaidReaction: Boolean = false,
+ val hasHiddenMembers: Boolean = false,
+ val canHideMembers: Boolean = false,
+ val canToggleAggressiveAntiSpam: Boolean = false,
+ val isAllHistoryAvailable: Boolean = false,
+ val canHaveSponsoredMessages: Boolean = false,
+ val hasAggressiveAntiSpamEnabled: Boolean = false,
+ val hasPaidMediaAllowed: Boolean = false,
+ val hasPinnedStories: Boolean = false,
val linkedChatId: Long = 0L,
val businessInfo: BusinessInfoModel? = null,
+ val publicPhotoPath: String? = null,
val note: String? = null,
+ val usesUnofficialApp: Boolean = false,
+ val hasSponsoredMessagesEnabled: Boolean = false,
+ val needPhoneNumberPrivacyException: Boolean = false,
+ val botVerification: BotVerificationModel? = null,
+ val mainProfileTab: ProfileTabType? = null,
+ val firstProfileAudio: ProfileAudioModel? = null,
+ val rating: UserRatingModel? = null,
+ val pendingRating: UserRatingModel? = null,
+ val pendingRatingDate: Int = 0,
val canBeCalled: Boolean = false,
val supportsVideoCalls: Boolean = false,
val hasPrivateCalls: Boolean = false,
@@ -30,6 +55,13 @@ data class ChatFullInfoModel(
val hasRestrictedVoiceAndVideoNoteMessages: Boolean = false,
val hasPostedToProfileStories: Boolean = false,
val setChatBackground: Boolean = false,
+ val myBoostCount: Int = 0,
+ val unrestrictBoostCount: Int = 0,
+ val stickerSetId: Long = 0L,
+ val customEmojiStickerSetId: Long = 0L,
+ val botCommands: List = emptyList(),
+ val upgradedFromBasicGroupId: Long = 0L,
+ val upgradedFromMaxMessageId: Long = 0L,
val incomingPaidMessageStarCount: Long = 0L,
val outgoingPaidMessageStarCount: Long = 0L,
)
\ No newline at end of file
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 63dcc4a9..b03eec09 100644
--- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt
@@ -63,6 +63,17 @@ data class ChatModel(
val isBot: Boolean = false,
val isMember: Boolean = true,
val isArchived: Boolean = false,
+ val isScam: Boolean = false,
+ val isFake: Boolean = false,
+ val botVerificationIconCustomEmojiId: Long = 0L,
+ val restrictionReason: String? = null,
+ val hasSensitiveContent: Boolean = false,
+ val activeStoryStateType: String? = null,
+ val activeStoryId: Int = 0,
+ val boostLevel: Int = 0,
+ val hasForumTabs: Boolean = false,
+ val isAdministeredDirectMessagesGroup: Boolean = false,
+ val paidMessageStarCount: Long = 0L,
)
@Serializable
diff --git a/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt
new file mode 100644
index 00000000..573a78d0
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt
@@ -0,0 +1,10 @@
+package org.monogram.domain.models
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ChatViewportCacheEntry(
+ val anchorMessageId: Long? = null,
+ val anchorOffsetPx: Int = 0,
+ val atBottom: Boolean = true
+)
diff --git a/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt
new file mode 100644
index 00000000..ef4652fd
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt
@@ -0,0 +1,110 @@
+package org.monogram.domain.models
+
+data class RestrictionInfoModel(
+ val restrictionReason: String? = null,
+ val hasSensitiveContent: Boolean = false
+)
+
+enum class ActiveStoryStateType {
+ LIVE,
+ UNREAD,
+ READ,
+ UNKNOWN
+}
+
+data class ActiveStoryStateModel(
+ val type: ActiveStoryStateType = ActiveStoryStateType.UNKNOWN,
+ val storyId: Int = 0
+)
+
+data class BotVerificationModel(
+ val botUserId: Long = 0L,
+ val iconCustomEmojiId: Long = 0L,
+ val customDescription: String? = null
+)
+
+data class BotVerificationParametersModel(
+ val iconCustomEmojiId: Long = 0L,
+ val organizationName: String? = null,
+ val defaultCustomDescription: String? = null,
+ val canSetCustomDescription: Boolean = false
+)
+
+enum class ProfileTabType {
+ POSTS,
+ GIFTS,
+ MEDIA,
+ FILES,
+ LINKS,
+ MUSIC,
+ VOICE,
+ GIFS,
+ UNKNOWN
+}
+
+data class ProfileAudioModel(
+ val duration: Int = 0,
+ val title: String? = null,
+ val performer: String? = null,
+ val fileName: String? = null,
+ val mimeType: String? = null,
+ val fileId: Int = 0,
+ val filePath: String? = null
+)
+
+data class UserRatingModel(
+ val level: Int = 0,
+ val isMaximumLevelReached: Boolean = false,
+ val rating: Long = 0L,
+ val currentLevelRating: Long = 0L,
+ val nextLevelRating: Long = 0L
+)
+
+data class ChatAdministratorRightsModel(
+ val canManageChat: Boolean = false,
+ val canChangeInfo: Boolean = false,
+ val canPostMessages: Boolean = false,
+ val canEditMessages: Boolean = false,
+ val canDeleteMessages: Boolean = false,
+ val canInviteUsers: Boolean = false,
+ val canRestrictMembers: Boolean = false,
+ val canPinMessages: Boolean = false,
+ val canManageTopics: Boolean = false,
+ val canPromoteMembers: Boolean = false,
+ val canManageVideoChats: Boolean = false,
+ val canPostStories: Boolean = false,
+ val canEditStories: Boolean = false,
+ val canDeleteStories: Boolean = false,
+ val canManageDirectMessages: Boolean = false,
+ val canManageTags: Boolean = false,
+ val isAnonymous: Boolean = false
+)
+
+data class AffiliateProgramInfoModel(
+ val commissionPerMille: Int = 0,
+ val monthCount: Int = 0,
+ val endDate: Int = 0,
+ val dailyRevenuePerUserStarCount: Long = 0L,
+ val dailyRevenuePerUserNanostarCount: Int = 0
+)
+
+data class UserTypeBotInfoModel(
+ val canBeEdited: Boolean = false,
+ val canJoinGroups: Boolean = false,
+ val canReadAllGroupMessages: Boolean = false,
+ val hasMainWebApp: Boolean = false,
+ val hasTopics: Boolean = false,
+ val allowsUsersToCreateTopics: Boolean = false,
+ val canManageBots: Boolean = false,
+ val isInline: Boolean = false,
+ val inlineQueryPlaceholder: String? = null,
+ val needLocation: Boolean = false,
+ val canConnectToBusiness: Boolean = false,
+ val canBeAddedToAttachmentMenu: Boolean = false,
+ val activeUserCount: Int = 0
+)
+
+data class SupergroupBotCommandsModel(
+ val botUserId: Long = 0L,
+ val commands: List = emptyList()
+)
diff --git a/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt
new file mode 100644
index 00000000..effcdc27
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt
@@ -0,0 +1,59 @@
+package org.monogram.domain.models
+
+sealed interface FileDownloadEvent {
+ val fileId: Int
+
+ data class Progress(
+ override val fileId: Int,
+ val progress: Float
+ ) : FileDownloadEvent
+
+ data class Completed(
+ override val fileId: Int,
+ val path: String
+ ) : FileDownloadEvent
+}
+
+sealed interface MessageDownloadEvent {
+ val chatId: Long
+ val messageId: Long
+ val fileId: Int
+
+ data class Progress(
+ override val chatId: Long,
+ override val messageId: Long,
+ override val fileId: Int,
+ val progress: Float
+ ) : MessageDownloadEvent
+
+ data class Completed(
+ override val chatId: Long,
+ override val messageId: Long,
+ override val fileId: Int,
+ val path: String
+ ) : MessageDownloadEvent
+
+ data class Cancelled(
+ override val chatId: Long,
+ override val messageId: Long,
+ override val fileId: Int
+ ) : MessageDownloadEvent
+}
+
+data class MessageUploadProgressEvent(
+ val chatId: Long,
+ val messageId: Long,
+ val fileId: Int,
+ val progress: Float
+)
+
+data class MessageDeletedEvent(
+ val chatId: Long,
+ val messageIds: List
+)
+
+data class MessageIdUpdatedEvent(
+ val chatId: Long,
+ val oldMessageId: Long,
+ val message: MessageModel
+)
diff --git a/domain/src/main/java/org/monogram/domain/models/UserModel.kt b/domain/src/main/java/org/monogram/domain/models/UserModel.kt
index 019c302b..f312e9ea 100644
--- a/domain/src/main/java/org/monogram/domain/models/UserModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/UserModel.kt
@@ -14,6 +14,9 @@ data class UserModel(
val lastSeen: Long = 0L,
val isPremium: Boolean = false,
val isVerified: Boolean = false,
+ val isScam: Boolean = false,
+ val isFake: Boolean = false,
+ val botVerificationIconCustomEmojiId: Long = 0L,
val isSponsor: Boolean = false,
val isSupport: Boolean = false,
val userStatus: UserStatusType = UserStatusType.OFFLINE,
@@ -23,8 +26,16 @@ data class UserModel(
val isMutualContact: Boolean = false,
val isCloseFriend: Boolean = false,
val type: UserTypeEnum = UserTypeEnum.REGULAR,
+ val botTypeInfo: UserTypeBotInfoModel? = null,
+ val restrictionInfo: RestrictionInfoModel? = null,
+ val activeStoryState: ActiveStoryStateModel? = null,
+ val restrictsNewChats: Boolean = false,
+ val paidMessageStarCount: Long = 0L,
val haveAccess: Boolean = true,
- val languageCode: String? = null
+ val languageCode: String? = null,
+ val backgroundCustomEmojiId: Long = 0L,
+ val profileBackgroundCustomEmojiId: Long = 0L,
+ val addedToAttachmentMenu: Boolean = false
)
enum class UserStatusType {
diff --git a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt
index d7f3c8ec..0b79f7f3 100644
--- a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt
+++ b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt
@@ -7,15 +7,25 @@ data class WallpaperModel(
val id: Long,
val slug: String,
val title: String,
+ val type: WallpaperType = WallpaperType.WALLPAPER,
val pattern: Boolean,
val documentId: Long,
val thumbnail: ThumbnailModel?,
val settings: WallpaperSettings?,
+ val themeName: String? = null,
val isDownloaded: Boolean,
val localPath: String?,
val isDefault: Boolean = false
)
+@Serializable
+enum class WallpaperType {
+ WALLPAPER,
+ PATTERN,
+ FILL,
+ CHAT_THEME
+}
+
@Serializable
data class ThumbnailModel(
val fileId: Int,
@@ -32,5 +42,7 @@ data class WallpaperSettings(
val fourthBackgroundColor: Int?,
val intensity: Int?,
val rotation: Int?,
- val isInverted: Boolean? = null
+ val isInverted: Boolean? = null,
+ val isMoving: Boolean? = null,
+ val isBlurred: Boolean? = null
)
diff --git a/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt b/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt
index a3312a36..ea9a49c2 100644
--- a/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt
+++ b/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt
@@ -8,7 +8,6 @@ sealed class WebAppEvent {
data object RequestTheme : WebAppEvent()
data class SetBackgroundColor(val color: String) : WebAppEvent()
data class SetHeaderColor(val colorKey: String?, val color: String?) : WebAppEvent()
- data class SetHeaderText(val text: String) : WebAppEvent()
data class SetBottomBarColor(val color: String) : WebAppEvent()
data class SetupMainButton(
val isVisible: Boolean,
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 8cea1ab6..5de4c8ce 100644
--- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
@@ -6,6 +6,48 @@ enum class PushProvider {
FCM, GMS_LESS
}
+enum class ProxyNetworkType {
+ WIFI,
+ MOBILE,
+ VPN,
+ OTHER
+}
+
+enum class ProxyNetworkMode {
+ DIRECT,
+ BEST_PROXY,
+ LAST_USED,
+ SPECIFIC_PROXY
+}
+
+enum class ProxySortMode {
+ ACTIVE_FIRST,
+ LOWEST_PING,
+ SERVER_NAME,
+ PROXY_TYPE,
+ STATUS
+}
+
+enum class ProxyUnavailableFallback {
+ BEST_PROXY,
+ DIRECT,
+ KEEP_CURRENT
+}
+
+data class ProxyNetworkRule(
+ val mode: ProxyNetworkMode,
+ val specificProxyId: Int? = null,
+ val lastUsedProxyId: Int? = null
+)
+
+fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode {
+ return if (networkType == ProxyNetworkType.VPN) {
+ ProxyNetworkMode.DIRECT
+ } else {
+ ProxyNetworkMode.BEST_PROXY
+ }
+}
+
interface AppPreferencesProvider {
val autoDownloadMobile: StateFlow
val autoDownloadWifi: StateFlow
@@ -42,9 +84,12 @@ interface AppPreferencesProvider {
val enabledProxyId: StateFlow
val isAutoBestProxyEnabled: StateFlow
- val isTelegaProxyEnabled: StateFlow
- val telegaProxyUrls: StateFlow>
val preferIpv6: StateFlow
+ val proxySortMode: StateFlow
+ val proxyUnavailableFallback: StateFlow
+ val hideOfflineProxies: StateFlow
+ val favoriteProxyId: StateFlow
+ val proxyNetworkRules: StateFlow