Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff8e2fdd49 | |||
| 514b8859c7 | |||
| c18a7963bf | |||
| 255a194c25 | |||
| 6b7f8f2397 | |||
| 0ffd92b875 | |||
| 68fff3ebee | |||
| f6c6ed59f3 | |||
| f24eae8209 | |||
| 96f45aef6a | |||
| 5dc000341b | |||
| 6961ac7240 | |||
| c380c1a73d | |||
| 2bf81c60d6 | |||
| 2e472733d9 | |||
| d91b726b9d | |||
| 3bb4de24a7 | |||
| 22d13fcbe5 | |||
| cb653eddc2 | |||
| df2c61d8d7 | |||
| 97c59a85b6 | |||
| 155a3666ad | |||
| ce375c902c | |||
| 96b4fc8539 | |||
| e3e9157dd5 | |||
| 5aa28066d7 | |||
| 1638d70ef2 | |||
| 8c13d9e7e1 | |||
| 5dd829b6f6 | |||
| 2a238fa1bf | |||
| 3f54961ac6 | |||
| 045f2e8268 | |||
| 3eb33b2612 | |||
| f2d565fd3e | |||
| 7ab333280c |
@@ -40,7 +40,7 @@ jobs:
|
|||||||
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Upload APK with original name
|
- name: Upload APK with original name
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ env.APK_NAME }}
|
name: ${{ env.APK_NAME }}
|
||||||
path: ${{ env.APK_PATH }}
|
path: ${{ env.APK_PATH }}
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Upload APK with original name
|
- name: Upload APK with original name
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ env.APK_NAME }}
|
name: ${{ env.APK_NAME }}
|
||||||
path: ${{ env.APK_PATH }}
|
path: ${{ env.APK_PATH }}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Upload release APK
|
- name: Upload release APK
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
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
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
run: ./gradlew bundleRelease
|
run: ./gradlew bundleRelease
|
||||||
|
|
||||||
- name: Upload release Bundle
|
- name: Upload release Bundle
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 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.
|
||||||
+16
-13
@@ -13,8 +13,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.meloda.fastvk"
|
applicationId = "dev.meloda.fastvk"
|
||||||
|
|
||||||
versionCode = 10
|
versionCode = 11
|
||||||
versionName = "0.2.2"
|
versionName = "0.2.3"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -59,17 +59,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
// applicationVariants.all {
|
||||||
outputs.all {
|
// outputs.all {
|
||||||
val date = System.currentTimeMillis() / 1000
|
// val date = System.currentTimeMillis() / 1000
|
||||||
val buildType = buildType.name
|
// val buildType = buildType.name
|
||||||
val appVersion = versionName
|
// val appVersion = versionName
|
||||||
val appVersionCode = versionCode
|
// val appVersionCode = versionCode
|
||||||
|
//
|
||||||
val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
|
// val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
|
||||||
(this as? BaseVariantOutputImpl)?.outputFileName = newApkName
|
// (this as? BaseVariantOutputImpl)?.outputFileName = newApkName
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -79,6 +79,9 @@ 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)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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
|
||||||
@@ -38,6 +37,7 @@ 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,6 +45,7 @@ interface MainViewModel {
|
|||||||
val isNeedToRequestNotifications: StateFlow<Boolean>
|
val isNeedToRequestNotifications: StateFlow<Boolean>
|
||||||
|
|
||||||
fun onError(error: BaseError)
|
fun onError(error: BaseError)
|
||||||
|
fun onErrorConsumed()
|
||||||
|
|
||||||
fun onNavigatedToAuth()
|
fun onNavigatedToAuth()
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ 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)
|
||||||
@@ -82,6 +84,10 @@ 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,
|
||||||
@@ -89,10 +95,16 @@ class MainViewModelImpl(
|
|||||||
isNeedToReplaceWithAuth.update { true }
|
isNeedToReplaceWithAuth.update { true }
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui
|
else -> {
|
||||||
|
baseError.update { error }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onErrorConsumed() {
|
||||||
|
baseError.update { null }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNavigatedToAuth() {
|
override fun onNavigatedToAuth() {
|
||||||
isNeedToReplaceWithAuth.update { false }
|
isNeedToReplaceWithAuth.update { false }
|
||||||
}
|
}
|
||||||
@@ -203,10 +215,6 @@ 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,8 +4,14 @@ 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
|
||||||
@@ -18,10 +24,14 @@ 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()
|
||||||
@@ -30,5 +40,21 @@ class AppGlobal : Application(), ImageLoaderFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader = get()
|
private fun initAcra() {
|
||||||
|
initAcra {
|
||||||
|
buildConfigClass = BuildConfig::class.java
|
||||||
|
reportFormat = StringFormat.JSON
|
||||||
|
|
||||||
|
mailSender {
|
||||||
|
mailTo = "lischenkodev@gmail.com"
|
||||||
|
reportAsFile = true
|
||||||
|
reportFileName = "Crash.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
text = "App crashed"
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import androidx.preference.PreferenceManager
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.annotation.ExperimentalCoilApi
|
import coil.annotation.ExperimentalCoilApi
|
||||||
import dev.meloda.fast.MainViewModelImpl
|
import dev.meloda.fast.MainViewModelImpl
|
||||||
import dev.meloda.fast.auth.captcha.di.captchaModule
|
import dev.meloda.fast.auth.authModule
|
||||||
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
|
||||||
@@ -38,9 +36,7 @@ import org.koin.dsl.module
|
|||||||
val applicationModule = module {
|
val applicationModule = module {
|
||||||
includes(domainModule)
|
includes(domainModule)
|
||||||
includes(
|
includes(
|
||||||
loginModule,
|
authModule,
|
||||||
validationModule,
|
|
||||||
captchaModule,
|
|
||||||
convosModule,
|
convosModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
messagesHistoryModule,
|
messagesHistoryModule,
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ 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
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object MainGraph
|
object MainGraph
|
||||||
@@ -29,20 +29,20 @@ fun NavGraphBuilder.mainScreen(
|
|||||||
val navigationItems = ImmutableList.of(
|
val navigationItems = ImmutableList.of(
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
titleResId = R.string.title_friends,
|
titleResId = R.string.title_friends,
|
||||||
selectedIconResId = R.drawable.baseline_people_alt_24,
|
selectedIconResId = R.drawable.ic_group_fill_round_24,
|
||||||
unselectedIconResId = R.drawable.outline_people_alt_24,
|
unselectedIconResId = R.drawable.ic_group_round_24,
|
||||||
route = Friends,
|
route = Friends,
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
titleResId = R.string.title_convos,
|
titleResId = R.string.title_convos,
|
||||||
selectedIconResId = R.drawable.baseline_chat_24,
|
selectedIconResId = R.drawable.ic_mail_fill_round_24,
|
||||||
unselectedIconResId = R.drawable.outline_chat_24,
|
unselectedIconResId = R.drawable.ic_mail_round_24,
|
||||||
route = ConvoGraph
|
route = ConvoGraph
|
||||||
),
|
),
|
||||||
BottomNavigationItem(
|
BottomNavigationItem(
|
||||||
titleResId = R.string.title_profile,
|
titleResId = R.string.title_profile,
|
||||||
selectedIconResId = R.drawable.baseline_account_circle_24,
|
selectedIconResId = R.drawable.ic_account_circle_fill_round_24,
|
||||||
unselectedIconResId = R.drawable.outline_account_circle_24,
|
unselectedIconResId = R.drawable.ic_account_circle_round_24,
|
||||||
route = Profile
|
route = Profile
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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
|
||||||
@@ -65,9 +64,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||||
LaunchedEffect(viewModel) {
|
|
||||||
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
|
|
||||||
}
|
|
||||||
|
|
||||||
LifecycleResumeEffect(true) {
|
LifecycleResumeEffect(true) {
|
||||||
viewModel.onAppResumed(intent)
|
viewModel.onAppResumed(intent)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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
|
||||||
@@ -69,15 +70,18 @@ fun MainScreen(
|
|||||||
val theme = LocalThemeConfig.current
|
val theme = LocalThemeConfig.current
|
||||||
val hazeState = remember { HazeState(true) }
|
val hazeState = remember { HazeState(true) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
val defaultTabIndex = remember(navigationItems) {
|
||||||
var selectedItemIndex by rememberSaveable {
|
navigationItems.indexOfFirst { it.route == ConvoGraph }.takeIf { it >= 0 } ?: 0
|
||||||
mutableIntStateOf(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = selectedItemIndex != 1) {
|
var selectedItemIndex by rememberSaveable {
|
||||||
|
mutableIntStateOf(defaultTabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = selectedItemIndex != defaultTabIndex) {
|
||||||
val currentRoute = navigationItems[selectedItemIndex].route
|
val currentRoute = navigationItems[selectedItemIndex].route
|
||||||
|
|
||||||
selectedItemIndex = 1
|
selectedItemIndex = defaultTabIndex
|
||||||
navController.navigate(navigationItems[selectedItemIndex].route) {
|
navController.navigate(navigationItems[selectedItemIndex].route) {
|
||||||
popUpTo(route = currentRoute) {
|
popUpTo(route = currentRoute) {
|
||||||
inclusive = true
|
inclusive = true
|
||||||
@@ -127,7 +131,7 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
if (index == navigationItems.size - 1) {
|
if (item.route == Profile) {
|
||||||
var isLoading by remember {
|
var isLoading by remember {
|
||||||
mutableStateOf(true)
|
mutableStateOf(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,16 +4,12 @@ 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 android.util.Log
|
|
||||||
import androidx.activity.compose.LocalActivity
|
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.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -41,6 +37,7 @@ 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.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
|
||||||
@@ -48,6 +45,8 @@ import dev.meloda.fast.common.LongPollController
|
|||||||
import dev.meloda.fast.common.model.LongPollState
|
import dev.meloda.fast.common.model.LongPollState
|
||||||
import dev.meloda.fast.convos.navigation.createChatScreen
|
import dev.meloda.fast.convos.navigation.createChatScreen
|
||||||
import dev.meloda.fast.convos.navigation.navigateToCreateChat
|
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.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
|
||||||
@@ -69,9 +68,7 @@ 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.LocalThemeConfig
|
||||||
import dev.meloda.fast.ui.theme.LocalUser
|
import dev.meloda.fast.ui.theme.LocalUser
|
||||||
import dev.meloda.fast.ui.util.ImmutableList
|
|
||||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||||
import dev.meloda.fast.ui.util.immutableListOf
|
|
||||||
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
|
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@@ -90,10 +87,7 @@ fun RootScreen(
|
|||||||
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
|
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
|
||||||
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
|
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||||
LaunchedEffect(viewModel) {
|
|
||||||
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
|
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -124,14 +118,12 @@ fun RootScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(longPollStateToApply) {
|
LifecycleResumeEffect(longPollStateToApply) {
|
||||||
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
|
|
||||||
if (longPollStateToApply != LongPollState.Background) {
|
if (longPollStateToApply != LongPollState.Background) {
|
||||||
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
|
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
|
||||||
&& longPollCurrentState != longPollStateToApply
|
&& longPollCurrentState != longPollStateToApply
|
||||||
) {
|
) {
|
||||||
toggleLongPollService(false, null)
|
toggleLongPollService(false, null)
|
||||||
Log.d("LongPoll", "recreate()")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLongPollService(
|
toggleLongPollService(
|
||||||
@@ -240,6 +232,7 @@ fun RootScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
|
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
|
||||||
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
|
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
|
||||||
|
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||||
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
|
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
|
||||||
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
|
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -303,15 +296,24 @@ 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 {
|
var photoViewerInfo by rememberSaveable {
|
||||||
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null)
|
mutableStateOf<Pair<List<String>, Int?>?>(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -333,10 +335,10 @@ fun RootScreen(
|
|||||||
onSettingsButtonClicked = navController::navigateToSettings,
|
onSettingsButtonClicked = navController::navigateToSettings,
|
||||||
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
|
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
|
||||||
onPhotoClicked = { url ->
|
onPhotoClicked = { url ->
|
||||||
photoViewerInfo = immutableListOf(url) to null
|
photoViewerInfo = listOf(url) to null
|
||||||
},
|
},
|
||||||
onMessageClicked = navController::navigateToMessagesHistory,
|
onMessageClicked = navController::navigateToMessagesHistory,
|
||||||
onNavigateToCreateChat = navController::navigateToCreateChat
|
onNavigateToCreateChat = navController::navigateToCreateChat,
|
||||||
)
|
)
|
||||||
|
|
||||||
messagesHistoryScreen(
|
messagesHistoryScreen(
|
||||||
@@ -344,13 +346,13 @@ fun RootScreen(
|
|||||||
onBack = navController::navigateUp,
|
onBack = navController::navigateUp,
|
||||||
onNavigateToChatMaterials = navController::navigateToChatMaterials,
|
onNavigateToChatMaterials = navController::navigateToChatMaterials,
|
||||||
onNavigateToPhotoViewer = { photos, index ->
|
onNavigateToPhotoViewer = { photos, index ->
|
||||||
photoViewerInfo = photos.toImmutableList() to index
|
photoViewerInfo = photos to index
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
chatMaterialsScreen(
|
chatMaterialsScreen(
|
||||||
onBack = navController::navigateUp,
|
onBack = navController::navigateUp,
|
||||||
onPhotoClicked = { url ->
|
onPhotoClicked = { url ->
|
||||||
photoViewerInfo = immutableListOf(url) to null
|
photoViewerInfo = listOf(url) to null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
createChatScreen(
|
createChatScreen(
|
||||||
@@ -378,9 +380,23 @@ fun RootScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PhotoViewDialog(
|
PhotoViewDialog(
|
||||||
photoViewerInfo = photoViewerInfo,
|
photoViewerInfo = photoViewerInfo?.let { info ->
|
||||||
|
info.first.toImmutableList() to info.second
|
||||||
|
},
|
||||||
onDismiss = { photoViewerInfo = null }
|
onDismiss = { photoViewerInfo = null }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CaptchaScreen(
|
||||||
|
captchaRedirectUri = captchaRedirectUri,
|
||||||
|
onBack = {
|
||||||
|
AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled)
|
||||||
|
},
|
||||||
|
onResult = { result ->
|
||||||
|
AppSettings.setCaptchaResult(
|
||||||
|
CaptchaTokenResult.Success(result)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ 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
|
||||||
@@ -32,10 +38,11 @@ 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() {
|
||||||
@@ -55,6 +62,8 @@ class LongPollingService : Service() {
|
|||||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||||
|
|
||||||
private val longPollUseCase: LongPollUseCase by inject()
|
private val longPollUseCase: LongPollUseCase by inject()
|
||||||
|
private val messagesUseCase: MessagesUseCase by inject()
|
||||||
|
private val storeUsersUseCase: StoreUsersUseCase by inject()
|
||||||
private val updatesParser: LongPollUpdatesParser by inject()
|
private val updatesParser: LongPollUpdatesParser by inject()
|
||||||
|
|
||||||
private var currentJob: Job? = null
|
private var currentJob: Job? = null
|
||||||
@@ -150,6 +159,8 @@ 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)")
|
||||||
|
|
||||||
@@ -160,6 +171,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -179,6 +191,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +209,9 @@ 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +220,7 @@ class LongPollingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
|
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
|
||||||
longPollUseCase.getLongPollServer(
|
longPollUseCase.getLongPollServer(
|
||||||
needPts = true,
|
needPts = true,
|
||||||
version = VkConstants.LP_VERSION
|
version = VkConstants.LP_VERSION
|
||||||
@@ -224,7 +240,7 @@ class LongPollingService : Service() {
|
|||||||
|
|
||||||
private suspend fun getUpdatesResponse(
|
private suspend fun getUpdatesResponse(
|
||||||
server: VkLongPollData
|
server: VkLongPollData
|
||||||
): LongPollUpdates? = suspendCoroutine {
|
): LongPollUpdates? = suspendCancellableCoroutine {
|
||||||
longPollUseCase.getLongPollUpdates(
|
longPollUseCase.getLongPollUpdates(
|
||||||
serverUrl = "https://${server.server}",
|
serverUrl = "https://${server.server}",
|
||||||
key = server.key,
|
key = server.key,
|
||||||
@@ -246,6 +262,73 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -10,4 +11,5 @@ import org.koin.dsl.module
|
|||||||
val longPollModule = module {
|
val longPollModule = module {
|
||||||
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
|
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
|
||||||
singleOf(::LongPollUpdatesParser)
|
singleOf(::LongPollUpdatesParser)
|
||||||
|
singleOf(::LongPollUpdatesReducer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.libs
|
import dev.meloda.fast.getVersionInt
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.kotlin.dsl.configure
|
import org.gradle.kotlin.dsl.configure
|
||||||
@@ -10,12 +10,15 @@ 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.targetSdk = 36
|
defaultConfig {
|
||||||
|
minSdk = getVersionInt("minSdk")
|
||||||
|
compileSdk = getVersionInt("compileSdk")
|
||||||
|
targetSdk = getVersionInt("targetSdk")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import com.android.build.gradle.LibraryExtension
|
import com.android.build.api.dsl.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.getByType
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
val extension = extensions.getByType<LibraryExtension>()
|
extensions.configure<LibraryExtension> {
|
||||||
extension.androidResources.enable = false
|
configureAndroidCompose(this)
|
||||||
configureAndroidCompose(extension)
|
androidResources.enable = false
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = getVersionInt("minSdk")
|
||||||
|
compileSdk = getVersionInt("compileSdk")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
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 org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
@@ -13,7 +13,6 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -21,7 +20,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
|
|||||||
extensions.configure<LibraryExtension> {
|
extensions.configure<LibraryExtension> {
|
||||||
configureKotlinAndroid(this)
|
configureKotlinAndroid(this)
|
||||||
androidResources.enable = false
|
androidResources.enable = false
|
||||||
defaultConfig.targetSdk = 36
|
|
||||||
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.gradle.TestExtension
|
import com.android.build.api.dsl.TestExtension
|
||||||
import dev.meloda.fast.configureKotlinAndroid
|
import dev.meloda.fast.configureKotlinAndroid
|
||||||
import dev.meloda.fast.libs
|
import dev.meloda.fast.getVersionInt
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.kotlin.dsl.configure
|
import org.gradle.kotlin.dsl.configure
|
||||||
@@ -10,12 +10,15 @@ 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.targetSdk = 36
|
defaultConfig {
|
||||||
|
minSdk = getVersionInt("minSdk")
|
||||||
|
compileSdk = getVersionInt("compileSdk")
|
||||||
|
targetSdk = getVersionInt("targetSdk")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ 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 {
|
buildFeatures.compose = true
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val bom = libs.findLibrary("compose-bom").get()
|
val bom = libs.findLibrary("compose-bom").get()
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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
|
||||||
@@ -13,24 +16,26 @@ 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) {
|
||||||
|
is ApplicationExtension -> commonExtension.compileOptions(buildCompileOptions())
|
||||||
|
is LibraryExtension -> commonExtension.compileOptions(buildCompileOptions())
|
||||||
|
}
|
||||||
|
|
||||||
commonExtension.apply {
|
commonExtension.apply {
|
||||||
compileSdk = 36
|
compileSdk = getVersionInt("compileSdk")
|
||||||
|
buildToolsVersion = "36.1.0"
|
||||||
defaultConfig {
|
|
||||||
minSdk = 23
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
|
||||||
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
|
||||||
@@ -47,7 +52,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 -> TODO("Unsupported project extension $this ${T::class}")
|
else -> throw IllegalArgumentException("Unsupported project extension $this ${T::class}")
|
||||||
}.apply {
|
}.apply {
|
||||||
jvmTarget = JvmTarget.JVM_21
|
jvmTarget = JvmTarget.JVM_21
|
||||||
allWarningsAsErrors = warningsAsErrors.toBoolean()
|
allWarningsAsErrors = warningsAsErrors.toBoolean()
|
||||||
@@ -57,6 +62,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
|
|||||||
"-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",
|
"-Xannotation-default-target=param-property",
|
||||||
|
"-Xcontext-parameters"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ 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()
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -9,4 +8,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -45,6 +46,14 @@ fun <T> Flow<T>.listenValue(
|
|||||||
action: suspend (T) -> Unit
|
action: suspend (T) -> Unit
|
||||||
): Job = onEach(action::invoke).launchIn(coroutineScope)
|
): Job = onEach(action::invoke).launchIn(coroutineScope)
|
||||||
|
|
||||||
|
fun CoroutineScope.launchDbRefresh(
|
||||||
|
load: suspend () -> Unit,
|
||||||
|
after: suspend () -> Unit
|
||||||
|
): Job = launch {
|
||||||
|
load()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
fun createTimerFlow(
|
fun createTimerFlow(
|
||||||
time: Int,
|
time: Int,
|
||||||
onStartAction: (suspend () -> Unit)? = null,
|
onStartAction: (suspend () -> Unit)? = null,
|
||||||
@@ -143,8 +152,6 @@ infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
|
|||||||
return this.start < other.endInclusive && other.start < this.endInclusive
|
return this.start < other.endInclusive && other.start < this.endInclusive
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dickPizda(a: Int): String = ""
|
|
||||||
|
|
||||||
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
|
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
|
||||||
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
|
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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,7 +1,6 @@
|
|||||||
package dev.meloda.fast.common.util
|
package dev.meloda.fast.common.util
|
||||||
|
|
||||||
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
|
||||||
@@ -12,6 +11,12 @@ 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 {
|
||||||
|
|
||||||
@@ -56,37 +61,23 @@ object TimeUtils {
|
|||||||
monthShort: () -> String,
|
monthShort: () -> String,
|
||||||
weekShort: () -> String,
|
weekShort: () -> String,
|
||||||
dayShort: () -> String,
|
dayShort: () -> String,
|
||||||
|
minuteShort: () -> String,
|
||||||
|
secondShort: () -> String,
|
||||||
now: () -> String
|
now: () -> String
|
||||||
): String {
|
): String {
|
||||||
val now = Calendar.getInstance()
|
val now = Clock.System.now()
|
||||||
val then = Calendar.getInstance().also { it.timeInMillis = date }
|
val then = Instant.fromEpochMilliseconds(date)
|
||||||
|
val diff = now - then
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
now.year != then.year -> {
|
diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
|
||||||
"${now.year - then.year}${yearShort().lowercase()}"
|
diff > 30.days -> "${diff.inWholeDays / 30}${monthShort().lowercase()}"
|
||||||
}
|
diff > 7.days -> "${diff.inWholeDays / 7}${weekShort().lowercase()}"
|
||||||
|
diff > 1.days -> "${diff.inWholeDays}${dayShort().lowercase()}"
|
||||||
now.month != then.month -> {
|
diff > 1.hours -> "${diff.inWholeHours}h"
|
||||||
"${now.month - then.month}${monthShort().lowercase()}"
|
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().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}${weekShort().lowercase()}"
|
|
||||||
} else {
|
|
||||||
"$change${dayShort().lowercase()}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now.hour == then.hour && now.minute == then.minute -> {
|
|
||||||
now().lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
package dev.meloda.fast.common.util
|
package dev.meloda.fast.common.util
|
||||||
|
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
fun String.urlEncode(encoding: String = "utf-8"): String {
|
fun String.urlEncode(encoding: String = "utf-8"): String {
|
||||||
return URLEncoder.encode(this, encoding)
|
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) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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,24 +1,18 @@
|
|||||||
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 builder = chain.request().url.newBuilder()
|
val request = chain.request()
|
||||||
|
val urlBuilder = request.url.newBuilder()
|
||||||
|
|
||||||
val uri = builder.build().toUri().toString().toUri()
|
if (request.url.queryParameter("access_token") == null) {
|
||||||
|
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(chain.request().newBuilder().apply { url(builder.build()) }.build())
|
return chain.proceed(request.newBuilder().url(urlBuilder.build()).build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ object UserConfig {
|
|||||||
accessToken = ""
|
accessToken = ""
|
||||||
fastToken = ""
|
fastToken = ""
|
||||||
userId = -1
|
userId = -1
|
||||||
|
trustedHash = null
|
||||||
|
exchangeToken = null
|
||||||
|
AppSettings.LongPoll.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isLoggedIn(): Boolean {
|
fun isLoggedIn(): Boolean {
|
||||||
|
|||||||
@@ -21,12 +21,10 @@ class VkGroupsMap(
|
|||||||
else map[abs(convo.id)]
|
else map[abs(convo.id)]
|
||||||
|
|
||||||
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
|
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
|
||||||
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
|
message.actionMemberId?.takeIf { it < 0 }?.let { map[abs(it)] }
|
||||||
else map[abs(message.actionMemberId!!)]
|
|
||||||
|
|
||||||
fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
|
fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
|
||||||
if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null
|
message.action?.memberId?.takeIf { it < 0 }?.let { map[abs(it)] }
|
||||||
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,6 +1,5 @@
|
|||||||
package dev.meloda.fast.data
|
package dev.meloda.fast.data
|
||||||
|
|
||||||
import dev.meloda.fast.data.UserConfig.userId
|
|
||||||
import dev.meloda.fast.model.api.domain.VkContactDomain
|
import dev.meloda.fast.model.api.domain.VkContactDomain
|
||||||
import dev.meloda.fast.model.api.domain.VkConvo
|
import dev.meloda.fast.model.api.domain.VkConvo
|
||||||
import dev.meloda.fast.model.api.domain.VkGroupDomain
|
import dev.meloda.fast.model.api.domain.VkGroupDomain
|
||||||
@@ -38,7 +37,15 @@ object VkMemoryCache {
|
|||||||
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
|
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun set(userid: Long, user: VkUser) {
|
fun clear() {
|
||||||
|
users.clear()
|
||||||
|
groups.clear()
|
||||||
|
messages.clear()
|
||||||
|
convos.clear()
|
||||||
|
contacts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun set(userId: Long, user: VkUser) {
|
||||||
users[userId] = user
|
users[userId] = user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,10 @@ class VkUsersMap(
|
|||||||
else map[convo.id]
|
else map[convo.id]
|
||||||
|
|
||||||
fun messageActionUser(message: VkMessage): VkUser? =
|
fun messageActionUser(message: VkMessage): VkUser? =
|
||||||
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
|
message.actionMemberId?.takeIf { it > 0 }?.let(map::get)
|
||||||
else map[message.actionMemberId]
|
|
||||||
|
|
||||||
fun messageActionUser(message: VkMessageData): VkUser? =
|
fun messageActionUser(message: VkMessageData): VkUser? =
|
||||||
if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null
|
message.action?.memberId?.takeIf { it > 0 }?.let(map::get)
|
||||||
else map[message.action!!.memberId]
|
|
||||||
|
|
||||||
fun messageUser(message: VkMessage): VkUser? =
|
fun messageUser(message: VkMessage): VkUser? =
|
||||||
if (!message.isUser()) null
|
if (!message.isUser()) null
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import dev.meloda.fast.network.RestApiErrorDomain
|
|||||||
interface ConvosRepository {
|
interface ConvosRepository {
|
||||||
|
|
||||||
suspend fun storeConvos(convos: List<VkConvo>)
|
suspend fun storeConvos(convos: List<VkConvo>)
|
||||||
|
suspend fun getLocalConvos(): List<VkConvo>
|
||||||
|
suspend fun getLocalConvoById(peerId: Long): VkConvo?
|
||||||
|
suspend fun deleteLocalConvo(peerId: Long)
|
||||||
|
|
||||||
suspend fun getConvos(
|
suspend fun getConvos(
|
||||||
count: Int?,
|
count: Int?,
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import dev.meloda.fast.model.api.domain.VkGroupDomain
|
|||||||
import dev.meloda.fast.model.api.domain.VkMessage
|
import dev.meloda.fast.model.api.domain.VkMessage
|
||||||
import dev.meloda.fast.model.api.domain.VkUser
|
import dev.meloda.fast.model.api.domain.VkUser
|
||||||
import dev.meloda.fast.model.api.domain.asEntity
|
import dev.meloda.fast.model.api.domain.asEntity
|
||||||
|
import dev.meloda.fast.model.database.VkConvoEntity
|
||||||
import dev.meloda.fast.model.api.requests.ConvosGetRequest
|
import dev.meloda.fast.model.api.requests.ConvosGetRequest
|
||||||
import dev.meloda.fast.network.RestApiErrorDomain
|
import dev.meloda.fast.network.RestApiErrorDomain
|
||||||
import dev.meloda.fast.network.mapApiDefault
|
import dev.meloda.fast.network.mapApiDefault
|
||||||
import dev.meloda.fast.network.mapApiResult
|
import dev.meloda.fast.network.mapApiResult
|
||||||
import dev.meloda.fast.network.service.convos.ConvosService
|
import dev.meloda.fast.network.service.convos.ConvosService
|
||||||
|
import dev.meloda.fast.model.database.asExternalModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -40,6 +42,28 @@ class ConvosRepositoryImpl(
|
|||||||
convoDao.insertAll(convos.map(VkConvo::asEntity))
|
convoDao.insertAll(convos.map(VkConvo::asEntity))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
|
||||||
|
convoDao.getAllWithMessage().map { convoWithMessage ->
|
||||||
|
convoWithMessage.convo.asExternalModel().copy(
|
||||||
|
lastMessage = convoWithMessage.message?.asExternalModel()
|
||||||
|
)
|
||||||
|
}.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
|
||||||
|
convoDao.getByIdWithMessage(peerId)?.let { convoWithMessage ->
|
||||||
|
convoWithMessage.convo.asExternalModel().copy(
|
||||||
|
lastMessage = convoWithMessage.message?.asExternalModel()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalConvo(peerId: Long) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
convoDao.deleteById(peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getConvos(
|
override suspend fun getConvos(
|
||||||
count: Int?,
|
count: Int?,
|
||||||
offset: Int?,
|
offset: Int?,
|
||||||
@@ -198,4 +222,28 @@ class ConvosRepositoryImpl(
|
|||||||
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
|
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
|
||||||
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
|
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<VkConvo>.sorted(): List<VkConvo> {
|
||||||
|
val newConvos = toMutableList()
|
||||||
|
|
||||||
|
val pinnedConvos = newConvos
|
||||||
|
.filter(VkConvo::isPinned)
|
||||||
|
.sortedWith { c1, c2 ->
|
||||||
|
val diff = c2.majorId - c1.majorId
|
||||||
|
|
||||||
|
if (diff == 0) {
|
||||||
|
c2.minorId - c1.minorId
|
||||||
|
} else {
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newConvos.removeAll(pinnedConvos)
|
||||||
|
newConvos.sortWith { c1, c2 ->
|
||||||
|
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
newConvos.addAll(0, pinnedConvos)
|
||||||
|
return newConvos
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +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.network.RestApiErrorDomain
|
import dev.meloda.fast.network.RestApiErrorDomain
|
||||||
import com.slack.eithernet.ApiResult
|
import com.slack.eithernet.ApiResult
|
||||||
@@ -21,4 +22,14 @@ 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,7 +1,9 @@
|
|||||||
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
|
||||||
@@ -52,4 +54,29 @@ class LongPollRepositoryImpl(
|
|||||||
|
|
||||||
longPollService.getResponse(serverUrl, requestModel.map).mapDefault()
|
longPollService.getResponse(serverUrl, requestModel.map).mapDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLongPollHistory(
|
||||||
|
ts: Int,
|
||||||
|
pts: Int,
|
||||||
|
lpVersion: Int,
|
||||||
|
lastN: Int?,
|
||||||
|
maxMsgId: Long?,
|
||||||
|
eventsLimit: Int?,
|
||||||
|
msgsLimit: Int?
|
||||||
|
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
|
||||||
|
val requestModel = LongPollGetHistoryRequest(
|
||||||
|
ts = ts,
|
||||||
|
pts = pts,
|
||||||
|
lpVersion = lpVersion,
|
||||||
|
lastN = lastN,
|
||||||
|
maxMsgId = maxMsgId,
|
||||||
|
eventsLimit = eventsLimit,
|
||||||
|
msgsLimit = msgsLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
messagesService.getLongPollHistory(requestModel.map).mapApiResult(
|
||||||
|
successMapper = { response -> response.requireResponse() },
|
||||||
|
errorMapper = { error -> error?.toDomain() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface MessagesRepository {
|
|||||||
|
|
||||||
suspend fun storeMessages(messages: List<VkMessage>)
|
suspend fun storeMessages(messages: List<VkMessage>)
|
||||||
|
|
||||||
|
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
|
||||||
|
suspend fun getLocalMessageById(messageId: Long): VkMessage?
|
||||||
|
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
|
||||||
|
suspend fun getLocalMaxMessageId(): Long?
|
||||||
|
suspend fun deleteLocalMessages(messageIds: List<Long>)
|
||||||
|
|
||||||
suspend fun getHistory(
|
suspend fun getHistory(
|
||||||
convoId: Long,
|
convoId: Long,
|
||||||
offset: Int?,
|
offset: Int?,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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
|
||||||
@@ -39,6 +40,7 @@ import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
|
|||||||
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
|
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
|
||||||
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
|
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
|
||||||
import dev.meloda.fast.model.api.responses.MessagesSendResponse
|
import dev.meloda.fast.model.api.responses.MessagesSendResponse
|
||||||
|
import dev.meloda.fast.model.database.asExternalModel
|
||||||
import dev.meloda.fast.network.RestApiErrorDomain
|
import dev.meloda.fast.network.RestApiErrorDomain
|
||||||
import dev.meloda.fast.network.mapApiDefault
|
import dev.meloda.fast.network.mapApiDefault
|
||||||
import dev.meloda.fast.network.mapApiResult
|
import dev.meloda.fast.network.mapApiResult
|
||||||
@@ -354,6 +356,28 @@ 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?,
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ interface OAuthRepository {
|
|||||||
validationCode: String?,
|
validationCode: String?,
|
||||||
captchaSid: String?,
|
captchaSid: String?,
|
||||||
captchaKey: String?,
|
captchaKey: String?,
|
||||||
|
successToken: String?
|
||||||
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
|
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ 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(
|
||||||
@@ -135,6 +137,7 @@ 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(
|
||||||
@@ -175,7 +178,8 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import dev.meloda.fast.model.database.VkConvoEntity
|
|||||||
@Dao
|
@Dao
|
||||||
abstract class ConvoDao : EntityDao<VkConvoEntity> {
|
abstract class ConvoDao : EntityDao<VkConvoEntity> {
|
||||||
|
|
||||||
@Query("SELECT * FROM convos")
|
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
|
||||||
abstract suspend fun getAll(): List<VkConvoEntity>
|
abstract suspend fun getAll(): List<VkConvoEntity>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
|
||||||
|
abstract suspend fun getAllWithMessage(): List<ConvoWithMessage>
|
||||||
|
|
||||||
@Query("SELECT * FROM convos WHERE id IN (:ids)")
|
@Query("SELECT * FROM convos WHERE id IN (:ids)")
|
||||||
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
|
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
|
||||||
|
|
||||||
@@ -22,6 +26,9 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
|
|||||||
@Query("SELECT * FROM convos WHERE id IS (:id)")
|
@Query("SELECT * FROM convos WHERE id IS (:id)")
|
||||||
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
|
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
|
||||||
|
|
||||||
|
@Query("DELETE FROM convos WHERE id IS (:id)")
|
||||||
|
abstract suspend fun deleteById(id: Long): Int
|
||||||
|
|
||||||
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
|
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
|
||||||
abstract suspend fun deleteByIds(ids: List<Int>): Int
|
abstract suspend fun deleteByIds(ids: List<Int>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,21 @@ 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)")
|
@Query("SELECT * FROM messages WHERE peerId IS (:convoId) ORDER BY date DESC, id DESC")
|
||||||
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
|
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM messages WHERE id IN (:ids)")
|
@Query("SELECT * FROM messages WHERE id IN (:ids)")
|
||||||
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
|
abstract suspend fun getAllByIds(ids: List<Long>): 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<Int>): Int
|
abstract suspend fun deleteByIds(ids: List<Long>): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,32 @@ 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
|
||||||
}
|
}
|
||||||
@@ -211,6 +230,21 @@ 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(
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ 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
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ dependencies {
|
|||||||
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(libs.bundles.nanokt)
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
interface ConvoUseCase : BaseUseCase {
|
interface ConvoUseCase : BaseUseCase {
|
||||||
|
|
||||||
suspend fun storeConvos(convos: List<VkConvo>)
|
suspend fun storeConvos(convos: List<VkConvo>)
|
||||||
|
suspend fun getLocalConvos(): List<VkConvo>
|
||||||
|
suspend fun getLocalConvoById(peerId: Long): VkConvo?
|
||||||
|
suspend fun deleteLocalConvo(peerId: Long)
|
||||||
|
|
||||||
fun getConvos(
|
fun getConvos(
|
||||||
count: Int? = null,
|
count: Int? = null,
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ class ConvoUseCaseImpl(
|
|||||||
repository.storeConvos(convos)
|
repository.storeConvos(convos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
|
||||||
|
repository.getLocalConvos()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
|
||||||
|
repository.getLocalConvoById(peerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalConvo(peerId: Long) = withContext(Dispatchers.IO) {
|
||||||
|
repository.deleteLocalConvo(peerId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getConvos(
|
override fun getConvos(
|
||||||
count: Int?,
|
count: Int?,
|
||||||
offset: Int?,
|
offset: Int?,
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ 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(
|
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
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,35 +1,17 @@
|
|||||||
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.ConvoFlags
|
|
||||||
import dev.meloda.fast.model.InteractionType
|
|
||||||
import dev.meloda.fast.model.LongPollEvent
|
import dev.meloda.fast.model.LongPollEvent
|
||||||
import dev.meloda.fast.model.LongPollParsedEvent
|
import dev.meloda.fast.model.LongPollParsedEvent
|
||||||
import dev.meloda.fast.model.MessageFlags
|
|
||||||
import dev.meloda.fast.model.api.domain.VkConvo
|
|
||||||
import dev.meloda.fast.model.api.domain.VkMessage
|
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
class LongPollUpdatesParser(
|
class LongPollUpdatesParser(
|
||||||
private val convoUseCase: ConvoUseCase,
|
convoUseCase: ConvoUseCase,
|
||||||
private val messagesUseCase: MessagesUseCase
|
messagesUseCase: MessagesUseCase
|
||||||
) {
|
) {
|
||||||
private val job = SupervisorJob()
|
private val job = SupervisorJob()
|
||||||
|
|
||||||
@@ -43,747 +25,89 @@ 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 listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
|
private val eventParser = LongPollEventParser(
|
||||||
mutableMapOf()
|
coroutineScope = coroutineScope,
|
||||||
|
convoUseCase = convoUseCase,
|
||||||
|
messagesUseCase = messagesUseCase,
|
||||||
|
dispatch = eventDispatcher::dispatch,
|
||||||
|
dispatchAll = eventDispatcher::dispatchAll
|
||||||
|
)
|
||||||
|
|
||||||
fun parseNextUpdate(event: List<Any>) {
|
fun parseNextUpdate(event: List<Any>) {
|
||||||
val eventId = event.first().asInt()
|
eventParser.parseNextUpdate(event)
|
||||||
|
|
||||||
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 convo =
|
|
||||||
async {
|
|
||||||
loadConvo(
|
|
||||||
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 = convo?.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 = ConvoFlags.parse(flags)
|
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
parsedFlags.forEach { flag ->
|
|
||||||
when (flag) {
|
|
||||||
ConvoFlags.ARCHIVED -> {
|
|
||||||
val convo = loadConvo(
|
|
||||||
peerId = peerId,
|
|
||||||
extended = true,
|
|
||||||
fields = VkConstants.ALL_FIELDS
|
|
||||||
) ?: return@forEach
|
|
||||||
|
|
||||||
val message = loadMessage(
|
|
||||||
peerId = peerId,
|
|
||||||
cmId = convo.lastCmId
|
|
||||||
)
|
|
||||||
|
|
||||||
val eventToSend = LongPollParsedEvent.ChatArchived(
|
|
||||||
convo = convo.copy(lastMessage = message),
|
|
||||||
archived = false
|
|
||||||
)
|
|
||||||
eventsToSend += eventToSend
|
|
||||||
|
|
||||||
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.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 = ConvoFlags.parse(flags)
|
|
||||||
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
parsedFlags.forEach { flag ->
|
|
||||||
when (flag) {
|
|
||||||
ConvoFlags.ARCHIVED -> {
|
|
||||||
val convo = loadConvo(
|
|
||||||
peerId = peerId,
|
|
||||||
extended = true,
|
|
||||||
fields = VkConstants.ALL_FIELDS
|
|
||||||
) ?: return@forEach
|
|
||||||
|
|
||||||
val message = loadMessage(
|
|
||||||
peerId = peerId,
|
|
||||||
cmId = convo.lastCmId
|
|
||||||
)
|
|
||||||
|
|
||||||
val eventToSend = LongPollParsedEvent.ChatArchived(
|
|
||||||
convo = convo.copy(lastMessage = message),
|
|
||||||
archived = true
|
|
||||||
)
|
|
||||||
eventsToSend += eventToSend
|
|
||||||
|
|
||||||
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.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 loadConvo(
|
|
||||||
peerId: Long,
|
|
||||||
extended: Boolean = false,
|
|
||||||
fields: String? = null
|
|
||||||
): VkConvo? = suspendCoroutine { continuation ->
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
convoUseCase.getById(
|
|
||||||
peerIds = listOf(peerId),
|
|
||||||
extended = extended,
|
|
||||||
fields = fields
|
|
||||||
).listenValue(coroutineScope) { state ->
|
|
||||||
state.processState(
|
|
||||||
error = { error ->
|
|
||||||
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
|
|
||||||
continuation.resume(null)
|
|
||||||
},
|
|
||||||
success = { response ->
|
|
||||||
val convo = response.singleOrNull() ?: run {
|
|
||||||
continuation.resume(null)
|
|
||||||
return@listenValue
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.resume(convo)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun <T : LongPollParsedEvent> registerListener(
|
|
||||||
eventType: LongPollEvent,
|
|
||||||
listener: VkEventCallback<T>
|
|
||||||
) {
|
|
||||||
listenersMap.let { map ->
|
|
||||||
map[eventType] = (map[eventType] ?: mutableListOf())
|
|
||||||
.also {
|
|
||||||
it.add(listener as VkEventCallback<LongPollParsedEvent>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T : LongPollParsedEvent> registerListeners(
|
|
||||||
eventTypes: List<LongPollEvent>,
|
|
||||||
listener: VkEventCallback<T>
|
|
||||||
) {
|
|
||||||
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
|
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
|
||||||
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
|
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
|
||||||
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
|
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
|
||||||
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
|
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
|
||||||
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
|
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
|
||||||
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
|
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
|
||||||
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
|
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
|
||||||
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
|
eventDispatcher.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) {
|
||||||
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
|
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
|
||||||
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
|
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
|
||||||
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
|
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
|
||||||
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
|
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
|
||||||
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
|
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
|
||||||
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
|
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
|
||||||
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
|
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
|
||||||
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
|
eventDispatcher.registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
|
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
|
||||||
registerListeners(
|
eventDispatcher.registerListeners(
|
||||||
eventTypes = listOf(
|
eventTypes = listOf(
|
||||||
LongPollEvent.TYPING,
|
LongPollEvent.TYPING,
|
||||||
LongPollEvent.AUDIO_MESSAGE_RECORDING,
|
LongPollEvent.AUDIO_MESSAGE_RECORDING,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -21,4 +22,14 @@ 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,6 +3,7 @@ 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
|
||||||
@@ -48,4 +49,27 @@ 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,6 +12,11 @@ interface MessagesUseCase : BaseUseCase {
|
|||||||
|
|
||||||
suspend fun storeMessage(message: VkMessage)
|
suspend fun storeMessage(message: VkMessage)
|
||||||
suspend fun storeMessages(messages: List<VkMessage>)
|
suspend fun storeMessages(messages: List<VkMessage>)
|
||||||
|
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
|
||||||
|
suspend fun getLocalMessageById(messageId: Long): VkMessage?
|
||||||
|
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
|
||||||
|
suspend fun getLocalMaxMessageId(): Long?
|
||||||
|
suspend fun deleteLocalMessages(messageIds: List<Long>)
|
||||||
|
|
||||||
fun getMessagesHistory(
|
fun getMessagesHistory(
|
||||||
convoId: Long,
|
convoId: Long,
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ class MessagesUseCaseImpl(
|
|||||||
repository.storeMessages(messages)
|
repository.storeMessages(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> {
|
||||||
|
return repository.getLocalMessages(convoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalMessageById(messageId: Long): VkMessage? {
|
||||||
|
return repository.getLocalMessageById(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? {
|
||||||
|
return repository.getLocalMessageByConvoMessageId(convoId, cmId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocalMaxMessageId(): Long? {
|
||||||
|
return repository.getLocalMaxMessageId()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
|
||||||
|
repository.deleteLocalMessages(messageIds)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMessagesHistory(
|
override fun getMessagesHistory(
|
||||||
convoId: Long,
|
convoId: Long,
|
||||||
count: Int?,
|
count: Int?,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ interface OAuthUseCase {
|
|||||||
password: String,
|
password: String,
|
||||||
forceSms: Boolean,
|
forceSms: Boolean,
|
||||||
validationCode: String?,
|
validationCode: String?,
|
||||||
captchaSid: String?,
|
captchaSid: String? = null,
|
||||||
captchaKey: String?
|
captchaKey: String? = null,
|
||||||
|
successToken: String? = null
|
||||||
): Flow<State<GetSilentTokenResponse>>
|
): Flow<State<GetSilentTokenResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,22 +22,35 @@ class OAuthUseCaseImpl(
|
|||||||
): Flow<State<AuthInfo>> = flow {
|
): Flow<State<AuthInfo>> = flow {
|
||||||
emit(State.Loading)
|
emit(State.Loading)
|
||||||
|
|
||||||
val newState = oAuthRepository.auth(
|
val newState = when (val authResult = 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(
|
)) {
|
||||||
successMapper = {
|
is com.slack.eithernet.ApiResult.Success -> {
|
||||||
AuthInfo(
|
val value = authResult.value
|
||||||
userId = it.userId!!,
|
val userId = value.userId
|
||||||
accessToken = it.accessToken!!,
|
val accessToken = value.accessToken
|
||||||
validationHash = it.validationHash!!
|
val validationHash = value.validationHash
|
||||||
)
|
|
||||||
|
if (userId == null || accessToken == null || validationHash == null) {
|
||||||
|
State.Error.InternalError
|
||||||
|
} else {
|
||||||
|
State.Success(
|
||||||
|
AuthInfo(
|
||||||
|
userId = userId,
|
||||||
|
accessToken = accessToken,
|
||||||
|
validationHash = validationHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
else -> authResult.asState()
|
||||||
|
}
|
||||||
|
|
||||||
emit(newState)
|
emit(newState)
|
||||||
}
|
}
|
||||||
@@ -48,7 +61,8 @@ 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)
|
||||||
|
|
||||||
@@ -58,7 +72,8 @@ 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)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ fun VkConvo.extractAvatar(): UiImage = when (peerType) {
|
|||||||
PeerType.CHAT -> {
|
PeerType.CHAT -> {
|
||||||
photo200
|
photo200
|
||||||
}
|
}
|
||||||
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
|
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_fill_round_24)
|
||||||
|
|
||||||
fun VkConvo.extractTitle(
|
fun VkConvo.extractTitle(
|
||||||
useContactName: Boolean,
|
useContactName: Boolean,
|
||||||
@@ -470,16 +470,25 @@ fun extractActionText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAttachmentIcon(
|
fun extractAttachmentIcon(
|
||||||
lastMessage: VkMessage?
|
lastMessage: VkMessage?
|
||||||
): UiImage? = when {
|
): UiImage? = when {
|
||||||
lastMessage == null -> null
|
lastMessage == null -> null
|
||||||
lastMessage.text == null -> null
|
lastMessage.text == null -> null
|
||||||
|
lastMessage.geoType != null -> {
|
||||||
|
val geoType = lastMessage.geoType
|
||||||
|
if (geoType == "point") {
|
||||||
|
UiImage.Resource(R.drawable.ic_pin_drop_fill_round_24)
|
||||||
|
} else {
|
||||||
|
UiImage.Resource(R.drawable.ic_map_fill_round_24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
!lastMessage.forwards.isNullOrEmpty() -> {
|
!lastMessage.forwards.isNullOrEmpty() -> {
|
||||||
if (lastMessage.forwards.orEmpty().size == 1) {
|
if (lastMessage.forwards.orEmpty().size == 1) {
|
||||||
UiImage.Resource(R.drawable.ic_attachment_forwarded_message)
|
UiImage.Resource(R.drawable.ic_reply_round_24)
|
||||||
} else {
|
} else {
|
||||||
UiImage.Resource(R.drawable.ic_attachment_forwarded_messages)
|
UiImage.Resource(R.drawable.ic_reply_all_round_24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,13 +496,9 @@ private fun extractAttachmentIcon(
|
|||||||
lastMessage.attachments?.let { attachments ->
|
lastMessage.attachments?.let { attachments ->
|
||||||
if (attachments.isEmpty()) return null
|
if (attachments.isEmpty()) return null
|
||||||
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
|
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
|
||||||
lastMessage.geoType?.let {
|
|
||||||
return UiImage.Resource(R.drawable.ic_map_marker)
|
|
||||||
}
|
|
||||||
|
|
||||||
getAttachmentIconByType(attachments.first().type)
|
getAttachmentIconByType(attachments.first().type)
|
||||||
} else {
|
} else {
|
||||||
UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
|
UiImage.Resource(R.drawable.ic_attach_file_round_24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,22 +570,22 @@ fun extractAttachmentText(
|
|||||||
|
|
||||||
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
||||||
return when (attachmentType) {
|
return when (attachmentType) {
|
||||||
AttachmentType.PHOTO -> R.drawable.ic_attachment_photo
|
AttachmentType.PHOTO -> R.drawable.ic_image_fill_round_24
|
||||||
AttachmentType.VIDEO -> R.drawable.ic_attachment_video
|
AttachmentType.VIDEO -> R.drawable.ic_video_fill_round_24
|
||||||
AttachmentType.AUDIO -> R.drawable.ic_attachment_audio
|
AttachmentType.AUDIO -> R.drawable.ic_music_note_round_24
|
||||||
AttachmentType.FILE -> R.drawable.ic_attachment_file
|
AttachmentType.FILE -> R.drawable.ic_draft_fill_round_24
|
||||||
AttachmentType.LINK -> R.drawable.ic_attachment_link
|
AttachmentType.LINK -> R.drawable.ic_language_round_24
|
||||||
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_attachment_voice
|
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_mic_fill_round_24
|
||||||
AttachmentType.MINI_APP -> R.drawable.ic_attachment_mini_app
|
AttachmentType.MINI_APP -> R.drawable.ic_widgets_fill_round_24
|
||||||
AttachmentType.STICKER -> R.drawable.ic_attachment_sticker
|
AttachmentType.STICKER -> R.drawable.ic_sticker_fill_round_24
|
||||||
AttachmentType.GIFT -> R.drawable.ic_attachment_gift
|
AttachmentType.GIFT -> R.drawable.ic_attachment_gift_old
|
||||||
AttachmentType.WALL -> R.drawable.ic_attachment_wall
|
AttachmentType.WALL -> R.drawable.ic_brick_fill_round_24
|
||||||
AttachmentType.GRAFFITI -> R.drawable.ic_attachment_graffiti
|
AttachmentType.GRAFFITI -> R.drawable.ic_fragrance_fill_round_24
|
||||||
AttachmentType.POLL -> R.drawable.ic_attachment_poll
|
AttachmentType.POLL -> R.drawable.ic_insert_chart_fill_round_24
|
||||||
AttachmentType.WALL_REPLY -> R.drawable.ic_attachment_wall_reply
|
AttachmentType.WALL_REPLY -> R.drawable.ic_comment_fill_round_24
|
||||||
AttachmentType.CALL -> R.drawable.ic_attachment_call
|
AttachmentType.CALL -> R.drawable.ic_call_round_24
|
||||||
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_attachment_group_call
|
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_perm_phone_msg_fill_round_24
|
||||||
AttachmentType.STORY -> R.drawable.ic_attachment_story
|
AttachmentType.STORY -> R.drawable.ic_history_toggle_off_round_24
|
||||||
AttachmentType.UNKNOWN -> null
|
AttachmentType.UNKNOWN -> null
|
||||||
AttachmentType.CURATOR -> null
|
AttachmentType.CURATOR -> null
|
||||||
AttachmentType.EVENT -> null
|
AttachmentType.EVENT -> null
|
||||||
@@ -591,7 +596,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
|||||||
AttachmentType.NARRATIVE -> null
|
AttachmentType.NARRATIVE -> null
|
||||||
AttachmentType.ARTICLE -> null
|
AttachmentType.ARTICLE -> null
|
||||||
AttachmentType.VIDEO_MESSAGE -> null
|
AttachmentType.VIDEO_MESSAGE -> null
|
||||||
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_attachment_sticker
|
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
|
||||||
AttachmentType.STICKER_PACK_PREVIEW -> null
|
AttachmentType.STICKER_PACK_PREVIEW -> null
|
||||||
}?.let(UiImage::Resource)
|
}?.let(UiImage::Resource)
|
||||||
}
|
}
|
||||||
@@ -685,20 +690,6 @@ fun getAttachmentUiText(
|
|||||||
}.let(UiText::Resource)
|
}.let(UiText::Resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAttachmentConvoIcon(message: VkMessage?): UiImage? {
|
|
||||||
return message?.attachments?.let { attachments ->
|
|
||||||
if (attachments.isEmpty()) return null
|
|
||||||
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
|
|
||||||
message.geoType?.let {
|
|
||||||
return UiImage.Resource(R.drawable.ic_map_marker)
|
|
||||||
}
|
|
||||||
getAttachmentIconByType(attachments.first().type)
|
|
||||||
} else {
|
|
||||||
UiImage.Resource(R.drawable.ic_baseline_attach_file_24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun extractBirthday(convo: VkConvo): Boolean {
|
fun extractBirthday(convo: VkConvo): Boolean {
|
||||||
val birthday = convo.user?.birthday ?: return false
|
val birthday = convo.user?.birthday ?: return false
|
||||||
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
|
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ fun VkConvo.asPresentation(
|
|||||||
monthShort = { resources.getString(R.string.month_short) },
|
monthShort = { resources.getString(R.string.month_short) },
|
||||||
weekShort = { resources.getString(R.string.week_short) },
|
weekShort = { resources.getString(R.string.week_short) },
|
||||||
dayShort = { resources.getString(R.string.day_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) },
|
now = { resources.getString(R.string.time_now) },
|
||||||
),
|
),
|
||||||
message = extractMessage(resources, lastMessage, id, peerType),
|
message = extractMessage(resources, lastMessage, id, peerType),
|
||||||
attachmentImage = if (lastMessage?.text == null) null
|
attachmentImage = if (lastMessage?.text == null) null
|
||||||
else getAttachmentConvoIcon(lastMessage),
|
else extractAttachmentIcon(lastMessage),
|
||||||
isPinned = majorId > 0,
|
isPinned = majorId > 0,
|
||||||
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
|
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
|
||||||
isBirthday = extractBirthday(this),
|
isBirthday = extractBirthday(this),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ fun VkMessage.extractAvatar() = when {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_cut)
|
}?.let(UiImage::Url) ?: UiImage.Resource(R.drawable.ic_account_circle_fill_round_24)
|
||||||
|
|
||||||
fun VkMessage.extractDate(): String =
|
fun VkMessage.extractDate(): String =
|
||||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
|
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "dev.meloda.fast.datastore"
|
namespace = "dev.meloda.fast.model"
|
||||||
}
|
}
|
||||||
|
|
||||||
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.bundles.compose)
|
implementation(libs.compose.ui)
|
||||||
|
|
||||||
implementation(libs.room.ktx)
|
implementation(libs.room.ktx)
|
||||||
implementation(libs.room.runtime)
|
implementation(libs.room.runtime)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
|
|||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class VkWidgetData(
|
data class VkWidgetData(
|
||||||
val id: Long
|
val id: Long?
|
||||||
) : VkAttachmentData {
|
) : VkAttachmentData {
|
||||||
|
|
||||||
fun toDomain() = VkWidgetDomain(id)
|
fun toDomain() = VkWidgetDomain(id)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package dev.meloda.fast.model.api.domain
|
package dev.meloda.fast.model.api.domain
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import dev.meloda.fast.model.api.data.AttachmentType
|
import dev.meloda.fast.model.api.data.AttachmentType
|
||||||
|
|
||||||
|
@Immutable
|
||||||
interface VkAttachment {
|
interface VkAttachment {
|
||||||
val type: AttachmentType
|
val type: AttachmentType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
|
|||||||
import dev.meloda.fast.model.api.data.AttachmentType
|
import dev.meloda.fast.model.api.data.AttachmentType
|
||||||
|
|
||||||
data class VkWidgetDomain(
|
data class VkWidgetDomain(
|
||||||
val id: Long
|
val id: Long?
|
||||||
) : VkAttachment {
|
) : VkAttachment {
|
||||||
|
|
||||||
override val type: AttachmentType = AttachmentType.WIDGET
|
override val type: AttachmentType = AttachmentType.WIDGET
|
||||||
|
|||||||
@@ -19,3 +19,27 @@ data class LongPollGetUpdatesRequest(
|
|||||||
"version" to version.toString()
|
"version" to version.toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class LongPollGetHistoryRequest(
|
||||||
|
val ts: Int,
|
||||||
|
val pts: Int,
|
||||||
|
val lpVersion: Int,
|
||||||
|
val lastN: Int? = null,
|
||||||
|
val maxMsgId: Long? = null,
|
||||||
|
val eventsLimit: Int? = null,
|
||||||
|
val msgsLimit: Int? = null,
|
||||||
|
val extended: Boolean = true,
|
||||||
|
) {
|
||||||
|
val map: Map<String, String>
|
||||||
|
get() = mutableMapOf(
|
||||||
|
"ts" to ts.toString(),
|
||||||
|
"pts" to pts.toString(),
|
||||||
|
"lp_version" to lpVersion.toString(),
|
||||||
|
"extended" to if (extended) "1" else "0",
|
||||||
|
).apply {
|
||||||
|
lastN?.let { this["last_n"] = it.toString() }
|
||||||
|
maxMsgId?.let { this["max_msg_id"] = it.toString() }
|
||||||
|
eventsLimit?.let { this["events_limit"] = it.toString() }
|
||||||
|
msgsLimit?.let { this["msgs_limit"] = it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ data class AuthDirectRequest(
|
|||||||
val validationCode: String? = null,
|
val validationCode: String? = null,
|
||||||
val captchaSid: String? = null,
|
val captchaSid: String? = null,
|
||||||
val captchaKey: String? = null,
|
val captchaKey: String? = null,
|
||||||
val trustedHash: String? = null
|
val trustedHash: String? = null,
|
||||||
|
val successToken: String? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val map
|
val map
|
||||||
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
|
|||||||
captchaSid?.let { this["captcha_sid"] = it }
|
captchaSid?.let { this["captcha_sid"] = it }
|
||||||
captchaKey?.let { this["captcha_key"] = it }
|
captchaKey?.let { this["captcha_key"] = it }
|
||||||
trustedHash?.let { this["trusted_hash"] = it }
|
trustedHash?.let { this["trusted_hash"] = it }
|
||||||
|
successToken?.let { this["success_token"] = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ sealed class OAuthErrorDomain {
|
|||||||
|
|
||||||
data class CaptchaRequiredError(
|
data class CaptchaRequiredError(
|
||||||
val captchaSid: String,
|
val captchaSid: String,
|
||||||
val captchaImageUrl: String
|
val captchaImageUrl: String,
|
||||||
|
val redirectUri: String?
|
||||||
) : OAuthErrorDomain()
|
) : OAuthErrorDomain()
|
||||||
|
|
||||||
data class UserBannedError(
|
data class UserBannedError(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
|
|||||||
},
|
},
|
||||||
onFailure = { failure ->
|
onFailure = { failure ->
|
||||||
if (failure is JsonDataException) {
|
if (failure is JsonDataException) {
|
||||||
|
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
RestApiError(
|
RestApiError(
|
||||||
errorCode = -1,
|
errorCode = -1,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package dev.meloda.fast.network
|
|||||||
|
|
||||||
enum class ValidationType(val value: String) {
|
enum class ValidationType(val value: String) {
|
||||||
APP("2fa_app"),
|
APP("2fa_app"),
|
||||||
SMS("2fa_sms");
|
SMS("sms"),
|
||||||
|
SMS2("2fa_sms");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(value: String): ValidationType =
|
fun parse(value: String): ValidationType =
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import dev.meloda.fast.network.JsonConverter
|
|||||||
import dev.meloda.fast.network.MoshiConverter
|
import dev.meloda.fast.network.MoshiConverter
|
||||||
import dev.meloda.fast.network.OAuthResultCallFactory
|
import dev.meloda.fast.network.OAuthResultCallFactory
|
||||||
import dev.meloda.fast.network.ResponseConverterFactory
|
import dev.meloda.fast.network.ResponseConverterFactory
|
||||||
|
import dev.meloda.fast.network.interceptor.Error14HandlingInterceptor
|
||||||
import dev.meloda.fast.network.interceptor.LanguageInterceptor
|
import dev.meloda.fast.network.interceptor.LanguageInterceptor
|
||||||
import dev.meloda.fast.network.interceptor.VersionInterceptor
|
import dev.meloda.fast.network.interceptor.VersionInterceptor
|
||||||
import dev.meloda.fast.network.service.account.AccountService
|
import dev.meloda.fast.network.service.account.AccountService
|
||||||
@@ -45,6 +46,7 @@ val networkModule = module {
|
|||||||
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
|
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
|
||||||
singleOf(::VersionInterceptor)
|
singleOf(::VersionInterceptor)
|
||||||
singleOf(::LanguageInterceptor)
|
singleOf(::LanguageInterceptor)
|
||||||
|
singleOf(::Error14HandlingInterceptor)
|
||||||
|
|
||||||
single<OkHttpClient>(named("auth")) {
|
single<OkHttpClient>(named("auth")) {
|
||||||
buildHttpClient(true)
|
buildHttpClient(true)
|
||||||
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
|
|||||||
addInterceptor(get(named("token_interceptor")) as Interceptor)
|
addInterceptor(get(named("token_interceptor")) as Interceptor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.addInterceptor(get<Error14HandlingInterceptor>())
|
||||||
.addInterceptor(get<VersionInterceptor>())
|
.addInterceptor(get<VersionInterceptor>())
|
||||||
.addInterceptor(get<LanguageInterceptor>())
|
.addInterceptor(get<LanguageInterceptor>())
|
||||||
.addInterceptor(get<ChuckerInterceptor>())
|
.addInterceptor(get<ChuckerInterceptor>())
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.meloda.fast.network.interceptor
|
||||||
|
|
||||||
|
import dev.meloda.fast.datastore.AppSettings
|
||||||
|
import dev.meloda.fast.datastore.CaptchaTokenResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
class Error14HandlingInterceptor : Interceptor {
|
||||||
|
|
||||||
|
private val cookie = AtomicReference<String?>(null)
|
||||||
|
private val captchaMutex = Mutex()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request().withCookie()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
response.parseCookie()
|
||||||
|
|
||||||
|
if (request.shouldSkipCaptcha()) return response
|
||||||
|
|
||||||
|
val redirectUri = response.getRedirectUri() ?: return response
|
||||||
|
val token = awaitCaptchaToken(redirectUri)
|
||||||
|
|
||||||
|
return chain.proceed(chain.request().withCookie().withSuccessToken(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitCaptchaToken(redirectUri: String): String = runBlocking(Dispatchers.IO) {
|
||||||
|
captchaMutex.withLock {
|
||||||
|
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
|
||||||
|
AppSettings.setCaptchaRedirectUri(redirectUri)
|
||||||
|
|
||||||
|
try {
|
||||||
|
withTimeout(CAPTCHA_TIMEOUT) {
|
||||||
|
AppSettings.getCaptchaResultFlow()
|
||||||
|
.first { it != CaptchaTokenResult.Initial }
|
||||||
|
.toToken()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
|
||||||
|
AppSettings.setCaptchaRedirectUri(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CaptchaTokenResult.toToken(): String = when (this) {
|
||||||
|
is CaptchaTokenResult.Success -> token
|
||||||
|
CaptchaTokenResult.Cancelled -> throw IOException("Captcha cancelled")
|
||||||
|
CaptchaTokenResult.Null -> throw IOException("Captcha result is empty")
|
||||||
|
CaptchaTokenResult.Initial -> throw IllegalStateException("Captcha result not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.withSuccessToken(token: String): Request {
|
||||||
|
return newBuilder()
|
||||||
|
.url(url.newBuilder().addQueryParameter("success_token", token).build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.getRedirectUri(): String? {
|
||||||
|
val responseBody = JSONObject(peekBody(Long.MAX_VALUE).string())
|
||||||
|
return if (responseBody.has("error")) {
|
||||||
|
val stringError = try {
|
||||||
|
responseBody.getString("error")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringError != null) {
|
||||||
|
if (stringError == CAPTCHA_ERROR_KIND && responseBody.has("redirect_uri")) {
|
||||||
|
responseBody.getString("redirect_uri")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val error = responseBody.getJSONObject("error")
|
||||||
|
if (error.getInt("error_code") == CAPTCHA_ERROR_CODE) {
|
||||||
|
error.getString("redirect_uri")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.shouldSkipCaptcha(): Boolean {
|
||||||
|
return false
|
||||||
|
// return !domains.contains(url.toUrl().host) && domains.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.parseCookie() {
|
||||||
|
headers("Set-Cookie").firstOrNull { it.contains("remixstlid") }?.let(cookie::set)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.withCookie(): Request {
|
||||||
|
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val CAPTCHA_ERROR_CODE = 14
|
||||||
|
private const val CAPTCHA_ERROR_KIND = "need_captcha"
|
||||||
|
private val CAPTCHA_TIMEOUT = 10.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -2,6 +2,7 @@ package dev.meloda.fast.network.service.messages
|
|||||||
|
|
||||||
import com.slack.eithernet.ApiResult
|
import com.slack.eithernet.ApiResult
|
||||||
import dev.meloda.fast.model.api.data.VkChatData
|
import dev.meloda.fast.model.api.data.VkChatData
|
||||||
|
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
|
||||||
import dev.meloda.fast.model.api.data.VkLongPollData
|
import dev.meloda.fast.model.api.data.VkLongPollData
|
||||||
import dev.meloda.fast.model.api.data.VkMessageData
|
import dev.meloda.fast.model.api.data.VkMessageData
|
||||||
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
|
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
|
||||||
@@ -44,6 +45,12 @@ interface MessagesService {
|
|||||||
@FieldMap params: Map<String, String>
|
@FieldMap params: Map<String, String>
|
||||||
): ApiResult<ApiResponse<VkLongPollData>, RestApiError>
|
): ApiResult<ApiResponse<VkLongPollData>, RestApiError>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@POST(MessagesUrls.GET_LONG_POLL_HISTORY)
|
||||||
|
suspend fun getLongPollHistory(
|
||||||
|
@FieldMap params: Map<String, String>
|
||||||
|
): ApiResult<ApiResponse<LongPollHistoryResponse>, RestApiError>
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST(MessagesUrls.MARK_AS_READ)
|
@POST(MessagesUrls.MARK_AS_READ)
|
||||||
suspend fun markAsRead(
|
suspend fun markAsRead(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.meloda.fast.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
|
||||||
|
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_TYPE_NORMAL
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.Wallpapers.BLUE_DOMINATED_EXAMPLE
|
||||||
|
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||||
|
import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE
|
||||||
|
import androidx.compose.ui.tooling.preview.Wallpapers.YELLOW_DOMINATED_EXAMPLE
|
||||||
|
|
||||||
|
@Preview(name = "70%", fontScale = 0.70f)
|
||||||
|
@Preview(name = "85%", fontScale = 0.85f)
|
||||||
|
@Preview(name = "100%", fontScale = 1.0f)
|
||||||
|
@Preview(name = "115%", fontScale = 1.15f)
|
||||||
|
@Preview(name = "130%", fontScale = 1.3f)
|
||||||
|
@Preview(name = "150%", fontScale = 1.5f)
|
||||||
|
@Preview(name = "180%", fontScale = 1.8f)
|
||||||
|
@Preview(name = "200%", fontScale = 2f)
|
||||||
|
|
||||||
|
@Preview(name = "Light")
|
||||||
|
@Preview(name = "Red", wallpaper = RED_DOMINATED_EXAMPLE)
|
||||||
|
@Preview(name = "Blue", wallpaper = BLUE_DOMINATED_EXAMPLE)
|
||||||
|
@Preview(name = "Green", wallpaper = GREEN_DOMINATED_EXAMPLE)
|
||||||
|
@Preview(name = "Yellow", wallpaper = YELLOW_DOMINATED_EXAMPLE)
|
||||||
|
|
||||||
|
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
|
||||||
|
@Preview(
|
||||||
|
name = "Dark Red",
|
||||||
|
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
|
||||||
|
wallpaper = RED_DOMINATED_EXAMPLE
|
||||||
|
)
|
||||||
|
@Preview(
|
||||||
|
name = "Dark Blue",
|
||||||
|
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
|
||||||
|
wallpaper = BLUE_DOMINATED_EXAMPLE
|
||||||
|
)
|
||||||
|
@Preview(
|
||||||
|
name = "Dark Green",
|
||||||
|
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
|
||||||
|
wallpaper = GREEN_DOMINATED_EXAMPLE
|
||||||
|
)
|
||||||
|
@Preview(
|
||||||
|
name = "Dark Yellow",
|
||||||
|
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
|
||||||
|
wallpaper = YELLOW_DOMINATED_EXAMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
annotation class FastPreview
|
||||||
@@ -25,7 +25,7 @@ import dev.meloda.fast.ui.R
|
|||||||
@Composable
|
@Composable
|
||||||
fun ErrorView(
|
fun ErrorView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
iconResId: Int? = R.drawable.round_error_24,
|
iconResId: Int? = R.drawable.ic_error_fill_round_24,
|
||||||
text: String,
|
text: String,
|
||||||
buttonText: String? = null,
|
buttonText: String? = null,
|
||||||
onButtonClick: (() -> Unit)? = null,
|
onButtonClick: (() -> Unit)? = null,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package dev.meloda.fast.ui.components
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,19 +11,27 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialogDefaults
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
import androidx.compose.material3.BasicAlertDialog
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -30,9 +40,22 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onPlaced
|
import androidx.compose.ui.layout.onPlaced
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewDynamicColors
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewFontScale
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import dev.meloda.fast.ui.R
|
||||||
|
import dev.meloda.fast.ui.common.FastPreview
|
||||||
|
import dev.meloda.fast.ui.theme.AppTheme
|
||||||
import dev.meloda.fast.ui.util.ImmutableList
|
import dev.meloda.fast.ui.util.ImmutableList
|
||||||
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||||
|
|
||||||
@@ -41,23 +64,31 @@ import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
|||||||
fun MaterialDialog(
|
fun MaterialDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
confirmText: String? = null,
|
icon: ImageVector? = null,
|
||||||
confirmAction: (() -> Unit)? = null,
|
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||||
cancelText: String? = null,
|
|
||||||
cancelAction: (() -> Unit)? = null,
|
|
||||||
neutralText: String? = null,
|
|
||||||
neutralAction: (() -> Unit)? = null,
|
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
text: String? = null,
|
text: String? = null,
|
||||||
selectionType: SelectionType = SelectionType.None,
|
selectionType: SelectionType = SelectionType.None,
|
||||||
items: ImmutableList<String> = ImmutableList.empty(),
|
items: ImmutableList<String> = ImmutableList.empty(),
|
||||||
preSelectedItems: ImmutableList<Int> = ImmutableList.empty(),
|
preSelectedItems: ImmutableList<Int> = ImmutableList.empty(),
|
||||||
onItemClick: ((index: Int) -> Unit)? = null,
|
onItemClick: ((index: Int) -> Unit)? = null,
|
||||||
|
confirmText: String? = null,
|
||||||
|
confirmAction: (() -> Unit)? = null,
|
||||||
|
confirmContainerColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
confirmContentColor: Color = MaterialTheme.colorScheme.contentColorFor(confirmContainerColor),
|
||||||
|
cancelText: String? = null,
|
||||||
|
cancelAction: (() -> Unit)? = null,
|
||||||
|
cancelContainerColor: Color = Color.Transparent,
|
||||||
|
cancelContentColor: Color = MaterialTheme.colorScheme.contentColorFor(cancelContainerColor),
|
||||||
|
neutralText: String? = null,
|
||||||
|
neutralAction: (() -> Unit)? = null,
|
||||||
|
neutralContainerColor: Color = Color.Transparent,
|
||||||
|
neutralContentColor: Color = MaterialTheme.colorScheme.contentColorFor(neutralContainerColor),
|
||||||
properties: DialogProperties = DialogProperties(),
|
properties: DialogProperties = DialogProperties(),
|
||||||
actionInvokeDismiss: ActionInvokeDismiss = ActionInvokeDismiss.IfNoAction,
|
actionInvokeDismiss: ActionInvokeDismiss = ActionInvokeDismiss.IfNoAction,
|
||||||
customContent: (@Composable ColumnScope.() -> Unit)? = null
|
customContent: (@Composable ColumnScope.() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
var alertItems by remember {
|
var alertItems by remember(items, preSelectedItems) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
items.mapIndexed { index, title ->
|
items.mapIndexed { index, title ->
|
||||||
DialogItem(
|
DialogItem(
|
||||||
@@ -77,6 +108,13 @@ fun MaterialDialog(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
|
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
|
||||||
val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
|
val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
|
||||||
|
val shouldAddVerticalPadding = remember(
|
||||||
|
icon, title, text, items,
|
||||||
|
confirmText, cancelText, neutralText
|
||||||
|
) {
|
||||||
|
icon != null || title != null || text != null || items.isNotEmpty() ||
|
||||||
|
confirmText != null || cancelText != null || neutralText != null
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -84,19 +122,33 @@ fun MaterialDialog(
|
|||||||
shape = AlertDialogDefaults.shape,
|
shape = AlertDialogDefaults.shape,
|
||||||
tonalElevation = AlertDialogDefaults.TonalElevation
|
tonalElevation = AlertDialogDefaults.TonalElevation
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(bottom = 10.dp)) {
|
Column(
|
||||||
if (title != null) {
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
) {
|
||||||
|
if (shouldAddVerticalPadding) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
if (icon != null) {
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
Icon(
|
||||||
Text(
|
imageVector = icon,
|
||||||
modifier = Modifier.weight(1f),
|
contentDescription = null,
|
||||||
text = title,
|
tint = iconTint,
|
||||||
style = MaterialTheme.typography.headlineSmall
|
modifier = Modifier.size(30.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(isPlaced && canScrollBackward) {
|
AnimatedVisibility(isPlaced && canScrollBackward) {
|
||||||
@@ -110,25 +162,22 @@ fun MaterialDialog(
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onPlaced { isPlaced = true }
|
.onPlaced { isPlaced = true }
|
||||||
) {
|
) {
|
||||||
if (text != null && title == null) {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text != null) {
|
if (text != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
Row {
|
Row(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
if (text != null || title != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (alertItems.isNotEmpty()) {
|
if (alertItems.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@@ -158,7 +207,7 @@ fun MaterialDialog(
|
|||||||
alertItems = newItems.toImmutableList()
|
alertItems = newItems.toImmutableList()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
} else {
|
} else {
|
||||||
customContent?.invoke(this)
|
customContent?.invoke(this)
|
||||||
}
|
}
|
||||||
@@ -168,67 +217,77 @@ fun MaterialDialog(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
if (confirmText != null || cancelText != null || neutralText != null) {
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
Column(
|
||||||
if (neutralText != null) {
|
modifier = Modifier
|
||||||
TextButton(
|
.padding(horizontal = 24.dp)
|
||||||
onClick = {
|
.padding(top = 10.dp),
|
||||||
neutralAction?.invoke() ?: kotlin.run {
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
) {
|
||||||
|
if (confirmText != null) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
val hadAction = confirmAction != null
|
||||||
|
confirmAction?.invoke()
|
||||||
|
|
||||||
|
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
containerColor = confirmContainerColor,
|
||||||
onDismissRequest()
|
contentColor = confirmContentColor
|
||||||
}
|
)
|
||||||
|
) {
|
||||||
|
Text(text = confirmText)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Text(text = neutralText)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
if (cancelText != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
val hadAction = cancelAction != null
|
||||||
|
cancelAction?.invoke()
|
||||||
|
|
||||||
if (cancelText != null) {
|
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
cancelAction?.invoke() ?: kotlin.run {
|
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
containerColor = cancelContainerColor,
|
||||||
onDismissRequest()
|
contentColor = cancelContentColor
|
||||||
}
|
)
|
||||||
|
) {
|
||||||
|
Text(text = cancelText)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Text(text = cancelText)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
if (neutralText != null) {
|
||||||
|
TextButton(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
val hadAction = neutralAction != null
|
||||||
|
neutralAction?.invoke()
|
||||||
|
|
||||||
if (confirmText != null) {
|
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
confirmAction?.invoke() ?: kotlin.run {
|
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
containerColor = neutralContainerColor,
|
||||||
onDismissRequest()
|
contentColor = neutralContentColor
|
||||||
}
|
)
|
||||||
|
) {
|
||||||
|
Text(text = neutralText)
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Text(text = confirmText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
if (shouldAddVerticalPadding) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,41 +312,40 @@ fun AlertItems(
|
|||||||
} else {
|
} else {
|
||||||
onItemClick?.invoke(index)
|
onItemClick?.invoke(index)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
.padding(horizontal = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
when (selectionType) {
|
when (selectionType) {
|
||||||
SelectionType.Multi -> {
|
SelectionType.Multi -> {
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = item.isSelected,
|
checked = item.isSelected,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
onItemCheckedChanged?.invoke(index)
|
onItemCheckedChanged?.invoke(index)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectionType.Single -> {
|
SelectionType.Single -> {
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = item.isSelected,
|
selected = item.isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
onItemClick?.invoke(index)
|
onItemClick?.invoke(index)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectionType.None -> {
|
SelectionType.None -> {
|
||||||
Spacer(modifier = Modifier.width(26.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
text = item.title,
|
text = item.title,
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,3 +366,93 @@ sealed class SelectionType {
|
|||||||
data object Multi : SelectionType()
|
data object Multi : SelectionType()
|
||||||
data object None : SelectionType()
|
data object None : SelectionType()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FastPreview
|
||||||
|
@Composable
|
||||||
|
private fun MaterialDialogPreview() {
|
||||||
|
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||||
|
MaterialDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = "Material Dialog",
|
||||||
|
text = "This is a preview of a Material dialog.",
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
icon = ImageVector.vectorResource(R.drawable.ic_info_round_24)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FastPreview
|
||||||
|
@Composable
|
||||||
|
private fun MaterialDialogWithListPreview() {
|
||||||
|
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||||
|
MaterialDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = "Material Dialog",
|
||||||
|
text = "This is a preview of a Material dialog.",
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
items = listOf("Item 1", "Item 2", "Item 3").toImmutableList(),
|
||||||
|
selectionType = SelectionType.Single,
|
||||||
|
icon = ImageVector.vectorResource(R.drawable.ic_info_round_24)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FastPreview
|
||||||
|
@Composable
|
||||||
|
private fun MaterialDialogWithCustomContent() {
|
||||||
|
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||||
|
MaterialDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
title = "Material Dialog",
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
icon = ImageVector.vectorResource(R.drawable.ic_info_round_24)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.weight(1f),
|
||||||
|
value = "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text(text = "Text") },
|
||||||
|
placeholder = { Text(text = "Text") },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FastPreview
|
||||||
|
@Composable
|
||||||
|
private fun MaterialDialogWithOnlyCustomContent() {
|
||||||
|
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||||
|
MaterialDialog(onDismissRequest = {}) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.weight(1f),
|
||||||
|
value = "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = { Text(text = "Text") },
|
||||||
|
placeholder = { Text(text = "Text") },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.meloda.fast.ui.components
|
package dev.meloda.fast.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -8,15 +9,17 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.meloda.fast.ui.R
|
import dev.meloda.fast.ui.R
|
||||||
|
import dev.meloda.fast.ui.common.FastPreview
|
||||||
|
import dev.meloda.fast.ui.theme.AppTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoItemsView(
|
fun NoItemsView(
|
||||||
@@ -49,11 +52,15 @@ fun NoItemsView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@FastPreview
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoItemsViewPreview() {
|
private fun NoItemsViewPreview() {
|
||||||
NoItemsView(
|
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||||
customText = "Nothing here...",
|
Surface {
|
||||||
buttonText = "Refresh"
|
NoItemsView(
|
||||||
)
|
customText = "Nothing here...",
|
||||||
|
buttonText = "Refresh"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package dev.meloda.fast.ui.extensions
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> ProvidableCompositionLocal<T?>.getOrThrow(): T {
|
fun <T> ProvidableCompositionLocal<T?>.getOrThrow(): T {
|
||||||
return requireNotNull(current)
|
return requireNotNull(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun Modifier.ifTrue(
|
||||||
|
condition: Boolean,
|
||||||
|
block: Modifier.() -> Modifier
|
||||||
|
): Modifier = if (condition) block() else this
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.koin.androidx.compose.koinViewModel
|
|||||||
import org.koin.core.parameter.ParametersDefinition
|
import org.koin.core.parameter.ParametersDefinition
|
||||||
import org.koin.core.qualifier.Qualifier
|
import org.koin.core.qualifier.Qualifier
|
||||||
|
|
||||||
|
@Suppress("ParamsComparedByRef")
|
||||||
@Composable
|
@Composable
|
||||||
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
|
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
|
|||||||
@@ -11,31 +11,31 @@ sealed class ConvoOption(
|
|||||||
|
|
||||||
data object MarkAsRead : ConvoOption(
|
data object MarkAsRead : ConvoOption(
|
||||||
title = UiText.Resource(R.string.action_mark_as_read),
|
title = UiText.Resource(R.string.action_mark_as_read),
|
||||||
icon = UiImage.Resource(R.drawable.round_done_all_24)
|
icon = UiImage.Resource(R.drawable.ic_done_all_round_24)
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Pin : ConvoOption(
|
data object Pin : ConvoOption(
|
||||||
title = UiText.Resource(R.string.action_pin),
|
title = UiText.Resource(R.string.action_pin),
|
||||||
icon = UiImage.Resource(R.drawable.pin_outline_24)
|
icon = UiImage.Resource(R.drawable.ic_keep_round_24)
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Unpin : ConvoOption(
|
data object Unpin : ConvoOption(
|
||||||
title = UiText.Resource(R.string.action_unpin),
|
title = UiText.Resource(R.string.action_unpin),
|
||||||
icon = UiImage.Resource(R.drawable.pin_off_outline_24)
|
icon = UiImage.Resource(R.drawable.ic_keep_off_round_24)
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Delete : ConvoOption(
|
data object Delete : ConvoOption(
|
||||||
title = UiText.Resource(R.string.action_delete),
|
title = UiText.Resource(R.string.action_delete),
|
||||||
icon = UiImage.Resource(R.drawable.round_delete_outline_24)
|
icon = UiImage.Resource(R.drawable.ic_delete_round_24)
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Archive : ConvoOption(
|
data object Archive : ConvoOption(
|
||||||
title = UiText.Resource(R.string.convo_context_action_archive),
|
title = UiText.Resource(R.string.convo_context_action_archive),
|
||||||
icon = UiImage.Resource(R.drawable.outline_archive_24)
|
icon = UiImage.Resource(R.drawable.ic_archive_round_24)
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Unarchive : ConvoOption(
|
data object Unarchive : ConvoOption(
|
||||||
title = UiText.Resource(R.string.convo_context_action_unarchive),
|
title = UiText.Resource(R.string.convo_context_action_unarchive),
|
||||||
icon = UiImage.Resource(R.drawable.outline_unarchive_24)
|
icon = UiImage.Resource(R.drawable.ic_unarchive_round_24)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
|
|||||||
|
|
||||||
operator fun get(index: Int): T = values[index]
|
operator fun get(index: Int): T = values[index]
|
||||||
|
|
||||||
inline fun forEach(action: (T) -> Unit) {
|
|
||||||
for (element in values) action(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
|
inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
|
||||||
return values.map(transform).toImmutableList()
|
return values.map(transform).toImmutableList()
|
||||||
}
|
}
|
||||||
@@ -49,13 +45,15 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
|
|||||||
if (elements.isNotEmpty()) copyOf(elements.asList()) else empty()
|
if (elements.isNotEmpty()) copyOf(elements.asList()) else empty()
|
||||||
|
|
||||||
fun <T> of(element: T) = ImmutableList(listOf(element))
|
fun <T> of(element: T) = ImmutableList(listOf(element))
|
||||||
|
|
||||||
|
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun iterator(): Iterator<T> = values.listIterator()
|
override fun iterator(): Iterator<T> = values.listIterator()
|
||||||
|
|
||||||
|
val lastIndex: Int get() = this.size - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
|
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
|
||||||
|
|
||||||
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
|
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
|
||||||
|
|
||||||
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM12,6c1.93,0 3.5,1.57 3.5,3.5S13.93,13 12,13s-3.5,-1.57 -3.5,-3.5S10.07,6 12,6zM12,20c-2.03,0 -4.43,-0.82 -6.14,-2.88C7.55,15.8 9.68,15 12,15s4.45,0.8 6.14,2.12C16.43,19.18 14.03,20 12,20z" />
|
|
||||||
|
|
||||||
</vector>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:autoMirrored="true"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z" />
|
|
||||||
|
|
||||||
</vector>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M16.67,13.13C18.04,14.06 19,15.32 19,17v3h4v-3C23,14.82 19.43,13.53 16.67,13.13z" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M9,8m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M15,12c2.21,0 4,-1.79 4,-4c0,-2.21 -1.79,-4 -4,-4c-0.47,0 -0.91,0.1 -1.33,0.24C14.5,5.27 15,6.58 15,8s-0.5,2.73 -1.33,3.76C14.09,11.9 14.53,12 15,12z" />
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M9,13c-2.67,0 -8,1.34 -8,4v3h16v-3C17,14.34 11.67,13 9,13z" />
|
|
||||||
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="48"
|
|
||||||
android:viewportHeight="48">
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M24,0C10.7452,0 0,10.7452 0,24C0,37.2548 10.7452,48 24,48C37.2548,48 48,37.2548 48,24C48,10.7452 37.2548,0 24,0ZM32.25,13.8152C32.25,9.4191 28.7383,6 24.5,6C20.2617,6 16.75,9.4191 16.75,13.8152C16.75,18.0891 20.2617,21.6304 24.5,21.6304C28.7383,21.6304 32.25,18.0891 32.25,13.8152ZM9,34.5743C12.3906,39.5809 18.082,43 24.5,43C30.918,43 36.6094,39.5809 40,34.5743C39.8789,29.3234 29.5859,26.5149 24.5,26.5149C19.293,26.5149 9.1211,29.3234 9,34.5743Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM480,520Q421,520 380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM480,520Q421,520 380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q533,800 580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800ZM480,440Q506,440 523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,261Q120,247 124.5,234Q129,221 138,210L188,149Q199,135 215.5,127.5Q232,120 250,120L710,120Q728,120 744.5,127.5Q761,135 772,149L822,210Q831,221 835.5,234Q840,247 840,261L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM216,240L744,240L710,200Q710,200 710,200Q710,200 710,200L250,200Q250,200 250,200Q250,200 250,200L216,240ZM480,400Q463,400 451.5,411.5Q440,423 440,440L440,568L404,532Q393,521 376,521Q359,521 348,532Q337,543 337,560Q337,577 348,588L452,692Q464,704 480,704Q496,704 508,692L612,588Q623,577 623,560Q623,543 612,532Q601,521 584,521Q567,521 556,532L520,568L520,440Q520,423 508.5,411.5Q497,400 480,400Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M480,400Q463,400 451.5,411.5Q440,423 440,440L440,568L404,532Q393,521 376,521Q359,521 348,532Q337,543 337,560Q337,577 348,588L452,692Q464,704 480,704Q496,704 508,692L612,588Q623,577 623,560Q623,543 612,532Q601,521 584,521Q567,521 556,532L520,568L520,440Q520,423 508.5,411.5Q497,400 480,400ZM200,320L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,320L200,320ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,261Q120,247 124.5,234Q129,221 138,210L188,149Q199,135 215.5,127.5Q232,120 250,120L710,120Q728,120 744.5,127.5Q761,135 772,149L822,210Q831,221 835.5,234Q840,247 840,261L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM216,240L744,240L710,200Q710,200 710,200Q710,200 710,200L250,200Q250,200 250,200Q250,200 250,200L216,240ZM480,540L480,540L480,540Q480,540 480,540Q480,540 480,540L480,540Q480,540 480,540Q480,540 480,540Z"/>
|
||||||
|
</vector>
|
||||||
+3
-5
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:autoMirrored="true"
|
|
||||||
android:viewportWidth="960"
|
android:viewportWidth="960"
|
||||||
android:viewportHeight="960">
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#ffffff"
|
android:fillColor="#ffffff"
|
||||||
android:pathData="M313,520L509,716Q521,728 520.5,744Q520,760 508,772Q496,783 480,783.5Q464,784 452,772L188,508Q182,502 179.5,495Q177,488 177,480Q177,472 179.5,465Q182,458 188,452L452,188Q463,177 479.5,177Q496,177 508,188Q520,200 520,216.5Q520,233 508,245L313,440L760,440Q777,440 788.5,451.5Q800,463 800,480Q800,497 788.5,508.5Q777,520 760,520L313,520Z" />
|
android:pathData="M313,520L509,716Q521,728 520.5,744Q520,760 508,772Q496,783 480,783.5Q464,784 452,772L188,508Q182,502 179.5,495Q177,488 177,480Q177,472 179.5,465Q182,458 188,452L452,188Q463,177 479.5,177Q496,177 508,188Q520,200 520,216.5Q520,233 508,245L313,440L760,440Q777,440 788.5,451.5Q800,463 800,480Q800,497 788.5,508.5Q777,520 760,520L313,520Z"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:pathData="M5,13L16.17,13l-4.88,4.88a1.008,1.008 0,0 0,-0 1.42,1 1,0 0,0 1.41,-0L19.29,12.71a1,1 0,0 0,-0 -1.41l-6.59,-6.59a1,1 0,0 0,-1.41 1.41L16.17,11L5,11a1,1 0,0 0,-0 2Z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M647,520L200,520Q183,520 171.5,508.5Q160,497 160,480Q160,463 171.5,451.5Q183,440 200,440L647,440L451,244Q439,232 439.5,216Q440,200 452,188Q464,177 480,176.5Q496,176 508,188L772,452Q778,458 780.5,465Q783,472 783,480Q783,488 780.5,495Q778,502 772,508L508,772Q497,783 480.5,783Q464,783 452,772Q440,760 440,743.5Q440,727 452,715L647,520Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:pathData="M720,630Q720,734 647,807Q574,880 470,880Q366,880 293,807Q220,734 220,630L220,260Q220,185 272.5,132.5Q325,80 400,80Q475,80 527.5,132.5Q580,185 580,260L580,610Q580,656 548,688Q516,720 470,720Q424,720 392,688Q360,656 360,610L360,280Q360,263 371.5,251.5Q383,240 400,240Q417,240 428.5,251.5Q440,263 440,280L440,610Q440,623 448.5,631.5Q457,640 470,640Q483,640 491.5,631.5Q500,623 500,610L500,260Q499,218 470.5,189Q442,160 400,160Q358,160 329,189Q300,218 300,260L300,630Q299,701 349,750.5Q399,800 470,800Q540,800 589,750.5Q638,701 640,630L640,280Q640,263 651.5,251.5Q663,240 680,240Q697,240 708.5,251.5Q720,263 720,280L720,630Z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:pathData="M12 3V13.55C11.41 13.21 10.73 13 10 13C7.79 13 6 14.79 6 17S7.79 21 10 21 14 19.21 14 17V7H18V3H12Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:pathData="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:pathData="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:pathData="M13,9V5L6,12L13,19V14.9C18,14.9 21.5,16.5 24,20C23,15 20,10 13,9M7,8V5L0,12L7,19V16L3,12L7,8Z" />
|
|
||||||
</vector>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user