1 Commits

Author SHA1 Message Date
melod1n 82fb78e9ea Release 0.2.0 (#150)
Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 21:47:05 +03:00
445 changed files with 8545 additions and 14245 deletions
+20 -34
View File
@@ -1,10 +1,10 @@
name: Android CI Build name: Android CI Build
on: on:
workflow_dispatch: push:
branches: [ "dev", "release/*", "hotfix/*" ]
permissions: pull_request:
contents: read branches: [ "dev", "release/*", "hotfix/*" ]
env: env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -12,15 +12,15 @@ env:
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }} RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs: jobs:
build_apks: build_apk_aab:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
name: Build artifacts name: Build artifacts
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: set up JDK 21 - name: set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@@ -29,34 +29,20 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew 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" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK - name: Build and sign debug APK
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Find generated debug APK name - name: Upload debug APK
id: find_apk_debug uses: actions/upload-artifact@v4
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
with: with:
name: ${{ env.APK_NAME }} name: app-debug.apk
path: ${{ env.APK_PATH }} path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
+24 -9
View File
@@ -1,11 +1,8 @@
name: Android CI Release name: Android CI Release
permissions:
contents: read
on: on:
workflow_dispatch: pull_request:
push: branches: [ "master" ]
branches: [ "release/*"]
env: env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -18,10 +15,10 @@ jobs:
name: Build artifacts name: Build artifacts
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- name: set up JDK 21 - name: set up JDK 21
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@@ -30,20 +27,38 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK - name: Build and sign release APK
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload release APK - name: Upload release APK
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: app-release.apk name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk path: app/build/outputs/apk/release/app-release.apk
- name: Build and sign debug Bundle
run: ./gradlew bundleDebug
- name: Upload debug Bundle
uses: actions/upload-artifact@v4
with:
name: app-debug.aab
path: app/build/outputs/bundle/debug/app-debug.aab
- name: Build and sign release Bundle - name: Build and sign release Bundle
run: ./gradlew bundleRelease run: ./gradlew bundleRelease
- name: Upload release Bundle - name: Upload release Bundle
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: app-release.aab name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/release/app-release.aab
+12 -21
View File
@@ -7,17 +7,15 @@ Unofficial messenger for russian social network VKontakte
- [x] 2FA support - [x] 2FA support
- [x] Resend otp - [x] Resend otp
- [x] Captcha support - [x] Captcha support
- [x] Support for new authorization with service and refresh tokens - [ ] Support for new authorization with service and refresh tokens
- [ ] Handle token expiration
- [x] Ability to export/import tokens
- [x] Conversations list - [x] Conversations list
- [x] Pagination - [x] Pagination
- [x] Manual refresh - [x] Manual refresh
- [x] Pin & unpin conversations - [x] Pin & unpin conversations
- [x] Delete conversations - [x] Delete conversations
- [x] Archive - [ ] Archive
- [x] View archived conversations - [ ] View archived conversations
- [x] Archive & unarchive conversations - [ ] Archive & unarchive conversations
- [x] Friends list - [x] Friends list
- [x] Sort alphabetically, by priority or random - [x] Sort alphabetically, by priority or random
- [x] Separate tab with only friends who are online - [x] Separate tab with only friends who are online
@@ -32,24 +30,17 @@ Unofficial messenger for russian social network VKontakte
- [x] Read status - [x] Read status
- [x] Edit status - [x] Edit status
- [x] Sending status - [x] Sending status
- [x] Message's attachments - [ ] Message's attachments
- [x] Photo - [ ] Photo
- [x] Video - [ ] Video
- [x] Audio - [ ] Audio
- [x] File - [ ] File
- [x] Link - [ ] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
- [x] Pinned message - [x] Pinned message
- [x] Pin & unpin messages - [x] Pin & unpin messages
- [x] Reply to message - [ ] Reply to message
- [x] Swipe to reply to message
- [x] Delete message - [x] Delete message
- [x] Select multiple messages - [x] Select multiple messages
- [x] Delete - [x] Delete
@@ -64,7 +55,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments - [x] View attachments
- [x] Open photo - [x] Open photo
- [x] Internal viewer - [x] Internal viewer
- [x] External viewer - [ ] External viewer
- [ ] Open video in external player - [ ] Open video in external player
- [ ] TODO - [ ] TODO
- [ ] Caching - [ ] Caching
-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.
+3 -19
View File
@@ -1,4 +1,3 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -13,8 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "dev.meloda.fastvk" applicationId = "dev.meloda.fastvk"
versionCode = 11 versionCode = libs.versions.versionCode.get().toInt()
versionName = "0.2.3" versionName = libs.versions.versionName.get()
} }
signingConfigs { signingConfigs {
@@ -59,18 +58,6 @@ android {
} }
} }
// applicationVariants.all {
// outputs.all {
// val date = System.currentTimeMillis() / 1000
// val buildType = buildType.name
// val appVersion = versionName
// val appVersionCode = versionCode
//
// val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
// (this as? BaseVariantOutputImpl)?.outputFileName = newApkName
// }
// }
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -79,13 +66,10 @@ 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)
implementation(projects.feature.convos) implementation(projects.feature.conversations)
implementation(projects.feature.languagepicker) implementation(projects.feature.languagepicker)
implementation(projects.feature.messageshistory) implementation(projects.feature.messageshistory)
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
Binary file not shown.
@@ -2,6 +2,7 @@ package dev.meloda.fast
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
@@ -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()
@@ -74,7 +73,6 @@ class MainViewModelImpl(
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 +82,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,15 +89,9 @@ 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 +203,10 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase() val currentAccount = getCurrentAccountUseCase()
Log.d("MainViewModel", "currentAccount: $currentAccount")
listenLongPollState()
if (currentAccount != null) { if (currentAccount != null) {
UserConfig.apply { UserConfig.apply {
this.userId = currentAccount.userId this.userId = currentAccount.userId
@@ -4,14 +4,8 @@ import android.app.Application
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
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 org.acra.config.mailSender
import org.acra.data.StringFormat
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
@@ -24,14 +18,10 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences) AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra()
} }
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() { private fun initKoin() {
startKoin { startKoin {
androidLogger() androidLogger()
@@ -40,21 +30,5 @@ class AppGlobal : Application(), ImageLoaderFactory {
} }
} }
private fun initAcra() { override fun newImageLoader(): ImageLoader = get()
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
mailSender {
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
}
dialog {
text = "App crashed"
enabled = true
}
}
}
} }
@@ -5,17 +5,18 @@ import android.content.res.Resources
import android.os.PowerManager import android.os.PowerManager
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authModule import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl import dev.meloda.fast.common.LongPollControllerImpl
import dev.meloda.fast.common.provider.Provider import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.convos.di.convosModule import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.convos.di.createChatModule import dev.meloda.fast.conversations.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
@@ -32,12 +33,13 @@ import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module { val applicationModule = module {
includes(domainModule) includes(domainModule)
includes( includes(
authModule, loginModule,
convosModule, validationModule,
captchaModule,
conversationsModule,
settingsModule, settingsModule,
messagesHistoryModule, messagesHistoryModule,
photoViewModule, photoViewModule,
@@ -64,7 +66,6 @@ val applicationModule = module {
ImageLoader.Builder(get()) ImageLoader.Builder(get())
.crossfade(true) .crossfade(true)
.build() .build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
} }
singleOf(::LongPollControllerImpl) bind LongPollController::class singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -2,15 +2,15 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
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.presentation.MainScreen import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R as UiR
@Serializable @Serializable
object MainGraph object MainGraph
@@ -21,28 +21,28 @@ object Main
fun NavGraphBuilder.mainScreen( fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit, onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit, onMessageClicked: (userid: Long) -> Unit,
onNavigateToCreateChat: () -> Unit onNavigateToCreateChat: () -> Unit
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_friends, titleResId = UiR.string.title_friends,
selectedIconResId = R.drawable.ic_group_fill_round_24, selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = R.drawable.ic_group_round_24, unselectedIconResId = UiR.drawable.outline_people_alt_24,
route = Friends, route = Friends,
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_convos, titleResId = UiR.string.title_conversations,
selectedIconResId = R.drawable.ic_mail_fill_round_24, selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = R.drawable.ic_mail_round_24, unselectedIconResId = UiR.drawable.outline_chat_24,
route = ConvoGraph route = ConversationsGraph
), ),
BottomNavigationItem( BottomNavigationItem(
titleResId = R.string.title_profile, titleResId = UiR.string.title_profile,
selectedIconResId = R.drawable.ic_account_circle_fill_round_24, selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = R.drawable.ic_account_circle_round_24, unselectedIconResId = UiR.drawable.outline_account_circle_24,
route = Profile route = Profile
) )
) )
@@ -4,29 +4,55 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel 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.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
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.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -63,30 +89,177 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions() requestNotificationPermissions()
setContent { setContent {
KoinContext {
val context = LocalContext.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent) viewModel.onAppResumed(intent)
onPauseOrDispose {} onPauseOrDispose {}
} }
RootScreen( val permissionState =
toggleLongPollService = { enable, inBackground -> rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
toggleLongPollService( toggleLongPollService(
enable = enable, enable = true,
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground inBackground = true
) )
}, }
toggleOnlineService = ::toggleOnlineService }
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
) )
} }
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
}
}
} }
private fun createNotificationChannels() { private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val noCategoryName = getString(R.string.notification_channel_no_category_name) val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
val noCategoryDescriptionText = val noCategoryDescriptionText =
getString(R.string.notification_channel_no_category_description) getString(UiR.string.notification_channel_no_category_description)
val noCategoryChannel = val noCategoryChannel =
NotificationChannel( NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED, AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
@@ -96,9 +269,9 @@ class MainActivity : AppCompatActivity() {
description = noCategoryDescriptionText description = noCategoryDescriptionText
} }
val longPollName = getString(R.string.notification_channel_long_polling_service_name) val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
val longPollDescriptionText = val longPollDescriptionText =
getString(R.string.notification_channel_long_polling_service_description) getString(UiR.string.notification_channel_long_polling_service_description)
val longPollChannel = val longPollChannel =
NotificationChannel( NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING, AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
@@ -109,7 +282,7 @@ class MainActivity : AppCompatActivity() {
} }
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
getSystemService(NOTIFICATION_SERVICE) as NotificationManager getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels( notificationManager.createNotificationChannels(
listOf( listOf(
@@ -1,8 +1,5 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -18,6 +15,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -38,14 +36,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.conversations.navigation.Conversations
import dev.meloda.fast.convos.navigation.convosGraph import dev.meloda.fast.conversations.navigation.conversationsGraph
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
@@ -61,38 +58,30 @@ fun MainScreen(
navigationItems: ImmutableList<BottomNavigationItem>, navigationItems: ImmutableList<BottomNavigationItem>,
onError: (BaseError) -> Unit = {}, onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onNavigateToMessagesHistory: (convoId: Long) -> Unit = {}, onNavigateToMessagesHistory: (conversationId: Long) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {} onNavigateToCreateChat: () -> Unit = {}
) { ) {
val activity = LocalActivity.current as? AppCompatActivity ?: return
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) } val hazeState = remember { HazeState() }
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) { val user = LocalUser.current
val currentRoute = navigationItems[selectedItemIndex].route val profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 }
selectedItemIndex = defaultTabIndex
navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) {
inclusive = true
} }
}
}
val profileImageUrl = LocalUser.current?.photo100
var tabReselected by remember { var tabReselected by remember {
mutableStateOf(navigationItems.associate { it.route to false }) mutableStateOf(
navigationItems.associate {
it.route to false
}
)
} }
Scaffold( Scaffold(
@@ -104,7 +93,7 @@ fun MainScreen(
if (theme.enableBlur) { if (theme.enableBlur) {
Modifier.hazeEffect( Modifier.hazeEffect(
state = hazeState, state = hazeState,
style = HazeMaterials.regular(NavigationBarDefaults.containerColor) style = HazeMaterials.thick()
) )
} else Modifier } else Modifier
), ),
@@ -131,7 +120,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)
} }
@@ -191,7 +180,6 @@ fun MainScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) } exitTransition = { fadeOut(animationSpec = tween(200)) }
) { ) {
friendsScreen( friendsScreen(
activity = activity,
onError = onError, onError = onError,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
@@ -201,19 +189,17 @@ fun MainScreen(
} }
}, },
) )
convosGraph( conversationsGraph(
activity = activity,
onError = onError, onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory, onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat, onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = { onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also { tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false it[Conversations] = false
} }
} }
) )
profileScreen( profileScreen(
activity = activity,
onError = onError, 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))
}
}
)
}
@@ -1,238 +1,55 @@
package dev.meloda.fast.presentation package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.layout.fillMaxSize 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
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.common.LongPollController import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.convos.navigation.createChatScreen
import dev.meloda.fast.convos.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog import dev.meloda.fast.photoviewer.navigation.navigateToPhotoView
import dev.meloda.fast.photoviewer.navigation.photoViewScreen
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.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun RootScreen( fun RootScreen(
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit, navController: NavHostController = rememberNavController(),
toggleOnlineService: (enable: Boolean) -> Unit viewModel: MainViewModel
) { ) {
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false, null)
}
toggleLongPollService(true, true)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false, null)
}
toggleLongPollService(
longPollStateToApply.isLaunched(),
longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
val navController: NavHostController = rememberNavController()
val activity = LocalActivity.current
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,25 +113,11 @@ 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,
LocalNavController provides navController LocalNavController provides navController
) { ) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = requireNotNull(startDestination), startDestination = requireNotNull(startDestination),
@@ -326,7 +129,6 @@ fun RootScreen(
viewModel.onUserAuthenticated() viewModel.onUserAuthenticated()
navController.navigateToMain() navController.navigateToMain()
}, },
onNavigateToSettings = navController::navigateToSettings,
navController = navController navController = navController
) )
@@ -334,31 +136,24 @@ fun RootScreen(
onError = viewModel::onError, onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings, onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory, onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory, onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat, onNavigateToCreateChat = navController::navigateToCreateChat
) )
messagesHistoryScreen( messagesHistoryScreen(
onError = viewModel::onError, onError = viewModel::onError,
onBack = navController::navigateUp, onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials, onNavigateToChatMaterials = navController::navigateToChatMaterials
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
) )
chatMaterialsScreen( chatMaterialsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onPhotoClicked = { url -> onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
photoViewerInfo = listOf(url) to null
}
) )
createChatScreen( createChatScreen(
onChatCreated = { convoId -> onChatCreated = { conversationId ->
navController.popBackStack() navController.popBackStack()
navController.navigateToMessagesHistory(convoId) navController.navigateToMessagesHistory(conversationId)
}, },
navController = navController navController = navController
) )
@@ -366,39 +161,11 @@ fun RootScreen(
settingsScreen( settingsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) }, onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker, onLanguageItemClicked = navController::navigateToLanguagePicker
onRestartRequired = {
activity?.let {
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
activity.finish()
}
}
) )
languagePickerScreen(onBack = navController::navigateUp) languagePickerScreen(onBack = navController::navigateUp)
}
PhotoViewDialog( photoViewScreen(onBack = navController::navigateUp)
photoViewerInfo = photoViewerInfo?.let { info ->
info.first.toImmutableList() to info.second
},
onDismiss = { photoViewerInfo = null }
)
CaptchaScreen(
captchaRedirectUri = captchaRedirectUri,
onBack = {
AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled)
},
onResult = { result ->
AppSettings.setCaptchaResult(
CaptchaTokenResult.Success(result)
)
},
)
}
}
} }
} }
} }
@@ -16,17 +16,11 @@ 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.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.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
@@ -38,11 +32,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob 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.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
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() { class LongPollingService : Service() {
@@ -62,8 +55,6 @@ 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 var currentJob: Job? = null private var currentJob: Job? = null
@@ -159,8 +150,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 +160,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 +179,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)
} }
@@ -209,9 +196,6 @@ class LongPollingService : Service() {
updates.forEach(updatesParser::parseNextUpdate) updates.forEach(updatesParser::parseNextUpdate)
} }
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))
} }
} }
@@ -220,7 +204,7 @@ class LongPollingService : Service() {
} }
} }
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine { private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
longPollUseCase.getLongPollServer( longPollUseCase.getLongPollServer(
needPts = true, needPts = true,
version = VkConstants.LP_VERSION version = VkConstants.LP_VERSION
@@ -240,7 +224,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse( private suspend fun getUpdatesResponse(
server: VkLongPollData server: VkLongPollData
): LongPollUpdates? = suspendCancellableCoroutine { ): LongPollUpdates? = suspendCoroutine {
longPollUseCase.getLongPollUpdates( longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}", serverUrl = "https://${server.server}",
key = server.key, key = server.key,
@@ -262,73 +246,6 @@ class LongPollingService : Service() {
} }
} }
private suspend fun syncLongPollHistory(serverInfo: VkLongPollData) {
val cursorTs = AppSettings.LongPoll.ts ?: serverInfo.ts
val cursorPts = AppSettings.LongPoll.pts ?: serverInfo.pts
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") Log.e(TAG, "error: $throwable")
@@ -1,7 +1,6 @@
package dev.meloda.fast.service.longpolling.di package dev.meloda.fast.service.longpolling.di
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 +10,4 @@ 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)
} }
@@ -6,7 +6,7 @@ import android.content.Context
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R as UiR
object NotificationsUtils { object NotificationsUtils {
@@ -28,7 +28,7 @@ object NotificationsUtils {
actions: List<NotificationCompat.Action> = emptyList(), actions: List<NotificationCompat.Action> = emptyList(),
): NotificationCompat.Builder { ): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_fast_logo) .setSmallIcon(UiR.drawable.ic_fast_logo)
.setContentTitle(title) .setContentTitle(title)
.setPriority(priority.value) .setPriority(priority.value)
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools"> <network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Allow cleartext network traffic -->
<base-config <base-config
cleartextTrafficPermitted="false" cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration"> tools:ignore="InsecureBaseConfiguration">
<trust-anchors> <trust-anchors>
<!-- Trust pre-installed CAs --> <!-- Trust pre-installed CAs -->
@@ -10,7 +10,6 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) { with(target) {
apply(plugin = "com.android.application") apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>() val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension) configureAndroidCompose(extension)
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt import dev.meloda.fast.libs
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
@@ -10,15 +10,12 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.application") apply("com.android.application")
apply("org.jetbrains.kotlin.android")
} }
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig { defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
} }
} }
} }
@@ -1,26 +1,18 @@
import com.android.build.api.dsl.LibraryExtension import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureAndroidCompose import dev.meloda.fast.configureAndroidCompose
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.apply import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.getByType
class AndroidLibraryComposeConventionPlugin : Plugin<Project> { class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
apply(plugin = "com.android.library") apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
extensions.configure<LibraryExtension> { val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(this) configureAndroidCompose(extension)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
} }
} }
} }
@@ -1,7 +1,8 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.libs
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
@@ -13,13 +14,14 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.library") apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.parcelize") apply("org.jetbrains.kotlin.plugin.parcelize")
apply("org.jetbrains.kotlin.plugin.serialization") apply("org.jetbrains.kotlin.plugin.serialization")
} }
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.TestExtension import com.android.build.gradle.TestExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt import dev.meloda.fast.libs
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
@@ -10,15 +10,12 @@ class AndroidTestConventionPlugin : Plugin<Project> {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.android.test") apply("com.android.test")
apply("org.jetbrains.kotlin.android")
} }
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig { defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
} }
} }
} }
@@ -5,10 +5,12 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
internal fun Project.configureAndroidCompose( internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension, commonExtension: CommonExtension<*, *, *, *, *, *>,
) { ) {
commonExtension.apply { commonExtension.apply {
buildFeatures.compose = true buildFeatures {
compose = true
}
dependencies { dependencies {
val bom = libs.findLibrary("compose-bom").get() val bom = libs.findLibrary("compose-bom").get()
@@ -1,9 +1,6 @@
package dev.meloda.fast package dev.meloda.fast
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.CompileOptions
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.plugins.JavaPluginExtension
@@ -16,26 +13,24 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
internal fun Project.configureKotlinAndroid( internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension, commonExtension: CommonExtension<*, *, *, *, *, *>,
) { ) {
when (commonExtension) { commonExtension.apply {
is ApplicationExtension -> commonExtension.compileOptions(buildCompileOptions()) compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
is LibraryExtension -> commonExtension.compileOptions(buildCompileOptions())
defaultConfig {
minSdk = libs.findVersion("minSdk").get().toString().toInt()
} }
commonExtension.apply { compileOptions {
compileSdk = getVersionInt("compileSdk") sourceCompatibility = JavaVersion.VERSION_21
buildToolsVersion = "36.1.0" targetCompatibility = JavaVersion.VERSION_21
}
} }
configureKotlin<KotlinAndroidProjectExtension>() configureKotlin<KotlinAndroidProjectExtension>()
} }
private fun buildCompileOptions(): CompileOptions.() -> Unit = {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
internal fun Project.configureKotlinJvm() { internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> { extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
@@ -52,7 +47,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
when (this) { when (this) {
is KotlinAndroidProjectExtension -> compilerOptions is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions is KotlinJvmProjectExtension -> compilerOptions
else -> throw IllegalArgumentException("Unsupported project extension $this ${T::class}") else -> TODO("Unsupported project extension $this ${T::class}")
}.apply { }.apply {
jvmTarget = JvmTarget.JVM_21 jvmTarget = JvmTarget.JVM_21
allWarningsAsErrors = warningsAsErrors.toBoolean() allWarningsAsErrors = warningsAsErrors.toBoolean()
@@ -60,9 +55,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow // Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview"
"-Xannotation-default-target=param-property",
"-Xcontext-parameters"
) )
} }
} }
@@ -7,8 +7,3 @@ import org.gradle.kotlin.dsl.getByType
val Project.libs val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs") get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
fun Project.getVersionInt(alias: String): Int {
return libs.findVersion(alias).get().requiredVersion.toInt()
}
+1 -2
View File
@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
@@ -8,6 +9,4 @@ plugins {
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) apply true
alias(libs.plugins.stability.analyzer) apply false
} }
@@ -5,8 +5,8 @@ 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.238"
const val URL_OAUTH = "https://oauth.vk.ru" const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.com/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized" const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling" const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
@@ -1,7 +1,5 @@
package dev.meloda.fast.common.extensions package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -13,8 +11,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.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -27,33 +23,11 @@ fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element) if (condition.invoke()) add(element)
} }
fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
var removed = false
val each = iterator()
while (each.hasNext()) {
if (condition(each.next())) {
each.remove()
removed = true
}
}
return removed
}
fun <T> Flow<T>.listenValue( fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
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,
@@ -115,7 +89,7 @@ fun Any.asInt(): Int {
} }
fun Any.asLong(): Long { fun Any.asLong(): Long {
return when (this) { return when(this) {
is Number -> this.toLong() is Number -> this.toLong()
else -> throw IllegalArgumentException("Object is not numeric") else -> throw IllegalArgumentException("Object is not numeric")
@@ -129,33 +103,3 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
else -> emptyList() else -> emptyList()
} }
} }
fun <T> Bundle.getParcelableCompat(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz.java)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
return this.start < other.endInclusive && other.start < this.endInclusive
}
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
}
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
return (this.start - other)..(this.endInclusive - other)
}
@@ -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,22 +1,19 @@
package dev.meloda.fast.common.util package dev.meloda.fast.common.util
import android.content.res.Resources
import com.conena.nanokt.jvm.util.dayOfMonth import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay import com.conena.nanokt.jvm.util.hourOfDay
import com.conena.nanokt.jvm.util.millisecond import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute import com.conena.nanokt.jvm.util.minute
import com.conena.nanokt.jvm.util.month import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year import com.conena.nanokt.jvm.util.year
import dev.meloda.fast.common.R
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
object TimeUtils { object TimeUtils {
@@ -30,11 +27,7 @@ object TimeUtils {
}.timeInMillis }.timeInMillis
} }
fun getLocalizedDate( fun getLocalizedDate(resources: Resources, date: Long): String {
date: Long,
yesterday: () -> String,
today: () -> String
): String {
val now = Calendar.getInstance() val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date } val then = Calendar.getInstance().also { it.timeInMillis = date }
@@ -43,41 +36,48 @@ object TimeUtils {
now.month != then.month -> "dd MMMM" now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> { now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) { if (now.dayOfMonth - then.dayOfMonth == 1) {
return yesterday() return resources.getString(R.string.yesterday)
} else { } else {
"dd MMMM" "dd MMMM"
} }
} }
else -> return today() else -> return resources.getString(R.string.today)
} }
return SimpleDateFormat(pattern, Locale.getDefault()).format(date) return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
} }
fun getLocalizedTime( fun getLocalizedTime(resources: Resources, date: Long): String {
date: Long, val now = Calendar.getInstance()
yearShort: () -> String, val then = Calendar.getInstance().also { it.timeInMillis = date }
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String
): String {
val now = Clock.System.now()
val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when { return when {
diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}" now.year != then.year -> {
diff > 30.days -> "${diff.inWholeDays / 30}${monthShort().lowercase()}" "${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}"
diff > 7.days -> "${diff.inWholeDays / 7}${weekShort().lowercase()}" }
diff > 1.days -> "${diff.inWholeDays}${dayShort().lowercase()}"
diff > 1.hours -> "${diff.inWholeHours}h" now.month != then.month -> {
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}" "${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}"
diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}" }
else -> now().lowercase()
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${resources.getString(R.string.week_short).lowercase()}"
} else {
"$change${resources.getString(R.string.day_short).lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
resources.getString(R.string.time_now).lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
} }
} }
} }
@@ -1,17 +0,0 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
fun String.sha256() = this.hashString("SHA-256")
fun String.hashString(algorithm: String): String {
return MessageDigest
.getInstance(algorithm)
.digest(this.toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
+1
View File
@@ -14,6 +14,7 @@ dependencies {
api(projects.core.network) api(projects.core.network)
api(projects.core.database) api(projects.core.database)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.koin.android) implementation(libs.koin.android)
@@ -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 {
@@ -1,7 +1,7 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain 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 kotlin.math.abs import kotlin.math.abs
@@ -16,15 +16,17 @@ class VkGroupsMap(
fun groups(): List<VkGroupDomain> = map.values.toList() fun groups(): List<VkGroupDomain> = map.values.toList()
fun convoGroup(convo: VkConvo): VkGroupDomain? = fun conversationGroup(conversation: VkConversation): VkGroupDomain? =
if (!convo.peerType.isGroup()) null if (!conversation.peerType.isGroup()) null
else map[abs(convo.id)] else map[abs(conversation.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,7 +1,8 @@
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.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain 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
@@ -12,7 +13,7 @@ object VkMemoryCache {
private val users: HashMap<Long, VkUser> = hashMapOf() private val users: HashMap<Long, VkUser> = hashMapOf()
private val groups: HashMap<Long, VkGroupDomain> = hashMapOf() private val groups: HashMap<Long, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Long, VkMessage> = hashMapOf() private val messages: HashMap<Long, VkMessage> = hashMapOf()
private val convos: HashMap<Long, VkConvo> = hashMapOf() private val conversations: HashMap<Long, VkConversation> = hashMapOf()
private val contacts: HashMap<Long, VkContactDomain> = hashMapOf() private val contacts: HashMap<Long, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) { fun appendUsers(users: List<VkUser>) {
@@ -27,9 +28,9 @@ object VkMemoryCache {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message } messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
} }
fun appendConvos(convos: List<VkConvo>) { fun appendConversations(conversations: List<VkConversation>) {
convos.forEach { convo -> conversations.forEach { conversation ->
VkMemoryCache.convos[convo.id] = convo VkMemoryCache.conversations[conversation.id] = conversation
} }
} }
@@ -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
} }
@@ -57,8 +50,8 @@ object VkMemoryCache {
messages[messageId] = message messages[messageId] = message
} }
operator fun set(convoId: Long, convo: VkConvo) { operator fun set(conversationId: Long, conversation: VkConversation) {
convos[convoId] = convo conversations[conversationId] = conversation
} }
operator fun set(contactId: Long, contact: VkContactDomain) { operator fun set(contactId: Long, contact: VkContactDomain) {
@@ -101,16 +94,16 @@ object VkMemoryCache {
return ids.mapNotNull { id -> messages[id] } return ids.mapNotNull { id -> messages[id] }
} }
fun getConvo(id: Long): VkConvo? { fun getConversation(id: Long): VkConversation? {
return getConvos(id).firstOrNull() return getConversations(id).firstOrNull()
} }
fun getConvos(vararg ids: Long): List<VkConvo> { fun getConversations(vararg ids: Long): List<VkConversation> {
return getConvos(ids.toList()) return getConversations(ids.toList())
} }
fun getConvos(ids: List<Long>): List<VkConvo> { fun getConversations(ids: List<Long>): List<VkConversation> {
return ids.mapNotNull { id -> convos[id] } return ids.mapNotNull { id -> conversations[id] }
} }
fun getContact(id: Long): VkContactDomain? { fun getContact(id: Long): VkContactDomain? {
@@ -1,7 +1,8 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
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
@@ -15,15 +16,17 @@ class VkUsersMap(
fun users(): List<VkUser> = map.values.toList() fun users(): List<VkUser> = map.values.toList()
fun convoUser(convo: VkConvo): VkUser? = fun conversationUser(conversation: VkConversation): VkUser? =
if (!convo.peerType.isUser()) null if (!conversation.peerType.isUser()) null
else map[convo.id] else map[conversation.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
@@ -33,7 +36,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId] if (message.fromId > 0) map[message.fromId]
else null else null
fun user(userId: Long): VkUser? = map[userId] fun user(userid: Long): VkUser? = map[userId]
companion object { companion object {
@@ -1,28 +1,25 @@
package dev.meloda.fast.data.api.convos package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
interface ConvosRepository { interface ConversationsRepository {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
suspend fun getConvos( suspend fun getConversations(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConvosFilter filter: ConversationsFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain> ): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun getConvosById( suspend fun getConversationsById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): ApiResult<List<VkConvo>, RestApiErrorDomain> ): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain>
suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain> suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
@@ -1,75 +1,51 @@
package dev.meloda.fast.data.api.convos package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain 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.ConversationsGetRequest
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.conversations.ConversationsService
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
class ConvosRepositoryImpl( class ConversationsRepositoryImpl(
private val convosService: ConvosService, private val conversationsService: ConversationsService,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val userDao: UserDao, private val userDao: UserDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val convoDao: ConvoDao private val conversationDao: ConversationDao
) : ConvosRepository { ) : ConversationsRepository {
override suspend fun storeConvos(convos: List<VkConvo>) { override suspend fun storeConversations(conversations: List<VkConversation>) {
convoDao.insertAll(convos.map(VkConvo::asEntity)) conversationDao.insertAll(conversations.map(VkConversation::asEntity))
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) { override suspend fun getConversations(
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(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConvosFilter filter: ConversationsFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConvosGetRequest( val requestModel = ConversationsGetRequest(
count = count, count = count,
offset = offset, offset = offset,
fields = VkConstants.ALL_FIELDS, fields = VkConstants.ALL_FIELDS,
@@ -78,7 +54,7 @@ class ConvosRepositoryImpl(
startMessageId = null startMessageId = null
) )
convosService.getConvos(requestModel.map).mapApiResult( conversationsService.getConversations(requestModel.map).mapApiResult(
successMapper = { apiResponse -> successMapper = { apiResponse ->
val response = apiResponse.requireResponse() val response = apiResponse.requireResponse()
@@ -93,39 +69,33 @@ class ConvosRepositoryImpl(
VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList) VkMemoryCache.appendContacts(contactsList)
val convos = response.items.map { item -> val conversations = response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message -> val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy( message.copy(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message), actionGroup = groupsMap.messageActionGroup(message)
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
).also { VkMemoryCache[message.id] = it } ).also { VkMemoryCache[message.id] = it }
} }
item.convo.asDomain(lastMessage).let { convo -> item.conversation.asDomain(lastMessage).let { conversation ->
convo.copy( conversation.copy(
user = usersMap.convoUser(convo), user = usersMap.conversationUser(conversation),
group = groupsMap.convoGroup(convo) group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[convo.id] = it } ).also { VkMemoryCache[conversation.id] = it }
} }
} }
val messages = convos.mapNotNull(VkConvo::lastMessage) val messages = conversations.mapNotNull(VkConversation::lastMessage)
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity)) conversationDao.insertAll(conversations.map(VkConversation::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
} }
convos conversations
}, },
errorMapper = { error -> errorMapper = { error ->
error?.toDomain() error?.toDomain()
@@ -133,11 +103,11 @@ class ConvosRepositoryImpl(
) )
} }
override suspend fun getConvosById( override suspend fun getConversationsById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mutableMapOf( val requestParams = mutableMapOf(
"peer_ids" to peerIds.joinToString(separator = ",") "peer_ids" to peerIds.joinToString(separator = ",")
).apply { ).apply {
@@ -145,7 +115,7 @@ class ConvosRepositoryImpl(
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
} }
convosService.getConvosById(requestParams).mapApiResult( conversationsService.getConversationsById(requestParams).mapApiResult(
successMapper = { apiResponse -> successMapper = { apiResponse ->
val response = apiResponse.requireResponse() val response = apiResponse.requireResponse()
@@ -156,17 +126,17 @@ class ConvosRepositoryImpl(
val usersMap = VkUsersMap.forUsers(profilesList) val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList) val groupsMap = VkGroupsMap.forGroups(groupsList)
val convos = response.items.map { item -> val conversations = response.items.map { item ->
item.asDomain().let { convo -> item.asDomain().let { conversation ->
convo.copy( conversation.copy(
user = usersMap.convoUser(convo), user = usersMap.conversationUser(conversation),
group = groupsMap.convoGroup(convo) group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[convo.id] = it } ).also { VkMemoryCache[conversation.id] = it }
} }
} }
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity)) conversationDao.insertAll(conversations.map(VkConversation::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
} }
@@ -175,7 +145,7 @@ class ConvosRepositoryImpl(
VkMemoryCache.appendGroups(groupsList) VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList) VkMemoryCache.appendContacts(contactsList)
convos conversations
}, },
errorMapper = { error -> errorMapper = { error ->
error?.toDomain() error?.toDomain()
@@ -185,7 +155,7 @@ class ConvosRepositoryImpl(
override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> = override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
convosService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult( conversationsService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId }, successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() } errorMapper = { error -> error?.toDomain() }
) )
@@ -194,19 +164,19 @@ class ConvosRepositoryImpl(
override suspend fun pin( override suspend fun pin(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault() conversationsService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun unpin( override suspend fun unpin(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault() conversationsService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun reorderPinned( override suspend fun reorderPinned(
peerIds: List<Long> peerIds: List<Long>
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService conversationsService
.reorderPinned(mapOf("peer_ids" to peerIds.joinToString(","))) .reorderPinned(mapOf("peer_ids" to peerIds.joinToString(",")))
.mapApiDefault() .mapApiDefault()
} }
@@ -214,36 +184,12 @@ class ConvosRepositoryImpl(
override suspend fun archive( override suspend fun archive(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault() conversationsService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
override suspend fun unarchive( override suspend fun unarchive(
peerId: Long peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault() conversationsService.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() }
)
}
} }
@@ -1,9 +1,9 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo( data class MessagesHistoryInfo(
val messages: List<VkMessage>, val messages: List<VkMessage>,
val convos: List<VkConvo> val conversations: List<VkConversation>
) )
@@ -5,8 +5,7 @@ import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
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.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -14,14 +13,8 @@ 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, conversationId: Long,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
@@ -39,9 +32,8 @@ interface MessagesRepository {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
forward: String?, replyTo: Long?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> ): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead( suspend fun markAsRead(
@@ -105,21 +97,16 @@ interface MessagesRepository {
fields: String? = null fields: String? = null
): ApiResult<VkChatData, RestApiErrorDomain> ): ApiResult<VkChatData, RestApiErrorDomain>
suspend fun getConvoMembers( suspend fun getConversationMembers(
peerId: Long, peerId: Long,
offset: Int? = null, offset: Int? = null,
count: Int? = null, count: Int? = null,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain>
suspend fun removeChatUser( suspend fun removeChatUser(
chatId: Long, chatId: Long,
memberId: Long memberId: Long
): ApiResult<Int, RestApiErrorDomain> ): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
} }
@@ -5,7 +5,7 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
@@ -17,18 +17,17 @@ import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain 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
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetChatRequest import dev.meloda.fast.model.api.requests.MessagesGetChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetConvoMembersRequest import dev.meloda.fast.model.api.requests.MessagesGetConversationMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
@@ -37,10 +36,8 @@ import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest import dev.meloda.fast.model.api.requests.MessagesSendRequest
import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
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
@@ -54,18 +51,18 @@ class MessagesRepositoryImpl(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val userDao: UserDao, private val userDao: UserDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val convoDao: ConvoDao private val conversationDao: ConversationDao
) : MessagesRepository { ) : MessagesRepository {
override suspend fun getHistory( override suspend fun getHistory(
convoId: Long, conversationId: Long,
offset: Int?, offset: Int?,
count: Int? count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest( val requestModel = MessagesGetHistoryRequest(
count = count, count = count,
offset = offset, offset = offset,
peerId = convoId, peerId = conversationId,
extended = true, extended = true,
startMessageId = null, startMessageId = null,
rev = null, rev = null,
@@ -93,32 +90,24 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message), actionGroup = groupsMap.messageActionGroup(message)
replyMessage = message.replyMessage.let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
).also { VkMemoryCache[message.id] = it } ).also { VkMemoryCache[message.id] = it }
} }
} }
val convos = response.convos.orEmpty().map { item -> val conversations = response.conversations.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId } val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message) item.asDomain(message)
.let { convo -> .let { conversation ->
convo.copy( conversation.copy(
user = usersMap.convoUser(convo), user = usersMap.conversationUser(conversation),
group = groupsMap.convoGroup(convo) group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[convo.id] = it } ).also { VkMemoryCache[conversation.id] = it }
} }
} }
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity)) conversationDao.insertAll(conversations.map(VkConversation::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity)) userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
@@ -126,7 +115,7 @@ class MessagesRepositoryImpl(
MessagesHistoryInfo( MessagesHistoryInfo(
messages = messages, messages = messages,
convos = convos conversations = conversations
) )
}, },
errorMapper = { error -> errorMapper = { error ->
@@ -170,15 +159,7 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message), user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message), group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message), actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message), actionGroup = groupsMap.messageActionGroup(message)
replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
) )
} }
@@ -202,17 +183,15 @@ class MessagesRepositoryImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
forward: String?, replyTo: Long?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?
formatData: VkMessage.FormatData?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest( val requestModel = MessagesSendRequest(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
forward = forward, replyTo = replyTo,
attachments = attachments, attachments = attachments
formatData = formatData
) )
messagesService.send(requestModel.map).mapApiDefault() messagesService.send(requestModel.map).mapApiDefault()
@@ -245,7 +224,7 @@ class MessagesRepositoryImpl(
offset = offset, offset = offset,
preserveOrder = true, preserveOrder = true,
attachmentTypes = attachmentTypes, attachmentTypes = attachmentTypes,
cmId = cmId, conversationMessageId = cmId,
fields = VkConstants.ALL_FIELDS fields = VkConstants.ALL_FIELDS
) )
@@ -299,7 +278,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesPinMessageRequest( val requestModel = MessagesPinMessageRequest(
peerId = peerId, peerId = peerId,
messageId = messageId, messageId = messageId,
cmId = cmId conversationMessageId = cmId
) )
messagesService.pin(requestModel.map).mapApiResult( messagesService.pin(requestModel.map).mapApiResult(
@@ -327,12 +306,7 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(), messagesIds = messageIds.orEmpty(),
important = important important = important
) )
messagesService.markAsImportant(requestModel.map).mapApiResult( messagesService.markAsImportant(requestModel.map).mapApiDefault()
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
} }
override suspend fun delete( override suspend fun delete(
@@ -345,7 +319,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesDeleteRequest( val requestModel = MessagesDeleteRequest(
peerId = peerId, peerId = peerId,
messagesIds = messageIds, messagesIds = messageIds,
cmIds = cmIds, conversationsMessagesIds = cmIds,
isSpam = spam, isSpam = spam,
deleteForAll = deleteForAll deleteForAll = deleteForAll
) )
@@ -356,28 +330,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?,
@@ -418,15 +370,15 @@ class MessagesRepositoryImpl(
messagesService.getChat(requestModel.map).mapApiDefault() messagesService.getChat(requestModel.map).mapApiDefault()
} }
override suspend fun getConvoMembers( override suspend fun getConversationMembers(
peerId: Long, peerId: Long,
offset: Int?, offset: Int?,
count: Int?, count: Int?,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> = ): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requestModel = MessagesGetConvoMembersRequest( val requestModel = MessagesGetConversationMembersRequest(
peerId = peerId, peerId = peerId,
offset = offset, offset = offset,
count = count, count = count,
@@ -434,7 +386,7 @@ class MessagesRepositoryImpl(
fields = fields fields = fields
) )
messagesService.getConvoMembers(requestModel.map).mapApiDefault() messagesService.getConversationMembers(requestModel.map).mapApiDefault()
} }
override suspend fun removeChatUser( override suspend fun removeChatUser(
@@ -448,18 +400,4 @@ class MessagesRepositoryImpl(
messagesService.removeChatUser(requestModel.map).mapApiDefault() messagesService.removeChatUser(requestModel.map).mapApiDefault()
} }
override suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
messagesService.getMessageReadPeers(
mapOf(
"peer_id" to peerId.toString(),
"cmid" to cmId.toString(),
"extended" to "1",
"fields" to VkConstants.USER_FIELDS
)
).mapApiDefault()
}
} }
@@ -23,6 +23,5 @@ interface OAuthRepository {
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
} }
@@ -79,8 +79,7 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(), captchaImageUrl = response.captchaImage.orEmpty()
redirectUri = response.redirectUri
) )
} }
@@ -123,7 +122,6 @@ class OAuthRepositoryImpl(
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> = ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest( val requestModel = AuthDirectRequest(
@@ -137,7 +135,6 @@ class OAuthRepositoryImpl(
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey, captchaKey = captchaKey,
successToken = successToken
) )
oAuthService.getSilentToken(requestModel.map).mapResult( oAuthService.getSilentToken(requestModel.map).mapResult(
@@ -178,8 +175,7 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(), captchaImageUrl = response.captchaImage.orEmpty()
redirectUri = response.redirectUri
) )
} }
@@ -6,8 +6,8 @@ import dev.meloda.fast.data.api.account.AccountRepositoryImpl
import dev.meloda.fast.data.api.audios.AudiosRepository import dev.meloda.fast.data.api.audios.AudiosRepository
import dev.meloda.fast.data.api.auth.AuthRepository import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.api.auth.AuthRepositoryImpl import dev.meloda.fast.data.api.auth.AuthRepositoryImpl
import dev.meloda.fast.data.api.convos.ConvosRepository import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.convos.ConvosRepositoryImpl import dev.meloda.fast.data.api.conversations.ConversationsRepositoryImpl
import dev.meloda.fast.data.api.files.FilesRepository import dev.meloda.fast.data.api.files.FilesRepository
import dev.meloda.fast.data.api.friends.FriendsRepository import dev.meloda.fast.data.api.friends.FriendsRepository
import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl
@@ -45,7 +45,7 @@ val dataModule = module {
singleOf(::AuthRepositoryImpl) bind AuthRepository::class singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConvosRepositoryImpl) bind ConvosRepository::class singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class
singleOf(::FilesRepository) singleOf(::FilesRepository)
@@ -1,58 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fastToken",
"columnName": "fastToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "trustedHash",
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"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, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -1,413 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "6c315b7f800694f635318d86032746ec",
"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, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` 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": "important",
"columnName": "important",
"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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "conversations",
"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, '6c315b7f800694f635318d86032746ec')"
]
}
}
@@ -1,413 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a746865995959331f8a1b512c049dacb",
"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, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` 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": "important",
"columnName": "important",
"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
}
],
"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, 'a746865995959331f8a1b512c049dacb')"
]
}
}
@@ -3,12 +3,12 @@ package dev.meloda.fast.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import dev.meloda.fast.database.dao.ConvoDao import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.database.typeconverters.Converters import dev.meloda.fast.database.typeconverters.Converters
import dev.meloda.fast.model.database.VkConvoEntity import dev.meloda.fast.model.database.VkConversationEntity
import dev.meloda.fast.model.database.VkGroupEntity import dev.meloda.fast.model.database.VkGroupEntity
import dev.meloda.fast.model.database.VkMessageEntity import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.database.VkUserEntity import dev.meloda.fast.model.database.VkUserEntity
@@ -18,15 +18,15 @@ import dev.meloda.fast.model.database.VkUserEntity
VkUserEntity::class, VkUserEntity::class,
VkGroupEntity::class, VkGroupEntity::class,
VkMessageEntity::class, VkMessageEntity::class,
VkConvoEntity::class VkConversationEntity::class
], ],
version = 11 version = 10
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UserDao abstract fun userDao(): UserDao
abstract fun groupDao(): GroupDao abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao abstract fun messageDao(): MessageDao
abstract fun convoDao(): ConvoDao abstract fun conversationDao(): ConversationDao
} }
@@ -0,0 +1,30 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConversationWithMessage
import dev.meloda.fast.model.database.VkConversationEntity
@Dao
abstract class ConversationDao : EntityDao<VkConversationEntity> {
@Query("SELECT * FROM conversations")
abstract suspend fun getAll(): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -1,37 +0,0 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConvoWithMessage
import dev.meloda.fast.model.database.VkConvoEntity
@Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
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)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity?
@Transaction
@Query("SELECT * FROM convos WHERE id IS (:id)")
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)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -10,21 +10,15 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages") @Query("SELECT * FROM messages")
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 (:conversationId)")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity> abstract suspend fun getAll(conversationId: 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
} }
@@ -17,13 +17,13 @@ val databaseModule = module {
single { single {
Room.databaseBuilder(get(), CacheDatabase::class.java, "cache") Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration(true) .fallbackToDestructiveMigration()
.build() .build()
} }
single { cacheDB().userDao() } single { cacheDB().userDao() }
single { cacheDB().groupDao() } single { cacheDB().groupDao() }
single { cacheDB().messageDao() } single { cacheDB().messageDao() }
single { cacheDB().convoDao() } single { cacheDB().conversationDao() }
} }
private fun Scope.cacheDB(): CacheDatabase = get() private fun Scope.cacheDB(): CacheDatabase = get()
@@ -4,32 +4,13 @@ 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.LogLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.properties.Delegates import kotlin.properties.Delegates
import kotlin.reflect.KClass import kotlin.reflect.KClass
sealed class CaptchaTokenResult {
data object Initial : CaptchaTokenResult()
data object Null : CaptchaTokenResult()
data object Cancelled : CaptchaTokenResult()
data class Success(val token: String) : CaptchaTokenResult()
}
object AppSettings { object AppSettings {
private var preferences: SharedPreferences by Delegates.notNull() private var preferences: SharedPreferences by Delegates.notNull()
private val captchaResult = MutableStateFlow<CaptchaTokenResult>(CaptchaTokenResult.Initial)
fun getCaptchaResultFlow(): StateFlow<CaptchaTokenResult> = captchaResult.asStateFlow()
fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result }
private val captchaRedirectUri = MutableStateFlow<String?>(null)
fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow()
fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri }
fun init(preferences: SharedPreferences) { fun init(preferences: SharedPreferences) {
this.preferences = preferences this.preferences = preferences
} }
@@ -115,20 +96,6 @@ object AppSettings {
) )
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value) set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var showManualRefreshOptions: Boolean
get() = get(
SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS,
SettingsKeys.DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS
)
set(value) = put(SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS, value)
var enableHaptic: Boolean var enableHaptic: Boolean
get() = get( get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC, SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -230,21 +197,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(
@@ -11,10 +11,6 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button" const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_MANUAL_REFRESH_OPTIONS = "show_manual_refresh_options"
const val DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS = false
const val KEY_APPEARANCE = "appearance" const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline" const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -39,8 +35,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
@@ -52,9 +46,6 @@ object SettingsKeys {
const val DEFAULT_ENABLE_HAPTIC = true const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level" const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 3 const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font" const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations" const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -14,10 +14,15 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean> val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String> val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean> val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean> val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean> val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean> val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean> val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean> val showDebugCategory: StateFlow<Boolean>
@@ -30,10 +35,15 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean) fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String) fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean) fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean) fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean) fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean) fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean) fun onShowDebugCategoryChanged(show: Boolean)
} }
@@ -48,11 +58,16 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors) override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage) override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus) override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val longPollInBackground = override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash)
MutableStateFlow(AppSettings.Experimental.longPollInBackground) override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur) override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont) override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations) override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory) override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
@@ -81,10 +96,18 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language appLanguage.value = language
} }
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) { override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send sendOnlineStatus.value = send
} }
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) { override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground longPollInBackground.value = inBackground
} }
@@ -93,6 +116,14 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use useBlur.value = use
} }
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) { override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use useSystemFont.value = use
} }
+1 -6
View File
@@ -8,16 +8,11 @@ android {
} }
dependencies { dependencies {
api(projects.core.common)
api(projects.core.data) api(projects.core.data)
api(projects.core.model) api(projects.core.model)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
} }
@@ -1,28 +1,25 @@
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.ConvosFilter import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ConvoUseCase : BaseUseCase { interface ConversationsUseCase : BaseUseCase {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
fun getConvos( fun getConversations(
count: Int? = null, count: Int? = null,
offset: Int? = null, offset: Int? = null,
filter: ConvosFilter filter: ConversationsFilter
): Flow<State<List<VkConvo>>> ): Flow<State<List<VkConversation>>>
fun getById( fun getById(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): Flow<State<List<VkConvo>>> ): Flow<State<List<VkConversation>>>
fun delete(peerId: Long): Flow<State<Long>> fun delete(peerId: Long): Flow<State<Long>>
@@ -1,42 +1,30 @@
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.data.api.convos.ConvosRepository import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ConvoUseCaseImpl( class ConversationsUseCaseImpl(
private val repository: ConvosRepository, private val repository: ConversationsRepository,
) : ConvoUseCase { ) : ConversationsUseCase {
override suspend fun storeConvos( override suspend fun storeConversations(
convos: List<VkConvo> conversations: List<VkConversation>
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
repository.storeConvos(convos) repository.storeConversations(conversations)
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) { override fun getConversations(
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(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
filter: ConvosFilter filter: ConversationsFilter
): Flow<State<List<VkConvo>>> = flowNewState { ): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConvos( repository.getConversations(
count = count, count = count,
offset = offset, offset = offset,
filter = filter filter = filter
@@ -47,8 +35,8 @@ class ConvoUseCaseImpl(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean?, extended: Boolean?,
fields: String? fields: String?
): Flow<State<List<VkConvo>>> = flowNewState { ): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConvosById( repository.getConversationsById(
peerIds = peerIds, peerIds = peerIds,
extended = extended, extended = extended,
fields = fields fields = fields
@@ -6,7 +6,10 @@ import dev.meloda.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) { class GetCurrentAccountUseCase(
private val accountsRepository: AccountsRepository
) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) { suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId) accountsRepository.getAccountById(UserConfig.currentUserId)
} }
@@ -1,21 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.messages.MessagesRepository
import dev.meloda.fast.data.mapToState
import kotlinx.coroutines.flow.Flow
class GetMessageReadPeersUseCase(
private val repository: MessagesRepository
) : BaseUseCase {
operator fun invoke(
peerId: Long,
cmId: Long
): Flow<State<Int>> = flowNewState {
repository.getMessageReadPeers(
peerId = peerId,
cmId = cmId
).mapToState(successMapper = { it.totalCount })
}
}
@@ -1,22 +1,22 @@
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.data.api.convos.ConvosRepository import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class LoadConvosByIdUseCase( class LoadConversationsByIdUseCase(
private val convosRepository: ConvosRepository private val conversationsRepository: ConversationsRepository
) : BaseUseCase { ) : BaseUseCase {
operator fun invoke( operator fun invoke(
peerIds: List<Long>, peerIds: List<Long>,
extended: Boolean? = null, extended: Boolean? = null,
fields: String? = null fields: String? = null
): Flow<State<List<VkConvo>>> = flowNewState { ): Flow<State<List<VkConversation>>> = flowNewState {
convosRepository conversationsRepository
.getConvosById( .getConversationsById(
peerIds = peerIds, peerIds = peerIds,
extended = extended, extended = extended,
fields = fields, fields = fields,
@@ -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)
}
}
@@ -1,17 +1,35 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import android.util.Log 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.ConversationFlags
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.VkConversation
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.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser( class LongPollUpdatesParser(
convoUseCase: ConvoUseCase, private val conversationsUseCase: ConversationsUseCase,
messagesUseCase: MessagesUseCase private val messagesUseCase: MessagesUseCase
) { ) {
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -25,89 +43,747 @@ class LongPollUpdatesParser(
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( private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
coroutineScope = coroutineScope, mutableMapOf()
convoUseCase = convoUseCase,
messagesUseCase = messagesUseCase,
dispatch = eventDispatcher::dispatch,
dispatchAll = eventDispatcher::dispatchAll
)
fun parseNextUpdate(event: List<Any>) { fun parseNextUpdate(event: List<Any>) {
eventParser.parseNextUpdate(event) val eventId = event.first().asInt()
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
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>) {
Log.d("LongPollUpdatesParser", "$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]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.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]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$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 {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(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
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$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 conversation =
async {
loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
message?.let {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = conversation?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
)
}
}
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_EDITED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>)
.onEvent(LongPollParsedEvent.MessageEdited(message))
}
}
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>)
.onEvent(
LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>)
.onEvent(
LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
conversation = conversation.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>)
.onEvent(
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
}
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>)
.onEvent(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
}
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>)
.onEvent(
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
}
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$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
}
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 contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return
listenersMap[longPollEvent]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
.onEvent(
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
}
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$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()
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
.onEvent(
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
}
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>)
.onEvent(LongPollParsedEvent.MessageUpdated(message))
}
}
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>)
.onEvent(LongPollParsedEvent.MessageCacheClear(message))
}
}
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCoroutine { 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 ->
Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
}
private suspend fun loadConversation(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConversation? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
conversationsUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConversation: error: $error")
continuation.resume(null)
},
success = { response ->
val conversation = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(conversation)
}
)
}
}
}
@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,14 +12,9 @@ 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, conversationId: Long,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryInfo>> ): Flow<State<MessagesHistoryInfo>>
@@ -37,9 +32,8 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
forward: String?, replyTo: Long?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> ): Flow<State<MessagesSendResponse>>
fun markAsRead( fun markAsRead(
@@ -22,33 +22,13 @@ 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, conversationId: Long,
count: Int?, count: Int?,
offset: Int? offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState { ): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory( repository.getHistory(
convoId = convoId, conversationId = conversationId,
offset = offset, offset = offset,
count = count count = count
).mapToState() ).mapToState()
@@ -76,17 +56,15 @@ class MessagesUseCaseImpl(
peerId: Long, peerId: Long,
randomId: Long, randomId: Long,
message: String?, message: String?,
forward: String?, replyTo: Long?,
attachments: List<VkAttachment>?, attachments: List<VkAttachment>?
formatData: VkMessage.FormatData?
): Flow<State<MessagesSendResponse>> = flowNewState { ): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send( repository.send(
peerId = peerId, peerId = peerId,
randomId = randomId, randomId = randomId,
message = message, message = message,
forward = forward, replyTo = replyTo,
attachments = attachments, attachments = attachments
formatData = formatData
).mapToState() ).mapToState()
} }
@@ -21,8 +21,7 @@ interface OAuthUseCase {
password: String, password: String,
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String? = null, captchaSid: String?,
captchaKey: String? = null, captchaKey: String?
successToken: String? = null
): Flow<State<GetSilentTokenResponse>> ): Flow<State<GetSilentTokenResponse>>
} }
@@ -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
val userId = value.userId
val accessToken = value.accessToken
val validationHash = value.validationHash
if (userId == null || accessToken == null || validationHash == null) {
State.Error.InternalError
} else {
State.Success(
AuthInfo( AuthInfo(
userId = userId, userId = it.userId!!,
accessToken = accessToken, accessToken = it.accessToken!!,
validationHash = validationHash validationHash = it.validationHash!!
)
) )
} }
} )
else -> authResult.asState()
}
emit(newState) emit(newState)
} }
@@ -61,8 +48,7 @@ class OAuthUseCaseImpl(
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?
successToken: String?
): Flow<State<GetSilentTokenResponse>> = flow { ): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading) emit(State.Loading)
@@ -72,8 +58,7 @@ class OAuthUseCaseImpl(
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey, captchaKey = captchaKey
successToken = successToken
).asState() ).asState()
emit(newState) emit(newState)
@@ -6,8 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase import dev.meloda.fast.domain.StoreUsersUseCase
@@ -27,7 +26,5 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase) singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConvosByIdUseCase) singleOf(::LoadConversationsByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
} }
@@ -1,49 +0,0 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.ActionState
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
fun VkConvo.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConvoOption> = emptyImmutableList()
): UiConvo = UiConvo(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
minuteShort = { resources.getString(R.string.minute_short) },
secondShort = { resources.getString(R.string.second_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else extractAttachmentIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.vk.UiFriend import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation( fun VkUser.asPresentation(
useContactNames: Boolean = false useContactNames: Boolean = false
@@ -1,65 +0,0 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
fun VkMessage.asPresentation(
convo: VkConvo,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): MessageUiItem = when {
action != null -> MessageUiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionCmId
)
else -> MessageUiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(convo),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = replyMessage?.extractReplySummary(resourceProvider.resources)
)
}
@@ -1,177 +0,0 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.collidesWith
import dev.meloda.fast.common.extensions.minus
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MentionIndex
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun emptyAnnotatedString(): AnnotatedString = AnnotatedString(text = "")
fun AnnotatedString?.orEmpty(): AnnotatedString = this ?: emptyAnnotatedString()
fun String.annotated(): AnnotatedString = AnnotatedString(text = this)
fun isAccount(id: Long) = id == UserConfig.userId
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out androidx.compose.ui.text.AnnotatedString.Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
start = startIndex,
end = endIndex
)
}
if (formatData == null) {
return AnnotatedString(text = newText, annotations = annotations)
}
var current = 0
val newOffsets = formatData.items.map { (offset, length) ->
val r = replacements.filter { (range, _) ->
(range - current) collidesWith (offset..<offset + length) || offset > range.first
}
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
offset + current
}
formatData.items.forEachIndexed { index, item ->
val offset = newOffsets[index]
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> {
annotations += AnnotatedString.Range(
item = StringAnnotation(item.url.orEmpty()),
start = offset,
end = offset + item.length,
tag = newText.substring(offset, offset + item.length)
)
if (isOut) {
SpanStyle(
fontWeight = FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
)
} else {
SpanStyle(
fontWeight = FontWeight.SemiBold,
color = Color.Red
)
}
}
}
annotations += AnnotatedString.Range(
item = spanStyle,
start = offset,
end = offset + item.length
)
}
return AnnotatedString(text = newText, annotations = annotations)
}
fun List<MessageUiItem>.firstMessage(): MessageUiItem.Message =
filterIsInstance<MessageUiItem.Message>().first()
fun List<MessageUiItem>.firstMessageOrNull(): MessageUiItem.Message? =
filterIsInstance<MessageUiItem.Message>().firstOrNull()
fun List<MessageUiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<MessageUiItem>.findMessageById(messageId: Long): MessageUiItem.Message? =
firstOrNull { it.id == messageId } as MessageUiItem.Message?
fun List<MessageUiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<MessageUiItem>.findMessageByCmId(cmId: Long): MessageUiItem.Message =
first { it.cmId == cmId } as MessageUiItem.Message
+2 -2
View File
@@ -4,7 +4,7 @@ plugins {
} }
android { android {
namespace = "dev.meloda.fast.model" namespace = "dev.meloda.fast.datastore"
} }
dependencies { dependencies {
@@ -12,7 +12,7 @@ dependencies {
ksp(libs.moshi.kotlin.codegen) ksp(libs.moshi.kotlin.codegen)
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.bundles.compose)
implementation(libs.room.ktx) implementation(libs.room.ktx)
implementation(libs.room.runtime) implementation(libs.room.runtime)
@@ -1,6 +1,6 @@
package dev.meloda.fast.model package dev.meloda.fast.model
enum class ConvoFlags(val value: Int) { enum class ConversationFlags(val value: Int) {
DISABLE_PUSH(16), DISABLE_PUSH(16),
DISABLE_SOUND(32), DISABLE_SOUND(32),
INCOMING_CHAT_REQUEST(256), INCOMING_CHAT_REQUEST(256),
@@ -17,10 +17,10 @@ enum class ConvoFlags(val value: Int) {
companion object { companion object {
fun parse(mask: Int): List<ConvoFlags> { fun parse(mask: Int): List<ConversationFlags> {
val flags = mutableListOf<ConvoFlags>() val flags = mutableListOf<ConversationFlags>()
ConvoFlags.entries.forEach { flag -> ConversationFlags.entries.forEach { flag ->
if (mask and flag.value > 0) { if (mask and flag.value > 0) {
flags.add(flag) flags.add(flag)
} }
@@ -1,5 +1,5 @@
package dev.meloda.fast.model package dev.meloda.fast.model
enum class ConvosFilter { enum class ConversationsFilter {
ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY
} }
@@ -1,6 +1,6 @@
package dev.meloda.fast.model package dev.meloda.fast.model
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent { sealed interface LongPollParsedEvent {
@@ -92,7 +92,7 @@ sealed interface LongPollParsedEvent {
) : LongPollParsedEvent ) : LongPollParsedEvent
data class ChatArchived( data class ChatArchived(
val convo: VkConvo, val conversation: VkConversation,
val archived: Boolean val archived: Boolean
) : LongPollParsedEvent ) : LongPollParsedEvent
} }
@@ -1,8 +0,0 @@
package dev.meloda.fast.model
data class PhotoSize(
val height: Int,
val width: Int,
val type: String,
val url: String
)
@@ -1,7 +1,5 @@
package dev.meloda.fast.model.api package dev.meloda.fast.model.api
import dev.meloda.fast.model.api.domain.VkMessage
enum class PeerType(val value: String) { enum class PeerType(val value: String) {
USER("user"), USER("user"),
GROUP("group"), GROUP("group"),
@@ -15,14 +13,5 @@ enum class PeerType(val value: String) {
fun parse(type: String): PeerType { fun parse(type: String): PeerType {
return entries.first { it.value == type } return entries.first { it.value == type }
} }
fun VkMessage.getPeerType(): PeerType {
return when {
peerId > 2_000_000_000 -> CHAT
peerId > 0 -> USER
peerId < 0 -> GROUP
else -> throw IllegalArgumentException("Unknown peer type for peerId: 0")
}
}
} }
} }
@@ -6,8 +6,8 @@ enum class AttachmentType(var value: String) {
UNKNOWN("unknown"), UNKNOWN("unknown"),
PHOTO("photo"), PHOTO("photo"),
VIDEO("video"), VIDEO("video"),
FILE("doc"),
AUDIO("audio"), AUDIO("audio"),
FILE("doc"),
LINK("link"), LINK("link"),
AUDIO_MESSAGE("audio_message"), AUDIO_MESSAGE("audio_message"),
MINI_APP("mini_app"), MINI_APP("mini_app"),
@@ -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
)
}
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
data class VkAttachmentHistoryMessageData( data class VkAttachmentHistoryMessageData(
@Json(name = "message_id") val messageId: Long, @Json(name = "message_id") val messageId: Long,
@Json(name = "date") val date: Int, @Json(name = "date") val date: Int,
@Json(name = "cmid") val cmId: Long, @Json(name = "cmid") val conversationMessageId: Long,
@Json(name = "from_id") val fromId: Long, @Json(name = "from_id") val fromId: Long,
@Json(name = "position") val position: Int, @Json(name = "position") val position: Int,
@Json(name = "attachment") val attachment: VkAttachmentItemData @Json(name = "attachment") val attachment: VkAttachmentItemData
@@ -16,7 +16,7 @@ data class VkAttachmentHistoryMessageData(
fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage( fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage(
messageId = messageId, messageId = messageId,
cmId = cmId, conversationMessageId = conversationMessageId,
date = date, date = date,
fromId = fromId, fromId = fromId,
position = position, position = position,
@@ -3,19 +3,19 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.PeerType import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkConvoData( data class VkConversationData(
@Json(name = "peer") val peer: Peer, @Json(name = "peer") val peer: Peer,
@Json(name = "last_message_id") val lastMessageId: Long?, @Json(name = "last_message_id") val lastMessageId: Long?,
@Json(name = "in_read") val inRead: Long, @Json(name = "in_read") val inRead: Long,
@Json(name = "out_read") val outRead: Long, @Json(name = "out_read") val outRead: Long,
@Json(name = "in_read_cmid") val inReadCmId: Long, @Json(name = "in_read_cmid") val inReadConversationMessageId: Long,
@Json(name = "out_read_cmid") val outReadCmId: Long, @Json(name = "out_read_cmid") val outReadConversationMessageId: Long,
@Json(name = "sort_id") val sortId: SortId, @Json(name = "sort_id") val sortId: SortId,
@Json(name = "last_conversation_message_id") val lastCmId: Long, @Json(name = "last_conversation_message_id") val lastConversationMessageId: Long,
@Json(name = "is_marked_unread") val isMarkedUnread: Boolean, @Json(name = "is_marked_unread") val isMarkedUnread: Boolean,
@Json(name = "important") val important: Boolean, @Json(name = "important") val important: Boolean,
@Json(name = "push_settings") val pushSettings: PushSettings?, @Json(name = "push_settings") val pushSettings: PushSettings?,
@@ -111,7 +111,7 @@ data class VkConvoData(
fun asDomain( fun asDomain(
lastMessage: VkMessage? = null, lastMessage: VkMessage? = null,
): VkConvo = VkConvo( ): VkConversation = VkConversation(
id = peer.id, id = peer.id,
localId = peer.localId, localId = peer.localId,
title = chatSettings?.title, title = chatSettings?.title,
@@ -120,7 +120,7 @@ data class VkConvoData(
photo200 = chatSettings?.photo?.photo200, photo200 = chatSettings?.photo?.photo200,
isCallInProgress = callInProgress != null, isCallInProgress = callInProgress != null,
isPhantom = chatSettings?.isDisappearing == true, isPhantom = chatSettings?.isDisappearing == true,
lastCmId = lastCmId, lastCmId = lastConversationMessageId,
inRead = inRead, inRead = inRead,
outRead = outRead, outRead = outRead,
lastMessageId = lastMessageId, lastMessageId = lastMessageId,
@@ -132,8 +132,8 @@ data class VkConvoData(
canChangePin = chatSettings?.acl?.canChangePin == true, canChangePin = chatSettings?.acl?.canChangePin == true,
canChangeInfo = chatSettings?.acl?.canChangeInfo == true, canChangeInfo = chatSettings?.acl?.canChangeInfo == true,
pinnedMessageId = chatSettings?.pinnedMessage?.id, pinnedMessageId = chatSettings?.pinnedMessage?.id,
inReadCmId = inReadCmId, inReadCmId = inReadConversationMessageId,
outReadCmId = outReadCmId, outReadCmId = outReadConversationMessageId,
interactionType = -1, interactionType = -1,
interactionIds = emptyList(), interactionIds = emptyList(),
peerType = PeerType.parse(peer.type), peerType = PeerType.parse(peer.type),
@@ -27,9 +27,7 @@ data class VkFileData(
) { ) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Photo( data class Photo(val sizes: List<Size>) {
val sizes: List<Size>
) {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Size( data class Size(
@@ -1,13 +1,12 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkGiftDomain
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkGiftDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkGiftData( data class VkGiftData(
@Json(name = "id") val id: Long, @Json(name = "id") val id: Long,
@Json(name = "thumb_512") val thumb512: String?,
@Json(name = "thumb_256") val thumb256: String?, @Json(name = "thumb_256") val thumb256: String?,
@Json(name = "thumb_96") val thumb96: String?, @Json(name = "thumb_96") val thumb96: String?,
@Json(name = "thumb_48") val thumb48: String @Json(name = "thumb_48") val thumb48: String
@@ -15,7 +14,6 @@ data class VkGiftData(
fun toDomain() = VkGiftDomain( fun toDomain() = VkGiftDomain(
id = id, id = id,
thumb512 = thumb512,
thumb256 = thumb256, thumb256 = thumb256,
thumb96 = thumb96, thumb96 = thumb96,
thumb48 = thumb48 thumb48 = thumb48
@@ -56,7 +56,7 @@ data class VkMessageData(
@Json(name = "type") val type: String, @Json(name = "type") val type: String,
@Json(name = "member_id") val memberId: Long?, @Json(name = "member_id") val memberId: Long?,
@Json(name = "text") val text: String?, @Json(name = "text") val text: String?,
@Json(name = "conversation_message_id") val cmId: Long?, @Json(name = "conversation_message_id") val conversationMessageId: Long?,
@Json(name = "message") val message: String? @Json(name = "message") val message: String?
) )
@@ -102,10 +102,10 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
action = VkMessage.Action.parse(action?.type), action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId, actionMemberId = action?.memberId,
actionText = action?.text, actionText = action?.text,
actionCmId = action?.cmId, actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
isImportant = important == true, isImportant = important ?: false,
updateTime = updateTime, updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain), forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = attachments.map(VkAttachmentItemData::toDomain), attachments = attachments.map(VkAttachmentItemData::toDomain),
@@ -2,7 +2,6 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -36,14 +35,7 @@ data class VkPhotoData(
ownerId = ownerId, ownerId = ownerId,
hasTags = hasTags == true, hasTags = hasTags == true,
accessKey = accessKey, accessKey = accessKey,
sizes = sizes.map { size -> sizes = sizes,
PhotoSize(
height = size.height,
width = size.width,
type = size.type,
url = size.url
)
},
text = text, text = text,
userId = userId userId = userId
) )
@@ -12,7 +12,7 @@ data class VkPinnedMessageData(
@Json(name = "from_id") val fromId: Long, @Json(name = "from_id") val fromId: Long,
@Json(name = "out") val out: Boolean?, @Json(name = "out") val out: Boolean?,
@Json(name = "text") val text: String, @Json(name = "text") val text: String,
@Json(name = "conversation_message_id") val cmId: Long, @Json(name = "conversation_message_id") val conversationMessageId: Long,
@Json(name = "fwd_messages") val forwards: List<VkMessageData>?, @Json(name = "fwd_messages") val forwards: List<VkMessageData>?,
@Json(name = "important") val important: Boolean = false, @Json(name = "important") val important: Boolean = false,
@Json(name = "random_id") val randomId: Long = 0, @Json(name = "random_id") val randomId: Long = 0,
@@ -28,7 +28,7 @@ data class VkPinnedMessageData(
fun mapToDomain(): VkMessage = VkMessage( fun mapToDomain(): VkMessage = VkMessage(
id = id ?: -1, id = id ?: -1,
cmId = cmId, cmId = conversationMessageId,
text = text.ifBlank { null }, text = text.ifBlank { null },
isOut = out == true, isOut = out == true,
peerId = peerId ?: -1, peerId = peerId ?: -1,
@@ -38,7 +38,7 @@ data class VkPinnedMessageData(
action = VkMessage.Action.parse(action?.type), action = VkMessage.Action.parse(action?.type),
actionMemberId = action?.memberId, actionMemberId = action?.memberId,
actionText = action?.text, actionText = action?.text,
actionCmId = action?.cmId, actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message, actionMessage = action?.message,
geoType = geo?.type, geoType = geo?.type,
isImportant = important, isImportant = important,
@@ -37,7 +37,7 @@ data class VkUserData(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class LastSeen( data class LastSeen(
@Json(name = "platform") val platform: Int?, @Json(name = "platform") val platform: Int,
@Json(name = "time") val time: Int @Json(name = "time") val time: Int
) )
@@ -23,7 +23,7 @@ data class VkVideoData(
@Json(name = "is_favorite") val isFavorite: Boolean?, @Json(name = "is_favorite") val isFavorite: Boolean?,
@Json(name = "image") val image: List<Image>?, @Json(name = "image") val image: List<Image>?,
@Json(name = "first_frame") val firstFrame: List<FirstFrame>?, @Json(name = "first_frame") val firstFrame: List<FirstFrame>?,
@Json(name = "files") val files: File?, @Json(name = "files") val files: File?
) : VkAttachmentData { ) : VkAttachmentData {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@@ -73,7 +73,6 @@ data class VkVideoData(
accessKey = accessKey, accessKey = accessKey,
title = title, title = title,
views = views, views = views,
duration = duration, duration = duration
isShortVideo = type == "short_video"
) )
} }
@@ -54,9 +54,9 @@ data class VkVideoMessageData(
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Image( data class Image(
val height: Int, val height: Int?,
val url: String, val url: String?,
val width: Int, val width: Int?,
val with_padding: Int?, val with_padding: Int?,
) )
@@ -73,7 +73,6 @@ data class VkVideoMessageData(
) )
fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain( fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain(
id = id, id = id
image = image.orEmpty().filter { it.width / it.height == 1 }.maxByOrNull { it.width }?.url
) )
} }

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