release/0.1.6 (#103)

* Bump com.google.guava:guava from 33.3.1-jre to 33.4.0-jre (#97)

* Bump coroutines from 1.9.0 to 1.10.1 (#100)

* some improvements + loading conversation on new message if it is not already in the list

* Bump koin from 4.0.0 to 4.0.1 (#101)

* minor update

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-01-11 06:38:08 +03:00
committed by GitHub
parent 7c14df1824
commit 7e5843759d
12 changed files with 310 additions and 194 deletions
@@ -26,8 +26,8 @@ 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.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind
import org.koin.dsl.module
@@ -61,7 +61,7 @@ val applicationModule = module {
qualifier = qualifier("main")
}
single {
single<ImageLoader> {
ImageLoader.Builder(get())
.crossfade(true)
.build()
@@ -1,7 +1,5 @@
package dev.meloda.fast.common.extensions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -20,18 +20,20 @@ sealed class State<out T> {
data object ConnectionError : Error()
data object Unknown : Error()
data object UnknownError : Error()
data object InternalError : Error()
data class OAuthError(val error: OAuthErrorDomain) : Error()
data class TestError(val message: String) : Error()
}
fun isLoading(): Boolean = this is Loading
companion object {
val UNKNOWN_ERROR = Error.Unknown
val UNKNOWN_ERROR = Error.UnknownError
}
}
@@ -73,11 +75,12 @@ fun <T : Any> ApiResult<T, RestApiErrorDomain>.mapToState() = when (this) {
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) = when (this) {
is ApiResult.Success -> State.Success(successMapper(this.value))
fun <T : Any, N> ApiResult<T, RestApiErrorDomain>.mapToState(successMapper: (T) -> N) =
when (this) {
is ApiResult.Success -> State.Success(successMapper(this.value))
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
is ApiResult.Failure.NetworkFailure -> State.Error.ConnectionError
is ApiResult.Failure.UnknownFailure -> State.UNKNOWN_ERROR
is ApiResult.Failure.HttpFailure -> this.error.toStateApiError()
is ApiResult.Failure.ApiFailure -> this.error.toStateApiError()
}
@@ -33,92 +33,100 @@ class OAuthUseCaseImpl(
forceSms = forceSms
)
val error = response.error?.let(VkOAuthError::parse)
val errorType = response.errorType?.let(VkOAuthErrorType::parse)
kotlin.runCatching {
val error = response.error?.let(VkOAuthError::parse)
val errorType = response.errorType?.let(VkOAuthErrorType::parse)
val newState = when (error) {
null -> {
State.Success(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
validationHash = response.validationHash
)
)
}
VkOAuthError.FLOOD_CONTROL -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
val newState = when (error) {
null -> {
State.Success(
AuthInfo(
userId = response.userId,
accessToken = response.accessToken,
validationHash = response.validationHash
)
)
}
}
VkOAuthError.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
VkOAuthError.FLOOD_CONTROL -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthError.NEED_VALIDATION -> {
if (response.banInfo != null) {
val info = requireNotNull(response.banInfo)
State.Error.OAuthError(
OAuthErrorDomain.UserBannedError(
memberName = info.memberName,
message = info.message,
accessToken = info.accessToken,
restoreUrl = info.restoreUrl
)
)
} else {
State.Error.OAuthError(
OAuthErrorDomain.ValidationRequiredError(
description = response.errorDescription.orEmpty(),
validationType = response.validationType.orEmpty()
.let(ValidationType::parse),
validationSid = response.validationSid.orEmpty(),
phoneMask = response.phoneMask.orEmpty(),
redirectUri = response.redirectUri.orEmpty(),
validationResend = response.validationResend,
restoreIfCannotGetCode = response.restoreIfCannotGetCode
)
)
}
}
VkOAuthError.NEED_CAPTCHA -> {
State.Error.OAuthError(
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
)
)
)
}
}
VkOAuthError.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthError.INVALID_CLIENT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthError.INVALID_REQUEST -> {
when (errorType) {
null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError)
VkOAuthError.INVALID_REQUEST -> {
when (errorType) {
null -> State.Error.OAuthError(OAuthErrorDomain.UnknownError)
VkOAuthErrorType.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
VkOAuthErrorType.WRONG_OTP -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCode)
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
}
}
VkOAuthErrorType.WRONG_OTP_FORMAT -> {
State.Error.OAuthError(OAuthErrorDomain.WrongValidationCodeFormat)
}
VkOAuthErrorType.PASSWORD_BRUTEFORCE_ATTEMPT -> {
State.Error.OAuthError(OAuthErrorDomain.TooManyTriesError)
}
VkOAuthErrorType.USERNAME_OR_PASSWORD_IS_INCORRECT -> {
State.Error.OAuthError(OAuthErrorDomain.InvalidCredentialsError)
}
VkOAuthError.UNKNOWN -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
}
}
VkOAuthError.UNKNOWN -> {
State.Error.OAuthError(OAuthErrorDomain.UnknownError)
emit(newState)
}.fold(
onSuccess = {
},
onFailure = {
emit(State.Error.TestError(it.stackTraceToString()))
}
}
emit(newState)
)
}
}
@@ -5,6 +5,8 @@ enum class ValidationType(val value: String) {
SMS("2fa_sms");
companion object {
fun parse(value: String): ValidationType = entries.first { it.value == value }
fun parse(value: String): ValidationType =
entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown validation type $value")
}
}
@@ -345,6 +345,13 @@ class LoginViewModelImpl(
true
}
is State.Error.TestError -> {
val message = stateError.message
val error = LoginError.SimpleError(message = message)
loginError.update { error }
true
}
else -> false
}
}
@@ -9,4 +9,5 @@ sealed class LoginError {
data object TooManyTries : LoginError()
data object WrongValidationCode : LoginError()
data object WrongValidationCodeFormat : LoginError()
data class SimpleError(val message: String): LoginError()
}
@@ -50,8 +50,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginError
import dev.meloda.fast.auth.login.model.LoginScreenState
@@ -441,5 +439,14 @@ fun HandleError(
confirmText = stringResource(id = UiR.string.ok)
)
}
is LoginError.SimpleError -> {
MaterialDialog(
onDismissRequest = onDismiss,
title = "Error",
text = error.message,
confirmText = stringResource(id = UiR.string.ok)
)
}
}
}
@@ -1,8 +1,11 @@
package dev.meloda.fast.conversations
import android.content.Context
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
@@ -18,6 +21,7 @@ import dev.meloda.fast.data.State
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConversationsUseCase
import dev.meloda.fast.domain.LoadConversationsByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError
@@ -43,7 +47,6 @@ interface ConversationsViewModel {
val screenState: StateFlow<ConversationsScreenState>
val baseError: StateFlow<BaseError?>
val imagesToPreload: StateFlow<List<String>>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
val scrollToTop: StateFlow<Boolean>
@@ -78,16 +81,23 @@ class ConversationsViewModelImpl(
private val conversationsUseCase: ConversationsUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings
private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConversationsByIdUseCase: LoadConversationsByIdUseCase
) : ConversationsViewModel, ViewModel() {
override val screenState = MutableStateFlow(ConversationsScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val imagesToPreload = MutableStateFlow<List<String>>(emptyList())
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override val scrollToTop = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite
private val useContactNames = {
userSettings.useContactNames.value
}
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size }
loadConversations()
@@ -281,9 +291,17 @@ class ConversationsViewModelImpl(
val paginationExhausted = !itemsCountSufficient &&
screenState.value.conversations.isNotEmpty()
imagesToPreload.setValue {
val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
conversationsUseCase.storeConversations(response)
val loadedConversations = response.map {
@@ -337,7 +355,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
@@ -373,13 +396,40 @@ class ConversationsViewModelImpl(
private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) {
val message = event.message
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == message.peerId }
if (conversationIndex == null) { // диалога нет в списке
// pizdets
// TODO: 04/07/2024, Danil Nikolaev: load conversation and store info
if (conversationIndex == null) {
loadConversationsByIdUseCase(peerIds = listOf(message.peerId))
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
},
success = { response ->
val conversation = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
// TODO: 22-Dec-24, Danil Nikolaev: handle interactions and pinned state
newConversations.add(pinnedConversationsCount.value, conversation)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
)
}
} else {
val conversation = newConversations[conversationIndex]
var newConversation = conversation.copy(
@@ -420,7 +470,12 @@ class ConversationsViewModelImpl(
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
@@ -444,7 +499,12 @@ class ConversationsViewModelImpl(
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
@@ -454,20 +514,29 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations.indexOfFirstOrNull { it.id == event.peerId }
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inRead = event.messageId,
unreadCount = event.unreadCount
)
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
inRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
}
@@ -475,19 +544,28 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations.indexOfFirstOrNull { it.id == event.peerId }
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationIndex] =
newConversations[conversationIndex].copy(
outRead = event.messageId,
unreadCount = event.unreadCount
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
}
@@ -496,34 +574,43 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList()
val conversationIndex =
newConversations.indexOfFirstOrNull { it.id == event.peerId } ?: return
newConversations.indexOfFirstOrNull { it.id == event.peerId }
val pin = event.majorId > 0
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
newConversations.removeAt(conversationIndex)
if (pin) {
newConversations.add(0, conversation)
if (conversationIndex == null) { // диалога нет в списке
// pizdets
} else {
pinnedCount -= 1
val pin = event.majorId > 0
newConversations.add(conversation)
val conversation = newConversations[conversationIndex].copy(majorId = event.majorId)
val pinnedSubList = newConversations.filter(VkConversation::isPinned)
val unpinnedSubList = newConversations
.filterNot(VkConversation::isPinned)
.sortedByDescending { it.lastMessage?.date }
newConversations.removeAt(conversationIndex)
newConversations.clear()
newConversations += pinnedSubList + unpinnedSubList
}
if (pin) {
newConversations.add(0, conversation)
} else {
pinnedCount -= 1
conversations.update { newConversations }
newConversations.add(conversation)
screenState.setValue { old ->
old.copy(conversations = newConversations.map { it.asPresentation(resources) })
val pinnedSubList = newConversations.filter(VkConversation::isPinned)
val unpinnedSubList = newConversations
.filterNot(VkConversation::isPinned)
.sortedByDescending { it.lastMessage?.date }
newConversations.clear()
newConversations += pinnedSubList + unpinnedSubList
}
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
})
}
}
}
@@ -543,44 +630,53 @@ class ConversationsViewModelImpl(
val newConversations = conversations.value.toMutableList()
val conversationAndIndex =
newConversations.findWithIndex { it.id == peerId } ?: return
newConversations.findWithIndex { it.id == peerId }
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
if (conversationAndIndex == null) { // диалога нет в списке
// pizdets
} else {
newConversations[conversationAndIndex.first] =
conversationAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
conversations.update { newConversations }
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
)
}
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException)
screenState.setValue { old ->
old.copy(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
var timeoutAction: (() -> Unit)? = null
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException)
}
}
val timerJob = createTimerFlow(
time = 5,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
var timeoutAction: (() -> Unit)? = null
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
val timerJob = createTimerFlow(
time = 5,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
interactionsTimers[peerId] = newInteractionJob
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
}
@@ -600,7 +696,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
@@ -629,7 +730,12 @@ class ConversationsViewModelImpl(
conversations.update { newConversations }
screenState.setValue { old ->
old.copy(
conversations = newConversations.map { it.asPresentation(resources) }
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
)
}
)
}
}
@@ -54,7 +54,6 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -65,8 +64,6 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -96,24 +93,11 @@ fun ConversationsRoute(
onConversationPhotoClicked: (url: String) -> Unit,
viewModel: ConversationsViewModel
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isNeedToScrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle()
val imagesToPreload by viewModel.imagesToPreload.collectAsStateWithLifecycle()
LaunchedEffect(imagesToPreload) {
imagesToPreload.forEach { url ->
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.build()
)
}
}
ConversationsScreen(
screenState = screenState,
baseError = baseError,
@@ -33,7 +33,7 @@ import dev.meloda.fast.ui.R as UiR
fun VkConversation.asPresentation(
resources: Resources,
useContactName: Boolean = false
useContactName: Boolean
): UiConversation = UiConversation(
id = id,
lastMessageId = lastMessageId,
+5 -5
View File
@@ -2,8 +2,8 @@
minSdk = "23"
targetSdk = "35"
compileSdk = "35"
versionCode = "8"
versionName = "0.1.5"
versionCode = "9"
versionName = "0.1.6"
agp = "8.7.3"
converterMoshi = "2.11.0"
@@ -13,14 +13,14 @@ kotlin = "2.1.0"
ksp = "2.1.0-1.0.29"
compose-bom = "2024.12.01"
koin = "4.0.0"
koin = "4.0.1"
accompanist = "0.37.0"
coil = "2.7.0"
coroutines = "1.9.0"
coroutines = "1.10.1"
junit = "4.13.2"
chucker = "4.1.0"
guava = "33.3.1-jre"
guava = "33.4.0-jre"
lifecycle = "2.8.7"
core-ktx = "1.15.0"
material = "1.12.0"