1 Commits

34 changed files with 435 additions and 805 deletions
-1
View File
@@ -3,7 +3,6 @@
xmlns:tools="http://schemas.android.com/tools"> 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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@@ -22,7 +22,6 @@ import dev.meloda.fast.languagepicker.di.languagePickerModule
import dev.meloda.fast.logger.loggerModule 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.presentation.NetworkObserver
import dev.meloda.fast.profile.di.profileModule import dev.meloda.fast.profile.di.profileModule
import dev.meloda.fast.provider.ApiLanguageProvider import dev.meloda.fast.provider.ApiLanguageProvider
import dev.meloda.fast.service.longpolling.di.longPollModule import dev.meloda.fast.service.longpolling.di.longPollModule
@@ -73,6 +72,4 @@ val applicationModule = module {
singleOf(::LongPollControllerImpl) bind LongPollController::class singleOf(::LongPollControllerImpl) bind LongPollController::class
singleOf(::ResourceProviderImpl) bind ResourceProvider::class singleOf(::ResourceProviderImpl) bind ResourceProvider::class
singleOf(::NetworkObserver)
} }
@@ -23,13 +23,11 @@ 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.domain.LongPollEventsHandler
import dev.meloda.fast.logger.FastLogger 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 dev.meloda.fast.ui.common.LocalLogger
import org.koin.android.ext.android.get
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -183,8 +181,6 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
stopServices() stopServices()
get<LongPollEventsHandler>().onDestroy()
get<NetworkObserver>().onDestroy()
} }
companion object { companion object {
@@ -1,274 +0,0 @@
package dev.meloda.fast.presentation
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dev.meloda.fast.logger.FastLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
class NetworkObserver(
context: Context,
private val logger: FastLogger
) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val networkStatus = MutableStateFlow(NetworkStatus.UNAVAILABLE)
val networkStatusFlow = networkStatus.asStateFlow()
private val networkState = MutableStateFlow(NetworkState.DISCONNECTED)
val networkStateFlow = networkState.asStateFlow()
private val networks = ConcurrentHashMap<Network, NetworkModel>()
private var clearCallbacks: (() -> Unit)? = null
init {
startListener()
}
private fun syncNetworkState() {
val state = if (networks.values.any { it.isInternetAvailable() }) {
NetworkState.CONNECTED
} else {
NetworkState.DISCONNECTED
}
networkState.value = state
networkStatus.value = when (state) {
NetworkState.CONNECTED -> NetworkStatus.AVAILABLE
NetworkState.DISCONNECTED -> NetworkStatus.UNAVAILABLE
}
log("STATE: $state")
}
private fun startListener() {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
log("onAvailable(): network: $network")
networks[network] = mapNetworkModel(
network = network,
capabilities = connectivityManager.getNetworkCapabilities(network),
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onUnavailable() {
log("onUnavailable()")
networks.clear()
syncNetworkState()
}
override fun onLost(network: Network) {
log("onLost() network: $network")
networks.remove(network)
syncNetworkState()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
log("onCapabilitiesChanged(): network: $network; caps: $networkCapabilities")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
capabilities = networkCapabilities,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onBlockedStatusChanged(
network: Network,
blocked: Boolean
) {
log("onBlockedStatusChanged(): network: $network; blocked: $blocked")
networks[network] = mapNetworkModel(
network = network,
from = networks[network],
status = if (blocked) NetworkStatus.BLOCKED else NetworkStatus.UNBLOCKED
)
syncNetworkState()
}
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: LinkProperties
) {
log("onLinkPropertiesChanged(): network: $network; props: $linkProperties")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
properties = linkProperties,
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
log("onLosing(): network: $network; maxMsToLive: $maxMsToLive")
val current = networks[network]
networks[network] = mapNetworkModel(
network = network,
maxMsToLive = maxMsToLive.toLong(),
from = current,
status = current?.status ?: NetworkStatus.AVAILABLE
)
syncNetworkState()
}
override fun onReserved(networkCapabilities: NetworkCapabilities) {
log("onReserved(): caps: $networkCapabilities")
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
clearCallbacks = { connectivityManager.unregisterNetworkCallback(callback) }
refreshActiveNetwork()
}
private fun refreshActiveNetwork() {
val network = connectivityManager.activeNetwork
if (network == null) {
networks.clear()
syncNetworkState()
return
}
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) {
networks[network] = mapNetworkModel(
network = network,
capabilities = capabilities,
properties = connectivityManager.getLinkProperties(network),
status = NetworkStatus.AVAILABLE
)
}
syncNetworkState()
}
private fun log(text: String) {
logger.debug(this::class, text)
}
private fun mapNetworkModel(
network: Network,
capabilities: NetworkCapabilities? = null,
properties: LinkProperties? = null,
status: NetworkStatus? = null,
maxMsToLive: Long? = null,
from: NetworkModel? = null
): NetworkModel {
val caps = capabilities
?: from?.networkCapabilities
?: connectivityManager.getNetworkCapabilities(network)
val networkType = when {
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> NetworkType.CELLULAR
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> NetworkType.WIFI
else -> from?.type ?: NetworkType.UNKNOWN
}
val hasInternet = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
?: from?.hasInternet
?: false
val signalStrength =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
caps?.signalStrength
} else {
null
} ?: from?.signalStrength ?: Int.MAX_VALUE
return NetworkModel(
id = network.hashCode(),
type = networkType,
original = network,
hasInternet = hasInternet,
signalStrength = signalStrength,
status = status ?: from?.status ?: NetworkStatus.UNAVAILABLE,
maxMsToLive = maxMsToLive ?: from?.maxMsToLive,
networkCapabilities = caps,
linkProperties = properties
?: from?.linkProperties
?: connectivityManager.getLinkProperties(network)
)
}
fun onDestroy() {
clearCallbacks?.let { unregisterCallback ->
runCatching { unregisterCallback() }
.onFailure { throwable ->
logger.error(
this::class.java,
"Failed to unregister network callback",
throwable
)
}
}
clearCallbacks = null
networks.clear()
syncNetworkState()
}
}
enum class NetworkType {
CELLULAR, WIFI, UNKNOWN
}
data class NetworkModel(
val id: Int,
val type: NetworkType,
val original: Network,
val hasInternet: Boolean,
val signalStrength: Int,
val status: NetworkStatus,
val maxMsToLive: Long?,
val networkCapabilities: NetworkCapabilities?,
val linkProperties: LinkProperties?
) {
fun isStatusOk(): Boolean = status.isOk()
fun isInternetAvailable(): Boolean = hasInternet && isStatusOk()
}
enum class NetworkStatus {
AVAILABLE, UNAVAILABLE, LOST, BLOCKED, UNBLOCKED;
fun isOk(): Boolean = when (this) {
AVAILABLE, UNBLOCKED -> true
UNAVAILABLE, LOST, BLOCKED -> false
}
}
enum class NetworkState { CONNECTED, DISCONNECTED }
@@ -59,7 +59,6 @@ import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.model.SettingsNavigationIntent
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
@@ -363,31 +362,18 @@ fun RootScreen(
) )
settingsScreen( settingsScreen(
handleNavigationIntent = { intent -> onBack = navController::navigateUp,
when (intent) { onLogOutButtonClicked = { navController.navigateToAuth(true) },
SettingsNavigationIntent.Back -> navController.navigateUp() onLanguageItemClicked = navController::navigateToLanguagePicker,
SettingsNavigationIntent.Language -> navController.navigateToLanguagePicker() onRestartRequired = {
SettingsNavigationIntent.Restart -> {
activity?.let { activity?.let {
val intent = val intent = Intent(activity, MainActivity::class.java)
Intent(activity, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
activity.finish()
activity.startActivity(intent) activity.startActivity(intent)
} activity.finish()
}
SettingsNavigationIntent.LogOut -> {
navController.navigateToAuth(true)
}
} }
} }
) )
languagePickerScreen(onBack = navController::navigateUp) languagePickerScreen(onBack = navController::navigateUp)
} }
@@ -2,13 +2,10 @@ package dev.meloda.fast.common.extensions
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@@ -16,7 +13,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -44,16 +40,6 @@ fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
return removed return removed
} }
context(viewModel: ViewModel)
fun <T> Flow<T>.listenValue(action: suspend (T) -> Unit): Job =
listenValue(viewModel.viewModelScope, action)
context(viewModel: ViewModel)
fun <T> MutableSharedFlow<T>.emitOnMain(value: T) {
val flow = this
viewModel.viewModelScope.launch { flow.emit(value) }
}
fun <T> Flow<T>.listenValue( fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
action: suspend (T) -> Unit action: suspend (T) -> Unit
@@ -35,7 +35,7 @@ object VkMemoryCache {
} }
fun appendContacts(contacts: List<VkContactDomain>) { fun appendContacts(contacts: List<VkContactDomain>) {
contacts.forEach { contact -> VkMemoryCache[contact.userId] = contact } contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
} }
operator fun set(userid: Long, user: VkUser) { operator fun set(userid: Long, user: VkUser) {
@@ -129,10 +129,6 @@ class ConvosRepositoryImpl(
val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain) val groupsList = response.groups.orEmpty().map(VkGroupData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain) val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
val usersMap = VkUsersMap.forUsers(profilesList) val usersMap = VkUsersMap.forUsers(profilesList)
val groupsMap = VkGroupsMap.forGroups(groupsList) val groupsMap = VkGroupsMap.forGroups(groupsList)
@@ -151,6 +147,10 @@ class ConvosRepositoryImpl(
groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity)) groupDao.insertAll(groupsList.map(VkGroupDomain::asEntity))
} }
VkMemoryCache.appendUsers(profilesList)
VkMemoryCache.appendGroups(groupsList)
VkMemoryCache.appendContacts(contactsList)
convos convos
}, },
errorMapper = { error -> errorMapper = { error ->
@@ -1,12 +1,9 @@
package dev.meloda.fast.data.api.friends package dev.meloda.fast.data.api.friends
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.database.dao.UserDao import dev.meloda.fast.database.dao.UserDao
import dev.meloda.fast.model.FriendsInfo import dev.meloda.fast.model.FriendsInfo
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
@@ -16,6 +13,8 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.friends.FriendsService import dev.meloda.fast.network.service.friends.FriendsService
import com.slack.eithernet.ApiResult
import com.slack.eithernet.successOrElse
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -52,18 +51,14 @@ class FriendsRepositoryImpl(
order = order, order = order,
count = count, count = count,
offset = offset, offset = offset,
fields = VkConstants.USER_FIELDS, fields = VkConstants.USER_FIELDS
extended = true
) )
service.getFriends(requestModel.map).mapApiResult( service.getFriends(requestModel.map).mapApiResult(
successMapper = { apiResponse -> successMapper = { apiResponse ->
val response = apiResponse.requireResponse() val response = apiResponse.requireResponse()
val users = response.items.map(VkUserData::mapToDomain) val users = response.items.map(VkUserData::mapToDomain)
val contactsList = response.contacts.orEmpty().map(VkContactData::mapToDomain)
VkMemoryCache.appendUsers(users) VkMemoryCache.appendUsers(users)
VkMemoryCache.appendContacts(contactsList)
users users
}, },
@@ -3,20 +3,13 @@ package dev.meloda.fast.domain
import dev.meloda.fast.database.dao.ConvoDao import dev.meloda.fast.database.dao.ConvoDao
import dev.meloda.fast.database.dao.MessageDao import dev.meloda.fast.database.dao.MessageDao
import dev.meloda.fast.logger.FastLogger import dev.meloda.fast.logger.FastLogger
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
typealias EventListener = (event: LongPollParsedEvent) -> Unit
typealias EventListenerMap = MutableMap<LongPollEvent, MutableList<EventListener>>
class LongPollEventsHandler( class LongPollEventsHandler(
private val logger: FastLogger, private val logger: FastLogger,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
@@ -36,16 +29,9 @@ class LongPollEventsHandler(
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: EventListenerMap = mutableMapOf() suspend fun handleEvents(events: List<LongPollParsedEvent>) {
fun handleEvents(events: List<LongPollParsedEvent>) {
coroutineScope.launch {
// TODO: 30.05.2026, Danil Nikolaev: switch to interactors or something else
withContext(Dispatchers.IO) {
events.forEach { handleNextEvent(it) } events.forEach { handleNextEvent(it) }
} }
}
}
private suspend fun handleNextEvent(event: LongPollParsedEvent) { private suspend fun handleNextEvent(event: LongPollParsedEvent) {
when (event) { when (event) {
@@ -102,21 +88,11 @@ class LongPollEventsHandler(
} }
is LongPollParsedEvent.Interaction -> { is LongPollParsedEvent.Interaction -> {
val eventType = when (event.interactionType) {
InteractionType.Typing -> LongPollEvent.TYPING
InteractionType.VoiceMessage -> LongPollEvent.AUDIO_MESSAGE_RECORDING
InteractionType.Photo -> LongPollEvent.PHOTO_UPLOADING
InteractionType.Video -> LongPollEvent.VIDEO_UPLOADING
InteractionType.File -> LongPollEvent.FILE_UPLOADING
}
emitEvent(eventType, event)
} }
is LongPollParsedEvent.MessageCacheClear -> { is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_CACHE_CLEAR, event)
} }
is LongPollParsedEvent.MessageDeleted -> { is LongPollParsedEvent.MessageDeleted -> {
@@ -130,14 +106,10 @@ class LongPollEventsHandler(
this::class, this::class,
"markDeleted: updated $affectedRows rows." "markDeleted: updated $affectedRows rows."
) )
emitEvent(LongPollEvent.MESSAGE_DELETED, event)
} }
is LongPollParsedEvent.MessageEdited -> { is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_EDITED, event)
} }
is LongPollParsedEvent.MessageMarkedAsImportant -> { is LongPollParsedEvent.MessageMarkedAsImportant -> {
@@ -151,14 +123,10 @@ class LongPollEventsHandler(
this::class, this::class,
"markImportant: updated $affectedRows rows." "markImportant: updated $affectedRows rows."
) )
emitEvent(LongPollEvent.MARKED_AS_IMPORTANT, event)
} }
is LongPollParsedEvent.MessageMarkedAsNotSpam -> { is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MARKED_AS_NOT_SPAM, event)
} }
is LongPollParsedEvent.MessageMarkedAsSpam -> { is LongPollParsedEvent.MessageMarkedAsSpam -> {
@@ -172,26 +140,18 @@ class LongPollEventsHandler(
this::class, this::class,
"markSpam: updated $affectedRows rows." "markSpam: updated $affectedRows rows."
) )
emitEvent(LongPollEvent.MARKED_AS_SPAM, event)
} }
is LongPollParsedEvent.MessageRestored -> { is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_RESTORED, event)
} }
is LongPollParsedEvent.MessageUpdated -> { is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_UPDATED, event)
} }
is LongPollParsedEvent.MessageNew -> { is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessage(event.message) messagesUseCase.storeMessage(event.message)
emitEvent(LongPollEvent.MESSAGE_NEW, event)
} }
is LongPollParsedEvent.IncomingMessageRead -> { is LongPollParsedEvent.IncomingMessageRead -> {
@@ -205,8 +165,6 @@ class LongPollEventsHandler(
this::class, this::class,
"inMessageRead: updated $affectedRows rows." "inMessageRead: updated $affectedRows rows."
) )
emitEvent(LongPollEvent.INCOMING_MESSAGE_READ, event)
} }
is LongPollParsedEvent.OutgoingMessageRead -> { is LongPollParsedEvent.OutgoingMessageRead -> {
@@ -220,113 +178,11 @@ class LongPollEventsHandler(
this::class, this::class,
"outMessageRead: updated $affectedRows rows." "outMessageRead: updated $affectedRows rows."
) )
emitEvent(LongPollEvent.OUTGOING_MESSAGE_READ, event)
} }
is LongPollParsedEvent.UnreadCounter -> { is LongPollParsedEvent.UnreadCounter -> {
emitEvent(LongPollEvent.UNREAD_COUNTER_UPDATE, event)
}
}
}
private fun <T : LongPollParsedEvent> emitEvent(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { it(event) }
} }
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: (T) -> Unit
) {
if (listenersMap[eventType] == null) {
listenersMap[eventType] = mutableListOf()
} }
@Suppress("UNCHECKED_CAST")
listenersMap[eventType]?.add(listener as EventListener)
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: (T) -> Unit
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, block)
}
fun onMessageMarkAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, block)
}
fun onMessageMarkAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, block)
}
fun onMessageDelete(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, block)
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, block)
}
fun onMessageMarkAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, block)
}
fun onMessageRestore(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, block)
}
fun onMessageNew(block: (LongPollParsedEvent.MessageNew) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, block)
}
fun onMessageEdit(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, block)
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, block)
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, block)
}
fun onChatClear(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, block)
}
fun onChatMajorChange(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, block)
}
fun onChatMinorChange(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, block)
}
fun onChatArchive(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, block)
}
fun onInteraction(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = block
)
}
fun onDestroy() {
listenersMap.clear()
} }
} }
@@ -11,6 +11,7 @@ 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
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
@@ -42,6 +43,9 @@ class LongPollUpdatesParser(
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> { suspend fun parseNextUpdate(event: List<Any>): List<LongPollParsedEvent> {
val eventId = event.first().asInt() val eventId = event.first().asInt()
@@ -97,6 +101,9 @@ class LongPollUpdatesParser(
marked = true marked = true
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
?.forEach { it.onEvent(eventToSend) }
} }
MessageFlags.SPAM -> { MessageFlags.SPAM -> {
@@ -105,6 +112,7 @@ class LongPollUpdatesParser(
cmId = cmId cmId = cmId
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.forEach { it.onEvent(eventToSend) }
} }
MessageFlags.DELETED -> { MessageFlags.DELETED -> {
@@ -123,6 +131,7 @@ class LongPollUpdatesParser(
) )
} }
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.forEach { it.onEvent(eventToSend) }
} }
MessageFlags.AUDIO_LISTENED -> { MessageFlags.AUDIO_LISTENED -> {
@@ -131,6 +140,9 @@ class LongPollUpdatesParser(
cmId = cmId cmId = cmId
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]
?.forEach { it.onEvent(eventToSend) }
} }
MessageFlags.UNREAD -> Unit MessageFlags.UNREAD -> Unit
@@ -144,6 +156,14 @@ class LongPollUpdatesParser(
} }
} }
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.forEach { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
return eventsToSend return eventsToSend
} }
@@ -173,6 +193,9 @@ class LongPollUpdatesParser(
marked = false marked = false
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]
?.forEach { it.onEvent(eventToSend) }
} }
MessageFlags.SPAM -> { MessageFlags.SPAM -> {
@@ -181,6 +204,9 @@ class LongPollUpdatesParser(
val eventToSend = val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message) LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]
?.forEach { it.onEvent(eventToSend) }
} }
} }
} }
@@ -190,6 +216,9 @@ class LongPollUpdatesParser(
val eventToSend = val eventToSend =
LongPollParsedEvent.MessageRestored(message = message) LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]
?.forEach { it.onEvent(eventToSend) }
} }
} }
@@ -205,6 +234,10 @@ class LongPollUpdatesParser(
} }
} }
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend) continuation.resume(eventsToSend)
} }
} }
@@ -231,7 +264,7 @@ class LongPollUpdatesParser(
}.await() }.await()
if (message != null) { if (message != null) {
val event = LongPollParsedEvent.MessageNew( val event = LongPollParsedEvent.NewMessage(
message = message, message = message,
inArchive = convo?.isArchived == true inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev: // TODO: 03-Apr-25, Danil Nikolaev:
@@ -239,6 +272,7 @@ class LongPollUpdatesParser(
// enabled notifications from archive // enabled notifications from archive
) )
listenersMap[LongPollEvent.MESSAGE_NEW]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event)) continuation.resume(listOf(event))
} else { } else {
continuation.resume(emptyList()) continuation.resume(emptyList())
@@ -259,6 +293,7 @@ class LongPollUpdatesParser(
val message = loadMessage(peerId = peerId, cmId = cmId) val message = loadMessage(peerId = peerId, cmId = cmId)
if (message != null) { if (message != null) {
val event = LongPollParsedEvent.MessageEdited(message) val event = LongPollParsedEvent.MessageEdited(message)
listenersMap[LongPollEvent.MESSAGE_EDITED]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event)) continuation.resume(listOf(event))
} else { } else {
continuation.resume(emptyList()) continuation.resume(emptyList())
@@ -281,6 +316,7 @@ class LongPollUpdatesParser(
cmId = cmId, cmId = cmId,
unreadCount = unreadCount unreadCount = unreadCount
) )
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -300,6 +336,7 @@ class LongPollUpdatesParser(
unreadCount = unreadCount unreadCount = unreadCount
) )
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -336,6 +373,8 @@ class LongPollUpdatesParser(
archived = false archived = false
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
} }
ConvoFlags.DISABLE_PUSH -> Unit ConvoFlags.DISABLE_PUSH -> Unit
@@ -353,6 +392,10 @@ class LongPollUpdatesParser(
} }
} }
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend) continuation.resume(eventsToSend)
} }
} }
@@ -390,6 +433,8 @@ class LongPollUpdatesParser(
archived = true archived = true
) )
eventsToSend += eventToSend eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.forEach { it.onEvent(eventToSend) }
} }
ConvoFlags.DISABLE_PUSH -> Unit ConvoFlags.DISABLE_PUSH -> Unit
@@ -407,6 +452,10 @@ class LongPollUpdatesParser(
} }
} }
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.forEach { listener ->
eventsToSend.forEach { listener.onEvent(it) }
}
continuation.resume(eventsToSend) continuation.resume(eventsToSend)
} }
} }
@@ -424,6 +473,7 @@ class LongPollUpdatesParser(
peerId = peerId, peerId = peerId,
toCmId = cmId toCmId = cmId
) )
listenersMap[LongPollEvent.CHAT_CLEARED]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -440,6 +490,7 @@ class LongPollUpdatesParser(
peerId = peerId, peerId = peerId,
majorId = majorId, majorId = majorId,
) )
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -456,6 +507,7 @@ class LongPollUpdatesParser(
peerId = peerId, peerId = peerId,
minorId = minorId, minorId = minorId,
) )
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -474,6 +526,14 @@ class LongPollUpdatesParser(
else -> return emptyList() else -> return emptyList()
} }
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
}
val peerId = event[1].asLong() val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId } val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt() val totalCount = event[3].asInt()
@@ -490,6 +550,7 @@ class LongPollUpdatesParser(
timestamp = timestamp timestamp = timestamp
) )
listenersMap[longPollEvent]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -516,6 +577,7 @@ class LongPollUpdatesParser(
archiveUnmuted = archiveUnreadUnmutedCount, archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount archiveMentions = archiveMentionsCount
) )
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.forEach { it.onEvent(event) }
return listOf(event) return listOf(event)
} }
@@ -533,6 +595,7 @@ class LongPollUpdatesParser(
if (message != null) { if (message != null) {
val event = LongPollParsedEvent.MessageUpdated(message) val event = LongPollParsedEvent.MessageUpdated(message)
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event)) continuation.resume(listOf(event))
} else { } else {
continuation.resume(emptyList()) continuation.resume(emptyList())
@@ -552,6 +615,7 @@ class LongPollUpdatesParser(
val message = loadMessage(messageId = messageId) val message = loadMessage(messageId = messageId)
if (message != null) { if (message != null) {
val event = LongPollParsedEvent.MessageCacheClear(message) val event = LongPollParsedEvent.MessageCacheClear(message)
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.forEach { it.onEvent(event) }
continuation.resume(listOf(event)) continuation.resume(listOf(event))
} else { } else {
continuation.resume(emptyList()) continuation.resume(emptyList())
@@ -577,10 +641,7 @@ class LongPollUpdatesParser(
).listenValue(this) { state -> ).listenValue(this) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
logger.error( logger.error(this::class, "loadMessage(): ERROR: $error")
this@LongPollUpdatesParser::class,
"loadMessage(): ERROR: $error"
)
continuation.resume(null) continuation.resume(null)
}, },
success = { response -> success = { response ->
@@ -609,10 +670,7 @@ class LongPollUpdatesParser(
).listenValue(coroutineScope) { state -> ).listenValue(coroutineScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
logger.error( logger.error(this::class, "loadConvo(): ERROR: $error")
this@LongPollUpdatesParser::class,
"loadConvo(): ERROR: $error"
)
continuation.resume(null) continuation.resume(null)
}, },
success = { response -> success = { response ->
@@ -627,4 +685,107 @@ class LongPollUpdatesParser(
} }
} }
} }
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
}
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
}
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
}
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
}
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
}
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
}
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
}
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
}
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
}
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
}
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
}
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
}
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
}
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners(
eventTypes = listOf(
LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING,
LongPollEvent.PHOTO_UPLOADING,
LongPollEvent.VIDEO_UPLOADING,
LongPollEvent.FILE_UPLOADING
),
listener = assembleEventCallback(block)
)
}
}
internal inline fun <R : LongPollParsedEvent> assembleEventCallback(
crossinline block: (R) -> Unit,
): VkEventCallback<R> {
return VkEventCallback { event -> block.invoke(event) }
}
fun interface VkEventCallback<in T : LongPollParsedEvent> {
fun onEvent(event: T)
} }
@@ -52,7 +52,7 @@ fun VkConvo.extractTitle(
} else { } else {
val userName = user?.let { user -> val userName = user?.let { user ->
if (useContactName) { if (useContactName) {
VkMemoryCache.getContact(user.id)?.name ?: user.fullName VkMemoryCache.getContact(user.id)?.name
} else { } else {
user.fullName user.fullName
} }
@@ -5,7 +5,7 @@ import dev.meloda.fast.model.api.domain.VkMessage
sealed interface LongPollParsedEvent { sealed interface LongPollParsedEvent {
data class MessageNew( data class NewMessage(
val message: VkMessage, val message: VkMessage,
val inArchive: Boolean val inArchive: Boolean
) : LongPollParsedEvent ) : LongPollParsedEvent
@@ -4,8 +4,7 @@ data class GetFriendsRequest(
val order: String?, val order: String?,
val count: Int?, val count: Int?,
val offset: Int?, val offset: Int?,
val fields: String?, val fields: String?
val extended: Boolean?
) { ) {
val map val map
@@ -15,7 +14,6 @@ data class GetFriendsRequest(
count?.let { this["count"] = it.toString() } count?.let { this["count"] = it.toString() }
offset?.let { this["offset"] = it.toString() } offset?.let { this["offset"] = it.toString() }
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
extended?.let { this["extended"] = it.toString() }
} }
} }
@@ -1,13 +1,11 @@
package dev.meloda.fast.model.api.responses package dev.meloda.fast.model.api.responses
import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkContactData
import dev.meloda.fast.model.api.data.VkUserData
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class GetFriendsResponse( data class GetFriendsResponse(
@Json(name = "count") val count: Int, @Json(name = "count") val count: Int,
@Json(name = "items") val items: List<VkUserData>, @Json(name = "items") val items: List<VkUserData>
@Json(name = "contacts") val contacts: List<VkContactData>?
) )
@@ -7,7 +7,6 @@ import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
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.network.JsonConverter import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter import dev.meloda.fast.network.MoshiConverter
import dev.meloda.fast.network.OAuthResultCallFactory import dev.meloda.fast.network.OAuthResultCallFactory
@@ -124,12 +123,7 @@ private fun Scope.buildRetrofit(client: OkHttpClient): Retrofit {
.baseUrl("${AppConstants.URL_API}/") .baseUrl("${AppConstants.URL_API}/")
.addConverterFactory(ApiResultConverterFactory) .addConverterFactory(ApiResultConverterFactory)
.addCallAdapterFactory(ApiResultCallAdapterFactory) .addCallAdapterFactory(ApiResultCallAdapterFactory)
.addConverterFactory( .addConverterFactory(ResponseConverterFactory(get<JsonConverter>()))
ResponseConverterFactory(
get<JsonConverter>(),
get<FastLogger>()
)
)
.addConverterFactory(MoshiConverterFactory.create(get())) .addConverterFactory(MoshiConverterFactory.create(get()))
.client(client) .client(client)
.build() .build()
@@ -154,8 +154,7 @@ fun CaptchaScreen(
// TODO: 03/05/2026, Danil Nikolaev: show error // TODO: 03/05/2026, Danil Nikolaev: show error
} }
}, },
onCloseRequested = { showExitAlert = true }, onCloseRequested = { showExitAlert = true }
logger = logger
), ),
"AndroidBridge" "AndroidBridge"
) )
@@ -27,7 +27,7 @@ import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollEventsHandler 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
@@ -46,7 +46,7 @@ import kotlinx.coroutines.flow.launchIn
@Immutable @Immutable
class ConvosViewModel( class ConvosViewModel(
eventsHandler: LongPollEventsHandler, updatesParser: LongPollUpdatesParser,
val filter: ConvosFilter, val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
@@ -74,15 +74,15 @@ class ConvosViewModel(
init { init {
loadConvos() loadConvos()
eventsHandler.onMessageNew(::handleNewMessage) updatesParser.onNewMessage(::handleNewMessage)
eventsHandler.onMessageEdit(::handleEditedMessage) updatesParser.onMessageEdited(::handleEditedMessage)
eventsHandler.onMessageIncomingRead(::handleReadIncomingMessage) updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
eventsHandler.onMessageOutgoingRead(::handleReadOutgoingMessage) updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
eventsHandler.onInteraction(::handleInteraction) updatesParser.onInteractions(::handleInteraction)
eventsHandler.onChatMajorChange(::handleChatMajorChanged) updatesParser.onChatMajorChanged(::handleChatMajorChanged)
eventsHandler.onChatMinorChange(::handleChatMinorChanged) updatesParser.onChatMinorChanged(::handleChatMinorChanged)
eventsHandler.onChatClear(::handleChatClearing) updatesParser.onChatCleared(::handleChatClearing)
eventsHandler.onChatArchive(::handleChatArchived) updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) { userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos() syncUiConvos()
@@ -382,7 +382,7 @@ class ConvosViewModel(
} }
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages // TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.MessageNew) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
val newConvos = convos.toMutableList() val newConvos = convos.toMutableList()
@@ -25,7 +25,7 @@ val convosModule = module {
private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel { private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
return ConvosViewModel( return ConvosViewModel(
filter = filter, filter = filter,
eventsHandler = get(), updatesParser = get(),
convoUseCase = get(), convoUseCase = get(),
messagesUseCase = get(), messagesUseCase = get(),
resources = get(), resources = get(),
@@ -65,7 +65,6 @@ 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.SegmentedButtonItem import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.VkErrorView
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
@@ -283,12 +282,12 @@ fun ConvosScreen(
) { padding -> ) { padding ->
when { when {
// TODO: 30.05.2026, Danil Nikolaev: move to UI State // TODO: 30.05.2026, Danil Nikolaev: move to UI State
screenState.error != null -> { // baseError != null -> {
VkErrorView( // VkErrorView(
baseError = screenState.error, // baseError = baseError,
onButtonClick = { handleIntent(ConvoIntent.ErrorActionButtonClick) } // onButtonClick = onErrorViewButtonClicked
) // )
} // }
screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader() screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
@@ -21,11 +21,11 @@ fun NavGraphBuilder.friendsScreen(
onMessageClicked: (userId: Long) -> Unit, onMessageClicked: (userId: Long) -> Unit,
onScrolledToTop: () -> Unit onScrolledToTop: () -> Unit
) { ) {
composable<Friends> {
val friendsViewModel: FriendsViewModel = activity.getViewModel<FriendsViewModelImpl>() val friendsViewModel: FriendsViewModel = activity.getViewModel<FriendsViewModelImpl>()
val onlineFriendsViewModel = val onlineFriendsViewModel =
activity.getViewModel<OnlineFriendsViewModelImpl>() activity.getViewModel<OnlineFriendsViewModelImpl>()
composable<Friends> {
FriendsRoute( FriendsRoute(
friendsViewModel = friendsViewModel, friendsViewModel = friendsViewModel,
onlineFriendsViewModel = onlineFriendsViewModel, onlineFriendsViewModel = onlineFriendsViewModel,
@@ -39,7 +39,7 @@ import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.GetMessageReadPeersUseCase import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollEventsHandler 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
@@ -84,7 +84,7 @@ class MessagesHistoryViewModelImpl(
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase, private val loadConvosByIdUseCase: LoadConvosByIdUseCase,
private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase, private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase,
eventsHandler: LongPollEventsHandler, updatesParser: LongPollUpdatesParser,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : MessagesHistoryViewModel, ViewModel() { ) : MessagesHistoryViewModel, ViewModel() {
@@ -124,15 +124,15 @@ class MessagesHistoryViewModelImpl(
loadConvo() loadConvo()
loadMessagesHistory() loadMessagesHistory()
eventsHandler.onMessageNew(::handleNewMessage) updatesParser.onNewMessage(::handleNewMessage)
eventsHandler.onMessageEdit(::handleEditedMessage) updatesParser.onMessageEdited(::handleEditedMessage)
eventsHandler.onMessageIncomingRead(::handleReadIncomingEvent) updatesParser.onMessageIncomingRead(::handleReadIncomingEvent)
eventsHandler.onMessageOutgoingRead(::handleReadOutgoingEvent) updatesParser.onMessageOutgoingRead(::handleReadOutgoingEvent)
eventsHandler.onMessageDelete(::handleMessageDeleted) updatesParser.onMessageDeleted(::handleMessageDeleted)
eventsHandler.onMessageRestore(::handleMessageRestored) updatesParser.onMessageRestored(::handleMessageRestored)
eventsHandler.onMessageMarkAsImportant(::handleMessageMarkedAsImportant) updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant)
eventsHandler.onMessageMarkAsSpam(::handleMessageMarkedAsSpam) updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam)
eventsHandler.onMessageMarkAsNotSpam(::handleMessageMarkedAsNotSpam) updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam)
} }
override fun onNavigationConsumed() { override fun onNavigationConsumed() {
@@ -681,7 +681,7 @@ class MessagesHistoryViewModelImpl(
} }
} }
private fun handleNewMessage(event: LongPollParsedEvent.MessageNew) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
if (message.peerId != screenState.value.convoId) return if (message.peerId != screenState.value.convoId) return
@@ -9,56 +9,49 @@ 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.logger.FastLogger
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.asStateFlow import kotlinx.coroutines.flow.asStateFlow
class ProfileViewModel( class ProfileViewModel(
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase, private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase, private val loadUserByIdUseCase: LoadUserByIdUseCase
private val logger: FastLogger
) : ViewModel() { ) : ViewModel() {
private val screenState = MutableStateFlow(ProfileScreenState.EMPTY) private val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
init { init {
getLocalAccountInfo() getLocalAccountInfo()
} }
fun screenStateFlow(): StateFlow<ProfileScreenState> = screenState.asStateFlow()
private fun getLocalAccountInfo() { private fun getLocalAccountInfo() {
logger.debug(this@ProfileViewModel::class, "START") getLocalUserByIdUseCase(UserConfig.userId)
emit(screenState.value.copy(isLoading = true)) .listenValue(viewModelScope) { state ->
getLocalUserByIdUseCase(UserConfig.userId).listenValue { state ->
logger.debug(this@ProfileViewModel::class, "LOADED: $state")
emit(screenState.value.copy(isLoading = false))
state.processState( state.processState(
error = { error = {
logger.debug(this@ProfileViewModel::class, "ERROR") screenState.setValue { old ->
emit(screenState.value.copy(avatarUrl = null, fullName = null)) old.copy(
avatarUrl = null,
fullName = null
)
}
}, },
success = { user -> success = { user ->
logger.debug(this@ProfileViewModel::class, "SUCCESS") screenState.setValue { old ->
emit( old.copy(
screenState.value.copy(
avatarUrl = user?.photo200, avatarUrl = user?.photo200,
fullName = user?.fullName fullName = user?.fullName
) )
) }
}, },
any = ::loadAccountInfo any = ::loadAccountInfo
) )
} }
} }
private fun emit(state: ProfileScreenState) {
screenState.setValue { state }
}
private fun loadAccountInfo() { private fun loadAccountInfo() {
loadUserByIdUseCase( loadUserByIdUseCase(
userId = null, userId = null,
@@ -18,9 +18,10 @@ fun NavGraphBuilder.profileScreen(
onSettingsButtonClicked: () -> Unit, onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit onPhotoClicked: (url: String) -> Unit
) { ) {
val viewModel: ProfileViewModel = with(activity) { getViewModel() }
composable<Profile> { composable<Profile> {
val viewModel: ProfileViewModel = activity.getViewModel() val screenState by viewModel.screenStateFlow().collectAsStateWithLifecycle()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
ProfileRoute( ProfileRoute(
screenState = screenState, screenState = screenState,
@@ -11,10 +11,9 @@ import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.findWithIndex 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.extensions.updateValue
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.common.model.NetworkLogLevel import dev.meloda.fast.common.model.NetworkLogLevel
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
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
@@ -25,22 +24,19 @@ import dev.meloda.fast.datastore.SettingsKeys
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.database.AccountEntity import dev.meloda.fast.model.database.AccountEntity
import dev.meloda.fast.settings.model.HapticType import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsDialog import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsItem import dev.meloda.fast.settings.model.SettingsItem
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.TextProvider import dev.meloda.fast.settings.model.TextProvider
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
@@ -49,76 +45,35 @@ class SettingsViewModel(
private val getCurrentAccountUseCase: GetCurrentAccountUseCase, private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val resources: Resources, private val resources: Resources,
private val longPollController: LongPollController, private val longPollController: LongPollController
private val logger: FastLogger
) : ViewModel() { ) : ViewModel() {
private val screenState = MutableStateFlow(SettingsScreenState.EMPTY) private val _screenState = MutableStateFlow(SettingsScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow() val screenState = _screenState.asStateFlow()
private val screenEffect = MutableSharedFlow<SettingsEffect>(extraBufferCapacity = 1) private val _hapticType = MutableStateFlow<HapticType?>(null)
val screenEffectFlow = screenEffect.asSharedFlow() val hapticType = _hapticType.asStateFlow()
private val settings = mutableListOf<SettingsItem<*>>() private val _dialog = MutableStateFlow<SettingsDialog?>(null)
private var showDebugCategory: Boolean = userSettings.showDebugCategory.value val dialog = _dialog.asStateFlow()
private val _isNeedToRestart = MutableStateFlow(false)
val isNeedToRestart = _isNeedToRestart.asStateFlow()
private val settings = MutableStateFlow<List<SettingsItem<*>>>(emptyList())
init { init {
createSettings() createSettings()
} }
fun handleIntent(intent: SettingsIntent) { fun onDialogConfirmed(dialog: SettingsDialog, bundle: Bundle) {
when (intent) { onDialogDismissed(dialog)
SettingsIntent.BackClick -> {
screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.Back))
}
is SettingsIntent.ItemClick -> {
onSettingsItemClicked(intent.key)
}
is SettingsIntent.ItemLongClick -> {
onSettingsItemLongClicked(intent.key)
}
is SettingsIntent.ItemValueChanged -> {
onSettingsItemChanged(intent.key, intent.newValue)
}
is SettingsIntent.Dialog -> {
when (intent) {
SettingsIntent.Dialog.CancelClick -> Unit
is SettingsIntent.Dialog.ConfirmClick -> {
onDialogConfirmed(intent.bundle)
}
SettingsIntent.Dialog.Dismiss -> {
onDialogDismissed()
}
is SettingsIntent.Dialog.ItemPick -> {
onDialogItemPicked(intent.bundle)
}
}
}
}
}
private fun setDialog(dialog: SettingsDialog?) {
screenState.updateValue { copy(dialog = dialog) }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
when (dialog) { when (dialog) {
is SettingsDialog.LogOut -> onLogOutAlertPositiveClick() is SettingsDialog.LogOut -> onLogOutAlertPositiveClick()
is SettingsDialog.PerformCrash -> onPerformCrashPositiveButtonClicked() is SettingsDialog.PerformCrash -> onPerformCrashPositiveButtonClicked()
is SettingsDialog.ImportAuthData -> { is SettingsDialog.ImportAuthData -> {
if (bundle == null) return
val accessToken = bundle.getString("ACCESS_TOKEN") ?: return val accessToken = bundle.getString("ACCESS_TOKEN") ?: return
val exchangeToken = bundle.getString("EXCHANGE_TOKEN") val exchangeToken = bundle.getString("EXCHANGE_TOKEN")
val trustedHash = bundle.getString("TRUSTED_HASH") val trustedHash = bundle.getString("TRUSTED_HASH")
@@ -134,17 +89,10 @@ class SettingsViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
logger.error(
this@SettingsViewModel::class,
"importAuthInfo(): loadUserById(): ERROR: $error"
)
UserConfig.accessToken = oldToken UserConfig.accessToken = oldToken
}, },
success = { user -> success = { user ->
if (user == null) { if (user == null) return@listenValue
UserConfig.accessToken = oldToken
return@listenValue
}
UserConfig.currentUserId = user.id UserConfig.currentUserId = user.id
@@ -165,9 +113,7 @@ class SettingsViewModel(
accountsRepository.storeAccounts(listOf(account)) accountsRepository.storeAccounts(listOf(account))
screenEffect.tryEmit( _isNeedToRestart.setValue { true }
SettingsEffect.Navigate(SettingsNavigationIntent.Restart)
)
} }
) )
} }
@@ -178,8 +124,7 @@ class SettingsViewModel(
} }
} }
private fun onDialogDismissed() { fun onDialogDismissed(dialog: SettingsDialog) {
val dialog = screenState.value.dialog ?: return
when (dialog) { when (dialog) {
is SettingsDialog.LogOut -> Unit is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit is SettingsDialog.PerformCrash -> Unit
@@ -187,11 +132,10 @@ class SettingsViewModel(
is SettingsDialog.ExportAuthData -> Unit is SettingsDialog.ExportAuthData -> Unit
} }
setDialog(null) _dialog.setValue { null }
} }
private fun onDialogItemPicked(bundle: Bundle?) { fun onDialogItemPicked(dialog: SettingsDialog, bundle: Bundle) {
val dialog = screenState.value.dialog ?: return
when (dialog) { when (dialog) {
is SettingsDialog.LogOut -> Unit is SettingsDialog.LogOut -> Unit
is SettingsDialog.PerformCrash -> Unit is SettingsDialog.PerformCrash -> Unit
@@ -200,8 +144,10 @@ class SettingsViewModel(
} }
} }
private fun onLogOutAlertPositiveClick() { fun onLogOutAlertPositiveClick() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tasks = listOf(
async {
accountsRepository.storeAccounts( accountsRepository.storeAccounts(
listOf( listOf(
AccountEntity( AccountEntity(
@@ -213,39 +159,40 @@ class SettingsViewModel(
) )
) )
) )
},
async { UserConfig.clear() }
)
UserConfig.clear() tasks.awaitAll()
screenEffect.tryEmit(SettingsEffect.Navigate(SettingsNavigationIntent.LogOut))
} }
} }
private fun onPerformCrashPositiveButtonClicked() { fun onPerformCrashPositiveButtonClicked() {
throw Exception("Test exception") throw Exception("Test exception")
} }
private fun onSettingsItemClicked(key: String) { fun onSettingsItemClicked(key: String) {
when (key) { when (key) {
SettingsKeys.KEY_ACCOUNT_LOGOUT -> { SettingsKeys.KEY_ACCOUNT_LOGOUT -> {
setDialog(SettingsDialog.LogOut) _dialog.setValue { SettingsDialog.LogOut }
} }
SettingsKeys.KEY_DEBUG_PERFORM_CRASH -> { SettingsKeys.KEY_DEBUG_PERFORM_CRASH -> {
setDialog(SettingsDialog.PerformCrash) _dialog.setValue { SettingsDialog.PerformCrash }
} }
SettingsKeys.KEY_DEBUG_IMPORT_AUTH_DATA -> { SettingsKeys.KEY_DEBUG_IMPORT_AUTH_DATA -> {
setDialog(SettingsDialog.ImportAuthData) _dialog.setValue { SettingsDialog.ImportAuthData }
} }
SettingsKeys.KEY_DEBUG_EXPORT_AUTH_DATA -> { SettingsKeys.KEY_DEBUG_EXPORT_AUTH_DATA -> {
setDialog( _dialog.setValue {
SettingsDialog.ExportAuthData( SettingsDialog.ExportAuthData(
accessToken = UserConfig.accessToken, accessToken = UserConfig.accessToken,
exchangeToken = UserConfig.exchangeToken, exchangeToken = UserConfig.exchangeToken,
trustedHash = UserConfig.trustedHash trustedHash = UserConfig.trustedHash
) )
) }
} }
SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST -> { SettingsKeys.KEY_DEBUG_HIDE_DEBUG_LIST -> {
@@ -256,13 +203,13 @@ class SettingsViewModel(
createSettings() createSettings()
screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.REJECT)) _hapticType.update { HapticType.REJECT }
showDebugCategory = false _screenState.setValue { old -> old.copy(showDebugOptions = false) }
} }
} }
} }
private fun onSettingsItemLongClicked(key: String) { fun onSettingsItemLongClicked(key: String) {
when (key) { when (key) {
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> { SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
if (AppSettings.Debug.showDebugCategory) return if (AppSettings.Debug.showDebugCategory) return
@@ -272,18 +219,18 @@ class SettingsViewModel(
createSettings() createSettings()
screenEffect.tryEmit(SettingsEffect.PerformHaptic(HapticType.LONG_PRESS)) _hapticType.update { HapticType.LONG_PRESS }
showDebugCategory = true _screenState.setValue { old -> old.copy(showDebugOptions = true) }
} }
} }
} }
private fun onSettingsItemChanged(key: String, newValue: Any?) { fun onSettingsItemChanged(key: String, newValue: Any?) {
settings.findWithIndex { it.key == key }?.let { (index, item) -> settings.value.findWithIndex { it.key == key }?.let { (index, item) ->
item.updateValue(newValue) item.updateValue(newValue)
item.updateText() item.updateText()
screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
settings = old.settings.toMutableList().apply { settings = old.settings.toMutableList().apply {
this[index] = item.asPresentation(resources) this[index] = item.asPresentation(resources)
@@ -364,6 +311,10 @@ class SettingsViewModel(
} }
} }
fun onHapticPerformed() {
_hapticType.update { null }
}
private fun createSettings() { private fun createSettings() {
val accountVisible = UserConfig.isLoggedIn() val accountVisible = UserConfig.isLoggedIn()
val accountTitle = SettingsItem.Title( val accountTitle = SettingsItem.Title(
@@ -561,8 +512,7 @@ class SettingsViewModel(
values = logLevelValues.keys.toList().map(NetworkLogLevel::value) values = logLevelValues.keys.toList().map(NetworkLogLevel::value)
).apply { ).apply {
textProvider = TextProvider { item -> textProvider = TextProvider { item ->
val textValue = val textValue = logLevelValues[NetworkLogLevel.parse(item.value)].parseString(resources)
logLevelValues[NetworkLogLevel.parse(item.value)].parseString(resources)
UiText.Simple("Current value: $textValue") UiText.Simple("Current value: $textValue")
} }
@@ -652,10 +602,12 @@ class SettingsViewModel(
} }
private fun emitSettings(newSettings: List<SettingsItem<*>>) { private fun emitSettings(newSettings: List<SettingsItem<*>>) {
settings.clear() settings.update { newSettings }
settings.addAll(newSettings)
val uiSettings = newSettings.map { it.asPresentation(resources) } val uiSettings = newSettings.map { item ->
screenState.setValue { old -> old.copy(settings = uiSettings) } item.asPresentation(resources)
}
_screenState.setValue { old -> old.copy(settings = uiSettings) }
} }
} }
@@ -1,6 +0,0 @@
package dev.meloda.fast.settings.model
sealed interface SettingsEffect {
data class Navigate(val intent: SettingsNavigationIntent) : SettingsEffect
data class PerformHaptic(val type: HapticType) : SettingsEffect
}
@@ -1,18 +0,0 @@
package dev.meloda.fast.settings.model
import android.os.Bundle
sealed class SettingsIntent {
data object BackClick : SettingsIntent()
data class ItemClick(val key: String) : SettingsIntent()
data class ItemLongClick(val key: String) : SettingsIntent()
data class ItemValueChanged(val key: String, val newValue: Any?) : SettingsIntent()
sealed class Dialog : SettingsIntent() {
data object Dismiss : Dialog()
data class ConfirmClick(val bundle: Bundle? = null) : Dialog()
data object CancelClick : Dialog()
data class ItemPick(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,13 +1,13 @@
package dev.meloda.fast.settings.model package dev.meloda.fast.settings.model
import android.content.res.Resources import android.content.res.Resources
import androidx.compose.runtime.Stable import androidx.compose.runtime.Immutable
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
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import kotlin.reflect.KClass import kotlin.reflect.KClass
@Stable @Immutable
sealed class SettingsItem<T>( sealed class SettingsItem<T>(
val key: String, val key: String,
value: T, value: T,
@@ -1,8 +0,0 @@
package dev.meloda.fast.settings.model
sealed class SettingsNavigationIntent {
data object Back : SettingsNavigationIntent()
data object Language : SettingsNavigationIntent()
data object Restart : SettingsNavigationIntent()
data object LogOut : SettingsNavigationIntent()
}
@@ -6,13 +6,13 @@ import dev.meloda.fast.datastore.AppSettings
@Immutable @Immutable
data class SettingsScreenState( data class SettingsScreenState(
val settings: List<UiItem>, val settings: List<UiItem>,
val dialog: SettingsDialog? val showDebugOptions: Boolean
) { ) {
companion object { companion object {
val EMPTY: SettingsScreenState = SettingsScreenState( val EMPTY: SettingsScreenState = SettingsScreenState(
settings = emptyList(), settings = emptyList(),
dialog = null showDebugOptions = AppSettings.Debug.showDebugCategory
) )
} }
} }
@@ -1,49 +1,26 @@
package dev.meloda.fast.settings.navigation package dev.meloda.fast.settings.navigation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsEffect
import dev.meloda.fast.settings.model.SettingsNavigationIntent
import dev.meloda.fast.settings.presentation.SettingsRoute import dev.meloda.fast.settings.presentation.SettingsRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
@Serializable @Serializable
object Settings object Settings
fun NavGraphBuilder.settingsScreen( fun NavGraphBuilder.settingsScreen(
handleNavigationIntent: (SettingsNavigationIntent) -> Unit onBack: () -> Unit,
onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
) { ) {
composable<Settings> { composable<Settings> {
val view = LocalView.current
val viewModel: SettingsViewModel = koinViewModel()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.screenEffectFlow.onEach { effect ->
when (effect) {
is SettingsEffect.Navigate -> handleNavigationIntent(effect.intent)
is SettingsEffect.PerformHaptic -> {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(effect.type.getHaptic())
}
}
}
}.collect()
}
SettingsRoute( SettingsRoute(
handleIntent = viewModel::handleIntent, onBack = onBack,
screenState = screenState, onLogOutButtonClicked = onLogOutButtonClicked,
onLanguageItemClicked = onLanguageItemClicked,
onRestartRequired = onRestartRequired
) )
} }
} }
@@ -3,6 +3,7 @@ package dev.meloda.fast.settings.presentation
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -25,18 +26,20 @@ import androidx.core.os.bundleOf
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.SettingsKeys import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.model.SettingsDialog import dev.meloda.fast.settings.model.SettingsDialog
import dev.meloda.fast.settings.model.SettingsIntent
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
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.R
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
handleIntent: (SettingsIntent.Dialog) -> Unit,
screenState: SettingsScreenState, screenState: SettingsScreenState,
dialog: SettingsDialog?,
onConfirmed: (SettingsDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (SettingsDialog) -> Unit = {},
onItemPicked: (SettingsDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
val dialog = screenState.dialog ?: return if (dialog == null) return
val context = LocalContext.current val context = LocalContext.current
@@ -45,13 +48,13 @@ fun HandleDialogs(
val isEasterEgg = UserConfig.userId == SettingsKeys.ID_DMITRY val isEasterEgg = UserConfig.userId == SettingsKeys.ID_DMITRY
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource( title = stringResource(
id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry
else R.string.sign_out_confirm_title else R.string.sign_out_confirm_title
), ),
text = stringResource(id = R.string.sign_out_confirm), text = stringResource(id = R.string.sign_out_confirm),
confirmAction = { handleIntent(SettingsIntent.Dialog.ConfirmClick()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource( confirmText = stringResource(
id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry id = if (isEasterEgg) R.string.easter_egg_log_out_dmitry
else R.string.action_sign_out else R.string.action_sign_out
@@ -63,10 +66,10 @@ fun HandleDialogs(
is SettingsDialog.PerformCrash -> { is SettingsDialog.PerformCrash -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = "Perform crash", title = "Perform crash",
text = "App will be crashed. Are you sure?", text = "App will be crashed. Are you sure?",
confirmAction = { handleIntent(SettingsIntent.Dialog.ConfirmClick()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.yes), confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
actionInvokeDismiss = ActionInvokeDismiss.Always actionInvokeDismiss = ActionInvokeDismiss.Always
@@ -85,18 +88,17 @@ fun HandleDialogs(
} }
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = "Import auth data", title = "Import auth data",
confirmAction = { confirmAction = {
handleIntent( onConfirmed(
SettingsIntent.Dialog.ConfirmClick( dialog,
bundleOf( bundleOf(
"ACCESS_TOKEN" to accessToken, "ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null }, "EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null } "TRUSTED_HASH" to trustedHash.ifEmpty { null }
) )
) )
)
}, },
confirmText = "Import", confirmText = "Import",
cancelText = stringResource(R.string.cancel) cancelText = stringResource(R.string.cancel)
@@ -196,18 +198,17 @@ fun HandleDialogs(
} }
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(SettingsIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = "Export auth data", title = "Export auth data",
confirmAction = { confirmAction = {
handleIntent( onConfirmed(
SettingsIntent.Dialog.ConfirmClick( dialog,
bundleOf( bundleOf(
"ACCESS_TOKEN" to accessToken, "ACCESS_TOKEN" to accessToken,
"EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null }, "EXCHANGE_TOKEN" to exchangeToken.ifEmpty { null },
"TRUSTED_HASH" to trustedHash.ifEmpty { null } "TRUSTED_HASH" to trustedHash.ifEmpty { null }
) )
) )
)
}, },
confirmText = stringResource(R.string.ok), confirmText = stringResource(R.string.ok),
) { ) {
@@ -268,8 +269,7 @@ fun HandleDialogs(
"Auth data copied to clipboard. Be careful with this data. If another person gets it, your account will be at risk", "Auth data copied to clipboard. Be careful with this data. If another person gets it, your account will be at risk",
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
onDismissed(dialog)
handleIntent(SettingsIntent.Dialog.Dismiss)
}, },
modifier = Modifier.align(Alignment.CenterHorizontally) modifier = Modifier.align(Alignment.CenterHorizontally)
) { ) {
@@ -1,21 +1,65 @@
package dev.meloda.fast.settings.presentation package dev.meloda.fast.settings.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import dev.meloda.fast.settings.model.SettingsIntent import androidx.compose.runtime.LaunchedEffect
import dev.meloda.fast.settings.model.SettingsScreenState import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.datastore.SettingsKeys
import dev.meloda.fast.settings.SettingsViewModel
import dev.meloda.fast.settings.model.SettingsDialog
import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
handleIntent: (SettingsIntent) -> Unit, onBack: () -> Unit,
screenState: SettingsScreenState, onLogOutButtonClicked: () -> Unit,
onLanguageItemClicked: () -> Unit,
onRestartRequired: () -> Unit,
viewModel: SettingsViewModel = koinViewModel()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val hapticType by viewModel.hapticType.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val isNeedToRestart by viewModel.isNeedToRestart.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToRestart) {
if (isNeedToRestart) {
onRestartRequired()
}
}
SettingsScreen( SettingsScreen(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
hapticType = hapticType,
onBack = onBack,
onHapticPerformed = viewModel::onHapticPerformed,
onSettingsItemClicked = { key ->
when (key) {
SettingsKeys.KEY_APPEARANCE_LANGUAGE -> {
onLanguageItemClicked()
}
else -> viewModel.onSettingsItemClicked(key)
}
},
onSettingsItemLongClicked = viewModel::onSettingsItemLongClicked,
onSettingsItemValueChanged = viewModel::onSettingsItemChanged
) )
HandleDialogs( HandleDialogs(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
dialog = dialog,
onConfirmed = { dialog, bundle ->
when (dialog) {
is SettingsDialog.LogOut -> {
onLogOutButtonClicked()
}
else -> Unit
}
viewModel.onDialogConfirmed(dialog, bundle)
},
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
} }
@@ -21,10 +21,10 @@ 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.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource 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
@@ -34,7 +34,8 @@ 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.settings.model.SettingsIntent import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.settings.model.HapticType
import dev.meloda.fast.settings.model.SettingsScreenState import dev.meloda.fast.settings.model.SettingsScreenState
import dev.meloda.fast.settings.model.UiItem import dev.meloda.fast.settings.model.UiItem
import dev.meloda.fast.settings.presentation.item.ListItem import dev.meloda.fast.settings.presentation.item.ListItem
@@ -42,8 +43,8 @@ import dev.meloda.fast.settings.presentation.item.SwitchItem
import dev.meloda.fast.settings.presentation.item.TextFieldItem import dev.meloda.fast.settings.presentation.item.TextFieldItem
import dev.meloda.fast.settings.presentation.item.TitleItem import dev.meloda.fast.settings.presentation.item.TitleItem
import dev.meloda.fast.settings.presentation.item.TitleTextItem import dev.meloda.fast.settings.presentation.item.TitleTextItem
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.R
@OptIn( @OptIn(
@@ -52,19 +53,23 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
) )
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
handleIntent: (SettingsIntent) -> Unit,
screenState: SettingsScreenState = SettingsScreenState.EMPTY, screenState: SettingsScreenState = SettingsScreenState.EMPTY,
hapticType: HapticType? = null,
onBack: () -> Unit = {},
onHapticPerformed: () -> Unit = {},
onSettingsItemClicked: (key: String) -> Unit = {},
onSettingsItemLongClicked: (key: String) -> Unit = {},
onSettingsItemValueChanged: (key: String, newValue: Any?) -> Unit = { _, _ -> }
) { ) {
val onSettingsItemClicked by rememberUpdatedState { key: String -> val view = LocalView.current
handleIntent(SettingsIntent.ItemClick(key))
}
val onSettingsItemLongClicked by rememberUpdatedState { key: String -> LaunchedEffect(hapticType) {
handleIntent(SettingsIntent.ItemLongClick(key)) if (hapticType != null) {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(hapticType.getHaptic())
}
onHapticPerformed()
} }
val onSettingsItemValueChanged by rememberUpdatedState { key: String, newValue: Any? ->
handleIntent(SettingsIntent.ItemValueChanged(key, newValue))
} }
val themeConfig = LocalThemeConfig.current val themeConfig = LocalThemeConfig.current
@@ -85,7 +90,7 @@ fun SettingsScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = { handleIntent(SettingsIntent.BackClick) }) { IconButton(onClick = onBack) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_arrow_back_round_24), painter = painterResource(id = R.drawable.ic_arrow_back_round_24),
contentDescription = "Back button" contentDescription = "Back button"