1 Commits

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

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

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 21:47:05 +03:00
466 changed files with 8464 additions and 15105 deletions
-56
View File
@@ -1,56 +0,0 @@
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
android:
runs-on: android-jdk21
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make Gradle executable
run: chmod +x ./gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+20 -34
View File
@@ -1,10 +1,10 @@
name: Android CI Build
on:
workflow_dispatch:
permissions:
contents: read
push:
branches: [ "dev", "release/*", "hotfix/*" ]
pull_request:
branches: [ "dev", "release/*", "hotfix/*" ]
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -12,15 +12,15 @@ env:
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
build_apks:
build_apk_aab:
runs-on: ubuntu-24.04
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
@@ -29,34 +29,20 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v7
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
+24 -9
View File
@@ -1,11 +1,8 @@
name: Android CI Release
permissions:
contents: read
on:
workflow_dispatch:
push:
branches: [ "release/*"]
pull_request:
branches: [ "master" ]
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -18,10 +15,10 @@ jobs:
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
@@ -30,20 +27,38 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Build and sign debug Bundle
run: ./gradlew bundleDebug
- name: Upload debug Bundle
uses: actions/upload-artifact@v4
with:
name: app-debug.aab
path: app/build/outputs/bundle/debug/app-debug.aab
- name: Build and sign release Bundle
run: ./gradlew bundleRelease
- name: Upload release Bundle
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab
-2
View File
@@ -15,5 +15,3 @@ build/
local.properties
.idea
/.kotlin
.hotswan/
.java-version
+12 -22
View File
@@ -7,17 +7,15 @@ Unofficial messenger for russian social network VKontakte
- [x] 2FA support
- [x] Resend otp
- [x] Captcha support
- [x] Support for new authorization with service and refresh tokens
- [ ] Handle token expiration
- [x] Ability to export/import tokens
- [ ] Support for new authorization with service and refresh tokens
- [x] Conversations list
- [x] Pagination
- [x] Manual refresh
- [x] Pin & unpin conversations
- [x] Delete conversations
- [x] Archive
- [x] View archived conversations
- [x] Archive & unarchive conversations
- [ ] Archive
- [ ] View archived conversations
- [ ] Archive & unarchive conversations
- [x] Friends list
- [x] Sort alphabetically, by priority or random
- [x] Separate tab with only friends who are online
@@ -32,25 +30,17 @@ Unofficial messenger for russian social network VKontakte
- [x] Read status
- [x] Edit status
- [x] Sending status
- [x] Message's attachments
- [x] Photo
- [x] Video
- [x] Audio
- [x] File
- [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll
- [ ] Message's attachments
- [ ] Photo
- [ ] Video
- [ ] Audio
- [ ] File
- [ ] Link
- [ ] TODO
- [x] Send messages
- [x] Pinned message
- [x] Pin & unpin messages
- [x] Reply to message
- [x] Swipe to reply to message
- [ ] Reply to message
- [x] Delete message
- [x] Select multiple messages
- [x] Delete
@@ -65,7 +55,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments
- [x] Open photo
- [x] Internal viewer
- [x] External viewer
- [ ] External viewer
- [ ] Open video in external player
- [ ] TODO
- [ ] Caching
+4 -18
View File
@@ -1,4 +1,3 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties
plugins {
@@ -13,8 +12,8 @@ android {
defaultConfig {
applicationId = "dev.meloda.fastvk"
versionCode = 11
versionName = "0.2.3"
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
}
signingConfigs {
@@ -47,7 +46,7 @@ android {
applicationIdSuffix = ".debug"
}
named("release") {
signingConfig = signingConfigs.getByName("debugSigning")
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
@@ -59,18 +58,6 @@ android {
}
}
// applicationVariants.all {
// outputs.all {
// val date = System.currentTimeMillis() / 1000
// val buildType = buildType.name
// val appVersion = versionName
// val appVersionCode = versionCode
//
// val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
// (this as? BaseVariantOutputImpl)?.outputFileName = newApkName
// }
// }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -82,7 +69,7 @@ dependencies {
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
implementation(projects.feature.convos)
implementation(projects.feature.conversations)
implementation(projects.feature.languagepicker)
implementation(projects.feature.messageshistory)
implementation(projects.feature.photoviewer)
@@ -92,7 +79,6 @@ dependencies {
implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.logger)
implementation(projects.core.common)
implementation(projects.core.ui)
implementation(projects.core.data)
Binary file not shown.
+2 -9
View File
@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -38,12 +37,6 @@
</intent-filter>
</activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service
android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true"
@@ -2,6 +2,7 @@ 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
@@ -22,37 +23,66 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.settings.navigation.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class MainViewModel(
interface MainViewModel {
val startDestination: StateFlow<Any?>
val isNeedToReplaceWithAuth: StateFlow<Boolean>
val currentUser: StateFlow<VkUser?>
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
val isNeedToCheckNotificationsPermission: StateFlow<Boolean>
val isNeedToRequestNotifications: StateFlow<Boolean>
fun onError(error: BaseError)
fun onNavigatedToAuth()
fun onAppResumed(intent: Intent)
@OptIn(ExperimentalPermissionsApi::class)
fun onPermissionCheckStatus(status: PermissionStatus)
fun onPermissionsRequested()
fun onNotificationsDeniedDialogConfirmClicked()
fun onNotificationsDeniedDialogCancelClicked()
fun onNotificationsDeniedDialogDismissed()
fun onNotificationsRationaleDialogDismissed()
fun onNotificationsRationaleDialogCancelClicked()
fun onUserAuthenticated()
}
class MainViewModelImpl(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val userSettings: UserSettings,
private val longPollController: LongPollController,
private val logger: FastLogger
) : ViewModel() {
private val longPollController: LongPollController
) : MainViewModel, ViewModel() {
val startDestination = MutableStateFlow<Any?>(null)
val isNeedToReplaceWithAuth = MutableStateFlow(false)
val currentUser = MutableStateFlow<VkUser?>(null)
override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val currentUser = MutableStateFlow<VkUser?>(null)
val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
val isNeedToRequestNotifications = MutableStateFlow(false)
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
override val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
override val isNeedToRequestNotifications = MutableStateFlow(false)
private var openNotificationsSettings = false
private var openAppSettings = false
fun onError(error: BaseError) {
override fun onError(error: BaseError) {
when (error) {
BaseError.SessionExpired,
BaseError.AccountBlocked -> {
@@ -63,11 +93,11 @@ class MainViewModel(
}
}
fun onNavigatedToAuth() {
override fun onNavigatedToAuth() {
isNeedToReplaceWithAuth.update { false }
}
fun onAppResumed(intent: Intent) {
override fun onAppResumed(intent: Intent) {
openNotificationsSettings =
intent.hasCategory(NotificationCompat.INTENT_CATEGORY_NOTIFICATION_PREFERENCES)
openAppSettings =
@@ -94,7 +124,7 @@ class MainViewModel(
}
@ExperimentalPermissionsApi
fun onPermissionCheckStatus(status: PermissionStatus) {
override fun onPermissionCheckStatus(status: PermissionStatus) {
isNeedToCheckNotificationsPermission.update { false }
when (status) {
@@ -116,33 +146,33 @@ class MainViewModel(
}
}
fun onPermissionsRequested() {
override fun onPermissionsRequested() {
isNeedToRequestNotifications.update { false }
}
fun onNotificationsDeniedDialogConfirmClicked() {
override fun onNotificationsDeniedDialogConfirmClicked() {
isNeedToRequestNotifications.update { true }
}
fun onNotificationsDeniedDialogCancelClicked() {
override fun onNotificationsDeniedDialogCancelClicked() {
isNeedToShowNotificationsDeniedDialog.update { false }
disableBackgroundLongPoll()
}
fun onNotificationsDeniedDialogDismissed() {
override fun onNotificationsDeniedDialogDismissed() {
isNeedToShowNotificationsDeniedDialog.update { false }
}
fun onNotificationsRationaleDialogDismissed() {
override fun onNotificationsRationaleDialogDismissed() {
isNeedToShowNotificationsRationaleDialog.update { false }
}
fun onNotificationsRationaleDialogCancelClicked() {
override fun onNotificationsRationaleDialogCancelClicked() {
isNeedToShowNotificationsRationaleDialog.update { false }
disableBackgroundLongPoll()
}
fun onUserAuthenticated() {
override fun onUserAuthenticated() {
loadProfile()
}
@@ -171,18 +201,9 @@ class MainViewModel(
private fun loadAccounts() {
viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase()?.mapToDto()
val currentAccount = getCurrentAccountUseCase()
logger.debug(
this@MainViewModel::class,
"loadAccounts(): currentAccount: %s"
.format(
currentAccount?.copy(
accessToken = if (currentAccount.accessToken.isNotEmpty()) "<redacted>"
else "null"
)
)
)
Log.d("MainViewModel", "currentAccount: $currentAccount")
listenLongPollState()
@@ -1,25 +1,15 @@
package dev.meloda.fast.common
import android.app.Application
import android.content.Intent
import android.net.Uri
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 dev.meloda.fast.logger.FastLogLevel
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.presentation.CrashActivity
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory {
@@ -28,22 +18,10 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin()
initCrashHandler()
val logLevel =
if (BuildConfig.DEBUG) FastLogLevel.DEBUG
else FastLogLevel.ERROR
get<FastLogger>()
.apply { setLogLevel(logLevel) }
.also { FastLogger.setInstance(it) }
}
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() {
startKoin {
androidLogger()
@@ -52,37 +30,5 @@ class AppGlobal : Application(), ImageLoaderFactory {
}
}
private fun initCrashHandler() {
val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
crashLogsDirectory.mkdirs()
}
val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
}
override fun newImageLoader(): ImageLoader = get()
}
@@ -1,18 +0,0 @@
package dev.meloda.fast.common.di
import android.content.Context
import android.content.res.Resources
import android.net.ConnectivityManager
import android.os.PowerManager
import androidx.preference.PreferenceManager
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val androidModule = module {
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
factoryOf(PreferenceManager::getDefaultSharedPreferences)
factory<Resources> { androidContext().resources }
factory<PowerManager> { androidContext().getSystemService(Context.POWER_SERVICE) as PowerManager }
factory<ConnectivityManager> { androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager }
}
@@ -1,42 +1,45 @@
package dev.meloda.fast.common.di
import android.content.Context
import android.content.res.Resources
import android.os.PowerManager
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.auth.authModule
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.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl
import dev.meloda.fast.common.NetworkStateListener
import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.convos.di.convosModule
import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule
import dev.meloda.fast.logger.loggerModule
import dev.meloda.fast.messageshistory.di.messagesHistoryModule
import dev.meloda.fast.photoviewer.di.photoViewModule
import dev.meloda.fast.presentation.NetworkObserver
import dev.meloda.fast.profile.di.profileModule
import dev.meloda.fast.provider.ApiLanguageProvider
import dev.meloda.fast.service.longpolling.di.longPollModule
import dev.meloda.fast.settings.di.settingsModule
import org.koin.core.module.dsl.factoryOf
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind
import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module {
includes(domainModule)
includes(
authModule,
convosModule,
loginModule,
validationModule,
captchaModule,
conversationsModule,
settingsModule,
messagesHistoryModule,
photoViewModule,
@@ -48,25 +51,23 @@ val applicationModule = module {
createChatModule
)
includes(loggerModule)
includes(androidModule)
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
singleOf(PreferenceManager::getDefaultSharedPreferences)
single<Resources> { androidContext().resources }
factory<PowerManager> { androidContext().getSystemService(Context.POWER_SERVICE) as PowerManager }
factoryOf(::ApiLanguageProvider) bind Provider::class
singleOf(::ApiLanguageProvider) bind Provider::class
viewModelOf(::MainViewModel) { qualifier = qualifier("main") }
viewModelOf(::MainViewModelImpl) {
qualifier = qualifier("main")
}
single<ImageLoader> {
ImageLoader.Builder(get())
.crossfade(true)
.build()
.also {
it.diskCache?.directory?.toFile()?.listFiles()
}
}
singleOf(::LongPollControllerImpl) bind LongPollController::class
singleOf(::ResourceProviderImpl) bind ResourceProvider::class
singleOf(::NetworkStateListener)
singleOf(::NetworkObserver) { qualifier = qualifier("main") }
}
@@ -2,15 +2,15 @@ package dev.meloda.fast.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.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 as UiR
@Serializable
object MainGraph
@@ -21,28 +21,28 @@ object Main
fun NavGraphBuilder.mainScreen(
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToMessagesHistory: (conversationId: Long) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
onNavigateToCreateChat: () -> Unit
) {
val navigationItems = ImmutableList.of(
BottomNavigationItem(
titleResId = R.string.title_friends,
selectedIconResId = R.drawable.ic_group_fill_round_24,
unselectedIconResId = R.drawable.ic_group_round_24,
titleResId = UiR.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24,
route = Friends,
),
BottomNavigationItem(
titleResId = R.string.title_convos,
selectedIconResId = R.drawable.ic_mail_fill_round_24,
unselectedIconResId = R.drawable.ic_mail_round_24,
route = ConvoGraph
titleResId = UiR.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24,
route = ConversationsGraph
),
BottomNavigationItem(
titleResId = R.string.title_profile,
selectedIconResId = R.drawable.ic_account_circle_fill_round_24,
unselectedIconResId = R.drawable.ic_account_circle_round_24,
titleResId = UiR.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24,
route = Profile
)
)
@@ -1,35 +0,0 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -1,71 +0,0 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -4,33 +4,55 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
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
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import org.koin.android.ext.android.get
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
class MainActivity : AppCompatActivity() {
@@ -67,34 +89,177 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
val logger: FastLogger = koinInject()
KoinContext {
val context = LocalContext.current
val viewModel: MainViewModel = koinViewModel()
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
toggleLongPollService(
enable = true,
inBackground = true
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
CompositionLocalProvider(LocalLogger provides logger) {
RootScreen(
toggleLongPollService = { enable, inBackground ->
toggleLongPollService(
enable = enable,
inBackground = inBackground
?: AppSettings.Experimental.longPollInBackground
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
)
},
toggleOnlineService = ::toggleOnlineService
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
}
}
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val noCategoryName = getString(R.string.notification_channel_no_category_name)
val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
val noCategoryDescriptionText =
getString(R.string.notification_channel_no_category_description)
getString(UiR.string.notification_channel_no_category_description)
val noCategoryChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
@@ -104,9 +269,9 @@ class MainActivity : AppCompatActivity() {
description = noCategoryDescriptionText
}
val longPollName = getString(R.string.notification_channel_long_polling_service_name)
val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
val longPollDescriptionText =
getString(R.string.notification_channel_long_polling_service_description)
getString(UiR.string.notification_channel_long_polling_service_description)
val longPollChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
@@ -117,7 +282,7 @@ class MainActivity : AppCompatActivity() {
}
val notificationManager: NotificationManager =
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels(
listOf(
@@ -182,7 +347,6 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
super.onDestroy()
stopServices()
get<LongPollEventsHandler>().onDestroy()
}
companion object {
@@ -1,8 +1,5 @@
package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -18,6 +15,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -38,8 +36,8 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.conversationsGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError
@@ -60,35 +58,30 @@ fun MainScreen(
navigationItems: ImmutableList<BottomNavigationItem>,
onError: (BaseError) -> Unit = {},
onSettingsButtonClicked: () -> Unit = {},
onNavigateToMessagesHistory: (convoId: Long) -> Unit = {},
onNavigateToMessagesHistory: (conversationId: Long) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {}
) {
val activity = LocalActivity.current as? AppCompatActivity ?: return
val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) }
val hazeState = remember { HazeState() }
val navController = rememberNavController()
var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1)
}
BackHandler(enabled = selectedItemIndex != 1) {
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1
navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
val user = LocalUser.current
val profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 }
}
val profileImageUrl = LocalUser.current?.photo100
var tabReselected by remember {
mutableStateOf(navigationItems.associate { it.route to false })
mutableStateOf(
navigationItems.associate {
it.route to false
}
)
}
Scaffold(
@@ -100,7 +93,7 @@ fun MainScreen(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
style = HazeMaterials.thick()
)
} else Modifier
),
@@ -187,7 +180,6 @@ fun MainScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
friendsScreen(
activity = activity,
onError = onError,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
@@ -197,21 +189,18 @@ fun MainScreen(
}
},
)
convosGraph(
handleNavigationIntent = { intent ->
when (intent) {
ConvoNavigationIntent.Back -> {}
ConvoNavigationIntent.Archive -> {}
ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
is ConvoNavigationIntent.MessagesHistory -> {
onNavigateToMessagesHistory(intent.convoId)
}
conversationsGraph(
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[Conversations] = false
}
},
activity = activity,
}
)
profileScreen(
activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
@@ -1,238 +0,0 @@
package dev.meloda.fast.presentation
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import dev.meloda.fast.common.NetworkStateListener
import dev.meloda.fast.common.model.NetworkState
import dev.meloda.fast.common.model.NetworkStatus
import dev.meloda.fast.common.model.NetworkType
import dev.meloda.fast.logger.FastLogger
import java.util.concurrent.ConcurrentHashMap
internal class NetworkObserver(
private val connectivityManager: ConnectivityManager,
private val logger: FastLogger,
private val networkStateListener: NetworkStateListener
) {
private val networks = ConcurrentHashMap<Network, NetworkModel>()
private var clearCallbacks: (() -> Unit)? = null
init {
startListener()
}
private fun syncNetworkState() {
val state = if (networks.values.any { it.isInternetAvailable() }) {
NetworkState.CONNECTED
} else {
NetworkState.DISCONNECTED
}
networkStateListener.updateNetworkState(state)
log("STATE: $state")
}
private fun startListener() {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
log("onAvailable(): network: $network")
networks[network] = mapNetworkModel(
network = network,
capabilities = connectivityManager.getNetworkCapabilities(network),
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE,
assumeInternet = true
)
syncNetworkState()
}
override fun onUnavailable() {
log("onUnavailable()")
networks.clear()
syncNetworkState()
}
override fun onLost(network: Network) {
log("onLost() network: $network")
networks.remove(network)
syncNetworkState()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
log("onCapabilitiesChanged(): network: $network; caps: $networkCapabilities")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
capabilities = networkCapabilities,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onBlockedStatusChanged(
network: Network,
blocked: Boolean
) {
log("onBlockedStatusChanged(): network: $network; blocked: $blocked")
networks[network] = mapNetworkModel(
network = network,
from = networks[network],
status = if (blocked) NetworkStatus.BLOCKED else NetworkStatus.UNBLOCKED
)
syncNetworkState()
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: LinkProperties
) {
log("onLinkPropertiesChanged(): network: $network; props: $linkProperties")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
properties = linkProperties,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
log("onLosing(): network: $network; maxMsToLive: $maxMsToLive")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
maxMsToLive = maxMsToLive.toLong(),
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onReserved(networkCapabilities: NetworkCapabilities) {
log("onReserved(): caps: $networkCapabilities")
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
clearCallbacks = { connectivityManager.unregisterNetworkCallback(callback) }
refreshActiveNetwork()
}
private fun log(text: String) {
logger.debug(this::class, text)
}
private fun refreshActiveNetwork() {
val network = connectivityManager.activeNetwork
if (network == null) {
networks.clear()
syncNetworkState()
return
}
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) {
networks[network] = mapNetworkModel(
network = network,
capabilities = capabilities,
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE
)
}
syncNetworkState()
}
private fun mapNetworkModel(
network: Network,
capabilities: NetworkCapabilities? = null,
properties: LinkProperties? = null,
status: NetworkStatus? = null,
maxMsToLive: Long? = null,
from: NetworkModel? = null,
assumeInternet: Boolean = false
): NetworkModel {
val caps = capabilities
?: from?.networkCapabilities
?: connectivityManager.getNetworkCapabilities(network)
val networkType = when {
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> NetworkType.CELLULAR
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> NetworkType.WIFI
else -> from?.type ?: NetworkType.UNKNOWN
}
val hasInternet = if (caps != null) {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} else {
from?.hasInternet ?: assumeInternet
}
val signalStrength =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
caps?.signalStrength
} else {
null
} ?: from?.signalStrength ?: Int.MAX_VALUE
return NetworkModel(
id = network.hashCode(),
type = networkType,
original = network,
hasInternet = hasInternet,
signalStrength = signalStrength,
status = status ?: from?.status ?: NetworkStatus.UNAVAILABLE,
maxMsToLive = maxMsToLive ?: from?.maxMsToLive,
networkCapabilities = caps,
linkProperties = properties
?: from?.linkProperties
?: connectivityManager.getLinkProperties(network)
)
}
}
data class NetworkModel(
val id: Int,
val type: NetworkType,
val original: Network,
val hasInternet: Boolean,
val signalStrength: Int,
val status: NetworkStatus,
val maxMsToLive: Long?,
val networkCapabilities: NetworkCapabilities?,
val linkProperties: LinkProperties?
) {
fun isStatusOk(): Boolean = status.isOk()
fun isInternetAvailable(): Boolean = hasInternet && isStatusOk()
}
@@ -1,424 +1,171 @@
package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
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
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.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
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.NetworkStateListener
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.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.photoviewer.navigation.navigateToPhotoView
import dev.meloda.fast.photoviewer.navigation.photoViewScreen
import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.common.LocalNetworkState
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RootScreen(
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit
navController: NavHostController = rememberNavController(),
viewModel: MainViewModel
) {
val logger = LocalLogger.current
val resources = LocalResources.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) {
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true)
}
}
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false, null)
if (isNeedToShowDeniedDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) },
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
toggleLongPollService(true, true)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
logger.debug("RootScreen", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false, null)
}
toggleLongPollService(
longPollStateToApply.isLaunched(),
longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
val networkStateListener: NetworkStateListener = koinInject()
val networkState by networkStateListener.networkStateFlow.collectAsStateWithLifecycle()
LaunchedEffect(networkState) {
logger.debug("RootScreen", "NetworkState: $networkState")
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser,
LocalNetworkState provides networkState
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
val navController: NavHostController = rememberNavController()
val activity = LocalActivity.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) {
viewModel.onNavigatedToAuth()
navController.navigateToAuth(clearBackStack = true)
}
}
if (isNeedToShowDeniedDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) },
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (isNeedToShowRationaleDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) },
confirmButton = {
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
if (isNeedToShowRationaleDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) },
confirmButton = {
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat,
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url ->
photoViewerInfo = listOf(url) to null
}
)
createChatScreen(
onChatCreated = { convoId ->
navController.popBackStack()
navController.navigateToMessagesHistory(convoId)
},
navController = navController
)
settingsScreen(
handleNavigationIntent = { intent ->
when (intent) {
SettingsNavigationIntent.Back -> navController.navigateUp()
SettingsNavigationIntent.Language -> navController.navigateToLanguagePicker()
SettingsNavigationIntent.Restart -> {
activity?.let {
val intent =
Intent(activity, MainActivity::class.java)
intent.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
activity.finish()
activity.startActivity(intent)
}
}
SettingsNavigationIntent.LogOut -> {
navController.navigateToAuth(true)
}
}
}
)
languagePickerScreen(onBack = navController::navigateUp)
}
PhotoViewDialog(
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)
)
},
)
}
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
CompositionLocalProvider(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker
)
languagePickerScreen(onBack = navController::navigateUp)
photoViewScreen(onBack = navController::navigateUp)
}
}
}
@@ -6,9 +6,8 @@ import android.os.IBinder
import android.util.Log
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.data.processState
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -25,12 +24,11 @@ import kotlin.time.Duration.Companion.minutes
class OnlineService : Service() {
private val logger: FastLogger by inject()
private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.error(this::class.java, "CoroutineException", throwable)
Log.d(TAG, "error: $throwable")
throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext
@@ -44,20 +42,17 @@ class OnlineService : Service() {
private var onlineJob: Job? = null
override fun onBind(intent: Intent?): IBinder? {
logger.debug(this::class, "STATE: onBind(): intent: $intent")
Log.d(STATE_TAG, "onBind: intent: $intent")
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY
logger.debug(
this::class,
"STATE: onStartCommand(): flags: %s; startId: %s;\ninstance: %s"
.format("$flags", "$startId", "$this")
)
Log.d(STATE_TAG, "onStartCommand: flags: $flags; startId: $startId\ninstance: $this")
createTimer()
return START_STICKY
}
@@ -73,13 +68,13 @@ class OnlineService : Service() {
private fun setOnline() {
if (onlineJob != null) return
logger.debug(this::class, "setOnline()")
Log.d(TAG, "setOnline()")
onlineJob = coroutineScope.launch {
val token = UserConfig.fastToken ?: UserConfig.accessToken
if (token.isBlank()) {
logger.debug(this::class, "setOnline(): token is empty")
Log.d(TAG, "setOnline: token is empty")
return@launch
}
@@ -89,10 +84,10 @@ class OnlineService : Service() {
).onEach { state ->
state.processState(
error = { error ->
logger.error(this@OnlineService::class, "setOnline(): ERROR: $error")
Log.w(TAG, "setOnline(): error: $error")
},
success = { response ->
logger.debug(this@OnlineService::class, "setOnline(): response: $response")
Log.d(TAG, "setOnline(): success: $response")
}
)
}.collect()
@@ -101,7 +96,7 @@ class OnlineService : Service() {
}
override fun onDestroy() {
logger.debug(this::class, "onDestroy()")
Log.d(STATE_TAG, "onDestroy")
timerJob?.cancel("OnlineService destroyed")
onlineJob?.cancel("OnlineService destroyed")
@@ -7,6 +7,7 @@ import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.conena.nanokt.android.app.stopForegroundCompat
@@ -18,10 +19,8 @@ import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R
@@ -33,16 +32,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
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() {
private val logger: FastLogger by inject()
private val longPollController: LongPollController by inject()
private val job = SupervisorJob()
@@ -59,7 +56,6 @@ class LongPollingService : Service() {
private val longPollUseCase: LongPollUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject()
private val eventsHandler: LongPollEventsHandler by inject()
private var currentJob: Job? = null
@@ -67,21 +63,20 @@ class LongPollingService : Service() {
override fun onCreate() {
super.onCreate()
logger.debug(this::class, "STATE: onCreate()")
Log.d(STATE_TAG, "onCreate()")
}
override fun onBind(intent: Intent?): IBinder? {
logger.debug(this::class, "STATE: onBind(): intent: $intent")
Log.d(STATE_TAG, "onBind: intent: $intent")
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY
logger.debug(
this::class,
"STATE: onStartCommand(): asForeground: %s; flags: %s; startId: %s;\ninstance: %s"
.format("$inBackground", "$flags", "$startId", "$this")
Log.d(
STATE_TAG,
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this"
)
startJob()
@@ -136,15 +131,11 @@ class LongPollingService : Service() {
private fun startPolling(): Job {
if (job.isCompleted || job.isCancelled) {
logger.debug(
this::class,
"startPolling(): Job is already done. isCompleted: %s; isCancelled: %s"
.format("${job.isCompleted}", "${job.isCancelled}")
)
Log.d(STATE_TAG, "Job is completed or cancelled")
throw Exception("Job is over")
}
logger.debug(this::class, "startPolling(): Starting job.")
Log.d(STATE_TAG, "Starting job...")
return coroutineScope.launch(coroutineContext) {
longPollController.updateCurrentState(
@@ -202,7 +193,7 @@ class LongPollingService : Service() {
if (updates == null) {
failCount++
} else {
parseUpdates(updates)
updates.forEach(updatesParser::parseNextUpdate)
}
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
@@ -213,18 +204,18 @@ class LongPollingService : Service() {
}
}
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
longPollUseCase.getLongPollServer(
needPts = true,
version = VkConstants.LP_VERSION
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
logger.debug(this::class, "getServerInfo(): response: $response")
Log.d(TAG, "getServerInfo: serverInfoResponse: $response")
it.resume(response)
},
error = { error ->
logger.error(this::class, "getServerInfo(): ERROR: $error")
Log.e(TAG, "getServerInfo: $error")
it.resume(null)
}
)
@@ -233,7 +224,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse(
server: VkLongPollData
): LongPollUpdates? = suspendCancellableCoroutine {
): LongPollUpdates? = suspendCoroutine {
longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}",
key = server.key,
@@ -244,24 +235,19 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
logger.debug(this::class, "getUpdatesResponse(): response: $response")
Log.d(TAG, "lastUpdateResponse: $response")
it.resume(response)
},
error = { error ->
logger.debug(this::class, "getUpdatesResponse(): error: $error")
Log.d(TAG, "getUpdatesResponse: error: $error")
it.resume(null)
}
)
}
}
private suspend fun parseUpdates(updates: List<List<Any>>) {
val parsedUpdates = updates.flatMap { updatesParser.parseNextUpdate(it) }
eventsHandler.handleEvents(parsedUpdates)
}
private fun handleError(throwable: Throwable) {
logger.error(this::class, "CoroutineException", throwable)
Log.e(TAG, "error: $throwable")
if (throwable !is NoAccessTokenException) {
throwable.printStackTrace()
@@ -276,7 +262,7 @@ class LongPollingService : Service() {
}
override fun onDestroy() {
logger.debug(this::class, "STATE: onDestroy()")
Log.d(STATE_TAG, "onDestroy")
longPollController.updateCurrentState(LongPollState.Stopped)
try {
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
@@ -288,7 +274,7 @@ class LongPollingService : Service() {
}
override fun onTrimMemory(level: Int) {
logger.debug(this::class, "STATE: onTrimMemory(): Level: $level")
Log.d(STATE_TAG, "onTrimMemory. Level: $level")
super.onTrimMemory(level)
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl
@@ -11,5 +10,4 @@ import org.koin.dsl.module
val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser)
singleOf(::LongPollEventsHandler)
}
@@ -6,7 +6,7 @@ import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.R as UiR
object NotificationsUtils {
@@ -28,7 +28,7 @@ object NotificationsUtils {
actions: List<NotificationCompat.Action> = emptyList(),
): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_fast_logo)
.setSmallIcon(UiR.drawable.ic_fast_logo)
.setContentTitle(title)
.setPriority(priority.value)
.setContentIntent(contentIntent)
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Allow cleartext network traffic -->
<base-config
cleartextTrafficPermitted="false"
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors>
<!-- Trust pre-installed CAs -->
@@ -10,7 +10,6 @@ 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,6 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import dev.meloda.fast.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -10,18 +10,12 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
lint {
abortOnError = false
}
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
}
}
@@ -1,26 +1,18 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureAndroidCompose
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
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")
extensions.configure<LibraryExtension> {
configureAndroidCompose(this)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)
}
}
}
@@ -1,8 +1,8 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import dev.meloda.fast.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -14,20 +14,15 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.parcelize")
apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target)
@@ -1,6 +1,6 @@
import com.android.build.api.dsl.TestExtension
import com.android.build.gradle.TestExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import dev.meloda.fast.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -10,15 +10,12 @@ class AndroidTestConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("com.android.test")
apply("org.jetbrains.kotlin.android")
}
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
}
}
@@ -5,10 +5,12 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
buildFeatures.compose = true
buildFeatures {
compose = true
}
dependencies {
val bom = libs.findLibrary("compose-bom").get()
@@ -1,9 +1,6 @@
package dev.meloda.fast
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.CompileOptions
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
@@ -16,25 +13,24 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
when (commonExtension) {
is ApplicationExtension -> commonExtension.compileOptions(buildCompileOptions())
is LibraryExtension -> commonExtension.compileOptions(buildCompileOptions())
}
commonExtension.apply {
compileSdk = getVersionInt("compileSdk")
compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
defaultConfig {
minSdk = libs.findVersion("minSdk").get().toString().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
configureKotlin<KotlinAndroidProjectExtension>()
}
private fun buildCompileOptions(): CompileOptions.() -> Unit = {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_21
@@ -51,7 +47,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> throw IllegalArgumentException("Unsupported project extension $this ${T::class}")
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_21
allWarningsAsErrors = warningsAsErrors.toBoolean()
@@ -59,9 +55,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property",
"-Xcontext-parameters"
"-opt-in=kotlinx.coroutines.FlowPreview"
)
}
}
@@ -7,8 +7,3 @@ 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()
}
+1 -2
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.jvm) apply false
@@ -8,6 +9,4 @@ plugins {
alias(libs.plugins.room) apply false
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
}
@@ -4,9 +4,9 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.263"
const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method"
const val API_VERSION = "5.238"
const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.com/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
@@ -1,23 +0,0 @@
package dev.meloda.fast.common
import dev.meloda.fast.common.model.NetworkState
import dev.meloda.fast.common.model.NetworkStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class NetworkStateListener {
private val networkStatus = MutableStateFlow(NetworkStatus.UNAVAILABLE)
val networkStatusFlow = networkStatus.asStateFlow()
private val networkState = MutableStateFlow(NetworkState.DISCONNECTED)
val networkStateFlow = networkState.asStateFlow()
fun updateNetworkState(state: NetworkState) {
networkState.value = state
networkStatus.value = when (state) {
NetworkState.CONNECTED -> NetworkStatus.AVAILABLE
NetworkState.DISCONNECTED -> NetworkStatus.UNAVAILABLE
}
}
}
@@ -1,14 +1,9 @@
package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow
@@ -16,8 +11,6 @@ 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
@@ -30,30 +23,6 @@ fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element)
}
fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
var removed = false
val each = iterator()
while (each.hasNext()) {
if (condition(each.next())) {
each.remove()
removed = true
}
}
return removed
}
context(viewModel: ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit): Job =
listenValue(viewModel.viewModelScope, action)
context(viewModel: ViewModel)
fun <T> MutableSharedFlow<T>.emitOnMain(value: T) {
val flow = this
viewModel.viewModelScope.launch { flow.emit(value) }
}
fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
@@ -120,7 +89,7 @@ fun Any.asInt(): Int {
}
fun Any.asLong(): Long {
return when (this) {
return when(this) {
is Number -> this.toLong()
else -> throw IllegalArgumentException("Object is not numeric")
@@ -134,33 +103,3 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
else -> emptyList()
}
}
fun <T> Bundle.getParcelableCompat(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz.java)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
infix fun ClosedRange<Int>.collidesWith(other: ClosedRange<Int>): Boolean {
return this.start < other.endInclusive && other.start < this.endInclusive
}
operator fun ClosedRange<Int>.minus(other: ClosedRange<Int>): ClosedRange<Int> {
return (this.start - other.start)..(this.endInclusive - other.endInclusive)
}
operator fun ClosedRange<Int>.minus(other: Int): ClosedRange<Int> {
return (this.start - other)..(this.endInclusive - other)
}
@@ -1,13 +1,13 @@
package dev.meloda.fast.common.model
enum class NetworkLogLevel(val value: Int) {
enum class LogLevel(val value: Int) {
NONE(0),
BASIC(1),
HEADERS(2),
BODY(3);
companion object {
fun parse(value: Int): NetworkLogLevel = entries.firstOrNull { it.value == value }
fun parse(value: Int): LogLevel = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown log level with value: $value")
}
}
@@ -1,3 +0,0 @@
package dev.meloda.fast.common.model
enum class NetworkState { CONNECTED, DISCONNECTED }
@@ -1,10 +0,0 @@
package dev.meloda.fast.common.model
enum class NetworkStatus {
AVAILABLE, UNAVAILABLE, LOST, BLOCKED, UNBLOCKED;
fun isOk(): Boolean = when (this) {
AVAILABLE, UNBLOCKED -> true
UNAVAILABLE, LOST, BLOCKED -> false
}
}
@@ -1,5 +0,0 @@
package dev.meloda.fast.common.model
enum class NetworkType {
CELLULAR, WIFI, UNKNOWN
}
@@ -3,7 +3,9 @@ package dev.meloda.fast.common.provider
import android.content.res.Resources
interface ResourceProvider {
val resources: Resources
fun getString(resId: Int): String
}
@@ -1,22 +1,19 @@
package dev.meloda.fast.common.util
import android.content.res.Resources
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
import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year
import dev.meloda.fast.common.R
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 {
@@ -30,11 +27,7 @@ object TimeUtils {
}.timeInMillis
}
fun getLocalizedDate(
date: Long,
yesterday: () -> String,
today: () -> String
): String {
fun getLocalizedDate(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
@@ -43,41 +36,48 @@ object TimeUtils {
now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) {
return yesterday()
return resources.getString(R.string.yesterday)
} else {
"dd MMMM"
}
}
else -> return today()
else -> return resources.getString(R.string.today)
}
return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
}
fun getLocalizedTime(
date: Long,
yearShort: () -> String,
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String
): String {
val now = Clock.System.now()
val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
fun getLocalizedTime(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
return when {
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()
now.year != then.year -> {
"${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${resources.getString(R.string.week_short).lowercase()}"
} else {
"$change${resources.getString(R.string.day_short).lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
resources.getString(R.string.time_now).lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
}
}
}
@@ -1,17 +0,0 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
fun String.sha256() = this.hashString("SHA-256")
fun String.hashString(algorithm: String): String {
return MessageDigest
.getInstance(algorithm)
.digest(this.toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
+1
View File
@@ -14,6 +14,7 @@ dependencies {
api(projects.core.network)
api(projects.core.database)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.retrofit)
implementation(libs.eithernet)
implementation(libs.koin.android)
@@ -1,7 +1,7 @@
package dev.meloda.fast.data
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import kotlin.math.abs
@@ -16,9 +16,9 @@ class VkGroupsMap(
fun groups(): List<VkGroupDomain> = map.values.toList()
fun convoGroup(convo: VkConvo): VkGroupDomain? =
if (!convo.peerType.isGroup()) null
else map[abs(convo.id)]
fun conversationGroup(conversation: VkConversation): VkGroupDomain? =
if (!conversation.peerType.isGroup()) null
else map[abs(conversation.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null
@@ -2,7 +2,7 @@ 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.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -13,7 +13,7 @@ object VkMemoryCache {
private val users: HashMap<Long, VkUser> = hashMapOf()
private val groups: HashMap<Long, VkGroupDomain> = hashMapOf()
private val messages: HashMap<Long, VkMessage> = hashMapOf()
private val convos: HashMap<Long, VkConvo> = hashMapOf()
private val conversations: HashMap<Long, VkConversation> = hashMapOf()
private val contacts: HashMap<Long, VkContactDomain> = hashMapOf()
fun appendUsers(users: List<VkUser>) {
@@ -28,14 +28,14 @@ object VkMemoryCache {
messages.forEach { message -> VkMemoryCache.messages[message.id] = message }
}
fun appendConvos(convos: List<VkConvo>) {
convos.forEach { convo ->
VkMemoryCache.convos[convo.id] = convo
fun appendConversations(conversations: List<VkConversation>) {
conversations.forEach { conversation ->
VkMemoryCache.conversations[conversation.id] = conversation
}
}
fun appendContacts(contacts: List<VkContactDomain>) {
contacts.forEach { contact -> VkMemoryCache[contact.userId] = contact }
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
}
operator fun set(userid: Long, user: VkUser) {
@@ -50,8 +50,8 @@ object VkMemoryCache {
messages[messageId] = message
}
operator fun set(convoId: Long, convo: VkConvo) {
convos[convoId] = convo
operator fun set(conversationId: Long, conversation: VkConversation) {
conversations[conversationId] = conversation
}
operator fun set(contactId: Long, contact: VkContactDomain) {
@@ -94,16 +94,16 @@ object VkMemoryCache {
return ids.mapNotNull { id -> messages[id] }
}
fun getConvo(id: Long): VkConvo? {
return getConvos(id).firstOrNull()
fun getConversation(id: Long): VkConversation? {
return getConversations(id).firstOrNull()
}
fun getConvos(vararg ids: Long): List<VkConvo> {
return getConvos(ids.toList())
fun getConversations(vararg ids: Long): List<VkConversation> {
return getConversations(ids.toList())
}
fun getConvos(ids: List<Long>): List<VkConvo> {
return ids.mapNotNull { id -> convos[id] }
fun getConversations(ids: List<Long>): List<VkConversation> {
return ids.mapNotNull { id -> conversations[id] }
}
fun getContact(id: Long): VkContactDomain? {
@@ -1,7 +1,8 @@
package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -15,9 +16,9 @@ class VkUsersMap(
fun users(): List<VkUser> = map.values.toList()
fun convoUser(convo: VkConvo): VkUser? =
if (!convo.peerType.isUser()) null
else map[convo.id]
fun conversationUser(conversation: VkConversation): VkUser? =
if (!conversation.peerType.isUser()) null
else map[conversation.id]
fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null
@@ -35,7 +36,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId]
else null
fun user(userId: Long): VkUser? = map[userId]
fun user(userid: Long): VkUser? = map[userId]
companion object {
@@ -1,25 +1,25 @@
package dev.meloda.fast.data.api.convos
package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.RestApiErrorDomain
interface ConvosRepository {
interface ConversationsRepository {
suspend fun storeConvos(convos: List<VkConvo>)
suspend fun storeConversations(conversations: List<VkConversation>)
suspend fun getConvos(
suspend fun getConversations(
count: Int?,
offset: Int?,
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain>
filter: ConversationsFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun getConvosById(
suspend fun getConversationsById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): ApiResult<List<VkConvo>, RestApiErrorDomain>
): ApiResult<List<VkConversation>, RestApiErrorDomain>
suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain>
suspend fun pin(peerId: Long): ApiResult<Int, RestApiErrorDomain>
@@ -1,51 +1,51 @@
package dev.meloda.fast.data.api.convos
package dev.meloda.fast.data.api.conversations
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.model.api.requests.ConversationsGetRequest
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.network.service.conversations.ConversationsService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ConvosRepositoryImpl(
private val convosService: ConvosService,
class ConversationsRepositoryImpl(
private val conversationsService: ConversationsService,
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val convoDao: ConvoDao
) : ConvosRepository {
private val conversationDao: ConversationDao
) : ConversationsRepository {
override suspend fun storeConvos(convos: List<VkConvo>) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
override suspend fun storeConversations(conversations: List<VkConversation>) {
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
}
override suspend fun getConvos(
override suspend fun getConversations(
count: Int?,
offset: Int?,
filter: ConvosFilter
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConvosGetRequest(
filter: ConversationsFilter
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = ConversationsGetRequest(
count = count,
offset = offset,
fields = VkConstants.ALL_FIELDS,
@@ -54,7 +54,7 @@ class ConvosRepositoryImpl(
startMessageId = null
)
convosService.getConvos(requestModel.map).mapApiResult(
conversationsService.getConversations(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
@@ -69,39 +69,33 @@ class ConvosRepositoryImpl(
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val convos = response.items.map { item ->
val conversations = response.items.map { item ->
val lastMessage = item.lastMessage?.asDomain()?.let { message ->
message.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
item.convo.asDomain(lastMessage).let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
item.conversation.asDomain(lastMessage).let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
val messages = convos.mapNotNull(VkConvo::lastMessage)
val messages = conversations.mapNotNull(VkConversation::lastMessage)
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
convos
conversations
},
errorMapper = { error ->
error?.toDomain()
@@ -109,11 +103,11 @@ class ConvosRepositoryImpl(
)
}
override suspend fun getConvosById(
override suspend fun getConversationsById(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): ApiResult<List<VkConvo>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
): ApiResult<List<VkConversation>, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestParams = mutableMapOf(
"peer_ids" to peerIds.joinToString(separator = ",")
).apply {
@@ -121,7 +115,7 @@ class ConvosRepositoryImpl(
fields?.let { this["fields"] = it }
}
convosService.getConvosById(requestParams).mapApiResult(
conversationsService.getConversationsById(requestParams).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
@@ -129,29 +123,29 @@ class ConvosRepositoryImpl(
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList)
val convos = response.items.map { item ->
item.asDomain().let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
val conversations = response.items.map { item ->
item.asDomain().let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
}
convos
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
conversations
},
errorMapper = { error ->
error?.toDomain()
@@ -161,7 +155,7 @@ class ConvosRepositoryImpl(
override suspend fun delete(peerId: Long): ApiResult<Long, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
convosService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
conversationsService.delete(mapOf("peer_id" to peerId.toString())).mapApiResult(
successMapper = { response -> response.requireResponse().lastDeletedId },
errorMapper = { error -> error?.toDomain() }
)
@@ -170,19 +164,19 @@ class ConvosRepositoryImpl(
override suspend fun pin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
conversationsService.pin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unpin(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
conversationsService.unpin(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun reorderPinned(
peerIds: List<Long>
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService
conversationsService
.reorderPinned(mapOf("peer_ids" to peerIds.joinToString(",")))
.mapApiDefault()
}
@@ -190,12 +184,12 @@ class ConvosRepositoryImpl(
override suspend fun archive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
conversationsService.archive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
override suspend fun unarchive(
peerId: Long
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
conversationsService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
}
}
@@ -1,12 +1,9 @@
package dev.meloda.fast.data.api.friends
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.FriendsInfo
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity
@@ -16,6 +13,8 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.friends.FriendsService
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
@@ -52,18 +51,14 @@ class FriendsRepositoryImpl(
order = order,
count = count,
offset = offset,
fields = VkConstants.USER_FIELDS,
extended = true
fields = VkConstants.USER_FIELDS
)
service.getFriends(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
val response = apiResponse.requireResponse()
val users = response.items.map(VkUserData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(users)
VkMemoryCache.appendContacts(contactsList)
users
},
@@ -1,9 +1,9 @@
package dev.meloda.fast.data.api.messages
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesHistoryInfo(
val messages: List<VkMessage>,
val convos: List<VkConvo>
val conversations: List<VkConversation>
)
@@ -5,8 +5,7 @@ import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
@@ -15,7 +14,7 @@ interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getHistory(
convoId: Long,
conversationId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain>
@@ -33,9 +32,8 @@ interface MessagesRepository {
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead(
@@ -99,21 +97,16 @@ interface MessagesRepository {
fields: String? = null
): ApiResult<VkChatData, RestApiErrorDomain>
suspend fun getConvoMembers(
suspend fun getConversationMembers(
peerId: Long,
offset: Int? = null,
count: Int? = null,
extended: Boolean? = null,
fields: String? = null
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain>
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain>
suspend fun removeChatUser(
chatId: Long,
memberId: Long
): ApiResult<Int, RestApiErrorDomain>
suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain>
}
@@ -5,7 +5,7 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.VkUsersMap
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
@@ -17,7 +17,7 @@ import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser
@@ -27,7 +27,7 @@ import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetConvoMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetConversationMembersRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
import dev.meloda.fast.model.api.requests.MessagesMarkAsImportantRequest
@@ -36,8 +36,7 @@ import dev.meloda.fast.model.api.requests.MessagesPinMessageRequest
import dev.meloda.fast.model.api.requests.MessagesRemoveChatUserRequest
import dev.meloda.fast.model.api.requests.MessagesSendRequest
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.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault
@@ -52,18 +51,18 @@ class MessagesRepositoryImpl(
private val messageDao: MessageDao,
private val userDao: UserDao,
private val groupDao: GroupDao,
private val convoDao: ConvoDao
private val conversationDao: ConversationDao
) : MessagesRepository {
override suspend fun getHistory(
convoId: Long,
conversationId: Long,
offset: Int?,
count: Int?
): ApiResult<MessagesHistoryInfo, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesGetHistoryRequest(
count = count,
offset = offset,
peerId = convoId,
peerId = conversationId,
extended = true,
startMessageId = null,
rev = null,
@@ -91,32 +90,24 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage.let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
}
val convos = response.convos.orEmpty().map { item ->
val conversations = response.conversations.orEmpty().map { item ->
val message = messages.firstOrNull { it.id == item.lastMessageId }
item.asDomain(message)
.let { convo ->
convo.copy(
user = usersMap.convoUser(convo),
group = groupsMap.convoGroup(convo)
).also { VkMemoryCache[convo.id] = it }
.let { conversation ->
conversation.copy(
user = usersMap.conversationUser(conversation),
group = groupsMap.conversationGroup(conversation)
).also { VkMemoryCache[conversation.id] = it }
}
}
launch(Dispatchers.IO) {
convoDao.insertAll(convos.map(VkConvo::asEntity))
conversationDao.insertAll(conversations.map(VkConversation::asEntity))
messageDao.insertAll(messages.map(VkMessage::asEntity))
userDao.insertAll(profilesList.map(VkUser::asEntity))
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
@@ -124,7 +115,7 @@ class MessagesRepositoryImpl(
MessagesHistoryInfo(
messages = messages,
convos = convos
conversations = conversations
)
},
errorMapper = { error ->
@@ -168,15 +159,7 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
actionGroup = groupsMap.messageActionGroup(message)
)
}
@@ -200,17 +183,15 @@ class MessagesRepositoryImpl(
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
forward = forward,
attachments = attachments,
formatData = formatData
replyTo = replyTo,
attachments = attachments
)
messagesService.send(requestModel.map).mapApiDefault()
@@ -243,7 +224,7 @@ class MessagesRepositoryImpl(
offset = offset,
preserveOrder = true,
attachmentTypes = attachmentTypes,
cmId = cmId,
conversationMessageId = cmId,
fields = VkConstants.ALL_FIELDS
)
@@ -297,7 +278,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesPinMessageRequest(
peerId = peerId,
messageId = messageId,
cmId = cmId
conversationMessageId = cmId
)
messagesService.pin(requestModel.map).mapApiResult(
@@ -325,12 +306,7 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(),
important = important
)
messagesService.markAsImportant(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
messagesService.markAsImportant(requestModel.map).mapApiDefault()
}
override suspend fun delete(
@@ -343,7 +319,7 @@ class MessagesRepositoryImpl(
val requestModel = MessagesDeleteRequest(
peerId = peerId,
messagesIds = messageIds,
cmIds = cmIds,
conversationsMessagesIds = cmIds,
isSpam = spam,
deleteForAll = deleteForAll
)
@@ -394,15 +370,15 @@ class MessagesRepositoryImpl(
messagesService.getChat(requestModel.map).mapApiDefault()
}
override suspend fun getConvoMembers(
override suspend fun getConversationMembers(
peerId: Long,
offset: Int?,
count: Int?,
extended: Boolean?,
fields: String?
): ApiResult<MessagesGetConvoMembersResponse, RestApiErrorDomain> =
): ApiResult<MessagesGetConversationMembersResponse, RestApiErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = MessagesGetConvoMembersRequest(
val requestModel = MessagesGetConversationMembersRequest(
peerId = peerId,
offset = offset,
count = count,
@@ -410,7 +386,7 @@ class MessagesRepositoryImpl(
fields = fields
)
messagesService.getConvoMembers(requestModel.map).mapApiDefault()
messagesService.getConversationMembers(requestModel.map).mapApiDefault()
}
override suspend fun removeChatUser(
@@ -424,18 +400,4 @@ class MessagesRepositoryImpl(
messagesService.removeChatUser(requestModel.map).mapApiDefault()
}
override suspend fun getMessageReadPeers(
peerId: Long,
cmId: Long
): ApiResult<MessagesGetReadPeersResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
messagesService.getMessageReadPeers(
mapOf(
"peer_id" to peerId.toString(),
"cmid" to cmId.toString(),
"extended" to "1",
"fields" to VkConstants.USER_FIELDS
)
).mapApiDefault()
}
}
@@ -23,6 +23,5 @@ interface OAuthRepository {
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
}
@@ -79,8 +79,7 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
captchaImageUrl = response.captchaImage.orEmpty()
)
}
@@ -123,7 +122,6 @@ class OAuthRepositoryImpl(
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest(
@@ -137,7 +135,6 @@ class OAuthRepositoryImpl(
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
successToken = successToken
)
oAuthService.getSilentToken(requestModel.map).mapResult(
@@ -178,8 +175,7 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
captchaImageUrl = response.captchaImage.orEmpty()
)
}
@@ -6,8 +6,8 @@ import dev.meloda.fast.data.api.account.AccountRepositoryImpl
import dev.meloda.fast.data.api.audios.AudiosRepository
import dev.meloda.fast.data.api.auth.AuthRepository
import dev.meloda.fast.data.api.auth.AuthRepositoryImpl
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.convos.ConvosRepositoryImpl
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepositoryImpl
import dev.meloda.fast.data.api.files.FilesRepository
import dev.meloda.fast.data.api.friends.FriendsRepository
import dev.meloda.fast.data.api.friends.FriendsRepositoryImpl
@@ -45,7 +45,7 @@ val dataModule = module {
singleOf(::AuthRepositoryImpl) bind AuthRepository::class
singleOf(::ConvosRepositoryImpl) bind ConvosRepository::class
singleOf(::ConversationsRepositoryImpl) bind ConversationsRepository::class
singleOf(::FilesRepository)
@@ -1,58 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fastToken",
"columnName": "fastToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "trustedHash",
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -1,413 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "6c315b7f800694f635318d86032746ec",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6c315b7f800694f635318d86032746ec')"
]
}
}
@@ -1,413 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a746865995959331f8a1b512c049dacb",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a746865995959331f8a1b512c049dacb')"
]
}
}
@@ -1,425 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "5eca3b3da167aaf7e772977a1f4e56e2",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `isImportant` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isSpam` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "isImportant",
"columnName": "isImportant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSpam",
"columnName": "isSpam",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eca3b3da167aaf7e772977a1f4e56e2')"
]
}
}
@@ -3,12 +3,12 @@ package dev.meloda.fast.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.ConversationDao
import dev.meloda.fast.database.dao.GroupDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.database.typeconverters.Converters
import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.database.VkConversationEntity
import dev.meloda.fast.model.database.VkGroupEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.database.VkUserEntity
@@ -18,15 +18,15 @@ import dev.meloda.fast.model.database.VkUserEntity
VkUserEntity::class,
VkGroupEntity::class,
VkMessageEntity::class,
VkConvoEntity::class
VkConversationEntity::class
],
version = 12
version = 10
)
@TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun groupDao(): GroupDao
abstract fun messageDao(): MessageDao
abstract fun convoDao(): ConvoDao
abstract fun conversationDao(): ConversationDao
}
@@ -0,0 +1,30 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConversationWithMessage
import dev.meloda.fast.model.database.VkConversationEntity
@Dao
abstract class ConversationDao : EntityDao<VkConversationEntity> {
@Query("SELECT * FROM conversations")
abstract suspend fun getAll(): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConversationEntity>
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConversationEntity?
@Transaction
@Query("SELECT * FROM conversations WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConversationWithMessage?
@Query("DELETE FROM conversations WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
}
@@ -1,45 +0,0 @@
package dev.meloda.fast.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import dev.meloda.fast.model.database.ConvoWithMessage
import dev.meloda.fast.model.database.VkConvoEntity
@Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos")
abstract suspend fun getAll(): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Long>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity?
@Transaction
@Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Long>): Int
@Query("UPDATE convos SET inReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadIncoming(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET outReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadOutgoing(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET isArchived = :isArchived WHERE id = :convoId")
abstract suspend fun updateIsArchived(convoId: Long, isArchived: Boolean): Int
@Query("UPDATE convos SET majorId = :majorId WHERE id = :convoId")
abstract suspend fun updateMajorId(convoId: Long, majorId: Int): Int
@Query("UPDATE convos SET minorId = :minorId WHERE id = :convoId")
abstract suspend fun updateMinorId(convoId: Long, minorId: Int): Int
@Query("UPDATE convos SET lastCmId = :cmId WHERE id = :convoId")
abstract suspend fun updateLastCmId(convoId: Long, cmId: Long): Int
}
@@ -7,11 +7,11 @@ import dev.meloda.fast.model.database.VkMessageEntity
@Dao
abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages WHERE isDeleted = 0 AND isSpam = 0")
@Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:conversationId)")
abstract suspend fun getAll(conversationId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity>
@@ -21,13 +21,4 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int
@Query("UPDATE messages SET isDeleted = :isDeleted WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsDeleted(convoId: Long, cmId: Long, isDeleted: Boolean): Int
@Query("UPDATE messages SET isImportant = :isImportant WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsImportant(convoId: Long, cmId: Long, isImportant: Boolean): Int
@Query("UPDATE messages SET isSpam = :isSpam WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsSpam(convoId: Long, cmId: Long, isSpam: Boolean): Int
}
@@ -17,13 +17,13 @@ val databaseModule = module {
single {
Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration(true)
.fallbackToDestructiveMigration()
.build()
}
single { cacheDB().userDao() }
single { cacheDB().groupDao() }
single { cacheDB().messageDao() }
single { cacheDB().convoDao() }
single { cacheDB().conversationDao() }
}
private fun Scope.cacheDB(): CacheDatabase = get()
@@ -3,33 +3,14 @@ package dev.meloda.fast.datastore
import android.content.SharedPreferences
import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.NetworkLogLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import dev.meloda.fast.common.model.LogLevel
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
}
@@ -115,20 +96,6 @@ object AppSettings {
)
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var showManualRefreshOptions: Boolean
get() = get(
SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS,
SettingsKeys.DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS
)
set(value) = put(SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS, value)
var enableHaptic: Boolean
get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -238,11 +205,11 @@ object AppSettings {
)
set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value)
var networkLogLevel: NetworkLogLevel
var networkLogLevel: LogLevel
get() = get(
SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL,
SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL
).let(NetworkLogLevel::parse)
).let(LogLevel::parse)
set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value)
var showDebugCategory: Boolean
@@ -11,10 +11,6 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_MANUAL_REFRESH_OPTIONS = "show_manual_refresh_options"
const val DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS = false
const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -50,9 +46,6 @@ object SettingsKeys {
const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -14,10 +14,15 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean>
@@ -30,10 +35,15 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean)
}
@@ -48,11 +58,16 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val longPollInBackground =
MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash)
override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
@@ -81,10 +96,18 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language
}
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send
}
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground
}
@@ -93,6 +116,14 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use
}
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use
}
+1 -6
View File
@@ -8,16 +8,11 @@ android {
}
dependencies {
api(projects.core.common)
api(projects.core.data)
api(projects.core.model)
// TODO: 11/08/2024, Danil Nikolaev: remove?
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.eithernet)
implementation(libs.bundles.nanokt)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
}
@@ -1,25 +1,25 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow
interface ConvoUseCase : BaseUseCase {
interface ConversationsUseCase : BaseUseCase {
suspend fun storeConvos(convos: List<VkConvo>)
suspend fun storeConversations(conversations: List<VkConversation>)
fun getConvos(
fun getConversations(
count: Int? = null,
offset: Int? = null,
filter: ConvosFilter
): Flow<State<List<VkConvo>>>
filter: ConversationsFilter
): Flow<State<List<VkConversation>>>
fun getById(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConvo>>>
): Flow<State<List<VkConversation>>>
fun delete(peerId: Long): Flow<State<Long>>
@@ -1,30 +1,30 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.ConversationsFilter
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class ConvoUseCaseImpl(
private val repository: ConvosRepository,
) : ConvoUseCase {
class ConversationsUseCaseImpl(
private val repository: ConversationsRepository,
) : ConversationsUseCase {
override suspend fun storeConvos(
convos: List<VkConvo>
override suspend fun storeConversations(
conversations: List<VkConversation>
) = withContext(Dispatchers.IO) {
repository.storeConvos(convos)
repository.storeConversations(conversations)
}
override fun getConvos(
override fun getConversations(
count: Int?,
offset: Int?,
filter: ConvosFilter
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvos(
filter: ConversationsFilter
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversations(
count = count,
offset = offset,
filter = filter
@@ -35,8 +35,8 @@ class ConvoUseCaseImpl(
peerIds: List<Long>,
extended: Boolean?,
fields: String?
): Flow<State<List<VkConvo>>> = flowNewState {
repository.getConvosById(
): Flow<State<List<VkConversation>>> = flowNewState {
repository.getConversationsById(
peerIds = peerIds,
extended = extended,
fields = fields
@@ -6,7 +6,10 @@ 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)
}
@@ -1,21 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.messages.MessagesRepository
import dev.meloda.fast.data.mapToState
import kotlinx.coroutines.flow.Flow
class GetMessageReadPeersUseCase(
private val repository: MessagesRepository
) : BaseUseCase {
operator fun invoke(
peerId: Long,
cmId: Long
): Flow<State<Int>> = flowNewState {
repository.getMessageReadPeers(
peerId = peerId,
cmId = cmId
).mapToState(successMapper = { it.totalCount })
}
}
@@ -1,22 +1,22 @@
package dev.meloda.fast.domain
import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.convos.ConvosRepository
import dev.meloda.fast.data.api.conversations.ConversationsRepository
import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import kotlinx.coroutines.flow.Flow
class LoadConvosByIdUseCase(
private val convosRepository: ConvosRepository
class LoadConversationsByIdUseCase(
private val conversationsRepository: ConversationsRepository
) : BaseUseCase {
operator fun invoke(
peerIds: List<Long>,
extended: Boolean? = null,
fields: String? = null
): Flow<State<List<VkConvo>>> = flowNewState {
convosRepository
.getConvosById(
): Flow<State<List<VkConversation>>> = flowNewState {
conversationsRepository
.getConversationsById(
peerIds = peerIds,
extended = extended,
fields = fields,
@@ -1,332 +0,0 @@
package dev.meloda.fast.domain
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
typealias EventListener = (event: LongPollParsedEvent) -> Unit
typealias EventListenerMap = MutableMap<LongPollEvent, MutableList<EventListener>>
class LongPollEventsHandler(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val convoDao: ConvoDao,
private val messageDao: MessageDao,
) {
private val job = SupervisorJob()
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: EventListenerMap = mutableMapOf()
fun handleEvents(events: List<LongPollParsedEvent>) {
coroutineScope.launch {
// TODO: 30.05.2026, Danil Nikolaev: switch to interactors or something else
withContext(Dispatchers.IO) {
events.forEach { handleNextEvent(it) }
}
}
}
private suspend fun handleNextEvent(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.AudioMessageListened -> {
}
is LongPollParsedEvent.ChatArchived -> {
val affectedRows = convoDao.updateIsArchived(
convoId = event.convo.id,
isArchived = event.convo.isArchived
)
logger.debug(
this::class,
"isArchived ${event.convo.isArchived}: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatCleared -> {
val affectedRows = convoDao.updateLastCmId(
convoId = event.peerId,
cmId = event.toCmId
)
logger.debug(
this::class,
"updateLastCmId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMajorChanged -> {
val affectedRows = convoDao.updateMajorId(
convoId = event.peerId,
majorId = event.majorId
)
logger.debug(
this::class,
"updateMajorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMinorChanged -> {
val affectedRows = convoDao.updateMinorId(
convoId = event.peerId,
minorId = event.minorId
)
logger.debug(
this::class,
"updateMinorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.Interaction -> {
val eventType = when (event.interactionType) {
InteractionType.Typing -> LongPollEvent.TYPING
InteractionType.VoiceMessage -> LongPollEvent.AUDIO_MESSAGE_RECORDING
InteractionType.Photo -> LongPollEvent.PHOTO_UPLOADING
InteractionType.Video -> LongPollEvent.VIDEO_UPLOADING
InteractionType.File -> LongPollEvent.FILE_UPLOADING
}
emitEvent(eventType, event)
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_CACHE_CLEAR, event)
}
is LongPollParsedEvent.MessageDeleted -> {
val affectedRows = messageDao.markAsDeleted(
convoId = event.peerId,
cmId = event.cmId,
isDeleted = true
)
logger.debug(
this::class,
"markDeleted: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MESSAGE_DELETED, event)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_EDITED, event)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val affectedRows = messageDao.markAsImportant(
convoId = event.peerId,
cmId = event.cmId,
isImportant = event.marked
)
logger.debug(
this::class,
"markImportant: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MARKED_AS_IMPORTANT, event)
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MARKED_AS_NOT_SPAM, event)
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val affectedRows = messageDao.markAsSpam(
convoId = event.peerId,
cmId = event.cmId,
isSpam = true
)
logger.debug(
this::class,
"markSpam: updated $affectedRows rows."
)
emitEvent(LongPollEvent.MARKED_AS_SPAM, event)
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_RESTORED, event)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_UPDATED, event)
}
is LongPollParsedEvent.MessageNew -> {
messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_NEW, event)
}
is LongPollParsedEvent.IncomingMessageRead -> {
val affectedRows = convoDao.updateReadIncoming(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"inMessageRead: updated $affectedRows rows."
)
emitEvent(LongPollEvent.INCOMING_MESSAGE_READ, event)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
val affectedRows = convoDao.updateReadOutgoing(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"outMessageRead: updated $affectedRows rows."
)
emitEvent(LongPollEvent.OUTGOING_MESSAGE_READ, event)
}
is LongPollParsedEvent.UnreadCounter -> {
emitEvent(LongPollEvent.UNREAD_COUNTER_UPDATE, event)
}
}
}
private fun <T : LongPollParsedEvent> emitEvent(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { it(event) }
}
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: (T) -> Unit
) {
if (listenersMap[eventType] == null) {
listenersMap[eventType] = mutableListOf()
}
@Suppress("UNCHECKED_CAST")
listenersMap[eventType]?.add(listener as EventListener)
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: (T) -> Unit
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, block)
}
fun onMessageMarkAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, block)
}
fun onMessageMarkAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, block)
}
fun onMessageDelete(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, block)
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, block)
}
fun onMessageMarkAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, block)
}
fun onMessageRestore(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, block)
}
fun onMessageNew(block: (LongPollParsedEvent.MessageNew) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, block)
}
fun onMessageEdit(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, block)
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, block)
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, block)
}
fun onChatClear(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, block)
}
fun onChatMajorChange(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, block)
}
fun onChatMinorChange(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, block)
}
fun onChatArchive(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, block)
}
fun onInteraction(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = block
)
}
fun onDestroy() {
listenersMap.clear()
}
}
@@ -1,5 +1,6 @@
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
@@ -7,13 +8,13 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.ConversationFlags
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.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -21,20 +22,21 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase
) {
private val job = SupervisorJob()
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
Log.e("LongPollUpdatesParser", "error: $throwable")
throwable.printStackTrace()
}
private val coroutineContext: CoroutineContext
@@ -42,14 +44,14 @@ class LongPollUpdatesParser(
private val coroutineScope = CoroutineScope(coroutineContext)
suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> {
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
return when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> {
logger.debug(this::class, "parseNextUpdate(): unknownEvent: $event")
emptyList()
}
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)
@@ -75,11 +77,8 @@ class LongPollUpdatesParser(
}
}
private fun parseMessageSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageSetFlags(): $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()
@@ -97,6 +96,13 @@ class LongPollUpdatesParser(
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
@@ -105,6 +111,13 @@ class LongPollUpdatesParser(
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.DELETED -> {
@@ -123,6 +136,13 @@ class LongPollUpdatesParser(
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> {
@@ -131,27 +151,30 @@ class LongPollUpdatesParser(
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
else -> Unit
}
}
return eventsToSend
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
}
private suspend fun parseMessageClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageClearFlags(): $eventType: $event")
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
@@ -161,9 +184,7 @@ class LongPollUpdatesParser(
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
@@ -173,154 +194,187 @@ class LongPollUpdatesParser(
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)) {
if (message != null) {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
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 -> {
if (message != null) {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
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)
}
}
}
}
}
MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.AUDIO_LISTENED -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
else -> Unit
}
}
continuation.resume(eventsToSend)
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
}
private suspend fun parseMessageNew(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageNew(): $eventType: $event")
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 message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
val conversation =
async {
loadConvo(
loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
if (message != null) {
val event = LongPollParsedEvent.MessageNew(
message = message,
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
message?.let {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = conversation?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
)
}
}
}
}
}
private suspend fun parseMessageEdit(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageEdit(): $eventType: $event")
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) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageEdited(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadIncoming(): $eventType: $event")
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()
val event = LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
return listOf(event)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadOutgoing(): $eventType: $event")
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()
val event = LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
return listOf(event)
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 suspend fun parseChatClearFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatClearFlags(): $eventType: $event")
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)
val parsedFlags = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -328,53 +382,54 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
conversation = conversation.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
else -> Unit
}
}
continuation.resume(eventsToSend)
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private suspend fun parseChatSetFlags(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatSetFlags(): $eventType: $event")
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)
val parsedFlags = ConversationFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
ConversationFlags.ARCHIVED -> {
val conversation = loadConversation(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
@@ -382,88 +437,98 @@ class LongPollUpdatesParser(
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
cmId = conversation.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
conversation = conversation.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
ConvoFlags.DISABLE_PUSH -> Unit
ConvoFlags.DISABLE_SOUND -> Unit
ConvoFlags.INCOMING_CHAT_REQUEST -> Unit
ConvoFlags.DECLINED_CHAT_REQUEST -> Unit
ConvoFlags.MENTION -> Unit
ConvoFlags.HIDE_CHAT_FROM_SEARCH -> Unit
ConvoFlags.BUSINESS_CHAT -> Unit
ConvoFlags.MARKED_MESSAGE -> Unit
ConvoFlags.DO_NOT_NOTIFY_MENTIONS_ALL_ONLINE -> Unit
ConvoFlags.DO_NOT_NOTIFY_ALL_MENTIONS -> Unit
ConvoFlags.MARKED_AS_UNREAD -> Unit
ConvoFlags.CALL_IN_PROGRESS -> Unit
else -> Unit
}
}
continuation.resume(eventsToSend)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessagesDeleted(): $eventType: $event")
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val event = LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
return listOf(event)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMajorChanged(): $eventType: $event")
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
return listOf(event)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMinorChanged(): $eventType: $event")
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
val event = LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
return listOf(event)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseInteraction(): $eventType: $event")
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
@@ -471,7 +536,16 @@ class LongPollUpdatesParser(
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return emptyList()
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()
@@ -480,24 +554,26 @@ class LongPollUpdatesParser(
val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return emptyList()
if (userIds.isEmpty()) return
val event = LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
return listOf(event)
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>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseUnreadCounterUpdate(): $eventType: $event")
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
@@ -507,54 +583,58 @@ class LongPollUpdatesParser(
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
val event = LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
return listOf(event)
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 suspend fun parseMessageUpdated(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageUpdated(): $eventType: $event")
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) {
val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) {
val event = LongPollParsedEvent.MessageUpdated(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
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 suspend fun parseMessageCacheClear(
eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageCacheClear(): $eventType: $event")
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(messageId = messageId)
if (message != null) {
val event = LongPollParsedEvent.MessageCacheClear(message)
continuation.resume(listOf(event))
} else {
continuation.resume(emptyList())
loadMessage(messageId = messageId)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>)
.onEvent(LongPollParsedEvent.MessageCacheClear(message))
}
}
}
}
}
@@ -563,7 +643,7 @@ class LongPollUpdatesParser(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
): VkMessage? = suspendCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) {
@@ -577,10 +657,7 @@ class LongPollUpdatesParser(
).listenValue(this) { state ->
state.processState(
error = { error ->
logger.error(
this@LongPollUpdatesParser::class,
"loadMessage(): ERROR: $error"
)
Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
@@ -596,35 +673,135 @@ class LongPollUpdatesParser(
}
}
private suspend fun loadConvo(
private suspend fun loadConversation(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
): VkConversation? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
conversationsUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
logger.error(
this@LongPollUpdatesParser::class,
"loadConvo(): ERROR: $error"
)
Log.e("LongPollUpdatesParser", "loadConversation: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
val conversation = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
continuation.resume(conversation)
}
)
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
}
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
}
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
}
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
}
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
}
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
}
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
}
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
}
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
}
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = assembleEventCallback(block)
)
}
}
internal inline fun <R : LongPollParsedEvent> assembleEventCallback(
crossinline block: (R) -> Unit,
): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) }
}
fun interface VkEventCallback<in T : LongPollParsedEvent> {
fun onEvent(event: T)
}
@@ -14,7 +14,7 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessages(messages: List<VkMessage>)
fun getMessagesHistory(
convoId: Long,
conversationId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>>
@@ -32,9 +32,8 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): Flow<State<MessagesSendResponse>>
fun markAsRead(
@@ -23,12 +23,12 @@ class MessagesUseCaseImpl(
}
override fun getMessagesHistory(
convoId: Long,
conversationId: Long,
count: Int?,
offset: Int?
): Flow<State<MessagesHistoryInfo>> = flowNewState {
repository.getHistory(
convoId = convoId,
conversationId = conversationId,
offset = offset,
count = count
).mapToState()
@@ -56,17 +56,15 @@ class MessagesUseCaseImpl(
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send(
peerId = peerId,
randomId = randomId,
message = message,
forward = forward,
attachments = attachments,
formatData = formatData
replyTo = replyTo,
attachments = attachments
).mapToState()
}
@@ -21,8 +21,7 @@ interface OAuthUseCase {
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String? = null,
captchaKey: String? = null,
successToken: String? = null
captchaSid: String?,
captchaKey: String?
): Flow<State<GetSilentTokenResponse>>
}
@@ -48,8 +48,7 @@ class OAuthUseCaseImpl(
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
captchaKey: String?
): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading)
@@ -59,8 +58,7 @@ class OAuthUseCaseImpl(
forceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
successToken = successToken
captchaKey = captchaKey
).asState()
emit(newState)
@@ -6,8 +6,7 @@ import dev.meloda.fast.domain.AccountUseCaseImpl
import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.GetLocalUsersByIdsUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
@@ -27,7 +26,5 @@ val domainModule = module {
singleOf(::AccountUseCaseImpl) bind AccountUseCase::class
singleOf(::GetCurrentAccountUseCase)
singleOf(::LoadConvosByIdUseCase)
singleOf(::GetMessageReadPeersUseCase)
singleOf(::LoadConversationsByIdUseCase)
}
@@ -1,49 +0,0 @@
package dev.meloda.fast.domain.util
import android.content.res.Resources
import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.ActionState
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
fun VkConvo.asPresentation(
resources: Resources,
useContactName: Boolean,
isExpanded: Boolean = false,
options: ImmutableList<ConvoOption> = emptyImmutableList()
): UiConvo = UiConvo(
id = id,
lastMessageId = lastMessageId,
avatar = extractAvatar(),
title = extractTitle(useContactName, resources),
unreadCount = extractUnreadCount(lastMessage, this),
date = TimeUtils.getLocalizedTime(
date = (lastMessage?.date ?: -1) * 1000L,
yearShort = { resources.getString(R.string.year_short) },
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
minuteShort = { resources.getString(R.string.minute_short) },
secondShort = { resources.getString(R.string.second_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
attachmentImage = if (lastMessage?.text == null) null
else extractAttachmentIcon(lastMessage),
isPinned = majorId > 0,
actionImageId = ActionState.parse(isPhantom, isCallInProgress).getResourceId(),
isBirthday = extractBirthday(this),
isUnread = !isRead(),
isAccount = isAccount(id),
isOnline = !isAccount(id) && user?.onlineStatus?.isOnline() == true,
lastMessage = lastMessage,
peerType = peerType,
interactionText = extractInteractionText(resources, this),
isExpanded = isExpanded,
isArchived = isArchived,
options = options
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.vk.UiFriend
import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation(
useContactNames: Boolean = false
@@ -1,65 +0,0 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.vk.MessageUiItem
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
fun VkMessage.asPresentation(
convo: VkConvo,
resourceProvider: ResourceProvider,
showName: Boolean,
prevMessage: VkMessage?,
nextMessage: VkMessage?,
showTimeInActionMessages: Boolean,
isSelected: Boolean
): MessageUiItem = when {
action != null -> MessageUiItem.ActionMessage(
id = id,
cmId = cmId,
text = extractActionText(
resources = resourceProvider.resources,
youPrefix = resourceProvider.getString(R.string.you_message_prefix),
showTime = showTimeInActionMessages
) ?: buildAnnotatedString { },
actionCmId = actionCmId
)
else -> MessageUiItem.Message(
id = id,
cmId = cmId,
text = extractTextWithVisualizedMentions(
isOut = isOut,
originalText = text,
formatData = formatData
),
isOut = isOut,
fromId = fromId,
date = extractDate(),
randomId = randomId,
isInChat = isPeerChat(),
name = extractTitle(),
showDate = true,
showAvatar = extractShowAvatar(nextMessage),
showName = showName && extractShowName(prevMessage),
avatar = extractAvatar(),
isEdited = updateTime != null,
isRead = isRead(convo),
sendingStatus = when {
isFailed() -> SendingStatus.FAILED
id <= 0 -> SendingStatus.SENDING
else -> SendingStatus.SENT
},
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = replyMessage?.extractReplySummary(resourceProvider.resources)
)
}
@@ -1,177 +0,0 @@
package dev.meloda.fast.domain.util
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.StringAnnotation
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.collidesWith
import dev.meloda.fast.common.extensions.minus
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MentionIndex
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun emptyAnnotatedString(): AnnotatedString = AnnotatedString(text = "")
fun AnnotatedString?.orEmpty(): AnnotatedString = this ?: emptyAnnotatedString()
fun String.annotated(): AnnotatedString = AnnotatedString(text = this)
fun isAccount(id: Long) = id == UserConfig.userId
fun extractTextWithVisualizedMentions(
isOut: Boolean,
originalText: String?,
formatData: VkMessage.FormatData?
): AnnotatedString? {
if (originalText == null) return null
val annotations =
mutableListOf<AnnotatedString.Range<out androidx.compose.ui.text.AnnotatedString.Annotation>>()
val regex = """\[(id|club)(\d+)\|([^]]+)]""".toRegex()
val mentions = mutableListOf<MentionIndex>()
var currentIndex = 0
val replacements = mutableListOf<Pair<IntRange, String>>()
val newText = regex.replace(originalText) { matchResult ->
val idPrefix = matchResult.groups[1]?.value.orEmpty()
val startIndex = matchResult.range.first
val endIndex = matchResult.range.last
val id = matchResult.groups[2]?.value ?: ""
val replaced = matchResult.groups[3]?.value.orEmpty()
val indexRange =
(startIndex + currentIndex)..startIndex + currentIndex + replaced.length
replacements.add(indexRange to replaced)
mentions += MentionIndex(
id = id.toLongOrNull() ?: -1,
idPrefix = idPrefix,
indexRange = indexRange
)
currentIndex += replaced.length - (endIndex - startIndex + 1)
replaced
}
mentions.forEach { mention ->
val startIndex = mention.indexRange.first
val endIndex = mention.indexRange.last
annotations += if (isOut) {
AnnotatedString.Range(
item = SpanStyle(textDecoration = TextDecoration.Underline),
start = startIndex,
end = endIndex
)
} else {
AnnotatedString.Range(
item = SpanStyle(color = Color.Red),
start = startIndex,
end = endIndex
)
}
annotations += AnnotatedString.Range(
item = StringAnnotation(mention.id.toString()),
tag = mention.idPrefix,
start = startIndex,
end = endIndex
)
}
if (formatData == null) {
return AnnotatedString(text = newText, annotations = annotations)
}
var current = 0
val newOffsets = formatData.items.map { (offset, length) ->
val r = replacements.filter { (range, _) ->
(range - current) collidesWith (offset..<offset + length) || offset > range.first
}
current = r.sumOf { (range, _) -> range.last - range.first - 1 }
offset + current
}
formatData.items.forEachIndexed { index, item ->
val offset = newOffsets[index]
val spanStyle = when (item.type) {
FormatDataType.BOLD -> {
SpanStyle(fontWeight = FontWeight.SemiBold)
}
FormatDataType.ITALIC -> {
SpanStyle(fontStyle = FontStyle.Italic)
}
FormatDataType.UNDERLINE -> {
SpanStyle(textDecoration = TextDecoration.Underline)
}
FormatDataType.URL -> {
annotations += AnnotatedString.Range(
item = StringAnnotation(item.url.orEmpty()),
start = offset,
end = offset + item.length,
tag = newText.substring(offset, offset + item.length)
)
if (isOut) {
SpanStyle(
fontWeight = FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
)
} else {
SpanStyle(
fontWeight = FontWeight.SemiBold,
color = Color.Red
)
}
}
}
annotations += AnnotatedString.Range(
item = spanStyle,
start = offset,
end = offset + item.length
)
}
return AnnotatedString(text = newText, annotations = annotations)
}
fun List<MessageUiItem>.firstMessage(): MessageUiItem.Message =
filterIsInstance<MessageUiItem.Message>().first()
fun List<MessageUiItem>.firstMessageOrNull(): MessageUiItem.Message? =
filterIsInstance<MessageUiItem.Message>().firstOrNull()
fun List<MessageUiItem>.indexOfMessageById(messageId: Long): Int =
indexOfFirst { it.id == messageId }
fun List<MessageUiItem>.findMessageById(messageId: Long): MessageUiItem.Message? =
firstOrNull { it.id == messageId } as MessageUiItem.Message?
fun List<MessageUiItem>.indexOfMessageByCmId(cmId: Long): Int? =
indexOfFirstOrNull { it.cmId == cmId }
fun List<MessageUiItem>.findMessageByCmId(cmId: Long): MessageUiItem.Message =
first { it.cmId == cmId } as MessageUiItem.Message
-11
View File
@@ -1,11 +0,0 @@
plugins {
alias(libs.plugins.fast.android.library)
}
android {
namespace = "dev.meloda.fast.logger"
}
dependencies {
implementation(libs.koin.android)
}
@@ -1,17 +0,0 @@
package dev.meloda.fast.logger;
enum class FastLogLevel {
VERBOSE,
DEBUG,
INFO,
WARNING,
ERROR,
ASSERT;
companion object {
fun parse(value: Int): FastLogLevel {
if (value !in 0..5) throw IllegalArgumentException("Unknown LogLevel value $value")
return entries.first { it.ordinal == value }
}
}
}
@@ -1,108 +0,0 @@
package dev.meloda.fast.logger
import android.util.Log
import kotlin.reflect.KClass
class FastLogger {
companion object {
@Volatile
private lateinit var instance: FastLogger
fun setInstance(logger: FastLogger) {
if (::instance.isInitialized) {
throw IllegalStateException("FastLogger has already been initialized.")
}
instance = logger
}
fun getInstance(): FastLogger {
if (!::instance.isInitialized) {
throw UninitializedPropertyAccessException("FastLogger is not initialized.")
}
return instance
}
}
private var logLevel: FastLogLevel = FastLogLevel.ERROR
fun setLogLevel(logLevel: FastLogLevel) {
Log.v(this::class.java.simpleName, "Set LogLevel from ${this.logLevel} to $logLevel")
this.logLevel = logLevel
}
fun verbose(clazz: Class<*>, message: String, throwable: Throwable? = null) {
verbose(clazz.simpleName, message, throwable)
}
fun verbose(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.VERBOSE)) {
Log.v(tag, message, throwable)
}
}
fun debug(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
debug(clazz.java, message, throwable)
}
fun debug(clazz: Class<*>, message: String, throwable: Throwable? = null) {
debug(clazz.simpleName, message, throwable)
}
fun debug(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.DEBUG)) {
Log.d(tag, message, throwable)
}
}
fun info(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
info(clazz.java, message, throwable)
}
fun info(clazz: Class<*>, message: String, throwable: Throwable? = null) {
info(clazz.simpleName, message, throwable)
}
fun info(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.INFO)) {
Log.i(tag, message, throwable)
}
}
fun warning(clazz: Class<*>, message: String, throwable: Throwable? = null) {
warning(clazz.simpleName, message, throwable)
}
fun warning(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.WARNING)) {
Log.w(tag, message, throwable)
}
}
fun error(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
error(clazz.java, message, throwable)
}
fun error(clazz: Class<*>, message: String, throwable: Throwable? = null) {
error(clazz.simpleName, message, throwable)
}
fun error(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ERROR)) {
Log.e(tag, message, throwable)
}
}
fun assert(clazz: Class<*>, message: String, throwable: Throwable? = null) {
assert(clazz.simpleName, message, throwable)
}
fun assert(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ASSERT)) {
Log.wtf(tag, message, throwable)
}
}
private fun shouldLog(level: FastLogLevel): Boolean = level.ordinal >= logLevel.ordinal
}
@@ -1,8 +0,0 @@
package dev.meloda.fast.logger
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val loggerModule = module {
singleOf(::FastLogger)
}
+2 -2
View File
@@ -4,7 +4,7 @@ plugins {
}
android {
namespace = "dev.meloda.fast.model"
namespace = "dev.meloda.fast.datastore"
}
dependencies {
@@ -12,7 +12,7 @@ dependencies {
ksp(libs.moshi.kotlin.codegen)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.bundles.compose)
implementation(libs.room.ktx)
implementation(libs.room.runtime)
@@ -1,24 +0,0 @@
package dev.meloda.fast.model
import dev.meloda.fast.model.database.AccountEntity
data class AccountDto(
val userId: Long,
val accessToken: String,
val fastToken: String?,
val trustedHash: String?,
val exchangeToken: String?
) {
fun mapToEntity(): AccountEntity = AccountEntity(
userId = userId,
accessToken = accessToken,
fastToken = fastToken,
trustedHash = trustedHash,
exchangeToken = exchangeToken
)
override fun toString(): String {
return super.toString()
}
}
@@ -1,6 +1,6 @@
package dev.meloda.fast.model
enum class ConvoFlags(val value: Int) {
enum class ConversationFlags(val value: Int) {
DISABLE_PUSH(16),
DISABLE_SOUND(32),
INCOMING_CHAT_REQUEST(256),
@@ -17,10 +17,10 @@ enum class ConvoFlags(val value: Int) {
companion object {
fun parse(mask: Int): List<ConvoFlags> {
val flags = mutableListOf<ConvoFlags>()
fun parse(mask: Int): List<ConversationFlags> {
val flags = mutableListOf<ConversationFlags>()
ConvoFlags.entries.forEach { flag ->
ConversationFlags.entries.forEach { flag ->
if (mask and flag.value > 0) {
flags.add(flag)
}
@@ -1,5 +1,5 @@
package dev.meloda.fast.model
enum class ConvosFilter {
enum class ConversationsFilter {
ALL, UNREAD, ARCHIVE, BUSINESS_NOTIFY
}
@@ -1,11 +1,11 @@
package dev.meloda.fast.model
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent {
data class MessageNew(
data class NewMessage(
val message: VkMessage,
val inArchive: Boolean
) : LongPollParsedEvent
@@ -92,7 +92,7 @@ sealed interface LongPollParsedEvent {
) : LongPollParsedEvent
data class ChatArchived(
val convo: VkConvo,
val conversation: VkConversation,
val archived: Boolean
) : LongPollParsedEvent
}
@@ -1,8 +0,0 @@
package dev.meloda.fast.model
data class PhotoSize(
val height: Int,
val width: Int,
val type: String,
val url: String
)
@@ -1,7 +1,5 @@
package dev.meloda.fast.model.api
import dev.meloda.fast.model.api.domain.VkMessage
enum class PeerType(val value: String) {
USER("user"),
GROUP("group"),
@@ -15,14 +13,5 @@ enum class PeerType(val value: String) {
fun parse(type: String): PeerType {
return entries.first { it.value == type }
}
fun VkMessage.getPeerType(): PeerType {
return when {
peerId > 2_000_000_000 -> CHAT
peerId > 0 -> USER
peerId < 0 -> GROUP
else -> throw IllegalArgumentException("Unknown peer type for peerId: 0")
}
}
}
}
@@ -1,11 +1,13 @@
package dev.meloda.fast.model.api.data
import android.util.Log
enum class AttachmentType(var value: String) {
UNKNOWN("unknown"),
PHOTO("photo"),
VIDEO("video"),
FILE("doc"),
AUDIO("audio"),
FILE("doc"),
LINK("link"),
AUDIO_MESSAGE("audio_message"),
MINI_APP("mini_app"),
@@ -28,8 +30,7 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"),
VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview"),
CHANNEL_MESSAGE("channel_message")
STICKER_PACK_PREVIEW("sticker_pack_preview")
;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -40,6 +41,10 @@ enum class AttachmentType(var value: String) {
it.value == value
} ?: UNKNOWN
if (parsedValue == UNKNOWN) {
Log.e("AttachmentType", "Unknown attachment type: $value")
}
return parsedValue
}
}
@@ -8,7 +8,7 @@ import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
data class VkAttachmentHistoryMessageData(
@Json(name = "message_id") val messageId: Long,
@Json(name = "date") val date: Int,
@Json(name = "cmid") val cmId: Long,
@Json(name = "cmid") val conversationMessageId: Long,
@Json(name = "from_id") val fromId: Long,
@Json(name = "position") val position: Int,
@Json(name = "attachment") val attachment: VkAttachmentItemData
@@ -16,7 +16,7 @@ data class VkAttachmentHistoryMessageData(
fun toDomain(): VkAttachmentHistoryMessage = VkAttachmentHistoryMessage(
messageId = messageId,
cmId = cmId,
conversationMessageId = conversationMessageId,
date = date,
fromId = fromId,
position = position,

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