15 Commits

Author SHA1 Message Date
melod1n 36504fe4b9 refactor(logging): introduce custom FastLogger and replace direct Android logging 2026-05-30 18:31:00 +03:00
melod1n fc3b3cfcb3 refactor(longpoll): route parsed long poll events through dedicated handler and persist message/conversation state updates 2026-05-30 17:17:32 +03:00
melod1n f11b8dc6f4 refactor: consolidate convos state and intent handling 2026-05-30 15:39:43 +03:00
melod1n 167f980f29 refactor StateFlow exposure in ConvosViewModel 2026-05-30 12:01:06 +03:00
melod1n d428af4ac4 refactor: simplify Profile feature state management and update ViewModel 2026-05-30 11:46:14 +03:00
melod1n 63bae014c5 feat: replace settings icon button with segmented buttons in ProfileScreen 2026-05-30 11:43:48 +03:00
melod1n ad54477d11 feat: add segmented buttons to friends screen 2026-05-30 11:39:10 +03:00
melod1n c8bd485724 feat: add custom segmented buttons and refactor conversation screen actions 2026-05-30 11:33:32 +03:00
melod1n 26a0630393 Replace ACRA with custom crash dialog
* Add uncaught exception handler that saves stacktraces to local crash log files
* Show a Compose crash dialog with stacktrace toggle and share action
* Register crash handler activity in a separate process with dialog theme
* Remove ACRA dependencies and configuration
* Add crash dialog strings and ignore `.hotswan/`
2026-05-23 21:59:12 +03:00
melod1n 3d153df79c Update README.md 2026-05-23 08:58:52 +03:00
melod1n 9061a39407 update Gitea workflow 2026-05-23 08:58:52 +03:00
melod1n d8c8820b32 add Gitea workflow 2026-05-22 20:45:42 +03:00
melod1n abfe25d051 fix signing 2026-05-22 17:46:56 +03:00
melod1n 574b230b26 feat: add channel message support and refactor UI components
- Implement `VkChannelMessage` domain and data models for channel message attachments
- Add `CHANNEL_MESSAGE` to `AttachmentType` and map it to relevant UI resources and strings
- Refactor `Sticker` and `Gift` composables to accept URL strings instead of domain objects for better decoupling
- Simplify `AppTheme` by removing redundant color animations and passing the color scheme directly to `MaterialExpressiveTheme`
- Update `LoginScreen` to include a theme toggle (classic vs. light) and improve back-button behavior by resetting error states
- Bump VK API version from 5.238 to 5.263
- Adjust layout and padding for sticker and gift attachment previews
2026-05-19 22:54:44 +03:00
melod1n b31c0f30c5 build: update gradle wrapper and build logic conventions 2026-05-19 13:28:10 +03:00
133 changed files with 4369 additions and 3858 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
android:
runs-on: android-jdk21
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make Gradle executable
run: chmod +x ./gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+2
View File
@@ -15,3 +15,5 @@ build/
local.properties local.properties
.idea .idea
/.kotlin /.kotlin
.hotswan/
.java-version
+1
View File
@@ -43,6 +43,7 @@ Unofficial messenger for russian social network VKontakte
- [ ] Forwarded messages - [ ] Forwarded messages
- [ ] Wall post - [ ] Wall post
- [ ] Comment in wall post - [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll - [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
-131
View File
@@ -1,131 +0,0 @@
# Fast Messenger Tech Debt Audit
## Critical
### `core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt`
- captcha flow is built around `wait/notify`, a raw executor, and shared mutable state.
- risk: deadlocks, leaked jobs, hard-to-reproduce auth hangs.
- fix: rewrite as suspend-based flow with timeout and explicit cancellation.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- file mixes parsing, dispatching, IO loading, and concurrency orchestration.
- risk: regressions when VK event format changes.
- fix: split by event family and add parser tests.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- view model is doing too much: loaders, navigation, selection, long poll hooks, read peers, dialog flow.
- risk: brittle state transitions and untestable branching.
- fix: extract coordinators/handlers per concern.
### `app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt`
- root bootstrap handles auth, locale, long poll, permissions, profile, and start destination in one class.
- risk: startup bugs and hidden coupling between flows.
- fix: move startup/permission orchestration into dedicated controllers.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root composable owns too many top-level flows and dialogs.
- risk: UI orchestration drift and hard-to-read navigation logic.
- fix: split dialogs, bootstrap, and navigation concern into smaller composables.
## High
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- uses many `Log.d` calls and large `when` branches.
- fix: reduce logging noise and add structured tracing only where needed.
### `core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt`
- double-parsing response bodies and only logging malformed payloads.
- risk: opaque failures and harder debugging.
- fix: normalize error conversion and surface typed failures.
### `feature/profile/src/main/kotlin/dev/meloda/fast/profile/ProfileViewModel.kt`
- `loadAccountInfo()` has an empty error branch.
- risk: profile can fail silently.
- fix: set `baseError` and show fallback UI.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt`
- attachment preview logic still sits in UI layer and mixes fallback behavior.
- risk: silent drops of unsupported attachments.
- fix: move preview mapping to domain/ui-model layer.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/Link.kt`
- title handling is nullable and duplicated with preview logic.
- fix: create a small UI model for link rendering.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/File.kt`
- preview extraction is inline and branches on raw model internals.
- fix: extract to mapper/UI model.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt`
- message-item interaction is dense and repeated.
- fix: normalize scroll/reply handlers and reduce nested callbacks.
## Medium
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt`
- lookup helpers were using unsafe assumptions on external data.
- status: already improved, but should stay covered by tests.
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt`
- same risk profile as users map.
- status: already improved, still needs tests.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt`
- auth success mapping used forced unwraps on server data.
- status: already improved, but auth contract should be validated.
### `app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt`
- service lifecycle now respects config changes, but app exit semantics should be documented.
- fix: keep explicit separation of app-close vs config-change behavior.
### `build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt`
- build-tools are pinned to the local environment.
- risk: portable builds may drift between machines.
- fix: prefer SDK-managed consistency or document required SDK version.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootErrorDialog.kt`
- currently hardcodes English strings for some errors.
- fix: localize all texts through resources.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root-level error dialog is better, but some orchestration still remains in the root composable.
- fix: split into smaller root flows over time.
## Low
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/components/AnimatedDots.kt`
- marked TODO rewrite.
- fix when touching related loading UI.
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt`
- color picker TODO suggests unfinished theme customization.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/data/LongPollUpdates.kt`
- `List<List<Any>>` is a weakly typed API boundary.
- fix: introduce explicit event DTOs.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt`
- attachment persistence is still a TODO area.
### `core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt`
- attachment storage/restoration is unresolved.
### `core/common/src/main/kotlin/dev/meloda/fast/common/model/LongPollState.kt`
- Android 15 support TODO.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- large commented-out legacy block should be removed once upload flow is reimplemented.
### `feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt`
- debug/auth token acquisition TODO indicates unfinished auth path.
## What To Do First
1. Rewrite captcha interceptor.
2. Split `LongPollEventParser`.
3. Extract `MessagesHistoryViewModelImpl` orchestration.
4. Localize `RootErrorDialog` strings.
5. Add tests for auth, long poll parsing, and attachment mapping.
## Note
- Current code is already better on crash-prone nullable handling and service lifecycle.
- Remaining work is mostly structural and testability-focused.
+2 -4
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
named("release") { named("release") {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -79,9 +79,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) implementation(projects.feature.chatmaterials)
@@ -95,6 +92,7 @@ dependencies {
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat) implementation(projects.feature.createchat)
implementation(projects.core.logger)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.data) implementation(projects.core.data)
+8 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -37,6 +37,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service <service
android:name="dev.meloda.fast.service.longpolling.LongPollingService" android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true" android:enabled="true"
@@ -22,6 +22,7 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
@@ -37,7 +38,6 @@ interface MainViewModel {
val startDestination: StateFlow<Any?> val startDestination: StateFlow<Any?>
val isNeedToReplaceWithAuth: StateFlow<Boolean> val isNeedToReplaceWithAuth: StateFlow<Boolean>
val currentUser: StateFlow<VkUser?> val currentUser: StateFlow<VkUser?>
val baseError: StateFlow<BaseError?>
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean> val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean> val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
@@ -45,7 +45,6 @@ interface MainViewModel {
val isNeedToRequestNotifications: StateFlow<Boolean> val isNeedToRequestNotifications: StateFlow<Boolean>
fun onError(error: BaseError) fun onError(error: BaseError)
fun onErrorConsumed()
fun onNavigatedToAuth() fun onNavigatedToAuth()
@@ -68,13 +67,13 @@ class MainViewModelImpl(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase, private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase, private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val longPollController: LongPollController private val longPollController: LongPollController,
private val logger: FastLogger
) : MainViewModel, ViewModel() { ) : MainViewModel, ViewModel() {
override val startDestination = MutableStateFlow<Any?>(null) override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false) override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val currentUser = MutableStateFlow<VkUser?>(null) override val currentUser = MutableStateFlow<VkUser?>(null)
override val baseError = MutableStateFlow<BaseError?>(null)
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false) override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false) override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
@@ -84,10 +83,6 @@ class MainViewModelImpl(
private var openNotificationsSettings = false private var openNotificationsSettings = false
private var openAppSettings = false private var openAppSettings = false
init {
listenLongPollState()
}
override fun onError(error: BaseError) { override fun onError(error: BaseError) {
when (error) { when (error) {
BaseError.SessionExpired, BaseError.SessionExpired,
@@ -95,16 +90,10 @@ class MainViewModelImpl(
isNeedToReplaceWithAuth.update { true } isNeedToReplaceWithAuth.update { true }
} }
else -> { else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui
baseError.update { error }
}
} }
} }
override fun onErrorConsumed() {
baseError.update { null }
}
override fun onNavigatedToAuth() { override fun onNavigatedToAuth() {
isNeedToReplaceWithAuth.update { false } isNeedToReplaceWithAuth.update { false }
} }
@@ -215,6 +204,13 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase() val currentAccount = getCurrentAccountUseCase()
logger.debug(
this@MainViewModelImpl::class,
"loadAccounts(): currentAccount: $currentAccount"
)
listenLongPollState()
if (currentAccount != null) { if (currentAccount != null) {
UserConfig.apply { UserConfig.apply {
this.userId = currentAccount.userId this.userId = currentAccount.userId
@@ -1,6 +1,8 @@
package dev.meloda.fast.common package dev.meloda.fast.common
import android.app.Application import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
@@ -8,14 +10,16 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import org.acra.config.dialog import dev.meloda.fast.logger.FastLogLevel
import org.acra.config.mailSender import dev.meloda.fast.logger.FastLogger
import org.acra.data.StringFormat import dev.meloda.fast.presentation.CrashActivity
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory { class AppGlobal : Application(), ImageLoaderFactory {
@@ -27,7 +31,15 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra() initCrashHandler()
val logLevel =
if (BuildConfig.DEBUG) FastLogLevel.DEBUG
else FastLogLevel.ERROR
get<FastLogger>()
.apply { setLogLevel(logLevel) }
.also { FastLogger.setInstance(it) }
} }
override fun newImageLoader(): ImageLoader = get() override fun newImageLoader(): ImageLoader = get()
@@ -40,20 +52,36 @@ class AppGlobal : Application(), ImageLoaderFactory {
} }
} }
private fun initAcra() { private fun initCrashHandler() {
initAcra { val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
buildConfigClass = BuildConfig::class.java Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
reportFormat = StringFormat.JSON val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
mailSender { crashLogsDirectory.mkdirs()
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
} }
dialog { val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
text = "App crashed" val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
enabled = true
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
} }
} }
} }
@@ -19,6 +19,7 @@ import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
import dev.meloda.fast.logger.loggerModule
import dev.meloda.fast.messageshistory.di.messagesHistoryModule import dev.meloda.fast.messageshistory.di.messagesHistoryModule
import dev.meloda.fast.photoviewer.di.photoViewModule import dev.meloda.fast.photoviewer.di.photoViewModule
import dev.meloda.fast.profile.di.profileModule import dev.meloda.fast.profile.di.profileModule
@@ -49,6 +50,8 @@ val applicationModule = module {
createChatModule createChatModule
) )
includes(loggerModule)
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class // TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
singleOf(PreferenceManager::getDefaultSharedPreferences) singleOf(PreferenceManager::getDefaultSharedPreferences)
single<Resources> { androidContext().resources } single<Resources> { androidContext().resources }
@@ -0,0 +1,35 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -0,0 +1,71 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -13,7 +13,7 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -23,10 +23,13 @@ import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.service.OnlineService import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -63,22 +66,26 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions() requestNotificationPermissions()
setContent { setContent {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val logger: FastLogger = koinInject()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent) viewModel.onAppResumed(intent)
onPauseOrDispose {} onPauseOrDispose {}
} }
RootScreen( CompositionLocalProvider(LocalLogger provides logger) {
toggleLongPollService = { enable, inBackground -> RootScreen(
toggleLongPollService( toggleLongPollService = { enable, inBackground ->
enable = enable, toggleLongPollService(
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground enable = enable,
) inBackground = inBackground
}, ?: AppSettings.Experimental.longPollInBackground
toggleOnlineService = ::toggleOnlineService )
) },
toggleOnlineService = ::toggleOnlineService
)
}
} }
} }
@@ -38,14 +38,13 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.convosGraph import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.navigation.MainGraph import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.profile.navigation.profileScreen import dev.meloda.fast.profile.navigation.profileScreen
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -70,18 +69,15 @@ fun MainScreen(
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) } val hazeState = remember { HazeState(true) }
val navController = rememberNavController() val navController = rememberNavController()
val defaultTabIndex = remember(navigationItems) {
navigationItems.indexOfFirst { it.route == ConvoGraph }.takeIf { it >= 0 } ?: 0
}
var selectedItemIndex by rememberSaveable { var selectedItemIndex by rememberSaveable {
mutableIntStateOf(defaultTabIndex) mutableIntStateOf(1)
} }
BackHandler(enabled = selectedItemIndex != defaultTabIndex) { BackHandler(enabled = selectedItemIndex != 1) {
val currentRoute = navigationItems[selectedItemIndex].route val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = defaultTabIndex selectedItemIndex = 1
navController.navigate(navigationItems[selectedItemIndex].route) { navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) { popUpTo(route = currentRoute) {
inclusive = true inclusive = true
@@ -131,7 +127,7 @@ fun MainScreen(
} }
}, },
icon = { icon = {
if (item.route == Profile) { if (index == navigationItems.size - 1) {
var isLoading by remember { var isLoading by remember {
mutableStateOf(true) mutableStateOf(true)
} }
@@ -202,19 +198,20 @@ fun MainScreen(
}, },
) )
convosGraph( convosGraph(
activity = activity, handleNavigationIntent = { intent ->
onError = onError, when (intent) {
onNavigateToMessagesHistory = onNavigateToMessagesHistory, ConvoNavigationIntent.Back -> {}
onNavigateToCreateChat = onNavigateToCreateChat, ConvoNavigationIntent.Archive -> {}
onScrolledToTop = { ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
tabReselected = tabReselected.toMutableMap().also { is ConvoNavigationIntent.MessagesHistory -> {
it[ConvoGraph] = false onNavigateToMessagesHistory(intent.convoId)
}
} }
} },
activity = activity,
) )
profileScreen( profileScreen(
activity = activity, activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
) )
@@ -1,38 +0,0 @@
package dev.meloda.fast.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
@Composable
fun RootErrorDialog(
baseError: BaseError?,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
if (baseError == null) return
val errorText = when (baseError) {
BaseError.ConnectionError -> "Connection error"
BaseError.InternalError -> "Internal error"
BaseError.UnknownError -> "Unknown error"
is BaseError.SimpleError -> baseError.message
BaseError.SessionExpired -> "Session expired"
BaseError.AccountBlocked -> "Account blocked"
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = errorText) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(id = R.string.try_again))
}
}
)
}
@@ -10,6 +10,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -59,6 +62,7 @@ import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.navigation.navigateToSettings import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig import dev.meloda.fast.ui.model.SizeConfig
@@ -79,6 +83,7 @@ fun RootScreen(
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit, toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit toggleOnlineService: (enable: Boolean) -> Unit
) { ) {
val logger = LocalLogger.current
val resources = LocalResources.current val resources = LocalResources.current
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
@@ -87,8 +92,7 @@ fun RootScreen(
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle() val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle() val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle() val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState = val permissionState =
@@ -118,7 +122,8 @@ fun RootScreen(
} }
} }
LifecycleResumeEffect(longPollStateToApply) { LifecycleResumeEffect(longPollStateToApply) {
logger.debug("RootScreen", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) { if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched() if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply && longPollCurrentState != longPollStateToApply
@@ -232,7 +237,6 @@ fun RootScreen(
val context = LocalContext.current val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle() val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle() val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle() val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle() val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
@@ -296,12 +300,6 @@ fun RootScreen(
) )
} }
RootErrorDialog(
baseError = baseError,
onDismiss = viewModel::onErrorConsumed,
onConfirm = viewModel::onErrorConsumed
)
if (startDestination != null) { if (startDestination != null) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavRootController provides navController, LocalNavRootController provides navController,
@@ -6,8 +6,9 @@ import android.os.IBinder
import android.util.Log import android.util.Log
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -24,11 +25,12 @@ import kotlin.time.Duration.Companion.minutes
class OnlineService : Service() { class OnlineService : Service() {
private val logger: FastLogger by inject()
private val job = SupervisorJob() private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.d(TAG, "error: $throwable") logger.error(this::class.java, "CoroutineException", throwable)
throwable.printStackTrace()
} }
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
@@ -42,17 +44,20 @@ class OnlineService : Service() {
private var onlineJob: Job? = null private var onlineJob: Job? = null
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
Log.d(STATE_TAG, "onBind: intent: $intent") logger.debug(this::class, "STATE: onBind(): intent: $intent")
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY if (startId > 1) return START_STICKY
Log.d(STATE_TAG, "onStartCommand: flags: $flags; startId: $startId\ninstance: $this") logger.debug(
this::class,
"STATE: onStartCommand(): flags: %s; startId: %s;\ninstance: %s"
.format("$flags", "$startId", "$this")
)
createTimer() createTimer()
return START_STICKY return START_STICKY
} }
@@ -68,13 +73,13 @@ class OnlineService : Service() {
private fun setOnline() { private fun setOnline() {
if (onlineJob != null) return if (onlineJob != null) return
Log.d(TAG, "setOnline()") logger.debug(this::class, "setOnline()")
onlineJob = coroutineScope.launch { onlineJob = coroutineScope.launch {
val token = UserConfig.fastToken ?: UserConfig.accessToken val token = UserConfig.fastToken ?: UserConfig.accessToken
if (token.isBlank()) { if (token.isBlank()) {
Log.d(TAG, "setOnline: token is empty") logger.debug(this::class, "setOnline(): token is empty")
return@launch return@launch
} }
@@ -84,10 +89,10 @@ class OnlineService : Service() {
).onEach { state -> ).onEach { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.w(TAG, "setOnline(): error: $error") logger.error(this@OnlineService::class, "setOnline(): ERROR: $error")
}, },
success = { response -> success = { response ->
Log.d(TAG, "setOnline(): success: $response") logger.debug(this@OnlineService::class, "setOnline(): response: $response")
} }
) )
}.collect() }.collect()
@@ -96,7 +101,7 @@ class OnlineService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") logger.debug(this::class, "onDestroy()")
timerJob?.cancel("OnlineService destroyed") timerJob?.cancel("OnlineService destroyed")
onlineJob?.cancel("OnlineService destroyed") onlineJob?.cancel("OnlineService destroyed")
@@ -7,7 +7,6 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.conena.nanokt.android.app.stopForegroundCompat import com.conena.nanokt.android.app.stopForegroundCompat
@@ -16,17 +15,13 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.domain.StoreUsersUseCase
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@@ -39,7 +34,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.flow.last
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -47,6 +41,8 @@ import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() { class LongPollingService : Service() {
private val logger: FastLogger by inject()
private val longPollController: LongPollController by inject() private val longPollController: LongPollController by inject()
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -62,9 +58,8 @@ class LongPollingService : Service() {
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val longPollUseCase: LongPollUseCase by inject() private val longPollUseCase: LongPollUseCase by inject()
private val messagesUseCase: MessagesUseCase by inject()
private val storeUsersUseCase: StoreUsersUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject() private val updatesParser: LongPollUpdatesParser by inject()
private val eventsHandler: LongPollEventsHandler by inject()
private var currentJob: Job? = null private var currentJob: Job? = null
@@ -72,20 +67,21 @@ class LongPollingService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(STATE_TAG, "onCreate()") logger.debug(this::class, "STATE: onCreate()")
} }
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
Log.d(STATE_TAG, "onBind: intent: $intent") logger.debug(this::class, "STATE: onBind(): intent: $intent")
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY if (startId > 1) return START_STICKY
Log.d( logger.debug(
STATE_TAG, this::class,
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this" "STATE: onStartCommand(): asForeground: %s; flags: %s; startId: %s;\ninstance: %s"
.format("$inBackground", "$flags", "$startId", "$this")
) )
startJob() startJob()
@@ -140,11 +136,15 @@ class LongPollingService : Service() {
private fun startPolling(): Job { private fun startPolling(): Job {
if (job.isCompleted || job.isCancelled) { if (job.isCompleted || job.isCancelled) {
Log.d(STATE_TAG, "Job is completed or cancelled") logger.debug(
this::class,
"startPolling(): Job is already done. isCompleted: %s; isCancelled: %s"
.format("${job.isCompleted}", "${job.isCancelled}")
)
throw Exception("Job is over") throw Exception("Job is over")
} }
Log.d(STATE_TAG, "Starting job...") logger.debug(this::class, "startPolling(): Starting job.")
return coroutineScope.launch(coroutineContext) { return coroutineScope.launch(coroutineContext) {
longPollController.updateCurrentState( longPollController.updateCurrentState(
@@ -159,8 +159,6 @@ class LongPollingService : Service() {
var serverInfo = getServerInfo() var serverInfo = getServerInfo()
?: throw LongPollException(message = "bad VK response (server info)") ?: throw LongPollException(message = "bad VK response (server info)")
syncLongPollHistory(serverInfo)
var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo) var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo)
?: throw LongPollException(message = "initiation error: bad VK response (last updates)") ?: throw LongPollException(message = "initiation error: bad VK response (last updates)")
@@ -171,7 +169,6 @@ class LongPollingService : Service() {
failCount++ failCount++
serverInfo = getServerInfo() serverInfo = getServerInfo()
?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)") ?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)")
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
continue continue
} }
@@ -191,7 +188,6 @@ class LongPollingService : Service() {
?: throw LongPollException( ?: throw LongPollException(
message = "failed retrieving server info after error: bad VK response (server info #3)" message = "failed retrieving server info after error: bad VK response (server info #3)"
) )
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
} }
@@ -206,12 +202,9 @@ class LongPollingService : Service() {
if (updates == null) { if (updates == null) {
failCount++ failCount++
} else { } else {
updates.forEach(updatesParser::parseNextUpdate) parseUpdates(updates)
} }
AppSettings.LongPoll.ts = lastUpdatesResponse.ts ?: serverInfo.ts
AppSettings.LongPoll.pts = lastUpdatesResponse.pts ?: AppSettings.LongPoll.pts
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs)) lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
} }
} }
@@ -227,11 +220,11 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
success = { response -> success = { response ->
Log.d(TAG, "getServerInfo: serverInfoResponse: $response") logger.debug(this::class, "getServerInfo(): response: $response")
it.resume(response) it.resume(response)
}, },
error = { error -> error = { error ->
Log.e(TAG, "getServerInfo: $error") logger.error(this::class, "getServerInfo(): ERROR: $error")
it.resume(null) it.resume(null)
} }
) )
@@ -251,86 +244,24 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
success = { response -> success = { response ->
Log.d(TAG, "lastUpdateResponse: $response") logger.debug(this::class, "getUpdatesResponse(): response: $response")
it.resume(response) it.resume(response)
}, },
error = { error -> error = { error ->
Log.d(TAG, "getUpdatesResponse: error: $error") logger.debug(this::class, "getUpdatesResponse(): error: $error")
it.resume(null) it.resume(null)
} }
) )
} }
} }
private suspend fun syncLongPollHistory(serverInfo: VkLongPollData) { private suspend fun parseUpdates(updates: List<List<Any>>) {
val cursorTs = AppSettings.LongPoll.ts ?: serverInfo.ts val parsedUpdates = updates.flatMap { updatesParser.parseNextUpdate(it) }
val cursorPts = AppSettings.LongPoll.pts ?: serverInfo.pts eventsHandler.handleEvents(parsedUpdates)
val maxMsgId = messagesUseCase.getLocalMaxMessageId()
var currentTs = cursorTs
var currentPts = cursorPts
var more: Int?
do {
val historyResponse = getLongPollHistory(
ts = currentTs,
pts = currentPts,
maxMsgId = maxMsgId
) ?: return
historyResponse.history.orEmpty().forEach(updatesParser::parseNextUpdate)
historyResponse.messages?.items.orEmpty().takeIf { it.isNotEmpty() }?.let { rawMessages ->
val messages = rawMessages.map { it.asDomain() }
messagesUseCase.storeMessages(messages)
}
historyResponse.profiles.orEmpty().takeIf { it.isNotEmpty() }?.let { profiles ->
val users = profiles.map(VkUserData::mapToDomain)
VkMemoryCache.appendUsers(users)
storeUsersUseCase(users).last()
}
historyResponse.groups.orEmpty().takeIf { it.isNotEmpty() }?.let { groups ->
VkMemoryCache.appendGroups(groups.map(VkGroupData::mapToDomain))
}
currentTs = historyResponse.ts ?: historyResponse.fromPts ?: currentTs
currentPts = historyResponse.newPts ?: historyResponse.pts ?: currentPts
more = historyResponse.more
AppSettings.LongPoll.ts = currentTs
AppSettings.LongPoll.pts = currentPts
} while (more == 1)
}
private suspend fun getLongPollHistory(
ts: Int,
pts: Int,
maxMsgId: Long?
): dev.meloda.fast.model.api.data.LongPollHistoryResponse? = suspendCancellableCoroutine {
longPollUseCase.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = VkConstants.LP_VERSION,
maxMsgId = maxMsgId,
eventsLimit = 1000,
msgsLimit = 200
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
it.resume(response)
},
error = { error ->
Log.e(TAG, "getLongPollHistory: error: $error")
it.resume(null)
}
)
}
} }
private fun handleError(throwable: Throwable) { private fun handleError(throwable: Throwable) {
Log.e(TAG, "error: $throwable") logger.error(this::class, "CoroutineException", throwable)
if (throwable !is NoAccessTokenException) { if (throwable !is NoAccessTokenException) {
throwable.printStackTrace() throwable.printStackTrace()
@@ -345,7 +276,7 @@ class LongPollingService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") logger.debug(this::class, "STATE: onDestroy()")
longPollController.updateCurrentState(LongPollState.Stopped) longPollController.updateCurrentState(LongPollState.Stopped)
try { try {
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) } AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
@@ -357,7 +288,7 @@ class LongPollingService : Service() {
} }
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
Log.d(STATE_TAG, "onTrimMemory. Level: $level") logger.debug(this::class, "STATE: onTrimMemory(): Level: $level")
super.onTrimMemory(level) super.onTrimMemory(level)
} }
@@ -1,7 +1,7 @@
package dev.meloda.fast.service.longpolling.di package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUpdatesReducer
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl import dev.meloda.fast.domain.LongPollUseCaseImpl
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
@@ -11,5 +11,5 @@ import org.koin.dsl.module
val longPollModule = module { val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser) singleOf(::LongPollUpdatesParser)
singleOf(::LongPollUpdatesReducer) singleOf(::LongPollEventsHandler)
} }
@@ -19,6 +19,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk") targetSdk = getVersionInt("targetSdk")
} }
lint {
abortOnError = false
}
} }
} }
} }
@@ -2,6 +2,7 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -20,7 +21,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
@@ -25,7 +25,6 @@ internal fun Project.configureKotlinAndroid(
commonExtension.apply { commonExtension.apply {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
buildToolsVersion = "36.1.0"
} }
configureKotlin<KotlinAndroidProjectExtension>() configureKotlin<KotlinAndroidProjectExtension>()
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238" const val API_VERSION = "5.263"
const val URL_OAUTH = "https://oauth.vk.ru" const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.ru/method"
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -46,14 +45,6 @@ fun <T> Flow<T>.listenValue(
action: suspend (T) -> Unit action: suspend (T) -> Unit
): Job = onEach(action::invoke).launchIn(coroutineScope) ): Job = onEach(action::invoke).launchIn(coroutineScope)
fun CoroutineScope.launchDbRefresh(
load: suspend () -> Unit,
after: suspend () -> Unit
): Job = launch {
load()
after()
}
fun createTimerFlow( fun createTimerFlow(
time: Int, time: Int,
onStartAction: (suspend () -> Unit)? = null, onStartAction: (suspend () -> Unit)? = null,
@@ -1,13 +1,13 @@
package dev.meloda.fast.common.model package dev.meloda.fast.common.model
enum class LogLevel(val value: Int) { enum class NetworkLogLevel(val value: Int) {
NONE(0), NONE(0),
BASIC(1), BASIC(1),
HEADERS(2), HEADERS(2),
BODY(3); BODY(3);
companion object { companion object {
fun parse(value: Int): LogLevel = entries.firstOrNull { it.value == value } fun parse(value: Int): NetworkLogLevel = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown log level with value: $value") ?: throw IllegalArgumentException("Unknown log level with value: $value")
} }
} }
@@ -1,25 +0,0 @@
package dev.meloda.fast.common.paging
fun canPaginate(pageSize: Int, loadedCount: Int): Boolean = loadedCount == pageSize
fun isPaginationExhausted(
pageSize: Int,
loadedCount: Int,
hasExistingItems: Boolean
): Boolean = loadedCount != pageSize && hasExistingItems
fun <T> mergePage(
existing: List<T>,
page: List<T>,
offset: Int
): List<T> = if (offset == 0) page else existing + page
data class LoadingFlags(
val isLoading: Boolean,
val isPaginating: Boolean
)
fun loadingFlags(offset: Int, isLoading: Boolean): LoadingFlags = LoadingFlags(
isLoading = offset == 0 && isLoading,
isPaginating = offset > 0 && isLoading
)
@@ -1,18 +1,24 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import androidx.core.net.toUri
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import java.net.URLEncoder
class AccessTokenInterceptor : Interceptor { class AccessTokenInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val builder = chain.request().url.newBuilder()
val urlBuilder = request.url.newBuilder()
if (request.url.queryParameter("access_token") == null) { val uri = builder.build().toUri().toString().toUri()
urlBuilder.addQueryParameter("access_token", UserConfig.accessToken)
if (uri.getQueryParameter("access_token") == null) {
builder.addQueryParameter(
"access_token",
URLEncoder.encode(UserConfig.accessToken, "utf-8")
)
} }
return chain.proceed(request.newBuilder().url(urlBuilder.build()).build()) return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build())
} }
} }
@@ -24,9 +24,6 @@ object UserConfig {
accessToken = "" accessToken = ""
fastToken = "" fastToken = ""
userId = -1 userId = -1
trustedHash = null
exchangeToken = null
AppSettings.LongPoll.clear()
} }
fun isLoggedIn(): Boolean { fun isLoggedIn(): Boolean {
@@ -21,10 +21,12 @@ class VkGroupsMap(
else map[abs(convo.id)] else map[abs(convo.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? = fun messageActionGroup(message: VkMessage): VkGroupDomain? =
message.actionMemberId?.takeIf { it < 0 }?.let { map[abs(it)] } if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
else map[abs(message.actionMemberId!!)]
fun messageActionGroup(message: VkMessageData): VkGroupDomain? = fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
message.action?.memberId?.takeIf { it < 0 }?.let { map[abs(it)] } if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null
else map[abs(message.action!!.memberId!!)]
fun messageGroup(message: VkMessage): VkGroupDomain? = fun messageGroup(message: VkMessage): VkGroupDomain? =
if (!message.isGroup()) null if (!message.isGroup()) null
@@ -1,5 +1,6 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.domain.VkContactDomain import dev.meloda.fast.model.api.domain.VkContactDomain
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain import dev.meloda.fast.model.api.domain.VkGroupDomain
@@ -37,15 +38,7 @@ object VkMemoryCache {
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact } contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
} }
fun clear() { operator fun set(userid: Long, user: VkUser) {
users.clear()
groups.clear()
messages.clear()
convos.clear()
contacts.clear()
}
operator fun set(userId: Long, user: VkUser) {
users[userId] = user users[userId] = user
} }
@@ -20,10 +20,12 @@ class VkUsersMap(
else map[convo.id] else map[convo.id]
fun messageActionUser(message: VkMessage): VkUser? = fun messageActionUser(message: VkMessage): VkUser? =
message.actionMemberId?.takeIf { it > 0 }?.let(map::get) if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
else map[message.actionMemberId]
fun messageActionUser(message: VkMessageData): VkUser? = fun messageActionUser(message: VkMessageData): VkUser? =
message.action?.memberId?.takeIf { it > 0 }?.let(map::get) if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null
else map[message.action!!.memberId]
fun messageUser(message: VkMessage): VkUser? = fun messageUser(message: VkMessage): VkUser? =
if (!message.isUser()) null if (!message.isUser()) null
@@ -8,9 +8,6 @@ import dev.meloda.fast.network.RestApiErrorDomain
interface ConvosRepository { interface ConvosRepository {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
suspend fun getConvos( suspend fun getConvos(
count: Int?, count: Int?,
@@ -19,13 +19,11 @@ import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.api.requests.ConvosGetRequest import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.convos.ConvosService import dev.meloda.fast.network.service.convos.ConvosService
import dev.meloda.fast.model.database.asExternalModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -42,28 +40,6 @@ class ConvosRepositoryImpl(
convoDao.insertAll(convos.map(VkConvo::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
convoDao.getAllWithMessage().map { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}.sorted()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
convoDao.getByIdWithMessage(peerId)?.let { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}
}
override suspend fun deleteLocalConvo(peerId: Long) {
withContext(Dispatchers.IO) {
convoDao.deleteById(peerId)
}
}
override suspend fun getConvos( override suspend fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
@@ -222,28 +198,4 @@ class ConvosRepositoryImpl(
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList()
val pinnedConvos = newConvos
.filter(VkConvo::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConvos.removeAll(pinnedConvos)
newConvos.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConvos.addAll(0, pinnedConvos)
return newConvos
}
} }
@@ -1,7 +1,6 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
@@ -22,14 +21,4 @@ interface LongPollRepository {
mode: Int, mode: Int,
version: Int version: Int
): ApiResult<LongPollUpdates, RestApiErrorDomain> ): ApiResult<LongPollUpdates, RestApiErrorDomain>
suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain>
} }
@@ -1,9 +1,7 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.requests.LongPollGetHistoryRequest
import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest
import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -54,29 +52,4 @@ class LongPollRepositoryImpl(
longPollService.getResponse(serverUrl, requestModel.map).mapDefault() longPollService.getResponse(serverUrl, requestModel.map).mapDefault()
} }
override suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = LongPollGetHistoryRequest(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
)
messagesService.getLongPollHistory(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse() },
errorMapper = { error -> error?.toDomain() }
)
}
} }
@@ -14,12 +14,6 @@ interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
suspend fun getHistory( suspend fun getHistory(
convoId: Long, convoId: Long,
offset: Int?, offset: Int?,
@@ -22,7 +22,6 @@ import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesDeleteRequest import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest import dev.meloda.fast.model.api.requests.MessagesEditRequest
@@ -40,7 +39,6 @@ import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.model.database.asExternalModel
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
@@ -356,28 +354,6 @@ class MessagesRepositoryImpl(
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> = withContext(Dispatchers.IO) {
messageDao.getAll(convoId).map(VkMessageEntity::asExternalModel)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getById(messageId)?.asExternalModel()
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getByConvoMessageId(convoId, cmId)?.asExternalModel()
}
override suspend fun getLocalMaxMessageId(): Long? = withContext(Dispatchers.IO) {
messageDao.getMaxId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
withContext(Dispatchers.IO) {
messageDao.deleteByIds(messageIds)
}
}
override suspend fun edit( override suspend fun edit(
peerId: Long, peerId: Long,
messageId: Long?, messageId: Long?,
@@ -0,0 +1,425 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "5eca3b3da167aaf7e772977a1f4e56e2",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `isImportant` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isSpam` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "isImportant",
"columnName": "isImportant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSpam",
"columnName": "isSpam",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eca3b3da167aaf7e772977a1f4e56e2')"
]
}
}
@@ -21,7 +21,7 @@ import dev.meloda.fast.model.database.VkUserEntity
VkConvoEntity::class VkConvoEntity::class
], ],
version = 11 version = 12
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
@@ -9,15 +9,11 @@ import dev.meloda.fast.model.database.VkConvoEntity
@Dao @Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> { abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC") @Query("SELECT * FROM convos")
abstract suspend fun getAll(): List<VkConvoEntity> abstract suspend fun getAll(): List<VkConvoEntity>
@Transaction
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
abstract suspend fun getAllWithMessage(): List<ConvoWithMessage>
@Query("SELECT * FROM convos WHERE id IN (:ids)") @Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity> abstract suspend fun getAllByIds(ids: List<Long>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)") @Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity? abstract suspend fun getById(id: Long): VkConvoEntity?
@@ -26,12 +22,24 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos WHERE id IS (:id)") @Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage? abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE id IS (:id)")
abstract suspend fun deleteById(id: Long): Int
@Query("DELETE FROM convos WHERE rowid IN (:ids)") @Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Long>): Int
@Query("UPDATE convos SET inReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadIncoming(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET outReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadOutgoing(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET isArchived = :isArchived WHERE id = :convoId")
abstract suspend fun updateIsArchived(convoId: Long, isArchived: Boolean): Int
@Query("UPDATE convos SET majorId = :majorId WHERE id = :convoId")
abstract suspend fun updateMajorId(convoId: Long, majorId: Int): Int
@Query("UPDATE convos SET minorId = :minorId WHERE id = :convoId")
abstract suspend fun updateMinorId(convoId: Long, minorId: Int): Int
@Query("UPDATE convos SET lastCmId = :cmId WHERE id = :convoId")
abstract suspend fun updateLastCmId(convoId: Long, cmId: Long): Int
} }
@@ -7,24 +7,27 @@ import dev.meloda.fast.model.database.VkMessageEntity
@Dao @Dao
abstract class MessageDao : EntityDao<VkMessageEntity> { abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages") @Query("SELECT * FROM messages WHERE isDeleted = 0 AND isSpam = 0")
abstract suspend fun getAll(): List<VkMessageEntity> abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId) ORDER BY date DESC, id DESC") @Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity> abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)") @Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Long>): List<VkMessageEntity> abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IS (:messageId)") @Query("SELECT * FROM messages WHERE id IS (:messageId)")
abstract suspend fun getById(messageId: Long): VkMessageEntity? abstract suspend fun getById(messageId: Long): VkMessageEntity?
@Query("SELECT * FROM messages WHERE peerId IS (:convoId) AND cmId IS (:cmId)")
abstract suspend fun getByConvoMessageId(convoId: Long, cmId: Long): VkMessageEntity?
@Query("SELECT MAX(id) FROM messages")
abstract suspend fun getMaxId(): Long?
@Query("DELETE FROM messages WHERE id IN (:ids)") @Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Long>): Int abstract suspend fun deleteByIds(ids: List<Int>): Int
@Query("UPDATE messages SET isDeleted = :isDeleted WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsDeleted(convoId: Long, cmId: Long, isDeleted: Boolean): Int
@Query("UPDATE messages SET isImportant = :isImportant WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsImportant(convoId: Long, cmId: Long, isImportant: Boolean): Int
@Query("UPDATE messages SET isSpam = :isSpam WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsSpam(convoId: Long, cmId: Long, isSpam: Boolean): Int
} }
@@ -3,7 +3,7 @@ package dev.meloda.fast.datastore
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel import dev.meloda.fast.common.model.NetworkLogLevel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -230,21 +230,6 @@ object AppSettings {
set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value) set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value)
} }
object LongPoll {
var ts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_TS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_TS, value ?: 0)
var pts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_PTS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_PTS, value ?: 0)
fun clear() {
ts = null
pts = null
}
}
object Debug { object Debug {
var showAlertAfterCrash: Boolean var showAlertAfterCrash: Boolean
get() = get( get() = get(
@@ -253,11 +238,11 @@ object AppSettings {
) )
set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value) set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value)
var networkLogLevel: LogLevel var networkLogLevel: NetworkLogLevel
get() = get( get() = get(
SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL,
SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL
).let(LogLevel::parse) ).let(NetworkLogLevel::parse)
set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value) set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value)
var showDebugCategory: Boolean var showDebugCategory: Boolean
@@ -39,8 +39,6 @@ object SettingsKeys {
const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯" const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯"
const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background" const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background"
const val DEFAULT_LONG_POLL_IN_BACKGROUND = false const val DEFAULT_LONG_POLL_IN_BACKGROUND = false
const val KEY_LONG_POLL_TS = "lp_ts"
const val KEY_LONG_POLL_PTS = "lp_pts"
const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status" const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status"
const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false
@@ -8,9 +8,6 @@ import kotlinx.coroutines.flow.Flow
interface ConvoUseCase : BaseUseCase { interface ConvoUseCase : BaseUseCase {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
fun getConvos( fun getConvos(
count: Int? = null, count: Int? = null,
@@ -19,18 +19,6 @@ class ConvoUseCaseImpl(
repository.storeConvos(convos) repository.storeConvos(convos)
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
repository.getLocalConvos()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
repository.getLocalConvoById(peerId)
}
override suspend fun deleteLocalConvo(peerId: Long) = withContext(Dispatchers.IO) {
repository.deleteLocalConvo(peerId)
}
override fun getConvos( override fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
@@ -1,36 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
internal class LongPollEventDispatcher {
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> dispatch(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { callback ->
(callback as? VkEventCallback<T>)?.onEvent(event)
}
}
fun dispatchAll(eventType: LongPollEvent, events: List<LongPollParsedEvent>) {
events.forEach { event -> dispatch(eventType, event) }
}
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap[eventType] = (listenersMap[eventType] ?: mutableListOf())
.also { it.add(listener as VkEventCallback<LongPollParsedEvent>) }
}
fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
}
@@ -1,537 +0,0 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class LongPollEventParser(
private val coroutineScope: CoroutineScope,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val dispatch: (LongPollEvent, LongPollParsedEvent) -> Unit,
private val dispatchAll: (LongPollEvent, List<LongPollParsedEvent>) -> Unit
) {
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Unit
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = true
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend)
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_SPAM, eventToSend)
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = true
)
} else {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = false
)
}
eventsToSend += eventToSend
dispatch(LongPollEvent.MESSAGE_DELETED, eventToSend)
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.AUDIO_MESSAGE_LISTENED, eventToSend)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_SET_FLAGS, eventsToSend)
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend)
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_NOT_SPAM, eventToSend)
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
dispatch(LongPollEvent.MESSAGE_RESTORED, eventToSend)
}
}
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
message?.let {
dispatch(
LongPollEvent.MESSAGE_NEW,
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
)
)
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_EDITED, LongPollParsedEvent.MessageEdited(message))
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.INCOMING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.IncomingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.OUTGOING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.OutgoingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
handleArchivedChat(
peerId = peerId,
archived = false,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
handleArchivedChat(
peerId = peerId,
archived = true,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_SET_FLAGS, eventsToSend)
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val cmId = event[2].asLong()
dispatch(
LongPollEvent.CHAT_CLEARED,
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val majorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MAJOR_CHANGED,
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val minorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MINOR_CHANGED,
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
if (userIds.isEmpty()) return
dispatch(
longPollEvent,
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
dispatch(
LongPollEvent.UNREAD_COUNTER_UPDATE,
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_UPDATED, LongPollParsedEvent.MessageUpdated(message))
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
dispatch(
LongPollEvent.MESSAGE_CACHE_CLEAR,
LongPollParsedEvent.MessageCacheClear(message)
)
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
val job = coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
peerCmIds = null,
peerId = peerId,
messageIds = messageId?.let(::listOf),
cmIds = cmId?.let(::listOf),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
Log.e("LongPollEventParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
val job = coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollEventParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun handleArchivedChat(
peerId: Long,
archived: Boolean,
eventsToSend: MutableList<LongPollParsedEvent>
) {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = archived
)
eventsToSend += eventToSend
dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend)
}
private fun dispatchMessageRead(
longPollEvent: LongPollEvent,
parsedEvent: LongPollParsedEvent
) {
dispatch(longPollEvent, parsedEvent)
}
}
@@ -0,0 +1,188 @@
package dev.meloda.fast.domain
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.LongPollParsedEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext
class LongPollEventsHandler(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val convoDao: ConvoDao,
private val messageDao: MessageDao,
) {
private val job = SupervisorJob()
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext)
suspend fun handleEvents(events: List<LongPollParsedEvent>) {
events.forEach { handleNextEvent(it) }
}
private suspend fun handleNextEvent(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.AudioMessageListened -> {
}
is LongPollParsedEvent.ChatArchived -> {
val affectedRows = convoDao.updateIsArchived(
convoId = event.convo.id,
isArchived = event.convo.isArchived
)
logger.debug(
this::class,
"isArchived ${event.convo.isArchived}: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatCleared -> {
val affectedRows = convoDao.updateLastCmId(
convoId = event.peerId,
cmId = event.toCmId
)
logger.debug(
this::class,
"updateLastCmId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMajorChanged -> {
val affectedRows = convoDao.updateMajorId(
convoId = event.peerId,
majorId = event.majorId
)
logger.debug(
this::class,
"updateMajorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMinorChanged -> {
val affectedRows = convoDao.updateMinorId(
convoId = event.peerId,
minorId = event.minorId
)
logger.debug(
this::class,
"updateMinorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.Interaction -> {
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageDeleted -> {
val affectedRows = messageDao.markAsDeleted(
convoId = event.peerId,
cmId = event.cmId,
isDeleted = true
)
logger.debug(
this::class,
"markDeleted: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val affectedRows = messageDao.markAsImportant(
convoId = event.peerId,
cmId = event.cmId,
isImportant = event.marked
)
logger.debug(
this::class,
"markImportant: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val affectedRows = messageDao.markAsSpam(
convoId = event.peerId,
cmId = event.cmId,
isSpam = true
)
logger.debug(
this::class,
"markSpam: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.IncomingMessageRead -> {
val affectedRows = convoDao.updateReadIncoming(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"inMessageRead: updated $affectedRows rows."
)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
val affectedRows = convoDao.updateReadOutgoing(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"outMessageRead: updated $affectedRows rows."
)
}
is LongPollParsedEvent.UnreadCounter -> {
}
}
}
}
@@ -1,113 +1,773 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import android.util.Log import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
class LongPollUpdatesParser( class LongPollUpdatesParser(
convoUseCase: ConvoUseCase, private val logger: FastLogger,
messagesUseCase: MessagesUseCase private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase
) { ) {
private val job = SupervisorJob() private val job = SupervisorJob()
private val exceptionHandler = private val exceptionHandler =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
Log.e("LongPollUpdatesParser", "error: $throwable") logger.error(this::class, "CoroutineException", throwable)
throwable.printStackTrace()
} }
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val eventDispatcher = LongPollEventDispatcher()
private val eventParser = LongPollEventParser(
coroutineScope = coroutineScope,
convoUseCase = convoUseCase,
messagesUseCase = messagesUseCase,
dispatch = eventDispatcher::dispatch,
dispatchAll = eventDispatcher::dispatchAll
)
fun parseNextUpdate(event: List<Any>) { private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
eventParser.parseNextUpdate(event) mutableMapOf()
suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> {
val eventId = event.first().asInt()
return when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> {
logger.debug(this::class, "parseNextUpdate(): unknownEvent: $event")
emptyList()
}
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun parseMessageSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageSetFlags(): $eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
?.forEach { it.onEvent(eventToSend) }
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.forEach { it.onEvent(eventToSend) }
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = true
)
} else {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = false
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.forEach { it.onEvent(eventToSend) }
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]
?.forEach { it.onEvent(eventToSend) }
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.forEach { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
return eventsToSend
}
private suspend fun parseMessageClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageClearFlags(): $eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
?.forEach { it.onEvent(eventToSend) }
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
if (message != null) {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]
?.forEach { it.onEvent(eventToSend) }
}
}
}
MessageFlags.DELETED -> {
if (message != null) {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]
?.forEach { it.onEvent(eventToSend) }
}
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.AUDIO_LISTENED -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
}
}
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend)
}
}
private suspend fun parseMessageNew(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageNew(): $eventType: $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
if (message != null) {
val event = LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
listenersMap[LongPollEvent.MESSAGE_NEW]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
}
}
}
private suspend fun parseMessageEdit(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageEdit(): $eventType: $event")
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageEdited(message)
listenersMap[LongPollEvent.MESSAGE_EDITED]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
}
}
}
private fun parseMessageReadIncoming(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadIncoming(): $eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
val event = LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.forEach { it.onEvent(event) }
return listOf(event)
}
private fun parseMessageReadOutgoing(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadOutgoing(): $eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
val event = LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.forEach { it.onEvent(event) }
return listOf(event)
}
private suspend fun parseChatClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatClearFlags(): $eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
}
}
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend)
}
}
private suspend fun parseChatSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatSetFlags(): $eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
}
}
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend)
}
}
private fun parseMessagesDeleted(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessagesDeleted(): $eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val event = LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
listenersMap[LongPollEvent.CHAT_CLEARED]?.forEach { it.onEvent(event) }
return listOf(event)
}
private fun parseChatMajorChanged(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMajorChanged(): $eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.forEach { it.onEvent(event) }
return listOf(event)
}
private fun parseChatMinorChanged(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMinorChanged(): $eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.forEach { it.onEvent(event) }
return listOf(event)
}
private fun parseInteraction(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseInteraction(): $eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return emptyList()
}
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
}
val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return emptyList()
val event = LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
listenersMap[longPollEvent]?.forEach { it.onEvent(event) }
return listOf(event)
}
private fun parseUnreadCounterUpdate(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseUnreadCounterUpdate(): $eventType: $event")
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
val event = LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.forEach { it.onEvent(event) }
return listOf(event)
}
private suspend fun parseMessageUpdated(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageUpdated(): $eventType: $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageUpdated(message)
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
}
}
}
private suspend fun parseMessageCacheClear(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageCacheClear(): $eventType: $event")
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(messageId = messageId)
if (message != null) {
val event = LongPollParsedEvent.MessageCacheClear(message)
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
peerCmIds = null,
peerId = peerId,
messageIds = messageId?.let(::listOf),
cmIds = cmId?.let(::listOf),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
logger.error(this::class, "loadMessage(): ERROR: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
}
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
logger.error(this::class, "loadConvo(): ERROR: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
} }
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) { fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block)) registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
} }
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) { fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block)) registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
} }
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) { fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
} }
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) { fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block)) registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
} }
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) { fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onMessageUpdated(block: (LongPollParsedEvent.MessageUpdated) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_UPDATED, assembleEventCallback(block))
}
fun onMessageCacheClear(block: (LongPollParsedEvent.MessageCacheClear) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_CACHE_CLEAR, assembleEventCallback(block))
} }
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) { fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
} }
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) { fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block)) registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
} }
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) { fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block)) registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) { fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block)) registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) { fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block)) registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
} }
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) { fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block)) registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
} }
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) { fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block)) registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
} }
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) { fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block)) registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
} }
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) { fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
eventDispatcher.registerListeners( registerListeners(
eventTypes = listOf( eventTypes = listOf(
LongPollEvent.TYPING, LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING, LongPollEvent.AUDIO_MESSAGE_RECORDING,
@@ -1,213 +0,0 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class LongPollUpdatesReducer(
updatesParser: LongPollUpdatesParser,
private val messagesUseCase: MessagesUseCase,
private val convoUseCase: ConvoUseCase
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<LongPollParsedEvent>(extraBufferCapacity = 256)
val events: SharedFlow<LongPollParsedEvent> = _events.asSharedFlow()
val newMessages = events.filterIsInstance<LongPollParsedEvent.NewMessage>()
val messageEdited = events.filterIsInstance<LongPollParsedEvent.MessageEdited>()
val messageIncomingRead = events.filterIsInstance<LongPollParsedEvent.IncomingMessageRead>()
val messageOutgoingRead = events.filterIsInstance<LongPollParsedEvent.OutgoingMessageRead>()
val messageDeleted = events.filterIsInstance<LongPollParsedEvent.MessageDeleted>()
val messageRestored = events.filterIsInstance<LongPollParsedEvent.MessageRestored>()
val messageMarkedAsImportant = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsImportant>()
val messageMarkedAsSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsSpam>()
val messageMarkedAsNotSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsNotSpam>()
val interactions = events.filterIsInstance<LongPollParsedEvent.Interaction>()
val chatMajorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMajorChanged>()
val chatMinorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMinorChanged>()
val chatCleared = events.filterIsInstance<LongPollParsedEvent.ChatCleared>()
val chatArchived = events.filterIsInstance<LongPollParsedEvent.ChatArchived>()
val messageUpdated = events.filterIsInstance<LongPollParsedEvent.MessageUpdated>()
val messageCacheClear = events.filterIsInstance<LongPollParsedEvent.MessageCacheClear>()
init {
updatesParser.onNewMessage { publish(it) }
updatesParser.onMessageEdited { publish(it) }
updatesParser.onMessageIncomingRead { publish(it) }
updatesParser.onMessageOutgoingRead { publish(it) }
updatesParser.onMessageDeleted { publish(it) }
updatesParser.onMessageRestored { publish(it) }
updatesParser.onMessageUpdated { publish(it) }
updatesParser.onMessageCacheClear { publish(it) }
updatesParser.onMessageMarkedAsImportant { publish(it) }
updatesParser.onMessageMarkedAsSpam { publish(it) }
updatesParser.onMessageMarkedAsNotSpam { publish(it) }
updatesParser.onInteractions { publish(it) }
updatesParser.onChatMajorChanged { publish(it) }
updatesParser.onChatMinorChanged { publish(it) }
updatesParser.onChatCleared { publish(it) }
updatesParser.onChatArchived { publish(it) }
}
private fun publish(event: LongPollParsedEvent) {
scope.launch {
runCatching { applyCommon(event) }
.onFailure { throwable ->
Log.e("LongPollUpdatesReducer", "applyCommon failed: $event", throwable)
}
_events.emit(event)
}
}
private suspend fun applyCommon(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessages(listOf(event.message))
updateConvoForMessage(event.message, unreadIncrement = if (event.message.isOut) 0 else 1)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.IncomingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.MessageDeleted -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
) ?: return
messagesUseCase.storeMessage(message.copy(isImportant = event.marked))
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.ChatMajorChanged -> {
updateConvoSortState(event.peerId, majorId = event.majorId)
}
is LongPollParsedEvent.ChatMinorChanged -> {
updateConvoSortState(event.peerId, minorId = event.minorId)
}
is LongPollParsedEvent.ChatCleared -> {
convoUseCase.deleteLocalConvo(event.peerId)
}
is LongPollParsedEvent.ChatArchived -> {
event.convo.lastMessage?.let(messagesUseCase::storeMessage)
convoUseCase.storeConvos(listOf(event.convo.copy(isArchived = event.archived)))
}
is LongPollParsedEvent.Interaction -> Unit
}
}
private suspend fun updateConvoReadState(
peerId: Long,
inReadCmId: Long? = null,
outReadCmId: Long? = null,
unreadCount: Int
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
inReadCmId = inReadCmId ?: convo.inReadCmId,
outReadCmId = outReadCmId ?: convo.outReadCmId,
unreadCount = unreadCount
)
)
)
}
private suspend fun updateConvoSortState(
peerId: Long,
majorId: Int? = null,
minorId: Int? = null
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
majorId = majorId ?: convo.majorId,
minorId = minorId ?: convo.minorId
)
)
)
}
private suspend fun updateConvoForMessage(
message: VkMessage,
unreadIncrement: Int
) {
val convo = convoUseCase.getLocalConvoById(message.peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = convo.unreadCount + unreadIncrement
)
)
)
}
}
@@ -1,7 +1,6 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -22,14 +21,4 @@ interface LongPollUseCase {
mode: Int, mode: Int,
version: Int version: Int
): Flow<State<LongPollUpdates>> ): Flow<State<LongPollUpdates>>
fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): Flow<State<LongPollHistoryResponse>>
} }
@@ -3,7 +3,6 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.longpoll.LongPollRepository import dev.meloda.fast.data.api.longpoll.LongPollRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -49,27 +48,4 @@ class LongPollUseCaseImpl(
).mapToState() ).mapToState()
emit(newState) emit(newState)
} }
override fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): Flow<State<LongPollHistoryResponse>> = flow {
emit(State.Loading)
val newState = repository.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
).mapToState()
emit(newState)
}
} }
@@ -12,11 +12,6 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
fun getMessagesHistory( fun getMessagesHistory(
convoId: Long, convoId: Long,
@@ -22,26 +22,6 @@ class MessagesUseCaseImpl(
repository.storeMessages(messages) repository.storeMessages(messages)
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> {
return repository.getLocalMessages(convoId)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? {
return repository.getLocalMessageById(messageId)
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? {
return repository.getLocalMessageByConvoMessageId(convoId, cmId)
}
override suspend fun getLocalMaxMessageId(): Long? {
return repository.getLocalMaxMessageId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
repository.deleteLocalMessages(messageIds)
}
override fun getMessagesHistory( override fun getMessagesHistory(
convoId: Long, convoId: Long,
count: Int?, count: Int?,
@@ -22,35 +22,22 @@ class OAuthUseCaseImpl(
): Flow<State<AuthInfo>> = flow { ): Flow<State<AuthInfo>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = when (val authResult = oAuthRepository.auth( val newState = oAuthRepository.auth(
login = login, login = login,
password = password, password = password,
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey captchaKey = captchaKey
)) { ).asState(
is com.slack.eithernet.ApiResult.Success -> { successMapper = {
val value = authResult.value AuthInfo(
val userId = value.userId userId = it.userId!!,
val accessToken = value.accessToken accessToken = it.accessToken!!,
val validationHash = value.validationHash validationHash = it.validationHash!!
)
if (userId == null || accessToken == null || validationHash == null) {
State.Error.InternalError
} else {
State.Success(
AuthInfo(
userId = userId,
accessToken = accessToken,
validationHash = validationHash
)
)
}
} }
)
else -> authResult.asState()
}
emit(newState) emit(newState)
} }
@@ -598,6 +598,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24 AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -687,6 +688,7 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource) }.let(UiText::Resource)
} }
+1
View File
@@ -0,0 +1 @@
/build
+11
View File
@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.fast.android.library)
}
android {
namespace = "dev.meloda.fast.logger"
}
dependencies {
implementation(libs.koin.android)
}
@@ -0,0 +1,17 @@
package dev.meloda.fast.logger;
enum class FastLogLevel {
VERBOSE,
DEBUG,
INFO,
WARNING,
ERROR,
ASSERT;
companion object {
fun parse(value: Int): FastLogLevel {
if (value !in 0..5) throw IllegalArgumentException("Unknown LogLevel value $value")
return entries.first { it.ordinal == value }
}
}
}
@@ -0,0 +1,108 @@
package dev.meloda.fast.logger
import android.util.Log
import kotlin.reflect.KClass
class FastLogger {
companion object {
@Volatile
private lateinit var instance: FastLogger
fun setInstance(logger: FastLogger) {
if (::instance.isInitialized) {
throw IllegalStateException("FastLogger has already been initialized.")
}
instance = logger
}
fun getInstance(): FastLogger {
if (!::instance.isInitialized) {
throw UninitializedPropertyAccessException("FastLogger is not initialized.")
}
return instance
}
}
private var logLevel: FastLogLevel = FastLogLevel.ERROR
fun setLogLevel(logLevel: FastLogLevel) {
Log.v(this::class.java.simpleName, "Set LogLevel from ${this.logLevel} to $logLevel")
this.logLevel = logLevel
}
fun verbose(clazz: Class<*>, message: String, throwable: Throwable? = null) {
verbose(clazz.simpleName, message, throwable)
}
fun verbose(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.VERBOSE)) {
Log.v(tag, message, throwable)
}
}
fun debug(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
debug(clazz.java, message, throwable)
}
fun debug(clazz: Class<*>, message: String, throwable: Throwable? = null) {
debug(clazz.simpleName, message, throwable)
}
fun debug(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.DEBUG)) {
Log.d(tag, message, throwable)
}
}
fun info(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
info(clazz.java, message, throwable)
}
fun info(clazz: Class<*>, message: String, throwable: Throwable? = null) {
info(clazz.simpleName, message, throwable)
}
fun info(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.INFO)) {
Log.i(tag, message, throwable)
}
}
fun warning(clazz: Class<*>, message: String, throwable: Throwable? = null) {
warning(clazz.simpleName, message, throwable)
}
fun warning(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.WARNING)) {
Log.w(tag, message, throwable)
}
}
fun error(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
error(clazz.java, message, throwable)
}
fun error(clazz: Class<*>, message: String, throwable: Throwable? = null) {
error(clazz.simpleName, message, throwable)
}
fun error(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ERROR)) {
Log.e(tag, message, throwable)
}
}
fun assert(clazz: Class<*>, message: String, throwable: Throwable? = null) {
assert(clazz.simpleName, message, throwable)
}
fun assert(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ASSERT)) {
Log.wtf(tag, message, throwable)
}
}
private fun shouldLog(level: FastLogLevel): Boolean = level.ordinal >= logLevel.ordinal
}
@@ -0,0 +1,8 @@
package dev.meloda.fast.logger
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val loggerModule = module {
singleOf(::FastLogger)
}
@@ -1,7 +1,5 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import android.util.Log
enum class AttachmentType(var value: String) { enum class AttachmentType(var value: String) {
UNKNOWN("unknown"), UNKNOWN("unknown"),
PHOTO("photo"), PHOTO("photo"),
@@ -30,7 +28,8 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"), ARTICLE("article"),
VIDEO_MESSAGE("video_message"), VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"), GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview") STICKER_PACK_PREVIEW("sticker_pack_preview"),
CHANNEL_MESSAGE("channel_message")
; ;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -41,10 +40,6 @@ enum class AttachmentType(var value: String) {
it.value == value it.value == value
} ?: UNKNOWN } ?: UNKNOWN
if (parsedValue == UNKNOWN) {
Log.e("AttachmentType", "Unknown attachment type: $value")
}
return parsedValue return parsedValue
} }
} }
@@ -1,24 +0,0 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LongPollHistoryResponse(
@Json(name = "history") val history: List<List<Any>>? = null,
@Json(name = "messages") val messages: Messages? = null,
@Json(name = "profiles") val profiles: List<VkUserData>? = null,
@Json(name = "groups") val groups: List<VkGroupData>? = null,
@Json(name = "new_pts") val newPts: Int? = null,
@Json(name = "from_pts") val fromPts: Int? = null,
@Json(name = "ts") val ts: Int? = null,
@Json(name = "pts") val pts: Int? = null,
@Json(name = "more") val more: Int? = null,
@Json(name = "conversations") val conversations: List<VkConvoData>? = null
) {
@JsonClass(generateAdapter = true)
data class Messages(
@Json(name = "count") val count: Int? = null,
@Json(name = "items") val items: List<VkMessageData>? = null
)
}
@@ -35,7 +35,8 @@ data class VkAttachmentItemData(
@Json(name = "article") val article: VkArticleData?, @Json(name = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?, @Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, @Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?,
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData? @Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?,
@Json(name = "channel_message") val channelMessageData: VkChannelMessageData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -66,5 +67,6 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment } ?: VkUnknownAttachment
} }
@@ -0,0 +1,40 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkChannelMessage
@JsonClass(generateAdapter = true)
data class VkChannelMessageData(
@Json(name = "channel_id") val channelId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "author_id") val authorId: Long,
@Json(name = "channel_info") val channelInfo: ChannelInfo,
@Json(name = "channel_type") val channelType: String,
@Json(name = "guid") val guid: String,
@Json(name = "text") val text: String?,
@Json(name = "time") val time: Long,
@Json(name = "attachments") val attachments: List<VkAttachmentItemData> = emptyList(),
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
data class ChannelInfo(
@Json(name = "photo_base") val photoBase: String?,
@Json(name = "title") val title: String
)
fun toDomain(): VkChannelMessage = VkChannelMessage(
channelId = channelId,
cmId = cmId,
authorId = authorId,
channelInfo = VkChannelMessage.ChannelInfo(
title = channelInfo.title,
photoBase = channelInfo.photoBase
),
channelType = channelType,
guid = guid,
text = text,
time = time,
attachments = attachments.map(VkAttachmentItemData::toDomain),
)
}
@@ -117,5 +117,6 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
pinnedAt = pinnedAt, pinnedAt = pinnedAt,
isPinned = isPinned == true, isPinned = isPinned == true,
formatData = formatData?.asDomain(), formatData = formatData?.asDomain(),
isSpam = false isSpam = false,
isDeleted = false
) )
@@ -56,5 +56,6 @@ data class VkPinnedMessageData(
isPinned = true, isPinned = true,
isSpam = false, isSpam = false,
formatData = null, formatData = null,
isDeleted = false
) )
} }
@@ -0,0 +1,23 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkChannelMessage(
val channelId: Long,
val cmId: Long,
val authorId: Long,
val channelInfo: ChannelInfo,
val channelType: String,
val guid: String,
val text: String?,
val time: Long,
val attachments: List<VkAttachment>?,
) : VkAttachment {
data class ChannelInfo(
val title: String,
val photoBase: String?
)
override val type: AttachmentType = AttachmentType.CHANNEL_MESSAGE
}
@@ -36,6 +36,8 @@ data class VkMessage(
val group: VkGroupDomain?, val group: VkGroupDomain?,
val actionUser: VkUser?, val actionUser: VkUser?,
val actionGroup: VkGroupDomain?, val actionGroup: VkGroupDomain?,
val isDeleted: Boolean
) { ) {
fun isPeerChat() = peerId > 2_000_000_000 fun isPeerChat() = peerId > 2_000_000_000
@@ -111,7 +113,7 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
actionCmId = actionCmId, actionCmId = actionCmId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
important = isImportant, isImportant = isImportant,
forwardIds = forwards.orEmpty().map(VkMessage::id), forwardIds = forwards.orEmpty().map(VkMessage::id),
// TODO: 05/05/2024, Danil Nikolaev: save attachments // TODO: 05/05/2024, Danil Nikolaev: save attachments
attachments = emptyList(), attachments = emptyList(),
@@ -119,4 +121,6 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
geoType = geoType, geoType = geoType,
pinnedAt = pinnedAt, pinnedAt = pinnedAt,
isPinned = isPinned, isPinned = isPinned,
isDeleted = isDeleted,
isSpam = isSpam
) )
@@ -19,27 +19,3 @@ data class LongPollGetUpdatesRequest(
"version" to version.toString() "version" to version.toString()
) )
} }
data class LongPollGetHistoryRequest(
val ts: Int,
val pts: Int,
val lpVersion: Int,
val lastN: Int? = null,
val maxMsgId: Long? = null,
val eventsLimit: Int? = null,
val msgsLimit: Int? = null,
val extended: Boolean = true,
) {
val map: Map<String, String>
get() = mutableMapOf(
"ts" to ts.toString(),
"pts" to pts.toString(),
"lp_version" to lpVersion.toString(),
"extended" to if (extended) "1" else "0",
).apply {
lastN?.let { this["last_n"] = it.toString() }
maxMsgId?.let { this["max_msg_id"] = it.toString() }
eventsLimit?.let { this["events_limit"] = it.toString() }
msgsLimit?.let { this["msgs_limit"] = it.toString() }
}
}
@@ -21,13 +21,15 @@ data class VkMessageEntity(
val actionCmId: Long?, val actionCmId: Long?,
val actionMessage: String?, val actionMessage: String?,
val updateTime: Int?, val updateTime: Int?,
val important: Boolean, val isImportant: Boolean,
val forwardIds: List<Long>?, val forwardIds: List<Long>?,
val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store??? val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store???
val replyMessageId: Long?, val replyMessageId: Long?,
val geoType: String?, val geoType: String?,
val pinnedAt: Int?, val pinnedAt: Int?,
val isPinned: Boolean val isPinned: Boolean,
val isDeleted: Boolean,
val isSpam: Boolean
) )
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
@@ -45,7 +47,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
actionCmId = actionCmId, actionCmId = actionCmId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
isImportant = important, isImportant = isImportant,
forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel), forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel),
// TODO: 05/05/2024, Danil Nikolaev: restore attachments // TODO: 05/05/2024, Danil Nikolaev: restore attachments
attachments = attachments.orEmpty().map { VkUnknownAttachment }, attachments = attachments.orEmpty().map { VkUnknownAttachment },
@@ -59,4 +61,5 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
isPinned = isPinned, isPinned = isPinned,
isSpam = false, isSpam = false,
formatData = null, formatData = null,
isDeleted = isDeleted
) )
+1
View File
@@ -15,6 +15,7 @@ dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
api(projects.core.datastore) api(projects.core.datastore)
api(projects.core.logger)
implementation(libs.moshi.kotlin) implementation(libs.moshi.kotlin)
implementation(libs.koin.android) implementation(libs.koin.android)
@@ -1,10 +1,10 @@
package dev.meloda.fast.network package dev.meloda.fast.network
import android.util.Log
import com.slack.eithernet.ApiException import com.slack.eithernet.ApiException
import com.slack.eithernet.errorType import com.slack.eithernet.errorType
import com.slack.eithernet.toType import com.slack.eithernet.toType
import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonDataException
import dev.meloda.fast.logger.FastLogger
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Converter import retrofit2.Converter
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -16,7 +16,10 @@ import java.lang.reflect.Type
* *
* допускает Unit как SuccessType в случае невозможности каста ответа в ErrorType * допускает Unit как SuccessType в случае невозможности каста ответа в ErrorType
*/ */
class ResponseConverterFactory(private val converter: JsonConverter) : Converter.Factory() { class ResponseConverterFactory(
private val converter: JsonConverter,
private val logger: FastLogger
) : Converter.Factory() {
override fun responseBodyConverter( override fun responseBodyConverter(
type: Type, type: Type,
@@ -29,6 +32,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
successType = type, successType = type,
errorRaw = errorRaw, errorRaw = errorRaw,
converter = converter, converter = converter,
logger = logger
) )
} }
@@ -36,6 +40,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
private val successType: Type, private val successType: Type,
private val errorRaw: Class<*>, private val errorRaw: Class<*>,
private val converter: JsonConverter, private val converter: JsonConverter,
private val logger: FastLogger
) : Converter<ResponseBody, Any?> { ) : Converter<ResponseBody, Any?> {
override fun convert(value: ResponseBody): Any? { override fun convert(value: ResponseBody): Any? {
val string = value.string() val string = value.string()
@@ -53,7 +58,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
}, },
onFailure = { failure -> onFailure = { failure ->
if (failure is JsonDataException) { if (failure is JsonDataException) {
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure") logger.error(this::class, "convert(): ERROR", failure)
throw ApiException( throw ApiException(
RestApiError( RestApiError(
errorCode = -1, errorCode = -1,
@@ -68,10 +73,11 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
converter.fromJson(errorRaw, string) converter.fromJson(errorRaw, string)
}.fold( }.fold(
onSuccess = { errorModel -> onSuccess = { errorModel ->
Log.d("ResponseBodyConverter", "convert: $errorModel") logger.debug(this::class, "convert(): errorModel: $errorModel")
throw ApiException(errorModel) throw ApiException(errorModel)
}, },
onFailure = { exception -> onFailure = { exception ->
logger.error(this::class, "convert(): INNER: ERROR", exception)
if (!isUnit) { if (!isUnit) {
throw exception throw exception
} else { } else {
@@ -1,62 +1,88 @@
package dev.meloda.fast.network.interceptor package dev.meloda.fast.network.interceptor
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.minutes
class Error14HandlingInterceptor : Interceptor { class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val executor = Executors.newSingleThreadExecutor()
}
private val cookie = AtomicReference<String?>(null) private val cookie = AtomicReference<String?>(null)
private val captchaMutex = Mutex()
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie() val request = chain.request().withCookie()
val response = chain.proceed(request) val response = chain.proceed(request)
response.parseCookie() response.parseCookie()
if (request.shouldSkipCaptcha()) return response if (request.shouldSkipCaptcha()) return response
val redirectUri = response.getRedirectUri() ?: return response val redirectUri = response.getRedirectUri() ?: return response
val token = awaitCaptchaToken(redirectUri) val token = passCaptchaAndGetToken(redirectUri)
return chain.proceed(chain.request().withCookie().withSuccessToken(token)) return chain.proceed(chain.request().withCookie().withSuccessToken(token))
} }
private fun awaitCaptchaToken(redirectUri: String): String = runBlocking(Dispatchers.IO) { private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) {
captchaMutex.withLock { val tokenResult = AtomicReference<Result<String>>(Result.failure(Exception("No result")))
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
AppSettings.setCaptchaRedirectUri(redirectUri)
try { executor.submit {
withTimeout(CAPTCHA_TIMEOUT) { AppSettings.setCaptchaRedirectUri(redirectUri)
AppSettings.getCaptchaResultFlow() logger.debug(this::class, "passCaptchaAndGetToken: $redirectUri")
.first { it != CaptchaTokenResult.Initial }
.toToken() var job: Job? = null
job = AppSettings.getCaptchaResultFlow()
.listenValue(CoroutineScope(Dispatchers.IO)) {
logger.debug(this::class, "passCaptchaAndGetToken: $it")
if (it != CaptchaTokenResult.Initial) {
synchronized(tokenResult) {
logger.debug(
this::class,
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
)
tokenResult.set(wrapResult(it))
tokenResult.notifyAll()
job?.cancel()
logger.debug(
this::class,
"passCaptchaAndGetToken: NULL RESULT"
)
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
AppSettings.setCaptchaRedirectUri(null)
}
}
} }
} finally { }
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial) synchronized(tokenResult) {
AppSettings.setCaptchaRedirectUri(null) if (tokenResult.get().getOrNull() == null) {
tokenResult.wait()
} }
logger.debug(this::class, "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow()
} }
} }
private fun CaptchaTokenResult.toToken(): String = when (this) { private fun wrapResult(result: CaptchaTokenResult): Result<String> {
is CaptchaTokenResult.Success -> token return when (result) {
CaptchaTokenResult.Cancelled -> throw IOException("Captcha cancelled") // TODO: 03/05/2026, Danil Nikolaev: check again?
CaptchaTokenResult.Null -> throw IOException("Captcha result is empty") CaptchaTokenResult.Null -> Result.success("")
CaptchaTokenResult.Initial -> throw IllegalStateException("Captcha result not ready")
CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("")
is CaptchaTokenResult.Success -> Result.success(result.token)
}
} }
private fun Request.withSuccessToken(token: String): Request { private fun Request.withSuccessToken(token: String): Request {
@@ -105,10 +131,13 @@ class Error14HandlingInterceptor : Interceptor {
private fun Request.withCookie(): Request { private fun Request.withCookie(): Request {
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build() return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
} }
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val CAPTCHA_TIMEOUT = 10.minutes
}
} }
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.wait() = (this as Object).wait()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notify() = (this as Object).notify()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notifyAll() = (this as Object).notifyAll()
@@ -2,7 +2,6 @@ package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkChatData import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
@@ -45,12 +44,6 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<VkLongPollData>, RestApiError> ): ApiResult<ApiResponse<VkLongPollData>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_LONG_POLL_HISTORY)
suspend fun getLongPollHistory(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<LongPollHistoryResponse>, RestApiError>
@FormUrlEncoded @FormUrlEncoded
@POST(MessagesUrls.MARK_AS_READ) @POST(MessagesUrls.MARK_AS_READ)
suspend fun markAsRead( suspend fun markAsRead(
+1
View File
@@ -12,6 +12,7 @@ android {
dependencies { dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
api(projects.core.logger)
implementation(projects.core.presentation) implementation(projects.core.presentation)
implementation(libs.haze) implementation(libs.haze)
@@ -0,0 +1,6 @@
package dev.meloda.fast.ui.common
import androidx.compose.runtime.compositionLocalOf
import dev.meloda.fast.logger.FastLogger
val LocalLogger = compositionLocalOf { FastLogger.getInstance() }
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -30,27 +33,32 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
containerColor: Color = colors.containerColor(enabled),
contentColor: Color = colors.contentColor(enabled),
size: Dp = IconButtonTokens.StateLayerSize,
shape: Shape = IconButtonTokens.StateLayerShape,
alignment: Alignment = Alignment.Center,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Box( Box(
modifier = modifier =
modifier modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize) .size(size)
.clip(IconButtonTokens.StateLayerShape) .clip(shape)
.background(color = colors.containerColor(enabled)) .background(containerColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = ripple() indication = indication
), ),
contentAlignment = Alignment.Center contentAlignment = alignment
) { ) {
val contentColor = colors.contentColor(enabled) CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
} }
} }
@@ -0,0 +1,116 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.util.ImmutableList
data class SegmentedButtonItem(
val key: String,
val iconResId: Int
)
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<SegmentedButtonItem>,
onClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
iconContainerWidth: Dp = 42.dp,
iconContainerHeight: Dp = 36.dp,
iconSize: Dp = 18.dp,
showDividers: Boolean = true
) {
SegmentedButtonsRow(
modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize),
items = items.mapIndexed { index, item ->
{
val first = index == 0
val last = index == items.lastIndex
if (showDividers && !first) {
VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4))
}
SegmentedButton(
onClick = { onClick(index) },
iconResId = item.iconResId,
modifier = Modifier.size(
iconContainerWidth,
iconContainerHeight
),
iconSize = iconSize,
shape = containerShape.copy(
topStart = if (!first) CornerSize(0.dp) else containerShape.topStart,
bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart,
topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd,
bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd
)
)
}
},
containerShape = containerShape,
containerColor = containerColor,
borderColor = borderColor,
borderSize = borderSize
)
}
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<@Composable () -> Unit>,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
) {
Row(
modifier = modifier
.background(containerColor, containerShape)
.border(borderSize, borderColor, containerShape)
) {
items.forEach { it.invoke() }
}
}
@Composable
fun SegmentedButton(
onClick: () -> Unit,
iconResId: Int,
modifier: Modifier = Modifier,
iconSize: Dp = 18.dp,
shape: Shape = CircleShape
) {
FastIconButton(
onClick = onClick,
modifier = modifier,
shape = shape
) {
Icon(
modifier = Modifier.size(iconSize),
painter = painterResource(iconResId),
contentDescription = null
)
}
}
@@ -58,7 +58,7 @@ fun AppTheme(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val colorScheme: ColorScheme = when { val colorScheme: ColorScheme = predefinedColorScheme ?: when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context) else dynamicLightColorScheme(context)
@@ -82,10 +82,6 @@ fun AppTheme(
} }
} }
val colorPrimary by animateColorAsState(colorScheme.primary)
val colorSurface by animateColorAsState(colorScheme.surface)
val colorBackground by animateColorAsState(colorScheme.background)
val typography = if (useSystemFont) { val typography = if (useSystemFont) {
MaterialTheme.typography MaterialTheme.typography
} else { } else {
@@ -118,12 +114,7 @@ fun AppTheme(
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme) colorScheme = colorScheme,
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
typography = typography, typography = typography,
content = content content = content
) )
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -113,11 +114,12 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable @Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean { fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = context.resources.configuration.uiMode val systemUiNightMode = configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme = val isSystemUsingDarkTheme =
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable @Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> { class ImmutableList<T>(val values: List<T>) : Collection<T> {
@@ -57,3 +58,9 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList()) fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
inline fun <T> buildImmutableList(builderAction: MutableList<T>.() -> Unit): ImmutableList<T> {
val mutableList = mutableListOf<T>()
mutableList.apply(builderAction)
return mutableList.toImmutableList()
}
+4
View File
@@ -88,6 +88,7 @@
<string name="message_attachments_video_message">Video message</string> <string name="message_attachments_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</string> <string name="message_attachments_group_sticker">Group sticker</string>
<string name="message_attachments_sticker_pack_preview">Sticker pack preview</string> <string name="message_attachments_sticker_pack_preview">Sticker pack preview</string>
<string name="message_attachments_channel_message">Channel message</string>
<string name="chat_interaction_uploading_file">Uploading file</string> <string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string> <string name="chat_interaction_uploading_photo">Uploading photo</string>
@@ -305,4 +306,7 @@
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string> <string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string> <string name="title_edit_message">Edit message</string>
<string name="action_close">Close</string>
<string name="action_hide_stacktrace">Hide stacktrace</string>
<string name="action_show_stacktrace">Show stacktrace</string>
</resources> </resources>
+9
View File
@@ -2,4 +2,13 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" /> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="CrashDialogTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources> </resources>
@@ -1,7 +1,6 @@
package dev.meloda.fast.auth.captcha.presentation package dev.meloda.fast.auth.captcha.presentation
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
@@ -32,7 +31,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.FullScreenDialog import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@@ -46,6 +47,8 @@ fun CaptchaScreen(
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onResult: (String) -> Unit = {} onResult: (String) -> Unit = {}
) { ) {
val logger = LocalLogger.current
if (captchaRedirectUri != null) { if (captchaRedirectUri != null) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -114,7 +117,10 @@ fun CaptchaScreen(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
): Boolean { ): Boolean {
Log.i(TAG, "shouldOverrideUrlLoading: $request") logger.info(
"CaptchaScreen",
"WebViewClient(): shouldOverrideUrlLoading(): request: $request"
)
return false return false
} }
@@ -176,19 +182,18 @@ fun CaptchaScreen(
class WebCaptchaListener( class WebCaptchaListener(
private val onSuccessTokenReceived: (String) -> Unit, private val onSuccessTokenReceived: (String) -> Unit,
private val onCloseRequested: (String) -> Unit private val onCloseRequested: (String) -> Unit,
private val logger: FastLogger
) { ) {
private val tag = "WebCaptchaListener"
@JavascriptInterface @JavascriptInterface
fun VKCaptchaGetResult(arg: String) { fun VKCaptchaGetResult(arg: String) {
onSuccessTokenReceived(arg) onSuccessTokenReceived(arg)
Log.i(tag, "VKCaptchaGetResult($arg)") logger.info(this::class, "VKCaptchaGetResult(): arg: $arg")
} }
@JavascriptInterface @JavascriptInterface
fun VKCaptchaCloseCaptcha(arg: String) { fun VKCaptchaCloseCaptcha(arg: String) {
onCloseRequested(arg) onCloseRequested(arg)
Log.i(tag, "VKCaptchaCloseCaptcha($arg)") logger.info(this::class, "VKCaptchaCloseCaptcha(): arg: $arg")
} }
} }
@@ -2,7 +2,6 @@ package dev.meloda.fast.auth.login
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
@@ -28,6 +27,7 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.network.OAuthErrorDomain import dev.meloda.fast.network.OAuthErrorDomain
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -48,7 +48,8 @@ class LoginViewModel(
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator, private val loginValidator: LoginValidator,
private val longPollController: LongPollController, private val longPollController: LongPollController,
private val userSettings: UserSettings private val userSettings: UserSettings,
private val logger: FastLogger
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY) private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow() val screenState = _screenState.asStateFlow()
@@ -100,7 +101,13 @@ class LoginViewModel(
} }
fun onBackPressed() { fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old ->
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
} }
fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
@@ -183,7 +190,7 @@ class LoginViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") logger.error(this::class, "getSilentToken(): ERROR: $error")
_screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
@@ -31,9 +31,13 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
@@ -58,7 +62,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
@@ -67,6 +70,8 @@ import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.ClassicColorScheme
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -114,17 +119,27 @@ fun LoginRoute(
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
LoginScreen( var useClassic by rememberSaveable { mutableStateOf(true) }
screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged, AppTheme(
onPasswordInputChanged = viewModel::onPasswordInputChanged, predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, else lightColorScheme(),
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, ) {
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, LoginScreen(
onSignInButtonClicked = viewModel::onSignInButtonClicked, screenState = screenState,
onLogoClicked = viewModel::onLogoClicked, onLoginInputChanged = viewModel::onLoginInputChanged,
onLogoLongClicked = onNavigateToSettings onPasswordInputChanged = viewModel::onPasswordInputChanged,
) onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings
)
}
HandleDialogs( HandleDialogs(
loginDialog = loginDialog, loginDialog = loginDialog,
@@ -9,16 +9,12 @@ import dev.meloda.fast.chatmaterials.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -93,14 +89,11 @@ class ChatMaterialsViewModelImpl(
state.processState( state.processState(
error = ::handleError, error = ::handleError,
success = { response -> success = { response ->
val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size) val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = isPaginationExhaustedPage( val paginationExhausted = !itemsCountSufficient
pageSize = LOAD_COUNT, && screenState.value.materials.isNotEmpty()
loadedCount = response.size,
hasExistingItems = screenState.value.materials.isNotEmpty()
)
val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation) val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation)
@@ -114,27 +107,64 @@ class ChatMaterialsViewModelImpl(
} }
) )
screenState.setValue { if (offset == 0) {
newState.copy( screenState.setValue {
materials = mergePage(newState.materials, loadedMaterials, offset) newState.copy(materials = loadedMaterials)
) }
} else {
screenState.setValue {
newState.copy(
materials = newState.materials.plus(loadedMaterials)
)
}
} }
} }
) )
val flags = loadingFlags(offset, state.isLoading())
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
isLoading = flags.isLoading, isLoading = offset == 0 && state.isLoading(),
isPaginating = flags.isPaginating isPaginating = offset > 0 && state.isLoading()
) )
} }
} }
} }
private fun handleError(error: State.Error) { private fun handleError(error: State.Error) {
VkUtils.parseError(error)?.let { newBaseError -> when (error) {
baseError.setValue { newBaseError } is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
} }
} }
@@ -1,6 +1,5 @@
package dev.meloda.fast.chatmaterials.util package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
@@ -135,8 +134,5 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
) )
} }
else -> { else -> null
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
} }
@@ -3,210 +3,229 @@ package dev.meloda.fast.convos
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.updateValue import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.LongPollUpdatesReducer import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Immutable
class ConvosViewModel( class ConvosViewModel(
updatesReducer: LongPollUpdatesReducer, updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter, val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val applicationContext: Context private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null) private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val navigation = _navigation.asStateFlow() val screenStateFlow get() = screenState.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null) private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val dialog = _dialog.asStateFlow() val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList()) private val convos: MutableList<VkConvo> = mutableListOf()
val convos = _convos.asStateFlow()
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList()) private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
val uiConvos = _uiConvos.asStateFlow()
private val pinnedConvosCount = convos.map { convos -> private var currentOffset = 0
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos() loadConvos()
updatesReducer.newMessages.onEach(::handleNewMessage).launchIn(viewModelScope) updatesParser.onNewMessage(::handleNewMessage)
updatesReducer.messageEdited.onEach(::handleEditedMessage).launchIn(viewModelScope) updatesParser.onMessageEdited(::handleEditedMessage)
updatesReducer.messageIncomingRead.onEach(::handleReadIncomingMessage).launchIn(viewModelScope) updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesReducer.messageOutgoingRead.onEach(::handleReadOutgoingMessage).launchIn(viewModelScope) updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesReducer.interactions.onEach(::handleInteraction).launchIn(viewModelScope) updatesParser.onInteractions(::handleInteraction)
updatesReducer.chatMajorChanged.onEach(::handleChatMajorChanged).launchIn(viewModelScope) updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesReducer.chatMinorChanged.onEach(::handleChatMinorChanged).launchIn(viewModelScope) updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesReducer.chatCleared.onEach(::handleChatClearing).launchIn(viewModelScope) updatesParser.onChatCleared(::handleChatClearing)
updatesReducer.chatArchived.onEach(::handleChatArchived).launchIn(viewModelScope) updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) { userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos() syncUiConvos()
} }
} }
fun onNavigationConsumed() { fun handleIntent(intent: ConvoIntent) {
_navigation.setValue { null } when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
} }
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) { fun onNavigationConsumed() {
onDialogDismissed(dialog) navigationIntent.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) { when (dialog) {
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
deleteConvo(dialog.convoId) deleteConvo(convo.id)
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
pinConvo(dialog.convoId, true) pinConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
pinConvo(dialog.convoId, false) pinConvo(convo.id, false)
} }
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
archiveConvo(dialog.convoId, true) archiveConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
archiveConvo(dialog.convoId, false) archiveConvo(convo.id, false)
} }
} }
expandedConvoId.setValue { 0 } collapseConvos(false)
syncUiConvos() syncUiConvos()
} }
fun onDialogDismissed(dialog: ConvoDialog) { private fun onDialogDismissed() {
_dialog.setValue { null } screenState.updateValue { copy(dialog = null) }
} }
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) { private fun onPaginationConditionsMet() {
when (dialog) { currentOffset = convos.size
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos() loadConvos()
} }
fun onRefresh() { private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConvos(offset = 0) loadConvos(offset = 0)
} }
fun onConvoItemClick(convo: UiConvo) { private fun onConvoItemClick(convoId: Long) {
collapseConvos() collapseConvos()
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) } navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
} }
fun onConvoItemLongClick(convo: UiConvo) { private fun onConvoItemLongClick(convoId: Long) {
expandedConvoId.setValue { val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
if (convo.isExpanded) 0
else convo.id screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
}
syncUiConvos() syncUiConvos()
} }
fun onOptionClicked( private fun onOptionClicked(option: ConvoOption) {
convo: UiConvo, val convo =
option: ConvoOption screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
) {
when (option) { when (option) {
ConvoOption.Delete -> { ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> { ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId -> val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo( readConvo(
peerId = convo.id, peerId = convo.id,
startMessageId = lastMessageId startMessageId = lastMessageId
@@ -215,52 +234,39 @@ class ConvosViewModel(
} }
} }
ConvoOption.Pin -> { ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) } ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
} ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
} }
} }
fun onErrorConsumed() { private fun setScrollIndex(index: Int) {
_baseError.setValue { null } screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
fun setScrollIndex(index: Int) { private fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
fun setScrollOffset(offset: Int) { private fun setDialog(dialog: ConvoDialog?) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.updateValue { copy(dialog = dialog) }
} }
fun onCreateChatButtonClicked() { private fun replaceConvos(newConvos: List<VkConvo>) {
_navigation.setValue { ConvoNavigation.CreateChat } convos.clear()
convos.addAll(newConvos)
} }
private fun collapseConvos() { private fun collapseConvos(sync: Boolean = true) {
expandedConvoId.setValue { 0 } screenState.updateValue { copy(expandedConvoId = null) }
syncUiConvos()
}
private fun loadConvos( if (sync) {
offset: Int = currentOffset.value syncUiConvos()
) {
if (offset == 0) {
refreshConvosFromDb()
} }
}
private fun loadConvos(offset: Int = currentOffset) {
convoUseCase.getConvos( convoUseCase.getConvos(
count = LOAD_COUNT, count = LOAD_COUNT,
offset = offset, offset = offset,
@@ -268,18 +274,20 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
val newBaseError = VkUtils.parseError(error) screenState.updateValue { copy(error = VkUtils.parseError(error)) }
_baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size) val newConvos = if (offset == 0) {
val paginationExhausted = isPaginationExhaustedPage( response
pageSize = LOAD_COUNT, } else {
loadedCount = response.size, convos.plus(response)
hasExistingItems = this.convos.value.isNotEmpty() }
)
_screenState.updateValue { val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
} }
@@ -296,17 +304,17 @@ class ConvosViewModel(
convoUseCase.storeConvos(response) convoUseCase.storeConvos(response)
_convos.emit(mergePage(this.convos.value, response, offset)) replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos() syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
} }
) )
val flags = loadingFlags(offset, state.isLoading()) screenState.setValue { old ->
_screenState.setValue { old ->
old.copy( old.copy(
isLoading = flags.isLoading, isLoading = offset == 0 && state.isLoading(),
isPaginating = flags.isPaginating isPaginating = offset > 0 && state.isLoading()
) )
} }
} }
@@ -317,13 +325,17 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
viewModelScope.launch { val newConvos = convos.toMutableList()
convoUseCase.deleteLocalConvo(peerId) val convoIndex =
refreshConvosFromDb() newConvos.indexOfFirstOrNull { it.id == peerId }
} ?: return@processState
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
syncUiConvos()
} }
) )
_screenState.emit(screenState.value.copy(isLoading = state.isLoading())) screenState.emit(screenStateFlow.value.copy(isLoading = state.isLoading()))
} }
} }
@@ -333,26 +345,20 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
viewModelScope.launch { handleChatMajorChanged(
convoUseCase.getLocalConvoById(peerId)?.let { convo -> LongPollParsedEvent.ChatMajorChanged(
convoUseCase.storeConvos( peerId = peerId,
listOf( majorId = if (pin) {
convo.copy( pinnedConvosCount.plus(1) * 16
majorId = if (pin) { } else {
pinnedConvosCount.value.plus(1) * 16 0
} else { }
0 )
} )
)
)
)
}
refreshConvosFromDb()
}
} }
) )
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
} }
} }
@@ -362,35 +368,144 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
viewModelScope.launch { convos.find { it.id == peerId }?.let { convo ->
convoUseCase.getLocalConvoById(peerId)?.let { convo -> handleChatArchived(
convoUseCase.storeConvos( LongPollParsedEvent.ChatArchived(
listOf( convo = convo,
convo.copy(isArchived = archive) archived = archive
)
) )
} )
refreshConvosFromDb()
} }
} }
) )
} }
} }
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
refreshConvosFromDb() val message = event.message
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) {
if (event.inArchive != (filter == ConvosFilter.ARCHIVE)) return
loadConvosByIdUseCase(
peerIds = listOf(message.peerId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount, convo)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
}
} else {
val convo = newConvos[convoIndex]
var newConvo = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = if (message.isOut) convo.unreadCount
else convo.unreadCount + 1
)
interactionsTimers[convo.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in convo.interactionIds
) {
val newInteractionIds = newConvo.interactionIds.filter { id ->
id != message.fromId
}
newConvo = newConvo.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConvo.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (convo.isPinned()) {
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
newConvos.add(pinnedConvosCount, newConvo)
}
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
refreshConvosFromDb() val message = event.message
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
val convo = newConvos[convoIndex]
newConvos[convoIndex] = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) { private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
refreshConvosFromDb() val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) { private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
refreshConvosFromDb() val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleInteraction(event: LongPollParsedEvent.Interaction) { private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
@@ -398,7 +513,7 @@ class ConvosViewModel(
val peerId = event.peerId val peerId = event.peerId
val userIds = event.userIds val userIds = event.userIds
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } newConvos.findWithIndex { it.id == peerId }
@@ -409,7 +524,7 @@ class ConvosViewModel(
interactionIds = userIds interactionIds = userIds
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -441,7 +556,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) { private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return interactionsTimers[peerId] ?: return
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return newConvos.findWithIndex { it.id == peerId } ?: return
@@ -451,7 +566,7 @@ class ConvosViewModel(
interactionIds = emptyList() interactionIds = emptyList()
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -459,19 +574,89 @@ class ConvosViewModel(
} }
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
refreshConvosFromDb() val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
refreshConvosFromDb() val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
refreshConvosFromDb() val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
// TODO: 30.05.2026, Danil Nikolaev: reimplement
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) { private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
refreshConvosFromDb() val convo = event.convo
val newConvos = convos.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> {
if (event.archived) {
newConvos.add(0, convo)
} else {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
}
replaceConvos(newConvos)
syncUiConvos()
}
else -> {
if (event.archived) {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount, convo)
}
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
} }
private fun readConvo(peerId: Long, startMessageId: Long) { private fun readConvo(peerId: Long, startMessageId: Long) {
@@ -482,42 +667,21 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
viewModelScope.launch { val newConvos = convos.toMutableList()
convoUseCase.getLocalConvoById(peerId)?.let { convo -> val convoIndex =
convoUseCase.storeConvos( newConvos.indexOfFirstOrNull { it.id == peerId }
listOf( ?: return@listenValue
convo.copy(
inRead = startMessageId, newConvos[convoIndex] =
unreadCount = 0 newConvos[convoIndex].copy(inRead = startMessageId)
)
) replaceConvos(newConvos)
) syncUiConvos()
}
refreshConvosFromDb()
}
} }
) )
} }
} }
private fun refreshConvosFromDb() {
viewModelScope.launchDbRefresh(
load = {
val localConvos = convoUseCase.getLocalConvos()
val filteredConvos = when (filter) {
ConvosFilter.ARCHIVE -> localConvos.filter(VkConvo::isArchived)
ConvosFilter.UNREAD -> localConvos.filter { !it.isArchived && it.unreadCount > 0 }
ConvosFilter.ALL -> localConvos.filterNot(VkConvo::isArchived)
ConvosFilter.BUSINESS_NOTIFY -> localConvos
}
_convos.emit(filteredConvos)
},
after = ::syncUiConvos
)
}
private fun List<VkConvo>.sorted(): List<VkConvo> { private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList() val newConvos = toMutableList()
@@ -543,47 +707,44 @@ class ConvosViewModel(
} }
private fun syncUiConvos(): List<UiConvo> { private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo -> val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>() val options: ImmutableList<ConvoOption> = buildImmutableList {
convo.lastMessage?.run { if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
if (!convo.isRead() && !this.isOut) { add(ConvoOption.MarkAsRead)
options += ConvoOption.MarkAsRead
} }
if (convo.isPinned()) {
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
}
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
add(ConvoOption.Delete)
} }
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation( convo.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames, useContactName = userSettings.useContactNames.value,
isExpanded = expandedConvoId.value == convo.id, isExpanded = screenState.value.expandedConvoId == convo.id,
options = options.toImmutableList() options = options
) )
} }
_uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos return newUiConvos
} }
@@ -31,6 +31,7 @@ private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
resources = get(), resources = get(),
userSettings = get(), userSettings = get(),
imageLoader = get(), imageLoader = get(),
applicationContext = get() applicationContext = get(),
loadConvosByIdUseCase = get()
) )
} }
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed class ConvoDialog { sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog() data object Pin : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog() data object Unpin : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog() data object Delete : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog() data object Archive : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog() data object Unarchive : ConvoDialog()
} }
@@ -0,0 +1,29 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,6 +1,10 @@
package dev.meloda.fast.convos.model package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable @Immutable
data class ConvosScreenState( data class ConvosScreenState(
@@ -10,7 +14,13 @@ data class ConvosScreenState(
val profileImageUrl: String?, val profileImageUrl: String?,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val isArchive: Boolean val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) { ) {
companion object { companion object {
@@ -21,7 +31,11 @@ data class ConvosScreenState(
profileImageUrl = null, profileImageUrl = null,
scrollIndex = 0, scrollIndex = 0,
scrollOffset = 0, scrollOffset = 0,
isArchive = false canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
) )
} }
} }
@@ -1,12 +1,16 @@
package dev.meloda.fast.convos.navigation package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
@@ -24,44 +28,56 @@ object Convos
object Archive object Archive
fun NavGraphBuilder.convosGraph( fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity, activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) { ) {
navigation<ConvoGraph>( navigation<ConvoGraph>(
startDestination = Convos startDestination = Convos
) { ) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> { composable<Convos> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute( viewModel = with(activity) {
viewModel = convosViewModel, getViewModel(named(ConvosFilter.ALL))
onError = onError, }
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
) )
} }
composable<Archive> { composable<Archive> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute(
viewModel = with(activity) { viewModel = with(activity) {
getViewModel<ConvosViewModel>( getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
qualifier = named(ConvosFilter.ARCHIVE) }
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
) )
} }
} }
} }
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,82 +1,78 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
when (dialog) { when (screenState.dialog) {
null -> Unit null -> Unit
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_archive_convo), title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_archive), confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
) )
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_unarchive_convo), title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unarchive), confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
) )
} }
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
val errorColor = MaterialTheme.colorScheme.error val errorColor = MaterialTheme.colorScheme.error
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor, iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo), title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_delete), confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor, confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo), title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_pin), confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo), title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unpin), confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ConvoItem( fun ConvoItem(
onItemClick: (UiConvo) -> Unit, onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit, onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
maxLines: Int, maxLines: Int,
isUserAccount: Boolean, isUserAccount: Boolean,
convo: UiConvo, convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onItemClick(convo) }, onClick = { onItemClick(convo.id) },
onLongClick = { onLongClick = {
onItemLongClick(convo) onItemLongClick(convo.id)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
} }
) )
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder = val builder =
AnnotatedString.Builder(convo.message.text) AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange -> convo.message.spanStyles.forEach { spanStyleRange ->
val updatedSpanStyle = val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) { if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary) spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
} }
ElevatedAssistChip( ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) }, onClick = { onOptionClicked(option) },
leadingIcon = { leadingIcon = {
option.icon.getResourcePainter()?.let { painter -> option.icon.getResourcePainter()?.let { painter ->
Icon( Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList( fun ConvosList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>, convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit, onConvosClick: (Long) -> Unit,
onConvosLongClick: (UiConvo) -> Unit, onConvosLongClick: (Long) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
state: LazyListState, state: LazyListState,
maxLines: Int, maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
padding: PaddingValues padding: PaddingValues
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
@@ -1,79 +1,23 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import dev.meloda.fast.convos.model.ConvoIntent
import androidx.compose.runtime.getValue import dev.meloda.fast.convos.model.ConvosScreenState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun ConvosRoute( fun ConvosRoute(
viewModel: ConvosViewModel, handleIntent: (ConvoIntent) -> Unit,
onBack: (() -> Unit)? = null, screenState: ConvosScreenState,
onError: (BaseError) -> Unit, isArchive: Boolean,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen( ConvosScreen(
onBack = { onBack?.invoke() }, handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
convos = convos.toImmutableList(), isArchive = isArchive,
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
) )
HandleDialogs( HandleDialogs(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
} }

Some files were not shown because too many files have changed in this diff Show More