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 @@ + + + + + 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> val userProxyBackups: StateFlow> val isBiometricEnabled: StateFlow @@ -88,9 +133,14 @@ interface AppPreferencesProvider { fun setEnabledProxyId(proxyId: Int?) fun setAutoBestProxyEnabled(enabled: Boolean) - fun setTelegaProxyEnabled(enabled: Boolean) - fun setTelegaProxyUrls(urls: Set) fun setPreferIpv6(enabled: Boolean) + fun setProxySortMode(mode: ProxySortMode) + fun setProxyUnavailableFallback(fallback: ProxyUnavailableFallback) + fun setHideOfflineProxies(enabled: Boolean) + fun setFavoriteProxyId(proxyId: Int?) + fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) + fun setSpecificProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) + fun setLastUsedProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) fun setUserProxyBackups(backups: Set) fun setBiometricEnabled(enabled: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt index 0cfed7fa..dc19ecc1 100644 --- a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt @@ -3,6 +3,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.FolderModel import org.monogram.domain.models.GifModel import org.monogram.domain.models.RecentEmojiModel @@ -34,6 +35,9 @@ interface CacheProvider { fun saveChatScrollPosition(chatId: Long, messageId: Long) fun getChatScrollPosition(chatId: Long): Long + fun saveChatViewport(chatId: Long, threadId: Long?, viewport: ChatViewportCacheEntry) + fun getChatViewport(chatId: Long, threadId: Long?): ChatViewportCacheEntry? + fun setSavedGifs(gifs: List) fun setInstalledStickerSets(sets: List) diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt index 2711a13d..6678d0a5 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt @@ -4,7 +4,6 @@ import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel interface ExternalProxyRepository { - suspend fun fetchExternalProxies(): List suspend fun getProxies(): List suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? suspend fun editProxy(proxyId: Int, server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? diff --git a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt index dc00f8a4..4de949c8 100644 --- a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt @@ -1,12 +1,13 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.FileModel +import org.monogram.domain.models.MessageDownloadEvent interface FileRepository { - val messageDownloadProgressFlow: Flow> - val messageDownloadCancelledFlow: Flow - val messageDownloadCompletedFlow: Flow> + val fileDownloadFlow: Flow + val messageDownloadFlow: Flow fun downloadFile( fileId: Int, diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index e4667b98..dae5b713 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -1,7 +1,15 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow -import org.monogram.domain.models.* +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.MessageDeletedEvent +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.InstantViewModel sealed interface ReadUpdate { @@ -40,10 +48,10 @@ interface MessageRepository : val newMessageFlow: Flow val senderUpdateFlow: Flow val messageReadFlow: Flow - val messageUploadProgressFlow: Flow> - val messageDeletedFlow: Flow>> + val messageUploadProgressFlow: Flow + val messageDeletedFlow: Flow val messageEditedFlow: Flow - val messageIdUpdateFlow: Flow> + val messageIdUpdateFlow: Flow val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow suspend fun getHighResFileId(chatId: Long, messageId: Long): Int? diff --git a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt index 415db6f8..9900cf19 100644 --- a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt @@ -6,4 +6,15 @@ import org.monogram.domain.models.WallpaperModel interface WallpaperRepository { fun getWallpapers(): Flow> suspend fun downloadWallpaper(fileId: Int) -} \ No newline at end of file + suspend fun setDefaultWallpaper( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? + + suspend fun uploadWallpaper( + filePath: String, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? +} diff --git a/gradle.properties b/gradle.properties index 40628e35..ff9e0897 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,8 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.ref=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.locks=ALL-UNNAMED +org.gradle.configuration-cache=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -21,4 +22,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=true \ No newline at end of file +android.enableR8.fullMode=true +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31566b6d..76cc6af3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,54 +1,59 @@ [versions] # Plugins -agp = "8.12.0" -kotlin = "2.3.10" +agp = "9.1.0" +kotlin = "2.3.20" google-services = "4.4.4" -ossLicensesPlugin = "0.10.10" +ossLicensesPlugin = "0.11.0" ksp = "2.3.6" -baselineprofile = "1.4.1" +baselineprofile = "1.5.0-alpha05" # AndroidX -androidx-activityCompose = "1.12.4" -androidx-biometric = "1.4.0-alpha05" -androidx-camera = "1.5.3" -androidx-compose-bom = "2026.02.01" -androidx-compose-runtime = "1.10.4" +androidx-activityCompose = "1.13.0" +androidx-biometric = "1.4.0-alpha06" +androidx-camera = "1.6.0" +androidx-compose-bom = "2026.03.01" +androidx-compose-runtime = "1.10.6" +androidx-compose-ui = "1.11.0-rc01" androidx-material3 = "1.4.0" androidx-adaptive = "1.2.0" -androidx-media3 = "1.9.2" +androidx-media3 = "1.10.0" androidx-securityCrypto = "1.1.0" androidx-room = "2.8.4" androidx-uiautomator = "2.3.0" androidx-benchmark-macro-junit4 = "1.4.1" androidx-test-ext-junit = "1.3.0" +androidx-window = "1.5.1" +androidx-core-splashscreen = "1.2.0" # KotlinX kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.10.0" # Architecture & DI -decompose = "3.4.0" +decompose = "3.5.0" mvikotlin = "4.3.0" -koin = "4.1.1" +koin = "4.2.0" # UI & Media coil = "3.4.0" -maplibre = "1.4.1" +maplibre = "1.7.0" # Google & Firebase -firebase-bom = "34.10.0" +firebase-bom = "34.11.0" playServices-location = "21.3.0" playServices-mlkit-barcode = "18.3.1" -playServices-ossLicenses = "17.4.0" +playServices-ossLicenses = "17.5.0" # Others zxing = "3.5.4" junit = "4.13.2" -libphonenumber = "8.13.55" +libphonenumber = "9.0.27" [libraries] # AndroidX Activity androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activityCompose" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } # AndroidX Biometric androidx-biometric = { module = "androidx.biometric:biometric-compose", version.ref = "androidx-biometric" } @@ -67,13 +72,15 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended-android" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-material3" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidx-adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "androidx-adaptive" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "androidx-adaptive" } androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } androidx-compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose-runtime" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-runtime" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose-ui" } # AndroidX Media3 androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" } @@ -148,12 +155,15 @@ androidx-compose = [ "androidx-compose-ui", "androidx-compose-material3", "androidx-compose-material3-adaptive", + "androidx-compose-material3-adaptive-layout", + "androidx-compose-material3-adaptive-navigation", "androidx-compose-material3-adaptive-navigation-suite", "androidx-compose-material3-windowsizeclass", "androidx-compose-foundation-layout", "androidx-compose-ui-graphics", "androidx-compose-runtime", - "androidx-compose-material-icons-extended" + "androidx-compose-material-icons-extended", + "androidx-window" ] androidx-media3 = [ "androidx-media3-common", @@ -186,10 +196,9 @@ koin = [ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-lint = { id = "com.android.lint", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } google-oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } +androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..37f78a6a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index e836a24a..af285278 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) id("kotlin-parcelize") diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt index 085f12e4..8004a0cb 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt @@ -3,14 +3,28 @@ package org.monogram.presentation.core.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,7 +37,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import org.koin.compose.koinInject +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.FileModel import org.monogram.domain.repository.FileRepository import java.io.File @@ -95,9 +111,10 @@ fun SettingsItem( LaunchedEffect(icon.id) { if (localPath.isEmpty() || !File(localPath).exists()) { fileRepository.downloadFile(icon.id, 32) - fileRepository.messageDownloadCompletedFlow - .filter { it.first == icon.id.toLong() } - .collect { (_, _, completedPath) -> localPath = completedPath } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == icon.id } + .collect { completed -> localPath = completed.path } } } diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt index a67e63d6..22f3e3e7 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt @@ -1,17 +1,13 @@ package org.monogram.presentation.core.ui import androidx.compose.animation.core.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.Canvas import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.State import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -25,34 +21,37 @@ fun TypingDots( ) { val infiniteTransition = rememberInfiniteTransition(label = "TypingDots") val color = if (dotColor == Color.Unspecified) LocalContentColor.current else dotColor + val phase: State = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 600, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "DotPhase" + ) + val width = dotSize * 3 + spacing * 2 + val minAlpha = 0.2f + val maxAlpha = 1f + + Canvas(modifier = modifier.size(width = width, height = dotSize), onDraw = { + val diameter = size.height + val radius = diameter / 2f + val spacingPx = spacing.toPx() - Row( - modifier = modifier, - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(spacing) - ) { repeat(3) { index -> - val alpha by infiniteTransition.animateFloat( - initialValue = 0.2f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 600 - 0.2f at 0 - 1f at 300 - 0.2f at 600 - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(index * 200) - ), - label = "DotAlpha" - ) + val shifted = (phase.value + index / 3f) % 1f + val progress = if (shifted <= 0.5f) shifted * 2f else (1f - shifted) * 2f + val alpha = minAlpha + (maxAlpha - minAlpha) * progress - Box( - modifier = Modifier - .size(dotSize) - .background(color.copy(alpha = alpha), CircleShape) + drawCircle( + color = color.copy(alpha = alpha), + radius = radius, + center = Offset( + x = radius + index * (diameter + spacingPx), + y = radius + ) ) } - } + }) } \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index 9e102672..5b666ca0 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -11,7 +11,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.defaultProxyNetworkMode enum class NightMode { SYSTEM, LIGHT, DARK, SCHEDULED, BRIGHTNESS @@ -315,6 +321,10 @@ class AppPreferences( private val _showChatListPhotos = MutableStateFlow(prefs.getBoolean(KEY_SHOW_CHAT_LIST_PHOTOS, true)) override val showChatListPhotos: StateFlow = _showChatListPhotos + private val _isTabletInterfaceEnabled = + MutableStateFlow(prefs.getBoolean(KEY_TABLET_INTERFACE_ENABLED, true)) + val isTabletInterfaceEnabled: StateFlow = _isTabletInterfaceEnabled + private val _isAdBlockEnabled = MutableStateFlow(prefs.getBoolean(KEY_ADBLOCK_ENABLED, false)) val isAdBlockEnabled: StateFlow = _isAdBlockEnabled @@ -334,15 +344,50 @@ class AppPreferences( private val _isAutoBestProxyEnabled = MutableStateFlow(prefs.getBoolean(KEY_AUTO_BEST_PROXY, false)) override val isAutoBestProxyEnabled: StateFlow = _isAutoBestProxyEnabled - private val _isTelegaProxyEnabled = MutableStateFlow(prefs.getBoolean(KEY_TELEGA_PROXY, false)) - override val isTelegaProxyEnabled: StateFlow = _isTelegaProxyEnabled - - private val _telegaProxyUrls = MutableStateFlow(prefs.getStringSet(KEY_TELEGA_PROXY_URLS, emptySet()) ?: emptySet()) - override val telegaProxyUrls: StateFlow> = _telegaProxyUrls - private val _preferIpv6 = MutableStateFlow(prefs.getBoolean(KEY_PREFER_IPV6, false)) override val preferIpv6: StateFlow = _preferIpv6 + private val _proxySortMode = MutableStateFlow( + runCatching { + ProxySortMode.valueOf( + prefs.getString(KEY_PROXY_SORT_MODE, ProxySortMode.LOWEST_PING.name) + ?: ProxySortMode.LOWEST_PING.name + ) + }.getOrDefault(ProxySortMode.LOWEST_PING) + ) + override val proxySortMode: StateFlow = _proxySortMode + + private val _proxyUnavailableFallback = MutableStateFlow( + runCatching { + ProxyUnavailableFallback.valueOf( + prefs.getString( + KEY_PROXY_UNAVAILABLE_FALLBACK, + ProxyUnavailableFallback.BEST_PROXY.name + ) + ?: ProxyUnavailableFallback.BEST_PROXY.name + ) + }.getOrDefault(ProxyUnavailableFallback.BEST_PROXY) + ) + override val proxyUnavailableFallback: StateFlow = + _proxyUnavailableFallback + + private val _hideOfflineProxies = + MutableStateFlow(prefs.getBoolean(KEY_HIDE_OFFLINE_PROXIES, false)) + override val hideOfflineProxies: StateFlow = _hideOfflineProxies + + private val _favoriteProxyId = + MutableStateFlow( + if (prefs.contains(KEY_FAVORITE_PROXY_ID)) prefs.getInt( + KEY_FAVORITE_PROXY_ID, + 0 + ) else null + ) + override val favoriteProxyId: StateFlow = _favoriteProxyId + + private val _proxyNetworkRules = MutableStateFlow(readProxyNetworkRules()) + override val proxyNetworkRules: StateFlow> = + _proxyNetworkRules + private val _userProxyBackups = MutableStateFlow(prefs.getStringSet(KEY_USER_PROXY_BACKUPS, emptySet()) ?: emptySet()) override val userProxyBackups: StateFlow> = _userProxyBackups @@ -379,6 +424,89 @@ class AppPreferences( setAdBlockKeywords(keywords) } + private fun readProxyNetworkRules(): Map { + return ProxyNetworkType.entries.associateWith { networkType -> + val mode = runCatching { + ProxyNetworkMode.valueOf( + prefs.getString( + proxyModeKey(networkType), + defaultProxyNetworkMode(networkType).name + ) + ?: defaultProxyNetworkMode(networkType).name + ) + }.getOrDefault(defaultProxyNetworkMode(networkType)) + + val specificProxyId = if (prefs.contains(proxySpecificKey(networkType))) { + prefs.getInt(proxySpecificKey(networkType), 0) + } else { + null + } + + val lastUsedProxyId = if (prefs.contains(proxyLastUsedKey(networkType))) { + prefs.getInt(proxyLastUsedKey(networkType), 0) + } else { + null + } + + ProxyNetworkRule( + mode = mode, + specificProxyId = specificProxyId, + lastUsedProxyId = lastUsedProxyId + ) + } + } + + private fun updateProxyNetworkRule( + networkType: ProxyNetworkType, + transform: (ProxyNetworkRule) -> ProxyNetworkRule + ) { + val current = _proxyNetworkRules.value[networkType] ?: ProxyNetworkRule( + defaultProxyNetworkMode(networkType) + ) + val updated = transform(current) + val specificProxyId = updated.specificProxyId + val lastUsedProxyId = updated.lastUsedProxyId + + prefs.edit().apply { + putString(proxyModeKey(networkType), updated.mode.name) + if (specificProxyId != null) { + putInt(proxySpecificKey(networkType), specificProxyId) + } else { + remove(proxySpecificKey(networkType)) + } + if (lastUsedProxyId != null) { + putInt(proxyLastUsedKey(networkType), lastUsedProxyId) + } else { + remove(proxyLastUsedKey(networkType)) + } + }.apply() + + _proxyNetworkRules.value = _proxyNetworkRules.value.toMutableMap().apply { + put(networkType, updated) + } + } + + private fun proxyModeKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_MODE_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_MODE_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_MODE_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_MODE_OTHER + } + + private fun proxySpecificKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_SPECIFIC_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_SPECIFIC_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_SPECIFIC_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_SPECIFIC_OTHER + } + + private fun proxyLastUsedKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_LAST_USED_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_LAST_USED_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_LAST_USED_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_LAST_USED_OTHER + } + fun setFontSize(size: Float) { prefs.edit().putFloat(KEY_FONT_SIZE, size).apply() _fontSize.value = size @@ -804,6 +932,11 @@ class AppPreferences( _showChatListPhotos.value = enabled } + fun setTabletInterfaceEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_TABLET_INTERFACE_ENABLED, enabled).apply() + _isTabletInterfaceEnabled.value = enabled + } + fun setAdBlockEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply() _isAdBlockEnabled.value = enabled @@ -833,19 +966,45 @@ class AppPreferences( _isAutoBestProxyEnabled.value = enabled } - override fun setTelegaProxyEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_TELEGA_PROXY, enabled).apply() - _isTelegaProxyEnabled.value = enabled + override fun setPreferIpv6(enabled: Boolean) { + prefs.edit().putBoolean(KEY_PREFER_IPV6, enabled).apply() + _preferIpv6.value = enabled + } + + override fun setProxySortMode(mode: ProxySortMode) { + prefs.edit().putString(KEY_PROXY_SORT_MODE, mode.name).apply() + _proxySortMode.value = mode } - override fun setTelegaProxyUrls(urls: Set) { - prefs.edit().putStringSet(KEY_TELEGA_PROXY_URLS, urls).apply() - _telegaProxyUrls.value = urls + override fun setProxyUnavailableFallback(fallback: ProxyUnavailableFallback) { + prefs.edit().putString(KEY_PROXY_UNAVAILABLE_FALLBACK, fallback.name).apply() + _proxyUnavailableFallback.value = fallback } - override fun setPreferIpv6(enabled: Boolean) { - prefs.edit().putBoolean(KEY_PREFER_IPV6, enabled).apply() - _preferIpv6.value = enabled + override fun setHideOfflineProxies(enabled: Boolean) { + prefs.edit().putBoolean(KEY_HIDE_OFFLINE_PROXIES, enabled).apply() + _hideOfflineProxies.value = enabled + } + + override fun setFavoriteProxyId(proxyId: Int?) { + if (proxyId != null) { + prefs.edit().putInt(KEY_FAVORITE_PROXY_ID, proxyId).apply() + } else { + prefs.edit().remove(KEY_FAVORITE_PROXY_ID).apply() + } + _favoriteProxyId.value = proxyId + } + + override fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { + updateProxyNetworkRule(networkType) { it.copy(mode = mode) } + } + + override fun setSpecificProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) { + updateProxyNetworkRule(networkType) { it.copy(specificProxyId = proxyId) } + } + + override fun setLastUsedProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) { + updateProxyNetworkRule(networkType) { it.copy(lastUsedProxyId = proxyId) } } override fun setUserProxyBackups(backups: Set) { @@ -958,14 +1117,18 @@ class AppPreferences( _isChatAnimationsEnabled.value = true _chatListMessageLines.value = 1 _showChatListPhotos.value = true + _isTabletInterfaceEnabled.value = true _isAdBlockEnabled.value = false _adBlockKeywords.value = emptySet() _adBlockWhitelistedChannels.value = emptySet() _enabledProxyId.value = null _isAutoBestProxyEnabled.value = false - _isTelegaProxyEnabled.value = false - _telegaProxyUrls.value = emptySet() _preferIpv6.value = false + _proxySortMode.value = ProxySortMode.LOWEST_PING + _proxyUnavailableFallback.value = ProxyUnavailableFallback.BEST_PROXY + _hideOfflineProxies.value = false + _favoriteProxyId.value = null + _proxyNetworkRules.value = readProxyNetworkRules() _userProxyBackups.value = emptySet() _isPermissionRequested.value = false } @@ -1071,6 +1234,7 @@ class AppPreferences( private const val KEY_CHAT_ANIMATIONS_ENABLED = "chat_animations_enabled" private const val KEY_CHAT_LIST_MESSAGE_LINES = "chat_list_message_lines" private const val KEY_SHOW_CHAT_LIST_PHOTOS = "show_chat_list_photos" + private const val KEY_TABLET_INTERFACE_ENABLED = "tablet_interface_enabled" private const val KEY_ADBLOCK_ENABLED = "adblock_enabled" private const val KEY_ADBLOCK_KEYWORDS = "adblock_keywords" @@ -1078,9 +1242,23 @@ class AppPreferences( private const val KEY_ENABLED_PROXY_ID = "enabled_proxy_id" private const val KEY_AUTO_BEST_PROXY = "auto_best_proxy" - private const val KEY_TELEGA_PROXY = "telega_proxy" - private const val KEY_TELEGA_PROXY_URLS = "telega_proxy_urls" private const val KEY_PREFER_IPV6 = "prefer_ipv6" + private const val KEY_PROXY_SORT_MODE = "proxy_sort_mode" + private const val KEY_PROXY_UNAVAILABLE_FALLBACK = "proxy_unavailable_fallback" + private const val KEY_HIDE_OFFLINE_PROXIES = "hide_offline_proxies" + private const val KEY_FAVORITE_PROXY_ID = "favorite_proxy_id" + private const val KEY_PROXY_MODE_WIFI = "proxy_mode_wifi" + private const val KEY_PROXY_MODE_MOBILE = "proxy_mode_mobile" + private const val KEY_PROXY_MODE_VPN = "proxy_mode_vpn" + private const val KEY_PROXY_MODE_OTHER = "proxy_mode_other" + private const val KEY_PROXY_SPECIFIC_WIFI = "proxy_specific_wifi" + private const val KEY_PROXY_SPECIFIC_MOBILE = "proxy_specific_mobile" + private const val KEY_PROXY_SPECIFIC_VPN = "proxy_specific_vpn" + private const val KEY_PROXY_SPECIFIC_OTHER = "proxy_specific_other" + private const val KEY_PROXY_LAST_USED_WIFI = "proxy_last_used_wifi" + private const val KEY_PROXY_LAST_USED_MOBILE = "proxy_last_used_mobile" + private const val KEY_PROXY_LAST_USED_VPN = "proxy_last_used_vpn" + private const val KEY_PROXY_LAST_USED_OTHER = "proxy_last_used_other" private const val KEY_USER_PROXY_BACKUPS = "user_proxy_backups" private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt index d6f7f69a..d2b62a29 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.FolderModel import org.monogram.domain.models.GifModel import org.monogram.domain.models.RecentEmojiModel @@ -102,6 +103,33 @@ class CachePreferences(private val context: Context) : CacheProvider { return prefs.getLong("chat_scroll_$chatId", 0L) } + override fun saveChatViewport(chatId: Long, threadId: Long?, viewport: ChatViewportCacheEntry) { + prefs.edit() + .putString(chatViewportKey(chatId, threadId), Json.encodeToString(viewport)) + .apply() + } + + override fun getChatViewport(chatId: Long, threadId: Long?): ChatViewportCacheEntry? { + val directJson = prefs.getString(chatViewportKey(chatId, threadId), null) + if (!directJson.isNullOrBlank()) { + return try { + Json.decodeFromString(directJson) + } catch (_: Exception) { + null + } + } + + if (threadId != null) return null + if (!prefs.contains("chat_scroll_$chatId")) return null + + val legacy = prefs.getLong("chat_scroll_$chatId", 0L) + return if (legacy == 0L) { + ChatViewportCacheEntry(atBottom = true) + } else { + ChatViewportCacheEntry(anchorMessageId = legacy, anchorOffsetPx = 0, atBottom = false) + } + } + override fun setSavedGifs(gifs: List) { prefs.edit().putString(KEY_SAVED_GIFS, Json.encodeToString(gifs)).apply() _savedGifs.value = gifs @@ -138,5 +166,9 @@ class CachePreferences(private val context: Context) : CacheProvider { companion object { private const val KEY_SAVED_GIFS = "saved_gifs" + + private fun chatViewportKey(chatId: Long, threadId: Long?): String { + return "chat_viewport_${chatId}_${threadId ?: 0L}" + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt index a329897c..bc299cd4 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt @@ -5,4 +5,6 @@ import org.monogram.presentation.features.chats.currentChat.components.VideoPlay val LocalVideoPlayerPool = staticCompositionLocalOf { error("VideoPlayerPool not provided") -} \ No newline at end of file +} + +val LocalTabletInterfaceEnabled = staticCompositionLocalOf { true } diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt index 63102a16..b26c413c 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt @@ -1,6 +1,5 @@ package org.monogram.presentation.core.util -import android.util.Log import com.google.i18n.phonenumbers.PhoneNumberUtil import org.monogram.presentation.core.util.Country.Companion.FALLBACK_LENGTH @@ -285,6 +284,32 @@ object CountryManager { return result } + /** + * Get example phone number for a country with digits masked as zeros + * + * @param iso country ISO code + * @return formatted example number with body digits replaced by zeros, + * or throws if no example number is available for the given ISO + **/ + fun getExampleNumber(iso: String): String = + phoneUtil.format( + phoneUtil.getExampleNumberForType(iso, PhoneNumberUtil.PhoneNumberType.MOBILE), + PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL + ).let { it.substringBefore(" ") + " " + it.substringAfter(" ").replace(Regex("\\d"), "0") } + + /** + * Get country ISO code from the device's SIM card + * + * @param context Android context + * @return uppercase ISO code of the SIM card's country, + * or 'null' if no SIM is present or the country cannot be determined + **/ + fun getSimIso(context: android.content.Context): String? { + val tm = context.getSystemService(android.content.Context.TELEPHONY_SERVICE) + as android.telephony.TelephonyManager + return tm.simCountryIso?.uppercase()?.takeIf { it.isNotEmpty() } + } + private fun countryCodeToEmoji(countryCode: String): String { if (countryCode == "FT") return "⭐" if (countryCode == "YL") return "✈️" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt index 72056d4d..38f215f6 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt @@ -6,6 +6,10 @@ import java.util.Date import java.util.Locale import kotlin.math.abs import kotlin.math.roundToLong +import org.monogram.core.date.DateFormatManager as CoreDateFormatManager +import org.monogram.core.date.DateFormatManagerImpl as CoreDateFormatManagerImpl +import org.monogram.core.date.Fake12HourDateFormatManagerImpl as CoreFake12HourDateFormatManagerImpl +import org.monogram.core.date.Fake24HourDateFormatManagerImpl as CoreFake24HourDateFormatManagerImpl /** * Formats date as relative string as for day, week, year, periods and so-on @@ -14,6 +18,7 @@ import kotlin.math.roundToLong * @param now optional current date (for custom formatting or testing) **/ fun Date.toShortRelativeDate( + timeFormat: String, locale: Locale = Locale.getDefault(), now: Date = Date() ): String { @@ -42,7 +47,7 @@ fun Date.toShortRelativeDate( return when (diffDays) { 0L -> { - SimpleDateFormat("HH:mm", locale).format(this) + SimpleDateFormat(timeFormat, locale).format(this) } in 1..6 -> { SimpleDateFormat("EEE", locale).format(this) @@ -59,3 +64,8 @@ fun Date.toShortRelativeDate( } } } + +typealias DateFormatManager = CoreDateFormatManager +typealias DateFormatManagerImpl = CoreDateFormatManagerImpl +typealias Fake12HourDateFormatManagerImpl = CoreFake12HourDateFormatManagerImpl +typealias Fake24HourDateFormatManagerImpl = CoreFake24HourDateFormatManagerImpl diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt index 1189fbdc..bfe6b6dc 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt @@ -1,7 +1,11 @@ package org.monogram.presentation.core.util import android.app.Activity -import android.content.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentValues +import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.media.MediaScannerConnection import android.net.ConnectivityManager @@ -22,61 +26,60 @@ class DownloadUtils( ) : IDownloadUtils { override fun saveFileToDownloads(filePath: String) { - try { - val file = File(filePath) - if (!file.exists()) { - messageDisplayer.show("File not found") - return - } + when (saveFileToDownloadsInternal(filePath)) { + SaveResult.SUCCESS -> messageDisplayer.show("Saved to Downloads/MonoGram") + SaveResult.NOT_FOUND -> messageDisplayer.show("File not found") + SaveResult.FAILED -> messageDisplayer.show("Failed to save file") + } + } - val fileName = file.name - val mimeType = getMimeType(filePath) - val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/MonoGram" + override fun saveFilesToDownloads(filePaths: List) { + val paths = filePaths + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .toList() + + if (paths.isEmpty()) { + messageDisplayer.show("No files to save") + return + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put(MediaStore.MediaColumns.MIME_TYPE, mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - } + var savedCount = 0 + var notFoundCount = 0 + var failedCount = 0 - val resolver = context.contentResolver - val uri = resolver.insert( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, - contentValues - ) + paths.forEach { path -> + when (saveFileToDownloadsInternal(path)) { + SaveResult.SUCCESS -> savedCount++ + SaveResult.NOT_FOUND -> notFoundCount++ + SaveResult.FAILED -> failedCount++ + } + } - if (uri != null) { - resolver.openOutputStream(uri)?.use { outputStream -> - FileInputStream(file).use { inputStream -> - inputStream.copyTo(outputStream) - } - } - messageDisplayer.show("Saved to Downloads/MonoGram") - } else { - messageDisplayer.show("Failed to create file in Downloads") - } - } else { - val downloadsDir = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) - val monoGramDir = File(downloadsDir, "MonoGram") - if (!monoGramDir.exists()) { - monoGramDir.mkdirs() - } + when { + savedCount == 0 && notFoundCount > 0 && failedCount == 0 -> { + messageDisplayer.show("Files not found") + } - val destinationFile = File(monoGramDir, fileName) + savedCount == 0 -> { + messageDisplayer.show("Failed to save files") + } - FileInputStream(file).use { inputStream -> - destinationFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } + notFoundCount == 0 && failedCount == 0 -> { + if (savedCount == 1) { + messageDisplayer.show("Saved to Downloads/MonoGram") + } else { + messageDisplayer.show("Saved $savedCount files to Downloads/MonoGram") } + } - messageDisplayer.show("Saved to Downloads/MonoGram") + else -> { + messageDisplayer.show( + "Saved $savedCount files to Downloads/MonoGram ($notFoundCount not found, $failedCount failed)" + ) } - } catch (e: Exception) { - messageDisplayer.show("Failed to save: ${e.message}") } } @@ -258,6 +261,86 @@ class DownloadUtils( } } + private fun saveFileToDownloadsInternal(filePath: String): SaveResult { + return try { + val file = File(filePath) + if (!file.exists()) return SaveResult.NOT_FOUND + + val fileName = file.name + val mimeType = getMimeType(filePath) + val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/MonoGram" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + ?: return SaveResult.FAILED + + val isWritten = resolver.openOutputStream(uri)?.use { outputStream -> + FileInputStream(file).use { inputStream -> + inputStream.copyTo(outputStream) + } + true + } ?: false + + if (!isWritten) { + resolver.delete(uri, null, null) + return SaveResult.FAILED + } + + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + + SaveResult.SUCCESS + } else { + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val monoGramDir = File(downloadsDir, "MonoGram") + if (!monoGramDir.exists() && !monoGramDir.mkdirs()) { + return SaveResult.FAILED + } + + val destinationFile = resolveUniqueDestinationFile(monoGramDir, fileName) + FileInputStream(file).use { inputStream -> + destinationFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + SaveResult.SUCCESS + } + } catch (_: Exception) { + SaveResult.FAILED + } + } + + private fun resolveUniqueDestinationFile(directory: File, fileName: String): File { + val dotIndex = fileName.lastIndexOf('.') + val baseName = if (dotIndex > 0) fileName.substring(0, dotIndex) else fileName + val extension = if (dotIndex > 0) fileName.substring(dotIndex) else "" + + var candidate = File(directory, fileName) + var index = 1 + while (candidate.exists()) { + candidate = File(directory, "$baseName ($index)$extension") + index++ + } + return candidate + } + + private enum class SaveResult { + SUCCESS, + NOT_FOUND, + FAILED + } + override fun isWifiConnected(): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt index 91883322..c206e6c0 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt @@ -8,6 +8,8 @@ interface IDownloadUtils { fun saveFileToDownloads(filePath: String) + fun saveFilesToDownloads(filePaths: List) + fun saveBitmapToGallery(bitmap: Bitmap) fun copyBitmapToClipboard(bitmap: Bitmap) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt b/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt index 8df82199..2188a5c1 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt @@ -14,17 +14,17 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R import java.text.SimpleDateFormat import java.util.* -fun formatLastSeen(lastSeen: Long?, context: Context): String { +fun formatLastSeen(lastSeen: Long?, context: Context, timeFormat: String): String { if (lastSeen == null || lastSeen <= 0L) return context.getString(R.string.last_seen_recently) val now = System.currentTimeMillis() val diff = now - lastSeen - if (diff < 0) return context.getString(R.string.last_seen_just_now) return when { @@ -35,12 +35,12 @@ fun formatLastSeen(lastSeen: Long?, context: Context): String { } DateUtils.isToday(lastSeen) -> { - val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(lastSeen)) + val time = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(lastSeen)) context.getString(R.string.last_seen_at, time) } isYesterday(lastSeen) -> { - val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(lastSeen)) + val time = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(lastSeen)) context.getString(R.string.last_seen_yesterday_at, time) } @@ -57,10 +57,12 @@ fun rememberUserStatusText(user: UserModel?): String { if (user.type == UserTypeEnum.BOT) return stringResource(R.string.status_bot) val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() return remember(user.userStatus, user.lastSeen) { when (user.userStatus) { UserStatusType.ONLINE -> context.getString(R.string.status_online) - UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context) + UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context, timeFormat) UserStatusType.RECENTLY -> context.getString(R.string.last_seen_recently) UserStatusType.LAST_WEEK -> context.getString(R.string.last_seen_within_week) UserStatusType.LAST_MONTH -> context.getString(R.string.last_seen_within_month) @@ -73,13 +75,13 @@ private fun isYesterday(timestamp: Long): Boolean { return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) } -fun getUserStatusText(user: UserModel?, context: Context): String { +fun getUserStatusText(user: UserModel?, context: Context, timeFormat: String): String { if (user == null) return context.getString(R.string.status_offline) if (user.type == UserTypeEnum.BOT) return context.getString(R.string.status_bot) return when (user.userStatus) { UserStatusType.ONLINE -> context.getString(R.string.status_online) - UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context) + UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context, timeFormat) UserStatusType.RECENTLY -> context.getString(R.string.last_seen_recently) UserStatusType.LAST_WEEK -> context.getString(R.string.last_seen_within_week) UserStatusType.LAST_MONTH -> context.getString(R.string.last_seen_within_month) diff --git a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt index e9962e58..35901c01 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt @@ -1,13 +1,21 @@ package org.monogram.presentation.di.coil +import android.content.Context import coil3.ImageLoader +import coil3.memory.MemoryCache import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.svg.SvgDecoder import org.koin.dsl.module val coilModule = module { single { - ImageLoader.Builder(get()) + val context = get() + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.15) + .build() + } .components { add(LottieDecoder.Factory()) add(SvgDecoder.Factory()) diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt index cbcdbd60..a19083ee 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt @@ -2,14 +2,31 @@ package org.monogram.presentation.features.auth import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.SettingsEthernet +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -20,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.auth.components.AuthErrorDialog import org.monogram.presentation.features.auth.components.CodeInputScreen import org.monogram.presentation.features.auth.components.PasswordInputScreen @@ -30,7 +48,8 @@ import org.monogram.presentation.features.auth.components.PhoneInputScreen fun AuthContent(component: AuthComponent) { val model by component.model.subscribeAsState() val configuration = LocalConfiguration.current - val isTablet = configuration.screenWidthDp >= 600 + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = configuration.screenWidthDp >= 600 && isTabletInterfaceEnabled val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val maxContentWidth = if (isTablet && isLandscape) 1000.dp else 600.dp val motionScheme = MaterialTheme.motionScheme diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt index 09ae0b4a..694177a2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.rounded.VpnKey import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -190,8 +189,7 @@ fun PasswordInputScreen( PasswordContent( password = password, onPasswordChange = { - val filtered = it.filter { c -> c != ' ' } - password = filtered.take(64) + password = it.filter { c -> c != ' ' } }, passwordVisible = passwordVisible, onPasswordVisibleChange = { passwordVisible = it }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt index 8fcd30bd..9bd08a58 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalInspectionMode @@ -125,10 +126,11 @@ fun PhoneInputScreen( } } + val context = LocalContext.current val defaultCountry = remember { - val currentIso = Locale.getDefault().country - countries.find { it.iso == currentIso } ?: countries.find { it.code == "380" } - ?: countries.first() + val iso = if (isPreview) "US" else + (CountryManager.getSimIso(context) ?: Locale.getDefault().country) + countries.find { it.iso == iso } ?: countries.find { it.code == "380" } ?: countries.first() } var phoneBody by remember { mutableStateOf("") } @@ -208,6 +210,18 @@ fun PhoneInputScreen( } } + val phonePlaceholder = remember(selectedCountry) { + selectedCountry?.let { country -> + try { + val example = CountryManager.getExampleNumber(country.iso) + val prefix = "+${country.code}" + if (example.startsWith(prefix)) example.removePrefix(prefix).trim() else example + } catch (_: Exception) { + "" + } + } ?: "" + } + val fullNumber = "+$codeInput$phoneBody" val isFormValid = remember(fullNumber, selectedCountry?.iso) { val iso = selectedCountry?.iso @@ -385,7 +399,7 @@ fun PhoneInputScreen( codeInput = digits codeFieldValue = newValue.copy(text = digits) - val newCountry = countries.find { it.code == digits } + val newCountry = CountryManager.getCountryForPhone(digits) selectedCountry = newCountry phoneDisplay = newCountry?.let { @@ -483,7 +497,9 @@ fun PhoneInputScreen( ) { Column { Text( - text = if (phoneBody.isEmpty()) stringResource(R.string.phone_number_placeholder) else phoneDisplay, + text = if (phoneBody.isEmpty()) + phonePlaceholder.ifEmpty { stringResource(R.string.phone_number_placeholder) } + else phoneDisplay, style = MaterialTheme.typography.titleMedium, color = if (phoneBody.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.5f diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index a08e4df7..882033d1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -2,18 +2,48 @@ package org.monogram.presentation.features.chats.chatList import android.util.Log import androidx.activity.compose.BackHandler -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -25,9 +55,34 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -53,7 +108,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.core.layout.WindowSizeClass import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -63,8 +118,18 @@ import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.chatList.components.* +import org.monogram.presentation.features.chats.chatList.components.AccountMenu +import org.monogram.presentation.features.chats.chatList.components.ArchiveHeaderCard +import org.monogram.presentation.features.chats.chatList.components.ChatListItem +import org.monogram.presentation.features.chats.chatList.components.ChatListShimmer +import org.monogram.presentation.features.chats.chatList.components.ChatListTopBar +import org.monogram.presentation.features.chats.chatList.components.EmptyStateView +import org.monogram.presentation.features.chats.chatList.components.FolderTabs +import org.monogram.presentation.features.chats.chatList.components.MessageSearchItem +import org.monogram.presentation.features.chats.chatList.components.PermissionRequestSheet +import org.monogram.presentation.features.chats.chatList.components.SelectionTopBar import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.instantview.InstantViewer import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid @@ -94,7 +159,9 @@ fun ChatListContent(component: ChatListComponent) { var showPermissionRequest by remember { mutableStateOf(!isPermissionRequested) } val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu @@ -218,9 +285,9 @@ fun ChatListContent(component: ChatListComponent) { } val maxHide = if (isArchivePersistent) { - -(archiveItemHeightPx + tabsHeightPx) + -archiveItemHeightPx } else { - -tabsHeightPx + 0f } val oldOffset = headerOffsetPx @@ -311,23 +378,13 @@ fun ChatListContent(component: ChatListComponent) { } } - val maxHide = if (isArchivePersistent) { - -(archiveItemHeightPx + tabsHeightPx) - } else { - -tabsHeightPx - } + val maxHide = if (isArchivePersistent) -archiveItemHeightPx else 0f if (headerOffsetPx < 0f && headerOffsetPx > maxHide) { val target = if (isArchivePersistent) { - if (headerOffsetPx > -archiveItemHeightPx / 2) { - 0f - } else if (headerOffsetPx > -archiveItemHeightPx - tabsHeightPx / 2) { - -archiveItemHeightPx - } else { - maxHide - } + if (headerOffsetPx > -archiveItemHeightPx / 2) 0f else -archiveItemHeightPx } else { - if (headerOffsetPx > maxHide / 2) 0f else maxHide + 0f } headerAnimationJob = scope.launch { animate(initialValue = headerOffsetPx, targetValue = target) { value, _ -> @@ -555,13 +612,7 @@ fun ChatListContent(component: ChatListComponent) { } else { 0f } - val visibleTabsHeight = if (isArchivePersistent) { - (tabsHeightPx + (headerOffsetPx + archiveItemHeightPx).coerceAtMost(0f)).coerceAtLeast( - 0f - ) - } else { - (tabsHeightPx + headerOffsetPx).coerceAtLeast(0f) - } + val visibleTabsHeight = tabsHeightPx (visibleArchiveHeight + visibleTabsHeight).toDp() }) .clip(RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp)), @@ -573,7 +624,8 @@ fun ChatListContent(component: ChatListComponent) { .fillMaxWidth() .height(with(density) { if (isArchivePersistent) { - (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f).toDp() + (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f) + .toDp() } else if (isMainFolder) { archiveRevealPx.toDp() } else { @@ -614,14 +666,7 @@ fun ChatListContent(component: ChatListComponent) { if (state.folders.size > 1) { FolderTabs( - modifier = Modifier.offset { - val yOffset = if (isArchivePersistent) { - (headerOffsetPx + archiveItemHeightPx).coerceAtMost(0f) - } else { - headerOffsetPx - } - IntOffset(0, yOffset.roundToInt()) - }, + modifier = Modifier, folders = state.folders, pagerState = pagerState, onTabClick = { index -> @@ -684,7 +729,7 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .padding(top = padding.calculateTopPadding()) .fillMaxSize(), - shape = if (isTablet) RoundedCornerShape(0.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + shape = if (isTablet) RoundedCornerShape(16.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), color = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surface ) { Box(modifier = Modifier.fillMaxSize()) { @@ -762,7 +807,12 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() .semantics { contentDescription = "ChatList" }, - contentPadding = PaddingValues(top = 12.dp, bottom = 88.dp), + contentPadding = PaddingValues( + top = 12.dp, + bottom = 88.dp, + start = if (isTablet) 12.dp else 0.dp, + end = if (isTablet) 12.dp else 0.dp + ), ) { if (state.isSearchActive) { if (state.searchQuery.isEmpty() && state.searchHistory.isNotEmpty()) { @@ -806,7 +856,11 @@ fun ChatListContent(component: ChatListComponent) { .width(64.dp) .combinedClickable( onClick = { onChatClicked(chat.id) }, - onLongClick = { component.onRemoveSearchHistoryItem(chat.id) } + onLongClick = { + component.onRemoveSearchHistoryItem( + chat.id + ) + } ), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -824,7 +878,11 @@ fun ChatListContent(component: ChatListComponent) { .offset(x = 4.dp, y = (-4).dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { component.onRemoveSearchHistoryItem(chat.id) }, + .clickable { + component.onRemoveSearchHistoryItem( + chat.id + ) + }, contentAlignment = Alignment.Center ) { Icon( @@ -861,6 +919,7 @@ fun ChatListContent(component: ChatListComponent) { isSelected = false, onClick = { onChatClicked(chat.id) }, onLongClick = { component.onRemoveSearchHistoryItem(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -886,6 +945,7 @@ fun ChatListContent(component: ChatListComponent) { isSelected = state.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -914,6 +974,7 @@ fun ChatListContent(component: ChatListComponent) { isSelected = state.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1106,7 +1167,12 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() .semantics { contentDescription = "ChatList" }, - contentPadding = PaddingValues(top = 12.dp, bottom = 88.dp) + contentPadding = PaddingValues( + top = 12.dp, + bottom = 88.dp, + start = if (isTablet) 4.dp else 0.dp, + end = if (isTablet) 4.dp else 0.dp + ) ) { if (folderChats.isEmpty() && hasFolderLoadState && !isFolderLoading) { item { @@ -1213,7 +1279,7 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .width(menuWidth) .heightIn(max = maxMenuHeightDp) - .clip(ShapeDefaults.LargeIncreased) + .clip(RoundedCornerShape(16.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 090a52ec..3e9c0d24 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -1,14 +1,41 @@ package org.monogram.presentation.features.chats.chatList.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.* +import androidx.compose.material.icons.rounded.AlternateEmail +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,17 +56,19 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import org.monogram.core.date.toDate +import org.koin.compose.koinInject import org.monogram.domain.models.ChatModel import org.monogram.domain.models.MessageEntityType import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.TypingDots +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.toShortRelativeDate import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent import org.monogram.presentation.features.stickers.ui.view.StickerImage -import org.monogram.core.date.toDate @OptIn(ExperimentalFoundationApi::class) @Composable @@ -59,8 +88,8 @@ fun ChatListItem( val backgroundColor by animateColorAsState( targetValue = when { + isTabletSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - isTabletSelected -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) chat.isPinned -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else -> Color.Transparent }, @@ -71,7 +100,7 @@ fun ChatListItem( modifier = modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(24)) + .clip(RoundedCornerShape(12.dp)) .background(backgroundColor) .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 8.dp, vertical = 8.dp) @@ -192,7 +221,10 @@ private fun ChatListItemHeader( chat: ChatModel, isSavedMessages: Boolean ) { - val chatTime = chat.lastMessageDate.toDate().toShortRelativeDate() + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val chatTime = chat.lastMessageDate.toDate().toShortRelativeDate(timeFormat) + val savedMessagesTitle = stringResource(R.string.menu_saved_messages) Row( verticalAlignment = Alignment.CenterVertically, @@ -210,13 +242,15 @@ private fun ChatListItemHeader( Spacer(Modifier.width(4.dp)) } Text( - text = if (isSavedMessages) stringResource(R.string.menu_saved_messages) else chat.title, + text = if (isSavedMessages) savedMessagesTitle else chat.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.semantics { contentDescription = "ChatTitle" } + modifier = Modifier.semantics { + contentDescription = if (isSavedMessages) savedMessagesTitle else chat.title + } ) if (!isSavedMessages && chat.isMuted) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt index fe443ab2..68ff309a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt @@ -1,8 +1,27 @@ package org.monogram.presentation.features.chats.chatList.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Star @@ -10,9 +29,21 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shield import androidx.compose.material.icons.rounded.ShieldMoon +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -24,10 +55,12 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -50,6 +83,11 @@ fun ChatListTopBar( val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() val motionScheme = MaterialTheme.motionScheme + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled + AnimatedContent( targetState = isSearchActive, transitionSpec = { @@ -66,6 +104,7 @@ fun ChatListTopBar( modifier = Modifier .fillMaxWidth() .statusBarsPadding() + .then(if (isTablet) Modifier.padding(top = 6.dp) else Modifier) .padding(horizontal = 16.dp, vertical = 8.dp) ) { SearchBar( @@ -106,6 +145,7 @@ fun ChatListTopBar( modifier = Modifier .fillMaxWidth() .statusBarsPadding() + .then(if (isTablet) Modifier.padding(top = 6.dp) else Modifier) ) { Row( modifier = Modifier @@ -133,7 +173,9 @@ fun ChatListTopBar( Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .onGloballyPositioned { statusAnchorBounds = it.boundsInRoot() } + .onGloballyPositioned { + statusAnchorBounds = it.boundsInRoot() + } .clickable { onStatusClick(statusAnchorBounds) } ) { StickerImage( @@ -148,7 +190,9 @@ fun ChatListTopBar( contentDescription = stringResource(R.string.telegram_premium_title), modifier = Modifier .size(22.dp) - .onGloballyPositioned { statusAnchorBounds = it.boundsInRoot() } + .onGloballyPositioned { + statusAnchorBounds = it.boundsInRoot() + } .clickable { onStatusClick(statusAnchorBounds) }, tint = Color(0xFF31A6FD) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt index 8e46ef96..78a4978f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt @@ -10,9 +10,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -27,11 +29,14 @@ fun MessageSearchItem( val currentCalendar = Calendar.getInstance() calendar.time = date + val dateFormatManager: DateFormatManager = koinInject(); + val timeFormat = dateFormatManager.getHourMinuteFormat() + val isToday = calendar.get(Calendar.YEAR) == currentCalendar.get(Calendar.YEAR) && calendar.get(Calendar.DAY_OF_YEAR) == currentCalendar.get(Calendar.DAY_OF_YEAR) val format = if (isToday) { - SimpleDateFormat("HH:mm", Locale.getDefault()) + SimpleDateFormat(timeFormat, Locale.getDefault()) } else { SimpleDateFormat("MMM d", Locale.getDefault()) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index ef86b211..21e95bf8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -3,7 +3,22 @@ package org.monogram.presentation.features.chats.currentChat import androidx.compose.runtime.Stable import androidx.compose.ui.platform.Clipboard import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.* +import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.models.TopicModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.StickerRepository @@ -80,11 +95,13 @@ interface ChatComponent { fun onShowAllPinnedMessages() fun onDismissPinnedMessages() fun onScrollToMessageConsumed() + fun onScrollCommandConsumed() fun onScrollToBottom() fun onDownloadFile(fileId: Int) fun onDownloadHighRes(messageId: Long) fun onCancelDownloadFile(fileId: Int) fun updateScrollPosition(messageId: Long) + fun updateViewport(viewport: ChatViewportCacheEntry) fun onBottomReached(isAtBottom: Boolean) fun onHighlightConsumed() fun onTyping() @@ -196,6 +213,10 @@ interface ChatComponent { val canWrite: Boolean = false, val isAdmin: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val memberCount: Int = 0, val onlineCount: Int = 0, val unreadCount: Int = 0, @@ -214,13 +235,16 @@ interface ChatComponent { val pinnedMessage: MessageModel? = null, val allPinnedMessages: List = emptyList(), val showPinnedMessagesList: Boolean = false, + val isLoadingPinnedMessages: Boolean = false, val pinnedMessageCount: Int = 0, val pinnedMessageIndex: Int = 0, val scrollToMessageId: Long? = null, + val pendingScrollCommand: ChatScrollCommand? = null, val highlightedMessageId: Long? = null, val isAtBottom: Boolean = true, val currentScrollMessageId: Long = 0L, val lastScrollPosition: Long = 0L, + val lastSavedViewport: ChatViewportCacheEntry? = null, val isLatestLoaded: Boolean = true, val isOldestLoaded: Boolean = false, val fontSize: Float = 16f, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 2df3cd4a..c427a3ac 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -4,25 +4,67 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.collectIsDraggedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.rounded.Block -import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -33,6 +75,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -46,16 +89,40 @@ import androidx.compose.ui.unit.toSize import androidx.compose.ui.zIndex import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.ReplyMarkupModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults -import org.monogram.presentation.features.chats.currentChat.chatContent.* -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentBackground +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentList +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBar +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBarUiState +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatMessageOptionsMenu +import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem +import org.monogram.presentation.features.chats.currentChat.chatContent.ReportChatDialog +import org.monogram.presentation.features.chats.currentChat.chatContent.RestrictUserSheet +import org.monogram.presentation.features.chats.currentChat.chatContent.chatContentLeadingItemsCount +import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum +import org.monogram.presentation.features.chats.currentChat.chatContent.groupedIndexToLazyIndex +import org.monogram.presentation.features.chats.currentChat.chatContent.lazyIndexToGroupedIndex +import org.monogram.presentation.features.chats.currentChat.components.AdvancedCircularRecorderScreen +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBar +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarActions +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarState +import org.monogram.presentation.features.chats.currentChat.components.MessageListShimmer +import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandsSheet import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler import org.monogram.presentation.features.chats.currentChat.components.chats.PollVotersSheet @@ -77,20 +144,23 @@ fun ChatContent( val state by component.state.collectAsState() val scrollState = rememberLazyListState() val context = LocalContext.current + val density = LocalDensity.current val localClipboard = LocalClipboard.current val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && isTabletInterfaceEnabled var isVisible by remember { mutableStateOf(false) } var showInitialLoading by remember { mutableStateOf(false) } var isRecordingVideo by remember { mutableStateOf(false) } // Menu States - var selectedMessageId by remember { mutableStateOf(null) } + var selectedMessageId by rememberSaveable { mutableStateOf(null) } val transformedMessageTexts = remember { mutableStateMapOf() } val originalMessageTexts = remember { mutableStateMapOf() } val latestMessagesState = rememberUpdatedState(state.messages) @@ -108,10 +178,13 @@ fun ChatContent( } } } + val displayMessagesById by remember(displayMessages) { + derivedStateOf { displayMessages.associateBy(MessageModel::id) } + } val selectedMessage by remember { derivedStateOf { val currentSelectedId = selectedMessageIdState.value - displayMessages.find { it.id == currentSelectedId } + currentSelectedId?.let(displayMessagesById::get) } } var menuOffset by remember { mutableStateOf(Offset.Zero) } @@ -119,14 +192,28 @@ fun ChatContent( var clickOffset by remember { mutableStateOf(Offset.Zero) } var contentRect by remember { mutableStateOf(Rect.Zero) } - var pendingMediaPaths by remember { mutableStateOf>(emptyList()) } - var editingPhotoPath by remember { mutableStateOf(null) } - var editingVideoPath by remember { mutableStateOf(null) } - var pendingBlockUserId by remember { mutableStateOf(null) } + var pendingMediaPaths by rememberSaveable { mutableStateOf>(emptyList()) } + var editingPhotoPath by rememberSaveable { mutableStateOf(null) } + var editingVideoPath by rememberSaveable { mutableStateOf(null) } + var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } val groupedMessages by remember { derivedStateOf { groupMessagesByAlbum(displayMessages) } } + val groupedMessageIndexById by remember(groupedMessages) { + derivedStateOf { + buildMap { + groupedMessages.forEachIndexed { index, item -> + when (item) { + is GroupedMessageItem.Single -> put(item.message.id, index) + is GroupedMessageItem.Album -> item.messages.forEach { message -> + put(message.id, index) + } + } + } + } + } + } val isComments = state.rootMessage != null val isForumList = state.viewAsTopics && state.currentTopicId == null var showScrollToBottomButton by remember { mutableStateOf(false) } @@ -143,21 +230,24 @@ fun ChatContent( isRecordingVideo val scrollToMessageState = rememberUpdatedState(newValue = { msg: MessageModel -> - val index = groupedMessages.indexOfFirst { item -> - when (item) { - is GroupedMessageItem.Single -> item.message.id == msg.id - is GroupedMessageItem.Album -> item.messages.any { it.id == msg.id } - } - } + val index = groupedMessageIndexById[msg.id] ?: -1 if (index != -1) { coroutineScope.launch { - val targetIndex = if (isComments) { - if (state.rootMessage != null) index + 1 else index - } else index + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val targetIndex = groupedIndexToLazyIndex(index, leadingItems) - scrollState.scrollMessageToCenter( + scrollState.scrollToMessageIndex( index = targetIndex, - animated = state.isChatAnimationsEnabled + align = ScrollAlign.Center, + animated = state.isChatAnimationsEnabled, + staged = true ) } } else { @@ -173,6 +263,7 @@ fun ChatContent( } LaunchedEffect(state.messages) { + if (transformedMessageTexts.isEmpty() && originalMessageTexts.isEmpty()) return@LaunchedEffect val ids = state.messages.map { it.id }.toSet() transformedMessageTexts.keys.toList().forEach { id -> if (id !in ids) { @@ -206,30 +297,74 @@ fun ChatContent( } } - // Scroll to message when requested by component - LaunchedEffect(state.scrollToMessageId, groupedMessages) { - state.scrollToMessageId?.let { id -> - val index = groupedMessages.indexOfFirst { item -> - if (id == state.currentTopicId) { - false + // Unified command-based scrolling: restore, jump, bottom. + LaunchedEffect(state.pendingScrollCommand, isComments) { + val command = state.pendingScrollCommand ?: return@LaunchedEffect + + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + + when (command) { + is ChatScrollCommand.RestoreViewport -> { + if (command.atBottom || command.anchorMessageId == null) { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) } else { - when (item) { - is GroupedMessageItem.Single -> item.message.id == id - is GroupedMessageItem.Album -> item.messages.any { it.id == id } + val groupedIndex = groupedMessageIndexById[command.anchorMessageId] + ?: awaitGroupedIndex( + messageId = command.anchorMessageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.restoreViewportAtIndex( + targetIndex = targetIndex, + anchorOffsetPx = command.anchorOffsetPx + ) + } else { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) } } + component.onScrollCommandConsumed() } - if (index != -1) { - component.onScrollToMessageConsumed() - val targetIndex = if (isComments) { - if (state.rootMessage != null) index + 1 else index - } else index + is ChatScrollCommand.JumpToMessage -> { + val groupedIndex = groupedMessageIndexById[command.messageId] + ?: awaitGroupedIndex( + messageId = command.messageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.scrollToMessageIndex( + index = targetIndex, + align = command.align, + animated = command.animated && state.isChatAnimationsEnabled, + staged = true + ) + } + component.onScrollCommandConsumed() + } - scrollState.scrollMessageToCenter( - index = targetIndex, - animated = state.isChatAnimationsEnabled + is ChatScrollCommand.ScrollToBottom -> { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = command.animated && state.isChatAnimationsEnabled ) + component.onScrollCommandConsumed() } } } @@ -239,10 +374,9 @@ fun ChatContent( scrollState, isComments, isForumList, - showInitialLoading, - state.unreadCount, - state.isLatestLoaded + showInitialLoading ) { + var lastReportedBottomState: Boolean? = null snapshotFlow { BottomVisibilitySnapshot( isAtBottom = scrollState.isAtBottom( @@ -257,7 +391,10 @@ fun ChatContent( } .distinctUntilChanged() .collectLatest { snapshot -> - component.onBottomReached(snapshot.isAtBottom) + if (lastReportedBottomState != snapshot.isAtBottom) { + component.onBottomReached(snapshot.isAtBottom) + lastReportedBottomState = snapshot.isAtBottom + } val shouldShow = !isForumList && !showInitialLoading && @@ -267,7 +404,7 @@ fun ChatContent( showScrollToBottomButton = true } else { delay(120) - val keepVisible = state.unreadCount > 0 || !snapshot.isNearBottom + val keepVisible = snapshot.unreadCount > 0 || !snapshot.isNearBottom if (!keepVisible) { showScrollToBottomButton = false } @@ -275,49 +412,69 @@ fun ChatContent( } } - // Save scroll position - LaunchedEffect(scrollState, groupedMessages, isComments, state.rootMessage, state.isLatestLoaded) { - snapshotFlow { scrollState.isScrollInProgress to scrollState.firstVisibleItemIndex } - .filter { !it.first } - .map { - val isAtBottom = scrollState.isAtBottom( - isComments = isComments, - isLatestLoaded = state.isLatestLoaded - ) - - if (isAtBottom && !isComments) { - 0L - } else { - val visibleItems = scrollState.layoutInfo.visibleItemsInfo - if (visibleItems.isNotEmpty()) { - val firstVisibleItem = if (isComments) { - visibleItems.firstOrNull { it.index > 0 } - } else { - visibleItems.firstOrNull { it.index >= 0 } - } - - if (firstVisibleItem != null) { - val groupedIndex = - if (state.rootMessage != null) firstVisibleItem.index - 1 else firstVisibleItem.index - groupedMessages.getOrNull(groupedIndex)?.firstMessageId - } else null - } else null - } - } + // Save full viewport (anchor + pixel offset) for precise restore after reopen. + LaunchedEffect( + scrollState, + groupedMessages, + isComments, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + snapshotFlow { + buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + } + .filterNotNull() .distinctUntilChanged() - .collect { messageId -> - if (messageId != null) { - component.updateScrollPosition(messageId) - } + .debounce(120) + .collect { viewport -> + component.updateViewport(viewport) + } + } + + DisposableEffect( + scrollState, + groupedMessages, + isComments, + state.currentTopicId, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + onDispose { + val viewport = buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + if (viewport != null) { + component.updateViewport(viewport) } + } } // Performance: Update visible range for repository LaunchedEffect(scrollState, groupedMessages, state.rootMessage) { snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } .map { visibleItems -> - val visibleIds = mutableListOf() - val nearbyIds = mutableListOf() + val visibleIds = LinkedHashSet() + val nearbyIds = LinkedHashSet() if (visibleItems.isNotEmpty()) { val minIndex = visibleItems.minOf { it.index } val maxIndex = visibleItems.maxOf { it.index } @@ -327,7 +484,9 @@ fun ChatContent( groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) - is GroupedMessageItem.Album -> visibleIds.addAll(grouped.messages.map { it.id }) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + visibleIds.add(message.id) + } } } } @@ -340,12 +499,15 @@ fun ChatContent( groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) - is GroupedMessageItem.Album -> nearbyIds.addAll(grouped.messages.map { it.id }) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + nearbyIds.add(message.id) + } } } } } - visibleIds.distinct() to nearbyIds.distinct().filterNot { it in visibleIds } + val visibleIdList = visibleIds.toList() + visibleIdList to nearbyIds.filterNot(visibleIds::contains) } .distinctUntilChanged() .debounce(100) @@ -371,7 +533,7 @@ fun ChatContent( !state.isLoadingNewer && !scrollState.isScrollInProgress ) { - scrollState.scrollToChatBottom( + scrollState.scrollToChatBottomStaged( isComments = isComments, animated = state.isChatAnimationsEnabled ) @@ -426,44 +588,156 @@ fun ChatContent( label = "ContentOffset" ) - val showInputBar = (state.isMember || !state.isChannel && !state.isGroup) && - (state.canWrite || state.currentTopicId != null) && - !isRecordingVideo && - state.selectedMessageIds.isEmpty() && - (!state.viewAsTopics || state.currentTopicId != null) + val showInputBar by remember( + state.isMember, + state.isChannel, + state.isGroup, + state.canWrite, + state.currentTopicId, + state.selectedMessageIds, + state.viewAsTopics, + isRecordingVideo + ) { + derivedStateOf { + (state.isMember || !state.isChannel && !state.isGroup) && + (state.canWrite || state.currentTopicId != null) && + !isRecordingVideo && + state.selectedMessageIds.isEmpty() && + (!state.viewAsTopics || state.currentTopicId != null) + } + } var containerSize by remember { mutableStateOf(IntSize.Zero) } + var renderPinnedMessagesList by rememberSaveable { mutableStateOf(state.showPinnedMessagesList) } + var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } + + LaunchedEffect(state.showPinnedMessagesList) { + if (state.showPinnedMessagesList) { + renderPinnedMessagesList = true + } + } + + val requestPinnedMessagesListDismiss = { + if (state.showPinnedMessagesList) { + component.onDismissPinnedMessages() + } + } - val isCustomBackHandlingEnabled = - (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) + val isCustomBackHandlingEnabled by remember( + editingPhotoPath, + editingVideoPath, + selectedMessageId, + state.selectedMessageIds, + state.currentTopicId, + state.showBotCommands, + state.restrictUserId, + state.showPinnedMessagesList, + state.fullScreenImages, + state.fullScreenVideoPath, + state.fullScreenVideoMessageId, + state.miniAppUrl, + state.webViewUrl, + state.instantViewUrl, + state.youtubeUrl + ) { + derivedStateOf { + editingPhotoPath != null || + editingVideoPath != null || + selectedMessageId != null || + state.selectedMessageIds.isNotEmpty() || + state.currentTopicId != null || + state.showBotCommands || + state.restrictUserId != null || + state.showPinnedMessagesList || + state.fullScreenImages != null || + state.fullScreenVideoPath != null || + state.fullScreenVideoMessageId != null || + state.miniAppUrl != null || + state.webViewUrl != null || + state.instantViewUrl != null || + state.youtubeUrl != null + } + } + val selectedCount = state.selectedMessageIds.size + val selectedMessageIdSet by remember(state.selectedMessageIds) { + derivedStateOf { state.selectedMessageIds.toHashSet() } + } + val canRevokeSelected by remember(state.messages, selectedMessageIdSet) { + derivedStateOf { + if (selectedMessageIdSet.isEmpty()) { + false + } else { + state.messages.any { it.id in selectedMessageIdSet && it.canBeDeletedForAllUsers } + } + } + } + val topBarUiState = remember( + state.currentTopicId, + state.rootMessage, + state.isGroup, + state.isChannel, + state.isAdmin, + state.permissions, + state.otherUser, + state.currentUser, + state.typingAction, + state.memberCount, + state.onlineCount, + state.topics, + state.chatTitle, + state.chatAvatar, + state.chatPersonalAvatar, + state.chatEmojiStatus, + state.isOnline, + state.isVerified, + state.isSponsor, + state.isWhitelistedInAdBlock, + state.isInstalledFromGooglePlay, + state.isMuted, + state.isSearchActive, + state.searchQuery, + state.pinnedMessage, + state.pinnedMessageCount + ) { + ChatContentTopBarUiState( + currentTopicId = state.currentTopicId, + rootMessage = state.rootMessage, + isGroup = state.isGroup, + isChannel = state.isChannel, + isAdmin = state.isAdmin, + permissions = state.permissions, + otherUser = state.otherUser, + currentUser = state.currentUser, + typingAction = state.typingAction, + memberCount = state.memberCount, + onlineCount = state.onlineCount, + topics = state.topics, + chatTitle = state.chatTitle, + chatAvatar = state.chatAvatar, + chatPersonalAvatar = state.chatPersonalAvatar, + chatEmojiStatus = state.chatEmojiStatus, + isOnline = state.isOnline, + isVerified = state.isVerified, + isSponsor = state.isSponsor, + isWhitelistedInAdBlock = state.isWhitelistedInAdBlock, + isInstalledFromGooglePlay = state.isInstalledFromGooglePlay, + isMuted = state.isMuted, + isSearchActive = state.isSearchActive, + searchQuery = state.searchQuery, + pinnedMessage = state.pinnedMessage, + pinnedMessageCount = state.pinnedMessageCount + ) + } CompositionLocalProvider(LocalLinkHandler provides { component.onLinkClick(it) }) { + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val headerOverlayHeight = statusBarHeight + 16.dp Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .onGloballyPositioned { containerSize = it.size } ) { - /*if (isDragToBackEnabled && !isTablet && !isCustomBackHandlingEnabled && dragOffsetX.value > 0 && previousChild != null) { - Box( - modifier = Modifier.fillMaxSize() - ) { - renderChild(previousChild) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Color.Black.copy( - alpha = 0.3f * (1f - (dragOffsetX.value / containerSize.width.toFloat()).coerceIn( - 0f, - 1f - )) - ) - ) - ) - } - }*/ - Box( modifier = Modifier .fillMaxSize() @@ -479,6 +753,19 @@ fun ChatContent( ChatContentBackground(state = state) } + if (isTablet) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(headerOverlayHeight) + .graphicsLayer { + alpha = contentAlpha + translationY = contentOffset.toPx() + } + .background(MaterialTheme.colorScheme.surface) + ) + } + Scaffold( modifier = Modifier .fillMaxSize() @@ -487,10 +774,19 @@ fun ChatContent( containerColor = Color.Transparent, topBar = { ChatContentTopBar( - state = state, + topBarState = topBarUiState, + selectedCount = selectedCount, + canRevokeSelected = canRevokeSelected, component = component, contentAlpha = contentAlpha, - onBack = { keyboardController?.hide(); component.onBackClicked() }, + onBack = { + keyboardController?.hide() + if (state.currentTopicId != null) { + component.onTopicClick(0) + } else { + component.onBackClicked() + } + }, onOpenMenu = { keyboardController?.hide() focusManager.clearFocus(force = true) @@ -510,6 +806,10 @@ fun ChatContent( isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed ?: false, permissions = state.permissions, + slowModeDelay = state.slowModeDelay, + slowModeDelayExpiresIn = state.slowModeDelayExpiresIn, + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate, isAdmin = state.isAdmin, isChannel = state.isChannel, isBot = state.isBot, @@ -697,43 +997,43 @@ fun ChatContent( val onPhotoClickStable: (MessageModel, List, List, List, Int) -> Unit = remember(component) { { msg: MessageModel, paths: List, captions: List, messageIds: List, index: Int -> - val content = msg.content as? MessageContent.Photo - val clickedPath = paths.getOrNull(index) - ?.takeIf { it.isNotBlank() && File(it).exists() } - ?: content?.path?.takeIf { File(it).exists() } + val content = msg.content as? MessageContent.Photo + val clickedPath = paths.getOrNull(index) + ?.takeIf { it.isNotBlank() && File(it).exists() } + ?: content?.path?.takeIf { File(it).exists() } - if (clickedPath != null) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() + if (clickedPath != null) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() - val validItems = paths.mapIndexedNotNull { itemIndex, path -> - val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } - ?: return@mapIndexedNotNull null - Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) - } + val validItems = paths.mapIndexedNotNull { itemIndex, path -> + val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } + ?: return@mapIndexedNotNull null + Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) + } + + if (validItems.isNotEmpty()) { + val validPaths = validItems.map { it.second } + val validCaptions = validItems.map { it.third } + val validMessageIds = validItems.map { (itemIndex, _, _) -> + messageIds.getOrNull(itemIndex) ?: msg.id + } + val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } + .takeIf { it >= 0 } + ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } + ?: 0 - if (validItems.isNotEmpty()) { - val validPaths = validItems.map { it.second } - val validCaptions = validItems.map { it.third } - val validMessageIds = validItems.map { (itemIndex, _, _) -> - messageIds.getOrNull(itemIndex) ?: msg.id + component.onOpenImages( + images = validPaths, + captions = validCaptions, + startIndex = startIndex, + messageId = msg.id, + messageIds = validMessageIds + ) } - val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } - .takeIf { it >= 0 } - ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } - ?: 0 - - component.onOpenImages( - images = validPaths, - captions = validCaptions, - startIndex = startIndex, - messageId = msg.id, - messageIds = validMessageIds - ) + } else { + content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) } - } else { - content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) - } Unit } } @@ -744,25 +1044,25 @@ fun ChatContent( if (!currentIsVisible.value || currentShowInitialLoading.value || scrollState.isScrollInProgress) { Unit } else { - val videoContent = msg.content as? MessageContent.Video - val supportsStreaming = videoContent?.supportsStreaming ?: false - val validPath = path?.takeIf { File(it).exists() } + val videoContent = msg.content as? MessageContent.Video + val supportsStreaming = videoContent?.supportsStreaming ?: false + val validPath = path?.takeIf { File(it).exists() } - if (validPath != null || supportsStreaming) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) - } else { - val fileId = when (val c = msg.content) { - is MessageContent.Video -> c.fileId - is MessageContent.Gif -> c.fileId - else -> 0 - } - if (fileId != 0) { - component.onDownloadFile(fileId) + if (validPath != null || supportsStreaming) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) + } else { + val fileId = when (val c = msg.content) { + is MessageContent.Video -> c.fileId + is MessageContent.Gif -> c.fileId + else -> 0 + } + if (fileId != 0) { + component.onDownloadFile(fileId) + } } } - } } } @@ -879,9 +1179,9 @@ fun ChatContent( onMessagePositionChange = onMessagePositionChangeStable, onViaBotClick = onViaBotClickStable, toProfile = toProfileStable, - downloadUtils = component.downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) AnimatedVisibility( visible = showScrollToBottomButton, @@ -894,16 +1194,7 @@ fun ChatContent( Box { FloatingActionButton( onClick = { - if (!state.isLatestLoaded) { - component.onScrollToBottom() - } else { - coroutineScope.launch { - scrollState.scrollToChatBottom( - isComments = isComments, - animated = state.isChatAnimationsEnabled - ) - } - } + component.onScrollToBottom() }, containerColor = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape, @@ -999,15 +1290,42 @@ fun ChatContent( // Modals & Overlays - if (state.showPinnedMessagesList) { + if (renderPinnedMessagesList) { PinnedMessagesListSheet( - state = state, - onDismiss = { component.onDismissPinnedMessages() }, - onMessageClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + isVisible = state.showPinnedMessagesList, + allPinnedMessages = state.allPinnedMessages, + pinnedMessageCount = state.pinnedMessageCount, + isLoadingPinnedMessages = state.isLoadingPinnedMessages, + isGroup = state.isGroup, + isChannel = state.isChannel, + fontSize = state.fontSize, + letterSpacing = state.letterSpacing, + bubbleRadius = state.bubbleRadius, + stickerSize = state.stickerSize, + autoDownloadMobile = state.autoDownloadMobile, + autoDownloadWifi = state.autoDownloadWifi, + autoDownloadRoaming = state.autoDownloadRoaming, + autoDownloadFiles = state.autoDownloadFiles, + autoplayGifs = state.autoplayGifs, + autoplayVideos = state.autoplayVideos, + onDismissRequest = requestPinnedMessagesListDismiss, + onHidden = { + renderPinnedMessagesList = false + pendingPinnedSheetAction?.invoke() + pendingPinnedSheetAction = null + }, + onMessageClick = { + pendingPinnedSheetAction = { scrollToMessageState.value(it) } + requestPinnedMessagesListDismiss() + }, onUnpin = { component.onUnpinMessage(it) }, - onReplyClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + onReplyClick = { + pendingPinnedSheetAction = { scrollToMessageState.value(it) } + requestPinnedMessagesListDismiss() + }, onReactionClick = { id, r -> component.onSendReaction(id, r) }, - downloadUtils = component.downloadUtils + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen ) } @@ -1039,11 +1357,11 @@ fun ChatContent( ) } - ChatContentViewers( + /*ChatContentViewers( state = state, component = component, localClipboard = localClipboard - ) + )*/ selectedMessage?.let { msg -> ChatMessageOptionsMenu( @@ -1066,8 +1384,10 @@ fun ChatContent( transformedMessageTexts[msg.id] = newText }, onRestoreOriginalText = { - val originalText = originalMessageTexts[msg.id] ?: return@ChatMessageOptionsMenu - transformedMessageTexts[msg.id] = originalText + if (!originalMessageTexts.containsKey(msg.id)) { + return@ChatMessageOptionsMenu + } + transformedMessageTexts.remove(msg.id) originalMessageTexts.remove(msg.id) }, onBlockRequest = { userId -> @@ -1156,13 +1476,14 @@ fun ChatContent( else if (selectedMessageId != null) selectedMessageId = null else if (state.showBotCommands) component.onDismissBotCommands() else if (state.restrictUserId != null) component.onDismissRestrictDialog() + else if (state.showPinnedMessagesList && !isAnyViewerOpen) requestPinnedMessagesListDismiss() else if (state.fullScreenImages != null) component.onDismissImages() else if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) component.onDismissVideo() else if (state.instantViewUrl != null) component.onDismissInstantView() else if (state.youtubeUrl != null) component.onDismissYouTube() else if (state.miniAppUrl != null) component.onDismissMiniApp() else if (state.webViewUrl != null) component.onDismissWebView() - else if (state.currentTopicId != null) component.onBackClicked() + else if (state.currentTopicId != null) component.onTopicClick(0) } } } @@ -1193,16 +1514,40 @@ private fun MessageModel.withUpdatedTextContent(newText: String): MessageModel { return copy(content = updatedContent) } -private suspend fun LazyListState.scrollMessageToCenter( +private suspend fun LazyListState.scrollToMessageIndex( index: Int, - animated: Boolean + align: ScrollAlign, + animated: Boolean, + staged: Boolean ) { - if (animated) animateScrollToItem(index) else scrollToItem(index) + val total = layoutInfo.totalItemsCount + if (total <= 0) return - val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return - val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 - val itemCenter = itemInfo.offset + (itemInfo.size / 2) - val delta = (itemCenter - viewportCenter).toFloat() + val boundedIndex = index.coerceIn(0, total - 1) + val distance = abs(firstVisibleItemIndex - boundedIndex) + + if (staged && distance > 20) { + val coarseIndex = when { + boundedIndex > firstVisibleItemIndex -> (boundedIndex - 10).coerceAtLeast(0) + boundedIndex < firstVisibleItemIndex -> (boundedIndex + 10).coerceAtMost(total - 1) + else -> boundedIndex + } + scrollToItem(coarseIndex) + } + + scrollToItem(boundedIndex) + + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + val viewportCenter = (viewportStart + viewportEnd) / 2 + + val targetPosition = when (align) { + ScrollAlign.Start -> viewportStart + ScrollAlign.Center -> viewportCenter - (itemInfo.size / 2) + ScrollAlign.End -> viewportEnd - itemInfo.size + } + val delta = (itemInfo.offset - targetPosition).toFloat() if (abs(delta) > 1f) { if (animated) { @@ -1256,15 +1601,23 @@ private fun LazyListState.isNearBottom(isComments: Boolean): Boolean { } } -private suspend fun LazyListState.scrollToChatBottom( +private suspend fun LazyListState.scrollToChatBottomStaged( isComments: Boolean, animated: Boolean ) { - val targetIndex = if (isComments) { - val total = layoutInfo.totalItemsCount - if (total > 0) total - 1 else 0 - } else { - 0 + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + val targetIndex = if (isComments) total - 1 else 0 + val distance = abs(firstVisibleItemIndex - targetIndex) + + if (distance > 24) { + val coarse = if (isComments) { + (targetIndex - 8).coerceAtLeast(0) + } else { + (targetIndex + 8).coerceAtMost(total - 1) + } + scrollToItem(coarse) } if (animated) { @@ -1272,4 +1625,95 @@ private suspend fun LazyListState.scrollToChatBottom( } else { scrollToItem(targetIndex) } + + val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } + if (targetInfo != null) { + val delta = if (isComments) { + ((targetInfo.offset + targetInfo.size) - layoutInfo.viewportEndOffset).toFloat() + } else { + (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() + } + if (abs(delta) > 1f) { + scrollBy(delta) + } + } + + scrollToItem(targetIndex) +} + +private suspend fun awaitGroupedIndex( + messageId: Long, + groupedMessageIndexByIdProvider: () -> Map, + timeoutMs: Long = 1200L +): Int? { + return withTimeoutOrNull(timeoutMs) { + snapshotFlow { groupedMessageIndexByIdProvider()[messageId] } + .filterNotNull() + .first() + } +} + +private suspend fun LazyListState.restoreViewportAtIndex( + targetIndex: Int, + anchorOffsetPx: Int +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + val boundedIndex = targetIndex.coerceIn(0, total - 1) + + scrollToItem(boundedIndex) + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val desiredOffset = viewportStart + anchorOffsetPx + val delta = (itemInfo.offset - desiredOffset).toFloat() + + if (abs(delta) > 1f) { + scrollBy(delta) + } +} + +private fun buildViewportSnapshot( + scrollState: LazyListState, + groupedMessages: List, + isComments: Boolean, + isLatestLoaded: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isAtBottom: Boolean, + showNavPadding: Boolean +): ChatViewportCacheEntry? { + if (groupedMessages.isEmpty()) { + return ChatViewportCacheEntry(atBottom = true) + } + + val atBottomNow = scrollState.isAtBottom( + isComments = isComments, + isLatestLoaded = isLatestLoaded + ) + if (atBottomNow) { + return ChatViewportCacheEntry(atBottom = true) + } + + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = showNavPadding, + isLoadingOlder = isLoadingOlder, + isLoadingNewer = isLoadingNewer, + isAtBottom = isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val info = scrollState.layoutInfo + val anchorItem = info.visibleItemsInfo.firstOrNull { itemInfo -> + val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItems) + groupedIndex in groupedMessages.indices + } ?: return null + + val groupedIndex = lazyIndexToGroupedIndex(anchorItem.index, leadingItems) + val anchorMessageId = groupedMessages.getOrNull(groupedIndex)?.firstMessageId ?: return null + + return ChatViewportCacheEntry( + anchorMessageId = anchorMessageId, + anchorOffsetPx = anchorItem.offset - info.viewportStartOffset, + atBottom = false + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt new file mode 100644 index 00000000..ef5c099f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt @@ -0,0 +1,32 @@ +package org.monogram.presentation.features.chats.currentChat + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface ChatScrollCommand { + @Immutable + data class RestoreViewport( + val anchorMessageId: Long?, + val anchorOffsetPx: Int, + val atBottom: Boolean + ) : ChatScrollCommand + + @Immutable + data class JumpToMessage( + val messageId: Long, + val highlight: Boolean, + val align: ScrollAlign = ScrollAlign.Center, + val animated: Boolean = true + ) : ChatScrollCommand + + @Immutable + data class ScrollToBottom( + val animated: Boolean = true + ) : ChatScrollCommand +} + +enum class ScrollAlign { + Start, + Center, + End +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt index a97d076c..d4ad132a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt @@ -3,6 +3,7 @@ package org.monogram.presentation.features.chats.currentChat import androidx.compose.ui.platform.Clipboard import com.arkivanov.mvikotlin.core.store.Store import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.GifModel import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.KeyboardButtonModel @@ -76,11 +77,13 @@ interface ChatStore : Store component.handlePinnedMessageClick(intent.message) is Intent.ShowAllPinnedMessages -> { - component._state.update { it.copy(showPinnedMessagesList = true) } + component._state.update { + it.copy( + showPinnedMessagesList = true, + isLoadingPinnedMessages = true, + allPinnedMessages = emptyList() + ) + } component.loadAllPinnedMessages() } - is Intent.DismissPinnedMessages -> component._state.update { it.copy(showPinnedMessagesList = false) } - is Intent.ScrollToMessageConsumed -> component._state.update { it.copy(scrollToMessageId = null) } + + is Intent.DismissPinnedMessages -> { + component._state.update { + it.copy( + showPinnedMessagesList = false, + isLoadingPinnedMessages = false + ) + } + } + is Intent.ScrollToMessageConsumed, + is Intent.ScrollCommandConsumed -> component._state.update { + if (it.pendingScrollCommand == null && it.scrollToMessageId == null) it + else it.copy(scrollToMessageId = null, pendingScrollCommand = null) + } is Intent.ScrollToBottom -> component.scrollToBottomInternal() is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId) is Intent.DownloadHighRes -> component.handleDownloadHighRes(intent.messageId) is Intent.CancelDownloadFile -> component.handleCancelDownloadFile(intent.fileId) - is Intent.UpdateScrollPosition -> component._state.update { it.copy(currentScrollMessageId = intent.messageId) } - is Intent.BottomReached -> component._state.update { it.copy(isAtBottom = intent.isAtBottom) } + is Intent.UpdateScrollPosition -> component._state.update { + if (it.currentScrollMessageId == intent.messageId) it else it.copy(currentScrollMessageId = intent.messageId) + } + is Intent.UpdateViewport -> component._state.update { + if (it.lastSavedViewport == intent.viewport) it else it.copy(lastSavedViewport = intent.viewport) + } + is Intent.BottomReached -> component._state.update { + if (it.isAtBottom == intent.isAtBottom) it else it.copy(isAtBottom = intent.isAtBottom) + } is Intent.HighlightConsumed -> component._state.update { it.copy(highlightedMessageId = null) } is Intent.Typing -> { /* Handle typing */ } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index d4ab0c21..de33f752 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -8,17 +8,63 @@ import com.arkivanov.essenty.lifecycle.doOnStop import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider import org.monogram.domain.managers.DistrManager -import org.monogram.domain.models.* -import org.monogram.domain.repository.* +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatMembersFilter +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope -import org.monogram.presentation.features.chats.currentChat.impl.* +import org.monogram.presentation.features.chats.currentChat.impl.loadChatInfo +import org.monogram.presentation.features.chats.currentChat.impl.loadDraft +import org.monogram.presentation.features.chats.currentChat.impl.loadMessages +import org.monogram.presentation.features.chats.currentChat.impl.loadPinnedMessage +import org.monogram.presentation.features.chats.currentChat.impl.loadScheduledMessages +import org.monogram.presentation.features.chats.currentChat.impl.loadWallpapers +import org.monogram.presentation.features.chats.currentChat.impl.observePreferences +import org.monogram.presentation.features.chats.currentChat.impl.observeUserUpdates +import org.monogram.presentation.features.chats.currentChat.impl.setupMessageCollectors +import org.monogram.presentation.features.chats.currentChat.impl.setupPinnedMessageCollector import org.monogram.presentation.root.AppComponentContext import org.monogram.presentation.settings.storage.CacheController import java.io.File @@ -69,6 +115,7 @@ class DefaultChatComponent( internal val reactionUpdateSuppressedUntil = ConcurrentHashMap() internal val remappedMessageIds = ConcurrentHashMap() internal val mediaDownloadRetryCount = ConcurrentHashMap() + internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal var lastLoadedOlderId: Long = 0L internal var lastLoadedNewerId: Long = 0L @@ -102,6 +149,7 @@ class DefaultChatComponent( scrollToMessageId = initialMessageId, highlightedMessageId = initialMessageId, lastScrollPosition = cacheProvider.getChatScrollPosition(chatId), + lastSavedViewport = cacheProvider.getChatViewport(chatId, null), isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay() ) ) @@ -129,6 +177,9 @@ class DefaultChatComponent( lifecycle.doOnStop { autoLoadJob?.cancel() + _state.value.lastSavedViewport?.let { viewport -> + cacheProvider.saveChatViewport(chatId, _state.value.currentTopicId, viewport) + } } lifecycle.doOnResume { @@ -351,6 +402,8 @@ class DefaultChatComponent( override fun onScrollToMessageConsumed() = store.accept(ChatStore.Intent.ScrollToMessageConsumed) + override fun onScrollCommandConsumed() = store.accept(ChatStore.Intent.ScrollCommandConsumed) + override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) override fun onDownloadFile(fileId: Int) { @@ -366,11 +419,38 @@ class DefaultChatComponent( } override fun updateScrollPosition(messageId: Long) { - if (_state.value.currentTopicId == null) { - cacheProvider.saveChatScrollPosition(chatId, messageId) + updateViewport( + ChatViewportCacheEntry( + anchorMessageId = messageId, + anchorOffsetPx = 0, + atBottom = messageId == 0L + ) + ) + } + + override fun updateViewport(viewport: ChatViewportCacheEntry) { + val threadId = _state.value.currentTopicId + cacheProvider.saveChatViewport(chatId, threadId, viewport) + if (threadId == null) { + cacheProvider.saveChatScrollPosition(chatId, viewport.anchorMessageId ?: 0L) + } + _state.update { + if (it.lastSavedViewport == viewport && it.lastScrollPosition == (viewport.anchorMessageId + ?: 0L) + ) { + it + } else { + it.copy( + lastSavedViewport = viewport, + lastScrollPosition = viewport.anchorMessageId ?: 0L + ) + } + } + store.accept(ChatStore.Intent.UpdateViewport(viewport)) + val anchor = viewport.anchorMessageId ?: 0L + if (_state.value.currentScrollMessageId != anchor) { + store.accept(ChatStore.Intent.UpdateScrollPosition(anchor)) } - _state.update { it.copy(lastScrollPosition = messageId) } - store.accept(ChatStore.Intent.UpdateScrollPosition(messageId)) } override fun onBottomReached(isAtBottom: Boolean) = store.accept(ChatStore.Intent.BottomReached(isAtBottom)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt index f8de1cb7..d576b029 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt @@ -32,27 +32,28 @@ fun ChatContentBackground( isGrayscale = state.isWallpaperGrayscale ) } else if (state.wallpaper != null) { - if (File(state.wallpaper!!).exists()) { - var imageModifier = Modifier.fillMaxSize() + val file = File(state.wallpaper) + if (file.exists()) { + var imageModifier = modifier.fillMaxSize() if (state.isWallpaperBlurred && state.wallpaperBlurIntensity > 0) { imageModifier = imageModifier.blur((state.wallpaperBlurIntensity / 4f).dp) } AsyncImage( - model = File(state.wallpaper!!), + model = file, contentDescription = null, modifier = imageModifier, contentScale = ContentScale.Crop ) } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) } } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index d897f3dd..cfffc43f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.chatContent +import android.os.SystemClock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable @@ -12,7 +13,21 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -22,8 +37,22 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.PushPin -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,7 +79,10 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.DateSeparator +import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.ServiceMessage import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelMessageBubbleContainer import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.io.File @@ -80,16 +112,14 @@ fun ChatContentList( ) { val isComments = state.rootMessage != null val isScrolling by remember(scrollState) { derivedStateOf { scrollState.isScrollInProgress } } + val latestState by rememberUpdatedState(state) + var lastOlderLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } + var lastNewerLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } + val loadTriggerThrottleMs = 350L LaunchedEffect( scrollState, groupedMessages.size, - state.isLoading, - state.isLoadingOlder, - state.isLoadingNewer, - state.isLatestLoaded, - state.isOldestLoaded, - state.isAtBottom, isComments ) { snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } @@ -101,24 +131,38 @@ fun ChatContentList( } .distinctUntilChanged() .collect { (firstVisibleIndex, lastVisibleIndex) -> - if (state.isLoading || state.isLoadingOlder || state.isLoadingNewer) return@collect + val currentState = latestState + if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return@collect val nearStart = firstVisibleIndex <= 2 val nearEnd = lastVisibleIndex >= (groupedMessages.size - 3).coerceAtLeast(0) + val now = SystemClock.uptimeMillis() if (isComments) { if (!scrollState.isScrollInProgress) return@collect - if (nearStart && !state.isOldestLoaded) { - component.loadMore() - } else if (nearEnd && !state.isLatestLoaded) { - component.loadNewer() + if (nearStart && !currentState.isOldestLoaded) { + if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastOlderLoadTriggerUptimeMs = now + component.loadMore() + } + } else if (nearEnd && !currentState.isLatestLoaded) { + if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastNewerLoadTriggerUptimeMs = now + component.loadNewer() + } } } else { - if (nearEnd && !state.isOldestLoaded) { - component.loadMore() - } else if (nearStart && !state.isAtBottom && !state.isLatestLoaded) { - component.loadNewer() + if (nearEnd && !currentState.isOldestLoaded) { + if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastOlderLoadTriggerUptimeMs = now + component.loadMore() + } + } else if (nearStart && !currentState.isAtBottom && !currentState.isLatestLoaded) { + if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastNewerLoadTriggerUptimeMs = now + component.loadNewer() + } } } } @@ -154,24 +198,22 @@ fun ChatContentList( } if (isComments) { - if (state.rootMessage != null) { - item(key = "root_header") { - RootMessageSection( - state, - component, - onPhotoClick, - onPhotoDownload, - onVideoClick, - onDocumentClick, - onAudioClick, - onMessageOptionsClick, - onGoToReply, - onViaBotClick, - toProfile, - downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + item(key = "root_header") { + RootMessageSection( + state, + component, + onPhotoClick, + onPhotoDownload, + onVideoClick, + onDocumentClick, + onAudioClick, + onMessageOptionsClick, + onGoToReply, + onViaBotClick, + toProfile, + downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) } itemsIndexed( @@ -228,25 +270,6 @@ fun ChatContentList( ) } } - if (state.rootMessage != null) { - item(key = "root_header") { - RootMessageSection( - state, - component, - onPhotoClick, - onPhotoDownload, - onVideoClick, - onDocumentClick, - onAudioClick, - onMessageOptionsClick, - onGoToReply, - onViaBotClick, - toProfile, - downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } - } itemsIndexed( items = groupedMessages, key = { _, item -> @@ -494,6 +517,7 @@ private fun MessageBubbleSwitcher( isAnyViewerOpen: Boolean = false ) { val isChannel = state.isChannel && state.currentTopicId == null + val isTopicClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed?: false when (item) { is GroupedMessageItem.Single -> { @@ -591,6 +615,8 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, + canReply = state.canWrite && !isSelectionMode, + onReplySwipe = { component.onReplyMessage(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, downloadUtils = downloadUtils, @@ -694,7 +720,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -762,7 +788,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -874,7 +900,8 @@ private fun RootMessageSection( onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, onClosePoll = { component.onClosePoll(it) }, - toProfile = toProfile, swipeEnabled = false, + toProfile = toProfile, + swipeEnabled = false, onViaBotClick = onViaBotClick, onInstantViewClick = { component.onOpenInstantView(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, @@ -906,6 +933,38 @@ private fun isItemSelected(item: GroupedMessageItem, selectedIds: Set): Bo } } +internal fun chatContentLeadingItemsCount( + isComments: Boolean, + showNavPadding: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isAtBottom: Boolean, + hasMessages: Boolean +): Int { + return if (isComments) { + val loadingOlderTop = if (isLoadingOlder && hasMessages) 1 else 0 + loadingOlderTop + 1 // root header + } else { + val navPadding = if (showNavPadding) 1 else 0 + val loadingNewerBottom = if (isLoadingNewer && !isAtBottom && hasMessages) 1 else 0 + navPadding + loadingNewerBottom + } +} + +internal fun groupedIndexToLazyIndex( + groupedIndex: Int, + leadingItemsCount: Int +): Int { + return groupedIndex + leadingItemsCount +} + +internal fun lazyIndexToGroupedIndex( + lazyIndex: Int, + leadingItemsCount: Int +): Int { + return lazyIndex - leadingItemsCount +} + private fun handlePhotoClick( msg: MessageModel, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt index ec150579..c0036798 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt @@ -38,22 +38,28 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.TopicModel +import org.monogram.domain.models.UserModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults @@ -64,10 +70,42 @@ import org.monogram.presentation.features.chats.currentChat.components.pins.Pinn import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown +@Immutable +data class ChatContentTopBarUiState( + val currentTopicId: Long?, + val rootMessage: MessageModel?, + val isGroup: Boolean, + val isChannel: Boolean, + val isAdmin: Boolean, + val permissions: ChatPermissionsModel, + val otherUser: UserModel?, + val currentUser: UserModel?, + val typingAction: String?, + val memberCount: Int, + val onlineCount: Int, + val topics: List, + val chatTitle: String, + val chatAvatar: String?, + val chatPersonalAvatar: String?, + val chatEmojiStatus: String?, + val isOnline: Boolean, + val isVerified: Boolean, + val isSponsor: Boolean, + val isWhitelistedInAdBlock: Boolean, + val isInstalledFromGooglePlay: Boolean, + val isMuted: Boolean, + val isSearchActive: Boolean, + val searchQuery: String, + val pinnedMessage: MessageModel?, + val pinnedMessageCount: Int +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatContentTopBar( - state: ChatComponent.State, + topBarState: ChatContentTopBarUiState, + selectedCount: Int, + canRevokeSelected: Boolean, component: ChatComponent, contentAlpha: Float, onBack: () -> Unit, @@ -77,28 +115,21 @@ fun ChatContentTopBar( ) { val localClipboard = LocalClipboard.current val isAdBlockEnabled by component.appPreferences.isAdBlockEnabled.collectAsState() - val isSelectionMode = state.selectedMessageIds.isNotEmpty() - val isMainChat = state.currentTopicId == null && state.rootMessage == null - val canClearOrDeleteChat = (!state.isGroup && !state.isChannel) || state.isAdmin - val otherUserId = state.otherUser?.id - val canReportChat = state.isGroup || state.isChannel || - (otherUserId != null && state.currentUser?.id != otherUserId) + val isSelectionMode = selectedCount > 0 + val isMainChat = topBarState.currentTopicId == null && topBarState.rootMessage == null + val canClearOrDeleteChat = (!topBarState.isGroup && !topBarState.isChannel) || topBarState.isAdmin + val otherUserId = topBarState.otherUser?.id + val canReportChat = topBarState.isGroup || topBarState.isChannel || + (otherUserId != null && topBarState.currentUser?.id != otherUserId) - var showDeleteSheet by remember { mutableStateOf(false) } + var showDeleteSheet by rememberSaveable { mutableStateOf(false) } var pendingUnpinMessage by remember { mutableStateOf(null) } val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() if (showDeleteSheet) { - val selectedMessages = remember(state.messages, state.selectedMessageIds) { - state.messages.filter { it.id in state.selectedMessageIds } - } - val canRevoke = remember(selectedMessages) { - selectedMessages.any { it.canBeDeletedForAllUsers } - } - DeleteMessagesSheet( - count = state.selectedMessageIds.size, - canRevoke = canRevoke, + count = selectedCount, + canRevoke = canRevokeSelected, onDismiss = { showDeleteSheet = false }, onDelete = { revoke -> component.onDeleteSelectedMessages(revoke = revoke) @@ -136,7 +167,7 @@ fun ChatContentTopBar( if (selectionMode) { TopAppBar( title = { - Text(text = "${state.selectedMessageIds.size}") + Text(text = "$selectedCount") }, navigationIcon = { IconButton(onClick = { component.onClearSelection() }, shapes = iconButtonShapes) { @@ -165,7 +196,7 @@ fun ChatContentTopBar( contentDescription = stringResource(R.string.menu_delete) ) } - var showMenu by remember { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } Box { IconButton(onClick = { onOpenMenu() @@ -258,98 +289,103 @@ fun ChatContentTopBar( } ) } else { - val formattedUserStatus = rememberUserStatusText(state.otherUser) + val formattedUserStatus = rememberUserStatusText(topBarState.otherUser) val statusText = when { - state.typingAction != null -> state.typingAction - state.isChannel -> stringResource( + topBarState.typingAction != null -> topBarState.typingAction + topBarState.isChannel -> stringResource( R.string.subscribers_count_format, - state.memberCount + topBarState.memberCount ) - state.isGroup -> { - if (state.onlineCount > 0) { + topBarState.isGroup -> { + val members = pluralStringResource( + R.plurals.members_count_format, + topBarState.memberCount, + topBarState.memberCount + ) + if (topBarState.onlineCount > 0) { stringResource( R.string.members_online_count_format, - stringResource(R.string.members_count_format, state.memberCount), - state.onlineCount + members, + topBarState.onlineCount ) } else { - stringResource(R.string.members_count_format, state.memberCount) + members } } else -> formattedUserStatus } - val currentTopic = remember(state.currentTopicId, state.topics) { - if (state.currentTopicId != null) { - state.topics.find { it.id.toLong() == state.currentTopicId } + val currentTopic = remember(topBarState.currentTopicId, topBarState.topics) { + if (topBarState.currentTopicId != null) { + topBarState.topics.find { it.id.toLong() == topBarState.currentTopicId } } else null } val threadTitle = stringResource(R.string.thread_title) - val title = remember(currentTopic, state.rootMessage, state.chatTitle, threadTitle) { + val title = remember(currentTopic, topBarState.rootMessage, topBarState.chatTitle, threadTitle) { when { currentTopic != null -> currentTopic.name - state.rootMessage != null -> threadTitle - else -> state.chatTitle + topBarState.rootMessage != null -> threadTitle + else -> topBarState.chatTitle } } val topicEmojiPath = currentTopic?.iconCustomEmojiPath ChatTopBar( title = title, - avatarPath = state.chatAvatar, - emojiStatusPath = state.chatEmojiStatus, + avatarPath = topBarState.chatAvatar, + emojiStatusPath = topBarState.chatEmojiStatus, statusText = statusText, - isOnline = state.isOnline, - isVerified = state.isVerified, - isSponsor = state.isSponsor, + isOnline = topBarState.isOnline, + isVerified = topBarState.isVerified, + isSponsor = topBarState.isSponsor, onBack = onBack, onMenu = onOpenMenu, onClick = { component.onProfileClicked() }, topicEmojiPath = topicEmojiPath, - isChannel = state.isChannel, - isWhitelistedInAdBlock = state.isWhitelistedInAdBlock, - onToggleAdBlockWhitelist = if (isMainChat && state.isChannel && isAdBlockEnabled && !state.isInstalledFromGooglePlay) { + isChannel = topBarState.isChannel, + isWhitelistedInAdBlock = topBarState.isWhitelistedInAdBlock, + onToggleAdBlockWhitelist = if (isMainChat && topBarState.isChannel && isAdBlockEnabled && !topBarState.isInstalledFromGooglePlay) { { - if (state.isWhitelistedInAdBlock) { + if (topBarState.isWhitelistedInAdBlock) { component.onRemoveFromAdBlockWhitelist() } else { component.onAddToAdBlockWhitelist() } } } else null, - isMuted = state.isMuted, + isMuted = topBarState.isMuted, onToggleMute = component::onToggleMute, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, + isSearchActive = topBarState.isSearchActive, + searchQuery = topBarState.searchQuery, onSearchToggle = component::onSearchToggle, onSearchQueryChange = component::onSearchQueryChange, onClearHistory = if (isMainChat && canClearOrDeleteChat) component::onClearHistory else null, onDeleteChat = if (isMainChat && canClearOrDeleteChat) component::onDeleteChat else null, onReport = if (isMainChat && canReportChat) component::onReport else null, - onCopyLink = if (isMainChat && (state.isGroup || state.isChannel)) { + onCopyLink = if (isMainChat && (topBarState.isGroup || topBarState.isChannel)) { { component.onCopyLink(localClipboard) } } else null, - onManageMembers = if (isMainChat && state.isGroup && (state.isAdmin || state.permissions.canInviteUsers)) { + onManageMembers = if (isMainChat && topBarState.isGroup && (topBarState.isAdmin || topBarState.permissions.canInviteUsers)) { { component.onProfileClicked() } } else null, showBack = showBack, - personalAvatarPath = state.chatPersonalAvatar + personalAvatarPath = topBarState.chatPersonalAvatar ) } } - val showPinned = state.pinnedMessage != null && !isSelectionMode && state.rootMessage == null + val showPinned = topBarState.pinnedMessage != null && !isSelectionMode && topBarState.rootMessage == null AnimatedVisibility( visible = showPinned, enter = expandVertically(), exit = shrinkVertically() ) { - state.pinnedMessage?.let { pinned -> + topBarState.pinnedMessage?.let { pinned -> PinnedMessageBar( message = pinned, - count = state.pinnedMessageCount, + count = topBarState.pinnedMessageCount, onClose = { pendingUnpinMessage = pinned }, onClick = { onPinnedMessageClick(pinned) }, onShowAll = { component.onShowAllPinnedMessages() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt index df669f6a..a345eef7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt @@ -24,6 +24,18 @@ fun ChatContentViewers( component: ChatComponent, localClipboard: Clipboard ) { + InstantViewOverlay(state, component) + YouTubeOverlay(state, component, localClipboard) + MiniAppOverlay(state, component) + WebViewOverlay(state, component) + ImagesOverlay(state, component, localClipboard) + VideoOverlay(state, component, localClipboard) + InvoiceOverlay(state, component) + MiniAppTOSOverlay(state, component) +} + +@Composable +private fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -39,7 +51,14 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun YouTubeOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { AnimatedVisibility( visible = state.youtubeUrl != null, enter = fadeIn(), @@ -70,7 +89,10 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.miniAppUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -88,7 +110,10 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.webViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -101,7 +126,14 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun ImagesOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { AnimatedVisibility( visible = state.fullScreenImages != null, enter = fadeIn() + scaleIn(initialScale = 0.9f), @@ -189,7 +221,7 @@ fun ChatContentViewers( onDelete = { path -> val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } if (msg?.isOutgoing == true) { - component.onDeleteMessage(msg) + component.onDeleteMessage(msg, true) component.onDismissImages() } }, @@ -204,60 +236,67 @@ fun ChatContentViewers( } else { path } - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(link)) - ) - }, - onCopyText = { path -> - val msg = state.messages.find { - when (val content = it.content) { - is MessageContent.Photo -> content.path == path - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - else -> false - } - } - val textToCopy = when (val content = msg?.content) { - is MessageContent.Photo -> content.caption - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> "" - } - if (textToCopy.isNotEmpty()) { localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(textToCopy)) + ClipData.newPlainText("", AnnotatedString(link)) ) - } - }, - onVideoClick = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } - if (msg != null) { - val mediaPath = msg.displayMediaPathForViewer() ?: path - component.onOpenVideo( - path = mediaPath, - messageId = msg.id, - caption = when (val content = msg.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> null + }, + onCopyText = { path -> + val msg = state.messages.find { + when (val content = it.content) { + is MessageContent.Photo -> content.path == path + is MessageContent.Video -> content.path == path + is MessageContent.Gif -> content.path == path + else -> false } - ) - } else { - component.onOpenVideo(path = path) - } - }, - captions = state.fullScreenCaptions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, - downloadUtils = component.downloadUtils - ) + } + val textToCopy = when (val content = msg?.content) { + is MessageContent.Photo -> content.caption + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onVideoClick = { path -> + val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + if (msg != null) { + val mediaPath = msg.displayMediaPathForViewer() ?: path + component.onOpenVideo( + path = mediaPath, + messageId = msg.id, + caption = when (val content = msg.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> null + } + ) + } else { + component.onOpenVideo(path = path, messageId = null, caption = null) + } + }, + captions = state.fullScreenCaptions, + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = component.downloadUtils + ) } } } +} +@Composable +private fun VideoOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { val videoVisible = (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null - + AnimatedVisibility( visible = videoVisible, enter = fadeIn() + scaleIn(initialScale = 0.9f), @@ -301,7 +340,7 @@ fun ChatContentViewers( it.content.matchesDisplayPath(videoPath) } if (deleteMsg?.isOutgoing == true) { - component.onDeleteMessage(deleteMsg) + component.onDeleteMessage(deleteMsg, true) component.onDismissVideo() } }, @@ -351,7 +390,10 @@ fun ChatContentViewers( } } } +} +@Composable +private fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) { if (state.invoiceSlug != null || state.invoiceMessageId != null) { InvoiceDialog( slug = state.invoiceSlug, @@ -362,7 +404,10 @@ fun ChatContentViewers( onDismiss = { status -> component.onDismissInvoice(status) } ) } +} +@Composable +private fun MiniAppTOSOverlay(state: ChatComponent.State, component: ChatComponent) { MiniAppTOSBottomSheet( isVisible = state.showMiniAppTOS, onDismiss = { component.onDismissMiniAppTOS() }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index 880c4137..9b63400b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -2,7 +2,13 @@ package org.monogram.presentation.features.chats.currentChat.chatContent import android.content.ClipData import android.util.Log -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.Clipboard @@ -26,7 +32,7 @@ import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.stickers.ui.menu.MessageOptionsMenu -import java.util.* +import java.util.Locale @Composable fun ChatMessageOptionsMenu( @@ -210,11 +216,20 @@ fun ChatMessageOptionsMenu( val canCopyLink = state.isGroup || state.isChannel val canPinMessages = state.isAdmin || state.permissions.canPinMessages val isPremiumUser = state.currentUser?.isPremium == true - val canUseTelegramSummary = isPremiumUser && canSummarize(selectedMessage) - val canUseTelegramTranslator = isPremiumUser && canTranslate(selectedMessage) + val canUseTelegramSummary = + isPremiumUser && !canRestoreOriginalText && canSummarize(selectedMessage) + val canUseTelegramTranslator = + isPremiumUser && !canRestoreOriginalText && canTranslate(selectedMessage) val cocoonAttribution = stringResource(R.string.telegram_cocoon_attribution) + val menuMessage = remember(selectedMessage, canRestoreOriginalText) { + if (canRestoreOriginalText && selectedMessage.canBeForwarded) { + selectedMessage.copy(canBeForwarded = false) + } else { + selectedMessage + } + } MessageOptionsMenu( - message = messageWithReadDate, + message = menuMessage.copy(readDate = messageWithReadDate.readDate), canWrite = state.canWrite, canPinMessages = canPinMessages, isPinned = selectedMessage.id == state.pinnedMessage?.id, @@ -303,17 +318,11 @@ fun ChatMessageOptionsMenu( onDismiss() }, onSaveToDownloads = { - val path = when (val content = selectedMessage.content) { - is MessageContent.Photo -> content.path - is MessageContent.Video -> content.path - is MessageContent.Gif -> content.path - is MessageContent.Document -> content.path - is MessageContent.Voice -> content.path - is MessageContent.VideoNote -> content.path - else -> null - } - path?.let { - downloadUtils.saveFileToDownloads(it) + val paths = collectDownloadPaths(selectedMessage, groupedMessages) + if (paths.size == 1) { + downloadUtils.saveFileToDownloads(paths.first()) + } else if (paths.isNotEmpty()) { + downloadUtils.saveFilesToDownloads(paths) } onDismiss() }, @@ -420,7 +429,7 @@ fun ChatMessageOptionsMenu( private fun String.withCocoonAttribution(attribution: String): String { val cleanText = trim() - return "$cleanText\n\n$attribution" + return "$cleanText\n\n-----\n$attribution" } private const val TELEGRAM_AI_LOG_TAG = "TelegramAiActions" @@ -449,3 +458,37 @@ private fun canSummarize(message: MessageModel): Boolean { else -> false } } + +private fun collectDownloadPaths( + selectedMessage: MessageModel, + groupedMessages: List +): List { + val albumMessages = selectedMessage.mediaAlbumId + .takeIf { it != 0L } + ?.let { albumId -> + groupedMessages + .filterIsInstance() + .firstOrNull { album -> + album.albumId == albumId || album.messages.any { it.id == selectedMessage.id } + } + ?.messages + } + + val sourceMessages = albumMessages ?: listOf(selectedMessage) + return sourceMessages + .mapNotNull { extractDownloadPath(it.content) } + .distinct() +} + +private fun extractDownloadPath(content: MessageContent): String? { + return when (content) { + is MessageContent.Photo -> content.path + is MessageContent.Video -> content.path + is MessageContent.Gif -> content.path + is MessageContent.Document -> content.path + is MessageContent.Audio -> content.path + is MessageContent.Voice -> content.path + is MessageContent.VideoNote -> content.path + else -> null + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt index 10ff0110..5bddb4e0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -28,8 +29,8 @@ fun ReportChatDialog( onDismiss: () -> Unit, onReasonSelected: (String) -> Unit ) { - var showCustomInput by remember { mutableStateOf(false) } - var customText by remember { mutableStateOf("") } + var showCustomInput by rememberSaveable { mutableStateOf(false) } + var customText by rememberSaveable { mutableStateOf("") } val reasons = listOf( ReportReason("spam", stringResource(R.string.report_reason_spam), stringResource(R.string.report_reason_spam_description), Icons.Outlined.Report), @@ -99,7 +100,10 @@ fun ReportChatDialog( .fillMaxWidth() .padding(bottom = 24.dp) ) { - itemsIndexed(reasons) { _, reason -> + itemsIndexed( + items = reasons, + key = { _, reason -> reason.id } + ) { _, reason -> if (reason.id == "custom") { HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt index ec49902a..9bc9dac0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt @@ -16,8 +16,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.ChatPermissionsModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -40,6 +42,9 @@ fun RestrictUserSheet( } } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + if (showDatePicker) { val datePickerState = rememberDatePickerState( initialSelectedDateMillis = if (untilDate != 0) untilDate.toLong() * 1000 else System.currentTimeMillis() @@ -184,7 +189,7 @@ fun RestrictUserSheet( Text(stringResource(R.string.restrict_until), style = MaterialTheme.typography.bodyLarge) Text( text = if (untilDate == 0) stringResource(R.string.restrict_forever) else SimpleDateFormat( - "MMM d, yyyy, HH:mm", + "MMM d, yyyy, $timeFormat", Locale.getDefault() ).format(Date(untilDate.toLong() * 1000)), style = MaterialTheme.typography.bodySmall, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt index e388b822..a41e74c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.components @@ -7,11 +7,18 @@ import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.graphics.SurfaceTexture -import android.media.* +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.media.MediaMuxer +import android.media.MediaRecorder import android.opengl.EGL14 import android.opengl.EGLExt import android.opengl.GLES11Ext import android.opengl.GLES20 +import android.os.Build import android.util.Size import android.view.Surface import android.widget.Toast @@ -25,15 +32,40 @@ import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.* +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent import androidx.camera.view.PreviewView import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -41,10 +73,24 @@ import androidx.compose.material.icons.filled.Cameraswitch import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator -import androidx.compose.runtime.* +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -70,7 +116,7 @@ import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer -import java.util.* +import java.util.Locale private const val EGL_RECORDABLE_ANDROID = 0x3142 @@ -148,6 +194,7 @@ fun NativeCircularCameraContent( var lensFacing by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) } var camera by remember { mutableStateOf(null) } + val sessionAudioRecorder = remember { SessionAudioRecorder(context) } var currentZoomRatio by remember { mutableFloatStateOf(1f) } var minZoom by remember { mutableFloatStateOf(1f) } var maxZoom by remember { mutableFloatStateOf(1f) } @@ -195,7 +242,12 @@ fun NativeCircularCameraContent( val videoCapture = remember { val recorder = Recorder.Builder() - .setQualitySelector(QualitySelector.from(Quality.SD)) + .setQualitySelector( + QualitySelector.fromOrderedList( + listOf(Quality.HD, Quality.SD), + FallbackStrategy.lowerQualityOrHigherThan(Quality.SD) + ) + ) .build() val resolutionSelector = ResolutionSelector.Builder() @@ -210,6 +262,7 @@ fun NativeCircularCameraContent( DisposableEffect(Unit) { onDispose { + sessionAudioRecorder.stop(keepFile = false) try { val cameraProvider = ProcessCameraProvider.getInstance(context).get() cameraProvider.unbindAll() @@ -221,6 +274,7 @@ fun NativeCircularCameraContent( fun finishRecording() { if (recordedSegments.isEmpty()) { + sessionAudioRecorder.stop(keepFile = false) isRecording = false recording = null recordingStartMs = 0L @@ -232,6 +286,7 @@ fun NativeCircularCameraContent( recording = null recordingStartMs = 0L elapsedSeconds = 0L + val sessionAudioFile = sessionAudioRecorder.stop(keepFile = true) try { val cameraProvider = ProcessCameraProvider.getInstance(context).get() cameraProvider.unbindAll() @@ -241,7 +296,7 @@ fun NativeCircularCameraContent( val finalFile = File(context.filesDir, "CIRCLE_FULL_${System.currentTimeMillis()}.mp4") try { val segmentsToProcess = ArrayList(recordedSegments) - val transcoder = CircularTranscoder(segmentsToProcess, finalFile) + val transcoder = CircularTranscoder(segmentsToProcess, sessionAudioFile, finalFile) transcoder.start() withContext(Dispatchers.Main) { @@ -259,6 +314,7 @@ fun NativeCircularCameraContent( Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } finally { + sessionAudioFile?.delete() recordedSegments.clear() isProcessing = false } @@ -286,6 +342,7 @@ fun NativeCircularCameraContent( return } + sessionAudioRecorder.stop(keepFile = false) recordedSegments.forEach { it.delete() } recordedSegments.clear() recording = null @@ -297,6 +354,7 @@ fun NativeCircularCameraContent( fun handleSegmentSaved(file: File) { if (shouldDiscardAll) { + sessionAudioRecorder.stop(keepFile = false) file.delete() recordedSegments.forEach { it.delete() } recordedSegments.clear() @@ -321,6 +379,33 @@ fun NativeCircularCameraContent( } } + fun handleSegmentError(error: VideoRecordEvent.Finalize) { + sessionAudioRecorder.stop(keepFile = false) + if (shouldDiscardAll) { + recordedSegments.forEach { it.delete() } + recordedSegments.clear() + shouldDiscardAll = false + isSwitchingCamera = false + pendingResume = false + isRecording = false + recording = null + recordingStartMs = 0L + elapsedSeconds = 0L + onClose() + return + } + + isSwitchingCamera = false + pendingResume = false + isRecording = false + recording = null + recordingStartMs = 0L + elapsedSeconds = 0L + + val message = error.cause?.message ?: "Recording error" + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + LaunchedEffect(lensFacing) { val cameraProvider = ProcessCameraProvider.getInstance(context).get() val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() @@ -328,7 +413,7 @@ fun NativeCircularCameraContent( cameraProvider.unbindAll() val cam = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, videoCapture) camera = cam - cam.cameraInfo.zoomState.observe(lifecycleOwner) { state -> + cam.cameraInfo.zoomState.value?.let { state -> currentZoomRatio = state.zoomRatio minZoom = state.minZoomRatio maxZoom = state.maxZoomRatio @@ -343,7 +428,8 @@ fun NativeCircularCameraContent( context, videoCapture, onStart = { rec -> recording = rec }, - onSegmentSaved = ::handleSegmentSaved + onSegmentSaved = ::handleSegmentSaved, + onSegmentError = ::handleSegmentError ) } } catch (e: Exception) { e.printStackTrace() } @@ -361,6 +447,7 @@ fun NativeCircularCameraContent( detectTransformGestures { _, _, zoom, _ -> camera?.let { cam -> val newZoom = (currentZoomRatio * zoom).coerceIn(minZoom, maxZoom) + currentZoomRatio = newZoom cam.cameraControl.setZoomRatio(newZoom) } } @@ -368,8 +455,13 @@ fun NativeCircularCameraContent( .pointerInput(Unit) { detectTapGestures { offset -> camera?.let { cam -> - val point = previewView.meteringPointFactory.createPoint(offset.x, offset.y) - cam.cameraControl.startFocusAndMetering(FocusMeteringAction.Builder(point).build()) + val point = + previewView.meteringPointFactory.createPoint(offset.x, offset.y) + cam.cameraControl.startFocusAndMetering( + FocusMeteringAction.Builder( + point + ).build() + ) } } } @@ -410,7 +502,7 @@ fun NativeCircularCameraContent( .fillMaxWidth() .statusBarsPadding() ) { - IconButton(onClick = onClose, enabled = !isRecording && !isProcessing) { + IconButton(onClick = ::cancelAndClose, enabled = !isRecording && !isProcessing) { Icon( Icons.Default.Close, contentDescription = stringResource(R.string.recorder_close_cd), @@ -434,7 +526,11 @@ fun NativeCircularCameraContent( modifier = Modifier .clip(RoundedCornerShape(999.dp)) .background(Color.Black.copy(alpha = 0.45f)) - .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(999.dp)) + .border( + 1.dp, + Color.White.copy(alpha = 0.2f), + RoundedCornerShape(999.dp) + ) .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -532,13 +628,24 @@ fun NativeCircularCameraContent( detectTapGestures(onTap = { if (isProcessing || isSwitchingCamera) return@detectTapGestures if (!isRecording) { + if (!sessionAudioRecorder.isRecording) { + val audioStarted = sessionAudioRecorder.start() + if (!audioStarted) { + Toast.makeText( + context, + "Unable to start audio capture", + Toast.LENGTH_SHORT + ).show() + } + } isRecording = true recordingStartMs = System.currentTimeMillis() startNativeSegment( context, videoCapture, onStart = { r -> recording = r }, - onSegmentSaved = ::handleSegmentSaved + onSegmentSaved = ::handleSegmentSaved, + onSegmentError = ::handleSegmentError ) } else { recording?.stop() @@ -634,19 +741,21 @@ fun startNativeSegment( context: Context, videoCapture: VideoCapture, onStart: (Recording) -> Unit, - onSegmentSaved: (File) -> Unit + onSegmentSaved: (File) -> Unit, + onSegmentError: (VideoRecordEvent.Finalize) -> Unit ) { val tempFile = File(context.cacheDir, "segment_${System.currentTimeMillis()}.mp4") val outputOptions = FileOutputOptions.Builder(tempFile).build() val recording = videoCapture.output .prepareRecording(context, outputOptions) - .withAudioEnabled() .start(ContextCompat.getMainExecutor(context)) { event -> if (event is VideoRecordEvent.Finalize) { if (!event.hasError()) { onSegmentSaved(tempFile) } else { + tempFile.delete() + onSegmentError(event) if (event.cause != null) event.cause?.printStackTrace() } } @@ -654,11 +763,86 @@ fun startNativeSegment( onStart(recording) } -class CircularTranscoder(private val inputFiles: List, private val outputFile: File) { - private val OUTPUT_WIDTH = 384 - private val OUTPUT_HEIGHT = 384 - private val OUTPUT_BIT_RATE = 1_800_000 - private val FRAME_RATE = 60 +private class SessionAudioRecorder(private val context: Context) { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + var isRecording: Boolean = false + private set + + @Suppress("DEPRECATION") + fun start(): Boolean { + if (isRecording) return true + + return try { + val file = File(context.cacheDir, "circle_audio_${System.currentTimeMillis()}.m4a") + outputFile = file + + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + recorder?.apply { + setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(1) + setAudioEncodingBitRate(96_000) + setAudioSamplingRate(48_000) + setOutputFile(file.absolutePath) + prepare() + start() + } + + isRecording = true + true + } catch (e: Exception) { + e.printStackTrace() + stop(keepFile = false) + false + } + } + + fun stop(keepFile: Boolean): File? { + val file = outputFile + recorder?.let { mediaRecorder -> + try { + mediaRecorder.stop() + } catch (e: Exception) { + e.printStackTrace() + } finally { + try { + mediaRecorder.release() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + recorder = null + isRecording = false + + if (!keepFile) { + file?.delete() + outputFile = null + return null + } + + outputFile = null + return if (file?.exists() == true) file else null + } +} + +class CircularTranscoder( + private val inputFiles: List, + private val inputAudioFile: File?, + private val outputFile: File +) { + private val OUTPUT_WIDTH = 640 + private val OUTPUT_HEIGHT = 640 + private val OUTPUT_BIT_RATE = 1_920_000 + private val FRAME_RATE = 30 private var muxerAudioTrackIndex = -1 private var muxerVideoTrackIndex = -1 @@ -670,9 +854,9 @@ class CircularTranscoder(private val inputFiles: List, private val outputF val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) try { - processVideoSequence(muxer) + val totalVideoDurationUs = processVideoSequence(muxer) if (muxerStarted) { - processAudioSequence(muxer) + processAudioSequence(muxer, totalVideoDurationUs) } } finally { try { @@ -682,7 +866,7 @@ class CircularTranscoder(private val inputFiles: List, private val outputF } } - private fun processVideoSequence(muxer: MediaMuxer) { + private fun processVideoSequence(muxer: MediaMuxer): Long { val outputFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, OUTPUT_WIDTH, OUTPUT_HEIGHT) outputFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, OUTPUT_BIT_RATE) @@ -814,14 +998,19 @@ class CircularTranscoder(private val inputFiles: List, private val outputF val newFormat = encoder.outputFormat muxerVideoTrackIndex = muxer.addTrack(newFormat) - val audioEx = MediaExtractor() - audioEx.setDataSource(inputFiles[0].absolutePath) - val at = selectTrack(audioEx, "audio/") - if (at >= 0) { - val af = audioEx.getTrackFormat(at) - muxerAudioTrackIndex = muxer.addTrack(af) + if (inputAudioFile?.exists() == true) { + val audioEx = MediaExtractor() + try { + audioEx.setDataSource(inputAudioFile.absolutePath) + val at = selectTrack(audioEx, "audio/") + if (at >= 0) { + val af = audioEx.getTrackFormat(at) + muxerAudioTrackIndex = muxer.addTrack(af) + } + } finally { + audioEx.release() + } } - audioEx.release() muxer.start() muxerStarted = true @@ -869,43 +1058,46 @@ class CircularTranscoder(private val inputFiles: List, private val outputF encoder.stop(); encoder.release() inputSurface.release() + return totalDurationUs } - private fun processAudioSequence(muxer: MediaMuxer) { - if (muxerAudioTrackIndex < 0) return + private fun processAudioSequence(muxer: MediaMuxer, totalVideoDurationUs: Long) { + val audioFile = inputAudioFile ?: return + if (muxerAudioTrackIndex < 0 || !audioFile.exists()) return - var totalDurationUs = 0L + val extractor = MediaExtractor() val buffer = ByteBuffer.allocate(256 * 1024) val bufferInfo = MediaCodec.BufferInfo() - for (file in inputFiles) { - val extractor = MediaExtractor() - try { - extractor.setDataSource(file.absolutePath) - val trackIndex = selectTrack(extractor, "audio/") - if (trackIndex < 0) continue + try { + extractor.setDataSource(audioFile.absolutePath) + val trackIndex = selectTrack(extractor, "audio/") + if (trackIndex < 0) return - extractor.selectTrack(trackIndex) - var fileLastPts = 0L + extractor.selectTrack(trackIndex) - while (true) { - val chunkSize = extractor.readSampleData(buffer, 0) - if (chunkSize < 0) break + while (true) { + val chunkSize = extractor.readSampleData(buffer, 0) + if (chunkSize < 0) break - bufferInfo.offset = 0 - bufferInfo.size = chunkSize - bufferInfo.flags = extractor.sampleFlags + val originalPts = extractor.sampleTime + if (originalPts < 0) break + if (totalVideoDurationUs > 0 && originalPts > totalVideoDurationUs) { + break + } - val originalPts = extractor.sampleTime - fileLastPts = originalPts - bufferInfo.presentationTimeUs = originalPts + totalDurationUs + bufferInfo.offset = 0 + bufferInfo.size = chunkSize + bufferInfo.flags = extractor.sampleFlags + bufferInfo.presentationTimeUs = originalPts - muxer.writeSampleData(muxerAudioTrackIndex, buffer, bufferInfo) - extractor.advance() - } - totalDurationUs += (fileLastPts + 20000L) - } catch (e: Exception) { e.printStackTrace() } - finally { extractor.release() } + muxer.writeSampleData(muxerAudioTrackIndex, buffer, bufferInfo) + extractor.advance() + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + extractor.release() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt index 91555392..28104ca8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt @@ -1,11 +1,27 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -14,6 +30,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -65,8 +82,14 @@ fun AlbumMessageBubbleContainer( ) { if (messages.isEmpty()) return - val firstMsg = messages.first() - val lastMsg = messages.last() + val orderedMessages = remember(messages) { + messages + .distinctBy { it.id } + .sortedWith(compareBy { it.date }.thenBy { it.id }) + } + + val firstMsg = orderedMessages.first() + val lastMsg = orderedMessages.last() val isOutgoing = firstMsg.isOutgoing val configuration = LocalConfiguration.current @@ -84,11 +107,39 @@ fun AlbumMessageBubbleContainer( } } - val isSameSenderAbove = remember(olderMsg?.senderId, firstMsg.senderId, olderMsg?.date, firstMsg.date) { - olderMsg?.senderId == firstMsg.senderId && !shouldShowDate(firstMsg, olderMsg) + val isSameSenderAbove = remember( + olderMsg?.id, + olderMsg?.senderId, + olderMsg?.senderName, + olderMsg?.senderCustomTitle, + olderMsg?.date, + firstMsg.senderId, + firstMsg.senderName, + firstMsg.senderCustomTitle, + firstMsg.date + ) { + shouldGroupSenderBlock( + current = firstMsg, + neighbor = olderMsg, + dateBreak = olderMsg?.let { shouldShowDate(firstMsg, it) } ?: true + ) } - val isSameSenderBelow = remember(newerMsg?.senderId, lastMsg.senderId, newerMsg?.date, lastMsg.date) { - newerMsg != null && newerMsg.senderId == lastMsg.senderId && !shouldShowDate(newerMsg, lastMsg) + val isSameSenderBelow = remember( + newerMsg?.id, + newerMsg?.senderId, + newerMsg?.senderName, + newerMsg?.senderCustomTitle, + newerMsg?.date, + lastMsg.senderId, + lastMsg.senderName, + lastMsg.senderCustomTitle, + lastMsg.date + ) { + shouldGroupSenderBlock( + current = lastMsg, + neighbor = newerMsg, + dateBreak = newerMsg?.let { shouldShowDate(it, lastMsg) } ?: true + ) } val topSpacing = if (isChannel && !isSameSenderAbove) 12.dp else 2.dp @@ -97,11 +148,21 @@ fun AlbumMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing, bottom = 2.dp) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(lastMsg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -127,119 +188,135 @@ fun AlbumMessageBubbleContainer( verticalAlignment = Alignment.Bottom ) { if (isGroup && !isOutgoing && !isChannel) { - Avatar( - path = firstMsg.senderAvatar, - fallbackPath = firstMsg.senderPersonalAvatar, - name = firstMsg.senderName, - size = 40.dp, - onClick = { toProfile(firstMsg.senderId) } - ) + if (!isSameSenderBelow) { + Avatar( + path = firstMsg.senderAvatar, + fallbackPath = firstMsg.senderPersonalAvatar, + name = firstMsg.senderName, + size = 40.dp, + onClick = { toProfile(firstMsg.senderId) } + ) + } else { + Spacer(modifier = Modifier.width(40.dp)) + } Spacer(modifier = Modifier.width(8.dp)) } - Column( - modifier = Modifier - .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) - .widthIn(max = maxWidth) - .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + Box( + modifier = Modifier.wrapContentSize() + ) { + Column( + modifier = Modifier + .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) + .widthIn(max = maxWidth) + .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + } } + ) { + if (isGroup && !isOutgoing && !isChannel && !isSameSenderAbove) { + Text( + text = firstMsg.senderName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) + ) } - ) { - if (isGroup && !isOutgoing && !isChannel) { - Text( - text = firstMsg.senderName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) - ) - } - if (isChannel) { - ChannelAlbumMessageBubble( - messages = messages, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - fontSize = fontSize, - bubbleRadius = bubbleRadius, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } else { - ChatAlbumMessageBubble( - messages = messages, - isOutgoing = isOutgoing, - isGroup = isGroup, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - toProfile = toProfile, - modifier = Modifier, - fontSize = fontSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + if (isChannel) { + ChannelAlbumMessageBubble( + messages = orderedMessages, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + fontSize = fontSize, + bubbleRadius = bubbleRadius, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } else { + ChatAlbumMessageBubble( + messages = orderedMessages, + isOutgoing = isOutgoing, + isGroup = isGroup, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + toProfile = toProfile, + modifier = Modifier, + fontSize = fontSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - lastMsg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + lastMsg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + ) + } + + MessageViaBotAttribution( + msg = lastMsg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) ) } - MessageViaBotAttribution( - msg = lastMsg, + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth, ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt index bb8d8b91..27b4ad95 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt @@ -6,12 +6,60 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.* +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -19,16 +67,46 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import org.monogram.domain.models.* +import kotlinx.coroutines.delay +import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.camera.CameraScreen import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily -import org.monogram.presentation.features.chats.currentChat.components.inputbar.* +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarComposerSection +import org.monogram.presentation.features.chats.currentChat.components.inputbar.FullScreenEditorSheet +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduledMessagesSheet +import org.monogram.presentation.features.chats.currentChat.components.inputbar.applyMentionSuggestion +import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildEditingMessageTextValue +import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempPath +import org.monogram.presentation.features.chats.currentChat.components.inputbar.declaredPermissions +import org.monogram.presentation.features.chats.currentChat.components.inputbar.extractEntities +import org.monogram.presentation.features.chats.currentChat.components.inputbar.hasAllPermissions +import org.monogram.presentation.features.chats.currentChat.components.inputbar.isInlineBotPrefillText +import org.monogram.presentation.features.chats.currentChat.components.inputbar.parseInlineQueryInput +import org.monogram.presentation.features.chats.currentChat.components.inputbar.rememberVoiceRecorder import org.monogram.presentation.features.gallery.GalleryScreen -import java.util.* +import java.text.DateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.ceil @Immutable data class ChatInputBarState( @@ -38,6 +116,10 @@ data class ChatInputBarState( val pendingMediaPaths: List = emptyList(), val isClosed: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val isAdmin: Boolean = false, val isChannel: Boolean = false, val isBot: Boolean = false, @@ -88,6 +170,12 @@ data class ChatInputBarActions( val onSendScheduledNow: (MessageModel) -> Unit = {}, ) +private enum class InputBarMode { + Composer, + SlowMode, + Restricted +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatInputBar( @@ -101,44 +189,63 @@ fun ChatInputBar( return } - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - - val canWriteText = remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) + val canWriteText by remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) } } - val canSendMedia = remember( + val canSendMedia by remember( state.isChannel, state.isAdmin, state.permissions.canSendPhotos, state.permissions.canSendVideos, - state.permissions.canSendDocuments + state.permissions.canSendDocuments, + state.permissions.canSendAudios ) { - if (state.isChannel) true else (state.isAdmin || (state.permissions.canSendPhotos || state.permissions.canSendVideos || state.permissions.canSendDocuments)) + derivedStateOf { + if (state.isChannel) { + true + } else { + state.isAdmin || + state.permissions.canSendPhotos || + state.permissions.canSendVideos || + state.permissions.canSendDocuments || + state.permissions.canSendAudios + } + } + } + val canSendStickers by remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) } } - val canSendStickers = remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) + val canSendVoice by remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) } } - val canSendVoice = remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) + val canSendVideoNotes by remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) } } + val canSendAnything by remember(canWriteText, canSendMedia, canSendStickers, canSendVoice, canSendVideoNotes) { + derivedStateOf { canWriteText || canSendMedia || canSendStickers || canSendVoice || canSendVideoNotes } + } + + val context = LocalContext.current + val emojiStyle by appPreferences.emojiStyle.collectAsState() + val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - var textValue by remember { mutableStateOf(TextFieldValue(state.draftText)) } - var isStickerMenuVisible by remember { mutableStateOf(false) } + var textValue by rememberSaveable(state.editingMessage?.id, stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(state.draftText)) + } + var isStickerMenuVisible by rememberSaveable { mutableStateOf(false) } var closeStickerMenuWithoutSlide by remember { mutableStateOf(false) } var openStickerMenuAfterKeyboardClosed by remember { mutableStateOf(false) } var openKeyboardAfterStickerMenuClosed by remember { mutableStateOf(false) } - var isVideoMessageMode by remember { mutableStateOf(false) } + var isVideoMessageMode by rememberSaveable { mutableStateOf(false) } var isGifSearchFocused by remember { mutableStateOf(false) } var showGallery by remember { mutableStateOf(false) } var showCamera by remember { mutableStateOf(false) } - var showFullScreenEditor by remember { mutableStateOf(false) } - var showSendOptionsSheet by remember { mutableStateOf(false) } - var showScheduleDatePicker by remember { mutableStateOf(false) } - var showScheduleTimePicker by remember { mutableStateOf(false) } - var pendingScheduleDateMillis by remember { mutableStateOf(null) } - var showScheduledMessagesSheet by remember { mutableStateOf(false) } + var showFullScreenEditor by rememberSaveable { mutableStateOf(false) } + var showSendOptionsSheet by rememberSaveable { mutableStateOf(false) } + var showScheduleDatePicker by rememberSaveable { mutableStateOf(false) } + var showScheduleTimePicker by rememberSaveable { mutableStateOf(false) } + var pendingScheduleDateMillis by rememberSaveable { mutableStateOf(null) } + var showScheduledMessagesSheet by rememberSaveable { mutableStateOf(false) } val knownCustomEmojis = remember { mutableStateMapOf() } @@ -196,24 +303,87 @@ fun ChatInputBar( } } - var lastEditingMessageId by remember { mutableStateOf(null) } + LaunchedEffect(canSendStickers) { + if (!canSendStickers && isStickerMenuVisible) { + isStickerMenuVisible = false + } + } + + LaunchedEffect(canSendVideoNotes, canSendVoice) { + if (!canSendVideoNotes && isVideoMessageMode) { + isVideoMessageMode = false + } + if (!canSendVoice && canSendVideoNotes) { + isVideoMessageMode = true + } + } + + var lastEditingMessageId by rememberSaveable { mutableStateOf(null) } + + var slowModeRemainingSeconds by remember { + mutableIntStateOf(0) + } + LaunchedEffect(state.slowModeDelay, state.slowModeDelayExpiresIn, state.isAdmin) { + slowModeRemainingSeconds = if (!state.isAdmin && state.slowModeDelay > 0) { + ceil(state.slowModeDelayExpiresIn).toInt().coerceAtLeast(0) + } else { + 0 + } + } + LaunchedEffect(slowModeRemainingSeconds, state.slowModeDelay, state.isAdmin) { + if (!state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0) { + delay(1000) + slowModeRemainingSeconds = (slowModeRemainingSeconds - 1).coerceAtLeast(0) + } + } + val isSlowModeActive by remember(state.isAdmin, state.slowModeDelay, slowModeRemainingSeconds) { + derivedStateOf { !state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0 } + } + + fun activateSlowModeCooldown() { + if (!state.isAdmin && state.slowModeDelay > 0) { + slowModeRemainingSeconds = state.slowModeDelay + } + } - val voiceRecorder = rememberVoiceRecorder(onRecordingFinished = actions.onSendVoice) - val maxMessageLength = remember(state.pendingMediaPaths, state.isPremiumUser) { - if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 + val voiceRecorder = rememberVoiceRecorder { path, duration, waveform -> + if (!canSendVoice || isSlowModeActive) return@rememberVoiceRecorder + actions.onSendVoice(path, duration, waveform) + activateSlowModeCooldown() + } + val maxMessageLength by remember(state.pendingMediaPaths, state.isPremiumUser) { + derivedStateOf { if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 } + } + val currentMessageLength by remember(textValue.text) { + derivedStateOf { textValue.text.length } + } + val isOverMessageLimit by remember(currentMessageLength, maxMessageLength) { + derivedStateOf { currentMessageLength > maxMessageLength } } - val currentMessageLength = textValue.text.length - val isOverMessageLimit = currentMessageLength > maxMessageLength val sendWithOptions: (MessageSendOptions) -> Unit = sendWithOptions@{ if (isOverMessageLimit) return@sendWithOptions val isTextEmpty = textValue.text.isBlank() val captionEntities = extractEntities(textValue.annotatedString, knownCustomEmojis) + val isScheduling = it.scheduleDate != null + var sentInstantMessage = false + + val canSendNow = when { + state.pendingMediaPaths.isNotEmpty() && canSendMedia -> true + state.editingMessage != null -> false + canWriteText && !isTextEmpty -> true + else -> false + } + + if (isSlowModeActive && canSendNow && !isScheduling) { + return@sendWithOptions + } if (state.pendingMediaPaths.isNotEmpty() && canSendMedia) { actions.onSendMedia(state.pendingMediaPaths, textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling } else if (state.editingMessage != null && canWriteText) { if (!isTextEmpty) { actions.onSaveEdit(textValue.text, captionEntities) @@ -222,6 +392,11 @@ fun ChatInputBar( actions.onSend(textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling + } + + if (sentInstantMessage) { + activateSlowModeCooldown() } if (it.scheduleDate != null) { @@ -229,12 +404,14 @@ fun ChatInputBar( } } - val filteredCommands = remember(textValue.text, state.botCommands) { - if (textValue.text.startsWith("/")) { - val query = textValue.text.substring(1).lowercase() - state.botCommands.filter { it.command.lowercase().startsWith(query) } - } else { - emptyList() + val filteredCommands by remember(textValue.text, state.botCommands) { + derivedStateOf { + if (textValue.text.startsWith("/")) { + val query = textValue.text.substring(1).lowercase() + state.botCommands.filter { it.command.lowercase().startsWith(query) } + } else { + emptyList() + } } } @@ -421,6 +598,28 @@ fun ChatInputBar( if (granted) showCamera = true } + val inputBarMode by remember( + canSendAnything, + isSlowModeActive, + textValue.text, + state.pendingMediaPaths, + state.editingMessage, + voiceRecorder.isRecording + ) { + derivedStateOf { + when { + !canSendAnything -> InputBarMode.Restricted + isSlowModeActive && + textValue.text.isBlank() && + state.pendingMediaPaths.isEmpty() && + state.editingMessage == null && + !voiceRecorder.isRecording -> InputBarMode.SlowMode + + else -> InputBarMode.Composer + } + } + } + if (showCamera) { CameraScreen( onImageCaptured = { uri -> @@ -434,157 +633,213 @@ fun ChatInputBar( ) } else { Box { - ChatInputBarComposerSection( - editingMessage = state.editingMessage, - replyMessage = state.replyMessage, - pendingMediaPaths = state.pendingMediaPaths, - mentionSuggestions = state.mentionSuggestions, - filteredCommands = filteredCommands, - currentInlineBotUsername = state.currentInlineBotUsername, - isInlineBotLoading = state.isInlineBotLoading, - inlineBotResults = state.inlineBotResults, - isBot = state.isBot, - botMenuButton = state.botMenuButton, - botCommands = state.botCommands, - scheduledMessagesCount = state.scheduledMessages.size, - textValue = textValue, - onTextValueChange = { textValue = it }, - knownCustomEmojis = knownCustomEmojis, - emojiFontFamily = emojiFontFamily, - focusRequester = focusRequester, - canWriteText = canWriteText, - canSendMedia = canSendMedia, - canSendStickers = canSendStickers, - canSendVoice = canSendVoice, - isStickerMenuVisible = isStickerMenuVisible, - closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, - isKeyboardVisible = isKeyboardVisible, - transitionHoldBottomInset = transitionHoldBottomInset, - stickerMenuHeight = stickerMenuHeight, - voiceRecorder = voiceRecorder, - isGifSearchFocused = isGifSearchFocused, - showFullScreenEditor = showFullScreenEditor, - currentMessageLength = currentMessageLength, - maxMessageLength = maxMessageLength, - isOverMessageLimit = isOverMessageLimit, - isVideoMessageMode = isVideoMessageMode, - replyMarkup = state.replyMarkup, - showSendOptionsSheet = showSendOptionsSheet, - stickerRepository = stickerRepository, - onCancelEdit = actions.onCancelEdit, - onCancelReply = actions.onCancelReply, - onCancelMedia = actions.onCancelMedia, - onMediaOrderChange = actions.onMediaOrderChange, - onMediaClick = actions.onMediaClick, - onMentionClick = { user -> - textValue = applyMentionSuggestion(textValue, user) - }, - onMentionQueryClear = { actions.onMentionQueryChange(null) }, - onInlineResultClick = { resultId -> - actions.onSendInlineResult(resultId) - textValue = TextFieldValue("") + AnimatedContent( + targetState = inputBarMode, + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + slideInVertically(animationSpec = tween(220)) { it / 4 }) + .togetherWith( + fadeOut(animationSpec = tween(150)) + slideOutVertically(animationSpec = tween(150)) { it / 4 } + ) }, - onInlineSwitchPmClick = { text -> - state.currentInlineBotUsername?.let { username -> - actions.onInlineSwitchPm(username, text) - } - }, - onLoadMoreInlineResults = actions.onLoadMoreInlineResults, - onCommandClick = { command -> - actions.onSend("/$command", emptyList(), MessageSendOptions()) - textValue = TextFieldValue("") - }, - onAttachClick = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showGallery = true - }, - onStickerMenuToggle = { - if (isStickerMenuVisible) { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = true - closeStickerMenuWithoutSlide = true - isStickerMenuVisible = false - focusRequester.requestFocus() - } else { - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - if (isKeyboardVisible) { - openStickerMenuAfterKeyboardClosed = true + label = "InputBarModeTransition" + ) { mode -> + when (mode) { + InputBarMode.Composer -> ChatInputBarComposerSection( + editingMessage = state.editingMessage, + replyMessage = state.replyMessage, + pendingMediaPaths = state.pendingMediaPaths, + mentionSuggestions = state.mentionSuggestions, + filteredCommands = filteredCommands, + currentInlineBotUsername = state.currentInlineBotUsername, + isInlineBotLoading = state.isInlineBotLoading, + inlineBotResults = state.inlineBotResults, + isBot = state.isBot, + botMenuButton = state.botMenuButton, + botCommands = state.botCommands, + scheduledMessagesCount = state.scheduledMessages.size, + textValue = textValue, + onTextValueChange = { textValue = it }, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + canWriteText = canWriteText, + canSendMedia = canSendMedia, + canPasteMediaFromClipboard = canSendMedia && state.editingMessage == null, + canSendStickers = canSendStickers, + canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, + isStickerMenuVisible = isStickerMenuVisible, + closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, + isKeyboardVisible = isKeyboardVisible, + transitionHoldBottomInset = transitionHoldBottomInset, + stickerMenuHeight = stickerMenuHeight, + voiceRecorder = voiceRecorder, + isGifSearchFocused = isGifSearchFocused, + showFullScreenEditor = showFullScreenEditor, + currentMessageLength = currentMessageLength, + maxMessageLength = maxMessageLength, + isOverMessageLimit = isOverMessageLimit, + isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds, + replyMarkup = state.replyMarkup, + showSendOptionsSheet = showSendOptionsSheet, + stickerRepository = stickerRepository, + onCancelEdit = actions.onCancelEdit, + onCancelReply = actions.onCancelReply, + onCancelMedia = actions.onCancelMedia, + onMediaOrderChange = actions.onMediaOrderChange, + onMediaClick = actions.onMediaClick, + onPasteImages = { uris -> + if (!canSendMedia || state.editingMessage != null) return@ChatInputBarComposerSection + val localPaths = uris.mapNotNull { uri -> + context.copyUriToTempPath(uri) + } + if (localPaths.isNotEmpty()) { + actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) + } + }, + onMentionClick = { user -> + textValue = applyMentionSuggestion(textValue, user) + }, + onMentionQueryClear = { actions.onMentionQueryChange(null) }, + onInlineResultClick = { resultId -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onSendInlineResult(resultId) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onInlineSwitchPmClick = { text -> + state.currentInlineBotUsername?.let { username -> + actions.onInlineSwitchPm(username, text) + } + }, + onLoadMoreInlineResults = actions.onLoadMoreInlineResults, + onCommandClick = { command -> + if (isSlowModeActive || !canWriteText) return@ChatInputBarComposerSection + actions.onSend("/$command", emptyList(), MessageSendOptions()) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onAttachClick = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showGallery = true + }, + onStickerMenuToggle = { + if (isStickerMenuVisible) { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = true + closeStickerMenuWithoutSlide = true + isStickerMenuVisible = false + focusRequester.requestFocus() + } else { + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + if (isKeyboardVisible) { + openStickerMenuAfterKeyboardClosed = true + hideKeyboardAndClearFocus() + } else { + openStickerMenuAfterKeyboardClosed = false + isStickerMenuVisible = true + focusManager.clearFocus() + } + } + }, + onShowBotCommands = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false hideKeyboardAndClearFocus() - } else { + actions.onShowBotCommands() + }, + onOpenMiniApp = actions.onOpenMiniApp, + onInputFocus = { openStickerMenuAfterKeyboardClosed = false - isStickerMenuVisible = true - focusManager.clearFocus() + openKeyboardAfterStickerMenuClosed = false + if (isStickerMenuVisible) { + closeStickerMenuWithoutSlide = true + } + isStickerMenuVisible = false + }, + onOpenFullScreenEditor = { showFullScreenEditor = true }, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true + }, + onSendWithOptions = sendWithOptions, + onShowSendOptionsMenu = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showSendOptionsSheet = true + actions.onRefreshScheduledMessages() + }, + onCameraClick = { + hideKeyboardAndClearFocus() + actions.onCameraClick() + }, + onVideoModeToggle = { + if (canSendVideoNotes) { + isVideoMessageMode = !isVideoMessageMode + } + }, + onVoiceStart = { + hideKeyboardAndClearFocus() + voiceRecorder.startRecording() + }, + onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, + onVoiceLock = { voiceRecorder.lockRecording() }, + onSendSilent = { + showSendOptionsSheet = false + sendWithOptions(MessageSendOptions(silent = true)) + }, + onScheduleMessage = { + showSendOptionsSheet = false + pendingScheduleDateMillis = null + showScheduleDatePicker = true + }, + onOpenScheduledMessagesFromPopup = { + showSendOptionsSheet = false + showScheduledMessagesSheet = true + actions.onRefreshScheduledMessages() + }, + onDismissSendOptions = { showSendOptionsSheet = false }, + onStickerClick = { stickerPath -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onStickerClick(stickerPath) + activateSlowModeCooldown() + }, + onGifClick = { gif -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onGifClick(gif) + activateSlowModeCooldown() + }, + onGifSearchFocusedChange = { isGifSearchFocused = it }, + onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick + ) + + InputBarMode.SlowMode -> SlowModeInputBar( + remainingSeconds = slowModeRemainingSeconds, + scheduledMessagesCount = state.scheduledMessages.size, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true } - } - }, - onShowBotCommands = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - actions.onShowBotCommands() - }, - onOpenMiniApp = actions.onOpenMiniApp, - onInputFocus = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - if (isStickerMenuVisible) { - closeStickerMenuWithoutSlide = true - } - isStickerMenuVisible = false - }, - onOpenFullScreenEditor = { showFullScreenEditor = true }, - onOpenScheduledMessages = { - actions.onRefreshScheduledMessages() - showScheduledMessagesSheet = true - }, - onSendWithOptions = sendWithOptions, - onShowSendOptionsMenu = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showSendOptionsSheet = true - actions.onRefreshScheduledMessages() - }, - onCameraClick = { - hideKeyboardAndClearFocus() - actions.onCameraClick() - }, - onVideoModeToggle = { isVideoMessageMode = !isVideoMessageMode }, - onVoiceStart = { - hideKeyboardAndClearFocus() - voiceRecorder.startRecording() - }, - onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, - onVoiceLock = { voiceRecorder.lockRecording() }, - onSendSilent = { - showSendOptionsSheet = false - sendWithOptions(MessageSendOptions(silent = true)) - }, - onScheduleMessage = { - showSendOptionsSheet = false - pendingScheduleDateMillis = null - showScheduleDatePicker = true - }, - onOpenScheduledMessagesFromPopup = { - showSendOptionsSheet = false - showScheduledMessagesSheet = true - actions.onRefreshScheduledMessages() - }, - onDismissSendOptions = { showSendOptionsSheet = false }, - onStickerClick = actions.onStickerClick, - onGifClick = actions.onGifClick, - onGifSearchFocusedChange = { isGifSearchFocused = it }, - onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick - ) + ) + + InputBarMode.Restricted -> RestrictedInputBar( + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate + ) + } + } FullScreenEditorSheet( visible = showFullScreenEditor, @@ -746,3 +1001,152 @@ private fun ClosedTopicBar() { ) } } + +@Composable +private fun SlowModeInputBar( + remainingSeconds: Int, + scheduledMessagesCount: Int, + onOpenScheduledMessages: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.slow_mode_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + AnimatedContent( + targetState = remainingSeconds.coerceAtLeast(0), + transitionSpec = { + (fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)) { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(120)) + slideOutVertically(animationSpec = tween(120)) { -it / 2 } + ) + }, + label = "SlowModeRemaining" + ) { seconds -> + Text( + text = formatSlowModeDuration(seconds), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (scheduledMessagesCount > 0) { + IconButton(onClick = onOpenScheduledMessages) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = stringResource(R.string.action_scheduled_messages), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun RestrictedInputBar( + isCurrentUserRestricted: Boolean, + restrictedUntilDate: Int +) { + val restrictionDetails = remember(isCurrentUserRestricted, restrictedUntilDate) { + if (!isCurrentUserRestricted) { + null + } else if (restrictedUntilDate <= 0) { + RestrictionDetails.Permanent + } else { + RestrictionDetails.Until(formatRestrictedUntilDate(restrictedUntilDate)) + } + } + + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.input_error_not_allowed), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + AnimatedVisibility( + visible = restrictionDetails != null, + enter = fadeIn(animationSpec = tween(220)) + expandVertically(animationSpec = tween(220)), + exit = fadeOut(animationSpec = tween(140)) + shrinkVertically(animationSpec = tween(140)) + ) { + val detailsText = when (restrictionDetails) { + is RestrictionDetails.Until -> stringResource( + R.string.logs_restricted_until, + restrictionDetails.value + ) + + RestrictionDetails.Permanent -> stringResource(R.string.logs_restricted_permanently) + null -> "" + } + + Text( + text = detailsText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +private sealed interface RestrictionDetails { + data class Until(val value: String) : RestrictionDetails + data object Permanent : RestrictionDetails +} + +private fun formatRestrictedUntilDate(epochSeconds: Int): String { + val formatter = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + return formatter.format(Date(epochSeconds.toLong() * 1000L)) +} + +private fun formatSlowModeDuration(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index 317f96ab..bbf0c691 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -1,24 +1,63 @@ package org.monogram.presentation.features.chats.currentChat.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.PlaylistAddCheck import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CleaningServices +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Report +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.res.stringResource @@ -28,10 +67,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.window.core.layout.WindowSizeClass import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.TypingDots +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown @@ -66,13 +107,25 @@ fun ChatTopBar( onCopyLink: (() -> Unit)? = null, onManageMembers: (() -> Unit)? = null, showBack: Boolean = true, - personalAvatarPath: String? = null + personalAvatarPath: String? = null, + isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND + ) && LocalTabletInterfaceEnabled.current ) { - var showMenu by remember { mutableStateOf(false) } - var showClearHistorySheet by remember { mutableStateOf(false) } - var showDeleteChatSheet by remember { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } + var showClearHistorySheet by rememberSaveable { mutableStateOf(false) } + var showDeleteChatSheet by rememberSaveable { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxWidth()) { + val windowInsets = if (isTablet) WindowInsets(0, 0, 0, 0) else WindowInsets.statusBars + val topInsetModifier = if (isTablet) { + Modifier + .fillMaxWidth() + .padding(top = 10.dp) + } else { + Modifier.fillMaxWidth() + } + + Box(modifier = topInsetModifier) { AnimatedContent( targetState = isSearchActive, transitionSpec = { @@ -82,7 +135,7 @@ fun ChatTopBar( ) { searching -> if (searching) { TopAppBar( - windowInsets = WindowInsets.statusBars, + windowInsets = windowInsets, title = { TextField( value = searchQuery, @@ -117,7 +170,7 @@ fun ChatTopBar( ) } else { TopAppBar( - windowInsets = WindowInsets.statusBars, + windowInsets = windowInsets, title = { Row( verticalAlignment = Alignment.CenterVertically, @@ -196,11 +249,21 @@ fun ChatTopBar( label = "StatusAnimation" ) { targetStatus -> if (!targetStatus.isNullOrEmpty()) { - val isTyping = targetStatus.contains("печатает") || - targetStatus.contains("записывает") || - targetStatus.contains("отправляет") || - targetStatus.contains("выбирает") || - targetStatus.contains("играет") + val normalizedStatus = targetStatus.lowercase() + val typingTokens = listOf( + stringResource(R.string.typing_typing), + stringResource(R.string.typing_recording_video), + stringResource(R.string.typing_recording_voice), + stringResource(R.string.typing_uploading_photo), + stringResource(R.string.typing_uploading_video), + stringResource(R.string.typing_uploading_document), + stringResource(R.string.typing_choosing_sticker), + stringResource(R.string.typing_playing_game), + stringResource(R.string.typing_multi_typing) + ).map { it.lowercase() } + val isTyping = typingTokens.any { token -> + token.isNotBlank() && normalizedStatus.contains(token) + } Row(verticalAlignment = Alignment.Bottom) { Text( @@ -280,7 +343,7 @@ fun ChatTopBar( ), modifier = Modifier .align(Alignment.TopEnd) - .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(windowInsets) .padding(top = 56.dp, end = 16.dp) ) { ViewerSettingsDropdown { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt new file mode 100644 index 00000000..9cf04035 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt @@ -0,0 +1,142 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +const val REPLY_TRIGGER_FRACTION = 0.35f +const val MAX_SWIPE_FRACTION = 0.7f +const val ICON_OFFSET_FRACTION = 0.1f + +@Composable +fun FastReplyIndicator( + modifier: Modifier = Modifier, + dragOffsetX: Animatable, + isOutgoing: Boolean = false, + inverseOffset: Boolean = false, + maxWidth: Dp, +) { + val triggerDistance = maxWidth.value * REPLY_TRIGGER_FRACTION + val dragged = (-dragOffsetX.value).coerceAtLeast(0f) + val progress = ((dragged - 48.dp.value) / (triggerDistance - 48.dp.value)) + .coerceIn(0f, 1f) + + val iconAlpha by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 150) + ) + val iconScale by animateFloatAsState( + targetValue = lerp(0.5f, 1f, progress), + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + ) + val iconOffset = maxWidth * ICON_OFFSET_FRACTION + + if (dragged > 48.dp.value) { + Box( + modifier = modifier + .offset(x = if (isOutgoing) iconOffset else maxWidth) + .size(30.dp) + .graphicsLayer { + translationX = when { + isOutgoing -> (-dragOffsetX.value - iconOffset.value) * 0.5f + inverseOffset -> -iconOffset.value + else -> iconOffset.value + } + scaleX = iconScale + scaleY = iconScale + alpha = iconAlpha + } + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +fun Modifier.fastReplyPointer( + canReply: Boolean, + dragOffsetX: Animatable, + scope: CoroutineScope, + onReplySwipe: () -> Unit, + maxWidth: Float +): Modifier = pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (down.pressed) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48.dp.toPx()) { + isDragging = true + } else if (totalDragX > 48.dp.toPx()) { + break + } + } + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-(maxWidth * MAX_SWIPE_FRACTION), 0f)) + } + } + } + + if (isDragging) { + if (-dragOffsetX.value >= maxWidth * REPLY_TRIGGER_FRACTION) { + onReplySwipe() + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt index e03b6b36..bd774a8a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt @@ -2,13 +2,31 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -18,6 +36,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -28,7 +47,21 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.AudioMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.ContactMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.DocumentMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.GifMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.LocationMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution +import org.monogram.presentation.features.chats.currentChat.components.chats.PhotoMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.PollMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView +import org.monogram.presentation.features.chats.currentChat.components.chats.StickerMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.TextMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VenueMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VideoMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VideoNoteBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VoiceMessageBubble @Composable fun MessageBubbleContainer( @@ -89,11 +122,39 @@ fun MessageBubbleContainer( } val isOutgoing = msg.isOutgoing - val isSameSenderAbove = remember(olderMsg?.senderId, msg.senderId, olderMsg?.date, msg.date) { - olderMsg?.senderId == msg.senderId && !shouldShowDate(msg, olderMsg) + val isSameSenderAbove = remember( + olderMsg?.id, + olderMsg?.senderId, + olderMsg?.senderName, + olderMsg?.senderCustomTitle, + olderMsg?.date, + msg.senderId, + msg.senderName, + msg.senderCustomTitle, + msg.date + ) { + shouldGroupSenderBlock( + current = msg, + neighbor = olderMsg, + dateBreak = olderMsg?.let { shouldShowDate(msg, it) } ?: true + ) } - val isSameSenderBelow = remember(newerMsg?.senderId, msg.senderId, newerMsg?.date, msg.date) { - newerMsg != null && newerMsg.senderId == msg.senderId && !shouldShowDate(newerMsg, msg) + val isSameSenderBelow = remember( + newerMsg?.id, + newerMsg?.senderId, + newerMsg?.senderName, + newerMsg?.senderCustomTitle, + newerMsg?.date, + msg.senderId, + msg.senderName, + msg.senderCustomTitle, + msg.date + ) { + shouldGroupSenderBlock( + current = msg, + neighbor = newerMsg, + dateBreak = newerMsg?.let { shouldShowDate(it, msg) } ?: true + ) } val topSpacing = if (!isSameSenderAbove) 8.dp else 2.dp @@ -114,12 +175,22 @@ fun MessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .background(animatedColor.value, RoundedCornerShape(12.dp)) .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -152,70 +223,82 @@ fun MessageBubbleContainer( toProfile = toProfile ) - Column( - modifier = Modifier - .width(IntrinsicSize.Max) - .widthIn(max = maxWidth) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - }, - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + Box( + modifier = Modifier.wrapContentSize() ) { - MessageContentSelector( - msg = msg, - newerMsg = newerMsg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - isGroup = isGroup, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - stSize = stSize, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoDownloadFiles = autoDownloadFiles, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - showLinkPreviews = showLinkPreviews, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onReplyClick = onReplyClick, - onGoToReply = onGoToReply, - onReactionClick = onReactionClick, - onStickerClick = onStickerClick, - onPollOptionClick = onPollOptionClick, - onRetractVote = onRetractVote, - onShowVoters = onShowVoters, - onClosePoll = onClosePoll, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - toProfile = toProfile, - bubblePosition = bubblePosition, - bubbleSize = bubbleSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(max = maxWidth) + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(msg.id, bubblePosition, bubbleSize) + } + }, + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + ) { + MessageContentSelector( + msg = msg, + newerMsg = newerMsg, + isOutgoing = isOutgoing, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + isGroup = isGroup, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stSize = stSize, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + showLinkPreviews = showLinkPreviews, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onReplyClick = onReplyClick, + onGoToReply = onGoToReply, + onReactionClick = onReactionClick, + onStickerClick = onStickerClick, + onPollOptionClick = onPollOptionClick, + onRetractVote = onRetractVote, + onShowVoters = onShowVoters, + onClosePoll = onClosePoll, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + toProfile = toProfile, + bubblePosition = bubblePosition, + bubbleSize = bubbleSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) - MessageReplyMarkup( - msg = msg, - onReplyMarkupButtonClick = onReplyMarkupButtonClick - ) + MessageReplyMarkup( + msg = msg, + onReplyMarkupButtonClick = onReplyMarkupButtonClick + ) - MessageViaBotAttribution( - msg = msg, + MessageViaBotAttribution( + msg = msg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + ) + } + + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt new file mode 100644 index 00000000..56522ed8 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt @@ -0,0 +1,16 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import org.monogram.domain.models.MessageModel + +internal fun shouldGroupSenderBlock( + current: MessageModel, + neighbor: MessageModel?, + dateBreak: Boolean +): Boolean { + if (neighbor == null) return false + if (current.senderId <= 0L || neighbor.senderId <= 0L) return false + if (current.senderId != neighbor.senderId) return false + if (current.senderName != neighbor.senderName) return false + if (current.senderCustomTitle != neighbor.senderCustomTitle) return false + return !dateBreak +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt index 299902af..fec1af4c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt @@ -2,7 +2,14 @@ package org.monogram.presentation.features.chats.currentChat.components.channels import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -10,10 +17,18 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -25,11 +40,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.formatFileSize +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelAlbumMessageBubble( @@ -62,7 +86,7 @@ fun ChannelAlbumMessageBubble( ) { if (messages.isEmpty()) return - val context = LocalContext.current + LocalContext.current val uniqueMessages = remember(messages) { messages.distinct() } val isDocumentAlbum = remember(uniqueMessages) { uniqueMessages.all { it.content is MessageContent.Document } } val isAudioAlbum = remember(uniqueMessages) { uniqueMessages.all { it.content is MessageContent.Audio } } @@ -157,7 +181,10 @@ fun ChannelAlbumMessageBubble( } } - val formattedTime = remember(lastMsg.date) { formatTime(context, lastMsg.date) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + + val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date, timeFormat) } val revealedSpoilers = remember { mutableStateListOf() } var bubblePosition by remember { mutableStateOf(Offset.Zero) } @@ -228,6 +255,7 @@ fun ChannelAlbumMessageBubble( MessageText( text = finalAnnotatedString, + rawText = caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -415,7 +443,7 @@ fun ChannelDocumentAlbumBubble( overflow = TextOverflow.Ellipsis ) Text( - text = formatFileSize(content.size), + text = formatFileSize(content.size, content.isDownloading, content.downloadProgress), style = MaterialTheme.typography.labelSmall, color = timeColor ) @@ -436,6 +464,7 @@ fun ChannelDocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -477,7 +506,7 @@ fun ChannelDocumentAlbumBubble( } MessageReactionsView( - reactions = firstMsg.reactions, + reactions = lastMsg.reactions, onReactionClick = onReactionClick, modifier = Modifier.padding(top = 2.dp) ) @@ -618,7 +647,7 @@ fun ChannelAudioAlbumBubble( overflow = TextOverflow.Ellipsis ) Text( - text = content.performer.ifEmpty { formatFileSize(content.size) }, + text = content.performer.ifEmpty { formatFileSize(content.size, content.isDownloading, content.downloadProgress) }, style = MaterialTheme.typography.labelSmall, color = timeColor, maxLines = 1, @@ -641,6 +670,7 @@ fun ChannelAudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -682,7 +712,7 @@ fun ChannelAudioAlbumBubble( } MessageReactionsView( - reactions = firstMsg.reactions, + reactions = lastMsg.reactions, onReactionClick = onReactionClick, modifier = Modifier.padding(top = 2.dp) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt index de3f2ab3..23b54576 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt @@ -1,21 +1,46 @@ package org.monogram.presentation.features.chats.currentChat.components.channels -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -32,15 +57,24 @@ import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelGifMessageBubble( @@ -84,6 +118,9 @@ fun ChannelGifMessageBubble( var gifPosition by remember { mutableStateOf(Offset.Zero) } val revealedSpoilers = remember { mutableStateListOf() } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { @@ -234,7 +271,10 @@ fun ChannelGifMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -277,7 +317,10 @@ fun ChannelGifMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -299,7 +342,7 @@ fun ChannelGifMessageBubble( } } Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White ) @@ -353,6 +396,7 @@ fun ChannelGifMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -393,7 +437,7 @@ fun ChannelGifMessageBubble( } } Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt index 1c742a7d..79bae263 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -27,7 +28,9 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate +import org.monogram.presentation.features.chats.currentChat.components.FastReplyIndicator import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.fastReplyPointer @Composable fun ChannelMessageBubbleContainer( @@ -70,8 +73,10 @@ fun ChannelMessageBubbleContainer( showComments: Boolean = true, toProfile: (Long) -> Unit = {}, onViaBotClick: (String) -> Unit = {}, + canReply: Boolean = true, + onReplySwipe: (MessageModel) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, ) { val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp @@ -104,12 +109,22 @@ fun ChannelMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { androidx.compose.animation.core.Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .background(animatedColor.value, RoundedCornerShape(12.dp)) .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -134,268 +149,279 @@ fun ChannelMessageBubbleContainer( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Bottom ) { - Column( - modifier = Modifier - .padding(horizontal = 8.dp) - .widthIn(max = maxWidth) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - } + Box( + modifier = Modifier.wrapContentSize() ) { - when (val content = msg.content) { - is MessageContent.Text -> { - ChannelTextMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - showLinkPreviews = showLinkPreviews, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - onClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - onLongClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth() - ) - } + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .widthIn(max = maxWidth) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(msg.id, bubblePosition, bubbleSize) + } + } + ) { + when (val content = msg.content) { + is MessageContent.Text -> { + ChannelTextMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + showLinkPreviews = showLinkPreviews, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + onClick = { offset -> + onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + }, + onLongClick = { offset -> + onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth() + ) + } - is MessageContent.Photo -> { - ChannelPhotoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils - ) - } + is MessageContent.Photo -> { + ChannelPhotoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils + ) + } - is MessageContent.Video -> { - ChannelVideoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayVideos = autoplayVideos, - onVideoClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Video -> { + ChannelVideoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoplayVideos = autoplayVideos, + onVideoClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Document -> { - DocumentMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onDocumentClick = onDocumentClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Document -> { + DocumentMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onDocumentClick = onDocumentClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Audio -> { - AudioMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Audio -> { + AudioMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Gif -> { - ChannelGifMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayGifs = autoplayGifs, - onGifClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Gif -> { + ChannelGifMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoplayGifs = autoplayGifs, + onGifClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Sticker -> { - StickerMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - stickerSize = stickerSize, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onStickerClick = { onStickerClick(content.setId) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - toProfile = toProfile - ) + is MessageContent.Sticker -> { + StickerMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + stickerSize = stickerSize, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onStickerClick = { onStickerClick(content.setId) }, + onLongClick = { + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + (bubbleSize.toSize() / 2f).toOffset() + ) + }, + toProfile = toProfile + ) + } + + is MessageContent.Poll -> { + ChannelPollMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + onOptionClick = { onPollOptionClick(msg.id, it) }, + onRetractVote = { onRetractVote(msg.id) }, + onShowVoters = { onShowVoters(msg.id, it) }, + onClosePoll = { onClosePoll(msg.id) }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile + ) + } + + else -> {} } - is MessageContent.Poll -> { - ChannelPollMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - onOptionClick = { onPollOptionClick(msg.id, it) }, - onRetractVote = { onRetractVote(msg.id) }, - onShowVoters = { onShowVoters(msg.id, it) }, - onClosePoll = { onClosePoll(msg.id) }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile + msg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } ) } - else -> {} - } - - msg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } + MessageViaBotAttribution( + msg = msg, + isOutgoing = msg.isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(Alignment.Start) ) } - MessageViaBotAttribution( - msg = msg, - isOutgoing = msg.isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(Alignment.Start) + FastReplyIndicator( + modifier = Modifier.align(Alignment.CenterStart), + dragOffsetX = dragOffsetX, + inverseOffset = isLandscape, + maxWidth = maxWidth, ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt index 78f54012..42e9961a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt @@ -5,8 +5,8 @@ import org.monogram.presentation.R import java.text.SimpleDateFormat import java.util.* -fun formatTime(context: Context, ts: Int): String = - SimpleDateFormat(context.getString(R.string.format_time), Locale.getDefault()).format(Date(ts.toLong() * 1000)) +fun formatTime(ts: Int, timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(ts.toLong() * 1000)) fun formatViews(context: Context, views: Int): String { return when { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt index ce99f3c9..a649a8ca 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt @@ -5,13 +5,25 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -36,7 +48,15 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelPhotoMessageBubble( @@ -256,7 +276,10 @@ fun ChannelPhotoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, msg.isOutgoing, Color.White) @@ -284,6 +307,7 @@ fun ChannelPhotoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt index e05cfa21..a891f8be 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt @@ -1,8 +1,19 @@ package org.monogram.presentation.features.chats.currentChat.components.channels -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -23,10 +34,19 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.core.util.DateFormatManager +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.LinkPreview +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.isBigEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelTextMessageBubble( @@ -63,6 +83,9 @@ fun ChannelTextMessageBubble( bottomEnd = if (showComments && msg.canGetMessageThread) 4.dp else cornerRadius ) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val revealedSpoilers = remember { mutableStateListOf() } Column( @@ -106,6 +129,7 @@ fun ChannelTextMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.text, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = finalFontSize.sp, @@ -158,9 +182,8 @@ fun ChannelTextMessageBubble( Spacer(modifier = Modifier.width(8.dp)) } } - Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt index aec89204..8960a6bc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt @@ -301,7 +301,10 @@ fun ChannelVideoMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -349,7 +352,10 @@ fun ChannelVideoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, msg.isOutgoing, Color.White) @@ -377,6 +383,7 @@ fun ChannelVideoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt index 3d413cce..0ebe1e73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt @@ -2,17 +2,36 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -155,6 +174,7 @@ fun AudioMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -264,7 +284,7 @@ fun AudioRow( overflow = TextOverflow.Ellipsis ) Text( - text = content.performer.ifEmpty { formatFileSize(content.size) }, + text = content.performer.ifEmpty { formatFileSize(content.size, content.isDownloading, content.downloadProgress) }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), maxLines = 1, @@ -341,6 +361,12 @@ fun AudioAlbumBubble( MessageSenderName(lastMsg, toProfile = toProfile) } + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { ReplyContent( @@ -380,6 +406,7 @@ fun AudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -522,6 +549,7 @@ fun ChannelAudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt index 332d085c..28f0025f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt @@ -1,11 +1,22 @@ package org.monogram.presentation.features.chats.currentChat.components.chats -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -15,9 +26,11 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic @@ -122,6 +135,9 @@ fun ChatAlbumMessageBubble( ) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val captionMsg = remember(uniqueMessages) { uniqueMessages.firstOrNull { val content = it.content @@ -150,7 +166,7 @@ fun ChatAlbumMessageBubble( } val lastMsg = uniqueMessages.last() - val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date) } + val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date, timeFormat) } val revealedSpoilers = remember { mutableStateListOf() } var bubblePosition by remember { mutableStateOf(Offset.Zero) } @@ -164,8 +180,20 @@ fun ChatAlbumMessageBubble( modifier = Modifier.clip(bubbleShape) ) { Column { + if (isGroup && !isOutgoing && !isSameSenderAbove) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { + MessageSenderName(lastMsg, toProfile = toProfile) + } + } + + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> - Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { ReplyContent( replyToMsg = reply, isOutgoing = isOutgoing, @@ -214,6 +242,7 @@ fun ChatAlbumMessageBubble( MessageText( text = finalAnnotatedString, + rawText = caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -232,17 +261,15 @@ fun ChatAlbumMessageBubble( onLongClick = { offset -> onLongClick(bubblePosition + offset) } ) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - ChatTimestampInfo( - time = formattedTime, - isRead = lastMsg.isRead, + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata( + msg = lastMsg, isOutgoing = isOutgoing, - sendingState = lastMsg.sendingState, - color = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.6f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) + contentColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt index c89d6842..ce0db2fb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt @@ -1,20 +1,39 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.components.chats import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -178,6 +197,7 @@ fun DocumentMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -288,7 +308,7 @@ fun DocumentRow( overflow = TextOverflow.Ellipsis ) Text( - text = formatFileSize(content.size), + text = formatFileSize(content.size, content.isDownloading, content.downloadProgress), style = MaterialTheme.typography.labelSmall, color = timeColor ) @@ -363,6 +383,12 @@ fun DocumentAlbumBubble( MessageSenderName(lastMsg, toProfile = toProfile) } + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { ReplyContent( @@ -403,6 +429,7 @@ fun DocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -546,6 +573,7 @@ fun ChannelDocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt index 7a85ad4a..71f29111 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt @@ -33,8 +33,10 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression @@ -73,6 +75,9 @@ fun GifMessageBubble( val smallCorner = 4.dp val tailCorner = 2.dp + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + var stablePath by remember(msg.id) { mutableStateOf(content.path) } !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { @@ -169,7 +174,10 @@ fun GifMessageBubble( .heightIn(min = 160.dp, max = 360.dp) .aspectRatio( if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) + (content.width.toFloat() / content.height.toFloat()).coerceIn( + 0.5f, + 2f + ) else 1f ) .clipToBounds() @@ -242,7 +250,10 @@ fun GifMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -304,7 +315,10 @@ fun GifMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -318,7 +332,7 @@ fun GifMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White ) @@ -359,6 +373,7 @@ fun GifMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -391,7 +406,7 @@ fun GifMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = timeColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt index ef908270..f5e6b436 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt @@ -2,7 +2,6 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import android.content.ClipData import android.os.Build -import android.util.Log import android.widget.Toast import androidx.compose.animation.core.withInfiniteAnimationFrameMillis import androidx.compose.foundation.gestures.detectTapGestures @@ -33,6 +32,7 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.mod @Composable fun MessageText( text: AnnotatedString, + rawText: String = text.text, inlineContent: Map, style: TextStyle, modifier: Modifier = Modifier, @@ -70,10 +70,14 @@ fun MessageText( ) } else { var lastOffset = 0 + val displayTextLength = text.length blockEntities.forEach { entity -> - if (entity.offset > lastOffset) { - val subText = text.subSequence(lastOffset, entity.offset) + val safeLastOffset = lastOffset.coerceIn(0, displayTextLength) + val safeEntityStart = entity.offset.coerceIn(0, displayTextLength) + + if (safeEntityStart > safeLastOffset) { + val subText = text.subSequence(safeLastOffset, safeEntityStart) if (subText.text.isNotBlank()) { DefaultTextRender( text = subText, @@ -93,16 +97,21 @@ fun MessageText( } TextBlocks( - text = text.text, + text = rawText, entity = entity, isOutgoing = isOutgoing, ) - lastOffset = entity.offset + entity.length + val safeEntityEnd = (entity.offset.toLong() + entity.length.toLong()) + .coerceAtLeast(entity.offset.toLong()) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt() + lastOffset = maxOf(lastOffset, safeEntityEnd) } - if (lastOffset < text.length) { - val subText = text.subSequence(lastOffset, text.length) + val safeLastOffset = lastOffset.coerceIn(0, displayTextLength) + if (safeLastOffset < displayTextLength) { + val subText = text.subSequence(safeLastOffset, displayTextLength) if (subText.text.isNotBlank()) { DefaultTextRender( text = subText, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt index 8c40848a..d64f798b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt @@ -1,13 +1,30 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import android.content.Context -import androidx.compose.animation.* -import androidx.compose.animation.core.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,16 +42,19 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.EmojiStyle import org.monogram.presentation.features.chats.currentChat.components.channels.formatViews import java.io.File import java.text.BreakIterator import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import kotlin.math.log10 import kotlin.math.pow @@ -42,24 +62,34 @@ val LocalLinkHandler = staticCompositionLocalOf<(String) -> Unit> { { _ -> } } -fun formatTime(ts: Int): String = - SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(ts.toLong() * 1000)) +fun formatTime(ts: Int, timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(ts.toLong() * 1000)) fun formatDuration(seconds: Int): String { val m = seconds / 60 val s = seconds % 60 return String.format(Locale.getDefault(), "%02d:%02d", m, s) } -fun formatFileSize(size: Long): String { - if (size <= 0) return "0 B" +fun formatFileSize(size: Long, isDownloading: Boolean, downloadProgress: Float): String { val units = arrayOf("B", "KB", "MB", "GB", "TB") - val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt() - return String.format( - Locale.getDefault(), - "%.1f %s", - size / 1024.0.pow(digitGroups.toDouble()), - units[digitGroups] - ) + + fun format(value: Double): String { + if (value <= 0) return "0 B" + val digitGroups = (log10(value) / log10(1024.0)).toInt() + return String.format( + Locale.US, + "%.1f %s", + value / 1024.0.pow(digitGroups.toDouble()), + units[digitGroups] + ) + } + + return if (isDownloading) { + val downloaded = size * downloadProgress + "${format(downloaded.toDouble())} / ${format(size.toDouble())}" + } else { + format(size.toDouble()) + } } fun getEmojiFontFileName(style: EmojiStyle): String? = when (style) { @@ -129,8 +159,12 @@ fun isBigEmoji(text: String, entities: List): Boolean { if (emojiEntities.size == 1) { val entity = emojiEntities[0] - val textBefore = text.substring(0, entity.offset) - val textAfter = text.substring(entity.offset + entity.length) + val safeStart = entity.offset.coerceIn(0, text.length) + val safeEnd = (entity.offset.toLong() + entity.length.toLong()) + .coerceIn(safeStart.toLong(), text.length.toLong()) + .toInt() + val textBefore = text.substring(0, safeStart) + val textAfter = text.substring(safeEnd) return textBefore.trim().isEmpty() && textAfter.trim().isEmpty() } @@ -226,8 +260,10 @@ fun MessageMetadata( tint = contentColor ) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = contentColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt index 6166c0bd..6188fa20 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt @@ -5,12 +5,31 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -183,7 +202,10 @@ fun PhotoMessageBubble( .heightIn(min = 160.dp, max = 320.dp) .aspectRatio( if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) + (content.width.toFloat() / content.height.toFloat()).coerceIn( + 0.5f, + 2f + ) else 1f ) .clipToBounds() @@ -294,7 +316,10 @@ fun PhotoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(12.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, isOutgoing, Color.White) @@ -325,6 +350,7 @@ fun PhotoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt index 3cfd3d40..3d0cd133 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt @@ -28,8 +28,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager @Composable fun PollMessageBubble( @@ -444,6 +446,9 @@ private fun PollFooter( ) { val metaColor = contentColor.copy(alpha = 0.65f) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Row( modifier = Modifier .fillMaxWidth() @@ -461,7 +466,7 @@ private fun PollFooter( Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = formatTime(date), + text = formatTime(date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = metaColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt index 44d5861f..868f2b1e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt @@ -83,7 +83,10 @@ fun PollVotersSheet( LazyColumn( modifier = Modifier.fillMaxWidth() ) { - itemsIndexed(voters) { index, user -> + itemsIndexed( + items = voters, + key = { _, user -> user.id } + ) { index, user -> VoterItem( user = user, onClick = { onUserClick(user.id) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt index 0103eeda..a32897a0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.util.UnstableApi +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.StickerModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.presentation.features.stickers.ui.view.StickerSkeleton import java.io.File @@ -44,6 +46,9 @@ fun StickerMessageBubble( toProfile: (Long) -> Unit = {}, modifier: Modifier = Modifier ) { + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Column( modifier = modifier, horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start @@ -146,7 +151,7 @@ fun StickerMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White, ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt index 9b3bee5d..2c8bb8f4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt @@ -1,9 +1,24 @@ package org.monogram.presentation.features.chats.currentChat.components.chats -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -19,8 +34,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager @Composable @@ -49,6 +66,9 @@ fun TextMessageBubble( val smallCorner = (bubbleRadius / 4f).coerceAtLeast(4f).dp val tailCorner = 2.dp + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val bubbleShape = remember(isOutgoing, isSameSenderAbove, isSameSenderBelow, cornerRadius, smallCorner) { RoundedCornerShape( topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, @@ -102,7 +122,6 @@ fun TextMessageBubble( ) } - val inlineContent = rememberMessageInlineContent(content.entities, fontSize) val finalAnnotatedString = buildAnnotatedMessageTextWithEmoji( text = content.text, entities = content.entities, @@ -116,7 +135,7 @@ fun TextMessageBubble( val finalFontSize = if (isBigEmoji) fontSize * 5f else fontSize AnimatedContent( - targetState = finalAnnotatedString, + targetState = Triple(finalAnnotatedString, content.text, content.entities), transitionSpec = { (fadeIn(animationSpec = tween(240, easing = FastOutSlowInEasing)) + scaleIn(initialScale = 0.97f, animationSpec = tween(240, easing = FastOutSlowInEasing)) + @@ -128,10 +147,12 @@ fun TextMessageBubble( ) }, label = "TextEditAnimation" - ) { targetText -> + ) { (targetText, targetRawText, targetEntities) -> + val inlineContent = rememberMessageInlineContent(targetEntities, fontSize) MessageText( text = targetText, - entities = content.entities, + rawText = targetRawText, + entities = targetEntities, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = finalFontSize.sp, @@ -176,9 +197,8 @@ fun TextMessageBubble( ) Spacer(modifier = Modifier.width(4.dp)) } - Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = timeColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt index e30c3d73..07aa4aa5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt @@ -5,7 +5,15 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -14,8 +22,21 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.rounded.Stream -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -385,7 +406,10 @@ fun VideoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(12.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, isOutgoing, Color.White) @@ -416,6 +440,7 @@ fun VideoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt index 4fdcd024..105584e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt @@ -48,8 +48,10 @@ import androidx.media3.ui.PlayerView import coil3.compose.rememberAsyncImagePainter import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getMimeType import org.monogram.presentation.features.stickers.ui.view.shimmerEffect import java.io.File @@ -73,6 +75,9 @@ fun VideoNoteBubble( val size = 260.dp var notePosition by remember { mutableStateOf(Offset.Zero) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Column( modifier = modifier, horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start @@ -337,7 +342,7 @@ fun VideoNoteBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = Color.White ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt index c9a4ed52..5d605045 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt @@ -7,7 +7,14 @@ import org.monogram.domain.models.MessageEntityType * Gets text part for current [MessageEntity] **/ internal infix fun String.blockFor(entity: MessageEntity): String = - this.substring(entity.offset, entity.offset + entity.length) + safeSubstring(entity.offset, entity.offset.toLong() + entity.length.toLong()) + +private fun String.safeSubstring(start: Int, end: Long): String { + if (isEmpty()) return "" + val safeStart = start.coerceIn(0, length) + val safeEnd = end.coerceIn(safeStart.toLong(), length.toLong()).toInt() + return substring(safeStart, safeEnd) +} /** * Checks if [MessageEntityType] is block element diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index 327658fd..82f5abbe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -1,22 +1,57 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import org.monogram.domain.models.* +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandSuggestions @@ -24,6 +59,7 @@ import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu @Composable fun ChatInputBarComposerSection( + modifier: Modifier = Modifier, editingMessage: MessageModel?, replyMessage: MessageModel?, pendingMediaPaths: List, @@ -43,8 +79,10 @@ fun ChatInputBarComposerSection( focusRequester: FocusRequester, canWriteText: Boolean, canSendMedia: Boolean, + canPasteMediaFromClipboard: Boolean, canSendStickers: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, isStickerMenuVisible: Boolean, closeStickerMenuWithoutSlide: Boolean, isKeyboardVisible: Boolean, @@ -57,14 +95,18 @@ fun ChatInputBarComposerSection( maxMessageLength: Int, isOverMessageLimit: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, replyMarkup: ReplyMarkupModel?, showSendOptionsSheet: Boolean, stickerRepository: StickerRepository, + isTablet: Boolean = false, onCancelEdit: () -> Unit, onCancelReply: () -> Unit, onCancelMedia: () -> Unit, onMediaOrderChange: (List) -> Unit, onMediaClick: (String) -> Unit, + onPasteImages: (List) -> Unit, onMentionClick: (UserModel) -> Unit, onMentionQueryClear: () -> Unit, onInlineResultClick: (String) -> Unit, @@ -94,7 +136,12 @@ fun ChatInputBarComposerSection( onGifSearchFocusedChange: (Boolean) -> Unit, onReplyMarkupButtonClick: (KeyboardButtonModel) -> Unit ) { - Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shape = if (isTablet) RoundedCornerShape(16.dp) else RectangleShape + ) { Column( modifier = Modifier .fillMaxWidth() @@ -162,12 +209,12 @@ fun ChatInputBarComposerSection( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom + verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility( visible = !voiceRecorder.isRecording, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally() + enter = fadeIn(tween(250)) + expandHorizontally(tween(250)), + exit = fadeOut(tween(200)) + shrinkHorizontally(tween(200)) ) { InputBarLeadingIcons( editingMessage = editingMessage, @@ -219,6 +266,8 @@ fun ChatInputBarComposerSection( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + canPasteMediaFromClipboard = canPasteMediaFromClipboard, + onPasteImages = onPasteImages, onFocus = onInputFocus, onOpenFullScreenEditor = onOpenFullScreenEditor, modifier = Modifier.fillMaxWidth() @@ -248,8 +297,11 @@ fun ChatInputBarComposerSection( isOverCharLimit = isOverMessageLimit, canWriteText = canWriteText, canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, canSendMedia = canSendMedia, isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds, onSendWithOptions = onSendWithOptions, onShowSendOptionsMenu = onShowSendOptionsMenu, onCameraClick = onCameraClick, @@ -329,10 +381,13 @@ fun ChatInputBarComposerSection( onGifSelected = onGifClick, onSearchFocused = onGifSearchFocusedChange, panelHeight = stickerMenuHeight, + canSendStickers = canSendStickers, stickerRepository = stickerRepository ) } - Spacer(Modifier.navigationBarsPadding()) + if (!isTablet) { + Spacer(Modifier.navigationBarsPadding()) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt index cad1cc92..dc80821b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import android.content.ClipData import android.widget.Toast import androidx.compose.animation.* import androidx.compose.foundation.* @@ -17,6 +18,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -150,31 +153,33 @@ fun FullScreenEditorSheet( ) { if (!visible) return val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val nativeClipboard = clipboardManager.nativeClipboard val focusRequester = remember { FocusRequester() } - var showEmojiPicker by remember { mutableStateOf(false) } - var showLinkDialog by remember { mutableStateOf(false) } - var linkValue by remember { mutableStateOf("https://") } - var showLanguageDialog by remember { mutableStateOf(false) } - var languageValue by remember { mutableStateOf("") } - var isPreviewMode by remember { mutableStateOf(false) } - var markdownMode by remember { mutableStateOf(false) } - var showFindReplace by remember { mutableStateOf(false) } - var findQuery by remember { mutableStateOf("") } - var replaceValue by remember { mutableStateOf("") } - var currentMatchIndex by remember { mutableIntStateOf(0) } - var showTemplatesSheet by remember { mutableStateOf(false) } + var showEmojiPicker by rememberSaveable { mutableStateOf(false) } + var showLinkDialog by rememberSaveable { mutableStateOf(false) } + var linkValue by rememberSaveable { mutableStateOf("https://") } + var showLanguageDialog by rememberSaveable { mutableStateOf(false) } + var languageValue by rememberSaveable { mutableStateOf("") } + var isPreviewMode by rememberSaveable { mutableStateOf(false) } + var markdownMode by rememberSaveable { mutableStateOf(false) } + var showFindReplace by rememberSaveable { mutableStateOf(false) } + var findQuery by rememberSaveable { mutableStateOf("") } + var replaceValue by rememberSaveable { mutableStateOf("") } + var currentMatchIndex by rememberSaveable { mutableIntStateOf(0) } + var showTemplatesSheet by rememberSaveable { mutableStateOf(false) } var showAutoSaved by remember { mutableStateOf(false) } var fontScale by remember { mutableFloatStateOf(1f) } - var showAiSheet by remember { mutableStateOf(false) } - var aiTranslateLanguage by remember { mutableStateOf("") } - var aiSelectedStyle by remember { mutableStateOf("") } - var aiAddEmojis by remember { mutableStateOf(false) } - var aiMode by remember { mutableStateOf(AiEditorMode.Stylize) } - var aiShowDiffMode by remember { mutableStateOf(true) } + var showAiSheet by rememberSaveable { mutableStateOf(false) } + var aiTranslateLanguage by rememberSaveable { mutableStateOf("") } + var aiSelectedStyle by rememberSaveable { mutableStateOf("") } + var aiAddEmojis by rememberSaveable { mutableStateOf(false) } + var aiMode by rememberSaveable { mutableStateOf(AiEditorMode.Stylize) } + var aiShowDiffMode by rememberSaveable { mutableStateOf(true) } var aiResultText by remember { mutableStateOf(null) } var aiResultTextValue by remember { mutableStateOf(null) } - var aiErrorMessage by remember { mutableStateOf(null) } + var aiErrorMessage by rememberSaveable { mutableStateOf(null) } var aiLoading by remember { mutableStateOf(false) } val snippetProvider: EditorSnippetProvider = koinInject() @@ -315,6 +320,11 @@ fun FullScreenEditorSheet( } val richEntityCount = remember(entities) { entities.count { richEntityToAnnotation(it.type) != null } } val hasSelection = hasFormattableSelection(textValue) + val hasTextSelection = normalizedSelection(textValue.selection) != null + val canPasteFromClipboard = canWriteText && + nativeClipboard.primaryClip?.let { clip -> + clip.itemCount > 0 && clip.getItemAt(0).coerceToText(context).isNotEmpty() + } == true fun showAiPreview(result: FormattedTextResult) { val mappedTextValue = buildTextFieldValueFromTextAndEntities( @@ -558,6 +568,31 @@ fun FullScreenEditorSheet( AnimatedVisibility(visible = !isPreviewMode) { FullScreenEditorTools( hasSelection = hasSelection, + canCopy = hasTextSelection, + canCut = canWriteText && hasTextSelection, + canPaste = canPasteFromClipboard, + onCopy = { + selectedTextOrNull(textValue)?.let { selectedText -> + nativeClipboard.setPrimaryClip(ClipData.newPlainText("", selectedText)) + } + }, + onCut = { + selectedTextOrNull(textValue)?.let { selectedText -> + nativeClipboard.setPrimaryClip(ClipData.newPlainText("", selectedText)) + applyEditorChange(replaceSelection(textValue, "")) + } + }, + onPaste = { + val clipboardText = + nativeClipboard.primaryClip?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(context) + ?.toString() + .orEmpty() + if (clipboardText.isNotEmpty()) { + applyEditorChange(replaceSelection(textValue, clipboardText)) + } + }, onBold = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Bold)) }, onItalic = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Italic)) }, onUnderline = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Underline)) }, @@ -1561,6 +1596,12 @@ private fun FullScreenEditorToolButton(icon: ImageVector, hint: String, enabled: @Composable private fun FullScreenEditorTools( hasSelection: Boolean, + canCopy: Boolean, + canCut: Boolean, + canPaste: Boolean, + onCopy: () -> Unit, + onCut: () -> Unit, + onPaste: () -> Unit, onBold: () -> Unit, onItalic: () -> Unit, onUnderline: () -> Unit, @@ -1589,6 +1630,24 @@ private fun FullScreenEditorTools( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + FullScreenEditorToolButton( + Icons.Outlined.ContentCopy, + stringResource(R.string.editor_action_copy), + canCopy, + onCopy + ) + FullScreenEditorToolButton( + Icons.Outlined.ContentCut, + stringResource(R.string.editor_action_cut), + canCut, + onCut + ) + FullScreenEditorToolButton( + Icons.Outlined.ContentPaste, + stringResource(R.string.editor_action_paste), + canPaste, + onPaste + ) FullScreenEditorToolButton( Icons.Outlined.FormatBold, stringResource(R.string.rich_text_bold), @@ -1685,6 +1744,31 @@ private fun currentPreLanguage(value: TextFieldValue): String { .orEmpty() } +private fun selectedTextOrNull(value: TextFieldValue): String? { + val selection = normalizedSelection(value.selection) ?: return null + return value.text.substring(selection.start, selection.end) +} + +private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue { + val rawSelection = if (value.selection.start <= value.selection.end) { + value.selection + } else { + TextRange(value.selection.end, value.selection.start) + } + val maxLength = value.annotatedString.length + val selection = TextRange( + start = rawSelection.start.coerceIn(0, maxLength), + end = rawSelection.end.coerceIn(0, maxLength) + ) + val newAnnotated = buildAnnotatedString { + append(value.annotatedString.subSequence(0, selection.start)) + append(replacement) + append(value.annotatedString.subSequence(selection.end, value.annotatedString.length)) + } + val cursor = selection.start + replacement.length + return value.copy(annotatedString = newAnnotated, selection = TextRange(cursor, cursor)) +} + private fun insertSnippetAtSelection(value: TextFieldValue, snippet: String): TextFieldValue { if (snippet.isBlank()) return value val rawSelection = if (value.selection.start <= value.selection.end) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt index 69fc16e5..25349fab 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -29,7 +30,7 @@ fun FullScreenEditorTemplatesSheet( onDeleteSnippet: (EditorSnippet) -> Unit ) { if (!visible) return - var customTitle by remember { mutableStateOf("") } + var customTitle by rememberSaveable { mutableStateOf("") } ModalBottomSheet( onDismissRequest = onDismiss, @@ -91,7 +92,10 @@ fun FullScreenEditorTemplatesSheet( } } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(snippets) { snippet -> + items( + items = snippets, + key = { snippet -> "${snippet.title}|${snippet.text}" } + ) { snippet -> Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt index e1f548fc..9179d9d8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import androidx.compose.animation.* import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -21,15 +22,27 @@ fun InputBarLeadingIcons( canSendMedia: Boolean, onAttachClick: () -> Unit ) { - if (editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia) { - IconButton(onClick = onAttachClick) { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = stringResource(R.string.cd_attach), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + val canAttachMedia = editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia + + AnimatedContent( + targetState = canAttachMedia, + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.85f)).togetherWith( + fadeOut() + scaleOut(targetScale = 0.85f) + ).using(SizeTransform(clip = false)) + }, + label = "AttachIconVisibility" + ) { showAttach -> + if (showAttach) { + IconButton(onClick = onAttachClick) { + Icon( + imageVector = Icons.Outlined.AddCircleOutline, + contentDescription = stringResource(R.string.cd_attach), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Spacer(modifier = Modifier.width(12.dp)) } - } else if (!canSendMedia) { - Spacer(modifier = Modifier.width(12.dp)) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt index 4b1e6a77..09459e4b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.Crossfade +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -17,6 +18,7 @@ import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,8 +43,11 @@ fun InputBarSendButton( isOverCharLimit: Boolean, canWriteText: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, canSendMedia: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, onSendWithOptions: (MessageSendOptions) -> Unit, onShowSendOptionsMenu: () -> Unit, onCameraClick: () -> Unit, @@ -53,36 +58,57 @@ fun InputBarSendButton( ) { val haptic = LocalHapticFeedback.current val isTextEmpty = textValue.text.isBlank() + val canSendContent = canWriteText || (pendingMediaPaths.isNotEmpty() && canSendMedia) + val isSlowModeBlocked = isSlowModeActive && editingMessage == null val isSendEnabled = - (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && canWriteText && !isOverCharLimit + (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && + canSendContent && + !isOverCharLimit && + !isSlowModeBlocked var isVoiceRecordingActive by remember { mutableStateOf(false) } - val isRecordingMode = isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canSendVoice + val effectiveVideoMode = when { + !canSendVideoNotes -> false + !canSendVoice -> true + else -> isVideoMessageMode + } + val canUseRecording = canSendVoice || canSendVideoNotes + val canToggleRecordingMode = canSendVoice && canSendVideoNotes + val isRecordingMode = + isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canUseRecording && !isSlowModeBlocked + + val backgroundColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(250), + label = "BackgroundColor" + ) + val contentColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(250), + label = "ContentColor" + ) - if (canWriteText || canSendVoice) { + if (canWriteText || canSendVoice || canSendVideoNotes) { val sendIcon = when { pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send editingMessage != null -> Icons.Default.Check !isTextEmpty -> Icons.AutoMirrored.Filled.Send - isVideoMessageMode -> Icons.Default.Videocam + effectiveVideoMode -> Icons.Default.Videocam else -> Icons.Outlined.Mic } val canShowOptions = editingMessage == null && canWriteText && (!isTextEmpty || (pendingMediaPaths.isNotEmpty() && canSendMedia)) && - !isOverCharLimit + !isOverCharLimit && + !isSlowModeBlocked Box( modifier = Modifier + .size(48.dp) + .background(color = backgroundColor, shape = CircleShape) + .clip(CircleShape) .then( if (isRecordingMode) { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .pointerInput(isVideoMessageMode) { + Modifier.pointerInput(effectiveVideoMode, canToggleRecordingMode) { awaitEachGesture { try { awaitFirstDown() @@ -93,7 +119,7 @@ fun InputBarSendButton( } if (up == null) { - if (isVideoMessageMode) { + if (effectiveVideoMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onCameraClick() waitForUpOrCancellation() @@ -135,8 +161,10 @@ fun InputBarSendButton( } } } else { - onVideoModeToggle() - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + if (canToggleRecordingMode) { + onVideoModeToggle() + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } } } finally { if (isVoiceRecordingActive) { @@ -147,41 +175,95 @@ fun InputBarSendButton( } } } else { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .combinedClickable( - onClick = { - if (isSendEnabled) { - onSendWithOptions(MessageSendOptions()) - } - }, - onLongClick = { - if (canShowOptions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onShowSendOptionsMenu() - } + Modifier.combinedClickable( + onClick = { + if (isSendEnabled) { + onSendWithOptions(MessageSendOptions()) } - ) + }, + onLongClick = { + if (canShowOptions) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onShowSendOptionsMenu() + } + } + ) } ), contentAlignment = Alignment.Center ) { - Crossfade(targetState = sendIcon, label = "IconAnimation") { icon -> - Icon( - imageVector = icon, - contentDescription = null, - tint = if (isSendEnabled || isVoiceRecordingActive) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + if (isSlowModeBlocked) { + Text( + text = formatSlowModeCountdown(slowModeRemainingSeconds), + style = MaterialTheme.typography.labelSmall, + color = contentColor ) + } else { + AnimatedContent( + targetState = sendIcon, + transitionSpec = { + val enteringSend = + targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check + val leavingSend = + initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check + + when { + enteringSend && !leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { -it / 2 }) + } + + !enteringSend && leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { -it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { it / 2 }) + } + + else -> { + (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( + fadeOut( + animationSpec = tween(200) + ) + scaleOut(targetScale = 0.8f) + ) + } + }.using(SizeTransform(clip = false)) + }, + label = "IconAnimation" + ) { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } } } } } + +private fun formatSlowModeCountdown(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt index 855dace7..a50d92fc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt @@ -1,6 +1,17 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.foundation.layout.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.content.ReceiveContentListener +import androidx.compose.foundation.content.contentReceiver +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent @@ -9,9 +20,23 @@ import androidx.compose.foundation.text.contextmenu.builder.item import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -19,8 +44,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -61,9 +92,11 @@ private object RichMenuActionCode private object RichMenuActionPre private object RichMenuActionLink private object RichMenuActionClear +private object RichMenuActionPasteImage private data class Interval(val start: Int, val end: Int) +@OptIn(ExperimentalFoundationApi::class) @Composable fun InputTextField( textValue: TextFieldValue, @@ -76,15 +109,18 @@ fun InputTextField( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + canPasteMediaFromClipboard: Boolean = false, + onPasteImages: (List) -> Unit = {}, fontScale: Float = 1f, maxEditorHeight: Dp = 140.dp, onFocus: () -> Unit = {}, modifier: Modifier = Modifier ) { - var showLinkDialog by remember { mutableStateOf(false) } - var linkValue by remember { mutableStateOf("https://") } - var showPreLanguageDialog by remember { mutableStateOf(false) } - var preLanguageValue by remember { mutableStateOf("") } + var showLinkDialog by rememberSaveable { mutableStateOf(false) } + var linkValue by rememberSaveable { mutableStateOf("https://") } + var showPreLanguageDialog by rememberSaveable { mutableStateOf(false) } + var preLanguageValue by rememberSaveable { mutableStateOf("") } + val context = LocalContext.current val emojiSize = 20.sp val inlineContentMap = remember(knownCustomEmojis.size, knownCustomEmojis.hashCode()) { @@ -165,6 +201,34 @@ fun InputTextField( hasCustomEmojis || emojiFontFamily != FontFamily.Default || textValue.text.contains('@') || hasRichFormatting val scrollState = rememberScrollState() + val editorState = rememberTextFieldState(initialText = textValue.text) + + LaunchedEffect(textValue.text, textValue.selection) { + val currentText = editorState.text.toString() + val currentSelection = editorState.selection + if (currentText != textValue.text || currentSelection != textValue.selection) { + editorState.edit { + replace(0, length, textValue.text) + selection = textValue.selection + } + } + } + + val currentTextValue by rememberUpdatedState(textValue) + val currentOnValueChange by rememberUpdatedState(onValueChange) + LaunchedEffect(editorState) { + snapshotFlow { editorState.text.toString() to editorState.selection } + .collect { (editedText, editedSelection) -> + val external = currentTextValue + if (editedText == external.text && editedSelection == external.selection) return@collect + currentOnValueChange( + TextFieldValue( + annotatedString = AnnotatedString(editedText), + selection = editedSelection + ) + ) + } + } LaunchedEffect(textValue.text) { scrollState.scrollTo(scrollState.maxValue) @@ -179,6 +243,21 @@ fun InputTextField( val richTextPre = stringResource(R.string.rich_text_pre) val richTextLink = stringResource(R.string.rich_text_link) val richTextClear = stringResource(R.string.rich_text_clear) + val actionPasteImage = stringResource(R.string.action_paste_image) + val receiveContentListener = remember(context, canPasteMediaFromClipboard, onPasteImages) { + ReceiveContentListener { transferableContent -> + if (!canPasteMediaFromClipboard) return@ReceiveContentListener transferableContent + + val clipData = transferableContent.clipEntry.clipData + val imageUris = extractImageUrisFromClipData(context, clipData) + if (imageUris.isEmpty()) { + transferableContent + } else { + onPasteImages(imageUris) + null + } + } + } Box(modifier = modifier.fillMaxWidth()) { Box( @@ -193,6 +272,7 @@ fun InputTextField( .fillMaxWidth() .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) onFocus() } + .contentReceiver(receiveContentListener) .let { base -> when { !enableContextMenu -> base.filterTextContextMenuComponents { false } @@ -212,12 +292,23 @@ fun InputTextField( RichMenuActionCode, RichMenuActionPre, RichMenuActionLink, - RichMenuActionClear -> true + RichMenuActionClear, + RichMenuActionPasteImage -> true else -> false } } .appendTextContextMenuComponents { + if (canPasteMediaFromClipboard) { + val imageUris = extractImageUrisFromClipboard(context) + if (imageUris.isNotEmpty()) { + item(RichMenuActionPasteImage, actionPasteImage) { + close() + onPasteImages(imageUris) + } + } + } + if (hasFormattableSelection(textValue)) { separator() item(RichMenuActionBold, richTextBold) { @@ -327,16 +418,17 @@ fun InputTextField( ) BasicTextField( - value = textValue, - onValueChange = onValueChange, + state = editorState, modifier = fieldModifier, textStyle = textStyle.copy( color = if (shouldUseOverlayText) Color.Transparent else MaterialTheme.colorScheme.onSurface ), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - minLines = 1, - maxLines = Int.MAX_VALUE, - decorationBox = { innerTextField -> + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = Int.MAX_VALUE + ), + decorator = { innerTextField -> Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart @@ -452,6 +544,34 @@ fun InputTextField( } } +private fun extractImageUrisFromClipboard(context: Context): List { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return emptyList() + val clip = clipboard.primaryClip ?: return emptyList() + return extractImageUrisFromClipData(context, clip) +} + +private fun extractImageUrisFromClipData(context: Context, clip: ClipData): List { + if (clip.itemCount <= 0) return emptyList() + + val hasImageMimeType = clip.description?.let { description -> + (0 until description.mimeTypeCount).any { index -> + description.getMimeType(index)?.startsWith("image/") == true + } + } == true + + return buildList { + for (index in 0 until clip.itemCount) { + val item = clip.getItemAt(index) + val uri = item.uri ?: item.intent?.data ?: continue + val mime = context.contentResolver.getType(uri).orEmpty() + if (mime.startsWith("image/") || hasImageMimeType) { + add(uri) + } + } + }.distinct() +} + internal fun mergeInputTextValuePreservingAnnotations( currentValue: TextFieldValue, incomingValue: TextFieldValue diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt index 2fbab909..833cc363 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -1,7 +1,18 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* -import androidx.compose.foundation.layout.* +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -27,6 +38,7 @@ import org.monogram.domain.models.BotCommandModel import org.monogram.domain.models.BotMenuButtonModel import org.monogram.domain.models.StickerModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled @Composable fun InputTextFieldContainer( @@ -46,6 +58,8 @@ fun InputTextFieldContainer( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + canPasteMediaFromClipboard: Boolean = false, + onPasteImages: (List) -> Unit = {}, onFocus: () -> Unit = {}, onOpenFullScreenEditor: () -> Unit = {}, modifier: Modifier = Modifier @@ -55,36 +69,49 @@ fun InputTextFieldContainer( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { - val isTablet = LocalConfiguration.current.screenWidthDp >= 600 + val isTablet = + LocalConfiguration.current.screenWidthDp >= 600 && LocalTabletInterfaceEnabled.current Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) ) { val showBotActions = remember(isBot, textValue.text) { isBot && textValue.text.isEmpty() } - if (showBotActions) { - BotInputActions( - botMenuButton = botMenuButton, - botCommands = botCommands, - canSendStickers = canSendStickers, - isStickerMenuVisible = isStickerMenuVisible, - onStickerMenuToggle = onStickerMenuToggle, - onShowBotCommands = onShowBotCommands, - onOpenMiniApp = onOpenMiniApp - ) - } else if (canSendStickers) { - IconButton( - onClick = onStickerMenuToggle, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + AnimatedContent( + targetState = showBotActions to canSendStickers, + transitionSpec = { + (fadeIn() + expandHorizontally()).togetherWith(fadeOut() + shrinkHorizontally()) + }, + label = "InputActionsVisibility" + ) { (showBotActionsState, canSendStickersState) -> + when { + showBotActionsState -> { + BotInputActions( + botMenuButton = botMenuButton, + botCommands = botCommands, + canSendStickers = canSendStickersState, + isStickerMenuVisible = isStickerMenuVisible, + onStickerMenuToggle = onStickerMenuToggle, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp + ) + } + + canSendStickersState -> { + IconButton( + onClick = onStickerMenuToggle, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> Spacer(modifier = Modifier.width(8.dp)) } - } else { - Spacer(modifier = Modifier.width(8.dp)) } InputTextField( @@ -96,6 +123,8 @@ fun InputTextFieldContainer( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + canPasteMediaFromClipboard = canPasteMediaFromClipboard, + onPasteImages = onPasteImages, onFocus = onFocus, modifier = Modifier.weight(1f) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt index b4e31f09..507b770e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -56,7 +56,10 @@ fun KeyboardMarkupView( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { - items(rows) { row -> + itemsIndexed( + items = rows, + key = { index, row -> "${index}_${row.joinToString(separator = "|") { button -> button.text }}" } + ) { _, row -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt index fa2eb057..f4fe0961 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt @@ -14,9 +14,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.UserModel import org.monogram.domain.models.UserStatusType import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText @Composable @@ -25,6 +27,8 @@ fun MentionSuggestions( onMentionClick: (UserModel) -> Unit ) { val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Column( modifier = Modifier .fillMaxWidth() @@ -58,7 +62,7 @@ fun MentionSuggestions( maxLines = 1, overflow = TextOverflow.Ellipsis ) - val status = user.username?.let { "@$it" } ?: getUserStatusText(user, context) + val status = user.username?.let { "@$it" } ?: getUserStatusText(user, context, timeFormat) Text( text = status, style = MaterialTheme.typography.labelMedium, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt index f69c0b38..1d13fd40 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -104,9 +105,9 @@ fun buildScheduledDateEpochSeconds(selectedDateMillis: Long, hour: Int, minute: return (selected.timeInMillis / 1000L).toInt() } -fun formatScheduledTimestamp(epochSeconds: Int): String { +fun formatScheduledTimestamp(epochSeconds: Int, timeFormat: String): String { return try { - val formatter = SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) + val formatter = SimpleDateFormat("dd MMM, $timeFormat", Locale.getDefault()) formatter.format(Date(epochSeconds * 1000L)) } catch (_: Exception) { "" diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt index eb688fd4..dd8c0bf2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt @@ -14,9 +14,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -39,6 +41,9 @@ fun ScheduledMessagesSheet( scheduledMessagesSorted.count { canEditScheduledMessage(it) } } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + ModalBottomSheet( onDismissRequest = onDismiss, dragHandle = { BottomSheetDefaults.DragHandle() }, @@ -102,7 +107,7 @@ fun ScheduledMessagesSheet( text = if (nextScheduled != null) { stringResource( R.string.scheduled_messages_summary_next, - formatScheduledTimestamp(nextScheduled.date) + formatScheduledTimestamp(nextScheduled.date, timeFormat) ) } else { stringResource(R.string.scheduled_messages_empty) @@ -245,8 +250,9 @@ private fun ScheduledMessageRow( maxLines = 1, overflow = TextOverflow.Ellipsis ) + val dateFormatManager: DateFormatManager = koinInject() Text( - text = formatScheduledTimestamp(message.date), + text = formatScheduledTimestamp(message.date, dateFormatManager.getHourMinuteFormat()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -300,39 +306,50 @@ private fun ScheduledMessageRow( } } +@Composable private fun messagePreviewText(message: MessageModel): String = when (val content = message.content) { - is MessageContent.Text -> content.text - is MessageContent.Photo -> if (content.caption.isNotBlank()) content.caption else "Photo" - is MessageContent.Video -> if (content.caption.isNotBlank()) content.caption else "Video" - is MessageContent.Document -> if (content.caption.isNotBlank()) content.caption else "Document" - is MessageContent.Gif -> if (content.caption.isNotBlank()) content.caption else "GIF" - is MessageContent.Sticker -> "Sticker" - is MessageContent.Voice -> "Voice message" - is MessageContent.VideoNote -> "Video message" - is MessageContent.Audio -> "Audio" - is MessageContent.Location -> "Location" - is MessageContent.Venue -> content.title - is MessageContent.Contact -> listOf(content.firstName, content.lastName).filter { it.isNotBlank() } - .joinToString(" ") + is MessageContent.Text -> content.text.ifBlank { stringResource(R.string.reply_content_message) } + is MessageContent.Photo -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_photo) + is MessageContent.Video -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_video) + is MessageContent.Document -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.logs_media_document) + is MessageContent.Gif -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_gif) + is MessageContent.Sticker -> stringResource(R.string.reply_content_sticker) + is MessageContent.Voice -> stringResource(R.string.reply_content_voice_message) + is MessageContent.VideoNote -> stringResource(R.string.reply_content_video_message) + is MessageContent.Audio -> stringResource(R.string.logs_media_audio) + is MessageContent.Location -> stringResource(R.string.location_label) + is MessageContent.Venue -> content.title.ifBlank { stringResource(R.string.logs_media_venue) } + is MessageContent.Contact -> { + val fullName = listOf(content.firstName, content.lastName) + .filter { it.isNotBlank() } + .joinToString(" ") + fullName.ifBlank { stringResource(R.string.logs_media_contact) } + } - is MessageContent.Service -> content.text - is MessageContent.Poll -> content.question - is MessageContent.Unsupported -> "Unsupported message" - else -> "Message" + is MessageContent.Service -> content.text.ifBlank { stringResource(R.string.profile_statistics_preview_service_message) } + is MessageContent.Poll -> content.question.ifBlank { stringResource(R.string.logs_media_poll) } + is MessageContent.Unsupported -> stringResource(R.string.logs_media_unsupported) } +@Composable private fun scheduledMessageTypeLabel(message: MessageModel): String = when (message.content) { - is MessageContent.Text -> "Text" - is MessageContent.Photo -> "Photo" - is MessageContent.Video -> "Video" - is MessageContent.Document -> "Document" - is MessageContent.Gif -> "GIF" - is MessageContent.Sticker -> "Sticker" - is MessageContent.Voice -> "Voice" - is MessageContent.VideoNote -> "Video message" - else -> "Message" + is MessageContent.Text -> stringResource(R.string.photo_editor_tool_text) + is MessageContent.Photo -> stringResource(R.string.reply_content_photo) + is MessageContent.Video -> stringResource(R.string.reply_content_video) + is MessageContent.Document -> stringResource(R.string.logs_media_document) + is MessageContent.Gif -> stringResource(R.string.reply_content_gif) + is MessageContent.Sticker -> stringResource(R.string.reply_content_sticker) + is MessageContent.Voice -> stringResource(R.string.logs_media_voice) + is MessageContent.VideoNote -> stringResource(R.string.reply_content_video_message) + is MessageContent.Audio -> stringResource(R.string.logs_media_audio) + is MessageContent.Contact -> stringResource(R.string.logs_media_contact) + is MessageContent.Location -> stringResource(R.string.location_label) + is MessageContent.Venue -> stringResource(R.string.logs_media_venue) + is MessageContent.Poll -> stringResource(R.string.logs_media_poll) + is MessageContent.Service -> stringResource(R.string.profile_statistics_preview_service_message) + MessageContent.Unsupported -> stringResource(R.string.reply_content_message) } private fun canEditScheduledMessage(message: MessageModel): Boolean = diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index d7d4dd4f..759027df 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -1,34 +1,67 @@ package org.monogram.presentation.features.chats.currentChat.components.pins +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.ChannelMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.DateSeparator +import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun PinnedMessagesListSheet( - state: ChatComponent.State, - onDismiss: () -> Unit, + isVisible: Boolean, + allPinnedMessages: List, + pinnedMessageCount: Int, + isLoadingPinnedMessages: Boolean, + isGroup: Boolean, + isChannel: Boolean, + fontSize: Float, + letterSpacing: Float, + bubbleRadius: Float, + stickerSize: Float, + autoDownloadMobile: Boolean, + autoDownloadWifi: Boolean, + autoDownloadRoaming: Boolean, + autoDownloadFiles: Boolean, + autoplayGifs: Boolean, + autoplayVideos: Boolean, + onDismissRequest: () -> Unit, + onHidden: () -> Unit, onMessageClick: (MessageModel) -> Unit, onUnpin: (MessageModel) -> Unit, onReplyClick: (MessageModel) -> Unit, @@ -36,177 +69,383 @@ fun PinnedMessagesListSheet( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val messages = state.allPinnedMessages + val messages = allPinnedMessages val groupedMessages = remember(messages) { groupMessagesByAlbum(messages.distinctBy { it.id }) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - contentColor = MaterialTheme.colorScheme.onSurface, - dragHandle = { BottomSheetDefaults.DragHandle() } - ) { - Column( + val showLoadingSkeleton = isLoadingPinnedMessages && messages.isEmpty() + val displayedPinnedCount = maxOf(pinnedMessageCount, messages.size) + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val shimmerBrush = rememberShimmerBrush() + var dismissOffsetY by remember { mutableFloatStateOf(0f) } + var sheetHeightPx by remember { mutableFloatStateOf(0f) } + var isAnimationReady by remember { mutableStateOf(false) } + val dismissDistanceThresholdPx = with(density) { 104.dp.toPx() } + val dismissVelocityThresholdPx = with(density) { 360.dp.toPx() } + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val hiddenOffset = sheetHeightPx.takeIf { it > 0f } ?: with(density) { 640.dp.toPx() } + val dismissProgress = (dismissOffsetY / hiddenOffset).coerceIn(0f, 1f) + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val surfaceColor = MaterialTheme.colorScheme.background + val contentColor = MaterialTheme.colorScheme.onSurface + val scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f * (1f - dismissProgress)) + val scrimInteractionSource = remember { MutableInteractionSource() } + val dragState = rememberDraggableState { delta -> + if (isAnyViewerOpen) return@rememberDraggableState + dismissOffsetY = (dismissOffsetY + delta).coerceAtLeast(0f) + } + + LaunchedEffect(sheetHeightPx) { + if (sheetHeightPx > 0f && !isAnimationReady) { + dismissOffsetY = hiddenOffset + isAnimationReady = true + } + } + + LaunchedEffect(isVisible, isAnimationReady, hiddenOffset) { + if (!isAnimationReady) return@LaunchedEffect + val target = if (isVisible) 0f else hiddenOffset + animate( + initialValue = dismissOffsetY, + targetValue = target, + animationSpec = if (isVisible) spring() else tween(durationMillis = 220) + ) { value, _ -> dismissOffsetY = value } + if (!isVisible) { + onHidden() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Box( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars) + .background(scrimColor) + .clickable( + interactionSource = scrimInteractionSource, + indication = null + ) { + if (!isAnyViewerOpen) onDismissRequest() + } + ) + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxSize() + .padding(top = statusBarTopPadding) + .offset { IntOffset(x = 0, y = dismissOffsetY.roundToInt()) } + .onSizeChanged { sheetHeightPx = it.height.toFloat() }, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = surfaceColor, + contentColor = contentColor ) { - Row( + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars) ) { - Text( - text = stringResource(R.string.pinned_messages), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = pluralStringResource(R.plurals.pinned_count, messages.size, messages.size), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Column( + modifier = Modifier + .fillMaxWidth() + .draggable( + state = dragState, + orientation = Orientation.Vertical, + enabled = !isAnyViewerOpen, + onDragStopped = { velocity -> + if (isAnyViewerOpen) { + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> dismissOffsetY = value } + } + return@draggable + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + val shouldDismiss = + dismissOffsetY > dismissDistanceThresholdPx || + velocity > dismissVelocityThresholdPx - LazyColumn( - modifier = Modifier - .weight(1f) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), - contentPadding = PaddingValues(8.dp) - ) { - itemsIndexed(groupedMessages, key = { _, item -> - when (item) { - is GroupedMessageItem.Single -> "pin_${item.message.id}" - is GroupedMessageItem.Album -> "pin_album_${item.albumId}" - } - }) { index, item -> - val msg = when (item) { - is GroupedMessageItem.Single -> item.message - is GroupedMessageItem.Album -> item.messages.last() - } + if (shouldDismiss) { + onDismissRequest() + } else { + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> dismissOffsetY = value } + } + } + } + ) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheetDefaults.DragHandle() - val olderMsg = when (val olderItem = groupedMessages.getOrNull(index + 1)) { - is GroupedMessageItem.Single -> olderItem.message - is GroupedMessageItem.Album -> olderItem.messages.last() - null -> null + Text( + text = stringResource(R.string.pinned_messages), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + if (showLoadingSkeleton) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .width(108.dp) + .height(18.dp) + .background( + brush = shimmerBrush, + shape = RoundedCornerShape(9.dp) + ) + ) + } else { + Text( + text = pluralStringResource( + R.plurals.pinned_count, + displayedPinnedCount, + displayedPinnedCount + ), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - val newerMsg = when (val newerItem = groupedMessages.getOrNull(index - 1)) { - is GroupedMessageItem.Single -> newerItem.message - is GroupedMessageItem.Album -> newerItem.messages.first() - null -> null - } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + color = dividerColor + ) + } - if (shouldShowDate(msg, olderMsg)) { - DateSeparator(msg.date) - Spacer(modifier = Modifier.height(8.dp)) - } + if (showLoadingSkeleton) { + PinnedMessagesLoadingSkeleton( + brush = shimmerBrush, + isChannel = isChannel, + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)) + ) + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), + contentPadding = PaddingValues(8.dp) + ) { + itemsIndexed(groupedMessages, key = { _, item -> + when (item) { + is GroupedMessageItem.Single -> "pin_${item.message.id}" + is GroupedMessageItem.Album -> "pin_album_${item.albumId}" + } + }) { index, item -> + val msg = when (item) { + is GroupedMessageItem.Single -> item.message + is GroupedMessageItem.Album -> item.messages.last() + } - Box(modifier = Modifier.animateItem()) { - if (state.isChannel) { - if (item is GroupedMessageItem.Single) { - ChannelMessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, - downloadUtils = downloadUtils - ) - } else if (item is GroupedMessageItem.Album) { - AlbumMessageBubbleContainer( - messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = false, - isChannel = true, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) + val olderMsg = when (val olderItem = groupedMessages.getOrNull(index + 1)) { + is GroupedMessageItem.Single -> olderItem.message + is GroupedMessageItem.Album -> olderItem.messages.last() + null -> null } - } else { - if (item is GroupedMessageItem.Single) { - MessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = state.isGroup, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stSize = state.stickerSize, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, - autoDownloadFiles = state.autoDownloadFiles, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) - } else if (item is GroupedMessageItem.Album) { - AlbumMessageBubbleContainer( - messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = state.isGroup, - isChannel = false, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) + + val newerMsg = when (val newerItem = groupedMessages.getOrNull(index - 1)) { + is GroupedMessageItem.Single -> newerItem.message + is GroupedMessageItem.Album -> newerItem.messages.first() + null -> null + } + + if (shouldShowDate(msg, olderMsg)) { + DateSeparator(msg.date) + Spacer(modifier = Modifier.height(8.dp)) } + + Box(modifier = Modifier.animateItem()) { + if (isChannel) { + if (item is GroupedMessageItem.Single) { + ChannelMessageBubbleContainer( + msg = item.message, + olderMsg = olderMsg, + newerMsg = newerMsg, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadFiles = autoDownloadFiles, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onDocumentClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.message) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + downloadUtils = downloadUtils + ) + } else if (item is GroupedMessageItem.Album) { + AlbumMessageBubbleContainer( + messages = item.messages, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = false, + isChannel = true, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + canReply = false, + downloadUtils = downloadUtils + ) + } + } else { + if (item is GroupedMessageItem.Single) { + MessageBubbleContainer( + msg = item.message, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = isGroup, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stSize = stickerSize, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onDocumentClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.message) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + canReply = false, + downloadUtils = downloadUtils + ) + } else if (item is GroupedMessageItem.Album) { + AlbumMessageBubbleContainer( + messages = item.messages, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = isGroup, + isChannel = false, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + canReply = false, + downloadUtils = downloadUtils + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) } } - Spacer(modifier = Modifier.height(4.dp)) } } + } + } +} - Box(modifier = Modifier.padding(16.dp)) { - Button( - onClick = onDismiss, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) +private data class PinnedSkeletonConfig( + val isOutgoing: Boolean, + val bubbleWidth: Float, + val lineWidths: List +) + +@Composable +private fun PinnedMessagesLoadingSkeleton( + brush: Brush, + isChannel: Boolean, + modifier: Modifier = Modifier +) { + val items = listOf( + PinnedSkeletonConfig(false, 0.82f, listOf(0.92f, 0.64f)), + PinnedSkeletonConfig(true, 0.58f, listOf(0.8f)), + PinnedSkeletonConfig(false, 0.74f, listOf(0.88f, 0.7f)), + PinnedSkeletonConfig(true, 0.62f, listOf(0.86f, 0.6f)), + PinnedSkeletonConfig(false, 0.68f, listOf(0.76f)), + PinnedSkeletonConfig(true, 0.8f, listOf(0.9f, 0.62f)), + PinnedSkeletonConfig(false, 0.56f, listOf(0.72f)) + ) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = items, + key = { index, item -> "${index}_${item.isOutgoing}_${item.bubbleWidth}" } + ) { _, item -> + val outgoing = !isChannel && item.isOutgoing + val bubbleColor = if (outgoing) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.65f) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = bubbleColor, + modifier = Modifier.fillMaxWidth(item.bubbleWidth) ) { - Text(text = stringResource(R.string.pinned_close), fontSize = 16.sp, fontWeight = FontWeight.Bold) + Column( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 9.dp, bottom = 7.dp) + ) { + item.lineWidths.forEachIndexed { index, width -> + Box( + modifier = Modifier + .fillMaxWidth(width) + .height(14.dp) + .background( + brush = brush, + shape = RoundedCornerShape(5.dp) + ) + ) + if (index != item.lineWidths.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + } + } + + Spacer(modifier = Modifier.height(7.dp)) + + Box( + modifier = Modifier + .align(Alignment.End) + .width(if (outgoing) 44.dp else 32.dp) + .height(10.dp) + .background( + brush = brush, + shape = RoundedCornerShape(3.dp) + ) + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt index 69606a65..01c133b8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt @@ -1,7 +1,8 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.editor.photo +import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector @@ -36,9 +38,13 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.editor.photo.components.* +import org.monogram.presentation.features.chats.currentChat.editor.photo.crop.* import java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { @@ -50,6 +56,9 @@ enum class EditorTool(val labelRes: Int, val icon: ImageVector) { ERASER(R.string.photo_editor_tool_eraser, Icons.Rounded.CleaningServices) } +private const val MinImageScale = 0.5f +private const val MaxImageScale = 10f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PhotoEditorScreen( @@ -59,8 +68,9 @@ fun PhotoEditorScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val density = LocalDensity.current - var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var currentTool by remember { mutableStateOf(EditorTool.TRANSFORM) } val paths = remember { mutableStateListOf() } val pathsRedo = remember { mutableStateListOf() } @@ -70,6 +80,7 @@ fun PhotoEditorScreen( var brushSize by remember { mutableFloatStateOf(15f) } var currentFilter by remember { mutableStateOf(null) } + var imageRotation by remember { mutableFloatStateOf(0f) } var imageScale by remember { mutableFloatStateOf(1f) } var imageOffset by remember { mutableStateOf(Offset.Zero) } @@ -81,12 +92,226 @@ fun PhotoEditorScreen( var isSaving by remember { mutableStateOf(false) } var showDiscardDialog by remember { mutableStateOf(false) } + val imageSize by produceState(initialValue = IntSize.Zero, key1 = imagePath) { + value = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(imagePath, options) + IntSize(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } + } + + + val pivot by remember(canvasSize) { + derivedStateOf { Offset(canvasSize.width / 2f, canvasSize.height / 2f) } + } + + val cropState = rememberCropEditorState( + canvasSize = canvasSize, + imageSize = imageSize, + transformPivot = pivot, + imageScale = imageScale, + imageRotation = imageRotation, + imageOffset = imageOffset + ) + var transformAnimationJob by remember { mutableStateOf(null) } + + fun animateTransformTo( + targetCropRect: Rect, + targetRotation: Float, + targetScale: Float, + targetOffset: Offset, + durationMillis: Int = 180 + ) { + val startCrop = cropState.cropRect + val startRotation = imageRotation + val startScale = imageScale + val startOffset = imageOffset + + if ( + startCrop == targetCropRect && + startRotation == targetRotation && + startScale == targetScale && + startOffset == targetOffset + ) { + cropState.setCropRect(targetCropRect) + imageRotation = targetRotation + imageScale = targetScale + imageOffset = targetOffset + return + } + + transformAnimationJob?.cancel() + transformAnimationJob = scope.launch { + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(durationMillis)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (targetCropRect.left - startCrop.left) * t, + top = startCrop.top + (targetCropRect.top - startCrop.top) * t, + right = startCrop.right + (targetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (targetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageRotation = startRotation + (targetRotation - startRotation) * t + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } + + + fun fillAreaAfterResize() { + val crop = cropState.cropRect + if (crop.width <= 0f || crop.height <= 0f || canvasSize.width <= 0 || canvasSize.height <= 0) return + + val currentAspect = crop.width / crop.height + val targetCropRect = calculateTargetFillRect(canvasSize, currentAspect) + if (targetCropRect == Rect.Zero) return + + + val scaleFactor = maxOf( + targetCropRect.width / crop.width, + targetCropRect.height / crop.height + ) + val targetScale = (imageScale * scaleFactor).coerceIn(MinImageScale, MaxImageScale) + val z = if (imageScale != 0f) targetScale / imageScale else 1f + + + + + + val targetOffset = Offset( + x = (targetCropRect.center.x - pivot.x) - z * (crop.center.x - pivot.x - imageOffset.x), + y = (targetCropRect.center.y - pivot.y) - z * (crop.center.y - pivot.y - imageOffset.y) + ) + + val targetImageBounds = calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + val safeTargetCropRect = constrainCropRectToImage( + currentCropRect = crop, + candidateRect = targetCropRect, + visibleBounds = targetImageBounds, + minCropSizePx = cropState.minCropSizePx, + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + + + animateTransformTo( + targetCropRect = safeTargetCropRect, + targetRotation = imageRotation, + targetScale = targetScale, + targetOffset = targetOffset, + durationMillis = 200 + ) + } + + val shouldConstrain by remember(currentTool) { + derivedStateOf { currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE } + } + + fun applyTransform(centroid: Offset, pan: Offset, zoom: Float) { + val effectiveMinScale = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + minimumScaleToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + currentScale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = pivot + ).coerceAtLeast(MinImageScale) + } else { + MinImageScale + } + + val newScale = (imageScale * zoom).coerceIn(effectiveMinScale, MaxImageScale) + val actualZoom = if (imageScale != 0f) newScale / imageScale else 1f + + val offsetAfterZoom = offsetForZoomAroundAnchor(imageOffset, pivot, centroid, actualZoom) + val newOffset = offsetAfterZoom + pan + + imageScale = newScale + imageOffset = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + clampOffsetToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = newScale, + rotationDegrees = imageRotation, + offset = newOffset, + pivot = pivot + ) + } else { + newOffset + } + } + + fun applyRotation(newRotation: Float) { + val deltaAngle = newRotation - imageRotation + + val anchor = if (cropState.cropRect != Rect.Zero) cropState.cropRect.center else pivot + val newOffset = offsetForRotationAroundAnchor(imageOffset, pivot, anchor, deltaAngle) + + imageRotation = newRotation + + + if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + val (fittedScale, fittedOffset) = fitContentInBounds( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = imageScale, + rotationDegrees = newRotation, + offset = newOffset, + pivot = pivot, + minScale = MinImageScale, + maxScale = MaxImageScale + ) + imageScale = fittedScale + imageOffset = fittedOffset + } else { + imageOffset = newOffset + } + } + + fun rotateClockwise() { + val targetRotation = rotateClockwiseAnimationTarget(imageRotation) + + animateTransformTo( + targetCropRect = if (cropState.imageBounds == Rect.Zero) { + Rect.Zero + } else { + calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = 1f, + rotationDegrees = targetRotation, + offset = Offset.Zero, + pivot = pivot + ) + }, + targetRotation = targetRotation, + targetScale = 1f, + targetOffset = Offset.Zero + ) + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || textElements.isNotEmpty() || currentFilter != null || - imageRotation != 0f || + (cropState.cropRect != Rect.Zero && cropState.cropRect != cropState.defaultCropRect) || + normalizeRotationDegrees(imageRotation) != 0f || imageScale != 1f || imageOffset != Offset.Zero } @@ -120,7 +345,9 @@ fun PhotoEditorScreen( textElements, currentFilter, canvasSize, - imageRotation, + cropState.cropRect, + pivot, + normalizeRotationDegrees(imageRotation), imageScale, imageOffset ) @@ -151,9 +378,7 @@ fun PhotoEditorScreen( tonalElevation = 3.dp, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { - Column( - modifier = Modifier.navigationBarsPadding() - ) { + Column(modifier = Modifier.navigationBarsPadding()) { AnimatedContent( targetState = currentTool, label = "ToolOptions", @@ -162,24 +387,23 @@ fun PhotoEditorScreen( Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 100.dp), + .heightIn(min = 84.dp), contentAlignment = Alignment.Center ) { when (tool) { EditorTool.TRANSFORM -> { TransformControls( rotation = imageRotation, - scale = imageScale, - onRotationChange = { imageRotation = it }, - onScaleChange = { imageScale = it }, + onRotationChange = { newRotation -> applyRotation(newRotation) }, + onRotateClockwise = { rotateClockwise() }, onReset = { imageRotation = 0f imageScale = 1f imageOffset = Offset.Zero + cropState.reset() } ) } - EditorTool.DRAW, EditorTool.ERASER -> { DrawControls( isEraser = tool == EditorTool.ERASER, @@ -189,7 +413,6 @@ fun PhotoEditorScreen( onSizeChange = { brushSize = it } ) } - EditorTool.FILTER -> { FilterControls( imagePath = imagePath, @@ -197,7 +420,6 @@ fun PhotoEditorScreen( onFilterSelect = { currentFilter = it } ) } - EditorTool.TEXT -> { Button( onClick = { @@ -211,7 +433,6 @@ fun PhotoEditorScreen( Text(stringResource(R.string.photo_editor_action_add_text)) } } - else -> { Text( stringResource(R.string.photo_editor_label_select_tool), @@ -223,10 +444,7 @@ fun PhotoEditorScreen( } } - NavigationBar( - containerColor = Color.Transparent, - tonalElevation = 0.dp - ) { + NavigationBar(containerColor = Color.Transparent, tonalElevation = 0.dp) { EditorTool.entries.forEach { tool -> val label = stringResource(tool.labelRes) NavigationBarItem( @@ -255,180 +473,168 @@ fun PhotoEditorScreen( .background(Color.Black) .onGloballyPositioned { canvasSize = it.size } ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = imageScale, - scaleY = imageScale, - rotationZ = imageRotation, - translationX = imageOffset.x, - translationY = imageOffset.y - ) - .pointerInput(currentTool) { - if (currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE) { - detectTransformGestures { _, pan, zoom, rotation -> - imageScale *= zoom - imageRotation += rotation - imageOffset += pan - } - } - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(File(imagePath)) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } - ) - - Canvas( + Box(modifier = Modifier.fillMaxSize()) { + + Box( modifier = Modifier .fillMaxSize() + .graphicsLayer( + scaleX = imageScale, + scaleY = imageScale, + rotationZ = imageRotation, + translationX = imageOffset.x, + translationY = imageOffset.y, + transformOrigin = TransformOrigin.Center + ) .pointerInput(currentTool) { - if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { - detectDragGestures( - onDragStart = { offset -> - val path = Path().apply { moveTo(offset.x, offset.y) } - paths.add( - DrawnPath( - path = path, - color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, - strokeWidth = brushSize, - isEraser = currentTool == EditorTool.ERASER - ) - ) - pathsRedo.clear() - }, - onDrag = { change, _ -> - change.consume() - val index = paths.lastIndex - if (index == -1) return@detectDragGestures - - val currentPathData = paths[index] - val x1 = change.previousPosition.x - val y1 = change.previousPosition.y - val x2 = change.position.x - val y2 = change.position.y - - currentPathData.path.quadraticTo( - x1, y1, (x1 + x2) / 2, (y1 + y2) / 2 - ) - - paths.add(paths.removeAt(index)) - } - ) + if (currentTool == EditorTool.NONE) { + detectTransformGestures { centroid, pan, zoom, _ -> + applyTransform(centroid, pan, zoom) + } } } ) { - paths.forEach { pathData -> - drawPath( - path = pathData.path, - color = pathData.color, - alpha = pathData.alpha, - style = Stroke( - width = pathData.strokeWidth, - cap = StrokeCap.Round, - join = StrokeJoin.Round - ), - blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver - ) - } - } - - textElements.forEach { element -> - val density = LocalDensity.current - - var currentOffset by remember(element.id) { - mutableStateOf( - if (element.offset == Offset.Zero) Offset( - canvasSize.width / 2f, - canvasSize.height / 2f - ) else element.offset - ) - } - var currentScale by remember(element.id) { mutableStateOf(element.scale) } - var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + AsyncImage( + model = ImageRequest.Builder(context).data(File(imagePath)).build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } + ) - Box( + Canvas( modifier = Modifier - .offset( - x = with(density) { currentOffset.x.toDp() }, - y = with(density) { currentOffset.y.toDp() } + .fillMaxSize() + .pointerInput(currentTool) { + if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { + detectDragGestures( + onDragStart = { offset -> + val path = Path().apply { moveTo(offset.x, offset.y) } + paths.add( + DrawnPath( + path = path, + color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, + strokeWidth = brushSize, + isEraser = currentTool == EditorTool.ERASER + ) + ) + pathsRedo.clear() + }, + onDrag = { change, _ -> + change.consume() + val index = paths.lastIndex + if (index == -1) return@detectDragGestures + val cur = paths[index] + val x1 = change.previousPosition.x + val y1 = change.previousPosition.y + val x2 = change.position.x + val y2 = change.position.y + cur.path.quadraticTo(x1, y1, (x1 + x2) / 2, (y1 + y2) / 2) + paths.add(paths.removeAt(index)) + } + ) + } + } + ) { + paths.forEach { pathData -> + drawPath( + path = pathData.path, + color = pathData.color, + alpha = pathData.alpha, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver ) - .graphicsLayer( - scaleX = currentScale, - scaleY = currentScale, - rotationZ = currentRotation, - translationX = -with(density) { 100.dp.toPx() }, - translationY = -with(density) { 25.dp.toPx() } + } + } + + textElements.forEach { element -> + var currentOffset by remember(element.id) { + mutableStateOf( + if (element.offset == Offset.Zero) Offset(canvasSize.width / 2f, canvasSize.height / 2f) + else element.offset ) - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTransformGestures( - onGesture = { _, pan, zoom, rotation -> + } + var currentScale by remember(element.id) { mutableStateOf(element.scale) } + var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + + Box( + modifier = Modifier + .offset( + x = with(density) { currentOffset.x.toDp() }, + y = with(density) { currentOffset.y.toDp() } + ) + .graphicsLayer( + scaleX = currentScale, + scaleY = currentScale, + rotationZ = currentRotation, + translationX = -with(density) { 100.dp.toPx() }, + translationY = -with(density) { 25.dp.toPx() } + ) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTransformGestures { _, pan, zoom, rotation -> currentOffset += pan currentScale *= zoom currentRotation += rotation } - ) + } } - } - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTapGestures(onTap = { - if (currentTool == EditorTool.TEXT) { - editingTextElement = element - selectedColor = element.color - showTextDialog = true - } - }) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTapGestures(onTap = { + if (currentTool == EditorTool.TEXT) { + editingTextElement = element + selectedColor = element.color + showTextDialog = true + } + }) + } + } + ) { + LaunchedEffect(currentOffset, currentScale, currentRotation) { + val idx = textElements.indexOfFirst { it.id == element.id } + if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { + textElements[idx] = textElements[idx].copy( + offset = currentOffset, scale = currentScale, rotation = currentRotation + ) } } - ) { - LaunchedEffect(currentOffset, currentScale, currentRotation) { - val idx = textElements.indexOfFirst { it.id == element.id } - if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { - textElements[idx] = textElements[idx].copy( - offset = currentOffset, - scale = currentScale, - rotation = currentRotation - ) - } - } - - Text( - text = element.text, - color = element.color, - style = MaterialTheme.typography.headlineLarge.copy( - shadow = Shadow( - color = Color.Black, - offset = Offset(2f, 2f), - blurRadius = 4f + Text( + text = element.text, + color = element.color, + style = MaterialTheme.typography.headlineLarge.copy( + shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f) ) ) - ) - - if (currentTool == EditorTool.TEXT) { - Box( - modifier = Modifier - .matchParentSize() - .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) - ) + if (currentTool == EditorTool.TEXT) { + Box( + modifier = Modifier + .matchParentSize() + .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + ) + } } } } } + if (currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero) { + CropScrim(cropRect = cropState.cropRect) + } + AnimatedVisibility( - visible = currentTool == EditorTool.TRANSFORM, + visible = currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero, enter = fadeIn(), exit = fadeOut() ) { - CropOverlay() + CropOverlay( + cropRect = cropState.cropRect, + bounds = cropState.currentImageBounds, + minCropSizePx = cropState.minCropSizePx, + onCropRectChange = cropState.updateCropRect, + onContentTransform = { centroid, pan, zoom -> applyTransform(centroid, pan, zoom) }, + onResizeEnded = { fillAreaAfterResize() } + ) } if (isSaving) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt index 344bb578..1b534410 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.* import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix @@ -17,6 +18,8 @@ import java.io.FileOutputStream import java.util.* import android.graphics.Canvas as AndroidCanvas import android.graphics.Paint as AndroidPaint +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation data class DrawnPath( val path: Path, @@ -114,11 +117,17 @@ suspend fun saveImage( textElements: List, filter: ImageFilter?, canvasSize: IntSize, + cropRect: Rect, + transformPivot: Offset = cropRect.center, imageRotation: Float = 0f, imageScale: Float = 1f, imageOffset: Offset = Offset.Zero ): String? = withContext(Dispatchers.IO) { try { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || cropRect.width <= 0f || cropRect.height <= 0f) { + return@withContext null + } + val options = BitmapFactory.Options().apply { inMutable = true } var bitmap = BitmapFactory.decodeFile(originalPath, options) ?: return@withContext null @@ -150,66 +159,84 @@ suspend fun saveImage( dx = (screenW - (bitmapW * baseScale)) / 2f } - val resultBitmap = Bitmap.createBitmap(canvasSize.width, canvasSize.height, Bitmap.Config.ARGB_8888) + val exportScale = if (baseScale > 0f) 1f / baseScale else 1f + val resultBitmap = createBitmap( + (cropRect.width * exportScale).toInt().coerceAtLeast(1), + (cropRect.height * exportScale).toInt().coerceAtLeast(1) + ) val canvas = AndroidCanvas(resultBitmap) + val transformPivotX = transformPivot.x * exportScale + val transformPivotY = transformPivot.y * exportScale + val scaledCropLeft = cropRect.left * exportScale + val scaledCropTop = cropRect.top * exportScale + val scaledImageOffset = Offset(imageOffset.x * exportScale, imageOffset.y * exportScale) + + canvas.translate(-scaledCropLeft, -scaledCropTop) + + canvas.withTranslation(scaledImageOffset.x + transformPivotX, scaledImageOffset.y + transformPivotY) { + rotate(imageRotation) + scale(imageScale, imageScale) + translate(-transformPivotX, -transformPivotY) + + val imagePaint = AndroidPaint().apply { + isAntiAlias = true + if (filter != null) { + colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + } + } + val destRect = RectF( + dx * exportScale, + dy * exportScale, + dx * exportScale + bitmapW, + dy * exportScale + bitmapH + ) + drawBitmap(bitmap, null, destRect, imagePaint) - canvas.save() - canvas.translate(imageOffset.x + screenW / 2f, imageOffset.y + screenH / 2f) - canvas.rotate(imageRotation) - canvas.scale(imageScale, imageScale) - canvas.translate(-screenW / 2f, -screenH / 2f) + val pathPaint = AndroidPaint().apply { + isAntiAlias = true + style = AndroidPaint.Style.STROKE + strokeCap = AndroidPaint.Cap.ROUND + strokeJoin = AndroidPaint.Join.ROUND + } - val imagePaint = AndroidPaint().apply { - isAntiAlias = true - if (filter != null) { - colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + paths.forEach { pathData -> + if (pathData.isEraser) { + pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } else { + pathPaint.xfermode = null + pathPaint.color = pathData.color.toArgb() + pathPaint.alpha = (pathData.alpha * 255).toInt() + } + pathPaint.strokeWidth = pathData.strokeWidth * exportScale + val scaledPath = android.graphics.Path(pathData.path.asAndroidPath()) + scaledPath.transform( + android.graphics.Matrix().apply { setScale(exportScale, exportScale) } + ) + drawPath(scaledPath, pathPaint) } - } - val destRect = RectF(dx, dy, dx + bitmapW * baseScale, dy + bitmapH * baseScale) - canvas.drawBitmap(bitmap, null, destRect, imagePaint) - - val pathPaint = AndroidPaint().apply { - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - paths.forEach { pathData -> - if (pathData.isEraser) { - pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - } else { - pathPaint.xfermode = null - pathPaint.color = pathData.color.toArgb() - pathPaint.alpha = (pathData.alpha * 255).toInt() + val textPaint = AndroidPaint().apply { + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD } - pathPaint.strokeWidth = pathData.strokeWidth - canvas.drawPath(pathData.path.asAndroidPath(), pathPaint) - } - val textPaint = AndroidPaint().apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } + textElements.forEach { element -> + textPaint.color = element.color.toArgb() + textPaint.textSize = 64f * element.scale * exportScale - textElements.forEach { element -> - textPaint.color = element.color.toArgb() - textPaint.textSize = 64f * element.scale + withTranslation(element.offset.x * exportScale, element.offset.y * exportScale) { + rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) - canvas.save() - canvas.translate(element.offset.x, element.offset.y) - canvas.rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) + val textWidth = textPaint.measureText(element.text) + val fontMetrics = textPaint.fontMetrics + val textHeight = fontMetrics.descent - fontMetrics.ascent - val textWidth = textPaint.measureText(element.text) - val fontMetrics = textPaint.fontMetrics - val textHeight = fontMetrics.descent - fontMetrics.ascent + drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) + } + } - canvas.drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) - canvas.restore() } - canvas.restore() - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { out -> resultBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt deleted file mode 100644 index e9841bfd..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -@Composable -fun CropOverlay( - modifier: Modifier = Modifier -) { - Canvas( - modifier = modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - ) { - val width = size.width - val height = size.height - - val padding = 32.dp.toPx() - val cropWidth = width - padding * 2 - val cropHeight = height - padding * 2 - - val rect = Rect( - offset = Offset(padding, padding), - size = Size(cropWidth, cropHeight) - ) - - drawRect( - color = Color.Black.copy(alpha = 0.7f), - size = size - ) - - drawRect( - color = Color.Transparent, - topLeft = rect.topLeft, - size = rect.size, - blendMode = BlendMode.Clear - ) - - val strokeWidth = 1.dp.toPx() - val gridColor = Color.White.copy(alpha = 0.5f) - - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width / 3, rect.top), - end = Offset(rect.left + rect.width / 3, rect.bottom), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width * 2 / 3, rect.top), - end = Offset(rect.left + rect.width * 2 / 3, rect.bottom), - strokeWidth = strokeWidth - ) - - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height / 3), - end = Offset(rect.right, rect.top + rect.height / 3), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height * 2 / 3), - end = Offset(rect.right, rect.top + rect.height * 2 / 3), - strokeWidth = strokeWidth - ) - - val cornerLen = 24.dp.toPx() - val cornerStroke = 3.dp.toPx() - val cornerColor = Color.White - - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.topRight, rect.topRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.topRight, rect.topRight.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawRect( - color = Color.White.copy(alpha = 0.8f), - topLeft = rect.topLeft, - size = rect.size, - style = Stroke(width = 1.dp.toPx()) - ) - } -} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt index 99f663fe..c4fa9966 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt @@ -1,79 +1,205 @@ package org.monogram.presentation.features.chats.currentChat.editor.photo.components +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RotateLeft -import androidx.compose.material.icons.rounded.RotateRight +import androidx.compose.material.icons.rounded.Rotate90DegreesCw import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R +import kotlin.math.absoluteValue +import kotlin.math.floor +import kotlin.math.roundToInt @Composable fun TransformControls( rotation: Float, - scale: Float, onRotationChange: (Float) -> Unit, - onScaleChange: (Float) -> Unit, + onRotateClockwise: () -> Unit, onReset: () -> Unit ) { + val normalizedRotation = normalizeRotationDegrees(rotation) + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + verticalAlignment = Alignment.Bottom ) { - FilledTonalIconButton(onClick = { onRotationChange(rotation - 90f) }) { - Icon( - Icons.Rounded.RotateLeft, - contentDescription = stringResource(R.string.photo_editor_action_rotate_left) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${normalizedRotation.roundToInt()}°", + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RotationWheel( + angle = rotation, + onAngleChange = onRotationChange, + modifier = Modifier.fillMaxWidth() ) } - OutlinedButton( - onClick = onReset, - contentPadding = PaddingValues(horizontal = 16.dp) + Spacer(modifier = Modifier.width(12.dp)) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.photo_editor_action_reset)) - } + OutlinedIconButton( + onClick = onRotateClockwise, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Rotate90DegreesCw, + contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + ) + } - FilledTonalIconButton(onClick = { onRotationChange(rotation + 90f) }) { - Icon( - Icons.Rounded.RotateRight, - contentDescription = stringResource(R.string.photo_editor_action_rotate_right) - ) + OutlinedIconButton( + onClick = onReset, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) + ) + } } } + } +} + +@Composable +private fun RotationWheel( + angle: Float, + onAngleChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val onSurface = MaterialTheme.colorScheme.onSurface + val primary = MaterialTheme.colorScheme.primary + var visualAngle by remember { mutableFloatStateOf(angle) } - Spacer(modifier = Modifier.height(12.dp)) + LaunchedEffect(angle) { + visualAngle = closestEquivalentAngle(angle, visualAngle) + } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.large + ) + .pointerInput(Unit) { + var dragAngle = visualAngle + detectDragGestures( + onDragStart = { + dragAngle = visualAngle + } + ) { change, dragAmount -> + change.consume() + dragAngle -= dragAmount.x * 0.1f + visualAngle = dragAngle + onAngleChange(dragAngle) + } + } + .padding(horizontal = 12.dp, vertical = 8.dp) ) { - Text( - stringResource(R.string.photo_editor_label_zoom), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(48.dp) - ) - Slider( - value = scale, - onValueChange = onScaleChange, - valueRange = 0.5f..3f, - modifier = Modifier.weight(1f) - ) + Canvas(modifier = Modifier.fillMaxWidth().height(42.dp)) { + val centerX = size.width / 2f + val bottom = size.height + val tickSpacing = 6f + val minorStep = 5 + val majorStep = 45 + val currentTick = visualAngle / minorStep + val centerTick = floor(currentTick).toInt() + val visibleTickCount = (size.width / tickSpacing / 2f).roundToInt() + 4 + + for (relativeTick in -visibleTickCount..visibleTickCount) { + val tickIndex = centerTick + relativeTick + val tickAngle = tickIndex * minorStep + val x = centerX + (tickIndex - currentTick) * tickSpacing + if (x < 0f || x > size.width) continue + + val distanceRatio = ((x - centerX).absoluteValue / centerX).coerceIn(0f, 1f) + val alpha = 1f - distanceRatio * 0.8f + val isMajor = tickAngle % majorStep == 0 + val isMedium = tickAngle % 15 == 0 + val tickHeight = when { + isMajor -> size.height * 0.62f + isMedium -> size.height * 0.45f + else -> size.height * 0.28f + } + val strokeWidth = when { + isMajor -> 3f + isMedium -> 2.5f + else -> 1.5f + } + + drawLine( + color = onSurface.copy(alpha = alpha), + start = Offset(x, bottom - tickHeight), + end = Offset(x, bottom), + strokeWidth = strokeWidth + ) + } + + drawLine( + color = primary, + start = Offset(centerX, 0f), + end = Offset(centerX, bottom), + strokeWidth = 4f + ) + } } } } + +internal fun normalizeRotationDegrees(value: Float): Float { + var normalized = value % 360f + if (normalized > 180f) normalized -= 360f + if (normalized < -180f) normalized += 360f + return normalized +} + +internal fun rotateClockwiseAnimationTarget(value: Float): Float { + val snappedRotation = (value / 90f).roundToInt() * 90f + return snappedRotation + 90f +} + +internal fun rotateClockwiseToNextRightAngle(value: Float): Float { + return normalizeRotationDegrees(rotateClockwiseAnimationTarget(value)) +} + +private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { + val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() + return normalizedAngle + turns * 360f +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt new file mode 100644 index 00000000..ad405297 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt @@ -0,0 +1,145 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +@Stable +class CropEditorState internal constructor( + val minCropSizePx: Float, + val imageBounds: Rect, + val currentImageBounds: Rect, + val defaultCropRect: Rect, + val cropRect: Rect, + val updateCropRect: (Rect) -> Unit, + val setCropRect: (Rect) -> Unit, + val reset: () -> Unit +) + +fun calculateTargetFillRect(canvasSize: IntSize, aspectRatio: Float): Rect { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || aspectRatio <= 0f) return Rect.Zero + val cw = canvasSize.width.toFloat() + val ch = canvasSize.height.toFloat() + val centerX = cw / 2f + val centerY = ch / 2f + + val w: Float + val h: Float + if (ch * aspectRatio > cw) { + w = cw + h = cw / aspectRatio + } else { + h = ch + w = ch * aspectRatio + } + return Rect(centerX - w / 2f, centerY - h / 2f, centerX + w / 2f, centerY + h / 2f) +} + +@Composable +fun rememberCropEditorState( + canvasSize: IntSize, + imageSize: IntSize, + transformPivot: Offset, + imageScale: Float, + imageRotation: Float, + imageOffset: Offset +): CropEditorState { + val density = LocalDensity.current + val minCropSizePx = remember(density) { with(density) { 96.dp.toPx() } } + + val imageBounds by remember(canvasSize, imageSize) { + derivedStateOf { calculateCropRect(canvasSize, imageSize) } + } + val defaultCropRect by remember(imageBounds) { + derivedStateOf { imageBounds } + } + + var cropRect by remember { mutableStateOf(Rect.Zero) } + var previousImageBounds by remember { mutableStateOf(Rect.Zero) } + + val currentImageBounds by remember(imageBounds, imageScale, imageRotation, imageOffset, transformPivot) { + derivedStateOf { + if (imageBounds != Rect.Zero) { + calculateScalarTransformedBounds( + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + } else { + imageBounds + } + } + } + + LaunchedEffect(imageBounds) { + if (imageBounds == Rect.Zero) { + cropRect = Rect.Zero + previousImageBounds = Rect.Zero + } else if (cropRect == Rect.Zero || previousImageBounds == Rect.Zero) { + cropRect = imageBounds + previousImageBounds = imageBounds + } else if (previousImageBounds != imageBounds) { + cropRect = constrainCropRect( + cropRect = remapRectToBounds(cropRect, previousImageBounds, imageBounds), + bounds = imageBounds, + minCropSizePx = minCropSizePx + ) + previousImageBounds = imageBounds + } + } + + return CropEditorState( + minCropSizePx = minCropSizePx, + imageBounds = imageBounds, + currentImageBounds = currentImageBounds, + defaultCropRect = defaultCropRect, + cropRect = cropRect, + updateCropRect = { candidate -> + cropRect = constrainCropRectToImage( + currentCropRect = cropRect, + candidateRect = candidate, + visibleBounds = currentImageBounds, + minCropSizePx = minCropSizePx, + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + }, + setCropRect = { rect -> + cropRect = rect + }, + reset = { + cropRect = defaultCropRect + } + ) +} + +private fun remapRectToBounds(rect: Rect, fromBounds: Rect, toBounds: Rect): Rect { + if (rect == Rect.Zero || fromBounds.width <= 0f || fromBounds.height <= 0f) return toBounds + + val leftFraction = (rect.left - fromBounds.left) / fromBounds.width + val topFraction = (rect.top - fromBounds.top) / fromBounds.height + val rightFraction = (rect.right - fromBounds.left) / fromBounds.width + val bottomFraction = (rect.bottom - fromBounds.top) / fromBounds.height + + return Rect( + left = toBounds.left + toBounds.width * leftFraction, + top = toBounds.top + toBounds.height * topFraction, + right = toBounds.left + toBounds.width * rightFraction, + bottom = toBounds.top + toBounds.height * bottomFraction + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt new file mode 100644 index 00000000..d742f860 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt @@ -0,0 +1,339 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +private const val GeometryEpsilon = 0.001f + +private fun contentToScreen( + p: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val dx = p.x - pivot.x + val dy = p.y - pivot.y + return Offset( + x = pivot.x + scale * (dx * cosR - dy * sinR) + offset.x, + y = pivot.y + scale * (dx * sinR + dy * cosR) + offset.y + ) +} + +private fun screenToContent( + screen: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val sx = (screen.x - pivot.x - offset.x) / scale + val sy = (screen.y - pivot.y - offset.y) / scale + // R(-rotation) = transpose of R(rotation) + return Offset( + x = pivot.x + sx * cosR + sy * sinR, + y = pivot.y - sx * sinR + sy * cosR + ) +} + +private fun projectRectCornersToContentBounds( + rect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + if (rect.isEmpty || scale <= 0f) return Rect.Zero + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (corner in corners) { + val contentPoint = screenToContent(corner, scale, cosR, sinR, offset, pivot) + minX = min(minX, contentPoint.x) + minY = min(minY, contentPoint.y) + maxX = max(maxX, contentPoint.x) + maxY = max(maxY, contentPoint.y) + } + + return Rect(minX, minY, maxX, maxY) +} + +private fun rectCorners(rect: Rect): Array = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft +) + +private fun Rect.containsWithTolerance(point: Offset, epsilon: Float = GeometryEpsilon): Boolean { + return point.x >= left - epsilon && point.x <= right + epsilon && + point.y >= top - epsilon && point.y <= bottom + epsilon +} + +private fun lerpRect(start: Rect, end: Rect, fraction: Float): Rect { + return Rect( + left = start.left + (end.left - start.left) * fraction, + top = start.top + (end.top - start.top) * fraction, + right = start.right + (end.right - start.right) * fraction, + bottom = start.bottom + (end.bottom - start.bottom) * fraction + ) +} + +fun calculateScalarTransformedBounds( + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + baseBounds.topLeft, baseBounds.topRight, + baseBounds.bottomRight, baseBounds.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (p in corners) { + val s = contentToScreen(p, scale, cosR, sinR, offset, pivot) + minX = min(minX, s.x); minY = min(minY, s.y) + maxX = max(maxX, s.x); maxY = max(maxY, s.y) + } + return Rect(minX, minY, maxX, maxY) +} + +internal fun isCropRectCoveredByImage( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Boolean { + if (baseBounds.isEmpty || cropRect.isEmpty || scale <= 0f) return false + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + return rectCorners(cropRect).all { corner -> + baseBounds.containsWithTolerance( + point = screenToContent( + screen = corner, + scale = scale, + cosR = cosR, + sinR = sinR, + offset = offset, + pivot = pivot + ) + ) + } +} + +internal fun constrainCropRectToImage( + currentCropRect: Rect, + candidateRect: Rect, + visibleBounds: Rect, + minCropSizePx: Float, + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val constrainedCandidate = constrainCropRect( + cropRect = candidateRect, + bounds = visibleBounds, + minCropSizePx = minCropSizePx + ) + + if (baseBounds.isEmpty || constrainedCandidate.isEmpty || scale <= 0f) return constrainedCandidate + if (isCropRectCoveredByImage(baseBounds, constrainedCandidate, scale, rotationDegrees, offset, pivot)) { + return constrainedCandidate + } + if (!isCropRectCoveredByImage(baseBounds, currentCropRect, scale, rotationDegrees, offset, pivot)) { + return currentCropRect + } + + var low = 0f + var high = 1f + repeat(20) { + val mid = (low + high) / 2f + val interpolated = lerpRect(currentCropRect, constrainedCandidate, mid) + if (isCropRectCoveredByImage(baseBounds, interpolated, scale, rotationDegrees, offset, pivot)) { + low = mid + } else { + high = mid + } + } + + return lerpRect(currentCropRect, constrainedCandidate, low) +} + +fun offsetForZoomAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + zoom: Float +): Offset { + return Offset( + x = (1f - zoom) * (anchor.x - pivot.x) + zoom * currentOffset.x, + y = (1f - zoom) * (anchor.y - pivot.y) + zoom * currentOffset.y + ) +} + +fun offsetForRotationAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + deltaAngleDegrees: Float +): Offset { + val rad = Math.toRadians(deltaAngleDegrees.toDouble()) + val cosD = cos(rad).toFloat() + val sinD = sin(rad).toFloat() + + val vx = anchor.x - pivot.x - currentOffset.x + val vy = anchor.y - pivot.y - currentOffset.y + val rvx = vx * cosD - vy * sinD + val rvy = vx * sinD + vy * cosD + + return Offset( + x = anchor.x - pivot.x - rvx, + y = anchor.y - pivot.y - rvy + ) +} + +fun clampOffsetToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Offset { + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return offset + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + var cdx = 0f + var cdy = 0f + + if (cropContentBounds.left < baseBounds.left) { + cdx = baseBounds.left - cropContentBounds.left + } else if (cropContentBounds.right > baseBounds.right) { + cdx = baseBounds.right - cropContentBounds.right + } + + if (cropContentBounds.top < baseBounds.top) { + cdy = baseBounds.top - cropContentBounds.top + } else if (cropContentBounds.bottom > baseBounds.bottom) { + cdy = baseBounds.bottom - cropContentBounds.bottom + } + + if (cdx == 0f && cdy == 0f) return offset + + val screenDx = -scale * (cdx * cosR - cdy * sinR) + val screenDy = -scale * (cdx * sinR + cdy * cosR) + + return Offset(offset.x + screenDx, offset.y + screenDy) +} + +fun fitContentInBounds( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset, + minScale: Float = 0.5f, + maxScale: Float = 30f +): Pair { + if (baseBounds.isEmpty || cropRect.isEmpty) return Pair(scale, offset) + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return Pair(scale, offset) + + val cropContentW = cropContentBounds.width + val cropContentH = cropContentBounds.height + var newScale = scale + + if (cropContentW > baseBounds.width || cropContentH > baseBounds.height) { + val scaleX = if (baseBounds.width > 0f) cropContentW / baseBounds.width else 1f + val scaleY = if (baseBounds.height > 0f) cropContentH / baseBounds.height else 1f + val correction = max(scaleX, scaleY) + newScale = (scale * correction).coerceIn(minScale, maxScale) + } + + val newOffset = if (newScale != scale) { + val zoomFactor = newScale / scale + offsetForZoomAroundAnchor(offset, pivot, cropRect.center, zoomFactor) + } else { + offset + } + + val clampedOffset = clampOffsetToCoverCrop(baseBounds, cropRect, newScale, rotationDegrees, newOffset, pivot) + return Pair(newScale, clampedOffset) +} + +fun minimumScaleToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + currentScale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Float { + if (baseBounds.isEmpty || cropRect.isEmpty || currentScale <= 0f) return currentScale + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = currentScale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return currentScale + + val contentSpanX = cropContentBounds.width + val contentSpanY = cropContentBounds.height + + var minScale = 0f + if (baseBounds.width > 0f && contentSpanX > 0f) { + minScale = max(minScale, currentScale * contentSpanX / baseBounds.width) + } + if (baseBounds.height > 0f && contentSpanY > 0f) { + minScale = max(minScale, currentScale * contentSpanY / baseBounds.height) + } + return minScale +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt new file mode 100644 index 00000000..1a884fd2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt @@ -0,0 +1,257 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +fun calculateCropRect(bounds: IntSize, imageSize: IntSize): Rect { + if (bounds.width <= 0 || bounds.height <= 0 || imageSize.width <= 0 || imageSize.height <= 0) { + return Rect.Zero + } + val imageAspect = imageSize.width.toFloat() / imageSize.height.toFloat() + val canvasAspect = bounds.width.toFloat() / bounds.height.toFloat() + return if (imageAspect > canvasAspect) { + val fittedHeight = bounds.width / imageAspect + val top = (bounds.height - fittedHeight) / 2f + Rect(0f, top, bounds.width.toFloat(), top + fittedHeight) + } else { + val fittedWidth = bounds.height * imageAspect + val left = (bounds.width - fittedWidth) / 2f + Rect(left, 0f, left + fittedWidth, bounds.height.toFloat()) + } +} + +fun constrainCropRect(cropRect: Rect, bounds: Rect, minCropSizePx: Float): Rect { + val b = Rect( + left = minOf(bounds.left, bounds.right), + top = minOf(bounds.top, bounds.bottom), + right = maxOf(bounds.left, bounds.right), + bottom = maxOf(bounds.top, bounds.bottom) + ) + if (b.width <= 0f || b.height <= 0f) return cropRect + val minW = minCropSizePx.coerceAtMost(b.width) + val minH = minCropSizePx.coerceAtMost(b.height) + val w = cropRect.width.coerceIn(minW, b.width) + val h = cropRect.height.coerceIn(minH, b.height) + val l = cropRect.left.coerceIn(b.left, (b.right - w).coerceAtLeast(b.left)) + val t = cropRect.top.coerceIn(b.top, (b.bottom - h).coerceAtLeast(b.top)) + return Rect(l, t, l + w, t + h) +} + + + +private enum class CropHandle { + NONE, MOVE, + TOP_LEFT, TOP, TOP_RIGHT, RIGHT, + BOTTOM_RIGHT, BOTTOM, BOTTOM_LEFT, LEFT +} + + + +@Composable +fun CropScrim(cropRect: Rect, modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + drawRect(Color.Black.copy(alpha = 0.7f), size = size) + drawRect(Color.Transparent, topLeft = cropRect.topLeft, size = cropRect.size, blendMode = BlendMode.Clear) + } +} + + + +@Composable +fun CropOverlay( + cropRect: Rect, + bounds: Rect, + minCropSizePx: Float, + onCropRectChange: (Rect) -> Unit, + onContentTransform: (centroid: Offset, pan: Offset, zoom: Float) -> Unit = { _, _, _ -> }, + onResizeEnded: () -> Unit = {}, + onDragStateChange: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier +) { + val currentCropRect by rememberUpdatedState(cropRect) + val currentOnCropRectChange by rememberUpdatedState(onCropRectChange) + val currentOnContentTransform by rememberUpdatedState(onContentTransform) + val currentOnResizeEnded by rememberUpdatedState(onResizeEnded) + val currentOnDragStateChange by rememberUpdatedState(onDragStateChange) + val handleTouchRadiusPx = 28.dp + val cornerHandleZonePx = 44.dp + val sideHandleLengthPx = 36.dp + val sideTouchInsetPx = 24.dp + + + var isResizing by remember { mutableStateOf(false) } + + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + + .pointerInput(bounds, minCropSizePx) { + val handleTouchRadius = handleTouchRadiusPx.toPx() + val cornerHandleZone = cornerHandleZonePx.toPx() + val sideTouchInset = sideTouchInsetPx.toPx() + + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + val activeHandle = pickCropHandle( + down.position, currentCropRect, + handleTouchRadius, cornerHandleZone, sideTouchInset + ) + + if (activeHandle == CropHandle.NONE || activeHandle == CropHandle.MOVE) { + return@awaitEachGesture + } + + var dragRect = currentCropRect + down.consume() + isResizing = true + currentOnDragStateChange(true) + + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val primary = event.changes.firstOrNull { it.id == down.id } + ?: event.changes.firstOrNull { it.pressed } + if (primary == null || !primary.pressed) break + + val drag = primary.position - primary.previousPosition + if (drag == Offset.Zero) continue + primary.consume() + + dragRect = resizeCropRect(dragRect, activeHandle, drag, bounds, minCropSizePx) + currentOnCropRectChange(dragRect) + } + } finally { + isResizing = false + currentOnResizeEnded() + currentOnDragStateChange(false) + } + } + } + + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (!isResizing) { + currentOnContentTransform(centroid, pan, zoom) + } + } + } + ) { + + val strokeWidth = 1.dp.toPx() + val gridColor = Color.White.copy(alpha = 0.5f) + drawLine(gridColor, Offset(cropRect.left + cropRect.width / 3, cropRect.top), Offset(cropRect.left + cropRect.width / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.top), Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height / 3), Offset(cropRect.right, cropRect.top + cropRect.height / 3), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height * 2 / 3), Offset(cropRect.right, cropRect.top + cropRect.height * 2 / 3), strokeWidth) + + + val cornerLen = 24.dp.toPx() + val cornerStroke = 3.dp.toPx() + val white = Color.White + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(y = cropRect.bottom - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(y = cropRect.bottom - cornerLen), cornerStroke) + + + val sideLen = sideHandleLengthPx.toPx() + val sideStroke = 4.dp.toPx() + val cx = cropRect.left + cropRect.width / 2f + val cy = cropRect.top + cropRect.height / 2f + val half = sideLen / 2f + drawLine(white, Offset(cx - half, cropRect.top), Offset(cx + half, cropRect.top), sideStroke) + drawLine(white, Offset(cx - half, cropRect.bottom), Offset(cx + half, cropRect.bottom), sideStroke) + drawLine(white, Offset(cropRect.left, cy - half), Offset(cropRect.left, cy + half), sideStroke) + drawLine(white, Offset(cropRect.right, cy - half), Offset(cropRect.right, cy + half), sideStroke) + + + drawRect(Color.White.copy(alpha = 0.8f), cropRect.topLeft, cropRect.size, style = Stroke(1.dp.toPx())) + } +} + + + +private fun pickCropHandle( + point: Offset, crop: Rect, + touchRadius: Float, cornerZone: Float, sideInset: Float +): CropHandle { + val topBand = (crop.top - sideInset)..(crop.top + sideInset) + val bottomBand = (crop.bottom - sideInset)..(crop.bottom + sideInset) + val leftBand = (crop.left - sideInset)..(crop.left + sideInset) + val rightBand = (crop.right - sideInset)..(crop.right + sideInset) + val inH = point.x in crop.left..crop.right + val inV = point.y in crop.top..crop.bottom + return when { + inCorner(point, crop, CropHandle.TOP_LEFT, touchRadius, cornerZone) -> CropHandle.TOP_LEFT + inCorner(point, crop, CropHandle.TOP_RIGHT, touchRadius, cornerZone) -> CropHandle.TOP_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_RIGHT, touchRadius, cornerZone) -> CropHandle.BOTTOM_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_LEFT, touchRadius, cornerZone) -> CropHandle.BOTTOM_LEFT + inH && point.y in topBand -> CropHandle.TOP + inV && point.x in rightBand -> CropHandle.RIGHT + inH && point.y in bottomBand -> CropHandle.BOTTOM + inV && point.x in leftBand -> CropHandle.LEFT + inH && inV -> CropHandle.MOVE + else -> CropHandle.NONE + } +} + +private fun resizeCropRect(crop: Rect, handle: CropHandle, drag: Offset, bounds: Rect, minSize: Float): Rect { + if (bounds.width <= 0f || bounds.height <= 0f) return crop + val minW = minSize.coerceAtMost(bounds.width) + val minH = minSize.coerceAtMost(bounds.height) + var l = crop.left; var t = crop.top; var r = crop.right; var b = crop.bottom + when (handle) { + CropHandle.MOVE -> { /* not used here */ } + CropHandle.TOP_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP -> { t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right) } + CropHandle.BOTTOM_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM -> { b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW) } + CropHandle.NONE -> {} + } + return Rect(l, t, r, b) +} + +private fun inCorner(point: Offset, crop: Rect, handle: CropHandle, radius: Float, zone: Float): Boolean { + val r = when (handle) { + CropHandle.TOP_LEFT -> Rect(crop.left - radius, crop.top - radius, crop.left + zone, crop.top + zone) + CropHandle.TOP_RIGHT -> Rect(crop.right - zone, crop.top - radius, crop.right + radius, crop.top + zone) + CropHandle.BOTTOM_RIGHT -> Rect(crop.right - zone, crop.bottom - zone, crop.right + radius, crop.bottom + radius) + CropHandle.BOTTOM_LEFT -> Rect(crop.left - radius, crop.bottom - zone, crop.left + zone, crop.bottom + radius) + else -> return false + } + return point.x in r.left..r.right && point.y in r.top..r.bottom +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt index b2b01b23..3bc2b5fa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt @@ -32,6 +32,19 @@ internal fun DefaultChatComponent.loadChatInfo() { } } } + + runCatching { chatInfoRepository.getChatFullInfo(chatId) } + .getOrNull() + ?.let { fullInfo -> + _state.update { + it.copy( + slowModeDelay = fullInfo.slowModeDelay, + slowModeDelayExpiresIn = fullInfo.slowModeDelayExpiresIn + ) + } + } + + refreshCurrentUserRestrictionState() } chatListRepository.chatListFlow @@ -65,6 +78,14 @@ internal fun DefaultChatComponent.loadChatInfo() { } .launchIn(scope) + chatListRepository.chatListFlow + .map { chats -> chats.find { it.id == chatId } } + .filterNotNull() + .map { chat -> chat.permissions to chat.isMember } + .distinctUntilChanged() + .onEach { refreshCurrentUserRestrictionState() } + .launchIn(scope) + forumTopicsRepository.forumTopicsFlow .filter { it.first == chatId } .onEach { (_, topics) -> @@ -203,4 +224,17 @@ internal fun DefaultChatComponent.handleConfirmRestrict( ) _state.update { it.copy(restrictUserId = null) } } -} \ No newline at end of file +} + +private suspend fun DefaultChatComponent.refreshCurrentUserRestrictionState() { + val me = runCatching { userRepository.getMe() }.getOrNull() ?: return + val status = runCatching { chatInfoRepository.getChatMember(chatId, me.id)?.status }.getOrNull() + val restrictedStatus = status as? ChatMemberStatus.Restricted + + _state.update { + it.copy( + isCurrentUserRestricted = restrictedStatus != null, + restrictedUntilDate = restrictedStatus?.restrictedUntilDate ?: 0 + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt index c3bed7b9..57d80f27 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt @@ -1,20 +1,32 @@ package org.monogram.presentation.features.chats.currentChat.impl import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock -import org.monogram.domain.models.* +import kotlinx.coroutines.withContext +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageDownloadEvent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel +import org.monogram.domain.models.MessageSendingState +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ReadUpdate import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression +import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.currentChat.ScrollAlign import java.io.File private const val PAGE_SIZE = 50 private const val MAX_DOWNLOAD_RETRIES = 3 +private const val DEFAULT_SENDER_NAME = "User" private fun isUsableAvatarPath(path: String?): Boolean { if (path.isNullOrBlank()) return false @@ -57,6 +69,33 @@ private fun mergeSenderVisuals(previous: MessageModel, incoming: MessageModel): ) } +private fun MessageModel.needsSenderRefresh(): Boolean { + if (senderId <= 0L) return false + val hasPlaceholderName = senderName.isBlank() || senderName == DEFAULT_SENDER_NAME + val hasNoAvatar = senderAvatar.isNullOrBlank() && senderPersonalAvatar.isNullOrBlank() + return hasPlaceholderName || hasNoAvatar +} + +internal fun DefaultChatComponent.requestSenderRefreshIfNeeded(message: MessageModel) { + if (!message.needsSenderRefresh()) return + requestSenderRefresh(message.senderId) +} + +internal fun DefaultChatComponent.requestSenderRefresh(senderId: Long) { + if (senderId <= 0L) return + if (!pendingSenderRefreshes.add(senderId)) return + + scope.launch { + try { + repositoryMessage.invalidateSenderCache(senderId) + val user = userRepository.getUser(senderId) ?: return@launch + refreshMessagesForSender(senderId, user) + } finally { + pendingSenderRefreshes.remove(senderId) + } + } +} + private fun reactionsSemanticEqual( current: List, incoming: List @@ -134,6 +173,11 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( } else { emptyMap() } + val previousMessagesById = if (replace) { + state.messages.associateBy { it.id } + } else { + emptyMap() + } val isComments = state.rootMessage != null @@ -142,7 +186,7 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( var hasChanges = replace filteredNewMessages.forEach { msg -> - val previous = messageMap[msg.id] + val previous = messageMap[msg.id] ?: previousMessagesById[msg.id] val mergedMessage = if (previous != null) mergeSenderVisuals(previous, msg) else msg val restoredMessage = if (mergedMessage.reactions.isEmpty()) { val previousReactions = existingReactionsById[msg.id] @@ -193,7 +237,8 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } @@ -201,26 +246,75 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { val currentState = _state.value val threadId = currentState.currentTopicId val isComments = currentState.rootMessage != null - val savedScrollPosition = if (threadId == null) cacheProvider.getChatScrollPosition(chatId) else 0L + val savedViewport = cacheProvider.getChatViewport(chatId, threadId) + _state.update { it.copy(lastSavedViewport = savedViewport) } + + val chat = chatListRepository.getChatById(chatId) + val firstUnreadId = chat?.lastReadInboxMessageId?.let { lastRead -> + if (chat.unreadCount > 0) { + repositoryMessage.getMessagesNewer(chatId, lastRead, 1, threadId) + .firstOrNull()?.id + ?: lastRead.takeIf { it > 0L } + } else { + null + } + } if (isComments && threadId != null) { - loadComments(threadId) - } else if (savedScrollPosition != 0L) { - loadAroundMessage(savedScrollPosition, threadId, shouldHighlight = false) - } else { - val chat = chatListRepository.getChatById(chatId) - val firstUnreadId = chat?.lastReadInboxMessageId?.let { lastRead -> - if (chat.unreadCount > 0) { - repositoryMessage.getMessagesNewer(chatId, lastRead, 1, threadId).firstOrNull()?.id - ?: lastRead.takeIf { it > 0L } - } else null + val commentsAnchorId = savedViewport?.anchorMessageId + if (commentsAnchorId != null && !savedViewport.atBottom) { + loadAroundMessage( + messageId = commentsAnchorId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.RestoreViewport( + anchorMessageId = commentsAnchorId, + anchorOffsetPx = savedViewport.anchorOffsetPx, + atBottom = false + ) + ) + } else { + loadComments( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } - - if (firstUnreadId != null) { - loadAroundMessage(firstUnreadId, threadId, shouldHighlight = false) + } else if (firstUnreadId != null) { + loadAroundMessage( + messageId = firstUnreadId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.JumpToMessage( + messageId = firstUnreadId, + highlight = false, + align = ScrollAlign.Center, + animated = false + ) + ) + } else if (savedViewport != null) { + if (savedViewport.atBottom || savedViewport.anchorMessageId == null) { + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } else { - loadBottomMessages(threadId) + val savedAnchorId = savedViewport.anchorMessageId ?: return@launch + loadAroundMessage( + messageId = savedAnchorId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.RestoreViewport( + anchorMessageId = savedAnchorId, + anchorOffsetPx = savedViewport.anchorOffsetPx, + atBottom = false + ) + ) } + } else { + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } } catch (e: CancellationException) { throw e @@ -232,7 +326,10 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { } } -internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { +internal suspend fun DefaultChatComponent.loadComments( + threadId: Long, + scrollCommand: ChatScrollCommand? = ChatScrollCommand.ScrollToBottom(animated = false) +) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L val messages = repositoryMessage.getMessagesNewer(chatId, threadId, PAGE_SIZE, threadId) @@ -243,13 +340,20 @@ internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { isAtBottom = false, isLatestLoaded = reachedEnd, isOldestLoaded = true, - scrollToMessageId = messages.firstOrNull()?.id + scrollToMessageId = null ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } } -private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { +private suspend fun DefaultChatComponent.loadBottomMessages( + threadId: Long?, + scrollCommand: ChatScrollCommand? = null +) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L @@ -291,6 +395,10 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { } val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) + refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } if (!isOldestLoaded) { delay(100) loadMoreMessages() @@ -300,7 +408,13 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { private suspend fun DefaultChatComponent.loadAroundMessage( messageId: Long, threadId: Long?, - shouldHighlight: Boolean = true + shouldHighlight: Boolean = true, + scrollCommand: ChatScrollCommand? = ChatScrollCommand.JumpToMessage( + messageId = messageId, + highlight = shouldHighlight, + align = ScrollAlign.Center, + animated = true + ) ) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L @@ -311,16 +425,23 @@ private suspend fun DefaultChatComponent.loadAroundMessage( isAtBottom = false, isLatestLoaded = false, isOldestLoaded = false, - scrollToMessageId = messageId, + scrollToMessageId = null, highlightedMessageId = if (shouldHighlight) messageId else null ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } delay(100) loadMoreMessages() loadNewerMessages() } else { - loadBottomMessages(threadId) + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } } @@ -389,6 +510,7 @@ internal fun DefaultChatComponent.loadMoreMessages() { if (olderMessages.isNotEmpty()) { updateMessages(olderMessages) + refreshCachedSenderProfiles(olderMessages) } val afterSize = _state.value.messages.size @@ -461,6 +583,7 @@ internal fun DefaultChatComponent.loadNewerMessages() { if (newerMessages.isNotEmpty()) { updateMessages(newerMessages) + refreshCachedSenderProfiles(newerMessages) lastLoadedNewerId = anchorId } @@ -482,11 +605,22 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } try { - loadAroundMessage(messageId, _state.value.currentTopicId, shouldHighlight = true) + loadAroundMessage( + messageId = messageId, + threadId = _state.value.currentTopicId, + shouldHighlight = true, + scrollCommand = ChatScrollCommand.JumpToMessage( + messageId = messageId, + highlight = true, + align = ScrollAlign.Center, + animated = true + ) + ) } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to message", e) } finally { @@ -496,18 +630,32 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { } internal fun DefaultChatComponent.scrollToBottomInternal() { - if (_state.value.isLoading) return + val currentState = _state.value + if (currentState.messages.isNotEmpty() && currentState.isLatestLoaded) { + _state.update { + it.copy( + isAtBottom = true, + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(animated = true) + ) + } + return + } + if (currentState.isLoading) return cancelAllLoadingJobs() messageLoadingJob = scope.launch { _state.update { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } try { - loadBottomMessages(_state.value.currentTopicId) + loadBottomMessages( + threadId = _state.value.currentTopicId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = true) + ) } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to bottom", e) } finally { @@ -535,6 +683,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { _state.value.currentTopicId == null || message.threadId == _state.value.currentTopicId if (isCorrectThread) { updateMessages(listOf(message)) + requestSenderRefreshIfNeeded(message) _state.update { state -> state.copy( isLatestLoaded = if (message.isOutgoing || state.isAtBottom) true else state.isLatestLoaded @@ -546,7 +695,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { .launchIn(scope) repositoryMessage.messageIdUpdateFlow - .onEach { (cId, oldId, newMessage) -> + .onEach { event -> + val cId = event.chatId + val oldId = event.oldMessageId + val newMessage = event.message if (cId == chatId) { if (oldId != newMessage.id) { remappedMessageIds[oldId] = newMessage.id @@ -592,12 +744,20 @@ internal fun DefaultChatComponent.setupMessageCollectors() { ) } } + + val isCurrentThread = _state.value.currentTopicId == null || newMessage.threadId == _state.value.currentTopicId + if (isCurrentThread) { + requestSenderRefreshIfNeeded(newMessage) + } } } .launchIn(scope) repositoryMessage.messageUploadProgressFlow - .onEach { (messageId, progress) -> + .onEach { event -> + if (event.chatId != chatId) return@onEach + val messageId = event.messageId + val progress = event.progress updateMessageContent(messageId) { message -> val isUploading = progress < 1f && message.sendingState is MessageSendingState.Pending val newSendingState = if (progress >= 1f) null else message.sendingState @@ -616,262 +776,303 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } .launchIn(scope) - repositoryMessage.messageDownloadProgressFlow - .onEach { (messageId, progress) -> - updateMessageContent(messageId) { message -> - val isDownloading = progress < 1f - val newContent = when (val content = message.content) { - is MessageContent.Photo -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + repositoryMessage.messageDownloadFlow + .onEach { event -> + when (event) { + is MessageDownloadEvent.Progress -> { + if (event.chatId != chatId) return@onEach + updateMessageContent(event.messageId) { message -> + val isDownloading = event.progress < 1f + val newContent = when (val content = message.content) { + is MessageContent.Photo -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Video -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.Video -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.VideoNote -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.VideoNote -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Document -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.Document -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Gif -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.Gif -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Voice -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.Voice -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Sticker -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + is MessageContent.Sticker -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - else -> content + else -> content + } + message.copy(content = newContent) + } } - message.copy(content = newContent) - } - } - .launchIn(scope) - repositoryMessage.messageDownloadCancelledFlow - .onEach { messageId -> - var cancelledFileId = 0 - updateMessageContent(messageId) { message -> - val newContent = when (val content = message.content) { - is MessageContent.Photo -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageDownloadEvent.Cancelled -> { + if (event.chatId != chatId) return@onEach + var cancelledFileId = 0 + updateMessageContent(event.messageId) { message -> + val newContent = when (val content = message.content) { + is MessageContent.Photo -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Video -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Video -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.VideoNote -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.VideoNote -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Document -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Document -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Gif -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Gif -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Voice -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Voice -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Sticker -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Sticker -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - else -> content + else -> content + } + message.copy(content = newContent) + } + AutoDownloadSuppression.suppress(cancelledFileId) + if (cancelledFileId != 0) { + mediaDownloadRetryCount.remove(cancelledFileId) + } } - message.copy(content = newContent) - } - AutoDownloadSuppression.suppress(cancelledFileId) - if (cancelledFileId != 0) { - mediaDownloadRetryCount.remove(cancelledFileId) - } - } - .launchIn(scope) - repositoryMessage.messageDownloadCompletedFlow - .onEach { (messageId, downloadedFileId, path) -> - var fileIdToRetry: Int? = null - var mainFileId = 0 - var mainPathUpdated = false + is MessageDownloadEvent.Completed -> { + if (event.chatId != chatId) return@onEach + val messageId = event.messageId + val downloadedFileId = event.fileId + val path = event.path + var fileIdToRetry: Int? = null + var mainFileId = 0 + var mainPathUpdated = false + + updateMessageContent(messageId) { message -> + val isError = path.isEmpty() + val finalPath = path.ifEmpty { null } + + val newContent = when (val content = message.content) { + is MessageContent.Photo -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + if (finalPath != null) content.copy(thumbnailPath = finalPath) else content + } + } - updateMessageContent(messageId) { message -> - val isError = path.isEmpty() - val finalPath = path.ifEmpty { null } + is MessageContent.Video -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + if (finalPath != null) content.copy(thumbnailPath = finalPath) else content + } + } - val newContent = when (val content = message.content) { - is MessageContent.Photo -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - if (finalPath != null) content.copy(thumbnailPath = finalPath) else content - } - } + is MessageContent.VideoNote -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } - is MessageContent.Video -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - if (finalPath != null) content.copy(thumbnailPath = finalPath) else content - } - } + is MessageContent.Document -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } - is MessageContent.VideoNote -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } - } + is MessageContent.Gif -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } - is MessageContent.Document -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } - } + is MessageContent.Voice -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } - is MessageContent.Gif -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } - } + is MessageContent.Sticker -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } - is MessageContent.Voice -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content + else -> content } + message.copy(content = newContent) } - is MessageContent.Sticker -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } + if (path.isNotEmpty() && mainFileId != 0) { + AutoDownloadSuppression.clear(mainFileId) + mediaDownloadRetryCount.remove(mainFileId) } - else -> content - } - message.copy(content = newContent) - } - - if (path.isNotEmpty() && mainFileId != 0) { - AutoDownloadSuppression.clear(mainFileId) - mediaDownloadRetryCount.remove(mainFileId) - } + if (mainPathUpdated && path.isNotEmpty()) { + updateFullScreenImagePath(messageId, path) + } - if (mainPathUpdated && path.isNotEmpty()) { - updateFullScreenImagePath(messageId, path) - } + if (path.isNotEmpty() && messageId in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + updateInlineResultsWithFile(messageId.toInt(), path) + } - if (path.isNotEmpty() && messageId in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { - updateInlineResultsWithFile(messageId.toInt(), path) - } + if (path.isNotEmpty() && messageId == downloadedFileId.toLong()) { + refreshCachedSenderProfiles(_state.value.messages) + } - fileIdToRetry?.let { - if (it != 0) { - val suppressed = AutoDownloadSuppression.isSuppressed(it) - if (!suppressed) { - val attempts = (mediaDownloadRetryCount[it] ?: 0) + 1 - mediaDownloadRetryCount[it] = attempts - if (attempts <= MAX_DOWNLOAD_RETRIES) { - onDownloadFile(it) - } else { - AutoDownloadSuppression.suppress(it) - Log.w( - "DownloadDebug", - "retryLimitReached: fileId=$it attempts=$attempts chatId=$chatId" - ) + fileIdToRetry?.let { + if (it != 0) { + val suppressed = AutoDownloadSuppression.isSuppressed(it) + if (!suppressed) { + val attempts = (mediaDownloadRetryCount[it] ?: 0) + 1 + mediaDownloadRetryCount[it] = attempts + if (attempts <= MAX_DOWNLOAD_RETRIES) { + onDownloadFile(it) + } else { + AutoDownloadSuppression.suppress(it) + Log.w( + "DownloadDebug", + "retryLimitReached: fileId=$it attempts=$attempts chatId=$chatId" + ) + } + } else { + Log.d( + "DownloadDebug", + "retrySkippedBySuppression: fileId=$it chatId=$chatId" + ) + } } - } else { - Log.d("DownloadDebug", "retrySkippedBySuppression: fileId=$it chatId=$chatId") } } } @@ -879,7 +1080,9 @@ internal fun DefaultChatComponent.setupMessageCollectors() { .launchIn(scope) repositoryMessage.messageDeletedFlow - .onEach { (cId, messageIds) -> + .onEach { event -> + val cId = event.chatId + val messageIds = event.messageIds if (cId == chatId) { messageIds.forEach(reactionUpdateSuppressedUntil::remove) messageIds.forEach(remappedMessageIds::remove) @@ -1006,17 +1209,16 @@ private fun DefaultChatComponent.observeSenderUpdates() { .launchIn(scope) } -private suspend fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { +private fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { val senderIds = messages.asSequence() + .filter { it.needsSenderRefresh() } .map { it.senderId } .filter { it > 0L } .distinct() .toList() senderIds.forEach { senderId -> - repositoryMessage.invalidateSenderCache(senderId) - val user = userRepository.getUser(senderId) ?: return@forEach - refreshMessagesForSender(senderId, user) + requestSenderRefresh(senderId) } } @@ -1128,7 +1330,8 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { isOldestLoaded = false, isLatestLoaded = false, rootMessage = null, - isAtBottom = id == null + isAtBottom = id == null, + pendingScrollCommand = null ) } loadMessages(force = true) @@ -1144,7 +1347,8 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { messages = emptyList(), isOldestLoaded = false, isLatestLoaded = false, - isAtBottom = false + isAtBottom = false, + pendingScrollCommand = null ) } loadComments(messageId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt index 98795df8..1eeea807 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt @@ -18,6 +18,12 @@ internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { repositoryMessage.markAllMentionsAsRead(chatId) repositoryMessage.markAllReactionsAsRead(chatId) } + + _state.value.messages + .firstOrNull { it.id == messageId } + ?.let { visibleMessage -> + requestSenderRefreshIfNeeded(visibleMessage) + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt index 1ae23a2b..9da44438 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.currentChat.ScrollAlign internal fun DefaultChatComponent.loadPinnedMessage() { @@ -35,19 +37,29 @@ internal fun DefaultChatComponent.loadPinnedMessage() { internal fun DefaultChatComponent.loadAllPinnedMessages() { scope.launch { val threadId = _state.value.currentTopicId + _state.update { it.copy(isLoadingPinnedMessages = true) } try { val pinnedMessages = repositoryMessage.getAllPinnedMessages(chatId, threadId) _state.update { - it.copy(allPinnedMessages = pinnedMessages) + it.copy( + allPinnedMessages = pinnedMessages, + isLoadingPinnedMessages = false + ) } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error loading all pinned messages", e) + _state.update { it.copy(isLoadingPinnedMessages = false) } } } } internal fun DefaultChatComponent.loadScheduledMessages() { scope.launch { + if (!canLoadScheduledMessages()) { + _state.update { it.copy(scheduledMessages = emptyList()) } + return@launch + } + try { val scheduledMessages = repositoryMessage.getScheduledMessages(chatId) _state.update { it.copy(scheduledMessages = scheduledMessages) } @@ -57,6 +69,18 @@ internal fun DefaultChatComponent.loadScheduledMessages() { } } +private suspend fun DefaultChatComponent.canLoadScheduledMessages(): Boolean { + val currentState = _state.value + if (currentState.isChannel && !currentState.isAdmin) return false + if (currentState.canWrite) return true + + val chat = chatListRepository.getChatById(chatId) ?: return false + val canWrite = if (chat.isAdmin) true else chat.permissions.canSendBasicMessages + if (chat.isChannel && !chat.isAdmin) return false + + return canWrite +} + internal fun DefaultChatComponent.setupPinnedMessageCollector() { repositoryMessage.pinnedMessageFlow .onEach { cId -> @@ -109,20 +133,23 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { val threadId = _state.value.currentTopicId val messages = repositoryMessage.getMessagesAround(chatId, message.id, 50, threadId) if (messages.isNotEmpty()) { + updateMessages(messages, replace = true) + lastLoadedOlderId = 0L + lastLoadedNewerId = 0L _state.update { it.copy( - scrollToMessageId = message.id, + pendingScrollCommand = ChatScrollCommand.JumpToMessage( + messageId = message.id, + highlight = true, + align = ScrollAlign.Center, + animated = true + ), highlightedMessageId = message.id, isAtBottom = false, isLatestLoaded = false, isOldestLoaded = false ) } - updateMessages(messages, replace = true) - lastLoadedOlderId = 0L - lastLoadedNewerId = 0L - loadMoreMessages() - loadNewerMessages() } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error jumping to message", e) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt index 01adf2ab..2ab9333f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.UserModel import org.monogram.domain.models.UserStatusType import org.monogram.presentation.R @@ -44,6 +45,7 @@ import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.shimmerBackground +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.FileUtils import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.NewChannelContent @@ -621,6 +623,8 @@ private fun ContactItem( onRemoveContact: () -> Unit ) { val context = LocalContext.current + val timeFormatManager: DateFormatManager = koinInject() + val timeFormat = timeFormatManager.getHourMinuteFormat() val isSupport = user.isSupport var showMenu by remember { mutableStateOf(false) } val cornerRadius = 24.dp @@ -684,7 +688,7 @@ private fun ContactItem( color = MaterialTheme.colorScheme.onSurface ) } else { - val statusText = getUserStatusText(user, context) + val statusText = getUserStatusText(user, context, timeFormat) Text( text = statusText, style = MaterialTheme.typography.bodySmall, diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt index 6c21143d..af51b7bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt @@ -34,6 +34,7 @@ fun queryImages(context: Context): List { ), dateAdded = cursor.getLong(dateColumn), isVideo = false, + durationMs = null, bucketName = bucket, relativePath = relative, isCamera = isCameraBucket(bucket, relative), @@ -51,7 +52,8 @@ fun queryVideos(context: Context): List { MediaStore.Video.Media._ID, MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.BUCKET_DISPLAY_NAME, - MediaStore.Video.Media.RELATIVE_PATH + MediaStore.Video.Media.RELATIVE_PATH, + MediaStore.Video.Media.DURATION ) context.contentResolver.query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, @@ -64,6 +66,7 @@ fun queryVideos(context: Context): List { val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED) val bucketColumn = cursor.getColumnIndex(MediaStore.Video.Media.BUCKET_DISPLAY_NAME) val relColumn = cursor.getColumnIndex(MediaStore.Video.Media.RELATIVE_PATH) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION) while (cursor.moveToNext()) { val bucket = if (bucketColumn != -1) cursor.getString(bucketColumn).orEmpty() else "" val relative = if (relColumn != -1) cursor.getString(relColumn).orEmpty() else "" @@ -75,6 +78,7 @@ fun queryVideos(context: Context): List { ), dateAdded = cursor.getLong(dateColumn), isVideo = true, + durationMs = cursor.getLong(durationColumn).takeIf { it > 0L }, bucketName = bucket, relativePath = relative, isCamera = isCameraBucket(bucket, relative), diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt index 7d877dcd..45d80369 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt @@ -19,6 +19,7 @@ data class GalleryMediaItem( val uri: Uri, val dateAdded: Long, val isVideo: Boolean, + val durationMs: Long?, val bucketName: String, val relativePath: String, val isCamera: Boolean, diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt index f16b729c..51bac7e2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt @@ -28,6 +28,20 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.monogram.presentation.R import org.monogram.presentation.features.gallery.GalleryMediaItem +import java.util.Locale + +private fun formatDuration(durationMs: Long): String { + val totalSeconds = durationMs / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return if (hours > 0) { + String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } +} @Composable fun GalleryGrid( @@ -97,7 +111,8 @@ fun GalleryGrid( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) ) { Text( - text = stringResource(R.string.media_type_video), + text = item.durationMs?.let(::formatDuration) + ?: stringResource(R.string.media_type_video), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt index 4f9816ae..fad2161c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt @@ -2,7 +2,7 @@ package org.monogram.presentation.features.gallery.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -32,7 +32,7 @@ fun GalleryTopBar( }, actions = { IconButton(onClick = onPickFromOtherSources) { - Icon(Icons.Filled.Extension, contentDescription = stringResource(R.string.gallery_action_other_sources)) + Icon(Icons.Filled.PhotoLibrary, contentDescription = stringResource(R.string.gallery_action_other_sources)) } IconButton(onClick = onCameraClick) { Icon(Icons.Filled.PhotoCamera, contentDescription = stringResource(R.string.permission_camera_title)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt index 18fae7d5..08666249 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt @@ -1,13 +1,23 @@ package org.monogram.presentation.features.instantview.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -22,10 +32,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isUnspecified import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.webapp.PageBlockCaption import org.monogram.domain.models.webapp.RichText import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer @@ -94,15 +106,17 @@ fun AsyncImageWithDownload( fileRepository.downloadFile(fileId) val progressJob = launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .collect { progress = it.progress } } val completedPath = withTimeoutOrNull(60_000L) { - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .mapNotNull { event -> event.path.takeIf { it.isNotEmpty() } } .first() } @@ -175,15 +189,17 @@ fun AsyncVideoWithDownload( fileRepository.downloadFile(fileId) val progressJob = launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .collect { progress = it.progress } } val completedPath = withTimeoutOrNull(60_000L) { - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .mapNotNull { event -> event.path.takeIf { it.isNotEmpty() } } .first() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 9a0029b1..25dfe54a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -3,12 +3,42 @@ package org.monogram.presentation.features.profile import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.monogram.domain.models.* -import org.monogram.domain.repository.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatInteractionType +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatRevenueStatisticsModel +import org.monogram.domain.models.ChatStatisticsModel +import org.monogram.domain.models.FileDownloadEvent +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.StatisticsGraphModel +import org.monogram.domain.models.UserTypeEnum +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.domain.repository.ChatMembersFilter +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ChatSettingsRepository +import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfileMediaFilter +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope @@ -39,7 +69,9 @@ class DefaultProfileComponent( private val privacyRepository: PrivacyRepository = container.repositories.privacyRepository override val messageRepository: MessageRepository = container.repositories.messageRepository private val locationRepository: LocationRepository = container.repositories.locationRepository + private val gifRepository: GifRepository = container.repositories.gifRepository private val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider + private val stringProvider = container.utils.stringProvider() override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() private val scope = componentScope @@ -579,7 +611,7 @@ class DefaultProfileComponent( } } is MessageContent.Location -> { - onLocationClick(content.latitude, content.longitude, "Location") + onLocationClick(content.latitude, content.longitude, stringProvider.getString("location_label")) } is MessageContent.Venue -> { @@ -691,8 +723,10 @@ class DefaultProfileComponent( _state.update { it.copy( fullScreenImages = null, + fullScreenImageMessageIds = emptyList(), fullScreenCaptions = emptyList(), fullScreenVideoPath = null, + fullScreenVideoMessageId = null, fullScreenVideoCaption = null, isViewingProfilePhotos = false, isProfilePhotoHdLoading = false @@ -700,6 +734,82 @@ class DefaultProfileComponent( } } + override fun onDismissImages() { + onDismissViewer() + } + + override fun onDismissVideo() { + onDismissViewer() + } + + override fun onDismissInstantView() { + _state.update { it.copy(instantViewUrl = null) } + } + + override fun onDismissYouTube() { + _state.update { it.copy(youtubeUrl = null) } + } + + override fun onDismissWebView() { + _state.update { it.copy(webViewUrl = null) } + } + + override fun onDismissInvoice(status: String?) { + _state.update { it.copy(invoiceSlug = null, invoiceMessageId = null) } + } + + override fun onForwardMessage(message: MessageModel) { + onMessageLongClicked(message) + } + + override fun onDeleteMessage(message: MessageModel, revoke: Boolean) { + scope.launch { + messageRepository.deleteMessage(chatId, listOf(message.id), revoke) + } + } + + override fun onOpenVideo(path: String, messageId: Long?, caption: String?) { + _state.update { + it.copy( + fullScreenVideoPath = path, + fullScreenVideoMessageId = messageId, + fullScreenVideoCaption = caption, + fullScreenImages = null + ) + } + } + + override fun onDownloadHighRes(messageId: Long) { + scope.launch { + val fileId = messageRepository.getHighResFileId(chatId, messageId) + if (fileId != null && fileId != 0) { + messageRepository.downloadFile(fileId, priority = 32) + } + } + } + + override fun onAddToGifs(path: String) { + scope.launch { + gifRepository.addSavedGif(path) + } + } + + override fun onOpenWebView(url: String) { + _state.update { it.copy(webViewUrl = url) } + } + + override fun onDismissMiniAppTOS() { + _state.update { it.copy(showMiniAppTOS = false) } + } + + override fun onAcceptMiniAppTOS() { + val botId = _state.value.user?.id ?: return + scope.launch { + botPreferences.setWebappPermission(botId, "tos_accepted", true) + _state.update { it.copy(showMiniAppTOS = false, isTOSAccepted = true) } + } + } + override fun onOpenMiniApp(url: String, name: String, chatId: Long) { _state.update { it.copy(miniAppUrl = url, miniAppName = name, chatId = chatId) } } @@ -763,12 +873,12 @@ class DefaultProfileComponent( } val completed = withTimeoutOrNull(timeoutMs) { - messageRepository.messageDownloadCompletedFlow.first { (_, completedFileId, path) -> - completedFileId == fileId && path.isNotEmpty() - } + messageRepository.fileDownloadFlow + .filterIsInstance() + .first { event -> event.fileId == fileId && event.path.isNotEmpty() } } if (completed != null) { - return completed.third + return completed.path } val fallback = messageRepository.getFileInfo(fileId) @@ -986,9 +1096,9 @@ class DefaultProfileComponent( override fun onShowPermissions() { val botId = _state.value.user?.id ?: return val permissions = mapOf( - "Location" to botPreferences.getWebappPermission(botId, "location"), - "Biometry" to botPreferences.getWebappPermission(botId, "biometry"), - "Terms of Service" to botPreferences.getWebappPermission(botId, "tos_accepted") + stringProvider.getString("location_label") to botPreferences.getWebappPermission(botId, "location"), + stringProvider.getString("mini_app_permission_biometry") to botPreferences.getWebappPermission(botId, "biometry"), + stringProvider.getString("terms_of_service_title") to botPreferences.getWebappPermission(botId, "tos_accepted") ) _state.update { it.copy(isPermissionsVisible = true, botPermissions = permissions) } } @@ -1000,9 +1110,9 @@ class DefaultProfileComponent( override fun onTogglePermission(permission: String) { val botId = _state.value.user?.id ?: return val key = when (permission) { - "Location" -> "location" - "Biometry" -> "biometry" - "Terms of Service" -> "tos_accepted" + stringProvider.getString("location_label") -> "location" + stringProvider.getString("mini_app_permission_biometry") -> "biometry" + stringProvider.getString("terms_of_service_title") -> "tos_accepted" else -> return } val current = botPreferences.getWebappPermission(botId, key) @@ -1044,10 +1154,12 @@ class DefaultProfileComponent( override fun onLocationClick(lat: Double, lon: Double, address: String) { scope.launch { var finalAddress = address - if (address == "Location") { + if (address == stringProvider.getString("location_label")) { val reverse = locationRepository.reverseGeocode(lat, lon) if (reverse != null) { - finalAddress = reverse.address?.city ?: reverse.address?.toString() ?: "Location" + finalAddress = reverse.address?.city + ?: reverse.address?.toString() + ?: stringProvider.getString("location_label") } } _state.update { @@ -1083,21 +1195,27 @@ class DefaultProfileComponent( private fun MessageContent.toStatisticsPreview(): String { return when (this) { - is MessageContent.Text -> text.ifBlank { "Message" } - is MessageContent.Photo -> caption.ifBlank { "Photo" } - is MessageContent.Video -> caption.ifBlank { "Video" } - is MessageContent.Gif -> caption.ifBlank { "GIF" } - is MessageContent.Document -> caption.ifBlank { fileName.ifBlank { "Document" } } - is MessageContent.Audio -> caption.ifBlank { title.ifBlank { "Audio" } } - is MessageContent.Voice -> "Voice message" - is MessageContent.VideoNote -> "Video message" - is MessageContent.Sticker -> "Sticker ${emoji.ifBlank { "" }}".trim() - is MessageContent.Contact -> "Contact: ${firstName} ${lastName}".trim() - is MessageContent.Location -> "Location" - is MessageContent.Venue -> "Venue: $title" - is MessageContent.Poll -> "Poll: $question" - is MessageContent.Service -> text.ifBlank { "Service message" } - MessageContent.Unsupported -> "Unsupported message" + is MessageContent.Text -> text.ifBlank { stringProvider.getString("reply_content_message") } + is MessageContent.Photo -> caption.ifBlank { stringProvider.getString("reply_content_photo") } + is MessageContent.Video -> caption.ifBlank { stringProvider.getString("reply_content_video") } + is MessageContent.Gif -> caption.ifBlank { stringProvider.getString("reply_content_gif") } + is MessageContent.Document -> caption.ifBlank { fileName.ifBlank { stringProvider.getString("logs_media_document") } } + is MessageContent.Audio -> caption.ifBlank { title.ifBlank { stringProvider.getString("logs_media_audio") } } + is MessageContent.Voice -> stringProvider.getString("reply_content_voice_message") + is MessageContent.VideoNote -> stringProvider.getString("reply_content_video_message") + is MessageContent.Sticker -> listOf( + stringProvider.getString("reply_content_sticker"), + emoji.ifBlank { "" } + ).filter { it.isNotBlank() }.joinToString(" ") + is MessageContent.Contact -> stringProvider.getString( + "profile_statistics_preview_contact_format", + "$firstName $lastName".trim() + ) + is MessageContent.Location -> stringProvider.getString("location_label") + is MessageContent.Venue -> stringProvider.getString("profile_statistics_preview_venue_format", title) + is MessageContent.Poll -> stringProvider.getString("profile_statistics_preview_poll_format", question) + is MessageContent.Service -> text.ifBlank { stringProvider.getString("profile_statistics_preview_service_message") } + MessageContent.Unsupported -> stringProvider.getString("logs_media_unsupported") } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index e48a88a0..c4198cd5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -17,6 +17,20 @@ interface ProfileComponent { fun onMessageLongClick(message: MessageModel) fun onAvatarClick() fun onDismissViewer() + fun onDismissImages() + fun onDismissVideo() + fun onDismissInstantView() + fun onDismissYouTube() + fun onDismissWebView() + fun onDismissInvoice(status: String?) + fun onForwardMessage(message: MessageModel) + fun onDeleteMessage(message: MessageModel, revoke: Boolean) + fun onOpenVideo(path: String, messageId: Long?, caption: String?) + fun onDownloadHighRes(messageId: Long) + fun onAddToGifs(path: String) + fun onOpenWebView(url: String) + fun onDismissMiniAppTOS() + fun onAcceptMiniAppTOS() fun onLoadMoreMedia() fun onOpenMiniApp(url: String, name: String, chatId: Long) fun onDismissMiniApp() @@ -93,13 +107,31 @@ interface ProfileComponent { val personalAvatarPath: String? = null, val fullScreenImages: List? = null, + val fullScreenImageMessageIds: List = emptyList(), val fullScreenCaptions: List = emptyList(), val fullScreenStartIndex: Int = 0, val fullScreenVideoPath: String? = null, + val fullScreenVideoMessageId: Long? = null, val fullScreenVideoCaption: String? = null, val isViewingProfilePhotos: Boolean = false, val isProfilePhotoHdLoading: Boolean = false, + val instantViewUrl: String? = null, + val youtubeUrl: String? = null, + val webViewUrl: String? = null, + val invoiceSlug: String? = null, + val invoiceMessageId: Long? = null, + + val autoDownloadWifi: Boolean = true, + val autoDownloadRoaming: Boolean = false, + val autoDownloadMobile: Boolean = true, + + val isPlayerGesturesEnabled: Boolean = true, + val isPlayerDoubleTapSeekEnabled: Boolean = true, + val playerSeekDuration: Int = 10, + val isPlayerZoomEnabled: Boolean = true, + val isInstalledFromGooglePlay: Boolean = false, + val miniAppUrl: String? = null, val miniAppName: String? = null, val currentUser: UserModel? = null, @@ -122,6 +154,7 @@ interface ProfileComponent { val botPermissions: Map = emptyMap(), val isTOSVisible: Boolean = false, + val showMiniAppTOS: Boolean = false, val isTOSAccepted: Boolean = false, val isAcceptingTOS: Boolean = false, val pendingMiniAppUrl: String? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index ef0340f4..5a169157 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -5,9 +5,19 @@ package org.monogram.presentation.features.profile import android.content.ClipData import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.animation.* import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -18,43 +28,77 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Person -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState -import org.monogram.domain.models.MessageContent +import org.koin.compose.koinInject import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R -import org.monogram.presentation.core.ui.* +import org.monogram.presentation.core.ui.CollapsingToolbarScaffold +import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState +import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.util.DateFormatManager +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.SettingsTextField -import org.monogram.presentation.features.profile.components.* -import org.monogram.presentation.features.viewers.ImageViewer -import org.monogram.presentation.features.viewers.VideoViewer -import org.monogram.presentation.features.webapp.MiniAppViewer +import org.monogram.presentation.features.profile.components.ProfileHeaderTransformed +import org.monogram.presentation.features.profile.components.ProfileInfoSection +import org.monogram.presentation.features.profile.components.ProfileInfoSectionSkeleton +import org.monogram.presentation.features.profile.components.ProfilePermissionsDialog +import org.monogram.presentation.features.profile.components.ProfileQRDialog +import org.monogram.presentation.features.profile.components.ProfileReportDialog +import org.monogram.presentation.features.profile.components.ProfileTOSDialog +import org.monogram.presentation.features.profile.components.ProfileTopBar +import org.monogram.presentation.features.profile.components.profileMediaSection @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileContent(component: ProfileComponent) { val state by component.state.subscribeAsState() + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) && isTabletInterfaceEnabled val localClipboard = LocalClipboard.current val context = LocalContext.current val collapsingToolbarState = rememberCollapsingToolbarScaffoldState() + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val chat = state.chat val user = state.user @@ -77,39 +121,36 @@ fun ProfileContent(component: ProfileComponent) { .ifBlank { unknownTitle } } - val membersCountFormat = stringResource(R.string.members_count_format) val membersOnlineCountFormat = stringResource(R.string.members_online_count_format) val ownProfileSubtitle = stringResource(R.string.menu_my_profile_subtitle) - val subtitle = remember( - user, - chat, - isCurrentUserProfile, - membersCountFormat, - membersOnlineCountFormat, - ownProfileSubtitle - ) { - when { - chat?.isGroup == true || chat?.isChannel == true -> { - val members = String.format(membersCountFormat, chat.memberCount) - if (chat.onlineCount > 0) String.format( - membersOnlineCountFormat, - members, - chat.onlineCount - ) else members - } - - isCurrentUserProfile -> { - user.username - ?.takeIf { it.isNotBlank() } - ?.let { "$ownProfileSubtitle • @$it" } - ?: ownProfileSubtitle - } + val subtitle = when { + chat?.isGroup == true || chat?.isChannel == true -> { + val members = pluralStringResource( + R.plurals.members_count_format, + chat.memberCount, + chat.memberCount + ) + if (chat.onlineCount > 0) String.format( + membersOnlineCountFormat, + members, + chat.onlineCount + ) else members + } - else -> getUserStatusText(user, context) + isCurrentUserProfile -> { + user.username + ?.takeIf { it.isNotBlank() } + ?.let { "$ownProfileSubtitle • @$it" } + ?: ownProfileSubtitle } + + else -> getUserStatusText(user, context, timeFormat) } val isOnline = user?.type != UserTypeEnum.BOT && user?.userStatus == UserStatusType.ONLINE + val isBot = user?.type == UserTypeEnum.BOT || chat?.isBot == true + val isScam = user?.isScam == true || chat?.isScam == true + val isFake = user?.isFake == true || chat?.isFake == true val collapsedColor = MaterialTheme.colorScheme.surface val expandedColor = MaterialTheme.colorScheme.background @@ -150,7 +191,7 @@ fun ProfileContent(component: ProfileComponent) { val canReportTopBar = isGroupOrChannel && !isCurrentUserProfile val canBlockTopBar = !isCurrentUserProfile && !isGroupOrChannel && user?.type != UserTypeEnum.BOT val canEditContactTopBar = !isCurrentUserProfile && !isGroupOrChannel && user?.isContact == true - val canDeleteTopBar = !isCurrentUserProfile && (!isGroupOrChannel || chat?.isMember == true) + val canDeleteTopBar = !isCurrentUserProfile && (!isGroupOrChannel || chat.isMember) var showLeaveSheet by remember { mutableStateOf(false) } var showDeleteChatSheet by remember { mutableStateOf(false) } var showBlockSheet by remember { mutableStateOf(false) } @@ -170,6 +211,9 @@ fun ProfileContent(component: ProfileComponent) { chatModel = chat, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, canSearch = false, canShare = canShareTopBar, canEdit = canEditTopBar, @@ -268,6 +312,9 @@ fun ProfileContent(component: ProfileComponent) { isOnline = isOnline, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, statusEmojiPath = user?.statusEmojiPath, progress = progress, contentPadding = PaddingValues( @@ -289,6 +336,8 @@ fun ProfileContent(component: ProfileComponent) { columns = GridCells.Fixed(3), modifier = Modifier .fillMaxSize() + .padding(horizontal = if (isTablet) 12.dp else 0.dp) + .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.background), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -346,163 +395,6 @@ fun ProfileContent(component: ProfileComponent) { } } - val notImplemented = stringResource(R.string.not_implemented) - AnimatedVisibility( - visible = state.fullScreenImages != null, - enter = fadeIn() + scaleIn(initialScale = 0.9f), - exit = fadeOut() + scaleOut(targetScale = 0.9f) - ) { - state.fullScreenImages?.let { images -> - Box(modifier = Modifier.fillMaxSize()) { - ImageViewer( - images = images, - startIndex = state.fullScreenStartIndex, - onDismiss = component::onDismissViewer, - autoDownload = false, - downloadUtils = component.downloadUtils, - onPageChanged = { index -> - - if (!state.isViewingProfilePhotos && state.canLoadMoreMedia && !state.isLoadingMoreMedia && - index >= images.size - 5 - ) { - component.onLoadMoreMedia() - } - - if (!state.isViewingProfilePhotos) { - val photoMessages = state.mediaMessages.filter { it.content is MessageContent.Photo } - - val message = photoMessages.getOrNull(index) - if (message != null) { - component.onDownloadMedia(message) - - val nextMessage = photoMessages.getOrNull(index + 1) - if (nextMessage != null) { - component.onDownloadMedia(nextMessage) - } - } - } - }, - onForward = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onDelete = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyLink = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyText = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - captions = state.fullScreenCaptions.filterNotNull(), - showImageNumber = false - ) - - if (state.isViewingProfilePhotos && state.isProfilePhotoHdLoading) { - Surface( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 56.dp), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f) - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - LoadingIndicator( - modifier = Modifier.size(16.dp) - ) - Text( - text = "Loading HD", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } - } - } - - AnimatedVisibility( - visible = state.fullScreenVideoPath != null, - enter = fadeIn() + scaleIn(initialScale = 0.9f), - exit = fadeOut() + scaleOut(targetScale = 0.9f) - ) { - state.fullScreenVideoPath?.let { path -> - val msg = state.mediaMessages.find { - when (val content = it.content) { - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - is MessageContent.VideoNote -> content.path == path - else -> false - } - } - val videoContent = msg?.content as? MessageContent.Video - val fileId = videoContent?.fileId ?: (msg?.content as? MessageContent.Gif)?.fileId - ?: (msg?.content as? MessageContent.VideoNote)?.fileId ?: 0 - val supportsStreaming = videoContent?.supportsStreaming ?: false - - VideoViewer( - path = path, - onDismiss = component::onDismissViewer, - isGesturesEnabled = true, - isDoubleTapSeekEnabled = true, - seekDuration = 10, - isZoomEnabled = true, - downloadUtils = component.downloadUtils, - onForward = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onDelete = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyLink = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyText = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - caption = state.fullScreenVideoCaption, - fileId = fileId, - supportsStreaming = supportsStreaming - ) - } - } - - AnimatedVisibility( - visible = state.miniAppUrl != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.miniAppUrl != null && state.miniAppName != null) { - MiniAppViewer( - baseUrl = state.miniAppUrl.toString(), - botName = title, - onDismiss = { component.onDismissMiniApp() }, - chatId = state.chatId, - botUserId = state.user!!.id, - webAppRepository = component.messageRepository, - ) - } - } - - AnimatedVisibility( - visible = state.isStatisticsVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.statistics != null) { - StatisticsViewer( - title = stringResource(R.string.statistics_title), - data = state.statistics!!, - onDismiss = component::onDismissStatistics, - onLoadGraph = component::onLoadStatisticsGraph - ) - } - } - - AnimatedVisibility( - visible = state.isRevenueStatisticsVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.revenueStatistics != null) { - StatisticsViewer( - title = stringResource(R.string.revenue_title), - data = state.revenueStatistics!!, - onDismiss = component::onDismissStatistics, - onLoadGraph = component::onLoadStatisticsGraph - ) - } - } - ProfileQRDialog( state = state, onDismiss = component::onDismissQRCode @@ -565,7 +457,7 @@ fun ProfileContent(component: ProfileComponent) { dragHandle = { BottomSheetDefaults.DragHandle() }, containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { Column( @@ -657,13 +549,6 @@ fun ProfileContent(component: ProfileComponent) { onAccept = component::onAcceptTOS ) } - - state.selectedLocation?.let { location -> - LocationViewer( - location = location, - onDismiss = component::onDismissLocation - ) - } } } @@ -673,7 +558,8 @@ private fun ProfileHeaderSkeleton( contentPadding: PaddingValues ) { val shimmer = rememberShimmerBrush() - val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } val titleWidth = androidx.compose.ui.unit.lerp(220.dp, 124.dp, progress) val subtitleWidth = androidx.compose.ui.unit.lerp(132.dp, 88.dp, progress) val avatarCornerPercent = (100 * (1f - progress)).toInt() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt new file mode 100644 index 00000000..851a301a --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt @@ -0,0 +1,470 @@ +package org.monogram.presentation.features.profile + +import android.content.ClipData +import android.util.Log +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +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.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.R +import org.monogram.presentation.features.instantview.InstantViewer +import org.monogram.presentation.features.profile.components.LocationViewer +import org.monogram.presentation.features.profile.components.StatisticsViewer +import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.VideoViewer +import org.monogram.presentation.features.viewers.YouTubeViewer +import org.monogram.presentation.features.webapp.MiniAppViewer +import org.monogram.presentation.features.webapp.components.InvoiceDialog +import org.monogram.presentation.features.webapp.components.MiniAppTOSBottomSheet +import org.monogram.presentation.features.webview.InternalWebView + +@Composable +fun ProfileViewers( + state: ProfileComponent.State, + component: ProfileComponent +) { + val localClipboard = LocalClipboard.current + + InstantViewOverlay(state, component) + YouTubeOverlay(state, component, localClipboard) + MiniAppOverlay(state, component) + WebViewOverlay(state, component) + ImagesOverlay(state, component, localClipboard) + VideoOverlay(state, component, localClipboard) + InvoiceOverlay(state, component) + MiniAppTOSOverlay(state, component) +} + +@Composable +private fun InstantViewOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.instantViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.instantViewUrl?.let { url -> + InstantViewer( + url = url, + messageRepository = component.messageRepository, + fileRepository = component.messageRepository, + onDismiss = { component.onDismissInstantView() }, + onOpenWebView = { component.onOpenWebView(it) } + ) + } + } +} + +@Composable +private fun YouTubeOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + AnimatedVisibility( + visible = state.youtubeUrl != null, + enter = fadeIn(), + exit = fadeOut() + ) { + state.youtubeUrl?.let { url -> + YouTubeViewer( + videoUrl = url, + onDismiss = { component.onDismissYouTube() }, + onForward = { + val msg = state.mediaMessages.find { + (it.content as? MessageContent.Text)?.text?.contains(url) == true + } + if (msg != null) component.onForwardMessage(msg) + }, + onCopyLink = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + onCopyText = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + isPipEnabled = !state.isInstalledFromGooglePlay + ) + } + } +} + +@Composable +private fun MiniAppOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.miniAppUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + if (state.miniAppUrl != null && state.miniAppName != null) { + val title = state.chat?.title ?: listOfNotNull(state.user?.firstName, state.user?.lastName) + .joinToString(" ") + .ifBlank { "Unknown" } + + MiniAppViewer( + chatId = state.chatId, + botUserId = state.user?.id ?: 0L, + baseUrl = state.miniAppUrl, + botName = title, + botAvatarPath = state.chat?.avatarPath ?: state.user?.avatarPath, + webAppRepository = component.messageRepository, + onDismiss = { component.onDismissMiniApp() } + ) + } + } +} + +@Composable +private fun WebViewOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.webViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.webViewUrl?.let { url -> + InternalWebView( + url = url, + onDismiss = { component.onDismissWebView() } + ) + } + } +} + +@Composable +private fun ImagesOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + AnimatedVisibility( + visible = state.fullScreenImages != null, + enter = fadeIn() + scaleIn(initialScale = 0.9f), + exit = fadeOut() + scaleOut(targetScale = 0.9f) + ) { + state.fullScreenImages?.let { images -> + val autoDownload = remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { + when { + component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi + component.downloadUtils.isRoaming() -> state.autoDownloadRoaming + else -> state.autoDownloadMobile + } + } + + val viewerItems = remember(images, state.fullScreenImageMessageIds, state.mediaMessages) { + if (state.fullScreenImageMessageIds.size == images.size) { + state.fullScreenImageMessageIds.mapIndexed { index, messageId -> + val message = state.mediaMessages.firstOrNull { it.id == messageId } + val resolvedPath = message?.displayMediaPathForViewer() ?: images[index] + ViewerMediaItem(messageId = messageId, path = resolvedPath) + } + } else { + images.map { path -> + val message = state.mediaMessages.find { it.content.matchesDisplayPath(path) } + ViewerMediaItem( + messageId = message?.id ?: 0L, + path = message?.displayMediaPathForViewer() ?: path + ) + } + } + } + + val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } + val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } + var currentImageIndex by remember(viewerImages, state.fullScreenStartIndex) { + mutableIntStateOf( + state.fullScreenStartIndex.coerceIn( + 0, + (viewerImages.lastIndex).coerceAtLeast(0) + ) + ) + } + + val currentViewerMessage = remember(currentImageIndex, imageMessageIds, state.mediaMessages) { + imageMessageIds.getOrNull(currentImageIndex) + ?.takeIf { it != 0L } + ?.let { id -> state.mediaMessages.firstOrNull { it.id == id } } + } + + val imageDownloadingStates = remember(imageMessageIds, state.mediaMessages) { + imageMessageIds.map { id -> + val content = state.mediaMessages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.isDownloading + else -> false + } + } + } + + val imageDownloadProgressStates = remember(imageMessageIds, state.mediaMessages) { + imageMessageIds.map { id -> + val content = state.mediaMessages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.downloadProgress + else -> 0f + } + } + } + + if (viewerImages.isNotEmpty()) { + Box(modifier = Modifier.fillMaxSize()) { + ImageViewer( + images = viewerImages, + startIndex = state.fullScreenStartIndex.coerceIn(0, viewerImages.lastIndex), + onDismiss = component::onDismissImages, + autoDownload = autoDownload, + onPageChanged = { index -> + currentImageIndex = index + if (!state.isViewingProfilePhotos && state.canLoadMoreMedia && !state.isLoadingMoreMedia && + index >= viewerImages.size - 5 + ) { + component.onLoadMoreMedia() + } + + if (!state.isViewingProfilePhotos) { + imageMessageIds.getOrNull(index)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) + imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) + } + }, + onForward = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + msg?.let { component.onForwardMessage(it) } + }, + onDelete = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + if (msg?.isOutgoing == true) { + component.onDeleteMessage(msg, true) + component.onDismissImages() + } + }, + onCopyLink = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + val link = if (msg != null) { + "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${msg.id shr 20}" + } else { + path + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + val textToCopy = when (val content = msg?.content) { + is MessageContent.Photo -> content.caption + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onVideoClick = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + if (msg != null) { + val mediaPath = msg.displayMediaPathForViewer() ?: path + component.onOpenVideo( + path = mediaPath, + messageId = msg.id, + caption = when (val content = msg.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> null + } + ) + } else { + component.onOpenVideo(path = path, messageId = null, caption = null) + } + }, + captions = state.fullScreenCaptions.filterNotNull(), + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = component.downloadUtils, + showImageNumber = false + ) + + if (state.isViewingProfilePhotos && state.isProfilePhotoHdLoading) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 56.dp), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = "Loading HD", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + } +} + +@Composable +private fun VideoOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + val videoVisible = + (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null + + AnimatedVisibility( + visible = videoVisible, + enter = fadeIn() + scaleIn(initialScale = 0.9f), + exit = fadeOut() + scaleOut(targetScale = 0.9f) + ) { + if (videoVisible) { + val messageId = state.fullScreenVideoMessageId + val path = state.fullScreenVideoPath + + val msg = remember(messageId, path, state.mediaMessages) { + state.mediaMessages.find { it.id == messageId } ?: state.mediaMessages.find { + it.content.matchesDisplayPath(path ?: "") + } + } + + val videoContent = msg?.content as? MessageContent.Video + val gifContent = msg?.content as? MessageContent.Gif + + val fileId = videoContent?.fileId ?: gifContent?.fileId ?: 0 + val supportsStreaming = videoContent?.supportsStreaming ?: false + val finalPath = path ?: videoContent?.path ?: gifContent?.path ?: "" + + if (finalPath.isNotBlank() || (supportsStreaming && fileId != 0)) { + key(finalPath, fileId) { + VideoViewer( + path = finalPath, + onDismiss = component::onDismissVideo, + isGesturesEnabled = state.isPlayerGesturesEnabled, + isDoubleTapSeekEnabled = state.isPlayerDoubleTapSeekEnabled, + seekDuration = state.playerSeekDuration, + isZoomEnabled = state.isPlayerZoomEnabled, + onForward = { videoPath -> + val forwardMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + forwardMsg?.let { component.onForwardMessage(it) } + }, + onDelete = { videoPath -> + val deleteMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + if (deleteMsg?.isOutgoing == true) { + component.onDeleteMessage(deleteMsg, true) + component.onDismissVideo() + } + }, + onCopyLink = { videoPath -> + val linkMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + val link = if (linkMsg != null) { + "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${linkMsg.id shr 20}" + } else { + videoPath + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { videoPath -> + val textMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + val textToCopy = when (val content = textMsg?.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onSaveGif = if (state.mediaMessages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { + { videoPath -> component.onAddToGifs(videoPath) } + } else null, + caption = state.fullScreenVideoCaption, + fileId = fileId, + supportsStreaming = supportsStreaming, + downloadUtils = component.downloadUtils + ) + } + } + } + } +} + +@Composable +private fun InvoiceOverlay(state: ProfileComponent.State, component: ProfileComponent) { + if (state.invoiceSlug != null || state.invoiceMessageId != null) { + InvoiceDialog( + slug = state.invoiceSlug, + chatId = state.chatId, + messageId = state.invoiceMessageId, + paymentRepository = component.messageRepository, + fileRepository = component.messageRepository, + onDismiss = { status -> component.onDismissInvoice(status) } + ) + } +} + +@Composable +private fun MiniAppTOSOverlay(state: ProfileComponent.State, component: ProfileComponent) { + MiniAppTOSBottomSheet( + isVisible = state.showMiniAppTOS, + onDismiss = { component.onDismissMiniAppTOS() }, + onAccept = { component.onAcceptMiniAppTOS() } + ) +} + +private data class ViewerMediaItem( + val messageId: Long, + val path: String +) + +private fun MessageModel.displayMediaPathForViewer(): String? { + return when (val content = content) { + is MessageContent.Photo -> content.path ?: content.thumbnailPath + is MessageContent.Video -> content.path ?: content.thumbnailPath + is MessageContent.Gif -> content.path + else -> null + } +} + +private fun MessageContent.matchesDisplayPath(path: String): Boolean { + return when (this) { + is MessageContent.Photo -> (this.path ?: this.thumbnailPath) == path + is MessageContent.Video -> this.path == path || this.thumbnailPath == path + is MessageContent.Gif -> this.path == path + else -> false + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt index df5c1436..1149f819 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt @@ -37,6 +37,8 @@ fun ProfileHeader( isSponsor: Boolean, statusEmojiPath: String?, isBot: Boolean, + isScam: Boolean, + isFake: Boolean, onAvatarClick: () -> Unit ) { val displayPath = profilePhotos.firstOrNull() ?: avatarPath @@ -120,17 +122,27 @@ fun ProfileHeader( } if (isBot) { Spacer(Modifier.width(6.dp)) - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = stringResource(R.string.label_bot_badge), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } + ProfileStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + if (isScam) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + if (isFake) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -143,3 +155,22 @@ fun ProfileHeader( ) } } + +@Composable +private fun ProfileStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + color = contentColor + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt index e96d4e2b..73974023 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -22,7 +23,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -30,6 +33,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UserModel +import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarHeader import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -40,18 +44,22 @@ fun ProfileHeaderTransformed( subtitle: String, avatarSize: Dp, userModel: UserModel?, - chatModel: ChatModel?, avatarCornerPercent: Int, isOnline: Boolean, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, statusEmojiPath: String?, progress: Float, contentPadding: PaddingValues, onAvatarClick: () -> Unit, - onActionClick: () -> Unit + chatModel: ChatModel? = null, + onActionClick: () -> Unit = {} ) { - val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } BoxWithConstraints( modifier = Modifier @@ -149,23 +157,49 @@ fun ProfileHeaderTransformed( ) } - userModel?.let { user -> - if (!user.statusEmojiPath.isNullOrEmpty()) { - Spacer(modifier = Modifier.width(6.dp)) - StickerImage( - path = user.statusEmojiPath, - modifier = Modifier.size(26.dp), - animate = false - ) - } else if (user.isPremium) { - Spacer(modifier = Modifier.width(6.dp)) - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = Color(0xFF31A6FD) - ) - } + val displayedStatusEmojiPath = statusEmojiPath ?: userModel?.statusEmojiPath + if (!displayedStatusEmojiPath.isNullOrEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + StickerImage( + path = displayedStatusEmojiPath, + modifier = Modifier.size(26.dp), + animate = false + ) + } else if (userModel?.isPremium == true) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = Color(0xFF31A6FD) + ) + } + + if (isBot) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -202,3 +236,22 @@ fun ProfileHeaderTransformed( ) } } + +@Composable +private fun HeaderStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt index 6581cb7b..6ded266c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt @@ -35,6 +35,7 @@ import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -42,6 +43,7 @@ import org.monogram.domain.models.UserStatusType import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -378,7 +380,11 @@ private fun LazyGridScope.membersList( }, supportingContent = { val context = LocalContext.current - val statusText = getUserStatusText(user, context) + + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + + val statusText = getUserStatusText(user, context, timeFormat) Text( text = statusText, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index a122cd6d..684cc7df 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -1,31 +1,13 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.profile.components import android.content.ClipData import android.content.Intent -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith +import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -34,55 +16,9 @@ import androidx.compose.material.icons.automirrored.rounded.Login import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material.icons.rounded.AlternateEmail -import androidx.compose.material.icons.rounded.AssignmentTurnedIn -import androidx.compose.material.icons.rounded.BarChart -import androidx.compose.material.icons.rounded.Cake -import androidx.compose.material.icons.rounded.Collections -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.ForwardToInbox -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.MicOff -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.Payments -import androidx.compose.material.icons.rounded.PersonAdd -import androidx.compose.material.icons.rounded.Phone -import androidx.compose.material.icons.rounded.Portrait -import androidx.compose.material.icons.rounded.RocketLaunch -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material.icons.rounded.Shield -import androidx.compose.material.icons.rounded.Timer -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.ShapeDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -104,7 +40,7 @@ import org.monogram.presentation.core.ui.* import org.monogram.presentation.core.util.CountryManager import org.monogram.presentation.core.util.OperatorManager import org.monogram.presentation.features.profile.ProfileComponent -import java.util.Calendar +import java.util.* @Composable fun ProfileInfoSectionSkeleton( @@ -138,13 +74,13 @@ fun ProfileInfoSectionSkeleton( ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = 24.dp, - bottomEnd = 24.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, topStart = 4.dp, topEnd = 4.dp ) - ItemPosition.STANDALONE -> RoundedCornerShape(24.dp) + ItemPosition.STANDALONE -> RoundedCornerShape(16.dp) } Surface( @@ -206,7 +142,7 @@ fun ProfileInfoSectionSkeleton( private fun ProfileQuickActionsSkeleton(shimmer: androidx.compose.ui.graphics.Brush) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(bottom = 2.dp) @@ -252,7 +188,7 @@ private fun ProfileQuickActionsSkeleton(shimmer: androidx.compose.ui.graphics.Br private fun LinkedChatItemSkeleton(shimmer: androidx.compose.ui.graphics.Brush) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) @@ -353,7 +289,7 @@ fun ProfileInfoSection( onDismissRequest = { isSponsorSheetVisible = false }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -589,6 +525,33 @@ fun ProfileInfoSection( } } + if (!isGroupOrChannel && fullInfo?.usesUnofficialApp == true) { + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Security, + title = stringResource(R.string.unofficial_app_title), + subtitle = stringResource(R.string.unofficial_app_subtitle), + iconColor = Color(0xFFFF9800), + position = pos, + onClick = { } + ) + } + } + + fullInfo?.botVerification?.let { botVerification -> + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Verified, + title = stringResource(R.string.bot_verification_title), + subtitle = botVerification.customDescription + ?: stringResource(R.string.bot_verification_subtitle), + iconColor = MaterialTheme.colorScheme.primary, + position = pos, + onClick = { } + ) + } + } + user?.phoneNumber?.takeIf { it.isNotEmpty() }?.let { phone -> val formattedPhone = remember(phone) { CountryManager.formatPhoneNumber(phone) @@ -997,7 +960,7 @@ private fun ProfileQuickActions( ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = ShapeDefaults.LargeIncreased, + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(bottom = 2.dp) @@ -1450,7 +1413,7 @@ private fun UsernamesTile( else -> MaterialTheme.colorScheme.outline } - val cornerRadius = 24.dp + val cornerRadius = 16.dp val shape = when (position) { ItemPosition.TOP -> RoundedCornerShape( topStart = cornerRadius, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt index 71e4c6a7..e98205f0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -42,6 +41,9 @@ fun ProfileTopBar( chatModel: ChatModel?, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, canSearch: Boolean = false, canShare: Boolean = false, canEdit: Boolean = false, @@ -61,6 +63,8 @@ fun ProfileTopBar( var showMenu by remember { mutableStateOf(false) } val hasMenuActions = canShare || canEdit || canEditContact || canReport || canBlock || canDelete val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() + val hasTextStatusBadges = isBot || isScam || isFake + val shouldCompactTopBar = hasTextStatusBadges && (title.length >= 18 || canSearch || hasMenuActions) val iconTint = lerp( start = MaterialTheme.colorScheme.onSurface, @@ -87,7 +91,8 @@ fun ProfileTopBar( fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) ) if (isVerified) { @@ -109,6 +114,33 @@ fun ProfileTopBar( ) } + if (isBot && !shouldCompactTopBar) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam && !shouldCompactTopBar) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake && !shouldCompactTopBar) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + userModel?.let { user -> if (!user.statusEmojiPath.isNullOrEmpty()) { Spacer(modifier = Modifier.width(4.dp)) @@ -287,3 +319,22 @@ fun ProfileTopBar( } } } + +@Composable +private fun TopBarStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt index 0baf0bbe..b5b498a0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt @@ -36,8 +36,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.json.JSONObject +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.chats.chatList.components.SectionHeader import java.text.SimpleDateFormat import java.util.* @@ -767,8 +769,10 @@ private fun RevenueMetricCard(modifier: Modifier = Modifier, label: String, valu @Composable fun GraphSection(title: String, graph: StatisticsGraphModel, color: Color, onLoadGraph: (String) -> Unit) { if (graph is StatisticsGraphModel.Error) return + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val parsedGraph = remember(graph) { - if (graph is StatisticsGraphModel.Data) parseStatisticsGraph(graph.jsonData) else null + if (graph is StatisticsGraphModel.Data) parseStatisticsGraph(graph.jsonData, timeFormat) else null } Column { @@ -1394,7 +1398,7 @@ private data class ParsedStatisticsSeries( val values: List ) -private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { +private fun parseStatisticsGraph(jsonData: String, timeFormat: String): ParsedStatisticsGraph? { return coRunCatching { val root = JSONObject(jsonData) val columnsArray = root.optJSONArray("columns") ?: return null @@ -1423,7 +1427,7 @@ private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { val labels = (0 until labelCount).map { index -> val rawX = xValues.getOrNull(index)?.toLong() ?: index.toLong() - formatChartXAxisLabel(rawX, labelCount) + formatChartXAxisLabel(rawX, labelCount, timeFormat) } val series = mutableListOf() @@ -1450,14 +1454,14 @@ private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { }.getOrNull() } -private fun formatChartXAxisLabel(rawValue: Long, pointCount: Int): String { +private fun formatChartXAxisLabel(rawValue: Long, pointCount: Int, timeFormat: String): String { return if (rawValue > 10_000_000_000L) { val date = Date(rawValue) - if (pointCount <= 24) SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + if (pointCount <= 24) SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) else SimpleDateFormat("dd MMM", Locale.getDefault()).format(date) } else if (rawValue > 1_000_000_000L) { val date = Date(rawValue * 1000L) - if (pointCount <= 24) SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + if (pointCount <= 24) SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) else SimpleDateFormat("dd MMM", Locale.getDefault()).format(date) } else { rawValue.toString() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt index 8cef4e04..d25c43b0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt @@ -47,10 +47,12 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import org.koin.compose.koinInject import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatPermissionsModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import java.io.File import java.text.SimpleDateFormat @@ -141,8 +143,10 @@ fun ActionDetails( if (action.untilDate > 0) { val date = Date(action.untilDate.toLong() * 1000) + val dateFormatManager: DateFormatManager = koinInject(); + val timeFormat = dateFormatManager.getHourMinuteFormat() val dateText = - SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(date) + SimpleDateFormat("MMM dd, yyyy $timeFormat", Locale.getDefault()).format(date) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt index 8e62ff58..4a0af0bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt @@ -19,11 +19,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatEventModel import org.monogram.domain.models.MessageSenderModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import java.text.SimpleDateFormat import java.util.* @@ -87,10 +89,12 @@ fun LogBubble( var showFullDate by remember { mutableStateOf(false) } val date = Date(event.date.toLong() * 1000) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val dateText = if (showFullDate) { - SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()).format(date) + SimpleDateFormat("MMM dd, $timeFormat:ss", Locale.getDefault()).format(date) } else { - SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) } Row( diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt index 944791b8..a9f23c03 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt @@ -2,7 +2,13 @@ package org.monogram.presentation.features.profile.logs.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -69,7 +75,11 @@ fun MessagePreview( .background(MaterialTheme.colorScheme.surfaceVariant) .clickable { when (content) { - is MessageContent.Photo -> component.onPhotoClick(mediaPath, content.caption) + is MessageContent.Photo -> component.onPhotoClick( + mediaPath, + content.caption + ) + is MessageContent.Gif -> component.onVideoClick( mediaPath, content.caption, @@ -115,6 +125,7 @@ fun MessagePreview( ) MessageText( text = annotatedText, + rawText = content.text, inlineContent = inlineContent, style = MaterialTheme.typography.bodyMedium, entities = content.entities @@ -275,6 +286,7 @@ private fun MediaPreviewText( MessageText( text = annotatedText, + rawText = text, inlineContent = emptyMap(), style = MaterialTheme.typography.bodyMedium, entities = if (oldText != null && oldText != text) emptyList() else entities diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt index d76c1d83..9f17d29f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt @@ -1,14 +1,23 @@ package org.monogram.presentation.features.stickers.core -import org.monogram.presentation.core.util.coRunCatching import android.graphics.Bitmap +import android.os.Build import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.presentation.core.util.coRunCatching import java.io.File import kotlin.math.max @@ -33,6 +42,8 @@ class LottieStickerController( private var backBitmap: Bitmap? = null private var spareBitmap: Bitmap? = null private var decoder: RLottieWrapper? = null + private val isArmV7Device = + Build.SUPPORTED_ABIS.any { it.equals("armeabi-v7a", ignoreCase = true) } override fun start() { val previousJob = renderJob @@ -57,7 +68,12 @@ class LottieStickerController( val file = File(filePath) if (!file.exists()) return@withContext null - val localDecoder = RLottieWrapper() + val localDecoder = try { + RLottieWrapper() + } catch (t: Throwable) { + t.printStackTrace() + return@withContext null + } try { if (!localDecoder.open(file)) { return@withContext null @@ -67,8 +83,10 @@ class LottieStickerController( val compositionHeight = localDecoder.getHeight().coerceAtLeast(1) val extraPaddingX = minOf((compositionWidth * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) val extraPaddingY = minOf((compositionHeight * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) - val renderWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) - val renderHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val targetWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) + val targetHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val renderWidth = if (isArmV7Device) minOf(targetWidth, 384) else targetWidth + val renderHeight = if (isArmV7Device) minOf(targetHeight, 384) else targetHeight val boundsLeft = (renderWidth - compositionWidth) / 2 val boundsTop = (renderHeight - compositionHeight) / 2 @@ -88,11 +106,13 @@ class LottieStickerController( return@withContext null } - val imageBitmap = bitmap.asImageBitmap() - imageBitmap + createImageBitmapSnapshot(bitmap) } catch (e: Exception) { e.printStackTrace() null + } catch (oom: OutOfMemoryError) { + oom.printStackTrace() + null } finally { localDecoder.release() } @@ -111,7 +131,12 @@ class LottieStickerController( val file = File(filePath) if (!file.exists()) return - val localDecoder = RLottieWrapper() + val localDecoder = try { + RLottieWrapper() + } catch (t: Throwable) { + t.printStackTrace() + return + } if (!coRunCatching { localDecoder.open(file) }.getOrDefault(false)) { localDecoder.release() return @@ -122,8 +147,10 @@ class LottieStickerController( val compositionHeight = localDecoder.getHeight().coerceAtLeast(1) val extraPaddingX = minOf((compositionWidth * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) val extraPaddingY = minOf((compositionHeight * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) - val renderWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) - val renderHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val targetWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) + val targetHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val renderWidth = if (isArmV7Device) minOf(targetWidth, 384) else targetWidth + val renderHeight = if (isArmV7Device) minOf(targetHeight, 384) else targetHeight val boundsLeft = (renderWidth - compositionWidth) / 2 val boundsTop = (renderHeight - compositionHeight) / 2 @@ -153,7 +180,7 @@ class LottieStickerController( return } - currentImageBitmap = firstBitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap() + currentImageBitmap = createImageBitmapSnapshot(firstBitmap) val totalFrames = localDecoder.getTotalFrames().coerceAtLeast(1) val frameRate = localDecoder.getFrameRate().takeIf { it > 0.0 } @@ -209,8 +236,11 @@ class LottieStickerController( val localFrontBitmap = frontBitmap if (localFrontBitmap != null) { - currentImageBitmap = localFrontBitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap() - frameVersion++ + val renderedImage = createImageBitmapSnapshot(localFrontBitmap) + if (renderedImage != null) { + currentImageBitmap = renderedImage + frameVersion++ + } } lastFrameTime = now @@ -251,4 +281,14 @@ class LottieStickerController( private const val OVERFLOW_PADDING_RATIO = 0.20f private const val MAX_OVERFLOW_PADDING_PX = 96 } + + private fun createImageBitmapSnapshot(bitmap: Bitmap): ImageBitmap? { + return try { + bitmap.copy(Bitmap.Config.ARGB_8888, false)?.asImageBitmap() ?: bitmap.asImageBitmap() + } catch (_: OutOfMemoryError) { + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt index 8da4932a..b45a6051 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt @@ -1,7 +1,7 @@ package org.monogram.presentation.features.stickers.core -import org.monogram.presentation.core.util.coRunCatching import android.graphics.Bitmap +import org.monogram.presentation.core.util.coRunCatching import java.io.File import java.io.FileInputStream import java.util.zip.GZIPInputStream @@ -10,8 +10,9 @@ class RLottieWrapper { private var nativePtr: Long = 0 init { - System.loadLibrary("native-lib") - nativePtr = create() + if (isNativeLibraryLoaded) { + nativePtr = coRunCatching { create() }.getOrDefault(0L) + } } fun open(file: File): Boolean { @@ -84,4 +85,12 @@ class RLottieWrapper { private external fun getFrameRate(ptr: Long): Double private external fun getDurationMs(ptr: Long): Long private external fun destroy(ptr: Long) + + companion object { + private val isNativeLibraryLoaded: Boolean by lazy { + coRunCatching { + System.loadLibrary("native-lib") + }.isSuccess + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index dd3fab13..574d9e4d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -2,41 +2,126 @@ package org.monogram.presentation.features.stickers.ui.menu -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.automirrored.rounded.Forward import androidx.compose.material.icons.automirrored.rounded.Reply -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.rounded.Undo +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Gavel +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material.icons.rounded.Report +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.* -import androidx.compose.ui.graphics.* +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.* +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -49,11 +134,13 @@ import org.monogram.domain.repository.EmojiRepository import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.chats.currentChat.chatContent.DeleteMessagesSheet import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale @Composable fun MessageOptionsMenu( @@ -104,12 +191,16 @@ fun MessageOptionsMenu( onDismiss: () -> Unit ) { val density = LocalDensity.current - val configuration = LocalConfiguration.current val haptic = LocalHapticFeedback.current val scope = rememberCoroutineScope() val emojiRepository: EmojiRepository = koinInject() - val screenHeight = with(density) { configuration.screenHeightDp.dp.toPx() }.toInt() + val windowSize = LocalWindowInfo.current.containerSize + val screenHeight = windowSize.height + + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val windowInsets = WindowInsets.systemBars.union(WindowInsets.ime) val topInset = windowInsets.getTop(density) val bottomInset = windowInsets.getBottom(density) @@ -336,8 +427,12 @@ fun MessageOptionsMenu( ), topLeft = CornerRadius(if (!isMessageOutgoing && isSameSenderAbove) s else r), topRight = CornerRadius(if (isMessageOutgoing && isSameSenderAbove) s else r), - bottomRight = if (hasBottom) CornerRadius.Zero else CornerRadius(if (isMessageOutgoing) s else r), - bottomLeft = if (hasBottom) CornerRadius.Zero else CornerRadius(if (!isMessageOutgoing) s else r) + bottomRight = if (hasBottom) CornerRadius.Zero else CornerRadius( + if (isMessageOutgoing) s else r + ), + bottomLeft = if (hasBottom) CornerRadius.Zero else CornerRadius( + if (!isMessageOutgoing) s else r + ) ) ) @@ -350,7 +445,10 @@ fun MessageOptionsMenu( 0f, topHeight + currentGap ), - size = Size(messageSize.width.toFloat(), bottomHeight) + size = Size( + messageSize.width.toFloat(), + bottomHeight + ) ), topLeft = CornerRadius.Zero, topRight = CornerRadius.Zero, @@ -364,7 +462,10 @@ fun MessageOptionsMenu( RoundRect( rect = Rect( offset = messageOffset - containerOffset, - size = Size(messageSize.width.toFloat(), messageSize.height.toFloat()) + size = Size( + messageSize.width.toFloat(), + messageSize.height.toFloat() + ) ), topLeft = CornerRadius(if (!isMessageOutgoing && isSameSenderAbove) s else r), topRight = CornerRadius(if (isMessageOutgoing && isSameSenderAbove) s else r), @@ -388,7 +489,8 @@ fun MessageOptionsMenu( .widthIn(min = 208.dp, max = 276.dp) .heightIn(max = maxMenuHeight) .graphicsLayer { - this.alpha = if (menuSize == IntSize.Zero || containerSize == IntSize.Zero) 0f else menuAlpha + this.alpha = + if (menuSize == IntSize.Zero || containerSize == IntSize.Zero) 0f else menuAlpha scaleX = menuScale scaleY = menuScale this.transformOrigin = transformOrigin @@ -729,7 +831,7 @@ fun MessageOptionsMenu( if (sections.hasRestoreOriginalTextAction) { InternalMenuOptionItem( - icon = Icons.Rounded.Undo, + icon = Icons.AutoMirrored.Rounded.Undo, text = stringResource(R.string.menu_restore_original_text), onClick = { animateOutAndDismiss(onRestoreOriginalText) } ) @@ -777,7 +879,7 @@ fun MessageOptionsMenu( else -> { val viewerDateFormat = - remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) } + remember { SimpleDateFormat("MMM d, $timeFormat", Locale.getDefault()) } val scrollState = rememberScrollState() Column( modifier = Modifier @@ -851,7 +953,7 @@ private data class MessageMenuSections( } companion object { - val Saver = listSaver( + val Saver = listSaver( save = { listOf( it.hasViewersSection, @@ -1115,7 +1217,9 @@ private fun InternalMenuHeaderInfo( showReadInfo: Boolean, showViewsInfo: Boolean ) { - val dateFormat = remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val dateFormat = remember { SimpleDateFormat("MMM d, $timeFormat", Locale.getDefault()) } val editDate = if (message.editDate > 0) dateFormat.format(Date(message.editDate.toLong() * 1000)) else null val readDate = if (showReadInfo) @@ -1259,6 +1363,7 @@ private fun shouldShowDownload(message: MessageModel): Boolean { is MessageContent.Video -> content.path != null is MessageContent.Gif -> content.path != null is MessageContent.Document -> content.path != null + is MessageContent.Audio -> content.path != null is MessageContent.Voice -> content.path != null is MessageContent.VideoNote -> content.path != null else -> false diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt index 1a8e3c47..58b6ba96 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt @@ -31,12 +31,14 @@ fun StickerEmojiMenu( onGifSelected: (GifModel) -> Unit, panelHeight: Dp = 400.dp, emojiOnlyMode: Boolean = false, + canSendStickers: Boolean = true, onSearchFocused: (Boolean) -> Unit = {}, stickerRepository: StickerRepository ) { - var selectedTab by remember(emojiOnlyMode) { mutableIntStateOf(if (emojiOnlyMode) 1 else 0) } + val stickersAndGifsAllowed = !emojiOnlyMode && canSendStickers + var selectedTab by remember(stickersAndGifsAllowed) { mutableIntStateOf(if (stickersAndGifsAllowed) 0 else 1) } var isSearchMode by remember { mutableStateOf(false) } - val tabs = if (emojiOnlyMode) { + val tabs = if (!stickersAndGifsAllowed) { listOf(Triple(stringResource(R.string.sticker_menu_tab_emojis), Icons.Outlined.EmojiEmotions, 1)) } else { listOf( @@ -46,6 +48,12 @@ fun StickerEmojiMenu( ) } + LaunchedEffect(stickersAndGifsAllowed) { + if (!stickersAndGifsAllowed && selectedTab != 1) { + selectedTab = 1 + } + } + Surface( modifier = if (isSearchMode) { Modifier @@ -63,14 +71,14 @@ fun StickerEmojiMenu( Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { when (selectedTab) { - 0 -> StickersView( + 0 -> if (stickersAndGifsAllowed) StickersView( onStickerSelected = onStickerSelected, onSearchFocused = { focused -> isSearchMode = focused onSearchFocused(focused) }, contentPadding = PaddingValues(bottom = 76.dp) - ) + ) else Unit 1 -> EmojisGrid( onEmojiSelected = onEmojiSelected, @@ -82,7 +90,7 @@ fun StickerEmojiMenu( contentPadding = PaddingValues(bottom = 76.dp) ) - 2 -> GifsView( + 2 -> if (stickersAndGifsAllowed) GifsView( onGifSelected = onGifSelected, onSearchFocused = { focused -> isSearchMode = focused @@ -90,12 +98,12 @@ fun StickerEmojiMenu( }, contentPadding = PaddingValues(bottom = 76.dp), stickerRepository = stickerRepository - ) + ) else Unit } } AnimatedVisibility( - visible = !isSearchMode && !emojiOnlyMode, + visible = !isSearchMode && tabs.size > 1, enter = fadeIn(animationSpec = tween(180)) + slideInVertically(animationSpec = tween(220)) { it / 4 }, exit = fadeOut(animationSpec = tween(130)) + diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt index 69718d5e..b9d7a048 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt @@ -17,9 +17,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -75,9 +75,9 @@ fun MediaViewer( val context = LocalContext.current - val config = LocalConfiguration.current + val containerSize = LocalWindowInfo.current.containerSize val density = LocalDensity.current - val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() } + val screenHeightPx = containerSize.height.toFloat() val dismissDistancePx = with(density) { 160.dp.toPx() } val dismissVelocityThreshold = with(density) { 1000.dp.toPx() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt index 3677933d..bd2f9923 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt @@ -50,6 +50,8 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.viewers.components.* import java.io.ByteArrayInputStream import java.text.SimpleDateFormat @@ -71,6 +73,8 @@ fun YouTubeViewer( val youtubeId = extractYouTubeId(videoUrl) ?: return val startTime = extractYouTubeTime(videoUrl) val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val lifecycleOwner = LocalLifecycleOwner.current val playerState = remember { YouTubePlayerState() } var isInPipMode by remember { mutableStateOf(false) } @@ -290,7 +294,7 @@ fun YouTubeViewer( LaunchedEffect(Unit) { while (true) { - currentTimeStr = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) + currentTimeStr = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date()) delay(1000) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt index 91ac2248..b17932e6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt @@ -1,6 +1,10 @@ package org.monogram.presentation.features.viewers.components -import androidx.compose.animation.core.* +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -150,6 +154,8 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( screenHeightPx: Float, dismissThreshold: Float, dismissVelocityThreshold: Float, + allowZoom: Boolean = true, + allowDismiss: Boolean = true, onDismiss: () -> Unit, scope: CoroutineScope ) { @@ -173,9 +179,9 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( val zoomChange = event.calculateZoom() val panChange = event.calculatePan() - if (pointerCount > 1) isZooming = true + if (allowZoom && pointerCount > 1) isZooming = true - if (!isZooming && !isVerticalDrag && zoomState.scale.value == 1f && pointerCount == 1) { + if (allowDismiss && !isZooming && !isVerticalDrag && zoomState.scale.value == 1f && pointerCount == 1) { pan += panChange val totalPan = pan.getDistance() if (totalPan > touchSlop) { @@ -187,10 +193,10 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( } } - if (isZooming || zoomState.scale.value > 1f) { + if (allowZoom && (isZooming || zoomState.scale.value > 1f)) { zoomState.onTransform(scope, panChange, zoomChange, IntSize(size.width, size.height), 3f) event.changes.forEach { if (it.positionChanged()) it.consume() } - } else if (isVerticalDrag) { + } else if (allowDismiss && isVerticalDrag) { scope.launch { rootState.drag(panChange.y) } event.changes.forEach { if (it.positionChanged()) it.consume() } } @@ -199,9 +205,9 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( } val velocity = tracker.calculateVelocity() - if (zoomState.scale.value > 1f) { + if (allowZoom && zoomState.scale.value > 1f) { zoomState.ensureBounds(size.width.toFloat(), size.height.toFloat(), scope) - } else if (isVerticalDrag) { + } else if (allowDismiss && isVerticalDrag) { val offsetY = rootState.offsetY.value val shouldDismiss = abs(offsetY) > dismissThreshold || abs(velocity.y) > dismissVelocityThreshold if (shouldDismiss) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt index 7306314c..60c26cd0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.media3.exoplayer.ExoPlayer import kotlin.math.max +import kotlin.math.roundToInt @Composable fun Modifier.videoGestures( exoPlayer: ExoPlayer, isLocked: Boolean, isInPipMode: Boolean, + showControls: Boolean, isDoubleTapSeekEnabled: Boolean, isGesturesEnabled: Boolean, isZoomEnabled: Boolean, @@ -36,6 +38,7 @@ fun Modifier.videoGestures( context: Context ): Modifier { val scope = rememberCoroutineScope() + val controlZoneRatio = 0.33f return this .pointerInput(isLocked, isInPipMode) { @@ -60,30 +63,69 @@ fun Modifier.videoGestures( } .pointerInput(isLocked, isInPipMode) { if (isInPipMode) return@pointerInput + var dragOnLeft = false + var dragOnRight = false + var startBrightness = 0.5f + var startVolume = 0 + var maxVolume = 1 + var accumulatedDragY = 0f + var lastAppliedVolume = -1 detectVerticalDragGestures( onDragStart = { change -> + accumulatedDragY = 0f if (!isLocked && isGesturesEnabled && zoomState.scale.value == 1f) { val width = size.width val x = change.x - // Only show overlay if dragging on the edges (15% width) - if (x < width * 0.15f || x > width * 0.85f) { + dragOnLeft = x < width * controlZoneRatio + dragOnRight = x > width * (1f - controlZoneRatio) + if (dragOnLeft || dragOnRight) { + if (dragOnLeft) { + val activity = context.findActivity() + val currentBrightness = + activity?.window?.attributes?.screenBrightness + startBrightness = + (currentBrightness?.takeIf { it != -1f } ?: 0.5f).coerceIn( + 0f, + 1f + ) + } + if (dragOnRight) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + .coerceAtLeast(1) + startVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + lastAppliedVolume = startVolume + } onGestureOverlayChange(true, null, null) + } else { + dragOnLeft = false + dragOnRight = false } } }, - onDragEnd = { onGestureOverlayChange(false, null, null) }, - onDragCancel = { onGestureOverlayChange(false, null, null) } + onDragEnd = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f + onGestureOverlayChange(false, null, null) + }, + onDragCancel = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f + onGestureOverlayChange(false, null, null) + } ) { change, dragAmount -> if (!isLocked && isGesturesEnabled && zoomState.scale.value == 1f) { - val width = size.width - val x = change.position.x - val isLeft = x < width * 0.15f - val isRight = x > width * 0.85f + accumulatedDragY += dragAmount val activity = context.findActivity() - if (isLeft && activity != null) { + if (dragOnLeft && activity != null) { val lp = activity.window.attributes - var newBrightness = (lp.screenBrightness.takeIf { it != -1f } ?: 0.5f) - (dragAmount / 1000f) + var newBrightness = startBrightness - (accumulatedDragY / 1000f) newBrightness = newBrightness.coerceIn(0f, 1f) lp.screenBrightness = newBrightness activity.window.attributes = lp @@ -92,23 +134,25 @@ fun Modifier.videoGestures( Icons.Rounded.BrightnessMedium, "${(newBrightness * 100).toInt()}%" ) - } else if (isRight) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVol = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val currentVol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val delta = -(dragAmount / 50f) - val newVol = (currentVol + delta).coerceIn(0f, maxVol.toFloat()) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVol.toInt(), 0) + } else if (dragOnRight) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val volumeDelta = (-accumulatedDragY / 50f).roundToInt() + val newVol = (startVolume + volumeDelta).coerceIn(0, maxVolume) + if (newVol != lastAppliedVolume) { + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVol, 0) + lastAppliedVolume = newVol + } onGestureOverlayChange( true, Icons.AutoMirrored.Rounded.VolumeUp, - "${((newVol / maxVol) * 100).toInt()}%" + "${((newVol.toFloat() / maxVolume) * 100).toInt()}%" ) } } } } - .pointerInput(isLocked, isInPipMode) { + .pointerInput(isLocked, isInPipMode, isZoomEnabled, showControls) { if (isInPipMode) return@pointerInput detectZoomAndDismissGestures( zoomState = zoomState, @@ -116,6 +160,8 @@ fun Modifier.videoGestures( screenHeightPx = screenHeightPx, dismissThreshold = dismissDistancePx, dismissVelocityThreshold = dismissVelocityThreshold, + allowZoom = isZoomEnabled, + allowDismiss = showControls && !isLocked, onDismiss = onDismiss, scope = scope ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt index 3e5eeb82..f6b7679f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt @@ -7,22 +7,90 @@ import android.util.Log import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.annotation.OptIn -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Forward +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.AspectRatio +import androidx.compose.material.icons.rounded.Camera +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.FitScreen +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Gif +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PictureInPicture +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.RepeatOne +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.material.icons.rounded.Replay10 +import androidx.compose.material.icons.rounded.ScreenRotation +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,7 +108,12 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.media3.common.* +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultRenderersFactory @@ -55,6 +128,7 @@ import org.koin.compose.koinInject import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.domain.repository.StreamingRepository import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.getMimeType import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow @@ -100,6 +174,9 @@ fun VideoPage( val playerFactory = koinInject() val seekDurationMs = seekDuration * 1000L + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val currentOnDismiss by rememberUpdatedState(onDismiss) val currentOnToggleControls by rememberUpdatedState(onToggleControls) val currentOnToggleSettings by rememberUpdatedState(onToggleSettings) @@ -295,6 +372,7 @@ fun VideoPage( exoPlayer = exoPlayer, isLocked = isLocked, isInPipMode = isInPipMode, + showControls = showControls, isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, isGesturesEnabled = isGesturesEnabled, isZoomEnabled = isZoomEnabled, @@ -401,7 +479,7 @@ fun VideoPage( isEnded = isEnded, currentPosition = currentPosition, totalDuration = totalDuration, - currentTime = currentTime(), + currentTime = currentTime(timeFormat), isSettingsOpen = showSettingsMenu, caption = caption, downloadProgress = downloadProgress, @@ -592,7 +670,10 @@ fun VideoPlayerControls( .size(84.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)) - .clickable(interactionSource = interactionSource, indication = null) { onPlayPauseToggle() }, + .clickable( + interactionSource = interactionSource, + indication = null + ) { onPlayPauseToggle() }, contentAlignment = Alignment.Center ) { Icon( diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt index 4e1da8cd..f7b6aa1d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager +import java.text.SimpleDateFormat import java.util.* @Composable @@ -231,8 +233,8 @@ fun formatDuration(durationMs: Long): String { return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } -fun currentTime(): String = - java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) +fun currentTime(timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date()) fun Context.findActivity(): ComponentActivity? = when (this) { is ComponentActivity -> this diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt index 213261d6..3ad5fc1d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt @@ -76,8 +76,6 @@ class MiniAppState( var backgroundColor by mutableStateOf(initialThemeParams.backgroundColor?.let { Color(it.toColorInt()) }) var bottomBarColor by mutableStateOf(initialThemeParams.bottomBarBackgroundColor?.let { Color(it.toColorInt()) }) - var headerText by mutableStateOf(botName) - var isExpanded by mutableStateOf(false) var isFullscreen by mutableStateOf(false) @@ -499,8 +497,8 @@ class MiniAppState( reqId = "req_phone", method = "web_app_request_phone", params = "", - title = "Share Contact", - message = "Allow $botName to access your phone number?", + title = context.getString(R.string.mini_app_share_contact_title), + message = context.getString(R.string.mini_app_share_contact_message, botName), onConfirm = { scope.launch { val me = userRepository.getMe() @@ -533,8 +531,8 @@ class MiniAppState( reqId = "req_write_access", method = "web_app_request_write_access", params = "", - title = "Allow Messages", - message = "Allow $botName to send you messages?", + title = context.getString(R.string.mini_app_allow_messages_title), + message = context.getString(R.string.mini_app_allow_messages_message, botName), onConfirm = { telegramProxy?.dispatchToWebView("write_access_requested", JSONObject().put("status", "allowed")) activeCustomMethod = null @@ -619,10 +617,6 @@ class MiniAppState( topBarColor = themeParams.headerBackgroundColor?.let { Color(it.toColorInt()) } } - override fun onSetHeaderText(text: String) { - headerText = text - } - override fun onSetBottomBarColor(color: Int) { bottomBarColor = Color(color) } @@ -819,8 +813,12 @@ class MiniAppState( reqId = "req_file_download", method = "web_app_request_file_download", params = "", - title = "Download File", - message = "Download ${if (fileName.isBlank()) "this file" else fileName}?", + title = context.getString(R.string.mini_app_download_file_title), + message = if (fileName.isBlank()) { + context.getString(R.string.mini_app_download_file_message_generic) + } else { + context.getString(R.string.mini_app_download_file_message_named, fileName) + }, onConfirm = { val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager if (downloadManager == null) { @@ -1048,9 +1046,9 @@ class MiniAppState( override fun onAuthenticationFailed() {} }) val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric Authentication") - .setSubtitle(reason ?: "Authenticate to continue") - .setNegativeButtonText("Cancel") + .setTitle(context.getString(R.string.mini_app_biometric_auth_title)) + .setSubtitle(reason ?: context.getString(R.string.mini_app_biometric_auth_subtitle)) + .setNegativeButtonText(context.getString(R.string.cancel_button)) .build() biometricPrompt.authenticate(promptInfo) } @@ -1070,7 +1068,7 @@ class MiniAppState( return } showPermissionRequest = PermissionRequest( - message = "Allow this bot to access your location?", + message = context.getString(R.string.mini_app_request_location_access_message), onGranted = { savePermission("location", true) checkSystemLocationAndHandle() @@ -1127,9 +1125,9 @@ class MiniAppState( fun onShowPermissions() { val permissions = mapOf( - "Location" to botPreferences.getWebappPermission(botUserId, "location"), - "Biometry" to botPreferences.getWebappPermission(botUserId, "biometry"), - "Terms of Service" to botPreferences.getWebappPermission(botUserId, "tos_accepted") + context.getString(R.string.location_label) to botPreferences.getWebappPermission(botUserId, "location"), + context.getString(R.string.mini_app_permission_biometry) to botPreferences.getWebappPermission(botUserId, "biometry"), + context.getString(R.string.terms_of_service_title) to botPreferences.getWebappPermission(botUserId, "tos_accepted") ) botPermissions = permissions isPermissionsVisible = true @@ -1141,9 +1139,9 @@ class MiniAppState( fun onTogglePermission(permission: String) { val key = when (permission) { - "Location" -> "location" - "Biometry" -> "biometry" - "Terms of Service" -> "tos_accepted" + context.getString(R.string.location_label) -> "location" + context.getString(R.string.mini_app_permission_biometry) -> "biometry" + context.getString(R.string.terms_of_service_title) -> "tos_accepted" else -> return } val current = botPreferences.getWebappPermission(botUserId, key) diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt index 85eea84d..7bcf868e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt @@ -362,7 +362,7 @@ fun MiniAppViewer( Column(modifier = Modifier.fillMaxSize()) { if (!state.isFullscreen) { MiniAppTopBar( - headerText = state.headerText, + headerText = botName, isBackButtonVisible = state.isBackButtonVisible, isSettingsButtonVisible = state.isSettingsButtonVisible, isInitializing = state.isInitializing, diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt index 970332f7..c2d4152a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt @@ -53,7 +53,6 @@ interface TelegramWebAppHost { fun onSetBackgroundColor(color: Int) fun onSetHeaderColor(colorKey: String?, customColor: Int?) fun onResetHeaderColor() - fun onSetHeaderText(text: String) fun onSetBottomBarColor(color: Int) fun onResetBottomBarColor() fun onSetupClosingBehavior(needConfirmation: Boolean) diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt index 067abf4e..2e34a09c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt @@ -85,7 +85,14 @@ class TelegramWebviewProxy( document.documentElement.style.setProperty('--tg-content-safe-area-inset-left', '${contentSafeArea.optInt("left")}px'); document.documentElement.style.setProperty('--tg-content-safe-area-inset-right', '${contentSafeArea.optInt("right")}px'); """.trimIndent() - webView.evaluateJavascript(script, null) + + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for injecting safe area CSS", e) + } + } } @JavascriptInterface @@ -118,7 +125,6 @@ class TelegramWebviewProxy( data.optString("color_key").takeIf { it.isNotEmpty() }, data.optString("color").takeIf { it.isNotEmpty() }) - "web_app_set_header_text" -> WebAppEvent.SetHeaderText(data.optString("text")) "web_app_set_bottom_bar_color" -> WebAppEvent.SetBottomBarColor(data.optString("color")) "web_app_setup_main_button" -> WebAppEvent.SetupMainButton( data.optBoolean("is_visible"), data.optBoolean("is_active"), @@ -305,7 +311,6 @@ class TelegramWebviewProxy( is WebAppEvent.SetBackgroundColor -> host.onSetBackgroundColor(parseColor(event.color)) is WebAppEvent.SetHeaderColor -> host.onSetHeaderColor(event.colorKey, event.color?.let { parseColor(it) }) - is WebAppEvent.SetHeaderText -> host.onSetHeaderText(event.text) is WebAppEvent.SetBottomBarColor -> host.onSetBottomBarColor(parseColor(event.color)) is WebAppEvent.SetupMainButton -> host.onSetupMainButton( event.isVisible, @@ -350,11 +355,18 @@ class TelegramWebviewProxy( "accelerometer" ) - is WebAppEvent.StopAccelerometer -> stopSensor(Sensor.TYPE_ACCELEROMETER, "accelerometer") + is WebAppEvent.StopAccelerometer -> stopSensors("accelerometer", Sensor.TYPE_ACCELEROMETER) is WebAppEvent.StartGyroscope -> startSensor(Sensor.TYPE_GYROSCOPE, event.refreshRate, "gyroscope") - is WebAppEvent.StopGyroscope -> stopSensor(Sensor.TYPE_GYROSCOPE, "gyroscope") + is WebAppEvent.StopGyroscope -> stopSensors("gyroscope", Sensor.TYPE_GYROSCOPE) is WebAppEvent.StartDeviceOrientation -> startDeviceOrientation(event.refreshRate, event.needAbsolute) - is WebAppEvent.StopDeviceOrientation -> stopSensor(Sensor.TYPE_ROTATION_VECTOR, "device_orientation") + is WebAppEvent.StopDeviceOrientation -> stopSensors( + "device_orientation", + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_MAGNETIC_FIELD, + Sensor.TYPE_GAME_ROTATION_VECTOR, + Sensor.TYPE_ROTATION_VECTOR + ) + is WebAppEvent.ToggleOrientationLock -> host.onToggleOrientationLock(event.locked) is WebAppEvent.RequestFullscreen -> { host.onRequestFullscreen() @@ -409,10 +421,23 @@ class TelegramWebviewProxy( fun dispatchToWebView(eventType: String, eventData: JSONObject?) { Log.d(TAG, "dispatchToWebView: $eventType | Data: $eventData") - val data = eventData?.toString() ?: "{}" - val script = - "if (window.Telegram && window.Telegram.WebView && window.Telegram.WebView.receiveEvent) { window.Telegram.WebView.receiveEvent('$eventType', $data); }" - webView.evaluateJavascript(script, null) + + val data = eventData?.toString() + val quotedEvent = JSONObject.quote(eventType) + + val script = """ + if (window.Telegram?.WebView?.receiveEvent) { + window.Telegram.WebView.receiveEvent($quotedEvent, $data); + } + """.trimIndent() + + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for event $eventType", e) + } + } } private fun updateViewport() { @@ -429,7 +454,13 @@ class TelegramWebviewProxy( document.documentElement.style.setProperty('--tg-viewport-height', '${height}px'); document.documentElement.style.setProperty('--tg-viewport-stable-height', '${height}px'); """.trimIndent() - webView.evaluateJavascript(script, null) + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for updating viewport", e) + } + } } private fun injectCSSVars(themeParamsJson: String) { @@ -446,28 +477,57 @@ class TelegramWebviewProxy( } } sb.append("})();") - webView.evaluateJavascript(sb.toString(), null) + webView.post { + try { + webView.evaluateJavascript(sb.toString(), null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for injecting CSS vars", e) + } + } } catch (e: Exception) { Log.e(TAG, "CSS Inject Error", e) } } + private fun getSensorDelay(refreshRate: Long): Int { + if (refreshRate >= 160) return SensorManager.SENSOR_DELAY_NORMAL + if (refreshRate >= 60) return SensorManager.SENSOR_DELAY_UI + return SensorManager.SENSOR_DELAY_GAME + } + private fun startSensor(type: Int, refreshMs: Long, eventName: String) { - val delay = (refreshMs * 1000).toInt().coerceAtLeast(SensorManager.SENSOR_DELAY_GAME) + val clampedRefreshMs = refreshMs.coerceIn(20, 1000) - stopSensor(type, eventName) + stopSensors(eventName, type) val listener = object : SensorEventListener { + private var lastUpdateTimestamp = 0L + override fun onSensorChanged(event: SensorEvent) { + val currentTime = System.currentTimeMillis() + + if (currentTime - lastUpdateTimestamp < clampedRefreshMs) { + return + } + val params = JSONObject() when (type) { - Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE -> { + Sensor.TYPE_ACCELEROMETER -> { + params.put("x", -event.values[0]) + params.put("y", -event.values[1]) + params.put("z", -event.values[2]) + } + + Sensor.TYPE_GYROSCOPE -> { params.put("x", event.values[0]) params.put("y", event.values[1]) params.put("z", event.values[2]) } } dispatchToWebView("${eventName}_changed", params) + + lastUpdateTimestamp = currentTime + } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} @@ -475,72 +535,136 @@ class TelegramWebviewProxy( val sensor = sensorManager.getDefaultSensor(type) if (sensor != null) { - sensorManager.registerListener(listener, sensor, delay) + sensorManager.registerListener(listener, sensor, getSensorDelay(clampedRefreshMs)) activeSensors[type] = listener - dispatchToWebView("${eventName}_started", JSONObject()) + dispatchToWebView("${eventName}_started", null) } else { dispatchToWebView("${eventName}_failed", JSONObject().put("error", "UNSUPPORTED")) } } private fun startDeviceOrientation(refreshMs: Long, needAbsolute: Boolean) { - val type = if (needAbsolute) Sensor.TYPE_ROTATION_VECTOR else Sensor.TYPE_GAME_ROTATION_VECTOR - val delay = (refreshMs * 1000).toInt().coerceAtLeast(SensorManager.SENSOR_DELAY_GAME) + val clampedRefreshMs = refreshMs.coerceIn(20, 1000) - stopSensor(Sensor.TYPE_ROTATION_VECTOR, "") - stopSensor(Sensor.TYPE_GAME_ROTATION_VECTOR, "") + val sensorTypes = if (needAbsolute) { + if (sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null) { + intArrayOf(Sensor.TYPE_ROTATION_VECTOR) + } else { + intArrayOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_MAGNETIC_FIELD) + } + } else { + intArrayOf(Sensor.TYPE_GAME_ROTATION_VECTOR) + } + + stopSensors( + "device_orientation", + Sensor.TYPE_ROTATION_VECTOR, + Sensor.TYPE_GAME_ROTATION_VECTOR, + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_MAGNETIC_FIELD + ) val listener = object : SensorEventListener { + private var lastUpdateTimestamp = 0L + + private val rotationMatrix = FloatArray(9) + private val inclinationMatrix = FloatArray(9) + private val orientation = FloatArray(3) + private val truncatedVector = FloatArray(4) + + private var gravityValues: FloatArray? = null + private var magneticValues: FloatArray? = null + override fun onSensorChanged(event: SensorEvent) { - val rotationMatrix = FloatArray(9) - SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) - val orientation = FloatArray(3) - SensorManager.getOrientation(rotationMatrix, orientation) - - var alpha = Math.toDegrees(orientation[0].toDouble()) // Azimuth - if (alpha < 0) alpha += 360.0 - val beta = Math.toDegrees(orientation[1].toDouble()) // Pitch - val gamma = Math.toDegrees(orientation[2].toDouble()) // Roll - - if (alpha.isNaN() || beta.isNaN() || gamma.isNaN() || - alpha.isInfinite() || beta.isInfinite() || gamma.isInfinite() - ) { - return + val currentTime = System.currentTimeMillis() + if (currentTime - lastUpdateTimestamp < clampedRefreshMs) return + + val success = when (event.sensor.type) { + Sensor.TYPE_ROTATION_VECTOR, Sensor.TYPE_GAME_ROTATION_VECTOR -> { + val values = event.values + // Samsung/device-specific safety check for rotation vector length + if (values.size > 4) { + values.copyInto(truncatedVector, 0, 0, 4) + SensorManager.getRotationMatrixFromVector( + rotationMatrix, + truncatedVector + ) + } else { + SensorManager.getRotationMatrixFromVector(rotationMatrix, values) + } + true + } + + Sensor.TYPE_ACCELEROMETER -> { + gravityValues = event.values.clone() + tryComputeMatrix() + } + + Sensor.TYPE_MAGNETIC_FIELD -> { + magneticValues = event.values.clone() + tryComputeMatrix() + } + + else -> false } - val params = JSONObject() - params.put("alpha", alpha) - params.put("beta", beta) - params.put("gamma", gamma) - params.put("absolute", needAbsolute) + if (success) { + SensorManager.getOrientation(rotationMatrix, orientation) + lastUpdateTimestamp = currentTime - dispatchToWebView("device_orientation_changed", params) + val params = JSONObject().apply { + put("absolute", needAbsolute) + put("alpha", -orientation[0].toDouble()) + put("beta", -orientation[1].toDouble()) + put("gamma", orientation[2].toDouble()) + } + dispatchToWebView("device_orientation_changed", params) + } + } + + private fun tryComputeMatrix(): Boolean { + val g = gravityValues ?: return false + val m = magneticValues ?: return false + return SensorManager.getRotationMatrix(rotationMatrix, inclinationMatrix, g, m) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} } - val sensor = sensorManager.getDefaultSensor(type) - if (sensor != null) { - sensorManager.registerListener(listener, sensor, delay) - activeSensors[type] = listener - dispatchToWebView("device_orientation_started", JSONObject()) + var startedCount = 0 + sensorTypes.forEach { type -> + sensorManager.getDefaultSensor(type)?.let { sensor -> + if (sensorManager.registerListener( + listener, + sensor, + getSensorDelay(clampedRefreshMs) + ) + ) { + activeSensors[type] = listener + startedCount++ + } + } + } + + if (startedCount > 0) { + dispatchToWebView("device_orientation_started", null) } else { dispatchToWebView("device_orientation_failed", JSONObject().put("error", "UNSUPPORTED")) } } - private fun stopSensor(type: Int, eventName: String) { - if (eventName == "device_orientation") { - activeSensors.remove(Sensor.TYPE_ROTATION_VECTOR)?.let { sensorManager.unregisterListener(it) } - activeSensors.remove(Sensor.TYPE_GAME_ROTATION_VECTOR)?.let { sensorManager.unregisterListener(it) } - dispatchToWebView("device_orientation_stopped", JSONObject()) - return + private fun stopSensors(eventName: String, vararg types: Int) { + var shouldSendStoppedEvent = false + + types.forEach { type -> + activeSensors.remove(type)?.let { + sensorManager.unregisterListener(it) + shouldSendStoppedEvent = true + } } - activeSensors.remove(type)?.let { - sensorManager.unregisterListener(it) - dispatchToWebView("${eventName}_stopped", JSONObject()) + if (shouldSendStoppedEvent) { + dispatchToWebView("${eventName}_stopped", null) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt index a739101a..fd6c34f3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt @@ -1,10 +1,34 @@ package org.monogram.presentation.features.webapp.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -14,8 +38,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.webapp.InvoiceModel import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.PaymentRepository @@ -50,13 +74,19 @@ fun InvoiceDialog( if (photoPath == null) { fileRepository.downloadFile(fileId) launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .collect { event -> + if (event is FileDownloadEvent.Progress && event.fileId == fileId) { + progress = event.progress + } + } } - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .collect { (_, _, completedPath) -> photoPath = completedPath } + fileRepository.fileDownloadFlow + .collect { event -> + if (event is FileDownloadEvent.Completed && event.fileId == fileId) { + photoPath = event.path + } + } } } else { photoPath = fileIdStr diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt index 12754742..6794b4b1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt @@ -11,6 +11,10 @@ import android.view.ViewGroup import android.webkit.* import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +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.toArgb import androidx.compose.ui.viewinterop.AndroidView @@ -34,6 +38,8 @@ fun MiniAppWebView( onLoadingChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { + var lastLoadedUrl by remember { mutableStateOf(url) } + AndroidView( factory = { ctx -> WebView(ctx).apply { @@ -130,8 +136,9 @@ fun MiniAppWebView( view.setBackgroundColor( backgroundColor?.toArgb() ?: themeParams.backgroundColor?.toColorInt() ?: Color.TRANSPARENT ) - if (url.isNotEmpty() && view.url != url) { + if (url.isNotEmpty() && url != lastLoadedUrl) { view.loadUrl(url, mapOf("Accept-Language" to acceptLanguage)) + lastLoadedUrl = url } } ) diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index 91d9246f..27d1790d 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -9,7 +9,6 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -114,7 +113,6 @@ class DefaultRootComponent( observeStickerLoading() checkLockState() updateSimCountryIso() - initExternalProxies() } private fun observeAuthState() { @@ -192,29 +190,6 @@ class DefaultRootComponent( } } - private fun initExternalProxies() { - scope.launch { - if (appPreferences.isTelegaProxyEnabled.first()) { - fetchExternalProxies() - } - } - } - - private suspend fun fetchExternalProxies() { - Log.d("RootComponent", "Fetching external proxies...") - val addedProxies = externalProxyRepository.fetchExternalProxies() - Log.d("RootComponent", "Added ${addedProxies.size} proxies. Starting ping...") - - coroutineScope { - addedProxies.forEach { proxy -> - launch { - externalProxyRepository.pingProxy(proxy.id) - } - } - } - Log.d("RootComponent", "Finished pinging external proxies") - } - override fun onBack() { navigation.pop() } diff --git a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt index c08b58a2..fac1b48f 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt @@ -1,21 +1,46 @@ package org.monogram.presentation.root +import android.os.Build import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +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.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R @@ -33,69 +58,101 @@ class DefaultStartupComponent( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun StartupContent(component: StartupComponent) { +fun StartupContent( + component: StartupComponent, + modifier: Modifier = Modifier, + animateIn: Boolean = true, + logoSize: Dp = 72.dp +) { val connectionStatus by component.connectionStatus.collectAsState() + val showLogo = Build.VERSION.SDK_INT < Build.VERSION_CODES.S + var revealDynamicElements by remember(animateIn) { mutableStateOf(!animateIn) } + + LaunchedEffect(animateIn) { + if (animateIn) { + delay(120) + revealDynamicElements = true + } + } + + val startupAlpha by animateFloatAsState( + targetValue = if (revealDynamicElements) 1f else 0.92f, + animationSpec = tween(durationMillis = 260), + label = "StartupAlpha" + ) Surface( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { Column( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 24.dp) + .alpha(startupAlpha), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Surface( - modifier = Modifier.size(96.dp), - shape = ShapeDefaults.ExtraLargeIncreased, - color = MaterialTheme.colorScheme.primaryContainer - ) { + if (showLogo) { Image( painter = painterResource(id = R.drawable.ic_app_logo), contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.FillBounds + modifier = Modifier.size(logoSize), + contentScale = ContentScale.Fit ) } - Spacer(modifier = Modifier.height(20.dp)) + AnimatedVisibility( + visible = revealDynamicElements, + enter = fadeIn(tween(260)) + slideInVertically( + animationSpec = tween(260), + initialOffsetY = { it / 5 } + ), + exit = fadeOut(tween(120)) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(if (showLogo) 20.dp else 0.dp)) - Text( - text = stringResource(R.string.app_name_monogram), - style = MaterialTheme.typography.headlineMediumEmphasized, - color = MaterialTheme.colorScheme.primary - ) + Text( + text = stringResource(R.string.app_name_monogram), + style = MaterialTheme.typography.headlineMediumEmphasized, + color = MaterialTheme.colorScheme.primary + ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(6.dp)) - AnimatedContent( - targetState = connectionStatus, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "StartupStatus" - ) { status -> - Text( - text = when (status) { - ConnectionStatus.WaitingForNetwork -> stringResource(R.string.waiting_for_network) - ConnectionStatus.Connecting -> stringResource(R.string.connecting) - ConnectionStatus.Updating -> stringResource(R.string.updating) - ConnectionStatus.ConnectingToProxy -> stringResource(R.string.connecting_to_proxy) - ConnectionStatus.Connected -> stringResource(R.string.startup_connecting) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + AnimatedContent( + targetState = connectionStatus, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "StartupStatus" + ) { status -> + Text( + text = when (status) { + ConnectionStatus.WaitingForNetwork -> stringResource(R.string.waiting_for_network) + ConnectionStatus.Connecting -> stringResource(R.string.connecting) + ConnectionStatus.Updating -> stringResource(R.string.updating) + ConnectionStatus.ConnectingToProxy -> stringResource(R.string.connecting_to_proxy) + ConnectionStatus.Connected -> stringResource(R.string.startup_connecting) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(20.dp)) - LinearWavyProgressIndicator( - modifier = Modifier.width(220.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) + LinearWavyProgressIndicator( + modifier = Modifier.width(220.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index a1c5d595..90b8182d 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -17,7 +17,12 @@ import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.WallpaperRepository -import org.monogram.presentation.core.util.* +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.EmojiStyle +import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.core.util.NightMode +import org.monogram.presentation.core.util.coRunCatching +import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext import java.io.File import java.net.URL @@ -32,6 +37,7 @@ interface ChatSettingsComponent { fun onStickerSizeChanged(size: Float) fun onWallpaperChanged(wallpaper: String?) fun onWallpaperSelected(wallpaper: WallpaperModel) + fun onWallpaperUpload(path: String) fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) fun onWallpaperBlurIntensityChanged(intensity: Int) fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) @@ -77,6 +83,7 @@ interface ChatSettingsComponent { fun onNightModeEndTimeChanged(time: String) fun onNightModeBrightnessThresholdChanged(threshold: Float) fun onDragToBackChanged(enabled: Boolean) + fun onTabletInterfaceEnabledChanged(enabled: Boolean) fun onAdBlockClick() fun onEmojiStyleChanged(style: EmojiStyle) fun onEmojiStyleLongClick(style: EmojiStyle) @@ -98,6 +105,7 @@ interface ChatSettingsComponent { val isWallpaperMoving: Boolean = false, val wallpaperDimming: Int = 0, val isWallpaperGrayscale: Boolean = false, + val isWallpaperUploading: Boolean = false, val availableWallpapers: List = emptyList(), val selectedWallpaper: WallpaperModel? = null, val isPlayerGesturesEnabled: Boolean = true, @@ -135,6 +143,7 @@ interface ChatSettingsComponent { val nightModeEndTime: String = "07:00", val nightModeBrightnessThreshold: Float = 0.2f, val isDragToBackEnabled: Boolean = true, + val isTabletInterfaceEnabled: Boolean = true, val emojiStyle: EmojiStyle = EmojiStyle.SYSTEM, val isAppleEmojiDownloaded: Boolean = false, val isTwitterEmojiDownloaded: Boolean = false, @@ -216,6 +225,7 @@ class DefaultChatSettingsComponent( nightModeEndTime = appPreferences.nightModeEndTime.value, nightModeBrightnessThreshold = appPreferences.nightModeBrightnessThreshold.value, isDragToBackEnabled = appPreferences.isDragToBackEnabled.value, + isTabletInterfaceEnabled = appPreferences.isTabletInterfaceEnabled.value, emojiStyle = appPreferences.emojiStyle.value, isAppleEmojiDownloaded = appPreferences.isAppleEmojiDownloaded.value, isTwitterEmojiDownloaded = appPreferences.isTwitterEmojiDownloaded.value, @@ -503,6 +513,12 @@ class DefaultChatSettingsComponent( } .launchIn(scope) + appPreferences.isTabletInterfaceEnabled + .onEach { enabled -> + _state.update { it.copy(isTabletInterfaceEnabled = enabled) } + } + .launchIn(scope) + appPreferences.emojiStyle .onEach { style -> _state.update { it.copy(emojiStyle = style) } @@ -612,6 +628,29 @@ class DefaultChatSettingsComponent( .launchIn(scope) } + private fun wallpaperPreferenceKey(wallpaper: WallpaperModel): String? = when { + wallpaper.slug.isNotEmpty() -> wallpaper.slug + !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath + else -> null + } + + private fun syncWallpaperOnServer( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ) { + scope.launch { + wallpaperRepository.setDefaultWallpaper( + wallpaper = wallpaper, + isBlurred = isBlurred, + isMoving = isMoving + )?.let { syncedWallpaper -> + wallpaperPreferenceKey(syncedWallpaper)?.let { appPreferences.setWallpaper(it) } + _state.update { it.copy(selectedWallpaper = syncedWallpaper) } + } + } + } + override fun onBackClicked() { onBack() } @@ -639,24 +678,50 @@ class DefaultChatSettingsComponent( override fun onWallpaperSelected(wallpaper: WallpaperModel) { _state.update { it.copy(selectedWallpaper = wallpaper) } + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + if (!wallpaper.isDownloaded && wallpaper.documentId != 0L) { scope.launch { wallpaperRepository.downloadWallpaper(wallpaper.documentId.toInt()) } } - val key = when { - wallpaper.slug.isNotEmpty() -> wallpaper.slug - !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath - else -> null - } + wallpaperPreferenceKey(wallpaper)?.let { appPreferences.setWallpaper(it) } + syncWallpaperOnServer(wallpaper, currentBlur, currentMoving) + } + + override fun onWallpaperUpload(path: String) { + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + + appPreferences.setWallpaper(path) + _state.update { it.copy(isWallpaperUploading = true) } + + scope.launch { + val uploaded = wallpaperRepository.uploadWallpaper( + filePath = path, + isBlurred = currentBlur, + isMoving = currentMoving + ) - key?.let { appPreferences.setWallpaper(it) } + if (uploaded != null) { + _state.update { it.copy(selectedWallpaper = uploaded) } + wallpaperPreferenceKey(uploaded)?.let { appPreferences.setWallpaper(it) } + if (!uploaded.isDownloaded && uploaded.documentId != 0L) { + wallpaperRepository.downloadWallpaper(uploaded.documentId.toInt()) + } + } + + _state.update { it.copy(isWallpaperUploading = false) } + } } override fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) { + val currentMoving = _state.value.isWallpaperMoving _state.update { it.copy(isWallpaperBlurred = isBlurred) } appPreferences.setWallpaperBlurred(isBlurred) + syncWallpaperOnServer(wallpaper, isBlurred, currentMoving) } override fun onWallpaperBlurIntensityChanged(intensity: Int) { @@ -664,8 +729,10 @@ class DefaultChatSettingsComponent( } override fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) { + val currentBlur = _state.value.isWallpaperBlurred _state.update { it.copy(isWallpaperMoving = isMoving) } appPreferences.setWallpaperMoving(isMoving) + syncWallpaperOnServer(wallpaper, currentBlur, isMoving) } override fun onWallpaperDimmingChanged(dimming: Int) { @@ -943,6 +1010,10 @@ class DefaultChatSettingsComponent( appPreferences.setDragToBackEnabled(enabled) } + override fun onTabletInterfaceEnabledChanged(enabled: Boolean) { + appPreferences.setTabletInterfaceEnabled(enabled) + } + override fun onAdBlockClick() { onAdBlock() } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index 224761fc..abcd9045 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -1,11 +1,49 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.settings.chatSettings -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.EaseInQuint +import androidx.compose.animation.core.EaseOutQuint +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -14,14 +52,75 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.StickyNote2 -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Brightness4 +import androidx.compose.material.icons.rounded.BrightnessAuto +import androidx.compose.material.icons.rounded.BrightnessLow +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.EmojiEmotions +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Gesture +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Photo +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Square +import androidx.compose.material.icons.rounded.SwipeLeft +import androidx.compose.material.icons.rounded.TabletAndroid +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.VideoFile +import androidx.compose.material.icons.rounded.ZoomIn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -31,6 +130,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet @@ -43,11 +143,25 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.get import org.monogram.presentation.settings.chatSettings.components.ChatListPreview import org.monogram.presentation.settings.chatSettings.components.ChatSettingsPreview import org.monogram.presentation.settings.chatSettings.components.WallpaperItem +import java.io.File +import java.io.FileOutputStream @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatSettingsContent(component: ChatSettingsComponent) { val state by component.state.subscribeAsState() + val context = LocalContext.current + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + + val wallpaperPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + copyUriToTempWallpaperPath(context, uri)?.let(component::onWallpaperUpload) + } + } val blueColor = Color(0xFF4285F4) val greenColor = Color(0xFF34A853) @@ -274,6 +388,40 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(16.dp) ) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 4.dp) + .clickable(enabled = !state.isWallpaperUploading) { + wallpaperPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + Box( + modifier = Modifier + .size(80.dp, 120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center + ) { + if (state.isWallpaperUploading) { + CircularProgressIndicator( + modifier = Modifier.size(26.dp), + strokeWidth = 2.5.dp + ) + } else { + Icon( + imageVector = Icons.Rounded.Upload, + contentDescription = stringResource(R.string.upload_wallpaper_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + item { val isSelected = state.wallpaper == null @@ -311,7 +459,10 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { Box( modifier = Modifier .size(32.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), + .background( + MaterialTheme.colorScheme.primary, + CircleShape + ), contentAlignment = Alignment.Center ) { Icon( @@ -773,6 +924,17 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { position = ItemPosition.MIDDLE, onCheckedChange = component::onShowLinkPreviewsChanged ) + if (isTablet) { + SettingsSwitchTile( + icon = Icons.Rounded.TabletAndroid, + title = stringResource(R.string.tablet_interface_title), + subtitle = stringResource(R.string.tablet_interface_subtitle), + checked = state.isTabletInterfaceEnabled, + iconColor = greenColor, + position = ItemPosition.MIDDLE, + onCheckedChange = component::onTabletInterfaceEnabledChanged + ) + } SettingsSwitchTile( icon = Icons.Rounded.SwipeLeft, title = stringResource(R.string.drag_to_back_title), @@ -1043,12 +1205,20 @@ private fun AppearanceSliderItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) Spacer(modifier = Modifier.width(8.dp)) Surface( @@ -1066,13 +1236,14 @@ private fun AppearanceSliderItem( } TextButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp) + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(32.dp) ) { - Text( - stringResource(R.string.reset_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold + Icon( + imageVector = Icons.Rounded.RestartAlt, + contentDescription = stringResource(R.string.photo_editor_action_reset), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) ) } } @@ -1161,7 +1332,10 @@ private fun ThemeModeItem( Box( modifier = Modifier .size(34.dp) - .background(contentColor.copy(alpha = if (selected) 0.16f else 0.1f), CircleShape), + .background( + contentColor.copy(alpha = if (selected) 0.16f else 0.1f), + CircleShape + ), contentAlignment = Alignment.Center ) { Icon( @@ -1373,4 +1547,24 @@ private fun TimePickerDialogWrapper( content() } ) -} \ No newline at end of file +} + +private fun copyUriToTempWallpaperPath(context: Context, uri: Uri): String? = try { + if (uri.scheme == "file") return uri.path + + val mime = context.contentResolver.getType(uri).orEmpty() + val extension = when { + mime.contains("png") -> "png" + mime.contains("webp") -> "webp" + else -> "jpg" + } + + val file = File(context.cacheDir, "wallpaper_${System.nanoTime()}.$extension") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } ?: return null + + file.absolutePath +} catch (_: Exception) { + null +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt index 092c1fe3..794e5a49 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt @@ -5,7 +5,6 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import android.util.Log import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,8 +13,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -23,6 +24,7 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.models.WallpaperType import java.io.File @Composable @@ -137,16 +139,31 @@ fun WallpaperBackground( Box(modifier = modifier) { val settings = wallpaper.settings + val wallpaperType = remember( + wallpaper.type, + wallpaper.pattern, + wallpaper.slug, + wallpaper.documentId + ) { + wallpaper.resolveType() + } + + val colors = remember(settings) { + listOfNotNull( + settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } + ) + } - val hasColors = remember(settings) { - settings?.let { - it.backgroundColor != null || it.secondBackgroundColor != null || - it.thirdBackgroundColor != null || it.fourthBackgroundColor != null - } ?: false + val hasColors = remember(colors) { + colors.isNotEmpty() } - val isFullImage = remember(wallpaper) { - !wallpaper.pattern && !wallpaper.slug.startsWith("emoji") && (wallpaper.documentId != 0L || wallpaper.slug == "built-in") + val isFullImage = remember(wallpaperType, wallpaper.documentId, wallpaper.slug) { + wallpaperType == WallpaperType.WALLPAPER && + (wallpaper.documentId != 0L || wallpaper.slug == "built-in") } val isBackgroundDisabled = remember(isFullImage, hasColors) { @@ -156,49 +173,21 @@ fun WallpaperBackground( val shouldShowBackground = !isBackgroundDisabled || isChatSettings if (shouldShowBackground) { - val colors = remember(settings) { - listOfNotNull( - settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } - ) - } - val bgMod = Modifier.fillMaxSize() - if (colors.isNotEmpty()) { - if (colors.size == 1) { - Box(modifier = bgMod.background(colors[0])) - } else { - val rotation = settings?.rotation ?: 0 - Box( - modifier = bgMod.background( - Brush.linearGradient( - colors = colors, - start = Offset(0f, 0f), - end = when (rotation) { - 45 -> Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) - 90 -> Offset(Float.POSITIVE_INFINITY, 0f) - 135 -> Offset(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY) - 180 -> Offset(0f, Float.NEGATIVE_INFINITY) - 225 -> Offset(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY) - 270 -> Offset(Float.NEGATIVE_INFINITY, 0f) - 315 -> Offset(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY) - else -> Offset(0f, Float.POSITIVE_INFINITY) - } - ) - ) - ) - } - } else { - Box(modifier = bgMod.background(MaterialTheme.colorScheme.surface)) - } + val baseColor = colors.firstOrNull() ?: MaterialTheme.colorScheme.surface + Box(modifier = bgMod.background(baseColor)) } - val imagePath = if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { - wallpaper.localPath + val supportsImageLayer = wallpaperType == WallpaperType.WALLPAPER || wallpaperType == WallpaperType.PATTERN + + val imagePath = if (supportsImageLayer) { + if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { + wallpaper.localPath + } else { + wallpaper.thumbnail?.localPath + } } else { - wallpaper.thumbnail?.localPath + null } if (imagePath != null && File(imagePath).exists()) { @@ -227,7 +216,7 @@ fun WallpaperBackground( if (animatedBlur > 0f) it.blur((animatedBlur / 4f).dp) else it } - if (wallpaper.pattern) { + if (wallpaperType == WallpaperType.PATTERN) { val intensity = (settings?.intensity ?: 50) / 100f AsyncImage( model = file, @@ -245,9 +234,6 @@ fun WallpaperBackground( colorFilter = colorFilter ) } - } else if (wallpaper.slug.startsWith("emoji")) { - // TODO: Implement rendering with gradient and emojis - Log.d("WallpaperBackground", "Emoji wallpaper rendering not implemented for slug: ${wallpaper.slug}") } else if (!shouldShowBackground) { Box(modifier = Modifier .fillMaxSize() @@ -264,4 +250,12 @@ fun WallpaperBackground( ) } } -} \ No newline at end of file +} + +private fun WallpaperModel.resolveType(): 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 +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 643c32de..075ba5bf 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -309,7 +309,11 @@ class DefaultNotificationsComponent( notificationSettingsRepository.setChatNotificationSettings(chatId, enabled) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.map { chat -> + if (chat.id == chatId) chat.copy(isMuted = !enabled) else chat + } + } } } } @@ -319,7 +323,30 @@ class DefaultNotificationsComponent( notificationSettingsRepository.resetChatNotificationSettings(chatId) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.filterNot { it.id == chatId } + } + } + } + } + + private fun updateExceptionsState( + scope: TdNotificationScope, + transform: (List) -> List + ) { + _state.update { state -> + when (scope) { + TdNotificationScope.PRIVATE_CHATS -> state.copy( + privateExceptions = state.privateExceptions?.let(transform) + ) + + TdNotificationScope.GROUPS -> state.copy( + groupExceptions = state.groupExceptions?.let(transform) + ) + + TdNotificationScope.CHANNELS -> state.copy( + channelExceptions = state.channelExceptions?.let(transform) + ) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 1b953d9e..97e31afe 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -1,21 +1,28 @@ package org.monogram.presentation.settings.proxy -import android.util.Log -import androidx.core.net.toUri import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update -import kotlinx.coroutines.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray import org.json.JSONObject import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.CacheProvider import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -39,9 +46,15 @@ interface ProxyComponent { fun onConfirmDelete() fun onDismissAddEdit() fun onAutoBestProxyToggled(enabled: Boolean) - fun onTelegaProxyToggled(enabled: Boolean) fun onPreferIpv6Toggled(enabled: Boolean) - fun onFetchTelegaProxies() + fun onProxySortModeChanged(mode: ProxySortMode) + fun onProxyUnavailableFallbackChanged(fallback: ProxyUnavailableFallback) + fun onHideOfflineProxiesToggled(enabled: Boolean) + fun onToggleFavoriteProxy(proxyId: Int) + fun exportProxiesJson(): String + fun importProxiesJson(json: String) + fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode) + fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int) fun onClearUnavailableProxies() fun onRemoveAllProxies() fun onConfirmClearUnavailableProxies() @@ -51,20 +64,23 @@ interface ProxyComponent { data class State( val proxies: List = emptyList(), - val telegaProxies: List = emptyList(), - val telegaProxyPing: Long? = null, - val isTelegaProxyEnabled: Boolean = false, + val visibleProxies: List = emptyList(), val isLoading: Boolean = false, val isAddingProxy: Boolean = false, val isAutoBestProxyEnabled: Boolean = false, val preferIpv6: Boolean = false, + val proxySortMode: ProxySortMode = ProxySortMode.LOWEST_PING, + val proxyUnavailableFallback: ProxyUnavailableFallback = ProxyUnavailableFallback.BEST_PROXY, + val hideOfflineProxies: Boolean = false, + val favoriteProxyId: Int? = null, + val proxyNetworkRules: Map = ProxyNetworkType.entries.associateWith { + ProxyNetworkRule(defaultProxyNetworkMode(it)) + }, val proxyToEdit: ProxyModel? = null, val proxyToDelete: ProxyModel? = null, val testPing: Long? = null, val isTesting: Boolean = false, - val isFetchingExternal: Boolean = false, val toastMessage: String? = null, - val isRussianNumber: Boolean = false, val showClearOfflineConfirmation: Boolean = false, val showRemoveAllConfirmation: Boolean = false ) @@ -76,7 +92,6 @@ class DefaultProxyComponent( ) : ProxyComponent, AppComponentContext by context { private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences - private val cacheProvider: CacheProvider = container.cacheProvider private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository private val _state = MutableValue(ProxyComponent.State()) @@ -85,64 +100,175 @@ class DefaultProxyComponent( private var restoreAttempted = false init { - checkIfRussianNumber() scope.launch { refreshProxies(shouldPing = true) } combine( appPreferences.isAutoBestProxyEnabled, - appPreferences.isTelegaProxyEnabled, - appPreferences.preferIpv6 - ) { autoBest, telega, ipv6 -> Triple(autoBest, telega, ipv6) } + appPreferences.preferIpv6, + appPreferences.proxySortMode, + appPreferences.proxyUnavailableFallback, + appPreferences.hideOfflineProxies, + ) { autoBest, ipv6, sortMode, fallback, hideOffline -> + ProxyPreferencesBaseState( + autoBest = autoBest, + preferIpv6 = ipv6, + sortMode = sortMode, + fallback = fallback, + hideOffline = hideOffline + ) + } + .combine(appPreferences.favoriteProxyId) { base, favoriteProxyId -> + base to favoriteProxyId + } + .combine(appPreferences.proxyNetworkRules) { baseWithFavorite, networkRules -> + val (base, favoriteProxyId) = baseWithFavorite + ProxyPreferencesState( + autoBest = base.autoBest, + preferIpv6 = base.preferIpv6, + sortMode = base.sortMode, + fallback = base.fallback, + hideOffline = base.hideOffline, + favoriteProxyId = favoriteProxyId, + networkRules = networkRules + ) + } .distinctUntilChanged() - .onEach { (autoBest, telega, ipv6) -> - if (telega && autoBest) { - appPreferences.setAutoBestProxyEnabled(false) - } - _state.update { - it.copy( - isAutoBestProxyEnabled = if (telega) false else autoBest, - isTelegaProxyEnabled = telega, - preferIpv6 = ipv6 + .onEach { prefs -> + _state.update { current -> + val visible = buildVisibleProxies( + current.proxies, + prefs.sortMode, + prefs.hideOffline, + prefs.favoriteProxyId + ) + current.copy( + isAutoBestProxyEnabled = prefs.autoBest, + preferIpv6 = prefs.preferIpv6, + proxySortMode = prefs.sortMode, + proxyUnavailableFallback = prefs.fallback, + hideOfflineProxies = prefs.hideOffline, + favoriteProxyId = prefs.favoriteProxyId, + proxyNetworkRules = prefs.networkRules, + visibleProxies = visible ) } }.launchIn(scope) } - private fun checkIfRussianNumber() { - val cachedIso = cacheProvider.cachedSimCountryIso.value - if (cachedIso != null) { - val isRussian = cachedIso.equals("ru", ignoreCase = true) - _state.update { it.copy(isRussianNumber = isRussian) } - return - } else { - _state.update { it.copy(isRussianNumber = false) } - } - } + private data class ProxyPreferencesBaseState( + val autoBest: Boolean, + val preferIpv6: Boolean, + val sortMode: ProxySortMode, + val fallback: ProxyUnavailableFallback, + val hideOffline: Boolean + ) + + private data class ProxyPreferencesState( + val autoBest: Boolean, + val preferIpv6: Boolean, + val sortMode: ProxySortMode, + val fallback: ProxyUnavailableFallback, + val hideOffline: Boolean, + val favoriteProxyId: Int?, + val networkRules: Map + ) private suspend fun refreshProxies(shouldPing: Boolean = false) { _state.update { it.copy(isLoading = true) } restoreUserProxiesIfNeeded() val allProxies = externalProxyRepository.getProxies() - val telegaIdentifiers = getTelegaIdentifiers() - - val telegaProxies = allProxies.filter { isProxyTelega(it, telegaIdentifiers) } - val regularProxies = allProxies.filter { !isProxyTelega(it, telegaIdentifiers) } - _state.update { it.copy( - proxies = regularProxies, - telegaProxies = telegaProxies, + proxies = allProxies, + visibleProxies = buildVisibleProxies( + allProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ), isLoading = false ) } - updateTelegaStatus(allProxies) if (shouldPing) { performPingAll() } } + private fun buildVisibleProxies( + proxies: List, + sortMode: ProxySortMode, + hideOffline: Boolean, + favoriteProxyId: Int? + ): List { + val filtered = if (hideOffline) proxies.filter { it.ping != -1L } else proxies + return when (sortMode) { + ProxySortMode.ACTIVE_FIRST -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.LOWEST_PING -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { pingSortValue(it.ping) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.SERVER_NAME -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.PROXY_TYPE -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { proxyTypeOrder(it.type) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.STATUS -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { statusOrder(it) } + .thenBy { pingSortValue(it.ping) } + .thenBy { it.server.lowercase() } + ) + } + } + + private fun topPriorityOrder(proxy: ProxyModel, favoriteProxyId: Int?): Int { + return when { + proxy.isEnabled -> 0 + favoriteProxyId != null && proxy.id == favoriteProxyId -> 1 + else -> 2 + } + } + + private fun pingSortValue(ping: Long?): Long { + return if (ping == null || ping < 0L) Long.MAX_VALUE else ping + } + + private fun proxyTypeOrder(type: ProxyTypeModel): Int { + return when (type) { + is ProxyTypeModel.Mtproto -> 0 + is ProxyTypeModel.Socks5 -> 1 + is ProxyTypeModel.Http -> 2 + } + } + + private fun statusOrder(proxy: ProxyModel): Int { + val ping = proxy.ping + return when { + proxy.isEnabled -> 0 + ping != null && ping >= 0L -> 1 + ping == null -> 2 + else -> 3 + } + } + private suspend fun restoreUserProxiesIfNeeded() { if (restoreAttempted) return restoreAttempted = true @@ -240,51 +366,65 @@ class DefaultProxyComponent( val type: ProxyTypeModel ) - private fun updateTelegaStatus(allProxies: List) { - val identifiers = getTelegaIdentifiers() - val enabledProxy = allProxies.find { it.isEnabled } - val isTelega = enabledProxy?.let { isProxyTelega(it, identifiers) } ?: false + private fun proxyFingerprint(server: String, port: Int, type: ProxyTypeModel): String { + return when (type) { + is ProxyTypeModel.Mtproto -> "mtproto|$server|$port|${type.secret}" + is ProxyTypeModel.Socks5 -> "socks5|$server|$port|${type.username}|${type.password}" + is ProxyTypeModel.Http -> "http|$server|$port|${type.username}|${type.password}|${type.httpOnly}" + } + } - _state.update { - it.copy( - isTelegaProxyEnabled = appPreferences.isTelegaProxyEnabled.value, - telegaProxyPing = if (isTelega) enabledProxy.ping else null + private fun parseProxyBackupJson(json: JSONObject): ProxyBackup? { + val type = when (json.optString("type")) { + "mtproto" -> ProxyTypeModel.Mtproto(json.optString("secret")) + "socks5" -> ProxyTypeModel.Socks5( + username = json.optString("username"), + password = json.optString("password") ) + + "http" -> ProxyTypeModel.Http( + username = json.optString("username"), + password = json.optString("password"), + httpOnly = json.optBoolean("httpOnly", false) + ) + + else -> return null } + + val server = json.optString("server") + val port = json.optInt("port", 443) + if (server.isBlank() || port !in 1..65535) return null + + return ProxyBackup(server = server, port = port, type = type) } - private fun getTelegaIdentifiers(): Set { - val urls = appPreferences.telegaProxyUrls.value - Log.d("ProxyComponent", "Getting identifiers for ${urls.size} URLs") - return urls.mapNotNull { url -> - try { - val uri = url.replace("t.me/proxy", "tg://proxy").toUri() - val server = uri.getQueryParameter("server") - if (server != null) { - val port = uri.getQueryParameter("port") ?: "443" - "$server:$port" - } else { - val serverMatch = Regex("server=([^&]+)").find(url) - val portMatch = Regex("port=([^&]+)").find(url) - val s = serverMatch?.groupValues?.get(1) - val p = portMatch?.groupValues?.get(1) ?: "443" - if (s != null) "$s:$p" else null + private fun proxyToJson(proxy: ProxyModel): JSONObject { + return JSONObject().apply { + put("server", proxy.server) + put("port", proxy.port) + when (val type = proxy.type) { + is ProxyTypeModel.Mtproto -> { + put("type", "mtproto") + put("secret", type.secret) + } + + is ProxyTypeModel.Socks5 -> { + put("type", "socks5") + put("username", type.username) + put("password", type.password) + } + + is ProxyTypeModel.Http -> { + put("type", "http") + put("username", type.username) + put("password", type.password) + put("httpOnly", type.httpOnly) } - } catch (e: Exception) { - null } - }.toSet().also { - Log.d("ProxyComponent", "Generated ${it.size} identifiers: $it") + put("favorite", proxy.id == appPreferences.favoriteProxyId.value) } } - private fun isProxyTelega(proxy: ProxyModel, identifiers: Set): Boolean { - val id = "${proxy.server}:${proxy.port}" - val isTelega = id in identifiers - if (isTelega) Log.d("ProxyComponent", "Proxy $id is identified as Telega") - return isTelega - } - override fun onBackClicked() = onBack() override fun onAddProxyClicked() { @@ -307,9 +447,108 @@ class DefaultProxyComponent( onEditProxyClicked(proxy) } + override fun onToggleFavoriteProxy(proxyId: Int) { + val currentFavorite = appPreferences.favoriteProxyId.value + val nextFavorite = if (currentFavorite == proxyId) null else proxyId + appPreferences.setFavoriteProxyId(nextFavorite) + } + + override fun exportProxiesJson(): String { + val proxiesArray = JSONArray() + _state.value.proxies.forEach { proxy -> + proxiesArray.put(proxyToJson(proxy)) + } + + return JSONObject().apply { + put("version", 1) + put("proxies", proxiesArray) + }.toString(2) + } + + override fun importProxiesJson(json: String) { + scope.launch { + val existing = externalProxyRepository.getProxies() + val fingerprintToId = existing.associate { proxy -> + proxyFingerprint(proxy.server, proxy.port, proxy.type) to proxy.id + }.toMutableMap() + + var added = 0 + var skipped = 0 + var invalid = 0 + var favoriteProxyIdToSet: Int? = null + + val parsedEntries = runCatching { + val trimmed = json.trim() + if (trimmed.startsWith("[")) { + val array = JSONArray(trimmed) + List(array.length()) { index -> array.optJSONObject(index) } + } else { + val root = JSONObject(trimmed) + val array = root.optJSONArray("proxies") ?: JSONArray() + List(array.length()) { index -> array.optJSONObject(index) } + } + }.getOrNull() + + if (parsedEntries == null) { + _state.update { it.copy(toastMessage = "Import failed: invalid file") } + return@launch + } + + parsedEntries.forEach { item -> + if (item == null) { + invalid++ + return@forEach + } + + val backup = parseProxyBackupJson(item) + if (backup == null) { + invalid++ + return@forEach + } + + val fingerprint = proxyFingerprint(backup.server, backup.port, backup.type) + val existingId = fingerprintToId[fingerprint] + if (existingId != null) { + skipped++ + if (item.optBoolean("favorite", false) && favoriteProxyIdToSet == null) { + favoriteProxyIdToSet = existingId + } + return@forEach + } + + val proxy = externalProxyRepository.addProxy( + server = backup.server, + port = backup.port, + enable = false, + type = backup.type + ) + + if (proxy != null) { + addProxyToBackup(proxy) + fingerprintToId[fingerprint] = proxy.id + added++ + if (item.optBoolean("favorite", false) && favoriteProxyIdToSet == null) { + favoriteProxyIdToSet = proxy.id + } + } else { + invalid++ + } + } + + favoriteProxyIdToSet?.let { appPreferences.setFavoriteProxyId(it) } + refreshProxies(shouldPing = false) + _state.update { + it.copy(toastMessage = "Imported: $added, skipped: $skipped, invalid: $invalid") + } + } + } + override fun onEnableProxy(proxyId: Int) { scope.launch { if (externalProxyRepository.enableProxy(proxyId)) { + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) + } refreshProxies(shouldPing = false) onPingProxy(proxyId) } @@ -325,7 +564,7 @@ class DefaultProxyComponent( } override fun onRemoveProxy(proxyId: Int) { - val proxy = (_state.value.proxies + _state.value.telegaProxies).find { it.id == proxyId } + val proxy = _state.value.proxies.find { it.id == proxyId } _state.update { it.copy(proxyToDelete = proxy) } } @@ -336,7 +575,7 @@ class DefaultProxyComponent( } private suspend fun performPingAll() { - val allProxies = _state.value.proxies + _state.value.telegaProxies + val allProxies = _state.value.proxies val pings = coroutineScope { allProxies.map { proxy -> proxy.id to async { @@ -347,15 +586,21 @@ class DefaultProxyComponent( }.associate { (id, job) -> id to job.await() } } - val updatedRegular = _state.value.proxies.map { proxy -> - pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy - } - val updatedTelega = _state.value.telegaProxies.map { proxy -> + val updatedProxies = _state.value.proxies.map { proxy -> pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy } - _state.update { it.copy(proxies = updatedRegular, telegaProxies = updatedTelega) } - updateTelegaStatus(updatedRegular + updatedTelega) + _state.update { + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ) + ) + } } override fun onPingProxy(proxyId: Int) { @@ -364,15 +609,21 @@ class DefaultProxyComponent( externalProxyRepository.pingProxy(proxyId) } ?: -1L - val updatedRegular = _state.value.proxies.map { - if (it.id == proxyId) it.copy(ping = ping) else it - } - val updatedTelega = _state.value.telegaProxies.map { + val updatedProxies = _state.value.proxies.map { if (it.id == proxyId) it.copy(ping = ping) else it } - _state.update { it.copy(proxies = updatedRegular, telegaProxies = updatedTelega) } - updateTelegaStatus(updatedRegular + updatedTelega) + _state.update { + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ) + ) + } } } @@ -391,6 +642,9 @@ class DefaultProxyComponent( val proxy = externalProxyRepository.addProxy(server, port, true, type) if (proxy != null) { addProxyToBackup(proxy) + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) + } _state.update { it.copy(isAddingProxy = false) } refreshProxies(shouldPing = false) onPingProxy(proxy.id) @@ -400,10 +654,18 @@ class DefaultProxyComponent( override fun onEditProxy(proxyId: Int, server: String, port: Int, type: ProxyTypeModel) { scope.launch { - val oldProxy = (_state.value.proxies + _state.value.telegaProxies).find { it.id == proxyId } + val oldProxy = _state.value.proxies.find { it.id == proxyId } val proxy = externalProxyRepository.editProxy(proxyId, server, port, true, type) if (proxy != null) { replaceProxyInBackup(oldProxy, proxy) + ProxyNetworkType.entries.forEach { networkType -> + if (appPreferences.proxyNetworkRules.value[networkType]?.specificProxyId == proxyId) { + appPreferences.setSpecificProxyIdForNetwork(networkType, proxy.id) + } + if (appPreferences.proxyNetworkRules.value[networkType]?.lastUsedProxyId == proxyId) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) + } + } _state.update { it.copy(proxyToEdit = null) } refreshProxies(shouldPing = false) onPingProxy(proxy.id) @@ -420,6 +682,18 @@ class DefaultProxyComponent( scope.launch { if (externalProxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) + if (appPreferences.favoriteProxyId.value == proxy.id) { + appPreferences.setFavoriteProxyId(null) + } + ProxyNetworkType.entries.forEach { networkType -> + val rule = appPreferences.proxyNetworkRules.value[networkType] + if (rule?.specificProxyId == proxy.id) { + appPreferences.setSpecificProxyIdForNetwork(networkType, null) + } + if (rule?.lastUsedProxyId == proxy.id) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, null) + } + } _state.update { it.copy(proxyToDelete = null) } refreshProxies(shouldPing = false) } @@ -434,51 +708,29 @@ class DefaultProxyComponent( appPreferences.setAutoBestProxyEnabled(enabled) } - override fun onTelegaProxyToggled(enabled: Boolean) { - appPreferences.setTelegaProxyEnabled(enabled) - if (enabled) { - appPreferences.setAutoBestProxyEnabled(false) - scope.launch { - if (_state.value.telegaProxies.isEmpty()) { - onFetchTelegaProxies() - } - refreshProxies(shouldPing = false) - } - } else { - scope.launch { - externalProxyRepository.disableProxy() - refreshProxies(shouldPing = false) - } - } - } - override fun onPreferIpv6Toggled(enabled: Boolean) { externalProxyRepository.setPreferIpv6(enabled) } - override fun onFetchTelegaProxies() { - if (_state.value.isFetchingExternal) return + override fun onProxySortModeChanged(mode: ProxySortMode) { + appPreferences.setProxySortMode(mode) + } - scope.launch { - _state.update { it.copy(isFetchingExternal = true) } - try { - Log.d("ProxyComponent", "Fetching telega proxies...") - val addedProxies = externalProxyRepository.fetchExternalProxies() - Log.d("ProxyComponent", "Added ${addedProxies.size} proxies") - - if (addedProxies.isEmpty()) { - _state.update { it.copy(isFetchingExternal = false) } - return@launch - } + override fun onProxyUnavailableFallbackChanged(fallback: ProxyUnavailableFallback) { + appPreferences.setProxyUnavailableFallback(fallback) + } - refreshProxies(shouldPing = false) - } catch (e: Exception) { - Log.e("ProxyComponent", "Error fetching proxies", e) - _state.update { it.copy(toastMessage = "Failed to fetch proxies") } - } finally { - _state.update { it.copy(isFetchingExternal = false) } - } - } + override fun onHideOfflineProxiesToggled(enabled: Boolean) { + appPreferences.setHideOfflineProxies(enabled) + } + + override fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { + appPreferences.setProxyNetworkMode(networkType, mode) + } + + override fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int) { + appPreferences.setSpecificProxyIdForNetwork(networkType, proxyId) + appPreferences.setProxyNetworkMode(networkType, ProxyNetworkMode.SPECIFIC_PROXY) } override fun onClearUnavailableProxies() { @@ -493,11 +745,15 @@ class DefaultProxyComponent( override fun onConfirmClearUnavailableProxies() { scope.launch { val proxiesToDelete = _state.value.proxies.filter { it.ping == -1L } + val deletedIds = proxiesToDelete.map { it.id }.toSet() proxiesToDelete.forEach { proxy -> if (externalProxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) } } + if (appPreferences.favoriteProxyId.value in deletedIds) { + appPreferences.setFavoriteProxyId(null) + } _state.update { it.copy(showClearOfflineConfirmation = false) } refreshProxies(shouldPing = false) } @@ -519,6 +775,11 @@ class DefaultProxyComponent( removeProxyFromBackup(proxy) } } + appPreferences.setFavoriteProxyId(null) + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setSpecificProxyIdForNetwork(networkType, null) + appPreferences.setLastUsedProxyIdForNetwork(networkType, null) + } _state.update { it.copy(showRemoveAllConfirmation = false) } refreshProxies(shouldPing = false) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index 56ce6a0e..c4c80d95 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -2,14 +2,40 @@ package org.monogram.presentation.settings.proxy -import android.content.Intent -import android.net.Uri -import androidx.compose.animation.* +import android.content.ClipData +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable @@ -18,27 +44,106 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LinkOff +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.Password +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Sort +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.R -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow +import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -46,6 +151,50 @@ fun ProxyContent(component: ProxyComponent) { val state by component.state.subscribeAsState() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + LocalClipboard.current + var expandedNetworkMenu by remember { mutableStateOf(null) } + var sortMenuExpanded by remember { mutableStateOf(false) } + var fallbackMenuExpanded by remember { mutableStateOf(false) } + var showTopMenu by remember { mutableStateOf(false) } + + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openOutputStream(uri)?.bufferedWriter()?.use { writer -> + writer.write(component.exportProxiesJson()) + } + }.onSuccess { + Toast.makeText( + context, + context.getString(R.string.proxy_export_success), + Toast.LENGTH_SHORT + ).show() + }.onFailure { + Toast.makeText( + context, + context.getString(R.string.proxy_export_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + val importLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader() + ?.use { it.readText() }.orEmpty() + }.onSuccess { json -> + component.importProxiesJson(json) + }.onFailure { + Toast.makeText( + context, + context.getString(R.string.proxy_import_failed), + Toast.LENGTH_SHORT + ).show() + } + } LaunchedEffect(state.toastMessage) { state.toastMessage?.let { message -> @@ -77,6 +226,12 @@ fun ProxyContent(component: ProxyComponent) { IconButton(onClick = component::onPingAll) { Icon(Icons.Rounded.Refresh, contentDescription = stringResource(R.string.refresh_pings_cd)) } + IconButton(onClick = { showTopMenu = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd) + ) + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background @@ -111,21 +266,15 @@ fun ProxyContent(component: ProxyComponent) { .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) ) { - AnimatedVisibility( - visible = !state.isTelegaProxyEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - SettingsSwitchTile( - icon = Icons.Rounded.Bolt, - title = stringResource(R.string.smart_switching_title), - subtitle = stringResource(R.string.smart_switching_subtitle), - checked = state.isAutoBestProxyEnabled, - iconColor = Color(0xFFAF52DE), - position = ItemPosition.TOP, - onCheckedChange = component::onAutoBestProxyToggled - ) - } + SettingsSwitchTile( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.smart_switching_title), + subtitle = stringResource(R.string.smart_switching_subtitle), + checked = state.isAutoBestProxyEnabled, + iconColor = Color(0xFFAF52DE), + position = ItemPosition.TOP, + onCheckedChange = component::onAutoBestProxyToggled + ) SettingsSwitchTile( icon = Icons.Rounded.Public, @@ -133,11 +282,11 @@ fun ProxyContent(component: ProxyComponent) { subtitle = stringResource(R.string.prefer_ipv6_subtitle), checked = state.preferIpv6, iconColor = Color(0xFF34A853), - position = if (state.isTelegaProxyEnabled) ItemPosition.TOP else ItemPosition.MIDDLE, + position = ItemPosition.MIDDLE, onCheckedChange = component::onPreferIpv6Toggled ) - val isDirect = state.proxies.none { it.isEnabled } && state.telegaProxies.none { it.isEnabled } + val isDirect = state.proxies.none { it.isEnabled } SettingsTile( icon = Icons.Rounded.LinkOff, title = stringResource(R.string.disable_proxy_title), @@ -157,86 +306,240 @@ fun ProxyContent(component: ProxyComponent) { } item { - AnimatedVisibility( - visible = state.isRussianNumber, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + SectionHeader( + text = stringResource(R.string.proxy_network_rules_header), + subtitle = stringResource(R.string.proxy_network_rules_subtitle) + ) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) ) { - Column { - SectionHeader( - text = stringResource(R.string.telega_proxy_header), - subtitle = stringResource(R.string.telega_proxy_subtitle), - onSubtitleClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/telegaru")) - context.startActivity(intent) - } + ProxyNetworkType.entries.forEachIndexed { index, networkType -> + val rule = state.proxyNetworkRules[networkType] ?: ProxyNetworkRule( + defaultProxyNetworkMode(networkType) ) - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - SettingsSwitchTile( - icon = Icons.Rounded.CloudDownload, - title = stringResource(R.string.enable_telega_proxy_title), - subtitle = stringResource(R.string.enable_telega_proxy_subtitle), - checked = state.isTelegaProxyEnabled, - iconColor = Color(0xFF0088CC), - position = if (state.isTelegaProxyEnabled && state.telegaProxies.isNotEmpty()) ItemPosition.TOP else ItemPosition.STANDALONE, - onCheckedChange = component::onTelegaProxyToggled + val position = itemPosition(index, ProxyNetworkType.entries.size) + Box { + SettingsTile( + icon = Icons.Rounded.Wifi, + title = stringResource(networkTitleRes(networkType)), + subtitle = stringResource(networkRuleSubtitleRes(rule)), + iconColor = Color(0xFF1E88E5), + position = position, + onClick = { expandedNetworkMenu = networkType }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + networkModeLabelRes(rule.mode) + ) + ) + } ) - AnimatedVisibility( - visible = state.isTelegaProxyEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + StyledDropdownMenu( + expanded = expandedNetworkMenu == networkType, + onDismissRequest = { expandedNetworkMenu = null } ) { - SettingsTile( - icon = Icons.Rounded.Refresh, - title = stringResource(R.string.refresh_list_title), - subtitle = stringResource(R.string.refresh_list_subtitle), - iconColor = Color(0xFF0088CC), - position = ItemPosition.BOTTOM, - onClick = { component.onFetchTelegaProxies() }, - trailingContent = { - if (state.isFetchingExternal) { - LoadingIndicator( - modifier = Modifier.size(20.dp), + ProxyNetworkMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = networkModeIcon(mode), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) + }, + text = { Text(stringResource(networkModeLabelRes(mode))) }, + trailingIcon = { + if (rule.mode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxyNetworkModeChanged(networkType, mode) + expandedNetworkMenu = null } + ) + } + if (state.proxies.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + state.proxies.forEach { proxy -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { + Text( + stringResource( + R.string.proxy_specific_target_format, + proxy.server, + proxy.port + ) + ) + }, + trailingIcon = { + if (rule.mode == ProxyNetworkMode.SPECIFIC_PROXY && rule.specificProxyId == proxy.id) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onSpecificProxyForNetworkSelected( + networkType, + proxy.id + ) + expandedNetworkMenu = null + } + ) } - ) + } } } + } + } + } - AnimatedVisibility( - visible = state.isTelegaProxyEnabled && state.telegaProxies.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + item { + SectionHeader(stringResource(R.string.proxy_list_behavior_header)) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Box { + SettingsTile( + icon = Icons.Rounded.Sort, + title = stringResource(R.string.proxy_sort_mode_title), + subtitle = stringResource(R.string.proxy_sort_mode_subtitle), + iconColor = Color(0xFFF9A825), + position = ItemPosition.TOP, + onClick = { sortMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + sortModeLabelRes( + state.proxySortMode + ) + ) + ) + } + ) + + StyledDropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Spacer(modifier = Modifier.height(8.dp)) - state.telegaProxies.forEachIndexed { index, proxy -> - val position = when { - state.telegaProxies.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == state.telegaProxies.size - 1 -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE + ProxySortMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = sortModeIcon(mode), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(sortModeLabelRes(mode))) }, + trailingIcon = { + if (state.proxySortMode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxySortModeChanged(mode) + sortMenuExpanded = false } + ) + } + } + } - ProxyItem( - proxy = proxy, - position = position, - onClick = { component.onProxyClicked(proxy) }, - onLongClick = { component.onProxyLongClicked(proxy) }, - onRefreshPing = { component.onPingProxy(proxy.id) } + Box { + SettingsTile( + icon = Icons.Rounded.SwapHoriz, + title = stringResource(R.string.proxy_unavailable_fallback_title), + subtitle = stringResource(R.string.proxy_unavailable_fallback_subtitle), + iconColor = Color(0xFF6A1B9A), + position = ItemPosition.MIDDLE, + onClick = { fallbackMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + fallbackLabelRes( + state.proxyUnavailableFallback + ) ) - } + ) + } + ) + + StyledDropdownMenu( + expanded = fallbackMenuExpanded, + onDismissRequest = { fallbackMenuExpanded = false } + ) { + ProxyUnavailableFallback.entries.forEach { fallback -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = fallbackIcon(fallback), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(fallbackLabelRes(fallback))) }, + trailingIcon = { + if (state.proxyUnavailableFallback == fallback) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxyUnavailableFallbackChanged(fallback) + fallbackMenuExpanded = false + } + ) } } } + + SettingsSwitchTile( + icon = Icons.Rounded.VisibilityOff, + title = stringResource(R.string.hide_offline_proxies_title), + subtitle = stringResource(R.string.hide_offline_proxies_subtitle), + checked = state.hideOfflineProxies, + iconColor = Color(0xFF00897B), + position = ItemPosition.BOTTOM, + onCheckedChange = component::onHideOfflineProxiesToggled + ) } } @@ -276,11 +579,11 @@ fun ProxyContent(component: ProxyComponent) { } } - itemsIndexed(state.proxies, key = { _, it -> it.id }) { index, proxy -> + itemsIndexed(state.visibleProxies, key = { _, it -> it.id }) { index, proxy -> val position = when { - state.proxies.size == 1 -> ItemPosition.STANDALONE + state.visibleProxies.size == 1 -> ItemPosition.STANDALONE index == 0 -> ItemPosition.TOP - index == state.proxies.size - 1 -> ItemPosition.BOTTOM + index == state.visibleProxies.size - 1 -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE } @@ -289,15 +592,17 @@ fun ProxyContent(component: ProxyComponent) { ) { ProxyItem( proxy = proxy, + isFavorite = state.favoriteProxyId == proxy.id, position = position, onClick = { component.onProxyClicked(proxy) }, onLongClick = { component.onProxyLongClicked(proxy) }, - onRefreshPing = { component.onPingProxy(proxy.id) } + onRefreshPing = { component.onPingProxy(proxy.id) }, + onOpenMenu = { component.onEditProxyClicked(proxy) } ) } } - if (state.proxies.isEmpty() && (!state.isTelegaProxyEnabled || state.telegaProxies.isEmpty()) && !state.isLoading) { + if (state.visibleProxies.isEmpty() && !state.isLoading) { item { Column( modifier = Modifier @@ -314,7 +619,7 @@ fun ProxyContent(component: ProxyComponent) { ) Spacer(Modifier.height(16.dp)) Text( - stringResource(R.string.no_proxies_label), + stringResource(if (state.proxies.isEmpty()) R.string.no_proxies_label else R.string.no_proxies_match_filter_label), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -326,6 +631,62 @@ fun ProxyContent(component: ProxyComponent) { } } + if (showTopMenu) { + Popup( + onDismissRequest = { showTopMenu = false }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { showTopMenu = false } + ) { + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(MaterialTheme.motionScheme.defaultEffectsSpec()) + scaleIn( + animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + initialScale = 0.8f, + transformOrigin = TransformOrigin(1f, 0f) + ), + exit = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + scaleOut( + animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + targetScale = 0.9f, + transformOrigin = TransformOrigin(1f, 0f) + ), + modifier = Modifier + .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp, end = 16.dp) + ) { + ViewerSettingsDropdown { + MenuOptionRow( + icon = Icons.Rounded.Upload, + title = stringResource(R.string.proxy_export_action), + onClick = { + showTopMenu = false + exportLauncher.launch("monogram_proxies.json") + } + ) + MenuOptionRow( + icon = Icons.Rounded.Download, + title = stringResource(R.string.proxy_import_action), + onClick = { + showTopMenu = false + importLauncher.launch(arrayOf("application/json", "text/plain")) + } + ) + } + } + } + } + } + if (state.isAddingProxy || state.proxyToEdit != null) { ProxyAddEditSheet( proxy = state.proxyToEdit, @@ -333,6 +694,16 @@ fun ProxyContent(component: ProxyComponent) { onTest = component::onTestProxy, testPing = state.testPing, isTesting = state.isTesting, + isFavorite = state.proxyToEdit?.id == state.favoriteProxyId, + onToggleFavorite = { + state.proxyToEdit?.let { component.onToggleFavoriteProxy(it.id) } + }, + onDelete = { + state.proxyToEdit?.let { + component.onRemoveProxy(it.id) + component.onDismissAddEdit() + } + }, onSave = { server, port, type -> if (state.proxyToEdit != null) { component.onEditProxy(state.proxyToEdit!!.id, server, port, type) @@ -405,16 +776,164 @@ fun ProxyContent(component: ProxyComponent) { } ) } + +} + +private fun itemPosition(index: Int, total: Int): ItemPosition { + return when { + total <= 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == total - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } +} + +@Composable +private fun DropdownSelectionTrailing(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.widthIn(max = 180.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun StyledDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + offset = DpOffset(x = 0.dp, y = 8.dp), + shape = RoundedCornerShape(22.dp), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Surface( + modifier = Modifier.widthIn(min = 220.dp, max = 320.dp), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp), + content = content + ) + } + } +} + +private fun networkModeIcon(mode: ProxyNetworkMode): ImageVector = when (mode) { + ProxyNetworkMode.DIRECT -> Icons.Rounded.LinkOff + ProxyNetworkMode.BEST_PROXY -> Icons.Rounded.Bolt + ProxyNetworkMode.LAST_USED -> Icons.Rounded.History + ProxyNetworkMode.SPECIFIC_PROXY -> Icons.Rounded.Tune +} + +private fun sortModeIcon(mode: ProxySortMode): ImageVector = when (mode) { + ProxySortMode.ACTIVE_FIRST -> Icons.Rounded.CheckCircle + ProxySortMode.LOWEST_PING -> Icons.Rounded.Speed + ProxySortMode.SERVER_NAME -> Icons.Rounded.Language + ProxySortMode.PROXY_TYPE -> Icons.Rounded.Tune + ProxySortMode.STATUS -> Icons.Rounded.Info +} + +private fun fallbackIcon(fallback: ProxyUnavailableFallback): ImageVector = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> Icons.Rounded.Bolt + ProxyUnavailableFallback.DIRECT -> Icons.Rounded.LinkOff + ProxyUnavailableFallback.KEEP_CURRENT -> Icons.Rounded.Pause +} + +@StringRes +private fun networkTitleRes(networkType: ProxyNetworkType): Int = when (networkType) { + ProxyNetworkType.WIFI -> R.string.proxy_network_wifi + ProxyNetworkType.MOBILE -> R.string.proxy_network_mobile + ProxyNetworkType.VPN -> R.string.proxy_network_vpn + ProxyNetworkType.OTHER -> R.string.proxy_network_other +} + +@StringRes +private fun networkModeLabelRes(mode: ProxyNetworkMode): Int = when (mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific +} + +@StringRes +private fun networkRuleSubtitleRes(rule: ProxyNetworkRule): Int = when (rule.mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct_subtitle + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best_subtitle + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used_subtitle + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific_subtitle +} + +@StringRes +private fun sortModeLabelRes(mode: ProxySortMode): Int = when (mode) { + ProxySortMode.ACTIVE_FIRST -> R.string.proxy_sort_mode_active_first + ProxySortMode.LOWEST_PING -> R.string.proxy_sort_mode_lowest_ping + ProxySortMode.SERVER_NAME -> R.string.proxy_sort_mode_server_name + ProxySortMode.PROXY_TYPE -> R.string.proxy_sort_mode_proxy_type + ProxySortMode.STATUS -> R.string.proxy_sort_mode_status +} + +@StringRes +private fun fallbackLabelRes(fallback: ProxyUnavailableFallback): Int = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> R.string.proxy_fallback_best_proxy + ProxyUnavailableFallback.DIRECT -> R.string.proxy_fallback_direct + ProxyUnavailableFallback.KEEP_CURRENT -> R.string.proxy_fallback_keep_current +} + +private fun proxyToDeepLink(proxy: ProxyModel): String { + fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + + return when (val type = proxy.type) { + is ProxyTypeModel.Mtproto -> { + "tg://proxy?server=${encode(proxy.server)}&port=${proxy.port}&secret=${encode(type.secret)}" + } + + is ProxyTypeModel.Socks5 -> { + buildString { + append("tg://socks?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + } + + is ProxyTypeModel.Http -> { + buildString { + append("tg://http?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + } + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun ProxyItem( proxy: ProxyModel, + isFavorite: Boolean, position: ItemPosition, onClick: () -> Unit, onLongClick: () -> Unit, - onRefreshPing: () -> Unit + onRefreshPing: () -> Unit, + onOpenMenu: () -> Unit ) { val typeName = when (proxy.type) { is ProxyTypeModel.Mtproto -> "MTProto" @@ -485,19 +1004,34 @@ fun ProxyItem( Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = proxy.server, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = proxy.server, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) + ) + if (isFavorite) { + Spacer(Modifier.width(6.dp)) + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.proxy_action_remove_favorite), + tint = Color(0xFFFFB300), + modifier = Modifier.size(16.dp) + ) + } + } Row(verticalAlignment = Alignment.CenterVertically) { Text( text = typeName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) .padding(horizontal = 4.dp, vertical = 1.dp) ) Spacer(Modifier.width(8.dp)) @@ -516,13 +1050,23 @@ fun ProxyItem( isChecking = proxy.ping == null, ) - IconButton(onClick = onRefreshPing, modifier = Modifier.size(32.dp)) { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.refresh_list_title), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onRefreshPing, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.refresh_list_title), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onOpenMenu, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -616,9 +1160,14 @@ fun ProxyAddEditSheet( onTest: (String, Int, ProxyTypeModel) -> Unit, testPing: Long?, isTesting: Boolean, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, + onDelete: () -> Unit, onSave: (String, Int, ProxyTypeModel) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val clipboard = LocalClipboard.current var server by remember { mutableStateOf(proxy?.server ?: "") } var port by remember { mutableStateOf(proxy?.port?.toString() ?: "") } @@ -687,7 +1236,10 @@ fun ProxyAddEditSheet( Modifier .selectableGroup() .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(50)) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(50) + ) .padding(4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { @@ -774,6 +1326,51 @@ fun ProxyAddEditSheet( } } + if (proxy != null) { + Spacer(modifier = Modifier.height(20.dp)) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + MenuOptionRow( + icon = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + title = stringResource( + if (isFavorite) R.string.proxy_action_remove_favorite else R.string.proxy_action_set_favorite + ), + iconTint = if (isFavorite) Color(0xFFFFB300) else MaterialTheme.colorScheme.primary, + onClick = onToggleFavorite + ) + MenuOptionRow( + icon = Icons.Rounded.ContentCopy, + title = stringResource(R.string.proxy_action_copy_link), + onClick = { + val link = proxyToDeepLink(proxy) + clipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + Toast.makeText( + context, + context.getString(R.string.proxy_link_copied), + Toast.LENGTH_SHORT + ).show() + } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + MenuOptionRow( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.proxy_action_delete), + textColor = MaterialTheme.colorScheme.error, + iconTint = MaterialTheme.colorScheme.error, + onClick = onDelete + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) if (testPing != null || isTesting) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt index 9538e749..02fe9655 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt @@ -25,12 +25,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject +import org.monogram.presentation.R import org.monogram.domain.models.SessionModel import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.spacer.WidthSpacer +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.toShortRelativeDate @Composable @@ -41,6 +45,8 @@ internal fun SessionItem( position: ItemPosition = ItemPosition.STANDALONE, onTerminate: (() -> Unit)? ) { + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Surface( color = MaterialTheme.colorScheme.surfaceContainer, shape = position.toShape(), @@ -78,8 +84,8 @@ internal fun SessionItem( ) Text( - text = if (isPending) "Не подтверждено • ${session.lastActiveDate.toShortRelativeDate()}" - else "${session.location} • ${session.lastActiveDate.toShortRelativeDate()}", + text = if (isPending) "${stringResource(R.string.sessions_unconfirmed)} • ${session.lastActiveDate.toShortRelativeDate(timeFormat)}" + else "${session.location} • ${session.lastActiveDate.toShortRelativeDate(timeFormat)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -122,7 +128,7 @@ private fun PlatformName( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = if (isPending) "Попытка входа" else session.deviceModel, + text = if (isPending) stringResource(R.string.sessions_login_attempt) else session.deviceModel, style = MaterialTheme.typography.titleMedium, fontSize = 18.sp, fontWeight = if (isPending) FontWeight.Bold else FontWeight.Normal, @@ -133,7 +139,7 @@ private fun PlatformName( Spacer(modifier = Modifier.width(6.dp)) Icon( imageVector = Icons.Rounded.Verified, - contentDescription = "Official", + contentDescription = stringResource(R.string.sticker_official), modifier = Modifier.size(16.dp), tint = Color(0xFF31A6FD) ) @@ -151,7 +157,7 @@ private fun ExitButton( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Logout, - contentDescription = "Terminate session", + contentDescription = stringResource(R.string.sessions_terminate_action), tint = MaterialTheme.colorScheme.error ) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt index 0d40ea8d..5ab3bf40 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.filled.MoreVert @@ -47,7 +48,6 @@ import androidx.compose.material.icons.filled.PhoneIphone import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.DataUsage import androidx.compose.material.icons.rounded.Edit @@ -79,6 +79,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -99,10 +100,10 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -115,10 +116,12 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.CollapsingToolbarScaffold import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem import org.monogram.presentation.core.ui.StyledQRCode import org.monogram.presentation.core.ui.UserProfileHeader @@ -128,14 +131,13 @@ import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.ui.saveBitmapToGallery import org.monogram.presentation.core.ui.shareBitmap import org.monogram.presentation.core.util.CountryManager +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid import org.monogram.presentation.features.stickers.ui.view.StickerImage -import org.monogram.presentation.core.ui.SectionHeader import java.util.Locale import kotlin.math.roundToInt -val QrBackgroundColor = Color(0xFFEFF1E6) val QrDarkGreen = Color(0xFF3E4D36) val QrSurfaceShapeColor = Color(0xFFE3E6D8) @@ -143,6 +145,10 @@ val QrSurfaceShapeColor = Color(0xFFE3E6D8) @Composable fun SettingsContent(component: SettingsComponent) { val state by component.state.subscribeAsState() + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) && isTabletInterfaceEnabled val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val haptic = LocalHapticFeedback.current @@ -228,7 +234,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onQrCodeDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { val username = state.currentUser?.username ?: "user" val qrContent = state.qrContent.ifEmpty { "https://t.me/$username" } @@ -311,7 +317,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onSupportDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -367,7 +373,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onMoreOptionsDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -514,7 +520,7 @@ fun SettingsContent(component: SettingsComponent) { ) { IconButton(onClick = component::onBackClicked) { Icon( - Icons.Rounded.ArrowBack, + Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.cd_back), tint = iconTint ) @@ -565,16 +571,13 @@ fun SettingsContent(component: SettingsComponent) { ) } ) { padding -> - var safeTopPadding by remember { mutableStateOf(0.dp) } var safeBottomPadding by remember { mutableStateOf(0.dp) } val language = remember { Locale.getDefault().displayLanguage .replaceFirstChar { it.uppercase() } } - val currentTop = padding.calculateTopPadding() val currentBottom = padding.calculateBottomPadding() - if (currentTop > 0.dp) safeTopPadding = currentTop if (currentBottom > 0.dp) safeBottomPadding = currentBottom CollapsingToolbarScaffold( @@ -601,8 +604,8 @@ fun SettingsContent(component: SettingsComponent) { val sidePadding = lerp(24.dp, 0.dp, progress) val topPadding = 0.dp - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } val headerHeight = maxWidth.coerceAtMost(screenHeight * 0.6f) Box( @@ -640,8 +643,8 @@ fun SettingsContent(component: SettingsComponent) { .fillMaxSize() .semantics { contentDescription = "SettingsList" }, contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, + start = if (isTablet) 12.dp else 16.dp, + end = if (isTablet) 12.dp else 16.dp, top = 0.dp, bottom = safeBottomPadding ), @@ -976,7 +979,7 @@ fun SettingsContent(component: SettingsComponent) { modifier = Modifier .width(menuWidth) .heightIn(max = maxMenuHeightDp) - .clip(RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(16.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null diff --git a/presentation/src/main/res/raw/keep.xml b/presentation/src/main/res/raw/keep.xml new file mode 100644 index 00000000..5739f671 --- /dev/null +++ b/presentation/src/main/res/raw/keep.xml @@ -0,0 +1,4 @@ + + diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 41a3940e..b711dec4 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -12,6 +12,9 @@ Este dispositivo Solicitudes de inicio de sesión Sesiones activas + Intento de inicio de sesión + Sin confirmar + Finalizar sesión Navegar hacia atrás Cerrar escáner @@ -136,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Añadir Cuenta Inicia sesión en otra cuenta Mi Perfil @@ -152,7 +155,7 @@ Ayuda y Comentarios Preguntas frecuentes y soporte Política de Privacidad - MonoGram Dev para Android v%1$s + MonoGram Alpha para Android v%1$s Usuario Desconocido Sin información Mostrar cuentas @@ -247,7 +250,10 @@ Desconocido - %1$d miembros + + %1$d miembro + %1$d miembros + %1$s, %2$d en línea No implementado Búsqueda no implementada @@ -470,7 +476,7 @@ Configuración de Proxy Actualizar Pings - Añadir Proxy + Añadir Conexión Cambio Inteligente Usar automáticamente el proxy más rápido @@ -479,11 +485,47 @@ Deshabilitar Proxy Conectado directamente Cambiar a conexión directa - Telega Proxy - Se ha acusado a Telega Proxy de interceptar tráfico. MTProto protege los datos de los mensajes de la intercepción, pero usa este proxy bajo tu propio riesgo. Más info: t.me/te[...] - - Habilitar Telega Proxy - Auto-obtener y cambiar al mejor + Reglas de red + Elige cómo funciona el proxy para cada tipo de red + Wi-Fi + Datos móviles + VPN + Otras redes + Directo + Mejor proxy + Último usado + Proxy específico + Conectar siempre de forma directa en esta red + Elegir el proxy disponible más rápido en esta red + Reutilizar el último proxy usado en esta red + Usar siempre un proxy elegido en esta red + Específico: %1$s:%2$d + Comportamiento de la lista + Ordenar proxys + Elige cómo se ordena la lista de proxys + Activo primero + Menor latencia + Nombre del servidor + Tipo de proxy + Estado + Si el proxy seleccionado no está disponible + Comportamiento de respaldo para modos específico/último usado + Cambiar al mejor proxy + Cambiar a conexión directa + Mantener estado actual + Ocultar proxys sin conexión + Mostrar solo proxys disponibles o sin comprobar + Exportar proxys + Importar proxys + Lista de proxys exportada + Error al exportar la lista de proxys + Error al leer el archivo de importación + Marcar como favorito + Quitar de favoritos + Copiar como enlace + Editar proxy + Eliminar proxy + Enlace de proxy copiado Actualizar Lista Obtener últimos proxys de la comunidad Tus Proxys @@ -494,6 +536,7 @@ Eliminar Todos los Proxys Esto eliminará todos los proxys configurados de la aplicación. ¿Continuar? Sin proxys añadidos + Ningún proxy coincide con los filtros actuales Eliminar Proxy ¿Estás seguro de que quieres eliminar el proxy %1$s? Nuevo Proxy @@ -503,7 +546,7 @@ Secreto (Hex) Nombre de Usuario (Opcional) Contraseña (Opcional) - Guardar Cambios + Guardar Probar Resultado de la Prueba Eliminar @@ -570,9 +613,9 @@ Espaciado de letras del mensaje Redondeo de burbuja Tamaño del sticker - Restablecer Fondo de Pantalla del Chat Restablecer Fondo de Pantalla + Subir fondo de pantalla Estilo de Emoji Tema Modo Nocturno @@ -611,6 +654,8 @@ Mostrar vistas previas de enlaces en mensajes Arrastrar para Volver Deslizar desde el borde izquierdo para volver + Interfaz para tablet + Usar diseño de pantalla dividida en tablets Dos Líneas Tres Líneas Mostrar Fotos @@ -983,6 +1028,7 @@ Cancelar Copiar Pegar + Pegar imagen Cortar Seleccionar todo Aplicar @@ -1041,6 +1087,9 @@ %1$d palabras ~%1$d min de lectura Borrador guardado automáticamente + Copiar + Cortar + Pegar Insertar Anterior Siguiente @@ -1103,6 +1152,20 @@ Detener Servicio en segundo plano Notificación sobre la aplicación en ejecución en segundo plano + Nuevo mensaje + Yo + %1$d mensajes de %2$d chats + %1$d chats + Chats + Otros + Chats privados + Notificaciones de chats privados + Grupos + Notificaciones de grupos + Canales + Notificaciones de canales + Otros + Otras notificaciones 📷 Foto @@ -1297,6 +1360,12 @@ No se encontraron enlaces No se encontraron GIFs BOT + ESTAFA + FALSO + Usa app no oficial + Esta cuenta está usando un cliente no oficial de Telegram + Verificación del bot + Verificado por un bot de terceros Cerrado ID @@ -1356,6 +1425,10 @@ vs anterior Tipo de estadística desconocido Clase de datos: %1$s + Contacto: %1$s + Lugar: %1$s + Encuesta: %1$s + Mensaje de servicio Atrás @@ -1601,7 +1674,6 @@ Mensaje no soportado - HH:mm %1$02d:%2$02d @@ -1714,6 +1786,17 @@ Solicitud de permiso Permitir Denegar + Biometría + Compartir contacto + ¿Permitir que %1$s acceda a tu número de teléfono? + Permitir mensajes + ¿Permitir que %1$s te envíe mensajes? + Descargar archivo + ¿Descargar este archivo? + ¿Descargar %1$s? + Autenticación biométrica + Autentícate para continuar + ¿Permitir que este bot acceda a tu ubicación? Permisos del bot Términos de servicio Al lanzar esta Mini App, aceptas los Términos de servicio y la Política de privacidad. El bot podrá acceder a tu información de perfil básica. diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index d1c30d94..6cb51871 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -12,6 +12,9 @@ Այս սարքը Մուտքի հարցումներ Ակտիվ սեանսներ + Մուտքի փորձ + Չհաստատված + Ավարտել սեանսը Հետ գնալ Փակել սկաները @@ -129,7 +132,7 @@ Սկսեք նոր զրույց Մինի հավելված - MonoGram Dev + MonoGram Alpha Ավելացնել հաշիվ Մուտք գործել այլ հաշիվ Իմ պրոֆիլը @@ -145,7 +148,7 @@ Օգնություն և հետադարձ կապ Հաճախ տրվող հարցեր և աջակցություն Գաղտնիության քաղաքականություն - MonoGram Dev Android-ի համար v%1$s + MonoGram Alpha Android-ի համար v%1$s Անհայտ օգտատեր Տեղեկություն չկա Ցույց տալ հաշիվները @@ -236,7 +239,10 @@ Դիտումներ Անհայտ - %1$d անդամ + + %1$d անդամ + %1$d անդամ + %1$s, %2$d առցանց Դեռ հասանելի չէ Որոնումը դեռ հասանելի չէ @@ -450,7 +456,7 @@ Պրոքսիի կարգավորումներ Թարմացնել պինգերը - Ավելացնել պրոքսի + Ավելացնել Միացում Խելացի փոխանջատում Ավտոմատ օգտագործել ամենաարագ պրոքսին @@ -459,10 +465,47 @@ Անջատել պրոքսին Միացված է ուղղակիորեն Անցնել ուղիղ միացման - Telega Proxy - Telega Proxy-ն մեղադրվել է թրաֆիկի գաղտնալսման մեջ: MTProto-ն պաշտպանում է հաղորդագրությունները, բայց օգտագործեք այս պրոքսին Ձեր ռիսկով: Մանրամասն՝ t.me/telegaru - Միացնել Telega Proxy-ն - Ավտոմատ ընտրել լավագույնը + Ցանցային կանոններ + Ընտրեք պրոքսիի վարքագիծը ցանցի յուրաքանչյուր տեսակի համար + Wi-Fi + Բջջային տվյալներ + VPN + Այլ ցանցեր + Ուղիղ միացում + Լավագույն պրոքսի + Վերջինը օգտագործված + Կոնկրետ պրոքսի + Այս ցանցում միշտ միանալ ուղիղ + Այս ցանցում ընտրել ամենաարագ հասանելի պրոքսին + Այս ցանցում կրկին օգտագործել վերջին պրոքսին + Այս ցանցում միշտ օգտագործել ընտրված պրոքսին + Կոնկրետ: %1$s:%2$d + Ցուցակի վարքագիծ + Պրոքսիների դասավորում + Ընտրեք պրոքսիների ցուցակի դասավորման կարգը + Ակտիվը՝ առաջինը + Նվազագույն ուշացում + Սերվերի անուն + Պրոքսիի տեսակ + Կարգավիճակ + Եթե ընտրված պրոքսին անհասանելի է + Պահեստային վարքագիծ «կոնկրետ» և «վերջին» ռեժիմների համար + Փոխանցվել լավագույն պրոքսիի + Փոխանցվել ուղիղ միացման + Պահել ընթացիկ վիճակը + Թաքցնել անցանց պրոքսիները + Ցույց տալ միայն հասանելի կամ չստուգված պրոքսիները + Արտահանել պրոքսիները + Ներմուծել պրոքսիները + Պրոքսիների ցուցակն արտահանվել է + Չհաջողվեց արտահանել պրոքսիների ցուցակը + Չհաջողվեց կարդալ ներմուծման ֆայլը + Դարձնել ընտրյալ + Հեռացնել ընտրյալներից + Պատճենել որպես հղում + Խմբագրել պրոքսին + Ջնջել պրոքսին + Պրոքսիի հղումը պատճենված է Թարմացնել ցուցակը Ներբեռնել համայնքի թարմ պրոքսիները Ձեր պրոքսիները @@ -473,6 +516,7 @@ Ջնջել բոլոր պրոքսիները Սա կհեռացնի բոլոր կարգավորված պրոքսիները: Շարունակե՞լ: Պրոքսիներ չկան + Ընթացիկ ֆիլտրերին համապատասխան պրոքսի չկա Ջնջել պրոքսին Իսկապե՞ս ցանկանում եք ջնջել %1$s պրոքսին: Նոր պրոքսի @@ -482,7 +526,7 @@ Secret (Hex) Օգտանուն (ոչ պարտադիր) Գաղտնաբառ (ոչ պարտադիր) - Պահպանել փոփոխությունները + Պահպանել Թեստ Թեստի արդյունքը Ջնջել @@ -541,9 +585,9 @@ Տեքստի չափը Տառերի հեռավորությունը Հաղորդագրության կլորացումը - Վերակայել Չատի պաստառ Վերակայել պաստառը + Վերբեռնել պաստառ Էմոջիների ոճը Թեմա Գիշերային ռեժիմ @@ -581,6 +625,8 @@ Ցուցադրել հղումների բովանդակությունը հաղորդագրություններում Քաշել՝ հետ գնալու համար Սահեցրեք ձախ եզրից՝ հետ գնալու համար + Պլանշետային ինտերֆեյս + Պլանշետներում օգտագործել բաժանված էկրանի դասավորություն Երկտողանի Եռատողանի Ցույց տալ նկարները @@ -922,6 +968,7 @@ Չեղարկել Պատճենել Տեղադրել + Տեղադրել պատկեր Կտրել Ընտրել ամբողջը Կիրառել @@ -945,6 +992,9 @@ %1$d ֆորմատավորման բլոկ Ընտրեք տեքստը՝ ձևավորում կիրառելու համար %1$d/%2$d + Պատճենել + Կտրել + Տեղադրել Թավ Շեղ @@ -998,6 +1048,20 @@ Կանգնեցնել Հետին պլանի ծառայություն Ծանուցում հետին պլանում աշխատող հավելվածի մասին + Նոր հաղորդագրություն + Ես + %1$d հաղորդագրություն %2$d չատից + %1$d չատ + Չատեր + Այլ + Անձնական չատեր + Ծանուցումներ անձնական չատերից + Խմբեր + Ծանուցումներ խմբերից + Ալիքներ + Ծանուցումներ ալիքներից + Այլ + Այլ ծանուցումներ 📷 Նկար 📹 Վիդեո @@ -1174,6 +1238,12 @@ Հղումներ չկան GIF-եր չկան ԲՈՏ + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Փակ է ID @@ -1232,6 +1302,10 @@ նախորդի համեմատ Անհայտ վիճակագրություն Տվյալների դաս՝ %1$s + Կոնտակտ՝ %1$s + Վայր՝ %1$s + Հարցում՝ %1$s + Ծառայողական հաղորդագրություն Հետ Տարբերակներ @@ -1454,7 +1528,6 @@ Վայր Չաջակցվող հաղորդագրություն - HH:mm %1$02d:%2$02d %1$.1fՀ @@ -1556,6 +1629,17 @@ Թույլտվության հարցում Թույլատրել Մերժել + Կենսաչափություն + Կիսվել կոնտակտով + Թույլատրել %1$s-ին հասանելիություն ձեր հեռախոսահամարին՞ + Թույլատրել հաղորդագրությունները + Թույլատրել %1$s-ին ձեզ հաղորդագրություններ ուղարկել՞ + Ներբեռնել ֆայլը + Ներբեռնել այս ֆայլը՞ + Ներբեռնել %1$s-ը՞ + Կենսաչափական նույնականացում + Հաստատեք ինքնությունը՝ շարունակելու համար + Թույլատրել այս բոտին հասանելիություն ձեր տեղադրությանը՞ Բոտի թույլտվությունները Օգտագործման պայմաններ Գործարկելով այս մինի հավելվածը՝ Դուք համաձայնում եք օգտագործման պայմաններին: diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 6cb413bd..07b62519 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -12,6 +12,9 @@ Este dispositivo Solicitações de login Sessões ativas + Tentativa de login + Não confirmado + Encerrar sessão Voltar Fechar scanner @@ -137,7 +140,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Adicionar conta Entrar em outra conta Meu perfil @@ -153,7 +156,7 @@ Ajuda e feedback FAQ e suporte Política de privacidade - MonoGram Dev para Android v%1$s + MonoGram Alpha para Android v%1$s Usuário desconhecido Sem informações Mostrar contas @@ -247,7 +250,10 @@ Desconhecido - %1$d membros + + %1$d membro + %1$d membros + %1$s, %2$d online Não implementado Busca não implementada @@ -470,7 +476,7 @@ Configurações de proxy Atualizar pings - Adicionar proxy + Adicionar Conexão Troca inteligente Use automaticamente o proxy mais rápido @@ -479,11 +485,47 @@ Desativar proxy Conectado diretamente Alternar para conexão direta - Proxy Telega - O Telega Proxy foi acusado de interceptar tráfego. O MTProto protege os dados das mensagens, mas use este proxy por sua conta e risco. Saiba mais: t.me/telegaru - - Ativar Telega Proxy - Buscar automaticamente e alternar para o melhor + Regras de rede + Escolha como o proxy funciona para cada tipo de rede + Wi-Fi + Dados móveis + VPN + Outras redes + Direto + Melhor proxy + Último usado + Proxy específico + Sempre conectar diretamente nesta rede + Selecionar o proxy disponível mais rápido nesta rede + Reutilizar o último proxy usado nesta rede + Sempre usar um proxy escolhido nesta rede + Específico: %1$s:%2$d + Comportamento da lista + Ordenar proxies + Escolha como a lista de proxies é ordenada + Ativo primeiro + Menor latência + Nome do servidor + Tipo de proxy + Status + Se o proxy selecionado estiver indisponível + Comportamento de fallback para os modos específico/último usado + Alternar para o melhor proxy + Alternar para conexão direta + Manter estado atual + Ocultar proxies offline + Mostrar apenas proxies disponíveis ou não verificados + Exportar proxies + Importar proxies + Lista de proxies exportada + Falha ao exportar a lista de proxies + Falha ao ler o arquivo de importação + Definir como favorito + Remover dos favoritos + Copiar como link + Editar proxy + Excluir proxy + Link do proxy copiado Atualizar lista Buscar os proxies comunitários mais recentes Seus proxies @@ -494,6 +536,7 @@ Excluir todos os proxies Isso removerá todos os proxies configurados no app. Deseja continuar? Nenhum proxy adicionado + Nenhum proxy corresponde aos filtros atuais Excluir proxy Tem certeza de que deseja excluir o proxy %1$s? Novo proxy @@ -503,7 +546,7 @@ Segredo (Hex) Nome de usuário (opcional) Senha (opcional) - Salvar alterações + Salvar Testar Resultado do teste Excluir @@ -571,9 +614,9 @@ Espaçamento entre letras Arredondamento das bolhas Tamanho das figurinhas - Redefinir Papel de parede do chat Redefinir papel de parede + Carregar papel de parede Estilo de emoji Tema Modo noturno @@ -612,6 +655,8 @@ Exiba prévias de links nas mensagens Arrastar para voltar Deslize da borda esquerda para retornar + Interface para tablet + Usar layout de tela dividida em tablets Duas linhas Três linhas Mostrar fotos @@ -985,6 +1030,7 @@ Cancelar Copiar Colar + Colar imagem Recortar Selecionar tudo Aplicar @@ -1043,6 +1089,9 @@ %1$d palavras ~%1$d min de leitura Rascunho salvo automaticamente + Copiar + Recortar + Colar IA Editor de IA Traduzir @@ -1131,6 +1180,20 @@ Parar Serviço em segundo plano Notificação sobre o app rodando em segundo plano + Nova mensagem + Eu + %1$d mensagens de %2$d chats + %1$d chats + Chats + Outros + Conversas privadas + Notificações de conversas privadas + Grupos + Notificações de grupos + Canais + Notificações de canais + Outros + Outras notificações 📷 Foto @@ -1325,6 +1388,12 @@ Nenhum link encontrado Nenhum GIF encontrado BOT + GOLPE + FALSO + Usa app não oficial + Esta conta está usando um cliente não oficial do Telegram + Verificação do bot + Verificado por um bot de terceiros Fechado ID @@ -1384,6 +1453,10 @@ vs anterior Tipo de estatística desconhecido Classe de dados: %1$s + Contato: %1$s + Local: %1$s + Enquete: %1$s + Mensagem de serviço Voltar @@ -1629,7 +1702,6 @@ Mensagem não compatível - HH:mm %1$02d:%2$02d @@ -1742,6 +1814,17 @@ Solicitação de permissão Permitir Negar + Biometria + Compartilhar contato + Permitir que %1$s acesse seu número de telefone? + Permitir mensagens + Permitir que %1$s envie mensagens para você? + Baixar arquivo + Baixar este arquivo? + Baixar %1$s? + Autenticação biométrica + Autentique-se para continuar + Permitir que este bot acesse sua localização? Permissões do bot Termos de Serviço Ao iniciar este Mini App, você concorda com os Termos de Serviço e a Política de Privacidade. O bot poderá acessar suas informações básicas de perfil. diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 61f22d30..34907cfc 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -12,6 +12,9 @@ Это устройство Запросы на вход Активные сеансы + Попытка входа + Не подтверждено + Завершить сеанс Назад Закрыть сканер @@ -136,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Добавить аккаунт Войти в другой аккаунт Мой профиль @@ -152,7 +155,7 @@ Помощь FAQ и поддержка Политика конфиденциальности - MonoGram Dev для Android v%1$s + MonoGram Alpha для Android v%1$s Неизвестный пользователь Нет информации Показать аккаунты @@ -245,7 +248,12 @@ Неизвестно - Участники: %1$d + + %1$d участник + %1$d участника + %1$d участников + %1$d участника + %1$s, %2$d в сети Ещё не реализовано Поиск пока не работает @@ -462,7 +470,7 @@ Настройки прокси Обновить задержку - Добавить прокси + Добавить Соединение Умное переключение Автоматически выбирать самый быстрый прокси @@ -471,10 +479,47 @@ Отключить прокси Прямое подключение Переключиться на прямое соединение - Telega Proxy - Telega Proxy обвиняли в перехвате трафика. MTProto защищает данные сообщений от перехвата, но используйте этот прокси на свой риск. Подробнее: t.me/telegaru - Включить Telega Proxy - Автоматическое получение и переключение + Правила сети + Выберите поведение прокси для каждого типа сети + Wi-Fi + Мобильная сеть + VPN + Другие сети + Без прокси + Лучший прокси + Последний использованный + Конкретный прокси + Всегда подключаться напрямую в этой сети + Выбирать самый быстрый доступный прокси в этой сети + Использовать последний прокси для этой сети + Всегда использовать выбранный прокси в этой сети + Выбрать: %1$s:%2$d + Поведение списка + Сортировка прокси + Выберите порядок отображения списка + Сначала активный + Минимальная задержка + Имя сервера + Тип прокси + Статус + Если выбранный прокси недоступен + Поведение для режимов «конкретный» и «последний» + Переключаться на лучший прокси + Переключаться на прямое соединение + Оставлять как есть + Скрывать офлайн-прокси + Показывать только доступные или непроверенные прокси + Экспорт прокси + Импорт прокси + Список прокси экспортирован + Не удалось экспортировать список прокси + Не удалось прочитать файл импорта + Сделать избранным + Убрать из избранного + Скопировать ссылку + Изменить прокси + Удалить прокси + Ссылка прокси скопирована Обновить список Получить актуальные прокси от сообщества Ваши прокси @@ -485,6 +530,7 @@ Удалить все прокси Это действие удалит все настроенные прокси из приложения. Продолжить? Нет добавленных прокси + Нет прокси, подходящих под текущие фильтры Удалить прокси Вы уверены, что хотите удалить прокси %1$s? Новый прокси @@ -494,7 +540,7 @@ Секрет (Hex) Имя пользователя (опционально) Пароль (опционально) - Сохранить изменения + Сохранить Проверить Результат проверки Удалить @@ -553,13 +599,13 @@ Настройки чатов Внешний вид - Размер текста сообщений - Межбуквенный интервал сообщений + Размер текста + Межбуквенный интервал Скругление блоков Размер стикеров - Сбросить Обои чата Сбросить обои + Загрузить обои Стиль эмодзи Тема Ночной режим @@ -597,6 +643,8 @@ Отображать превью для ссылок в сообщениях Свайп для возврата Возврат назад свайпом от левого края + Планшетный интерфейс + Использовать разделенный интерфейс на планшетах Две строки Три строки Показывать фотографии @@ -963,6 +1011,7 @@ Отмена Копировать Вставить + Вставить изображение Вырезать Выделить все Применить @@ -1063,6 +1112,20 @@ Остановить Фоновая работа Уведомление о работе приложения в фоне + Новое сообщение + Я + %1$d сообщений из %2$d чатов + %1$d чатов + Чаты + Прочее + Личные чаты + Уведомления из личных переписок + Группы + Уведомления из групп + Каналы + Уведомления из каналов + Другое + Прочие уведомления 📷 Фото @@ -1259,6 +1322,12 @@ Ссылки не найдены GIF не найдены БОТ + СКАМ + ФЕЙК + Использует неофициальное приложение + Этот аккаунт использует неофициальный клиент Telegram + Проверка бота + Подтверждено сторонним ботом Закрыто ID @@ -1318,6 +1387,10 @@ по сравнению с прошлым пер. Неизвестный тип статистики Класс данных: %1$s + Контакт: %1$s + Место: %1$s + Опрос: %1$s + Служебное сообщение Назад @@ -1563,7 +1636,6 @@ Неподдерживаемое сообщение - HH:mm %1$02d:%2$02d @@ -1678,6 +1750,17 @@ Запрос разрешения Разрешить Отклонить + Биометрия + Поделиться контактом + Разрешить %1$s доступ к Вашему номеру телефона? + Разрешить сообщения + Разрешить %1$s отправлять Вам сообщения? + Скачать файл + Скачать этот файл? + Скачать %1$s? + Биометрическая аутентификация + Подтвердите личность, чтобы продолжить + Разрешить этому боту доступ к Вашему местоположению? Разрешения бота Условия использования Запуская это мини-приложение, Вы принимаете Условия использования и Политику конфиденциальности. Бот получит доступ к данным Вашего профиля. @@ -1893,6 +1976,9 @@ %1$d слов ≈%1$d мин чтения Черновик сохранён + Копировать + Вырезать + Вставить AI ИИ-редактор Перевод diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 092a7498..6a4edc6a 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -12,6 +12,9 @@ Toto zariadenie Žiadosti o prihlásenie Aktívne relácie + Pokus o prihlásenie + Nepotvrdené + Ukončiť reláciu Späť Zavrieť skener @@ -139,7 +142,7 @@ Mini aplikácia - MonoGram Dev + MonoGram Alpha Pridať účet Prihlásiť sa do iného účtu Môj profil @@ -155,7 +158,7 @@ Pomoc a spätná väzba FAQ a podpora Zásady ochrany súkromia - MonoGram Dev pre Android v%1$s + MonoGram Alpha pre Android v%1$s Neznámy používateľ Žiadne informácie Zobraziť účty @@ -255,7 +258,11 @@ Neznáme - %1$d členov + + %1$d člen + %1$d členovia + %1$d členov + %1$s, %2$d online Nie je implementované Vyhľadávanie nie je implementované @@ -487,7 +494,7 @@ Nastavenia proxy Obnoviť pingy - Pridať proxy + Pridať Pripojenie Inteligentné prepínanie Automaticky použiť najrýchlejšie proxy @@ -496,11 +503,47 @@ Vypnúť proxy Pripojené priamo Prepnúť na priame pripojenie - Telega Proxy - Proxy od komunity. Používajte na vlastné riziko. Viac info: t.me/telegaru - - Povoliť Telega Proxy - Automaticky načítať a prepnúť na najlepšie + Pravidlá siete + Vyberte správanie proxy pre každý typ siete + Wi-Fi + Mobilné dáta + VPN + Ostatné siete + Priame pripojenie + Najlepšie proxy + Naposledy použité + Konkrétne proxy + V tejto sieti sa vždy pripájať priamo + V tejto sieti vybrať najrýchlejšie dostupné proxy + V tejto sieti použiť naposledy použité proxy + V tejto sieti vždy použiť vybrané proxy + Konkrétne: %1$s:%2$d + Správanie zoznamu + Zoradenie proxy + Vyberte poradie zobrazenia zoznamu proxy + Aktívne ako prvé + Najnižší ping + Názov servera + Typ proxy + Stav + Ak je vybrané proxy nedostupné + Náhradné správanie pre režimy konkrétne/naposledy použité + Prepnúť na najlepšie proxy + Prepnúť na priame pripojenie + Ponechať aktuálny stav + Skryť offline proxy + Zobraziť iba dostupné alebo neoverené proxy + Exportovať proxy + Importovať proxy + Zoznam proxy bol exportovaný + Export zoznamu proxy zlyhal + Nepodarilo sa načítať importovaný súbor + Nastaviť ako obľúbené + Odstrániť z obľúbených + Kopírovať ako odkaz + Upraviť proxy + Odstrániť proxy + Odkaz na proxy bol skopírovaný Obnoviť zoznam Načítať najnovšie proxy od komunity Vaše proxy @@ -511,6 +554,7 @@ Odstrániť všetky proxy Táto akcia odstráni všetky nakonfigurované proxy z aplikácie. Pokračovať? Žiadne proxy nie sú pridané + Žiadne proxy nezodpovedajú aktuálnym filtrom Odstrániť proxy Naozaj chcete odstrániť proxy %1$s? Nové proxy @@ -520,7 +564,7 @@ Tajný kľúč (Hex) Používateľské meno (voliteľné) Heslo (voliteľné) - Uložiť zmeny + Uložiť Otestovať Výsledok testu Odstrániť @@ -587,9 +631,9 @@ Veľkosť textu správy Zaoblenie bublín Veľkosť nálepiek - Obnoviť Tapeta chatu Obnoviť tapetu + Nahrať tapetu Štýl emoji Téma Nočný režim @@ -628,6 +672,8 @@ Zobrazovať náhľady odkazov v správach Potiahnutím späť Potiahnutím z ľavého okraja sa vrátiť späť + Tabletové rozhranie + Použiť rozloženie rozdelenej obrazovky na tabletoch Dvojriadkové Trojriadkové Zobraziť fotografie @@ -1027,6 +1073,7 @@ Zrušiť Kopírovať Vložiť + Vložiť obrázok Vystrihnúť Vybrať všetko Použiť @@ -1126,6 +1173,20 @@ Zastaviť Služba na pozadí Oznámenie o behu aplikácie na pozadí + Nová správa + Ja + %1$d správ z %2$d chatov + %1$d chatov + Chaty + Ostatné + Súkromné chaty + Upozornenia zo súkromných chatov + Skupiny + Upozornenia zo skupín + Kanály + Upozornenia z kanálov + Ostatné + Ostatné upozornenia Fotografia @@ -1323,6 +1384,12 @@ Žiadne odkazy sa nenašli Žiadne GIFy sa nenašli BOT + SCAM + FAKE + Používa neoficiálnu aplikáciu + Tento účet používa neoficiálneho klienta Telegramu + Overenie bota + Overené botom tretej strany Zatvorené ID @@ -1382,6 +1449,10 @@ oproti predchádzajúcemu Neznámy typ štatistík Trieda dát: %1$s + Kontakt: %1$s + Miesto: %1$s + Anketa: %1$s + Servisná správa Späť @@ -1627,7 +1698,6 @@ Nepodporovaná správa - HH:mm %1$02d:%2$02d @@ -1760,6 +1830,17 @@ Žiadosť o oprávnenie Povoliť Odmietnuť + Biometria + Zdieľať kontakt + Povoliť %1$s prístup k vášmu telefónnemu číslu? + Povoliť správy + Povoliť %1$s posielať vám správy? + Stiahnuť súbor + Stiahnuť tento súbor? + Stiahnuť %1$s? + Biometrické overenie + Overte sa, aby ste mohli pokračovať + Povoliť tomuto botovi prístup k vašej polohe? Oprávnenia bota Podmienky služby Spustením tejto mini aplikácie súhlasíte s Podmienkami služby a Zásadami ochrany @@ -2025,6 +2106,9 @@ %1$d slov ≈%1$d min čítania Koncept uložený + Kopírovať + Vystrihnúť + Prilepiť AI AI editor Preklad diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 612da2f9..ab87273f 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -12,6 +12,9 @@ Цей пристрій Запити на вхід Активні сеанси + Спроба входу + Не підтверджено + Завершити сеанс Назад Закрити сканер @@ -136,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Додати акаунт Увійти в інший акаунт Мій профіль @@ -152,7 +155,7 @@ Допомога FAQ та підтримка Політика конфіденційності - MonoGram Dev для Android v%1$s + MonoGram Alpha для Android v%1$s Невідомий користувач Немає інформації Показати акаунти @@ -245,7 +248,12 @@ Невідомо - Учасники: %1$d + + %1$d учасник + %1$d учасники + %1$d учасників + %1$d учасника + %1$s, %2$d в мережі Ще не реалізовано Пошук поки не працює @@ -462,7 +470,7 @@ Налаштування проксі Оновити затримку - Додати проксі + Додати Підключення Розумне перемикання Автоматично вибирати найшвидший проксі @@ -471,10 +479,47 @@ Вимкнути проксі Пряме підключення Перемкнутися на пряме з\'єднання - Telega Proxy - Telega Proxy звинувачували у перехопленні трафіку. MTProto захищає дані повідомлень від перехоплення, але використовуйте цей проксі на власний ризик. Докладніше: t.me/telegaru - Увімкнути Telega Proxy - Автоматичне отримання та перемикання + Правила мережі + Оберіть поведінку проксі для кожного типу мережі + Wi-Fi + Мобільна мережа + VPN + Інші мережі + Без проксі + Найкращий проксі + Останній використаний + Конкретний проксі + Завжди підключатися напряму в цій мережі + Обирати найшвидший доступний проксі в цій мережі + Повторно використовувати останній проксі для цієї мережі + Завжди використовувати вибраний проксі в цій мережі + Конкретний: %1$s:%2$d + Поведінка списку + Сортування проксі + Оберіть порядок відображення списку проксі + Активний спочатку + Найменша затримка + Назва сервера + Тип проксі + Статус + Якщо вибраний проксі недоступний + Поведінка для режимів «конкретний» та «останній» + Перемикатися на найкращий проксі + Перемикатися на пряме з\'єднання + Залишати поточний стан + Приховувати офлайн-проксі + Показувати лише доступні або неперевірені проксі + Експорт проксі + Імпорт проксі + Список проксі експортовано + Не вдалося експортувати список проксі + Не вдалося прочитати файл імпорту + Додати в обране + Прибрати з обраного + Скопіювати як посилання + Змінити проксі + Видалити проксі + Посилання на проксі скопійовано Оновити список Отримати актуальні проксі від спільноти Ваші проксі @@ -485,6 +530,7 @@ Видалити всі проксі Ця дія видалить усі налаштовані проксі з застосунку. Продовжити? Немає доданих проксі + Немає проксі, що відповідають поточним фільтрам Видалити проксі Ви впевнені, що хочете видалити проксі %1$s? Новий проксі @@ -494,7 +540,7 @@ Секрет (Hex) Ім\'я користувача (опціонально) Пароль (опціонально) - Зберегти зміни + Зберегти Перевірити Результат перевірки Видалити @@ -557,9 +603,9 @@ Міжлітерний інтервал повідомлень Скруглення блоків Розмір стікерів - Скинути Шпалери чату Скинути шпалери + Завантажити шпалери Стиль емодзі Тема Нічний режим @@ -597,6 +643,8 @@ Відображати прев\'ю для посилань у повідомленнях Свайп для повернення Повернення назад свайпом від лівого краю + Планшетний інтерфейс + Використовувати розділений інтерфейс на планшетах Дві рядки Три рядки Показувати фотографії @@ -963,6 +1011,7 @@ Скасувати Копіювати Вставити + Вставити зображення Вирізати Виділити все Застосувати @@ -1063,6 +1112,20 @@ Зупинити Фонова робота Сповіщення про роботу програми у фоновому режимі + Нове повідомлення + Я + %1$d повідомлень із %2$d чатів + %1$d чатів + Чати + Інше + Приватні чати + Сповіщення з приватних чатів + Групи + Сповіщення з груп + Канали + Сповіщення з каналів + Інше + Інші сповіщення 📷 Фото @@ -1259,6 +1322,12 @@ Посилань не знайдено GIF не знайдено БОТ + ШАХРАЙСТВО + ФЕЙК + Використовує неофіційний застосунок + Цей акаунт використовує неофіційний клієнт Telegram + Перевірка бота + Підтверджено стороннім ботом Зачинено ID @@ -1318,6 +1387,10 @@ порівняно з минулим пер. Невідомий тип статистики Клас даних: %1$s + Контакт: %1$s + Місце: %1$s + Опитування: %1$s + Службове повідомлення Назад @@ -1563,7 +1636,6 @@ Непідтримуване повідомлення - HH:mm %1$02d:%2$02d @@ -1678,6 +1750,17 @@ Запит на дозвіл Дозволити Відхилити + Біометрія + Поділитися контактом + Дозволити %1$s доступ до вашого номера телефону? + Дозволити повідомлення + Дозволити %1$s надсилати вам повідомлення? + Завантажити файл + Завантажити цей файл? + Завантажити %1$s? + Біометрична автентифікація + Підтвердьте особу, щоб продовжити + Дозволити цьому боту доступ до вашого місцезнаходження? Дозволи бота Умови використання Запускаючи цей мінідодаток, ви погоджуєтеся з Умовами надання послуг та Політикою конфіденційності. Бот отримає доступ до основної інформації вашого профілю. @@ -1893,6 +1976,9 @@ %1$d слів ≈%1$d хв читання Чернетку збережено + Копіювати + Вирізати + Вставити AI AI-редактор Переклад diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 61a1e6da..4a11de8a 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -12,6 +12,9 @@ 此设备 登录请求 活跃会话 + 登录尝试 + 未确认 + 终止会话 返回 关闭扫描器 @@ -136,7 +139,7 @@ 小程序 - MonoGram Dev + MonoGram Alpha 添加账号 登录另一个账号 我的个人资料 @@ -152,7 +155,7 @@ 帮助 常见问题与支持 隐私政策 - MonoGram Dev for Android v%1$s + MonoGram Alpha for Android v%1$s 未知用户 暂无信息 显示账号 @@ -245,7 +248,9 @@ 未知 - %1$d 名成员 + + %1$d 名成员 + %1$s,%2$d 人在线 未实现 搜索尚未实现 @@ -462,7 +467,7 @@ 代理设置 刷新延迟 - 添加代理 + 添加 连接 智能切换 自动使用速度最快的代理 @@ -471,10 +476,47 @@ 禁用代理 直接连接 切换到直接连接 - Telega Proxy - Telega Proxy 曾被曝光存在流量拦截风险。MTProto 可保护消息数据不被中间人拦截,但仍请自行评估风险后使用。更多信息: t.me/telegaru - 启用 Telega Proxy - 自动获取并切换到最佳代理 + 网络规则 + 为每种网络类型选择代理行为 + Wi-Fi + 移动数据 + VPN + 其他网络 + 直连 + 最佳代理 + 上次使用 + 指定代理 + 在该网络下始终直连 + 在该网络下自动选择最快可用代理 + 在该网络下复用上次使用的代理 + 在该网络下始终使用指定代理 + 指定: %1$s:%2$d + 列表行为 + 代理排序 + 选择代理列表的排序方式 + 活动优先 + 最低延迟 + 服务器名称 + 代理类型 + 状态 + 当所选代理不可用时 + 指定/上次使用模式下的后备行为 + 切换到最佳代理 + 切换为直连 + 保持当前状态 + 隐藏离线代理 + 仅显示可用或未检测的代理 + 导出代理 + 导入代理 + 代理列表已导出 + 导出代理列表失败 + 读取导入文件失败 + 设为收藏 + 取消收藏 + 复制为链接 + 编辑代理 + 删除代理 + 代理链接已复制 刷新列表 获取最新的社区代理 您的代理 @@ -485,6 +527,7 @@ 删除所有代理 此操作将从应用中删除所有已配置的代理。是否继续? 未添加代理 + 没有代理符合当前筛选条件 删除代理 您确定要删除代理 %1$s 吗? 新代理 @@ -494,7 +537,7 @@ 密钥 (Hex) 用户名(选填) 密码(选填) - 保存修改 + 保存 测试 测试结果 删除 @@ -557,9 +600,9 @@ 字元間距 气泡圆角 贴纸大小 - 重置 会话壁纸 重置壁纸 + 上传壁纸 表情风格 主题 夜间模式 @@ -597,6 +640,8 @@ 在消息中显示链接预览 拖拽返回 从左边缘滑动返回 + 平板界面 + 在平板上使用分屏布局 两行显示 三行显示 显示头像 @@ -956,6 +1001,7 @@ 取消 复制 粘贴 + 粘贴图片 剪切 全选 应用 @@ -1053,6 +1099,20 @@ 停止 后台服务 关于应用在后台运行的通知 + 新消息 + + 来自 %2$d 个聊天的 %1$d 条消息 + %1$d 个聊天 + 聊天 + 其他 + 私聊 + 来自私聊的通知 + 群组 + 来自群组的通知 + 频道 + 来自频道的通知 + 其他 + 其他通知 📷 照片 @@ -1246,6 +1306,12 @@ 未找到链接 未找到 GIF 机器人 + 诈骗 + 冒充 + 使用非官方应用 + 该账号正在使用非官方 Telegram 客户端 + 机器人验证 + 由第三方机器人验证 已关闭 ID @@ -1305,6 +1371,10 @@ 与前期相比 未知统计类型 数据类: %1$s + 联系人:%1$s + 地点:%1$s + 投票:%1$s + 服务消息 返回 @@ -1550,7 +1620,6 @@ 不支持的消息 - HH:mm %1$02d:%2$02d @@ -1662,6 +1731,17 @@ 权限请求 允许 拒绝 + 生物识别 + 分享联系人 + 允许 %1$s 访问你的电话号码吗? + 允许消息 + 允许 %1$s 向你发送消息吗? + 下载文件 + 下载此文件吗? + 下载 %1$s 吗? + 生物识别验证 + 验证身份以继续 + 允许此机器人访问你的位置吗? 机器人权限 服务条款 启动此小程序即表示您同意其服务条款和隐私政策。该机器人将能够访问您的基本个人资料信息。 @@ -1875,6 +1955,9 @@ %1$d 个词 约 %1$d 分钟阅读 草稿已自动保存 + 复制 + 剪切 + 粘贴 AI AI 编辑器 翻译 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index cf71caef..d300c61d 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -12,6 +12,9 @@ This device Login requests Active sessions + Login attempt + Unconfirmed + Terminate session Navigate back Close scanner @@ -137,7 +140,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Add Account Login to another account My Profile @@ -153,7 +156,7 @@ Help & Feedback FAQ and support Privacy Policy - MonoGram Dev for Android v%1$s + MonoGram Alpha for Android v%1$s Unknown User No info Show accounts @@ -247,7 +250,10 @@ Unknown - %1$d members + + %1$d member + %1$d members + %1$s, %2$d online Not implemented Search not implemented @@ -476,7 +482,7 @@ Proxy Settings Refresh Pings - Add Proxy + Add Connection Smart Switching Automatically use the fastest proxy @@ -485,11 +491,47 @@ Disable Proxy Connected directly Switch to direct connection - Telega Proxy - Telega Proxy has been accused of intercepting traffic. MTProto protects message data from interception, but use this proxy at your own risk. More info: t.me/telegaru - - Enable Telega Proxy - Auto-fetch and switch to best + Network Rules + Choose how proxy works for each network type + Wi-Fi + Mobile data + VPN + Other networks + Direct + Best proxy + Last used + Specific proxy + Always connect directly on this network + Pick the fastest available proxy on this network + Reuse the last proxy used on this network + Always use a chosen proxy on this network + Specific: %1$s:%2$d + List Behavior + Sort proxies + Choose how your proxy list is ordered + Active first + Lowest ping + Server name + Proxy type + Status + If selected proxy is unavailable + Fallback behavior for specific/last-used modes + Switch to best proxy + Switch to direct + Keep current state + Hide offline proxies + Show only proxies that are available or unchecked + Export proxies + Import proxies + Proxy list exported + Failed to export proxy list + Failed to read import file + Set as favorite + Remove from favorites + Copy as link + Edit proxy + Delete proxy + Proxy link copied Refresh List Fetch latest community proxies Your Proxies @@ -500,6 +542,7 @@ Delete All Proxies This will remove all configured proxies from the app. Continue? No proxies added + No proxies match current filters Delete Proxy Are you sure you want to delete the proxy %1$s? New Proxy @@ -509,7 +552,7 @@ Secret (Hex) Username (Optional) Password (Optional) - Save Changes + Save Test Test Result Delete @@ -577,9 +620,9 @@ Message letter spacing Bubble rounding Sticker size - Reset Chat Wallpaper Reset Wallpaper + Upload Wallpaper Emoji Style Theme Night Mode @@ -618,6 +661,8 @@ Display previews for links in messages Drag to Back Swipe from left edge to go back + Tablet Interface + Use split-screen layout on tablets Two-line Three-line Show Photos @@ -998,6 +1043,7 @@ Cancel Copy Paste + Paste image Cut Select all Apply @@ -1056,6 +1102,9 @@ %1$d words ~%1$d min read Draft auto-saved + Copy + Cut + Paste AI AI editor Translate @@ -1144,6 +1193,20 @@ Stop Background Service Notification about app running in background + New message + Me + %1$d messages from %2$d chats + %1$d chats + Chats + Other + Private chats + Notifications from private conversations + Groups + Notifications from groups + Channels + Notifications from channels + Other + Other notifications 📷 Photo @@ -1338,6 +1401,12 @@ No links found No GIFs found BOT + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Closed ID @@ -1397,6 +1466,10 @@ vs previous Unknown Statistics Type Data class: %1$s + Contact: %1$s + Venue: %1$s + Poll: %1$s + Service message Back @@ -1642,7 +1715,6 @@ Unsupported message - HH:mm %1$02d:%2$02d @@ -1755,6 +1827,17 @@ Permission Request Allow Deny + Biometry + Share Contact + Allow %1$s to access your phone number? + Allow Messages + Allow %1$s to send you messages? + Download File + Download this file? + Download %1$s? + Biometric Authentication + Authenticate to continue + Allow this bot to access your location? Bot Permissions Terms of Service By launching this Mini App, you agree to the Terms of Service and Privacy Policy. The bot will be able to access your basic profile information. diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt index 0a3b8cb6..8126848f 100644 --- a/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt @@ -176,5 +176,9 @@ class CountryManagerTest { @Test fun `Y-land number mask`() = assertEquals("42777", CountryManager.maskPhoneNumber("42777")) + + @Test + fun `getExampleNumber returns valid example`() = + assertEquals("+7 000 000-00-00", CountryManager.getExampleNumber("RU")) } diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt index 732c6a76..eb55cafd 100644 --- a/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt @@ -9,15 +9,17 @@ import java.util.Locale /** * Test cases for date utils **/ -class DateUtilsTest { +class DateUtils24HourTest { private val ruLocale = Locale.forLanguageTag("ru") private val dateFormatter = SimpleDateFormat("dd.MM.yyyy HH:mm", ruLocale) private val mockToday: Date = dateFormatter.parse("20.03.2024 12:00")!! + private val time24HourFormat = Fake24HourDateFormatManagerImpl().getHourMinuteFormat() @Test fun `When date are today, then returns only hours and minutes`() { + val targetDate = dateFormatter.parse("20.03.2024 15:20")!! - val result = targetDate.toShortRelativeDate(locale = ruLocale, now = mockToday) + val result = targetDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday) assertEquals("15:20", result) } @@ -26,38 +28,91 @@ class DateUtilsTest { fun `When date are yesterday and within 1 week, then returns day of week`() { // Yesterday val yesterday = dateFormatter.parse("19.03.2024 10:15")!! - assertEquals("вт", yesterday.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("вт", yesterday.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // Six days ago val sixDaysAgo = dateFormatter.parse("14.03.2024 09:00")!! - assertEquals("чт", sixDaysAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("чт", sixDaysAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `When date are older than 1 week but within 1 year, then return day and month`() { // 13.03.2024 - ровно 7 дней назад val sevenDaysAgo = dateFormatter.parse("13.03.2024 14:00")!! - assertEquals("13 мар", sevenDaysAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("13 мар", sevenDaysAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // Прошлый месяц (меньше 365 дней назад) val monthsAgo = dateFormatter.parse("25.10.2023 18:30")!! - assertEquals("25 окт", monthsAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("25 окт", monthsAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `When date are older than 1 year, then return full date`() { // Past year val olderThanYear = dateFormatter.parse("10.03.2023 11:11")!! - assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // A long time ago... val veryOldDate = dateFormatter.parse("01.01.2020 00:00")!! - assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `Then future date is older than 1 year, then return full date`() { val futureDate = dateFormatter.parse("16.03.2029 10:00")!! - assertEquals("16.03.2029", futureDate.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("16.03.2029", futureDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) + } +} + +class DateUtils12HourTest { + private val enLocale = Locale.forLanguageTag("en") + private val dateFormatter = SimpleDateFormat("dd.MM.yyyy h:mm a", enLocale) + private val mockToday: Date = dateFormatter.parse("20.03.2024 12:00 PM")!! + private val time12HourFormat = Fake12HourDateFormatManagerImpl().getHourMinuteFormat() + + @Test + fun `When date are today, then returns only hours and minutes`() { + + val targetDate = dateFormatter.parse("20.03.2024 3:20 PM")!! + val result = targetDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday) + + assertEquals("3:20 PM", result) + } + + @Test + fun `When date are yesterday and within 1 week, then returns day of week`() { + // Yesterday + val yesterday = dateFormatter.parse("19.03.2024 10:15 AM")!! + assertEquals("tue", yesterday.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + // Six days ago + val sixDaysAgo = dateFormatter.parse("14.03.2024 9:00 AM")!! + assertEquals("thu", sixDaysAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `When date are older than 1 week but within 1 year, then return day and month`() { + val sevenDaysAgo = dateFormatter.parse("13.03.2024 2:00 PM")!! + assertEquals("13 mar", sevenDaysAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + val monthsAgo = dateFormatter.parse("25.10.2023 6:30 PM")!! + assertEquals("25 oct", monthsAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `When date are older than 1 year, then return full date`() { + // Past year + val olderThanYear = dateFormatter.parse("10.03.2023 11:11 AM")!! + assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + // A long time ago... + val veryOldDate = dateFormatter.parse("01.01.2020 12:00 AM")!! + assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `Then future date is older than 1 year, then return full date`() { + val futureDate = dateFormatter.parse("16.03.2029 10:00 AM")!! + assertEquals("16.03.2029", futureDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) } -} \ No newline at end of file +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt new file mode 100644 index 00000000..04df4f1c --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt @@ -0,0 +1,29 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransformControlsTest { + @Test + fun `rotateClockwiseAnimationTarget always moves clockwise to next quarter turn`() { + assertEquals(90f, rotateClockwiseAnimationTarget(10f), 0.001f) + assertEquals(90f, rotateClockwiseAnimationTarget(-10f), 0.001f) + assertEquals(270f, rotateClockwiseAnimationTarget(179f), 0.001f) + assertEquals(450f, rotateClockwiseAnimationTarget(350f), 0.001f) + } + + @Test + fun `rotateClockwiseToNextRightAngle advances exact quarter turn`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(0f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(90f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(180f), 0.001f) + } + + @Test + fun `rotateClockwiseToNextRightAngle snaps tilted image before rotating`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(10f), 0.001f) + assertEquals(90f, rotateClockwiseToNextRightAngle(-10f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(100f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(179f), 0.001f) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt new file mode 100644 index 00000000..1aa60c9f --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -0,0 +1,149 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CropGeometryTest { + @Test + fun `fitContentInBounds restores crop coverage for rotated image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val cropRect = Rect(left = 15f, top = 15f, right = 85f, bottom = 85f) + val initialScale = 1.2f + val initialOffset = Offset(50f, -40f) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center + ) + ) + + val (newScale, newOffset) = fitContentInBounds( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center, + minScale = 0.5f, + maxScale = 10f + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = newScale, + rotationDegrees = 35f, + offset = newOffset, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `isCropRectCoveredByImage rejects crop in rotated bounding box corner`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val cropRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = 65f, + bottom = 65f + ) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `constrainCropRectToImage pulls invalid rotated crop back inside image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val currentCropRect = Rect(left = 35f, top = 35f, right = 65f, bottom = 65f) + val candidateRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = currentCropRect.right, + bottom = currentCropRect.bottom + ) + + val constrained = constrainCropRectToImage( + currentCropRect = currentCropRect, + candidateRect = candidateRect, + visibleBounds = visibleBounds, + minCropSizePx = 16f, + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + + assertTrue(constrained.left > candidateRect.left + EPSILON) + assertTrue(constrained.top > candidateRect.top + EPSILON) + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = constrained, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `quarter turn transformed bounds stay covered by image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 200f, bottom = 100f) + val pivot = baseBounds.center + val cropRect = calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + ) + } + + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { + return calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + } + + private companion object { + const val EPSILON = 0.001f + } +}