9 Commits

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