Compare commits
27 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 |
@@ -40,7 +40,7 @@ jobs:
|
||||
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload APK with original name
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: ${{ env.APK_PATH }}
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload APK with original name
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: ${{ env.APK_PATH }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload release APK
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: app-release.apk
|
||||
path: app/build/outputs/apk/release/app-release.apk
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
- name: Upload release Bundle
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: 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.
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "dev.meloda.fastvk"
|
||||
|
||||
versionCode = 10
|
||||
versionName = "0.2.2"
|
||||
versionCode = 11
|
||||
versionName = "0.2.3"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -79,6 +79,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.acra.email)
|
||||
implementation(libs.acra.dialog)
|
||||
|
||||
implementation(projects.feature.auth)
|
||||
|
||||
implementation(projects.feature.chatmaterials)
|
||||
|
||||
@@ -2,7 +2,6 @@ package dev.meloda.fast
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
@@ -38,6 +37,7 @@ interface MainViewModel {
|
||||
val startDestination: StateFlow<Any?>
|
||||
val isNeedToReplaceWithAuth: StateFlow<Boolean>
|
||||
val currentUser: StateFlow<VkUser?>
|
||||
val baseError: StateFlow<BaseError?>
|
||||
|
||||
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
|
||||
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
|
||||
@@ -45,6 +45,7 @@ interface MainViewModel {
|
||||
val isNeedToRequestNotifications: StateFlow<Boolean>
|
||||
|
||||
fun onError(error: BaseError)
|
||||
fun onErrorConsumed()
|
||||
|
||||
fun onNavigatedToAuth()
|
||||
|
||||
@@ -73,6 +74,7 @@ class MainViewModelImpl(
|
||||
override val startDestination = MutableStateFlow<Any?>(null)
|
||||
override val isNeedToReplaceWithAuth = MutableStateFlow(false)
|
||||
override val currentUser = MutableStateFlow<VkUser?>(null)
|
||||
override val baseError = MutableStateFlow<BaseError?>(null)
|
||||
|
||||
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
|
||||
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
|
||||
@@ -82,6 +84,10 @@ class MainViewModelImpl(
|
||||
private var openNotificationsSettings = false
|
||||
private var openAppSettings = false
|
||||
|
||||
init {
|
||||
listenLongPollState()
|
||||
}
|
||||
|
||||
override fun onError(error: BaseError) {
|
||||
when (error) {
|
||||
BaseError.SessionExpired,
|
||||
@@ -89,10 +95,16 @@ class MainViewModelImpl(
|
||||
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() {
|
||||
isNeedToReplaceWithAuth.update { false }
|
||||
}
|
||||
@@ -203,10 +215,6 @@ class MainViewModelImpl(
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentAccount = getCurrentAccountUseCase()
|
||||
|
||||
Log.d("MainViewModel", "currentAccount: $currentAccount")
|
||||
|
||||
listenLongPollState()
|
||||
|
||||
if (currentAccount != null) {
|
||||
UserConfig.apply {
|
||||
this.userId = currentAccount.userId
|
||||
|
||||
@@ -4,8 +4,14 @@ import android.app.Application
|
||||
import androidx.preference.PreferenceManager
|
||||
import coil.ImageLoader
|
||||
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.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.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
@@ -18,10 +24,14 @@ class AppGlobal : Application(), ImageLoaderFactory {
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
AppSettings.init(preferences)
|
||||
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
|
||||
|
||||
initKoin()
|
||||
initAcra()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader = get()
|
||||
|
||||
private fun initKoin() {
|
||||
startKoin {
|
||||
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.annotation.ExperimentalCoilApi
|
||||
import dev.meloda.fast.MainViewModelImpl
|
||||
import dev.meloda.fast.auth.captcha.di.captchaModule
|
||||
import dev.meloda.fast.auth.login.di.loginModule
|
||||
import dev.meloda.fast.auth.validation.di.validationModule
|
||||
import dev.meloda.fast.auth.authModule
|
||||
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.LongPollControllerImpl
|
||||
@@ -38,9 +36,7 @@ import org.koin.dsl.module
|
||||
val applicationModule = module {
|
||||
includes(domainModule)
|
||||
includes(
|
||||
loginModule,
|
||||
validationModule,
|
||||
captchaModule,
|
||||
authModule,
|
||||
convosModule,
|
||||
settingsModule,
|
||||
messagesHistoryModule,
|
||||
|
||||
@@ -8,9 +8,9 @@ import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.model.BottomNavigationItem
|
||||
import dev.meloda.fast.presentation.MainScreen
|
||||
import dev.meloda.fast.profile.navigation.Profile
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.util.ImmutableList
|
||||
import kotlinx.serialization.Serializable
|
||||
import dev.meloda.fast.ui.R
|
||||
|
||||
@Serializable
|
||||
object MainGraph
|
||||
@@ -29,20 +29,20 @@ fun NavGraphBuilder.mainScreen(
|
||||
val navigationItems = ImmutableList.of(
|
||||
BottomNavigationItem(
|
||||
titleResId = R.string.title_friends,
|
||||
selectedIconResId = R.drawable.baseline_people_alt_24,
|
||||
unselectedIconResId = R.drawable.outline_people_alt_24,
|
||||
selectedIconResId = R.drawable.ic_group_fill_round_24,
|
||||
unselectedIconResId = R.drawable.ic_group_round_24,
|
||||
route = Friends,
|
||||
),
|
||||
BottomNavigationItem(
|
||||
titleResId = R.string.title_convos,
|
||||
selectedIconResId = R.drawable.baseline_chat_24,
|
||||
unselectedIconResId = R.drawable.outline_chat_24,
|
||||
selectedIconResId = R.drawable.ic_mail_fill_round_24,
|
||||
unselectedIconResId = R.drawable.ic_mail_round_24,
|
||||
route = ConvoGraph
|
||||
),
|
||||
BottomNavigationItem(
|
||||
titleResId = R.string.title_profile,
|
||||
selectedIconResId = R.drawable.baseline_account_circle_24,
|
||||
unselectedIconResId = R.drawable.outline_account_circle_24,
|
||||
selectedIconResId = R.drawable.ic_account_circle_fill_round_24,
|
||||
unselectedIconResId = R.drawable.ic_account_circle_round_24,
|
||||
route = Profile
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -65,9 +64,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContent {
|
||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||
LaunchedEffect(viewModel) {
|
||||
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(true) {
|
||||
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.BottomNavigationItem
|
||||
import dev.meloda.fast.navigation.MainGraph
|
||||
import dev.meloda.fast.profile.navigation.Profile
|
||||
import dev.meloda.fast.profile.navigation.profileScreen
|
||||
import dev.meloda.fast.ui.theme.LocalBottomPadding
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
@@ -69,15 +70,18 @@ fun MainScreen(
|
||||
val theme = LocalThemeConfig.current
|
||||
val hazeState = remember { HazeState(true) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var selectedItemIndex by rememberSaveable {
|
||||
mutableIntStateOf(1)
|
||||
val defaultTabIndex = remember(navigationItems) {
|
||||
navigationItems.indexOfFirst { it.route == ConvoGraph }.takeIf { it >= 0 } ?: 0
|
||||
}
|
||||
|
||||
BackHandler(enabled = selectedItemIndex != 1) {
|
||||
var selectedItemIndex by rememberSaveable {
|
||||
mutableIntStateOf(defaultTabIndex)
|
||||
}
|
||||
|
||||
BackHandler(enabled = selectedItemIndex != defaultTabIndex) {
|
||||
val currentRoute = navigationItems[selectedItemIndex].route
|
||||
|
||||
selectedItemIndex = 1
|
||||
selectedItemIndex = defaultTabIndex
|
||||
navController.navigate(navigationItems[selectedItemIndex].route) {
|
||||
popUpTo(route = currentRoute) {
|
||||
inclusive = true
|
||||
@@ -127,7 +131,7 @@ fun MainScreen(
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (index == navigationItems.size - 1) {
|
||||
if (item.route == Profile) {
|
||||
var isLoading by remember {
|
||||
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.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -41,6 +37,7 @@ import com.google.accompanist.permissions.rememberPermissionState
|
||||
import dev.meloda.fast.MainViewModel
|
||||
import dev.meloda.fast.MainViewModelImpl
|
||||
import dev.meloda.fast.auth.authNavGraph
|
||||
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
|
||||
import dev.meloda.fast.auth.navigateToAuth
|
||||
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
|
||||
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.convos.navigation.createChatScreen
|
||||
import dev.meloda.fast.convos.navigation.navigateToCreateChat
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.datastore.CaptchaTokenResult
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
|
||||
import dev.meloda.fast.languagepicker.navigation.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.LocalThemeConfig
|
||||
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.immutableListOf
|
||||
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
@@ -90,10 +87,7 @@ fun RootScreen(
|
||||
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
|
||||
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
|
||||
|
||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||
LaunchedEffect(viewModel) {
|
||||
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
|
||||
}
|
||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||
|
||||
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -124,14 +118,12 @@ fun RootScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(longPollStateToApply) {
|
||||
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
|
||||
LifecycleResumeEffect(longPollStateToApply) {
|
||||
if (longPollStateToApply != LongPollState.Background) {
|
||||
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
|
||||
&& longPollCurrentState != longPollStateToApply
|
||||
) {
|
||||
toggleLongPollService(false, null)
|
||||
Log.d("LongPoll", "recreate()")
|
||||
}
|
||||
|
||||
toggleLongPollService(
|
||||
@@ -240,6 +232,7 @@ fun RootScreen(
|
||||
val context = LocalContext.current
|
||||
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
|
||||
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
|
||||
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
|
||||
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.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) {
|
||||
CompositionLocalProvider(
|
||||
LocalNavRootController provides navController,
|
||||
LocalNavController provides navController
|
||||
) {
|
||||
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()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -333,10 +335,10 @@ fun RootScreen(
|
||||
onSettingsButtonClicked = navController::navigateToSettings,
|
||||
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
|
||||
onPhotoClicked = { url ->
|
||||
photoViewerInfo = immutableListOf(url) to null
|
||||
photoViewerInfo = listOf(url) to null
|
||||
},
|
||||
onMessageClicked = navController::navigateToMessagesHistory,
|
||||
onNavigateToCreateChat = navController::navigateToCreateChat
|
||||
onNavigateToCreateChat = navController::navigateToCreateChat,
|
||||
)
|
||||
|
||||
messagesHistoryScreen(
|
||||
@@ -344,13 +346,13 @@ fun RootScreen(
|
||||
onBack = navController::navigateUp,
|
||||
onNavigateToChatMaterials = navController::navigateToChatMaterials,
|
||||
onNavigateToPhotoViewer = { photos, index ->
|
||||
photoViewerInfo = photos.toImmutableList() to index
|
||||
photoViewerInfo = photos to index
|
||||
}
|
||||
)
|
||||
chatMaterialsScreen(
|
||||
onBack = navController::navigateUp,
|
||||
onPhotoClicked = { url ->
|
||||
photoViewerInfo = immutableListOf(url) to null
|
||||
photoViewerInfo = listOf(url) to null
|
||||
}
|
||||
)
|
||||
createChatScreen(
|
||||
@@ -378,9 +380,23 @@ fun RootScreen(
|
||||
}
|
||||
|
||||
PhotoViewDialog(
|
||||
photoViewerInfo = photoViewerInfo,
|
||||
photoViewerInfo = photoViewerInfo?.let { info ->
|
||||
info.first.toImmutableList() to info.second
|
||||
},
|
||||
onDismiss = { photoViewerInfo = null }
|
||||
)
|
||||
|
||||
CaptchaScreen(
|
||||
captchaRedirectUri = captchaRedirectUri,
|
||||
onBack = {
|
||||
AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled)
|
||||
},
|
||||
onResult = { result ->
|
||||
AppSettings.setCaptchaResult(
|
||||
CaptchaTokenResult.Success(result)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.data.VkMemoryCache
|
||||
import dev.meloda.fast.data.UserConfig
|
||||
import dev.meloda.fast.data.processState
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.domain.LongPollUpdatesParser
|
||||
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.VkLongPollData
|
||||
import dev.meloda.fast.ui.R
|
||||
@@ -32,10 +38,11 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.flow.last
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class LongPollingService : Service() {
|
||||
@@ -55,6 +62,8 @@ class LongPollingService : Service() {
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
|
||||
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 var currentJob: Job? = null
|
||||
@@ -150,6 +159,8 @@ class LongPollingService : Service() {
|
||||
var serverInfo = getServerInfo()
|
||||
?: throw LongPollException(message = "bad VK response (server info)")
|
||||
|
||||
syncLongPollHistory(serverInfo)
|
||||
|
||||
var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo)
|
||||
?: throw LongPollException(message = "initiation error: bad VK response (last updates)")
|
||||
|
||||
@@ -160,6 +171,7 @@ class LongPollingService : Service() {
|
||||
failCount++
|
||||
serverInfo = getServerInfo()
|
||||
?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)")
|
||||
syncLongPollHistory(serverInfo)
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo)
|
||||
continue
|
||||
}
|
||||
@@ -179,6 +191,7 @@ class LongPollingService : Service() {
|
||||
?: throw LongPollException(
|
||||
message = "failed retrieving server info after error: bad VK response (server info #3)"
|
||||
)
|
||||
syncLongPollHistory(serverInfo)
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo)
|
||||
}
|
||||
|
||||
@@ -196,6 +209,9 @@ class LongPollingService : Service() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -204,7 +220,7 @@ class LongPollingService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
|
||||
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
|
||||
longPollUseCase.getLongPollServer(
|
||||
needPts = true,
|
||||
version = VkConstants.LP_VERSION
|
||||
@@ -224,7 +240,7 @@ class LongPollingService : Service() {
|
||||
|
||||
private suspend fun getUpdatesResponse(
|
||||
server: VkLongPollData
|
||||
): LongPollUpdates? = suspendCoroutine {
|
||||
): LongPollUpdates? = suspendCancellableCoroutine {
|
||||
longPollUseCase.getLongPollUpdates(
|
||||
serverUrl = "https://${server.server}",
|
||||
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) {
|
||||
Log.e(TAG, "error: $throwable")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.meloda.fast.service.longpolling.di
|
||||
|
||||
import dev.meloda.fast.domain.LongPollUpdatesParser
|
||||
import dev.meloda.fast.domain.LongPollUpdatesReducer
|
||||
import dev.meloda.fast.domain.LongPollUseCase
|
||||
import dev.meloda.fast.domain.LongPollUseCaseImpl
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
@@ -10,4 +11,5 @@ import org.koin.dsl.module
|
||||
val longPollModule = module {
|
||||
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
|
||||
singleOf(::LongPollUpdatesParser)
|
||||
singleOf(::LongPollUpdatesReducer)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
|
||||
with(target) {
|
||||
apply(plugin = "com.android.application")
|
||||
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
|
||||
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
|
||||
|
||||
val extension = extensions.getByType<ApplicationExtension>()
|
||||
configureAndroidCompose(extension)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import dev.meloda.fast.configureKotlinAndroid
|
||||
import dev.meloda.fast.getVersionInt
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
@@ -14,9 +15,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
|
||||
extensions.configure<ApplicationExtension> {
|
||||
configureKotlinAndroid(this)
|
||||
defaultConfig {
|
||||
targetSdk = 36
|
||||
compileSdk = 36
|
||||
minSdk = 23
|
||||
minSdk = getVersionInt("minSdk")
|
||||
compileSdk = getVersionInt("compileSdk")
|
||||
targetSdk = getVersionInt("targetSdk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
import dev.meloda.fast.configureAndroidCompose
|
||||
import dev.meloda.fast.getVersionInt
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.apply
|
||||
import org.gradle.kotlin.dsl.getByType
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
|
||||
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {
|
||||
with(target) {
|
||||
apply(plugin = "com.android.library")
|
||||
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
|
||||
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
|
||||
|
||||
val extension = extensions.getByType<LibraryExtension>()
|
||||
extension.androidResources.enable = false
|
||||
configureAndroidCompose(extension)
|
||||
extensions.configure<LibraryExtension> {
|
||||
configureAndroidCompose(this)
|
||||
androidResources.enable = false
|
||||
defaultConfig {
|
||||
minSdk = getVersionInt("minSdk")
|
||||
compileSdk = getVersionInt("compileSdk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import com.android.build.api.dsl.TestExtension
|
||||
import dev.meloda.fast.configureKotlinAndroid
|
||||
import dev.meloda.fast.getVersionInt
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
@@ -13,7 +14,11 @@ class AndroidTestConventionPlugin : Plugin<Project> {
|
||||
|
||||
extensions.configure<TestExtension> {
|
||||
configureKotlinAndroid(this)
|
||||
defaultConfig.targetSdk = 36
|
||||
defaultConfig {
|
||||
minSdk = getVersionInt("minSdk")
|
||||
compileSdk = getVersionInt("compileSdk")
|
||||
targetSdk = getVersionInt("targetSdk")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ internal fun Project.configureKotlinAndroid(
|
||||
}
|
||||
|
||||
commonExtension.apply {
|
||||
compileSdk = 36
|
||||
compileSdk = getVersionInt("compileSdk")
|
||||
buildToolsVersion = "36.1.0"
|
||||
}
|
||||
|
||||
configureKotlin<KotlinAndroidProjectExtension>()
|
||||
@@ -61,6 +62,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-Xannotation-default-target=param-property",
|
||||
"-Xcontext-parameters"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType
|
||||
|
||||
val Project.libs
|
||||
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||||
|
||||
|
||||
fun Project.getVersionInt(alias: String): Int {
|
||||
return libs.findVersion(alias).get().requiredVersion.toInt()
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ plugins {
|
||||
alias(libs.plugins.ksp) apply false
|
||||
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.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -45,6 +46,14 @@ fun <T> Flow<T>.listenValue(
|
||||
action: suspend (T) -> Unit
|
||||
): Job = onEach(action::invoke).launchIn(coroutineScope)
|
||||
|
||||
fun CoroutineScope.launchDbRefresh(
|
||||
load: suspend () -> Unit,
|
||||
after: suspend () -> Unit
|
||||
): Job = launch {
|
||||
load()
|
||||
after()
|
||||
}
|
||||
|
||||
fun createTimerFlow(
|
||||
time: Int,
|
||||
onStartAction: (suspend () -> Unit)? = null,
|
||||
|
||||
@@ -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
|
||||
|
||||
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.millisecond
|
||||
import com.conena.nanokt.jvm.util.minute
|
||||
@@ -12,6 +11,12 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
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 {
|
||||
|
||||
@@ -56,37 +61,23 @@ object TimeUtils {
|
||||
monthShort: () -> String,
|
||||
weekShort: () -> String,
|
||||
dayShort: () -> String,
|
||||
minuteShort: () -> String,
|
||||
secondShort: () -> String,
|
||||
now: () -> String
|
||||
): String {
|
||||
val now = Calendar.getInstance()
|
||||
val then = Calendar.getInstance().also { it.timeInMillis = date }
|
||||
val now = Clock.System.now()
|
||||
val then = Instant.fromEpochMilliseconds(date)
|
||||
val diff = now - then
|
||||
|
||||
return when {
|
||||
now.year != then.year -> {
|
||||
"${now.year - then.year}${yearShort().lowercase()}"
|
||||
}
|
||||
|
||||
now.month != then.month -> {
|
||||
"${now.month - then.month}${monthShort().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)
|
||||
}
|
||||
diff > 365.days -> "${diff.inWholeDays / 365}${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()}"
|
||||
diff > 1.hours -> "${diff.inWholeHours}h"
|
||||
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
|
||||
diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
|
||||
else -> now().lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package dev.meloda.fast.common.util
|
||||
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun String.urlEncode(encoding: String = "utf-8"): String {
|
||||
return URLEncoder.encode(this, encoding)
|
||||
}
|
||||
|
||||
fun String.sha256() = this.hashString("SHA-256")
|
||||
|
||||
fun String.hashString(algorithm: String): String {
|
||||
return MessageDigest
|
||||
.getInstance(algorithm)
|
||||
.digest(this.toByteArray())
|
||||
.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
package dev.meloda.fast.data
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.net.URLEncoder
|
||||
|
||||
class AccessTokenInterceptor : Interceptor {
|
||||
|
||||
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 (uri.getQueryParameter("access_token") == null) {
|
||||
builder.addQueryParameter(
|
||||
"access_token",
|
||||
URLEncoder.encode(UserConfig.accessToken, "utf-8")
|
||||
)
|
||||
if (request.url.queryParameter("access_token") == null) {
|
||||
urlBuilder.addQueryParameter("access_token", UserConfig.accessToken)
|
||||
}
|
||||
|
||||
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 = ""
|
||||
fastToken = ""
|
||||
userId = -1
|
||||
trustedHash = null
|
||||
exchangeToken = null
|
||||
AppSettings.LongPoll.clear()
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean {
|
||||
|
||||
@@ -21,12 +21,10 @@ class VkGroupsMap(
|
||||
else map[abs(convo.id)]
|
||||
|
||||
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
|
||||
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
|
||||
else map[abs(message.actionMemberId!!)]
|
||||
message.actionMemberId?.takeIf { it < 0 }?.let { map[abs(it)] }
|
||||
|
||||
fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
|
||||
if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null
|
||||
else map[abs(message.action!!.memberId!!)]
|
||||
message.action?.memberId?.takeIf { it < 0 }?.let { map[abs(it)] }
|
||||
|
||||
fun messageGroup(message: VkMessage): VkGroupDomain? =
|
||||
if (!message.isGroup()) null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.VkConvo
|
||||
import dev.meloda.fast.model.api.domain.VkGroupDomain
|
||||
@@ -38,7 +37,15 @@ object VkMemoryCache {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,10 @@ class VkUsersMap(
|
||||
else map[convo.id]
|
||||
|
||||
fun messageActionUser(message: VkMessage): VkUser? =
|
||||
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
|
||||
else map[message.actionMemberId]
|
||||
message.actionMemberId?.takeIf { it > 0 }?.let(map::get)
|
||||
|
||||
fun messageActionUser(message: VkMessageData): VkUser? =
|
||||
if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null
|
||||
else map[message.action!!.memberId]
|
||||
message.action?.memberId?.takeIf { it > 0 }?.let(map::get)
|
||||
|
||||
fun messageUser(message: VkMessage): VkUser? =
|
||||
if (!message.isUser()) null
|
||||
|
||||
@@ -8,6 +8,9 @@ import dev.meloda.fast.network.RestApiErrorDomain
|
||||
interface ConvosRepository {
|
||||
|
||||
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(
|
||||
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.VkUser
|
||||
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.network.RestApiErrorDomain
|
||||
import dev.meloda.fast.network.mapApiDefault
|
||||
import dev.meloda.fast.network.mapApiResult
|
||||
import dev.meloda.fast.network.service.convos.ConvosService
|
||||
import dev.meloda.fast.model.database.asExternalModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -40,6 +42,28 @@ class ConvosRepositoryImpl(
|
||||
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(
|
||||
count: Int?,
|
||||
offset: Int?,
|
||||
@@ -198,4 +222,28 @@ class ConvosRepositoryImpl(
|
||||
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
|
||||
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
|
||||
|
||||
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.network.RestApiErrorDomain
|
||||
import com.slack.eithernet.ApiResult
|
||||
@@ -21,4 +22,14 @@ interface LongPollRepository {
|
||||
mode: Int,
|
||||
version: Int
|
||||
): 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
|
||||
|
||||
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.requests.LongPollGetHistoryRequest
|
||||
import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest
|
||||
import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest
|
||||
import dev.meloda.fast.network.RestApiErrorDomain
|
||||
@@ -52,4 +54,29 @@ class LongPollRepositoryImpl(
|
||||
|
||||
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 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(
|
||||
convoId: Long,
|
||||
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.VkUser
|
||||
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.MessagesDeleteRequest
|
||||
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.MessagesGetReadPeersResponse
|
||||
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.mapApiDefault
|
||||
import dev.meloda.fast.network.mapApiResult
|
||||
@@ -354,6 +356,28 @@ class MessagesRepositoryImpl(
|
||||
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(
|
||||
peerId: Long,
|
||||
messageId: Long?,
|
||||
|
||||
@@ -23,5 +23,6 @@ interface OAuthRepository {
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ class OAuthRepositoryImpl(
|
||||
VkOAuthError.NEED_CAPTCHA -> {
|
||||
OAuthErrorDomain.CaptchaRequiredError(
|
||||
captchaSid = response.captchaSid.orEmpty(),
|
||||
captchaImageUrl = response.captchaImage.orEmpty()
|
||||
captchaImageUrl = response.captchaImage.orEmpty(),
|
||||
redirectUri = response.redirectUri
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ class OAuthRepositoryImpl(
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val requestModel = AuthDirectRequest(
|
||||
@@ -135,6 +137,7 @@ class OAuthRepositoryImpl(
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey,
|
||||
successToken = successToken
|
||||
)
|
||||
|
||||
oAuthService.getSilentToken(requestModel.map).mapResult(
|
||||
@@ -175,7 +178,8 @@ class OAuthRepositoryImpl(
|
||||
VkOAuthError.NEED_CAPTCHA -> {
|
||||
OAuthErrorDomain.CaptchaRequiredError(
|
||||
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
|
||||
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>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
|
||||
abstract suspend fun getAllWithMessage(): List<ConvoWithMessage>
|
||||
|
||||
@Query("SELECT * FROM convos WHERE id IN (:ids)")
|
||||
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
|
||||
|
||||
@@ -22,6 +26,9 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
|
||||
@Query("SELECT * FROM convos WHERE id IS (:id)")
|
||||
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
|
||||
|
||||
@Query("DELETE FROM convos WHERE id IS (:id)")
|
||||
abstract suspend fun deleteById(id: Long): Int
|
||||
|
||||
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
|
||||
abstract suspend fun deleteByIds(ids: List<Int>): Int
|
||||
}
|
||||
|
||||
@@ -10,15 +10,21 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
|
||||
@Query("SELECT * FROM messages")
|
||||
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>
|
||||
|
||||
@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)")
|
||||
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)")
|
||||
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 dev.meloda.fast.common.model.DarkMode
|
||||
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.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 {
|
||||
|
||||
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) {
|
||||
this.preferences = preferences
|
||||
}
|
||||
@@ -211,6 +230,21 @@ object AppSettings {
|
||||
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 {
|
||||
var showAlertAfterCrash: Boolean
|
||||
get() = get(
|
||||
|
||||
@@ -39,6 +39,8 @@ object SettingsKeys {
|
||||
const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯"
|
||||
const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background"
|
||||
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 DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false
|
||||
|
||||
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface ConvoUseCase : BaseUseCase {
|
||||
|
||||
suspend fun storeConvos(convos: List<VkConvo>)
|
||||
suspend fun getLocalConvos(): List<VkConvo>
|
||||
suspend fun getLocalConvoById(peerId: Long): VkConvo?
|
||||
suspend fun deleteLocalConvo(peerId: Long)
|
||||
|
||||
fun getConvos(
|
||||
count: Int? = null,
|
||||
|
||||
@@ -19,6 +19,18 @@ class ConvoUseCaseImpl(
|
||||
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(
|
||||
count: Int?,
|
||||
offset: Int?,
|
||||
|
||||
@@ -6,10 +6,7 @@ import dev.meloda.fast.model.database.AccountEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class GetCurrentAccountUseCase(
|
||||
private val accountsRepository: AccountsRepository
|
||||
) {
|
||||
|
||||
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
|
||||
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
|
||||
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
|
||||
|
||||
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.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class LongPollUpdatesParser(
|
||||
private val convoUseCase: ConvoUseCase,
|
||||
private val messagesUseCase: MessagesUseCase
|
||||
convoUseCase: ConvoUseCase,
|
||||
messagesUseCase: MessagesUseCase
|
||||
) {
|
||||
private val job = SupervisorJob()
|
||||
|
||||
@@ -43,747 +25,89 @@ class LongPollUpdatesParser(
|
||||
get() = Dispatchers.Default + job + exceptionHandler
|
||||
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
|
||||
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
|
||||
mutableMapOf()
|
||||
private val eventDispatcher = LongPollEventDispatcher()
|
||||
private val eventParser = LongPollEventParser(
|
||||
coroutineScope = coroutineScope,
|
||||
convoUseCase = convoUseCase,
|
||||
messagesUseCase = messagesUseCase,
|
||||
dispatch = eventDispatcher::dispatch,
|
||||
dispatchAll = eventDispatcher::dispatchAll
|
||||
)
|
||||
|
||||
fun parseNextUpdate(event: List<Any>) {
|
||||
val eventId = event.first().asInt()
|
||||
|
||||
when (val eventType = ApiEvent.parseOrNull(eventId)) {
|
||||
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
|
||||
|
||||
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
|
||||
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
|
||||
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
|
||||
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
|
||||
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
|
||||
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
|
||||
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
|
||||
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
|
||||
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
|
||||
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
|
||||
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
|
||||
|
||||
ApiEvent.TYPING,
|
||||
ApiEvent.AUDIO_MESSAGE_RECORDING,
|
||||
ApiEvent.PHOTO_UPLOADING,
|
||||
ApiEvent.VIDEO_UPLOADING,
|
||||
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
|
||||
|
||||
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
|
||||
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
|
||||
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
|
||||
Log.d("LongPollUpdatesParser", "$eventType: $event")
|
||||
|
||||
val cmId = event[1].asLong()
|
||||
val flags = event[2].asInt()
|
||||
val peerId = event[3].asLong()
|
||||
|
||||
val eventsToSend = mutableListOf<LongPollParsedEvent>()
|
||||
|
||||
val parsedFlags = MessageFlags.parse(flags)
|
||||
parsedFlags.forEach { flag ->
|
||||
when (flag) {
|
||||
MessageFlags.IMPORTANT -> {
|
||||
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
|
||||
peerId = peerId,
|
||||
cmId = cmId,
|
||||
marked = true
|
||||
)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageFlags.SPAM -> {
|
||||
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
|
||||
peerId = peerId,
|
||||
cmId = cmId
|
||||
)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageFlags.DELETED -> {
|
||||
val eventToSend =
|
||||
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
|
||||
LongPollParsedEvent.MessageDeleted(
|
||||
peerId = peerId,
|
||||
cmId = cmId,
|
||||
forAll = true
|
||||
)
|
||||
} else {
|
||||
LongPollParsedEvent.MessageDeleted(
|
||||
peerId = peerId,
|
||||
cmId = cmId,
|
||||
forAll = false
|
||||
)
|
||||
}
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageFlags.AUDIO_LISTENED -> {
|
||||
val eventToSend = LongPollParsedEvent.AudioMessageListened(
|
||||
peerId = peerId,
|
||||
cmId = cmId
|
||||
)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
eventsToSend.forEach { eventToSend ->
|
||||
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
|
||||
Log.d("LongPollUpdatesParser", "$eventType: $event")
|
||||
|
||||
val cmId = event[1].asLong()
|
||||
val flags = event[2].asInt()
|
||||
val peerId = event[3].asLong()
|
||||
|
||||
val eventsToSend = mutableListOf<LongPollParsedEvent>()
|
||||
|
||||
val parsedFlags = MessageFlags.parse(flags)
|
||||
|
||||
coroutineScope.launch {
|
||||
parsedFlags.forEach { flag ->
|
||||
when (flag) {
|
||||
MessageFlags.IMPORTANT -> {
|
||||
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
|
||||
peerId = peerId,
|
||||
cmId = cmId,
|
||||
marked = false
|
||||
)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageFlags.SPAM -> {
|
||||
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val message = loadMessage(
|
||||
peerId = peerId,
|
||||
cmId = cmId
|
||||
)
|
||||
message?.let {
|
||||
val eventToSend =
|
||||
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageFlags.DELETED -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
val message = loadMessage(
|
||||
peerId = peerId,
|
||||
cmId = cmId
|
||||
)
|
||||
message?.let {
|
||||
val eventToSend =
|
||||
LongPollParsedEvent.MessageRestored(message = message)
|
||||
eventsToSend += eventToSend
|
||||
|
||||
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
|
||||
?.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
eventsToSend.forEach { eventToSend ->
|
||||
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
|
||||
listeners.map { vkEventCallback ->
|
||||
vkEventCallback.onEvent(eventToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
|
||||
Log.d("LongPollUpdatesParser", "$eventType: $event")
|
||||
|
||||
val cmId = event[1].asLong()
|
||||
val peerId = event[4].asLong()
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val message =
|
||||
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
|
||||
|
||||
val 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) }
|
||||
eventParser.parseNextUpdate(event)
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
|
||||
eventDispatcher.registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
|
||||
}
|
||||
|
||||
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) {
|
||||
registerListeners(
|
||||
eventDispatcher.registerListeners(
|
||||
eventTypes = listOf(
|
||||
LongPollEvent.TYPING,
|
||||
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
|
||||
|
||||
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.VkLongPollData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -21,4 +22,14 @@ interface LongPollUseCase {
|
||||
mode: Int,
|
||||
version: Int
|
||||
): 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.api.longpoll.LongPollRepository
|
||||
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.VkLongPollData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -48,4 +49,27 @@ class LongPollUseCaseImpl(
|
||||
).mapToState()
|
||||
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 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(
|
||||
convoId: Long,
|
||||
|
||||
@@ -22,6 +22,26 @@ class MessagesUseCaseImpl(
|
||||
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(
|
||||
convoId: Long,
|
||||
count: Int?,
|
||||
|
||||
@@ -21,7 +21,8 @@ interface OAuthUseCase {
|
||||
password: String,
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
captchaSid: String? = null,
|
||||
captchaKey: String? = null,
|
||||
successToken: String? = null
|
||||
): Flow<State<GetSilentTokenResponse>>
|
||||
}
|
||||
|
||||
@@ -22,22 +22,35 @@ class OAuthUseCaseImpl(
|
||||
): Flow<State<AuthInfo>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
val newState = oAuthRepository.auth(
|
||||
val newState = when (val authResult = oAuthRepository.auth(
|
||||
login = login,
|
||||
password = password,
|
||||
forceSms = forceSms,
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey
|
||||
).asState(
|
||||
successMapper = {
|
||||
AuthInfo(
|
||||
userId = it.userId!!,
|
||||
accessToken = it.accessToken!!,
|
||||
validationHash = it.validationHash!!
|
||||
)
|
||||
)) {
|
||||
is com.slack.eithernet.ApiResult.Success -> {
|
||||
val value = authResult.value
|
||||
val userId = value.userId
|
||||
val accessToken = value.accessToken
|
||||
val validationHash = value.validationHash
|
||||
|
||||
if (userId == null || accessToken == null || validationHash == null) {
|
||||
State.Error.InternalError
|
||||
} else {
|
||||
State.Success(
|
||||
AuthInfo(
|
||||
userId = userId,
|
||||
accessToken = accessToken,
|
||||
validationHash = validationHash
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
else -> authResult.asState()
|
||||
}
|
||||
|
||||
emit(newState)
|
||||
}
|
||||
@@ -48,7 +61,8 @@ class OAuthUseCaseImpl(
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): Flow<State<GetSilentTokenResponse>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
@@ -58,7 +72,8 @@ class OAuthUseCaseImpl(
|
||||
forceSms = forceSms,
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey
|
||||
captchaKey = captchaKey,
|
||||
successToken = successToken
|
||||
).asState()
|
||||
|
||||
emit(newState)
|
||||
|
||||
@@ -40,7 +40,7 @@ fun VkConvo.extractAvatar(): UiImage = when (peerType) {
|
||||
PeerType.CHAT -> {
|
||||
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(
|
||||
useContactName: Boolean,
|
||||
@@ -470,16 +470,25 @@ fun extractActionText(
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractAttachmentIcon(
|
||||
fun extractAttachmentIcon(
|
||||
lastMessage: VkMessage?
|
||||
): UiImage? = when {
|
||||
lastMessage == 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() -> {
|
||||
if (lastMessage.forwards.orEmpty().size == 1) {
|
||||
UiImage.Resource(R.drawable.ic_attachment_forwarded_message)
|
||||
UiImage.Resource(R.drawable.ic_reply_round_24)
|
||||
} 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 ->
|
||||
if (attachments.isEmpty()) return null
|
||||
if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) {
|
||||
lastMessage.geoType?.let {
|
||||
return UiImage.Resource(R.drawable.ic_map_marker)
|
||||
}
|
||||
|
||||
getAttachmentIconByType(attachments.first().type)
|
||||
} 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? {
|
||||
return when (attachmentType) {
|
||||
AttachmentType.PHOTO -> R.drawable.ic_attachment_photo
|
||||
AttachmentType.VIDEO -> R.drawable.ic_attachment_video
|
||||
AttachmentType.AUDIO -> R.drawable.ic_attachment_audio
|
||||
AttachmentType.FILE -> R.drawable.ic_attachment_file
|
||||
AttachmentType.LINK -> R.drawable.ic_attachment_link
|
||||
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_attachment_voice
|
||||
AttachmentType.MINI_APP -> R.drawable.ic_attachment_mini_app
|
||||
AttachmentType.STICKER -> R.drawable.ic_attachment_sticker
|
||||
AttachmentType.GIFT -> R.drawable.ic_attachment_gift
|
||||
AttachmentType.WALL -> R.drawable.ic_attachment_wall
|
||||
AttachmentType.GRAFFITI -> R.drawable.ic_attachment_graffiti
|
||||
AttachmentType.POLL -> R.drawable.ic_attachment_poll
|
||||
AttachmentType.WALL_REPLY -> R.drawable.ic_attachment_wall_reply
|
||||
AttachmentType.CALL -> R.drawable.ic_attachment_call
|
||||
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_attachment_group_call
|
||||
AttachmentType.STORY -> R.drawable.ic_attachment_story
|
||||
AttachmentType.PHOTO -> R.drawable.ic_image_fill_round_24
|
||||
AttachmentType.VIDEO -> R.drawable.ic_video_fill_round_24
|
||||
AttachmentType.AUDIO -> R.drawable.ic_music_note_round_24
|
||||
AttachmentType.FILE -> R.drawable.ic_draft_fill_round_24
|
||||
AttachmentType.LINK -> R.drawable.ic_language_round_24
|
||||
AttachmentType.AUDIO_MESSAGE -> R.drawable.ic_mic_fill_round_24
|
||||
AttachmentType.MINI_APP -> R.drawable.ic_widgets_fill_round_24
|
||||
AttachmentType.STICKER -> R.drawable.ic_sticker_fill_round_24
|
||||
AttachmentType.GIFT -> R.drawable.ic_attachment_gift_old
|
||||
AttachmentType.WALL -> R.drawable.ic_brick_fill_round_24
|
||||
AttachmentType.GRAFFITI -> R.drawable.ic_fragrance_fill_round_24
|
||||
AttachmentType.POLL -> R.drawable.ic_insert_chart_fill_round_24
|
||||
AttachmentType.WALL_REPLY -> R.drawable.ic_comment_fill_round_24
|
||||
AttachmentType.CALL -> R.drawable.ic_call_round_24
|
||||
AttachmentType.GROUP_CALL_IN_PROGRESS -> R.drawable.ic_perm_phone_msg_fill_round_24
|
||||
AttachmentType.STORY -> R.drawable.ic_history_toggle_off_round_24
|
||||
AttachmentType.UNKNOWN -> null
|
||||
AttachmentType.CURATOR -> null
|
||||
AttachmentType.EVENT -> null
|
||||
@@ -591,7 +596,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
|
||||
AttachmentType.NARRATIVE -> null
|
||||
AttachmentType.ARTICLE -> 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
|
||||
}?.let(UiImage::Resource)
|
||||
}
|
||||
@@ -685,20 +690,6 @@ fun getAttachmentUiText(
|
||||
}.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 {
|
||||
val birthday = convo.user?.birthday ?: return false
|
||||
val splitBirthday = birthday.split(".").mapNotNull(String::toIntOrNull)
|
||||
|
||||
@@ -27,11 +27,13 @@ fun VkConvo.asPresentation(
|
||||
monthShort = { resources.getString(R.string.month_short) },
|
||||
weekShort = { resources.getString(R.string.week_short) },
|
||||
dayShort = { resources.getString(R.string.day_short) },
|
||||
minuteShort = { resources.getString(R.string.minute_short) },
|
||||
secondShort = { resources.getString(R.string.second_short) },
|
||||
now = { resources.getString(R.string.time_now) },
|
||||
),
|
||||
message = extractMessage(resources, lastMessage, id, peerType),
|
||||
attachmentImage = if (lastMessage?.text == null) null
|
||||
else getAttachmentConvoIcon(lastMessage),
|
||||
else extractAttachmentIcon(lastMessage),
|
||||
isPinned = majorId > 0,
|
||||
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
|
||||
isBirthday = extractBirthday(this),
|
||||
|
||||
@@ -27,7 +27,7 @@ fun VkMessage.extractAvatar() = when {
|
||||
}
|
||||
|
||||
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 =
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date * 1000L)
|
||||
|
||||
@@ -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)
|
||||
data class VkWidgetData(
|
||||
val id: Long
|
||||
val id: Long?
|
||||
) : VkAttachmentData {
|
||||
|
||||
fun toDomain() = VkWidgetDomain(id)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package dev.meloda.fast.model.api.domain
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import dev.meloda.fast.model.api.data.AttachmentType
|
||||
|
||||
@Immutable
|
||||
interface VkAttachment {
|
||||
val type: AttachmentType
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
|
||||
import dev.meloda.fast.model.api.data.AttachmentType
|
||||
|
||||
data class VkWidgetDomain(
|
||||
val id: Long
|
||||
val id: Long?
|
||||
) : VkAttachment {
|
||||
|
||||
override val type: AttachmentType = AttachmentType.WIDGET
|
||||
|
||||
@@ -19,3 +19,27 @@ data class LongPollGetUpdatesRequest(
|
||||
"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 captchaSid: String? = null,
|
||||
val captchaKey: String? = null,
|
||||
val trustedHash: String? = null
|
||||
val trustedHash: String? = null,
|
||||
val successToken: String? = null
|
||||
) {
|
||||
|
||||
val map
|
||||
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
|
||||
captchaSid?.let { this["captcha_sid"] = it }
|
||||
captchaKey?.let { this["captcha_key"] = it }
|
||||
trustedHash?.let { this["trusted_hash"] = it }
|
||||
successToken?.let { this["success_token"] = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ sealed class OAuthErrorDomain {
|
||||
|
||||
data class CaptchaRequiredError(
|
||||
val captchaSid: String,
|
||||
val captchaImageUrl: String
|
||||
val captchaImageUrl: String,
|
||||
val redirectUri: String?
|
||||
) : OAuthErrorDomain()
|
||||
|
||||
data class UserBannedError(
|
||||
|
||||
@@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
|
||||
},
|
||||
onFailure = { failure ->
|
||||
if (failure is JsonDataException) {
|
||||
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
|
||||
throw ApiException(
|
||||
RestApiError(
|
||||
errorCode = -1,
|
||||
|
||||
@@ -2,7 +2,8 @@ package dev.meloda.fast.network
|
||||
|
||||
enum class ValidationType(val value: String) {
|
||||
APP("2fa_app"),
|
||||
SMS("2fa_sms");
|
||||
SMS("sms"),
|
||||
SMS2("2fa_sms");
|
||||
|
||||
companion object {
|
||||
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.OAuthResultCallFactory
|
||||
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.VersionInterceptor
|
||||
import dev.meloda.fast.network.service.account.AccountService
|
||||
@@ -45,6 +46,7 @@ val networkModule = module {
|
||||
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
|
||||
singleOf(::VersionInterceptor)
|
||||
singleOf(::LanguageInterceptor)
|
||||
singleOf(::Error14HandlingInterceptor)
|
||||
|
||||
single<OkHttpClient>(named("auth")) {
|
||||
buildHttpClient(true)
|
||||
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
|
||||
addInterceptor(get(named("token_interceptor")) as Interceptor)
|
||||
}
|
||||
}
|
||||
.addInterceptor(get<Error14HandlingInterceptor>())
|
||||
.addInterceptor(get<VersionInterceptor>())
|
||||
.addInterceptor(get<LanguageInterceptor>())
|
||||
.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 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.VkMessageData
|
||||
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
|
||||
@@ -44,6 +45,12 @@ interface MessagesService {
|
||||
@FieldMap params: Map<String, String>
|
||||
): ApiResult<ApiResponse<VkLongPollData>, RestApiError>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST(MessagesUrls.GET_LONG_POLL_HISTORY)
|
||||
suspend fun getLongPollHistory(
|
||||
@FieldMap params: Map<String, String>
|
||||
): ApiResult<ApiResponse<LongPollHistoryResponse>, RestApiError>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST(MessagesUrls.MARK_AS_READ)
|
||||
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
|
||||
fun ErrorView(
|
||||
modifier: Modifier = Modifier,
|
||||
iconResId: Int? = R.drawable.round_error_24,
|
||||
iconResId: Int? = R.drawable.ic_error_fill_round_24,
|
||||
text: String,
|
||||
buttonText: String? = null,
|
||||
onButtonClick: (() -> Unit)? = null,
|
||||
|
||||
@@ -2,6 +2,8 @@ package dev.meloda.fast.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.ColumnScope
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -30,9 +40,22 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.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.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.Companion.toImmutableList
|
||||
|
||||
@@ -41,23 +64,31 @@ import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
|
||||
fun MaterialDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
confirmText: String? = null,
|
||||
confirmAction: (() -> Unit)? = null,
|
||||
cancelText: String? = null,
|
||||
cancelAction: (() -> Unit)? = null,
|
||||
neutralText: String? = null,
|
||||
neutralAction: (() -> Unit)? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
title: String? = null,
|
||||
text: String? = null,
|
||||
selectionType: SelectionType = SelectionType.None,
|
||||
items: ImmutableList<String> = ImmutableList.empty(),
|
||||
preSelectedItems: ImmutableList<Int> = ImmutableList.empty(),
|
||||
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(),
|
||||
actionInvokeDismiss: ActionInvokeDismiss = ActionInvokeDismiss.IfNoAction,
|
||||
customContent: (@Composable ColumnScope.() -> Unit)? = null
|
||||
) {
|
||||
var alertItems by remember {
|
||||
var alertItems by remember(items, preSelectedItems) {
|
||||
mutableStateOf(
|
||||
items.mapIndexed { index, title ->
|
||||
DialogItem(
|
||||
@@ -77,6 +108,13 @@ fun MaterialDialog(
|
||||
val scrollState = rememberScrollState()
|
||||
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -84,19 +122,33 @@ fun MaterialDialog(
|
||||
shape = AlertDialogDefaults.shape,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = 10.dp)) {
|
||||
if (title != null) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (shouldAddVerticalPadding) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
}
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(30.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) {
|
||||
@@ -110,25 +162,22 @@ fun MaterialDialog(
|
||||
.verticalScroll(scrollState)
|
||||
.onPlaced { isPlaced = true }
|
||||
) {
|
||||
if (text != null && title == null) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (text != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = text,
|
||||
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()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -158,7 +207,7 @@ fun MaterialDialog(
|
||||
alertItems = newItems.toImmutableList()
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
} else {
|
||||
customContent?.invoke(this)
|
||||
}
|
||||
@@ -168,67 +217,77 @@ fun MaterialDialog(
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
if (neutralText != null) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
neutralAction?.invoke() ?: kotlin.run {
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
||||
if (confirmText != null || cancelText != null || neutralText != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (confirmText != null) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
val hadAction = confirmAction != null
|
||||
confirmAction?.invoke()
|
||||
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = confirmContainerColor,
|
||||
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) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
cancelAction?.invoke() ?: kotlin.run {
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = cancelContainerColor,
|
||||
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) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmAction?.invoke() ?: kotlin.run {
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction) {
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always || (actionInvokeDismiss == ActionInvokeDismiss.IfNoAction && !hadAction)) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
if (actionInvokeDismiss == ActionInvokeDismiss.Always) {
|
||||
onDismissRequest()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = neutralContainerColor,
|
||||
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 {
|
||||
onItemClick?.invoke(index)
|
||||
}
|
||||
},
|
||||
}
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
when (selectionType) {
|
||||
SelectionType.Multi -> {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Checkbox(
|
||||
checked = item.isSelected,
|
||||
onCheckedChange = {
|
||||
onItemCheckedChanged?.invoke(index)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
SelectionType.Single -> {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
RadioButton(
|
||||
selected = item.isSelected,
|
||||
onClick = {
|
||||
onItemClick?.invoke(index)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
SelectionType.None -> {
|
||||
Spacer(modifier = Modifier.width(26.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,3 +366,93 @@ sealed class SelectionType {
|
||||
data object Multi : 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
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.ui.common.FastPreview
|
||||
import dev.meloda.fast.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun NoItemsView(
|
||||
@@ -49,11 +52,15 @@ fun NoItemsView(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@FastPreview
|
||||
@Composable
|
||||
private fun NoItemsViewPreview() {
|
||||
NoItemsView(
|
||||
customText = "Nothing here...",
|
||||
buttonText = "Refresh"
|
||||
)
|
||||
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
|
||||
Surface {
|
||||
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.ProvidableCompositionLocal
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun <T> ProvidableCompositionLocal<T?>.getOrThrow(): T {
|
||||
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.qualifier.Qualifier
|
||||
|
||||
@Suppress("ParamsComparedByRef")
|
||||
@Composable
|
||||
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
|
||||
navController: NavController,
|
||||
|
||||
@@ -9,10 +9,6 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
|
||||
|
||||
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> {
|
||||
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()
|
||||
|
||||
fun <T> of(element: T) = ImmutableList(listOf(element))
|
||||
|
||||
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<T> = values.listIterator()
|
||||
|
||||
val lastIndex: Int get() = this.size - 1
|
||||
}
|
||||
|
||||
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
|
||||
|
||||
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="#ffffff"
|
||||
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="#ffffff"
|
||||
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>
|
||||
+3
-5
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
android:viewportHeight="960"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
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>
|
||||
|
||||
@@ -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="#ffffff"
|
||||
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>
|
||||
@@ -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="M10,4H12V6H10V4M7,3H9V5H7V3M7,6H9V8H7V6M6,8V10H4V8H6M6,5V7H4V5H6M6,2V4H4V2H6M13,22A2,2 0 0,1 11,20V10A2,2 0 0,1 13,8V7H14V4H17V7H18V8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H13M13,10V20H18V10H13Z" />
|
||||
</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="#ffffff"
|
||||
android:pathData="M20 15.5C18.75 15.5 17.55 15.3 16.43 14.93C16.08 14.82 15.69 14.9 15.41 15.17L13.21 17.37C10.38 15.93 8.06 13.62 6.62 10.79L8.82 8.58C9.1 8.31 9.18 7.92 9.07 7.57C8.7 6.45 8.5 5.25 8.5 4C8.5 3.45 8.05 3 7.5 3H4C3.45 3 3 3.45 3 4C3 13.39 10.61 21 20 21C20.55 21 21 20.55 21 20V16.5C21 15.95 20.55 15.5 20 15.5M12 3V13L15 10H21V3H12Z" />
|
||||
</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="M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||
</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="M5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3M7,7V9H9V7H7M11,7V9H13V7H11M15,7V9H17V7H15M7,11V13H9V11H7M11,11V13H13V11H11M15,11V13H17V11H15M7,15V17H9V15H7M11,15V17H13V15H11M15,15V17H17V15H15Z" />
|
||||
</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="#ffffff"
|
||||
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
|
||||
</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="M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9 17H7V10H9V17M13 17H11V7H13V17M17 17H15V13H17V17Z" />
|
||||
</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="M5.5,2C3.56,2 2,3.56 2,5.5V18.5C2,20.44 3.56,22 5.5,22H16L22,16V5.5C22,3.56 20.44,2 18.5,2H5.5M5.75,4H18.25A1.75,1.75 0 0,1 20,5.75V15H18.5C16.56,15 15,16.56 15,18.5V20H5.75A1.75,1.75 0 0,1 4,18.25V5.75A1.75,1.75 0 0,1 5.75,4M14.44,6.77C14.28,6.77 14.12,6.79 13.97,6.83C13.03,7.09 12.5,8.05 12.74,9C12.79,9.15 12.86,9.3 12.95,9.44L16.18,8.56C16.18,8.39 16.16,8.22 16.12,8.05C15.91,7.3 15.22,6.77 14.44,6.77M8.17,8.5C8,8.5 7.85,8.5 7.7,8.55C6.77,8.81 6.22,9.77 6.47,10.7C6.5,10.86 6.59,11 6.68,11.16L9.91,10.28C9.91,10.11 9.89,9.94 9.85,9.78C9.64,9 8.95,8.5 8.17,8.5M16.72,11.26L7.59,13.77C8.91,15.3 11,15.94 12.95,15.41C14.9,14.87 16.36,13.25 16.72,11.26Z" />
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user