15 Commits

Author SHA1 Message Date
melod1n 36504fe4b9 refactor(logging): introduce custom FastLogger and replace direct Android logging 2026-05-30 18:31:00 +03:00
melod1n fc3b3cfcb3 refactor(longpoll): route parsed long poll events through dedicated handler and persist message/conversation state updates 2026-05-30 17:17:32 +03:00
melod1n f11b8dc6f4 refactor: consolidate convos state and intent handling 2026-05-30 15:39:43 +03:00
melod1n 167f980f29 refactor StateFlow exposure in ConvosViewModel 2026-05-30 12:01:06 +03:00
melod1n d428af4ac4 refactor: simplify Profile feature state management and update ViewModel 2026-05-30 11:46:14 +03:00
melod1n 63bae014c5 feat: replace settings icon button with segmented buttons in ProfileScreen 2026-05-30 11:43:48 +03:00
melod1n ad54477d11 feat: add segmented buttons to friends screen 2026-05-30 11:39:10 +03:00
melod1n c8bd485724 feat: add custom segmented buttons and refactor conversation screen actions 2026-05-30 11:33:32 +03:00
melod1n 26a0630393 Replace ACRA with custom crash dialog
* Add uncaught exception handler that saves stacktraces to local crash log files
* Show a Compose crash dialog with stacktrace toggle and share action
* Register crash handler activity in a separate process with dialog theme
* Remove ACRA dependencies and configuration
* Add crash dialog strings and ignore `.hotswan/`
2026-05-23 21:59:12 +03:00
melod1n 3d153df79c Update README.md 2026-05-23 08:58:52 +03:00
melod1n 9061a39407 update Gitea workflow 2026-05-23 08:58:52 +03:00
melod1n d8c8820b32 add Gitea workflow 2026-05-22 20:45:42 +03:00
melod1n abfe25d051 fix signing 2026-05-22 17:46:56 +03:00
melod1n 574b230b26 feat: add channel message support and refactor UI components
- Implement `VkChannelMessage` domain and data models for channel message attachments
- Add `CHANNEL_MESSAGE` to `AttachmentType` and map it to relevant UI resources and strings
- Refactor `Sticker` and `Gift` composables to accept URL strings instead of domain objects for better decoupling
- Simplify `AppTheme` by removing redundant color animations and passing the color scheme directly to `MaterialExpressiveTheme`
- Update `LoginScreen` to include a theme toggle (classic vs. light) and improve back-button behavior by resetting error states
- Bump VK API version from 5.238 to 5.263
- Adjust layout and padding for sticker and gift attachment previews
2026-05-19 22:54:44 +03:00
melod1n b31c0f30c5 build: update gradle wrapper and build logic conventions 2026-05-19 13:28:10 +03:00
88 changed files with 2223 additions and 1039 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
android:
runs-on: android-jdk21
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make Gradle executable
run: chmod +x ./gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+2
View File
@@ -15,3 +15,5 @@ build/
local.properties local.properties
.idea .idea
/.kotlin /.kotlin
.hotswan/
.java-version
+1
View File
@@ -43,6 +43,7 @@ Unofficial messenger for russian social network VKontakte
- [ ] Forwarded messages - [ ] Forwarded messages
- [ ] Wall post - [ ] Wall post
- [ ] Comment in wall post - [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll - [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
+2 -4
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
named("release") { named("release") {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -79,9 +79,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) implementation(projects.feature.chatmaterials)
@@ -95,6 +92,7 @@ dependencies {
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat) implementation(projects.feature.createchat)
implementation(projects.core.logger)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.data) implementation(projects.core.data)
+8 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -37,6 +37,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service <service
android:name="dev.meloda.fast.service.longpolling.LongPollingService" android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true" android:enabled="true"
@@ -2,7 +2,6 @@ package dev.meloda.fast
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
@@ -23,6 +22,7 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.GetCurrentAccountUseCase import dev.meloda.fast.domain.GetCurrentAccountUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
@@ -67,7 +67,8 @@ class MainViewModelImpl(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase, private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase, private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val longPollController: LongPollController private val longPollController: LongPollController,
private val logger: FastLogger
) : MainViewModel, ViewModel() { ) : MainViewModel, ViewModel() {
override val startDestination = MutableStateFlow<Any?>(null) override val startDestination = MutableStateFlow<Any?>(null)
@@ -203,7 +204,10 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase() val currentAccount = getCurrentAccountUseCase()
Log.d("MainViewModel", "currentAccount: $currentAccount") logger.debug(
this@MainViewModelImpl::class,
"loadAccounts(): currentAccount: $currentAccount"
)
listenLongPollState() listenLongPollState()
@@ -1,6 +1,8 @@
package dev.meloda.fast.common package dev.meloda.fast.common
import android.app.Application import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
@@ -8,14 +10,16 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import org.acra.config.dialog import dev.meloda.fast.logger.FastLogLevel
import org.acra.config.mailSender import dev.meloda.fast.logger.FastLogger
import org.acra.data.StringFormat import dev.meloda.fast.presentation.CrashActivity
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory { class AppGlobal : Application(), ImageLoaderFactory {
@@ -27,7 +31,15 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra() initCrashHandler()
val logLevel =
if (BuildConfig.DEBUG) FastLogLevel.DEBUG
else FastLogLevel.ERROR
get<FastLogger>()
.apply { setLogLevel(logLevel) }
.also { FastLogger.setInstance(it) }
} }
override fun newImageLoader(): ImageLoader = get() override fun newImageLoader(): ImageLoader = get()
@@ -40,20 +52,36 @@ class AppGlobal : Application(), ImageLoaderFactory {
} }
} }
private fun initAcra() { private fun initCrashHandler() {
initAcra { val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
buildConfigClass = BuildConfig::class.java Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
reportFormat = StringFormat.JSON val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
mailSender { crashLogsDirectory.mkdirs()
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
} }
dialog { val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
text = "App crashed" val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
enabled = true
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
} }
} }
} }
@@ -19,6 +19,7 @@ import dev.meloda.fast.convos.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
import dev.meloda.fast.logger.loggerModule
import dev.meloda.fast.messageshistory.di.messagesHistoryModule import dev.meloda.fast.messageshistory.di.messagesHistoryModule
import dev.meloda.fast.photoviewer.di.photoViewModule import dev.meloda.fast.photoviewer.di.photoViewModule
import dev.meloda.fast.profile.di.profileModule import dev.meloda.fast.profile.di.profileModule
@@ -49,6 +50,8 @@ val applicationModule = module {
createChatModule createChatModule
) )
includes(loggerModule)
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class // TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
singleOf(PreferenceManager::getDefaultSharedPreferences) singleOf(PreferenceManager::getDefaultSharedPreferences)
single<Resources> { androidContext().resources } single<Resources> { androidContext().resources }
@@ -0,0 +1,35 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -0,0 +1,71 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -9,12 +9,11 @@ import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -24,10 +23,13 @@ import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.service.OnlineService import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -64,25 +66,26 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions() requestNotificationPermissions()
setContent { setContent {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val logger: FastLogger = koinInject()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
}
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent) viewModel.onAppResumed(intent)
onPauseOrDispose {} onPauseOrDispose {}
} }
RootScreen( CompositionLocalProvider(LocalLogger provides logger) {
toggleLongPollService = { enable, inBackground -> RootScreen(
toggleLongPollService( toggleLongPollService = { enable, inBackground ->
enable = enable, toggleLongPollService(
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground enable = enable,
) inBackground = inBackground
}, ?: AppSettings.Experimental.longPollInBackground
toggleOnlineService = ::toggleOnlineService )
) },
toggleOnlineService = ::toggleOnlineService
)
}
} }
} }
@@ -38,7 +38,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.convosGraph import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
@@ -198,19 +198,20 @@ fun MainScreen(
}, },
) )
convosGraph( convosGraph(
activity = activity, handleNavigationIntent = { intent ->
onError = onError, when (intent) {
onNavigateToMessagesHistory = onNavigateToMessagesHistory, ConvoNavigationIntent.Back -> {}
onNavigateToCreateChat = onNavigateToCreateChat, ConvoNavigationIntent.Archive -> {}
onScrolledToTop = { ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
tabReselected = tabReselected.toMutableMap().also { is ConvoNavigationIntent.MessagesHistory -> {
it[ConvoGraph] = false onNavigateToMessagesHistory(intent.convoId)
}
} }
} },
activity = activity,
) )
profileScreen( profileScreen(
activity = activity, activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
) )
@@ -4,7 +4,6 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -63,6 +62,7 @@ import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.navigation.navigateToSettings import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig import dev.meloda.fast.ui.model.SizeConfig
@@ -83,6 +83,7 @@ fun RootScreen(
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit, toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit toggleOnlineService: (enable: Boolean) -> Unit
) { ) {
val logger = LocalLogger.current
val resources = LocalResources.current val resources = LocalResources.current
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
@@ -92,10 +93,6 @@ fun RootScreen(
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle() val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle() val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState = val permissionState =
@@ -126,13 +123,12 @@ fun RootScreen(
} }
LifecycleResumeEffect(longPollStateToApply) { LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply") logger.debug("RootScreen", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) { if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched() if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply && longPollCurrentState != longPollStateToApply
) { ) {
toggleLongPollService(false, null) toggleLongPollService(false, null)
Log.d("LongPoll", "recreate()")
} }
toggleLongPollService( toggleLongPollService(
@@ -6,8 +6,9 @@ import android.os.IBinder
import android.util.Log import android.util.Log
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AccountUseCase
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -24,11 +25,12 @@ import kotlin.time.Duration.Companion.minutes
class OnlineService : Service() { class OnlineService : Service() {
private val logger: FastLogger by inject()
private val job = SupervisorJob() private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.d(TAG, "error: $throwable") logger.error(this::class.java, "CoroutineException", throwable)
throwable.printStackTrace()
} }
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
@@ -42,17 +44,20 @@ class OnlineService : Service() {
private var onlineJob: Job? = null private var onlineJob: Job? = null
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
Log.d(STATE_TAG, "onBind: intent: $intent") logger.debug(this::class, "STATE: onBind(): intent: $intent")
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY if (startId > 1) return START_STICKY
Log.d(STATE_TAG, "onStartCommand: flags: $flags; startId: $startId\ninstance: $this") logger.debug(
this::class,
"STATE: onStartCommand(): flags: %s; startId: %s;\ninstance: %s"
.format("$flags", "$startId", "$this")
)
createTimer() createTimer()
return START_STICKY return START_STICKY
} }
@@ -68,13 +73,13 @@ class OnlineService : Service() {
private fun setOnline() { private fun setOnline() {
if (onlineJob != null) return if (onlineJob != null) return
Log.d(TAG, "setOnline()") logger.debug(this::class, "setOnline()")
onlineJob = coroutineScope.launch { onlineJob = coroutineScope.launch {
val token = UserConfig.fastToken ?: UserConfig.accessToken val token = UserConfig.fastToken ?: UserConfig.accessToken
if (token.isBlank()) { if (token.isBlank()) {
Log.d(TAG, "setOnline: token is empty") logger.debug(this::class, "setOnline(): token is empty")
return@launch return@launch
} }
@@ -84,10 +89,10 @@ class OnlineService : Service() {
).onEach { state -> ).onEach { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.w(TAG, "setOnline(): error: $error") logger.error(this@OnlineService::class, "setOnline(): ERROR: $error")
}, },
success = { response -> success = { response ->
Log.d(TAG, "setOnline(): success: $response") logger.debug(this@OnlineService::class, "setOnline(): response: $response")
} }
) )
}.collect() }.collect()
@@ -96,7 +101,7 @@ class OnlineService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") logger.debug(this::class, "onDestroy()")
timerJob?.cancel("OnlineService destroyed") timerJob?.cancel("OnlineService destroyed")
onlineJob?.cancel("OnlineService destroyed") onlineJob?.cancel("OnlineService destroyed")
@@ -7,7 +7,6 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.conena.nanokt.android.app.stopForegroundCompat import com.conena.nanokt.android.app.stopForegroundCompat
@@ -19,8 +18,10 @@ import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@@ -40,6 +41,8 @@ import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() { class LongPollingService : Service() {
private val logger: FastLogger by inject()
private val longPollController: LongPollController by inject() private val longPollController: LongPollController by inject()
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -56,6 +59,7 @@ class LongPollingService : Service() {
private val longPollUseCase: LongPollUseCase by inject() private val longPollUseCase: LongPollUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject() private val updatesParser: LongPollUpdatesParser by inject()
private val eventsHandler: LongPollEventsHandler by inject()
private var currentJob: Job? = null private var currentJob: Job? = null
@@ -63,20 +67,21 @@ class LongPollingService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(STATE_TAG, "onCreate()") logger.debug(this::class, "STATE: onCreate()")
} }
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
Log.d(STATE_TAG, "onBind: intent: $intent") logger.debug(this::class, "STATE: onBind(): intent: $intent")
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY if (startId > 1) return START_STICKY
Log.d( logger.debug(
STATE_TAG, this::class,
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this" "STATE: onStartCommand(): asForeground: %s; flags: %s; startId: %s;\ninstance: %s"
.format("$inBackground", "$flags", "$startId", "$this")
) )
startJob() startJob()
@@ -131,11 +136,15 @@ class LongPollingService : Service() {
private fun startPolling(): Job { private fun startPolling(): Job {
if (job.isCompleted || job.isCancelled) { if (job.isCompleted || job.isCancelled) {
Log.d(STATE_TAG, "Job is completed or cancelled") logger.debug(
this::class,
"startPolling(): Job is already done. isCompleted: %s; isCancelled: %s"
.format("${job.isCompleted}", "${job.isCancelled}")
)
throw Exception("Job is over") throw Exception("Job is over")
} }
Log.d(STATE_TAG, "Starting job...") logger.debug(this::class, "startPolling(): Starting job.")
return coroutineScope.launch(coroutineContext) { return coroutineScope.launch(coroutineContext) {
longPollController.updateCurrentState( longPollController.updateCurrentState(
@@ -193,7 +202,7 @@ class LongPollingService : Service() {
if (updates == null) { if (updates == null) {
failCount++ failCount++
} else { } else {
updates.forEach(updatesParser::parseNextUpdate) parseUpdates(updates)
} }
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs)) lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
@@ -211,11 +220,11 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
success = { response -> success = { response ->
Log.d(TAG, "getServerInfo: serverInfoResponse: $response") logger.debug(this::class, "getServerInfo(): response: $response")
it.resume(response) it.resume(response)
}, },
error = { error -> error = { error ->
Log.e(TAG, "getServerInfo: $error") logger.error(this::class, "getServerInfo(): ERROR: $error")
it.resume(null) it.resume(null)
} }
) )
@@ -235,19 +244,24 @@ class LongPollingService : Service() {
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
success = { response -> success = { response ->
Log.d(TAG, "lastUpdateResponse: $response") logger.debug(this::class, "getUpdatesResponse(): response: $response")
it.resume(response) it.resume(response)
}, },
error = { error -> error = { error ->
Log.d(TAG, "getUpdatesResponse: error: $error") logger.debug(this::class, "getUpdatesResponse(): error: $error")
it.resume(null) it.resume(null)
} }
) )
} }
} }
private suspend fun parseUpdates(updates: List<List<Any>>) {
val parsedUpdates = updates.flatMap { updatesParser.parseNextUpdate(it) }
eventsHandler.handleEvents(parsedUpdates)
}
private fun handleError(throwable: Throwable) { private fun handleError(throwable: Throwable) {
Log.e(TAG, "error: $throwable") logger.error(this::class, "CoroutineException", throwable)
if (throwable !is NoAccessTokenException) { if (throwable !is NoAccessTokenException) {
throwable.printStackTrace() throwable.printStackTrace()
@@ -262,7 +276,7 @@ class LongPollingService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") logger.debug(this::class, "STATE: onDestroy()")
longPollController.updateCurrentState(LongPollState.Stopped) longPollController.updateCurrentState(LongPollState.Stopped)
try { try {
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) } AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
@@ -274,7 +288,7 @@ class LongPollingService : Service() {
} }
override fun onTrimMemory(level: Int) { override fun onTrimMemory(level: Int) {
Log.d(STATE_TAG, "onTrimMemory. Level: $level") logger.debug(this::class, "STATE: onTrimMemory(): Level: $level")
super.onTrimMemory(level) super.onTrimMemory(level)
} }
@@ -1,5 +1,6 @@
package dev.meloda.fast.service.longpolling.di package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollEventsHandler
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl import dev.meloda.fast.domain.LongPollUseCaseImpl
@@ -10,4 +11,5 @@ import org.koin.dsl.module
val longPollModule = module { val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser) singleOf(::LongPollUpdatesParser)
singleOf(::LongPollEventsHandler)
} }
@@ -19,6 +19,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk") targetSdk = getVersionInt("targetSdk")
} }
lint {
abortOnError = false
}
} }
} }
} }
@@ -2,6 +2,7 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -20,7 +21,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238" const val API_VERSION = "5.263"
const val URL_OAUTH = "https://oauth.vk.ru" const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.ru/method"
@@ -1,13 +1,13 @@
package dev.meloda.fast.common.model package dev.meloda.fast.common.model
enum class LogLevel(val value: Int) { enum class NetworkLogLevel(val value: Int) {
NONE(0), NONE(0),
BASIC(1), BASIC(1),
HEADERS(2), HEADERS(2),
BODY(3); BODY(3);
companion object { companion object {
fun parse(value: Int): LogLevel = entries.firstOrNull { it.value == value } fun parse(value: Int): NetworkLogLevel = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown log level with value: $value") ?: throw IllegalArgumentException("Unknown log level with value: $value")
} }
} }
@@ -0,0 +1,425 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "5eca3b3da167aaf7e772977a1f4e56e2",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT"
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cmId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionCmId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `isImportant` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isSpam` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "cmId",
"columnName": "cmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT"
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT"
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT"
},
{
"fieldPath": "actionCmId",
"columnName": "actionCmId",
"affinity": "INTEGER"
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT"
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER"
},
{
"fieldPath": "isImportant",
"columnName": "isImportant",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT"
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT"
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isSpam",
"columnName": "isSpam",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "convos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastCmId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER"
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT"
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT"
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT"
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCmId",
"columnName": "lastCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER"
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER"
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eca3b3da167aaf7e772977a1f4e56e2')"
]
}
}
@@ -21,7 +21,7 @@ import dev.meloda.fast.model.database.VkUserEntity
VkConvoEntity::class VkConvoEntity::class
], ],
version = 11 version = 12
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class CacheDatabase : RoomDatabase() { abstract class CacheDatabase : RoomDatabase() {
@@ -13,7 +13,7 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
abstract suspend fun getAll(): List<VkConvoEntity> abstract suspend fun getAll(): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IN (:ids)") @Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity> abstract suspend fun getAllByIds(ids: List<Long>): List<VkConvoEntity>
@Query("SELECT * FROM convos WHERE id IS (:id)") @Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getById(id: Long): VkConvoEntity? abstract suspend fun getById(id: Long): VkConvoEntity?
@@ -23,8 +23,23 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage? abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE rowid IN (:ids)") @Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Long>): Int
@Query("UPDATE convos SET inReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadIncoming(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET outReadCmId = :cmId, unreadCount = :unreadCount WHERE id = :convoId")
abstract suspend fun updateReadOutgoing(convoId: Long, cmId: Long, unreadCount: Int): Int
@Query("UPDATE convos SET isArchived = :isArchived WHERE id = :convoId")
abstract suspend fun updateIsArchived(convoId: Long, isArchived: Boolean): Int
@Query("UPDATE convos SET majorId = :majorId WHERE id = :convoId")
abstract suspend fun updateMajorId(convoId: Long, majorId: Int): Int
@Query("UPDATE convos SET minorId = :minorId WHERE id = :convoId")
abstract suspend fun updateMinorId(convoId: Long, minorId: Int): Int
@Query("UPDATE convos SET lastCmId = :cmId WHERE id = :convoId")
abstract suspend fun updateLastCmId(convoId: Long, cmId: Long): Int
} }
@@ -7,7 +7,7 @@ import dev.meloda.fast.model.database.VkMessageEntity
@Dao @Dao
abstract class MessageDao : EntityDao<VkMessageEntity> { abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages") @Query("SELECT * FROM messages WHERE isDeleted = 0 AND isSpam = 0")
abstract suspend fun getAll(): List<VkMessageEntity> abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)") @Query("SELECT * FROM messages WHERE peerId IS (:convoId)")
@@ -21,4 +21,13 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("DELETE FROM messages WHERE id IN (:ids)") @Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Int>): Int
@Query("UPDATE messages SET isDeleted = :isDeleted WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsDeleted(convoId: Long, cmId: Long, isDeleted: Boolean): Int
@Query("UPDATE messages SET isImportant = :isImportant WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsImportant(convoId: Long, cmId: Long, isImportant: Boolean): Int
@Query("UPDATE messages SET isSpam = :isSpam WHERE peerId = :convoId AND cmId = :cmId")
abstract suspend fun markAsSpam(convoId: Long, cmId: Long, isSpam: Boolean): Int
} }
@@ -3,7 +3,7 @@ package dev.meloda.fast.datastore
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel import dev.meloda.fast.common.model.NetworkLogLevel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -238,11 +238,11 @@ object AppSettings {
) )
set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value) set(value) = put(SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT, value)
var networkLogLevel: LogLevel var networkLogLevel: NetworkLogLevel
get() = get( get() = get(
SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL,
SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL
).let(LogLevel::parse) ).let(NetworkLogLevel::parse)
set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value) set(level) = put(SettingsKeys.KEY_DEBUG_NETWORK_LOG_LEVEL, level.value)
var showDebugCategory: Boolean var showDebugCategory: Boolean
@@ -0,0 +1,188 @@
package dev.meloda.fast.domain
import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.LongPollParsedEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext
class LongPollEventsHandler(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val convoDao: ConvoDao,
private val messageDao: MessageDao,
) {
private val job = SupervisorJob()
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
logger.error(this::class, "CoroutineException", throwable)
}
private val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext)
suspend fun handleEvents(events: List<LongPollParsedEvent>) {
events.forEach { handleNextEvent(it) }
}
private suspend fun handleNextEvent(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.AudioMessageListened -> {
}
is LongPollParsedEvent.ChatArchived -> {
val affectedRows = convoDao.updateIsArchived(
convoId = event.convo.id,
isArchived = event.convo.isArchived
)
logger.debug(
this::class,
"isArchived ${event.convo.isArchived}: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatCleared -> {
val affectedRows = convoDao.updateLastCmId(
convoId = event.peerId,
cmId = event.toCmId
)
logger.debug(
this::class,
"updateLastCmId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMajorChanged -> {
val affectedRows = convoDao.updateMajorId(
convoId = event.peerId,
majorId = event.majorId
)
logger.debug(
this::class,
"updateMajorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.ChatMinorChanged -> {
val affectedRows = convoDao.updateMinorId(
convoId = event.peerId,
minorId = event.minorId
)
logger.debug(
this::class,
"updateMinorId: updated $affectedRows rows."
)
}
is LongPollParsedEvent.Interaction -> {
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageDeleted -> {
val affectedRows = messageDao.markAsDeleted(
convoId = event.peerId,
cmId = event.cmId,
isDeleted = true
)
logger.debug(
this::class,
"markDeleted: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val affectedRows = messageDao.markAsImportant(
convoId = event.peerId,
cmId = event.cmId,
isImportant = event.marked
)
logger.debug(
this::class,
"markImportant: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val affectedRows = messageDao.markAsSpam(
convoId = event.peerId,
cmId = event.cmId,
isSpam = true
)
logger.debug(
this::class,
"markSpam: updated $affectedRows rows."
)
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.IncomingMessageRead -> {
val affectedRows = convoDao.updateReadIncoming(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"inMessageRead: updated $affectedRows rows."
)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
val affectedRows = convoDao.updateReadOutgoing(
convoId = event.peerId,
cmId = event.cmId,
unreadCount = event.unreadCount
)
logger.debug(
this::class,
"outMessageRead: updated $affectedRows rows."
)
}
is LongPollParsedEvent.UnreadCounter -> {
}
}
}
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong import dev.meloda.fast.common.extensions.asLong
@@ -8,6 +7,7 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.ApiEvent import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
@@ -22,12 +22,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser( class LongPollUpdatesParser(
private val logger: FastLogger,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase private val messagesUseCase: MessagesUseCase
) { ) {
@@ -35,8 +35,7 @@ class LongPollUpdatesParser(
private val exceptionHandler = private val exceptionHandler =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
Log.e("LongPollUpdatesParser", "error: $throwable") logger.error(this::class, "CoroutineException", throwable)
throwable.printStackTrace()
} }
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
@@ -47,11 +46,14 @@ class LongPollUpdatesParser(
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> = private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf() mutableMapOf()
fun parseNextUpdate(event: List<Any>) { suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> {
val eventId = event.first().asInt() val eventId = event.first().asInt()
when (val eventType = ApiEvent.parseOrNull(eventId)) { return when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") null -> {
logger.debug(this::class, "parseNextUpdate(): unknownEvent: $event")
emptyList()
}
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
@@ -77,8 +79,11 @@ class LongPollUpdatesParser(
} }
} }
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) { private fun parseMessageSetFlags(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageSetFlags(): $eventType: $event")
val cmId = event[1].asLong() val cmId = event[1].asLong()
val flags = event[2].asInt() val flags = event[2].asInt()
@@ -97,12 +102,8 @@ class LongPollUpdatesParser(
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
listeners.map { vkEventCallback -> ?.forEach { it.onEvent(eventToSend) }
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
} }
MessageFlags.SPAM -> { MessageFlags.SPAM -> {
@@ -111,13 +112,7 @@ class LongPollUpdatesParser(
cmId = cmId cmId = cmId
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.forEach { it.onEvent(eventToSend) }
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
} }
MessageFlags.DELETED -> { MessageFlags.DELETED -> {
@@ -136,13 +131,7 @@ class LongPollUpdatesParser(
) )
} }
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.forEach { it.onEvent(eventToSend) }
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
} }
MessageFlags.AUDIO_LISTENED -> { MessageFlags.AUDIO_LISTENED -> {
@@ -152,29 +141,37 @@ class LongPollUpdatesParser(
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners -> listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]
listeners.map { vkEventCallback -> ?.forEach { it.onEvent(eventToSend) }
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
} }
else -> Unit MessageFlags.UNREAD -> Unit
MessageFlags.OUTGOING -> Unit
MessageFlags.FROM_GROUP_CHAT -> Unit
MessageFlags.CANCEL_SPAM -> Unit
MessageFlags.DELETED_FOR_ALL -> Unit
MessageFlags.DO_NOT_SHOW_NOTIFICATION -> Unit
MessageFlags.MESSAGE_WITH_REPLY -> Unit
MessageFlags.REACTION -> Unit
} }
} }
eventsToSend.forEach { eventToSend -> eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners -> listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback -> listeners.forEach { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend) vkEventCallback.onEvent(eventToSend)
} }
} }
} }
return eventsToSend
} }
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) { private suspend fun parseMessageClearFlags(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageClearFlags(): $eventType: $event")
val cmId = event[1].asLong() val cmId = event[1].asLong()
val flags = event[2].asInt() val flags = event[2].asInt()
@@ -184,7 +181,9 @@ class LongPollUpdatesParser(
val parsedFlags = MessageFlags.parse(flags) val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch { coroutineScope.launch(Dispatchers.IO) {
val message = loadMessage(peerId = peerId, cmId = cmId)
parsedFlags.forEach { flag -> parsedFlags.forEach { flag ->
when (flag) { when (flag) {
MessageFlags.IMPORTANT -> { MessageFlags.IMPORTANT -> {
@@ -195,81 +194,65 @@ class LongPollUpdatesParser(
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners -> listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
listeners.map { vkEventCallback -> ?.forEach { it.onEvent(eventToSend) }
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
} }
MessageFlags.SPAM -> { MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
withContext(Dispatchers.IO) { if (message != null) {
val message = loadMessage( val eventToSend =
peerId = peerId, LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
cmId = cmId eventsToSend += eventToSend
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners -> listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]
listeners.map { vkEventCallback -> ?.forEach { it.onEvent(eventToSend) }
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
} }
} }
} }
MessageFlags.DELETED -> { MessageFlags.DELETED -> {
withContext(Dispatchers.IO) { if (message != null) {
val message = loadMessage( val eventToSend =
peerId = peerId, LongPollParsedEvent.MessageRestored(message = message)
cmId = cmId eventsToSend += eventToSend
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners -> listenersMap[LongPollEvent.MESSAGE_RESTORED]
listeners.map { vkEventCallback -> ?.forEach { it.onEvent(eventToSend) }
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
} }
} }
else -> Unit 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
} }
} }
eventsToSend.forEach { eventToSend -> listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.forEach { listener ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners -> eventsToSend.forEach { listener.onEvent(it) }
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
} }
continuation.resume(eventsToSend)
} }
} }
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) { private suspend fun parseMessageNew(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageNew(): $eventType: $event")
val cmId = event[1].asLong() val cmId = event[1].asLong()
val peerId = event[4].asLong() val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val message = val message = async { loadMessage(peerId = peerId, cmId = cmId) }.await()
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo = val convo =
async { async {
@@ -280,88 +263,88 @@ class LongPollUpdatesParser(
) )
}.await() }.await()
message?.let { if (message != null) {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let { val event = LongPollParsedEvent.NewMessage(
it.map { vkEventCallback -> message = message,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>) inArchive = convo?.isArchived == true
.onEvent( // TODO: 03-Apr-25, Danil Nikolaev:
LongPollParsedEvent.NewMessage( // load user settings about restoring chats with
message = message, // enabled notifications from archive
inArchive = convo?.isArchived == true )
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with listenersMap[LongPollEvent.MESSAGE_NEW]?.forEach { it.onEvent(event) }
// enabled notifications from archive continuation.resume(listOf(event))
) } else {
) continuation.resume(emptyList())
}
}
} }
} }
} }
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) { private suspend fun parseMessageEdit(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageEdit(): $eventType: $event")
val cmId = event[1].asLong() val cmId = event[1].asLong()
val peerId = event[3].asLong() val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
loadMessage( val message = loadMessage(peerId = peerId, cmId = cmId)
peerId = peerId, if (message != null) {
cmId = cmId val event = LongPollParsedEvent.MessageEdited(message)
)?.let { message -> listenersMap[LongPollEvent.MESSAGE_EDITED]?.forEach { it.onEvent(event) }
listenersMap[LongPollEvent.MESSAGE_EDITED]?.let { continuation.resume(listOf(event))
it.map { vkEventCallback -> } else {
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>) continuation.resume(emptyList())
.onEvent(LongPollParsedEvent.MessageEdited(message))
}
}
} }
} }
} }
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) { private fun parseMessageReadIncoming(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadIncoming(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val cmId = event[2].asLong() val cmId = event[2].asLong()
val unreadCount = event[3].asInt() val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners -> val event = LongPollParsedEvent.IncomingMessageRead(
listeners.map { vkEventCallback -> peerId = peerId,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>) cmId = cmId,
.onEvent( unreadCount = unreadCount
LongPollParsedEvent.IncomingMessageRead( )
peerId = peerId, listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.forEach { it.onEvent(event) }
cmId = cmId, return listOf(event)
unreadCount = unreadCount
)
)
}
}
} }
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) { private fun parseMessageReadOutgoing(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessageReadOutgoing(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val cmId = event[2].asLong() val cmId = event[2].asLong()
val unreadCount = event[3].asInt() val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners -> val event = LongPollParsedEvent.OutgoingMessageRead(
listeners.map { vkEventCallback -> peerId = peerId,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>) cmId = cmId,
.onEvent( unreadCount = unreadCount
LongPollParsedEvent.OutgoingMessageRead( )
peerId = peerId,
cmId = cmId, listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.forEach { it.onEvent(event) }
unreadCount = unreadCount return listOf(event)
)
)
}
}
} }
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) { private suspend fun parseChatClearFlags(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatClearFlags(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val flags = event[2].asInt() val flags = event[2].asInt()
@@ -391,32 +374,37 @@ class LongPollUpdatesParser(
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners -> listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
} }
else -> Unit 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
} }
} }
eventsToSend.forEach { eventToSend -> listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.forEach { listener ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners -> eventsToSend.forEach { listener.onEvent(it) }
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
} }
continuation.resume(eventsToSend)
} }
} }
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) { private suspend fun parseChatSetFlags(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseChatSetFlags(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val flags = event[2].asInt() val flags = event[2].asInt()
@@ -446,89 +434,88 @@ class LongPollUpdatesParser(
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners -> listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
} }
else -> Unit 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
} }
} }
eventsToSend.forEach { eventToSend -> listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.forEach { listener ->
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners -> eventsToSend.forEach { listener.onEvent(it) }
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
} }
continuation.resume(eventsToSend)
} }
} }
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) { private fun parseMessagesDeleted(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseMessagesDeleted(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val cmId = event[2].asLong() val cmId = event[2].asLong()
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners -> val event = LongPollParsedEvent.ChatCleared(
listeners.forEach { vkEventCallback -> peerId = peerId,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>) toCmId = cmId
.onEvent( )
LongPollParsedEvent.ChatCleared( listenersMap[LongPollEvent.CHAT_CLEARED]?.forEach { it.onEvent(event) }
peerId = peerId, return listOf(event)
toCmId = cmId
)
)
}
}
} }
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) { private fun parseChatMajorChanged(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMajorChanged(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val majorId = event[2].asInt() val majorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners -> val event = LongPollParsedEvent.ChatMajorChanged(
listeners.forEach { vkEventCallback -> peerId = peerId,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>) majorId = majorId,
.onEvent( )
LongPollParsedEvent.ChatMajorChanged( listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.forEach { it.onEvent(event) }
peerId = peerId, return listOf(event)
majorId = majorId,
)
)
}
}
} }
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) { private fun parseChatMinorChanged(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseChatMinorChanged(): $eventType: $event")
val peerId = event[1].asLong() val peerId = event[1].asLong()
val minorId = event[2].asInt() val minorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners -> val event = LongPollParsedEvent.ChatMinorChanged(
listeners.forEach { vkEventCallback -> peerId = peerId,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>) minorId = minorId,
.onEvent( )
LongPollParsedEvent.ChatMinorChanged( listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.forEach { it.onEvent(event) }
peerId = peerId, return listOf(event)
minorId = minorId,
)
)
}
}
} }
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) { private fun parseInteraction(
Log.d("LongPollUpdatesParser", "$eventType: $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseInteraction(): $eventType: $event")
val interactionType = when (eventType) { val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing ApiEvent.TYPING -> InteractionType.Typing
@@ -536,7 +523,7 @@ class LongPollUpdatesParser(
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return else -> return emptyList()
} }
val longPollEvent: LongPollEvent = when (eventType) { val longPollEvent: LongPollEvent = when (eventType) {
@@ -545,7 +532,6 @@ class LongPollUpdatesParser(
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
} }
val peerId = event[1].asLong() val peerId = event[1].asLong()
@@ -554,26 +540,25 @@ class LongPollUpdatesParser(
val timestamp = event[4].asInt() val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status // if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return if (userIds.isEmpty()) return emptyList()
listenersMap[longPollEvent]?.let { listeners -> val event = LongPollParsedEvent.Interaction(
listeners.forEach { vkEventCallback -> interactionType = interactionType,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>) peerId = peerId,
.onEvent( userIds = userIds,
LongPollParsedEvent.Interaction( totalCount = totalCount,
interactionType = interactionType, timestamp = timestamp
peerId = peerId, )
userIds = userIds,
totalCount = totalCount, listenersMap[longPollEvent]?.forEach { it.onEvent(event) }
timestamp = timestamp return listOf(event)
)
)
}
}
} }
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) { private fun parseUnreadCounterUpdate(
Log.d("LongPollUpdatesParser", "$eventType $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> {
logger.debug(this::class, "parseUnreadCounterUpdate(): $eventType: $event")
val unreadCount = event[1].asInt() val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt() val unreadUnmutedCount = event[2].asInt()
@@ -583,58 +568,57 @@ class LongPollUpdatesParser(
val archiveUnreadUnmutedCount = event[8].asInt() val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt() val archiveMentionsCount = event[9].asInt()
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners -> val event = LongPollParsedEvent.UnreadCounter(
listeners.forEach { vkEventCallback -> unread = unreadCount,
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>) unreadUnmuted = unreadUnmutedCount,
.onEvent( showOnlyMuted = showOnlyMuted,
LongPollParsedEvent.UnreadCounter( business = businessNotifyUnreadCount,
unread = unreadCount, archive = archiveUnreadCount,
unreadUnmuted = unreadUnmutedCount, archiveUnmuted = archiveUnreadUnmutedCount,
showOnlyMuted = showOnlyMuted, archiveMentions = archiveMentionsCount
business = businessNotifyUnreadCount, )
archive = archiveUnreadCount, listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.forEach { it.onEvent(event) }
archiveUnmuted = archiveUnreadUnmutedCount, return listOf(event)
archiveMentions = archiveMentionsCount
)
)
}
}
} }
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) { private suspend fun parseMessageUpdated(
Log.d("LongPollUpdatesParser", "$eventType $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageUpdated(): $eventType: $event")
val cmId = event[1].asLong() val cmId = event[1].asLong()
val peerId = event[4].asLong() val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
loadMessage( val message = loadMessage(peerId = peerId, cmId = cmId)
peerId = peerId,
cmId = cmId if (message != null) {
)?.let { message -> val event = LongPollParsedEvent.MessageUpdated(message)
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let { listenersMap[LongPollEvent.MESSAGE_UPDATED]?.forEach { it.onEvent(event) }
it.map { vkEventCallback -> continuation.resume(listOf(event))
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>) } else {
.onEvent(LongPollParsedEvent.MessageUpdated(message)) continuation.resume(emptyList())
}
}
} }
} }
} }
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) { private suspend fun parseMessageCacheClear(
Log.d("LongPollUpdatesParser", "$eventType $event") eventType: ApiEvent,
event: List<Any>
): List<LongPollParsedEvent> = suspendCancellableCoroutine { continuation ->
logger.debug(this::class, "parseMessageCacheClear(): $eventType: $event")
val messageId = event[1].asLong() val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message -> val message = loadMessage(messageId = messageId)
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let { if (message != null) {
it.map { vkEventCallback -> val event = LongPollParsedEvent.MessageCacheClear(message)
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>) listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.forEach { it.onEvent(event) }
.onEvent(LongPollParsedEvent.MessageCacheClear(message)) continuation.resume(listOf(event))
} } else {
} continuation.resume(emptyList())
} }
} }
} }
@@ -643,7 +627,7 @@ class LongPollUpdatesParser(
peerId: Long? = null, peerId: Long? = null,
cmId: Long? = null, cmId: Long? = null,
messageId: Long? = null messageId: Long? = null
): VkMessage? = suspendCoroutine { continuation -> ): VkMessage? = suspendCancellableCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null) require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
@@ -657,7 +641,7 @@ class LongPollUpdatesParser(
).listenValue(this) { state -> ).listenValue(this) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.e("LongPollUpdatesParser", "loadMessage: error: $error") logger.error(this::class, "loadMessage(): ERROR: $error")
continuation.resume(null) continuation.resume(null)
}, },
success = { response -> success = { response ->
@@ -677,7 +661,7 @@ class LongPollUpdatesParser(
peerId: Long, peerId: Long,
extended: Boolean = false, extended: Boolean = false,
fields: String? = null fields: String? = null
): VkConvo? = suspendCoroutine { continuation -> ): VkConvo? = suspendCancellableCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById( convoUseCase.getById(
peerIds = listOf(peerId), peerIds = listOf(peerId),
@@ -686,7 +670,7 @@ class LongPollUpdatesParser(
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.e("LongPollUpdatesParser", "loadConvo: error: $error") logger.error(this::class, "loadConvo(): ERROR: $error")
continuation.resume(null) continuation.resume(null)
}, },
success = { response -> success = { response ->
@@ -598,6 +598,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24 AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -687,6 +688,7 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource) }.let(UiText::Resource)
} }
+1
View File
@@ -0,0 +1 @@
/build
+11
View File
@@ -0,0 +1,11 @@
plugins {
alias(libs.plugins.fast.android.library)
}
android {
namespace = "dev.meloda.fast.logger"
}
dependencies {
implementation(libs.koin.android)
}
@@ -0,0 +1,17 @@
package dev.meloda.fast.logger;
enum class FastLogLevel {
VERBOSE,
DEBUG,
INFO,
WARNING,
ERROR,
ASSERT;
companion object {
fun parse(value: Int): FastLogLevel {
if (value !in 0..5) throw IllegalArgumentException("Unknown LogLevel value $value")
return entries.first { it.ordinal == value }
}
}
}
@@ -0,0 +1,108 @@
package dev.meloda.fast.logger
import android.util.Log
import kotlin.reflect.KClass
class FastLogger {
companion object {
@Volatile
private lateinit var instance: FastLogger
fun setInstance(logger: FastLogger) {
if (::instance.isInitialized) {
throw IllegalStateException("FastLogger has already been initialized.")
}
instance = logger
}
fun getInstance(): FastLogger {
if (!::instance.isInitialized) {
throw UninitializedPropertyAccessException("FastLogger is not initialized.")
}
return instance
}
}
private var logLevel: FastLogLevel = FastLogLevel.ERROR
fun setLogLevel(logLevel: FastLogLevel) {
Log.v(this::class.java.simpleName, "Set LogLevel from ${this.logLevel} to $logLevel")
this.logLevel = logLevel
}
fun verbose(clazz: Class<*>, message: String, throwable: Throwable? = null) {
verbose(clazz.simpleName, message, throwable)
}
fun verbose(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.VERBOSE)) {
Log.v(tag, message, throwable)
}
}
fun debug(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
debug(clazz.java, message, throwable)
}
fun debug(clazz: Class<*>, message: String, throwable: Throwable? = null) {
debug(clazz.simpleName, message, throwable)
}
fun debug(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.DEBUG)) {
Log.d(tag, message, throwable)
}
}
fun info(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
info(clazz.java, message, throwable)
}
fun info(clazz: Class<*>, message: String, throwable: Throwable? = null) {
info(clazz.simpleName, message, throwable)
}
fun info(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.INFO)) {
Log.i(tag, message, throwable)
}
}
fun warning(clazz: Class<*>, message: String, throwable: Throwable? = null) {
warning(clazz.simpleName, message, throwable)
}
fun warning(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.WARNING)) {
Log.w(tag, message, throwable)
}
}
fun error(clazz: KClass<*>, message: String, throwable: Throwable? = null) {
error(clazz.java, message, throwable)
}
fun error(clazz: Class<*>, message: String, throwable: Throwable? = null) {
error(clazz.simpleName, message, throwable)
}
fun error(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ERROR)) {
Log.e(tag, message, throwable)
}
}
fun assert(clazz: Class<*>, message: String, throwable: Throwable? = null) {
assert(clazz.simpleName, message, throwable)
}
fun assert(tag: String, message: String, throwable: Throwable? = null) {
if (shouldLog(FastLogLevel.ASSERT)) {
Log.wtf(tag, message, throwable)
}
}
private fun shouldLog(level: FastLogLevel): Boolean = level.ordinal >= logLevel.ordinal
}
@@ -0,0 +1,8 @@
package dev.meloda.fast.logger
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val loggerModule = module {
singleOf(::FastLogger)
}
@@ -1,7 +1,5 @@
package dev.meloda.fast.model.api.data package dev.meloda.fast.model.api.data
import android.util.Log
enum class AttachmentType(var value: String) { enum class AttachmentType(var value: String) {
UNKNOWN("unknown"), UNKNOWN("unknown"),
PHOTO("photo"), PHOTO("photo"),
@@ -30,7 +28,8 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"), ARTICLE("article"),
VIDEO_MESSAGE("video_message"), VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"), GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview") STICKER_PACK_PREVIEW("sticker_pack_preview"),
CHANNEL_MESSAGE("channel_message")
; ;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -41,10 +40,6 @@ enum class AttachmentType(var value: String) {
it.value == value it.value == value
} ?: UNKNOWN } ?: UNKNOWN
if (parsedValue == UNKNOWN) {
Log.e("AttachmentType", "Unknown attachment type: $value")
}
return parsedValue return parsedValue
} }
} }
@@ -35,7 +35,8 @@ data class VkAttachmentItemData(
@Json(name = "article") val article: VkArticleData?, @Json(name = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?, @Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, @Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?,
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData? @Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?,
@Json(name = "channel_message") val channelMessageData: VkChannelMessageData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -66,5 +67,6 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment } ?: VkUnknownAttachment
} }
@@ -0,0 +1,40 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkChannelMessage
@JsonClass(generateAdapter = true)
data class VkChannelMessageData(
@Json(name = "channel_id") val channelId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "author_id") val authorId: Long,
@Json(name = "channel_info") val channelInfo: ChannelInfo,
@Json(name = "channel_type") val channelType: String,
@Json(name = "guid") val guid: String,
@Json(name = "text") val text: String?,
@Json(name = "time") val time: Long,
@Json(name = "attachments") val attachments: List<VkAttachmentItemData> = emptyList(),
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
data class ChannelInfo(
@Json(name = "photo_base") val photoBase: String?,
@Json(name = "title") val title: String
)
fun toDomain(): VkChannelMessage = VkChannelMessage(
channelId = channelId,
cmId = cmId,
authorId = authorId,
channelInfo = VkChannelMessage.ChannelInfo(
title = channelInfo.title,
photoBase = channelInfo.photoBase
),
channelType = channelType,
guid = guid,
text = text,
time = time,
attachments = attachments.map(VkAttachmentItemData::toDomain),
)
}
@@ -117,5 +117,6 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
pinnedAt = pinnedAt, pinnedAt = pinnedAt,
isPinned = isPinned == true, isPinned = isPinned == true,
formatData = formatData?.asDomain(), formatData = formatData?.asDomain(),
isSpam = false isSpam = false,
isDeleted = false
) )
@@ -56,5 +56,6 @@ data class VkPinnedMessageData(
isPinned = true, isPinned = true,
isSpam = false, isSpam = false,
formatData = null, formatData = null,
isDeleted = false
) )
} }
@@ -0,0 +1,23 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkChannelMessage(
val channelId: Long,
val cmId: Long,
val authorId: Long,
val channelInfo: ChannelInfo,
val channelType: String,
val guid: String,
val text: String?,
val time: Long,
val attachments: List<VkAttachment>?,
) : VkAttachment {
data class ChannelInfo(
val title: String,
val photoBase: String?
)
override val type: AttachmentType = AttachmentType.CHANNEL_MESSAGE
}
@@ -36,6 +36,8 @@ data class VkMessage(
val group: VkGroupDomain?, val group: VkGroupDomain?,
val actionUser: VkUser?, val actionUser: VkUser?,
val actionGroup: VkGroupDomain?, val actionGroup: VkGroupDomain?,
val isDeleted: Boolean
) { ) {
fun isPeerChat() = peerId > 2_000_000_000 fun isPeerChat() = peerId > 2_000_000_000
@@ -111,7 +113,7 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
actionCmId = actionCmId, actionCmId = actionCmId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
important = isImportant, isImportant = isImportant,
forwardIds = forwards.orEmpty().map(VkMessage::id), forwardIds = forwards.orEmpty().map(VkMessage::id),
// TODO: 05/05/2024, Danil Nikolaev: save attachments // TODO: 05/05/2024, Danil Nikolaev: save attachments
attachments = emptyList(), attachments = emptyList(),
@@ -119,4 +121,6 @@ fun VkMessage.asEntity(): VkMessageEntity = VkMessageEntity(
geoType = geoType, geoType = geoType,
pinnedAt = pinnedAt, pinnedAt = pinnedAt,
isPinned = isPinned, isPinned = isPinned,
isDeleted = isDeleted,
isSpam = isSpam
) )
@@ -21,13 +21,15 @@ data class VkMessageEntity(
val actionCmId: Long?, val actionCmId: Long?,
val actionMessage: String?, val actionMessage: String?,
val updateTime: Int?, val updateTime: Int?,
val important: Boolean, val isImportant: Boolean,
val forwardIds: List<Long>?, val forwardIds: List<Long>?,
val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store??? val attachments: List<String>?, // TODO: 01/05/2024, Danil Nikolaev: how to store???
val replyMessageId: Long?, val replyMessageId: Long?,
val geoType: String?, val geoType: String?,
val pinnedAt: Int?, val pinnedAt: Int?,
val isPinned: Boolean val isPinned: Boolean,
val isDeleted: Boolean,
val isSpam: Boolean
) )
fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage( fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
@@ -45,7 +47,7 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
actionCmId = actionCmId, actionCmId = actionCmId,
actionMessage = actionMessage, actionMessage = actionMessage,
updateTime = updateTime, updateTime = updateTime,
isImportant = important, isImportant = isImportant,
forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel), forwards = emptyList(),//forwards.orEmpty().map(VkMessageEntity::asExternalModel),
// TODO: 05/05/2024, Danil Nikolaev: restore attachments // TODO: 05/05/2024, Danil Nikolaev: restore attachments
attachments = attachments.orEmpty().map { VkUnknownAttachment }, attachments = attachments.orEmpty().map { VkUnknownAttachment },
@@ -59,4 +61,5 @@ fun VkMessageEntity.asExternalModel(): VkMessage = VkMessage(
isPinned = isPinned, isPinned = isPinned,
isSpam = false, isSpam = false,
formatData = null, formatData = null,
isDeleted = isDeleted
) )
+1
View File
@@ -15,6 +15,7 @@ dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
api(projects.core.datastore) api(projects.core.datastore)
api(projects.core.logger)
implementation(libs.moshi.kotlin) implementation(libs.moshi.kotlin)
implementation(libs.koin.android) implementation(libs.koin.android)
@@ -1,10 +1,10 @@
package dev.meloda.fast.network package dev.meloda.fast.network
import android.util.Log
import com.slack.eithernet.ApiException import com.slack.eithernet.ApiException
import com.slack.eithernet.errorType import com.slack.eithernet.errorType
import com.slack.eithernet.toType import com.slack.eithernet.toType
import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonDataException
import dev.meloda.fast.logger.FastLogger
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Converter import retrofit2.Converter
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -16,7 +16,10 @@ import java.lang.reflect.Type
* *
* допускает Unit как SuccessType в случае невозможности каста ответа в ErrorType * допускает Unit как SuccessType в случае невозможности каста ответа в ErrorType
*/ */
class ResponseConverterFactory(private val converter: JsonConverter) : Converter.Factory() { class ResponseConverterFactory(
private val converter: JsonConverter,
private val logger: FastLogger
) : Converter.Factory() {
override fun responseBodyConverter( override fun responseBodyConverter(
type: Type, type: Type,
@@ -29,6 +32,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
successType = type, successType = type,
errorRaw = errorRaw, errorRaw = errorRaw,
converter = converter, converter = converter,
logger = logger
) )
} }
@@ -36,6 +40,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
private val successType: Type, private val successType: Type,
private val errorRaw: Class<*>, private val errorRaw: Class<*>,
private val converter: JsonConverter, private val converter: JsonConverter,
private val logger: FastLogger
) : Converter<ResponseBody, Any?> { ) : Converter<ResponseBody, Any?> {
override fun convert(value: ResponseBody): Any? { override fun convert(value: ResponseBody): Any? {
val string = value.string() val string = value.string()
@@ -53,7 +58,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
}, },
onFailure = { failure -> onFailure = { failure ->
if (failure is JsonDataException) { if (failure is JsonDataException) {
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure") logger.error(this::class, "convert(): ERROR", failure)
throw ApiException( throw ApiException(
RestApiError( RestApiError(
errorCode = -1, errorCode = -1,
@@ -68,10 +73,11 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
converter.fromJson(errorRaw, string) converter.fromJson(errorRaw, string)
}.fold( }.fold(
onSuccess = { errorModel -> onSuccess = { errorModel ->
Log.d("ResponseBodyConverter", "convert: $errorModel") logger.debug(this::class, "convert(): errorModel: $errorModel")
throw ApiException(errorModel) throw ApiException(errorModel)
}, },
onFailure = { exception -> onFailure = { exception ->
logger.error(this::class, "convert(): INNER: ERROR", exception)
if (!isUnit) { if (!isUnit) {
throw exception throw exception
} else { } else {
@@ -1,9 +1,9 @@
package dev.meloda.fast.network.interceptor package dev.meloda.fast.network.interceptor
import android.util.Log
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -14,11 +14,7 @@ import org.json.JSONObject
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class Error14HandlingInterceptor( class Error14HandlingInterceptor(private val logger: FastLogger) : Interceptor {
// private val domains: Set<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(null)
private companion object { private companion object {
private const val CAPTCHA_ERROR_CODE = 14 private const val CAPTCHA_ERROR_CODE = 14
@@ -26,6 +22,8 @@ class Error14HandlingInterceptor(
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
} }
private val cookie = AtomicReference<String?>(null)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie() val request = chain.request().withCookie()
val response = chain.proceed(request) val response = chain.proceed(request)
@@ -41,23 +39,23 @@ class Error14HandlingInterceptor(
executor.submit { executor.submit {
AppSettings.setCaptchaRedirectUri(redirectUri) AppSettings.setCaptchaRedirectUri(redirectUri)
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri") logger.debug(this::class, "passCaptchaAndGetToken: $redirectUri")
var job: Job? = null var job: Job? = null
job = AppSettings.getCaptchaResultFlow() job = AppSettings.getCaptchaResultFlow()
.listenValue(CoroutineScope(Dispatchers.IO)) { .listenValue(CoroutineScope(Dispatchers.IO)) {
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it") logger.debug(this::class, "passCaptchaAndGetToken: $it")
if (it != CaptchaTokenResult.Initial) { if (it != CaptchaTokenResult.Initial) {
synchronized(tokenResult) { synchronized(tokenResult) {
Log.d( logger.debug(
"Error14Interceptor", this::class,
"passCaptchaAndGetToken: SYNCHRONIZED: $it" "passCaptchaAndGetToken: SYNCHRONIZED: $it"
) )
tokenResult.set(wrapResult(it)) tokenResult.set(wrapResult(it))
tokenResult.notifyAll() tokenResult.notifyAll()
job?.cancel() job?.cancel()
Log.d( logger.debug(
"Error14Interceptor", this::class,
"passCaptchaAndGetToken: NULL RESULT" "passCaptchaAndGetToken: NULL RESULT"
) )
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial) AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
@@ -71,7 +69,7 @@ class Error14HandlingInterceptor(
tokenResult.wait() tokenResult.wait()
} }
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE") logger.debug(this::class, "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow() tokenResult.get().getOrThrow()
} }
} }
+1
View File
@@ -12,6 +12,7 @@ android {
dependencies { dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
api(projects.core.logger)
implementation(projects.core.presentation) implementation(projects.core.presentation)
implementation(libs.haze) implementation(libs.haze)
@@ -0,0 +1,6 @@
package dev.meloda.fast.ui.common
import androidx.compose.runtime.compositionLocalOf
import dev.meloda.fast.logger.FastLogger
val LocalLogger = compositionLocalOf { FastLogger.getInstance() }
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -30,27 +33,32 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
containerColor: Color = colors.containerColor(enabled),
contentColor: Color = colors.contentColor(enabled),
size: Dp = IconButtonTokens.StateLayerSize,
shape: Shape = IconButtonTokens.StateLayerShape,
alignment: Alignment = Alignment.Center,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Box( Box(
modifier = modifier =
modifier modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize) .size(size)
.clip(IconButtonTokens.StateLayerShape) .clip(shape)
.background(color = colors.containerColor(enabled)) .background(containerColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = ripple() indication = indication
), ),
contentAlignment = Alignment.Center contentAlignment = alignment
) { ) {
val contentColor = colors.contentColor(enabled) CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
} }
} }
@@ -0,0 +1,116 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.util.ImmutableList
data class SegmentedButtonItem(
val key: String,
val iconResId: Int
)
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<SegmentedButtonItem>,
onClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
iconContainerWidth: Dp = 42.dp,
iconContainerHeight: Dp = 36.dp,
iconSize: Dp = 18.dp,
showDividers: Boolean = true
) {
SegmentedButtonsRow(
modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize),
items = items.mapIndexed { index, item ->
{
val first = index == 0
val last = index == items.lastIndex
if (showDividers && !first) {
VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4))
}
SegmentedButton(
onClick = { onClick(index) },
iconResId = item.iconResId,
modifier = Modifier.size(
iconContainerWidth,
iconContainerHeight
),
iconSize = iconSize,
shape = containerShape.copy(
topStart = if (!first) CornerSize(0.dp) else containerShape.topStart,
bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart,
topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd,
bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd
)
)
}
},
containerShape = containerShape,
containerColor = containerColor,
borderColor = borderColor,
borderSize = borderSize
)
}
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<@Composable () -> Unit>,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
) {
Row(
modifier = modifier
.background(containerColor, containerShape)
.border(borderSize, borderColor, containerShape)
) {
items.forEach { it.invoke() }
}
}
@Composable
fun SegmentedButton(
onClick: () -> Unit,
iconResId: Int,
modifier: Modifier = Modifier,
iconSize: Dp = 18.dp,
shape: Shape = CircleShape
) {
FastIconButton(
onClick = onClick,
modifier = modifier,
shape = shape
) {
Icon(
modifier = Modifier.size(iconSize),
painter = painterResource(iconResId),
contentDescription = null
)
}
}
@@ -58,7 +58,7 @@ fun AppTheme(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val colorScheme: ColorScheme = when { val colorScheme: ColorScheme = predefinedColorScheme ?: when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context) else dynamicLightColorScheme(context)
@@ -82,10 +82,6 @@ fun AppTheme(
} }
} }
val colorPrimary by animateColorAsState(colorScheme.primary)
val colorSurface by animateColorAsState(colorScheme.surface)
val colorBackground by animateColorAsState(colorScheme.background)
val typography = if (useSystemFont) { val typography = if (useSystemFont) {
MaterialTheme.typography MaterialTheme.typography
} else { } else {
@@ -118,12 +114,7 @@ fun AppTheme(
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme) colorScheme = colorScheme,
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
typography = typography, typography = typography,
content = content content = content
) )
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -113,11 +114,12 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable @Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean { fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = context.resources.configuration.uiMode val systemUiNightMode = configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme = val isSystemUsingDarkTheme =
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable @Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> { class ImmutableList<T>(val values: List<T>) : Collection<T> {
@@ -57,3 +58,9 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList()) fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
inline fun <T> buildImmutableList(builderAction: MutableList<T>.() -> Unit): ImmutableList<T> {
val mutableList = mutableListOf<T>()
mutableList.apply(builderAction)
return mutableList.toImmutableList()
}
+4
View File
@@ -88,6 +88,7 @@
<string name="message_attachments_video_message">Video message</string> <string name="message_attachments_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</string> <string name="message_attachments_group_sticker">Group sticker</string>
<string name="message_attachments_sticker_pack_preview">Sticker pack preview</string> <string name="message_attachments_sticker_pack_preview">Sticker pack preview</string>
<string name="message_attachments_channel_message">Channel message</string>
<string name="chat_interaction_uploading_file">Uploading file</string> <string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string> <string name="chat_interaction_uploading_photo">Uploading photo</string>
@@ -305,4 +306,7 @@
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string> <string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string> <string name="title_edit_message">Edit message</string>
<string name="action_close">Close</string>
<string name="action_hide_stacktrace">Hide stacktrace</string>
<string name="action_show_stacktrace">Show stacktrace</string>
</resources> </resources>
+9
View File
@@ -2,4 +2,13 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" /> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="CrashDialogTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources> </resources>
@@ -1,7 +1,6 @@
package dev.meloda.fast.auth.captcha.presentation package dev.meloda.fast.auth.captcha.presentation
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
@@ -32,7 +31,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalLogger
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.FullScreenDialog import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@@ -46,6 +47,8 @@ fun CaptchaScreen(
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onResult: (String) -> Unit = {} onResult: (String) -> Unit = {}
) { ) {
val logger = LocalLogger.current
if (captchaRedirectUri != null) { if (captchaRedirectUri != null) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -114,7 +117,10 @@ fun CaptchaScreen(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
): Boolean { ): Boolean {
Log.i(TAG, "shouldOverrideUrlLoading: $request") logger.info(
"CaptchaScreen",
"WebViewClient(): shouldOverrideUrlLoading(): request: $request"
)
return false return false
} }
@@ -176,19 +182,18 @@ fun CaptchaScreen(
class WebCaptchaListener( class WebCaptchaListener(
private val onSuccessTokenReceived: (String) -> Unit, private val onSuccessTokenReceived: (String) -> Unit,
private val onCloseRequested: (String) -> Unit private val onCloseRequested: (String) -> Unit,
private val logger: FastLogger
) { ) {
private val tag = "WebCaptchaListener"
@JavascriptInterface @JavascriptInterface
fun VKCaptchaGetResult(arg: String) { fun VKCaptchaGetResult(arg: String) {
onSuccessTokenReceived(arg) onSuccessTokenReceived(arg)
Log.i(tag, "VKCaptchaGetResult($arg)") logger.info(this::class, "VKCaptchaGetResult(): arg: $arg")
} }
@JavascriptInterface @JavascriptInterface
fun VKCaptchaCloseCaptcha(arg: String) { fun VKCaptchaCloseCaptcha(arg: String) {
onCloseRequested(arg) onCloseRequested(arg)
Log.i(tag, "VKCaptchaCloseCaptcha($arg)") logger.info(this::class, "VKCaptchaCloseCaptcha(): arg: $arg")
} }
} }
@@ -2,7 +2,6 @@ package dev.meloda.fast.auth.login
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.CaptchaArguments
@@ -28,6 +27,7 @@ import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.OAuthUseCase import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.network.OAuthErrorDomain import dev.meloda.fast.network.OAuthErrorDomain
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
@@ -48,7 +48,8 @@ class LoginViewModel(
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator, private val loginValidator: LoginValidator,
private val longPollController: LongPollController, private val longPollController: LongPollController,
private val userSettings: UserSettings private val userSettings: UserSettings,
private val logger: FastLogger
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY) private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow() val screenState = _screenState.asStateFlow()
@@ -100,7 +101,13 @@ class LoginViewModel(
} }
fun onBackPressed() { fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old ->
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
} }
fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
@@ -183,7 +190,7 @@ class LoginViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") logger.error(this::class, "getSilentToken(): ERROR: $error")
_screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
@@ -31,9 +31,13 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
@@ -58,7 +62,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
@@ -67,6 +70,8 @@ import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.ClassicColorScheme
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -114,17 +119,27 @@ fun LoginRoute(
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
LoginScreen( var useClassic by rememberSaveable { mutableStateOf(true) }
screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged, AppTheme(
onPasswordInputChanged = viewModel::onPasswordInputChanged, predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, else lightColorScheme(),
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, ) {
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, LoginScreen(
onSignInButtonClicked = viewModel::onSignInButtonClicked, screenState = screenState,
onLogoClicked = viewModel::onLogoClicked, onLoginInputChanged = viewModel::onLoginInputChanged,
onLogoLongClicked = onNavigateToSettings onPasswordInputChanged = viewModel::onPasswordInputChanged,
) onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings
)
}
HandleDialogs( HandleDialogs(
loginDialog = loginDialog, loginDialog = loginDialog,
@@ -1,6 +1,5 @@
package dev.meloda.fast.chatmaterials.util package dev.meloda.fast.chatmaterials.util
import android.util.Log
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.common.util.AndroidUtils import dev.meloda.fast.common.util.AndroidUtils
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
@@ -135,8 +134,5 @@ fun VkAttachmentHistoryMessage.asPresentation(): UiChatMaterial? =
) )
} }
else -> { else -> null
Log.w("ChatMaterialMapper", "Unsupported type: $type")
null
}
} }
@@ -3,6 +3,7 @@ package dev.meloda.fast.convos
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader import coil.ImageLoader
@@ -15,10 +16,12 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
@@ -28,25 +31,23 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@Immutable
class ConvosViewModel( class ConvosViewModel(
updatesParser: LongPollUpdatesParser, updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter, val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
@@ -55,43 +56,22 @@ class ConvosViewModel(
private val applicationContext: Context, private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null) private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val navigation = _navigation.asStateFlow() val screenStateFlow get() = screenState.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null) private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val dialog = _dialog.asStateFlow() val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList()) private val convos: MutableList<VkConvo> = mutableListOf()
val convos = _convos.asStateFlow()
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList()) private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
val uiConvos = _uiConvos.asStateFlow()
private val pinnedConvosCount = convos.map { convos -> private var currentOffset = 0
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos() loadConvos()
updatesParser.onNewMessage(::handleNewMessage) updatesParser.onNewMessage(::handleNewMessage)
@@ -109,100 +89,143 @@ class ConvosViewModel(
} }
} }
fun onNavigationConsumed() { fun handleIntent(intent: ConvoIntent) {
_navigation.setValue { null } when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
} }
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) { fun onNavigationConsumed() {
onDialogDismissed(dialog) navigationIntent.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) { when (dialog) {
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
deleteConvo(dialog.convoId) deleteConvo(convo.id)
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
pinConvo(dialog.convoId, true) pinConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
pinConvo(dialog.convoId, false) pinConvo(convo.id, false)
} }
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
archiveConvo(dialog.convoId, true) archiveConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
archiveConvo(dialog.convoId, false) archiveConvo(convo.id, false)
} }
} }
expandedConvoId.setValue { 0 } collapseConvos(false)
syncUiConvos() syncUiConvos()
} }
fun onDialogDismissed(dialog: ConvoDialog) { private fun onDialogDismissed() {
_dialog.setValue { null } screenState.updateValue { copy(dialog = null) }
} }
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) { private fun onPaginationConditionsMet() {
when (dialog) { currentOffset = convos.size
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos() loadConvos()
} }
fun onRefresh() { private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConvos(offset = 0) loadConvos(offset = 0)
} }
fun onConvoItemClick(convo: UiConvo) { private fun onConvoItemClick(convoId: Long) {
collapseConvos() collapseConvos()
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) } navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
} }
fun onConvoItemLongClick(convo: UiConvo) { private fun onConvoItemLongClick(convoId: Long) {
expandedConvoId.setValue { val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
if (convo.isExpanded) 0
else convo.id screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
}
syncUiConvos() syncUiConvos()
} }
fun onOptionClicked( private fun onOptionClicked(option: ConvoOption) {
convo: UiConvo, val convo =
option: ConvoOption screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
) {
when (option) { when (option) {
ConvoOption.Delete -> { ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> { ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId -> val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo( readConvo(
peerId = convo.id, peerId = convo.id,
startMessageId = lastMessageId startMessageId = lastMessageId
@@ -211,48 +234,39 @@ class ConvosViewModel(
} }
} }
ConvoOption.Pin -> { ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) } ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
} ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
} }
} }
fun onErrorConsumed() { private fun setScrollIndex(index: Int) {
_baseError.setValue { null } screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
fun setScrollIndex(index: Int) { private fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
fun setScrollOffset(offset: Int) { private fun setDialog(dialog: ConvoDialog?) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.updateValue { copy(dialog = dialog) }
} }
fun onCreateChatButtonClicked() { private fun replaceConvos(newConvos: List<VkConvo>) {
_navigation.setValue { ConvoNavigation.CreateChat } convos.clear()
convos.addAll(newConvos)
} }
private fun collapseConvos() { private fun collapseConvos(sync: Boolean = true) {
expandedConvoId.setValue { 0 } screenState.updateValue { copy(expandedConvoId = null) }
syncUiConvos()
if (sync) {
syncUiConvos()
}
} }
private fun loadConvos( private fun loadConvos(offset: Int = currentOffset) {
offset: Int = currentOffset.value
) {
convoUseCase.getConvos( convoUseCase.getConvos(
count = LOAD_COUNT, count = LOAD_COUNT,
offset = offset, offset = offset,
@@ -260,23 +274,20 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
val newBaseError = VkUtils.parseError(error) screenState.updateValue { copy(error = VkUtils.parseError(error)) }
_baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val convos = response val newConvos = if (offset == 0) {
val fullConvos = if (offset == 0) { response
convos
} else { } else {
this.convos.value.plus(convos) convos.plus(response)
} }
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
this.convos.value.isNotEmpty()
_screenState.updateValue { screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
} }
@@ -293,13 +304,14 @@ class ConvosViewModel(
convoUseCase.storeConvos(response) convoUseCase.storeConvos(response)
_convos.emit(fullConvos) replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos() syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
} }
) )
_screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading() isPaginating = offset > 0 && state.isLoading()
@@ -313,17 +325,17 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId } newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState ?: return@processState
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
_convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
) )
_screenState.emit(screenState.value.copy(isLoading = state.isLoading())) screenState.emit(screenStateFlow.value.copy(isLoading = state.isLoading()))
} }
} }
@@ -337,7 +349,7 @@ class ConvosViewModel(
LongPollParsedEvent.ChatMajorChanged( LongPollParsedEvent.ChatMajorChanged(
peerId = peerId, peerId = peerId,
majorId = if (pin) { majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16 pinnedConvosCount.plus(1) * 16
} else { } else {
0 0
} }
@@ -346,7 +358,7 @@ class ConvosViewModel(
} }
) )
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
} }
} }
@@ -356,7 +368,7 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
convos.value.find { it.id == peerId }?.let { convo -> convos.find { it.id == peerId }?.let { convo ->
handleChatArchived( handleChatArchived(
LongPollParsedEvent.ChatArchived( LongPollParsedEvent.ChatArchived(
convo = convo, convo = convo,
@@ -373,7 +385,7 @@ class ConvosViewModel(
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId } newConvos.indexOfFirstOrNull { it.id == message.peerId }
@@ -391,8 +403,8 @@ class ConvosViewModel(
val convo = (response.firstOrNull() ?: return@listenValue) val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message) .copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo) newConvos.add(pinnedConvosCount, convo)
_convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
) )
@@ -428,19 +440,17 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvo newConvos[convoIndex] = newConvo
} else { } else {
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
newConvos.add(pinnedConvosCount, newConvo)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
} }
_convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message val message = event.message
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId } val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке if (convoIndex == null) { // диалога нет в списке
@@ -452,13 +462,14 @@ class ConvosViewModel(
lastMessageId = message.id, lastMessageId = message.id,
lastCmId = message.cmId lastCmId = message.cmId
) )
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) { private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -472,13 +483,13 @@ class ConvosViewModel(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) { private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -492,7 +503,7 @@ class ConvosViewModel(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
@@ -502,7 +513,7 @@ class ConvosViewModel(
val peerId = event.peerId val peerId = event.peerId
val userIds = event.userIds val userIds = event.userIds
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } newConvos.findWithIndex { it.id == peerId }
@@ -513,7 +524,7 @@ class ConvosViewModel(
interactionIds = userIds interactionIds = userIds
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -545,7 +556,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) { private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return interactionsTimers[peerId] ?: return
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return newConvos.findWithIndex { it.id == peerId } ?: return
@@ -555,7 +566,7 @@ class ConvosViewModel(
interactionIds = emptyList() interactionIds = emptyList()
) )
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -563,7 +574,7 @@ class ConvosViewModel(
} }
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -573,13 +584,13 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId) newConvos[convoIndex].copy(majorId = event.majorId)
_convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -589,22 +600,23 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId) newConvos[convoIndex].copy(minorId = event.minorId)
_convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId } val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке if (convoIndex == null) { // диалога нет в списке
// pizdets // pizdets
} else { } else {
// TODO: 30.05.2026, Danil Nikolaev: reimplement
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
_convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
@@ -612,7 +624,7 @@ class ConvosViewModel(
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) { private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo val convo = event.convo
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
when (filter) { when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit ConvosFilter.BUSINESS_NOTIFY -> Unit
@@ -627,7 +639,7 @@ class ConvosViewModel(
newConvos.removeAt(index) newConvos.removeAt(index)
} }
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
@@ -638,10 +650,10 @@ class ConvosViewModel(
newConvos.removeAt(index) newConvos.removeAt(index)
} else { } else {
newConvos.add(pinnedConvosCount.value, convo) newConvos.add(pinnedConvosCount, convo)
} }
_convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
@@ -655,7 +667,7 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convos.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId } newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue ?: return@listenValue
@@ -663,7 +675,7 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId) newConvos[convoIndex].copy(inRead = startMessageId)
_convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
) )
@@ -695,47 +707,44 @@ class ConvosViewModel(
} }
private fun syncUiConvos(): List<UiConvo> { private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo -> val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>() val options: ImmutableList<ConvoOption> = buildImmutableList {
convo.lastMessage?.run { if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
if (!convo.isRead() && !this.isOut) { add(ConvoOption.MarkAsRead)
options += ConvoOption.MarkAsRead
} }
if (convo.isPinned()) {
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
}
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
add(ConvoOption.Delete)
} }
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation( convo.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames, useContactName = userSettings.useContactNames.value,
isExpanded = expandedConvoId.value == convo.id, isExpanded = screenState.value.expandedConvoId == convo.id,
options = options.toImmutableList() options = options
) )
} }
_uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos return newUiConvos
} }
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed class ConvoDialog { sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog() data object Pin : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog() data object Unpin : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog() data object Delete : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog() data object Archive : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog() data object Unarchive : ConvoDialog()
} }
@@ -0,0 +1,29 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,6 +1,10 @@
package dev.meloda.fast.convos.model package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable @Immutable
data class ConvosScreenState( data class ConvosScreenState(
@@ -10,7 +14,13 @@ data class ConvosScreenState(
val profileImageUrl: String?, val profileImageUrl: String?,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val isArchive: Boolean val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) { ) {
companion object { companion object {
@@ -21,7 +31,11 @@ data class ConvosScreenState(
profileImageUrl = null, profileImageUrl = null,
scrollIndex = 0, scrollIndex = 0,
scrollOffset = 0, scrollOffset = 0,
isArchive = false canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
) )
} }
} }
@@ -1,12 +1,16 @@
package dev.meloda.fast.convos.navigation package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
@@ -24,44 +28,56 @@ object Convos
object Archive object Archive
fun NavGraphBuilder.convosGraph( fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity, activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) { ) {
navigation<ConvoGraph>( navigation<ConvoGraph>(
startDestination = Convos startDestination = Convos
) { ) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> { composable<Convos> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute( viewModel = with(activity) {
viewModel = convosViewModel, getViewModel(named(ConvosFilter.ALL))
onError = onError, }
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
) )
} }
composable<Archive> { composable<Archive> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute(
viewModel = with(activity) { viewModel = with(activity) {
getViewModel<ConvosViewModel>( getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
qualifier = named(ConvosFilter.ARCHIVE) }
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
) )
} }
} }
} }
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,82 +1,78 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
when (dialog) { when (screenState.dialog) {
null -> Unit null -> Unit
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_archive_convo), title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_archive), confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
) )
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_unarchive_convo), title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unarchive), confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
) )
} }
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
val errorColor = MaterialTheme.colorScheme.error val errorColor = MaterialTheme.colorScheme.error
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor, iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo), title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_delete), confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor, confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo), title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_pin), confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo), title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unpin), confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ConvoItem( fun ConvoItem(
onItemClick: (UiConvo) -> Unit, onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit, onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
maxLines: Int, maxLines: Int,
isUserAccount: Boolean, isUserAccount: Boolean,
convo: UiConvo, convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onItemClick(convo) }, onClick = { onItemClick(convo.id) },
onLongClick = { onLongClick = {
onItemLongClick(convo) onItemLongClick(convo.id)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
} }
) )
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder = val builder =
AnnotatedString.Builder(convo.message.text) AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange -> convo.message.spanStyles.forEach { spanStyleRange ->
val updatedSpanStyle = val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) { if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary) spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
} }
ElevatedAssistChip( ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) }, onClick = { onOptionClicked(option) },
leadingIcon = { leadingIcon = {
option.icon.getResourcePainter()?.let { painter -> option.icon.getResourcePainter()?.let { painter ->
Icon( Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList( fun ConvosList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>, convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit, onConvosClick: (Long) -> Unit,
onConvosLongClick: (UiConvo) -> Unit, onConvosLongClick: (Long) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
state: LazyListState, state: LazyListState,
maxLines: Int, maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
padding: PaddingValues padding: PaddingValues
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
@@ -1,79 +1,23 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import dev.meloda.fast.convos.model.ConvoIntent
import androidx.compose.runtime.getValue import dev.meloda.fast.convos.model.ConvosScreenState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun ConvosRoute( fun ConvosRoute(
viewModel: ConvosViewModel, handleIntent: (ConvoIntent) -> Unit,
onBack: (() -> Unit)? = null, screenState: ConvosScreenState,
onError: (BaseError) -> Unit, isArchive: Boolean,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen( ConvosScreen(
onBack = { onBack?.invoke() }, handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
convos = convos.toImmutableList(), isArchive = isArchive,
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
) )
HandleDialogs( HandleDialogs(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
} }
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -53,53 +52,39 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class,
) )
@Composable @Composable
fun ConvosScreen( fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY, handleIntent: (ConvoIntent) -> Unit,
convos: ImmutableList<UiConvo> = emptyImmutableList(), screenState: ConvosScreenState,
baseError: BaseError? = null, isArchive: Boolean,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1 val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -112,33 +97,33 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) { LaunchedEffect(currentTabReselected) {
if (currentTabReselected) { if (currentTabReselected) {
if (screenState.isArchive) { if (isArchive) {
onBack.invoke() handleIntent(ConvoIntent.Back)
} else { } else {
if (listState.firstVisibleItemIndex > 14) { if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14) listState.scrollToItem(14)
} }
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
onConsumeReselection() handleIntent(ConvoIntent.ConsumeScrollToTop)
} }
} }
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L) .debounce(500L.milliseconds)
.collectLatest(setScrollIndex) .collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) }
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L) .debounce(500L.milliseconds)
.collectLatest(setScrollOffset) .collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) }
} }
val paginationConditionMet by remember(canPaginate, listState) { val paginationConditionMet by remember(screenState.canPaginate, listState) {
derivedStateOf { derivedStateOf {
canPaginate && screenState.canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6) ?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
} }
@@ -146,7 +131,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) { LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) { if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet() handleIntent(ConvoIntent.PaginationConditionsMet)
} }
} }
@@ -181,7 +166,7 @@ fun ConvosScreen(
text = stringResource( text = stringResource(
id = when { id = when {
screenState.isLoading -> R.string.title_loading screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive isArchive -> R.string.title_archive
else -> R.string.title_convos else -> R.string.title_convos
} }
), ),
@@ -191,8 +176,8 @@ fun ConvosScreen(
) )
}, },
navigationIcon = { navigationIcon = {
if (screenState.isArchive) { if (isArchive) {
IconButton(onClick = onBack) { IconButton(onClick = { handleIntent(ConvoIntent.Back) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24), painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null contentDescription = null
@@ -201,54 +186,47 @@ fun ConvosScreen(
} }
}, },
actions = { actions = {
if (!screenState.isArchive) { val dropDownItems: List<@Composable () -> Unit> = buildList {}
IconButton(onClick = onArchiveActionClicked) {
Icon( val items = buildImmutableList {
painter = painterResource(R.drawable.ic_archive_round_24), if (!isArchive) {
contentDescription = null add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
) }
if (AppSettings.General.showManualRefreshOptions) {
add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24))
}
if (dropDownItems.isNotEmpty()) {
add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24))
} }
} }
val dropDownItems = mutableListOf<@Composable () -> Unit>()
if (AppSettings.General.showManualRefreshOptions) { SegmentedButtonsRow(
dropDownItems += { modifier = Modifier.padding(end = 8.dp),
DropdownMenuItem( items = items,
onClick = { onClick = { index ->
onRefresh() when (items[index].key) {
dropDownMenuExpanded = false "archive" -> handleIntent(ConvoIntent.ArchiveClick)
}, "refresh" -> handleIntent(ConvoIntent.Refresh)
text = { "more" -> dropDownMenuExpanded = true
Text(text = stringResource(id = R.string.action_refresh))
}, else -> Unit
leadingIcon = { }
Icon(
painter = painterResource(R.drawable.ic_refresh_round_24),
contentDescription = null
)
}
)
} }
} )
if (dropDownItems.isNotEmpty()) { if (dropDownItems.isNotEmpty()) {
IconButton(onClick = { dropDownMenuExpanded = true }) { DropdownMenu(
Icon( modifier = Modifier.defaultMinSize(minWidth = 140.dp),
painter = painterResource(R.drawable.ic_more_vert_round_24), expanded = dropDownMenuExpanded,
contentDescription = null onDismissRequest = { dropDownMenuExpanded = false },
) offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
dropDownItems.forEach { it.invoke() }
} }
} }
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = { dropDownMenuExpanded = false },
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
dropDownItems.forEach { it.invoke() }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy( containerColor = toolbarContainerColor.copy(
@@ -268,7 +246,7 @@ fun ConvosScreen(
) )
val showHorizontalProgressBar by remember(screenState) { val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && convos.isNotEmpty() } derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() }
} }
AnimatedVisibility(showHorizontalProgressBar) { AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -279,14 +257,14 @@ fun ConvosScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (!screenState.isArchive) { if (!isArchive) {
val offsetY by animateIntAsState( val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600 targetValue = if (listState.isScrollingUp()) 0 else 600
) )
Column { Column {
FloatingActionButton( FloatingActionButton(
onClick = onCreateChatButtonClicked, onClick = { handleIntent(ConvoIntent.CreateChatClick) },
modifier = Modifier.offset { modifier = Modifier.offset {
IntOffset(0, offsetY) IntOffset(0, offsetY)
} }
@@ -303,14 +281,15 @@ fun ConvosScreen(
} }
) { padding -> ) { padding ->
when { when {
baseError != null -> { // TODO: 30.05.2026, Danil Nikolaev: move to UI State
VkErrorView( // baseError != null -> {
baseError = baseError, // VkErrorView(
onButtonClick = onErrorViewButtonClicked // baseError = baseError,
) // onButtonClick = onErrorViewButtonClicked
} // )
// }
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader() screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -323,7 +302,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = screenState.isLoading, isRefreshing = screenState.isLoading,
onRefresh = onRefresh, onRefresh = { handleIntent(ConvoIntent.Refresh) },
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = pullToRefreshState, state = pullToRefreshState,
@@ -335,9 +314,9 @@ fun ConvosScreen(
} }
) { ) {
ConvosList( ConvosList(
convos = convos, convos = screenState.convos,
onConvosClick = onConvoItemClicked, onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) },
onConvosLongClick = onConvoItemLongClicked, onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) },
screenState = screenState, screenState = screenState,
state = listState, state = listState,
maxLines = maxLines, maxLines = maxLines,
@@ -346,14 +325,14 @@ fun ConvosScreen(
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
onOptionClicked = onOptionClicked, onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) },
padding = padding padding = padding
) )
if (convos.isEmpty()) { if (screenState.convos.isEmpty()) {
NoItemsView( NoItemsView(
buttonText = stringResource(R.string.action_refresh), buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh onButtonClick = { handleIntent(ConvoIntent.Refresh) }
) )
} }
} }
@@ -38,6 +38,7 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -47,7 +48,6 @@ fun FriendsScreen(
orderType: String, orderType: String,
padding: PaddingValues, padding: PaddingValues,
tabIndex: Int, tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {}, setCanScrollBackward: (Boolean) -> Unit = {},
@@ -100,13 +100,13 @@ fun FriendsScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(250L) .debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollIndex) .collectLatest(viewModel::setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(250L) .debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollOffset) .collectLatest(viewModel::setScrollOffset)
} }
@@ -9,12 +9,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -34,7 +33,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -47,11 +45,14 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.SelectionType import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -189,16 +190,24 @@ fun FriendsRoute(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
actions = { actions = {
IconButton( val items = buildImmutableList {
onClick = { add(
showOrderDialog = true SegmentedButtonItem(
} "filter",
) { R.drawable.ic_filter_list_round_24
Icon( )
painter = painterResource(R.drawable.ic_filter_list_round_24),
contentDescription = null
) )
} }
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"filter" -> showOrderDialog = true
}
}
)
} }
) )
PrimaryTabRow( PrimaryTabRow(
@@ -234,7 +243,6 @@ fun FriendsRoute(
orderType = orderType, orderType = orderType,
padding = padding, padding = padding,
tabIndex = index, tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it }, setCanScrollBackward = { canScrollBackward = it },
@@ -6,7 +6,6 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -685,8 +684,6 @@ class MessagesHistoryViewModelImpl(
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.convoId) return if (message.peerId != screenState.value.convoId) return
if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return if (messages.value.indexOfFirstOrNull { it.id == message.id } != null) return
@@ -835,8 +832,6 @@ class MessagesHistoryViewModelImpl(
} }
private fun loadConvo() { private fun loadConvo() {
Log.d("MessagesHistoryViewModelImpl", "loadConvo()")
loadConvosByIdUseCase( loadConvosByIdUseCase(
peerIds = listOf(screenState.value.convoId), peerIds = listOf(screenState.value.convoId),
extended = true, extended = true,
@@ -904,8 +899,6 @@ class MessagesHistoryViewModelImpl(
} }
private fun loadMessagesHistory(offset: Int = currentOffset.value) { private fun loadMessagesHistory(offset: Int = currentOffset.value) {
Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset")
messagesUseCase.getMessagesHistory( messagesUseCase.getMessagesHistory(
convoId = screenState.value.convoId, convoId = screenState.value.convoId,
count = MESSAGES_LOAD_COUNT, count = MESSAGES_LOAD_COUNT,
@@ -1037,6 +1030,7 @@ class MessagesHistoryViewModelImpl(
isSpam = false, isSpam = false,
pinnedAt = null, pinnedAt = null,
formatData = formatData, formatData = formatData,
isDeleted = false
) )
formatData = formatData.copy(items = emptyList()) formatData = formatData.copy(items = emptyList())
sendingMessages += newMessage sendingMessages += newMessage
@@ -1078,8 +1072,6 @@ class MessagesHistoryViewModelImpl(
state.processState( state.processState(
any = { sendingMessages.remove(newMessage) }, any = { sendingMessages.remove(newMessage) },
error = { error -> error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId) val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage failedMessages += newFailedMessage
@@ -1143,8 +1135,6 @@ class MessagesHistoryViewModelImpl(
) ?: return ) ?: return
// TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit // TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
} }
private fun markAsImportant( private fun markAsImportant(
@@ -1,7 +1,6 @@
package dev.meloda.fast.messageshistory.presentation package dev.meloda.fast.messageshistory.presentation
import android.content.Intent import android.content.Intent
import android.util.Log
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@@ -129,7 +128,6 @@ fun MessagesList(
when (attachment) { when (attachment) {
is VkPhotoDomain -> { is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize() val maxSize = attachment.getMaxSize()
Log.d("MessagesList", "onPhotoLongClicked. Max size: ${maxSize?.url}")
} }
} }
} }
@@ -119,12 +119,15 @@ fun Attachments(
AttachmentType.STICKER -> { AttachmentType.STICKER -> {
Sticker( Sticker(
item = attachment as VkStickerDomain url = (attachment as VkStickerDomain).getUrl(
width = 256,
withBackground = false
)
) )
} }
AttachmentType.GIFT -> { AttachmentType.GIFT -> {
Gift(item = attachment as VkGiftDomain) Gift(url = (attachment as VkGiftDomain).getDefaultThumbSizeOrLess())
} }
AttachmentType.VIDEO_MESSAGE -> { AttachmentType.VIDEO_MESSAGE -> {
@@ -21,25 +21,24 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkGiftDomain
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun Gift( fun Gift(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkGiftDomain url: String
) { ) {
Column( Column(
modifier = modifier.width(192.dp), modifier = modifier
.width(208.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AsyncImage( AsyncImage(
model = item.getDefaultThumbSizeOrLess(), model = url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.size(192.dp)
.padding(8.dp)
.fillMaxWidth()
) )
Row( Row(
@@ -64,7 +64,6 @@ fun DynamicPreviewGrid(
val spacingPx = with(LocalDensity.current) { spacing.toPx() } val spacingPx = with(LocalDensity.current) { spacing.toPx() }
val rows = previews.chunked(3) val rows = previews.chunked(3)
Log.d("ROWS", "DynamicPreviewGrid: ${rows.size}")
Column(verticalArrangement = Arrangement.spacedBy(spacing)) { Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { outerIndex, row -> rows.forEachIndexed { outerIndex, row ->
@@ -10,27 +10,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkStickerDomain
@Composable @Composable
fun Sticker( fun Sticker(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkStickerDomain url: String?
) { ) {
Box( Box(
modifier = modifier.size(192.dp), modifier = modifier
.size(208.dp)
.padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncImage( AsyncImage(
model = item.getUrl( model = url,
width = 256,
withBackground = false
),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize()
.padding(8.dp)
.fillMaxSize()
) )
} }
} }
@@ -5,49 +5,33 @@ import androidx.lifecycle.viewModelScope
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.GetLocalUserByIdUseCase import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.profile.model.ProfileScreenState import dev.meloda.fast.profile.model.ProfileScreenState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface ProfileViewModel { class ProfileViewModel(
val screenState: StateFlow<ProfileScreenState>
val baseError: StateFlow<BaseError?>
}
class ProfileViewModelImpl(
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase, private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase private val loadUserByIdUseCase: LoadUserByIdUseCase
) : ViewModel(), ProfileViewModel { ) : ViewModel() {
override val screenState = MutableStateFlow(ProfileScreenState.EMPTY) private val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
init { init {
getLocalAccountInfo() getLocalAccountInfo()
} }
fun screenStateFlow(): StateFlow<ProfileScreenState> = screenState.asStateFlow()
private fun getLocalAccountInfo() { private fun getLocalAccountInfo() {
getLocalUserByIdUseCase(UserConfig.userId) getLocalUserByIdUseCase(UserConfig.userId)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = {
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
avatarUrl = null, avatarUrl = null,
@@ -1,9 +1,9 @@
package dev.meloda.fast.profile.di package dev.meloda.fast.profile.di
import dev.meloda.fast.profile.ProfileViewModelImpl import dev.meloda.fast.profile.ProfileViewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
val profileModule = module { val profileModule = module {
viewModelOf(::ProfileViewModelImpl) viewModelOf(::ProfileViewModel)
} }
@@ -1,11 +1,11 @@
package dev.meloda.fast.profile.navigation package dev.meloda.fast.profile.navigation
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.presentation.ProfileRoute import dev.meloda.fast.profile.presentation.ProfileRoute
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.androidx.viewmodel.ext.android.getViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel
@@ -15,19 +15,18 @@ object Profile
fun NavGraphBuilder.profileScreen( fun NavGraphBuilder.profileScreen(
activity: AppCompatActivity, activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit
) { ) {
val viewModel: ProfileViewModel = with(activity) { val viewModel: ProfileViewModel = with(activity) { getViewModel() }
getViewModel<ProfileViewModelImpl>()
}
composable<Profile> { composable<Profile> {
val screenState by viewModel.screenStateFlow().collectAsStateWithLifecycle()
ProfileRoute( ProfileRoute(
onError = onError, screenState = screenState,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
viewModel = viewModel
) )
} }
} }
@@ -14,15 +14,12 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -31,28 +28,21 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.model.ProfileScreenState import dev.meloda.fast.profile.model.ProfileScreenState
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import org.koin.androidx.compose.koinViewModel import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.util.buildImmutableList
@Composable @Composable
fun ProfileRoute( fun ProfileRoute(
onError: (BaseError) -> Unit, screenState: ProfileScreenState,
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
ProfileScreen( ProfileScreen(
screenState = screenState, screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
) )
@@ -63,7 +53,6 @@ fun ProfileRoute(
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY, screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {}, onSettingsButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {} onPhotoClicked: (url: String) -> Unit = {}
) { ) {
@@ -72,12 +61,19 @@ fun ProfileScreen(
TopAppBar( TopAppBar(
title = {}, title = {},
actions = { actions = {
IconButton(onClick = onSettingsButtonClicked) { val items = buildImmutableList {
Icon( add(SegmentedButtonItem("settings", R.drawable.ic_settings_round_24))
painter = painterResource(R.drawable.ic_settings_round_24),
contentDescription = null
)
} }
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"settings" -> onSettingsButtonClicked()
}
}
)
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
) )
@@ -12,7 +12,7 @@ import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel import dev.meloda.fast.common.model.NetworkLogLevel
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
@@ -497,10 +497,10 @@ class SettingsViewModel(
) )
val logLevelValues = listOf( val logLevelValues = listOf(
LogLevel.NONE to UiText.Simple("None"), NetworkLogLevel.NONE to UiText.Simple("None"),
LogLevel.BASIC to UiText.Simple("Basic"), NetworkLogLevel.BASIC to UiText.Simple("Basic"),
LogLevel.HEADERS to UiText.Simple("Headers"), NetworkLogLevel.HEADERS to UiText.Simple("Headers"),
LogLevel.BODY to UiText.Simple("Body") NetworkLogLevel.BODY to UiText.Simple("Body")
).toMap() ).toMap()
val debugNetworkLogLevel = SettingsItem.ListItem( val debugNetworkLogLevel = SettingsItem.ListItem(
@@ -509,10 +509,10 @@ class SettingsViewModel(
valueClass = Int::class, valueClass = Int::class,
defaultValue = SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL, defaultValue = SettingsKeys.DEFAULT_NETWORK_LOG_LEVEL,
titles = logLevelValues.values.toList(), titles = logLevelValues.values.toList(),
values = logLevelValues.keys.toList().map(LogLevel::value) values = logLevelValues.keys.toList().map(NetworkLogLevel::value)
).apply { ).apply {
textProvider = TextProvider { item -> textProvider = TextProvider { item ->
val textValue = logLevelValues[LogLevel.parse(item.value)].parseString(resources) val textValue = logLevelValues[NetworkLogLevel.parse(item.value)].parseString(resources)
UiText.Simple("Current value: $textValue") UiText.Simple("Current value: $textValue")
} }
+1 -5
View File
@@ -14,7 +14,7 @@ kotlin = "2.3.21"
ksp = "2.3.7" ksp = "2.3.7"
moduleGraph = "2.9.1" moduleGraph = "2.9.1"
versions = "0.54.0" versions = "0.54.0"
stability-analyzer = "0.7.4" stability-analyzer = "0.7.5"
compose-bom = "2026.04.01" compose-bom = "2026.04.01"
koin = "4.2.1" koin = "4.2.1"
@@ -36,13 +36,9 @@ nanokt = "1.3.0"
androidx-navigation = "2.9.8" androidx-navigation = "2.9.8"
serialization = "1.11.0" serialization = "1.11.0"
acra = "5.13.1"
okhttp = "5.3.2" okhttp = "5.3.2"
[libraries] [libraries]
acra-email = { module = "ch.acra:acra-mail", version.ref = "acra" }
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
Binary file not shown.
+2
View File
@@ -2,6 +2,8 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored Regular → Executable
+5 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -55,3 +55,4 @@ include(":feature:friends")
include(":feature:profile") include(":feature:profile")
include(":feature:createchat") include(":feature:createchat")
include(":core:presentation") include(":core:presentation")
include(":core:logger")