From df2c61d8d7c819a863b059aa9b4aa60595b9ef35 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 3 May 2026 05:49:16 +0300 Subject: [PATCH] feat(auth): add web captcha handling - replace manual captcha screen with WebView-based VK captcha flow - handle captcha error 14 by showing the captcha overlay and retrying with success_token - pass captcha redirect/result state through AppSettings - remove old captcha ViewModel, navigation, validation, and DI - add ACRA crash reporting - add WIP message edit mode UI/state - update Gradle wrapper, SDK config, and dependencies --- app/build.gradle.kts | 3 + .../dev/meloda/fast/common/AppGlobal.kt | 28 +- .../fast/common/di/ApplicationModule.kt | 8 +- .../dev/meloda/fast/navigation/MainGraph.kt | 2 +- .../meloda/fast/presentation/RootScreen.kt | 20 +- .../service/longpolling/LongPollingService.kt | 6 +- .../AndroidApplicationConventionPlugin.kt | 7 +- .../AndroidLibraryComposeConventionPlugin.kt | 14 +- .../kotlin/AndroidTestConventionPlugin.kt | 7 +- .../kotlin/dev/meloda/fast/KotlinAndroid.kt | 3 +- .../dev/meloda/fast/ProjectExtensions.kt | 5 + .../fast/data/api/oauth/OAuthRepository.kt | 1 + .../data/api/oauth/OAuthRepositoryImpl.kt | 8 +- .../dev/meloda/fast/datastore/AppSettings.kt | 19 + .../dev/meloda/fast/domain/OAuthUseCase.kt | 5 +- .../meloda/fast/domain/OAuthUseCaseImpl.kt | 6 +- .../fast/model/api/data/VkWidgetData.kt | 2 +- .../fast/model/api/domain/VkWidgetDomain.kt | 2 +- .../fast/model/api/requests/OAuthRequest.kt | 4 +- .../meloda/fast/network/OAuthErrorDomain.kt | 3 +- .../fast/network/ResponseConverterFactory.kt | 1 + .../meloda/fast/network/di/NetworkModule.kt | 3 + .../interceptor/Error14HandlingInterceptor.kt | 145 +++++++ core/ui/src/main/res/values/strings.xml | 2 + .../meloda/fast/auth/login/LogoScreenTest.kt | 3 +- .../kotlin/dev/meloda/fast/auth/AuthGraph.kt | 19 - .../kotlin/dev/meloda/fast/auth/AuthModule.kt | 2 - .../fast/auth/captcha/CaptchaViewModel.kt | 69 ---- .../meloda/fast/auth/captcha/di/CaptchaDI.kt | 14 - .../auth/captcha/model/CaptchaScreenState.kt | 16 - .../captcha/model/CaptchaValidationResult.kt | 8 - .../captcha/navigation/CaptchaNavigation.kt | 40 -- .../captcha/presentation/CaptchaScreen.kt | 371 +++++++----------- .../captcha/validation/CaptchaValidator.kt | 14 - .../meloda/fast/auth/login/LoginViewModel.kt | 44 +-- .../fast/auth/login/model/CaptchaArguments.kt | 3 +- .../auth/login/navigation/LoginNavigation.kt | 17 - .../auth/login/presentation/LoginScreen.kt | 12 - .../auth/validation/model/ValidationType.kt | 3 +- .../presentation/ValidationScreen.kt | 1 - .../MessagesHistoryViewModel.kt | 5 +- .../MessagesHistoryViewModelImpl.kt | 220 ++++++++--- .../model/MessagesHistoryScreenState.kt | 4 +- .../messageshistory/presentation/InputBar.kt | 13 +- .../presentation/MessagesHistoryRoute.kt | 6 +- .../presentation/MessagesHistoryScreen.kt | 28 +- .../presentation/MessagesHistoryTopBar.kt | 103 +++-- .../MessagesHistoryTopBarContainer.kt | 15 +- .../presentation/MessagesList.kt | 86 ++-- gradle/libs.versions.toml | 43 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 51 files changed, 776 insertions(+), 689 deletions(-) create mode 100644 core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/CaptchaViewModel.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/di/CaptchaDI.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaScreenState.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaValidationResult.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt delete mode 100644 feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/validation/CaptchaValidator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80f5cee7..ae7d8c66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,9 @@ android { } dependencies { + implementation(libs.acra.email) + implementation(libs.acra.dialog) + implementation(projects.feature.auth) implementation(projects.feature.chatmaterials) diff --git a/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt index 35ebfb66..c98ffcd5 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/AppGlobal.kt @@ -8,6 +8,10 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer import dev.meloda.fast.auth.BuildConfig import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.datastore.AppSettings +import org.acra.config.dialog +import org.acra.config.mailSender +import org.acra.data.StringFormat +import org.acra.ktx.initAcra import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger @@ -20,12 +24,14 @@ class AppGlobal : Application(), ImageLoaderFactory { val preferences = PreferenceManager.getDefaultSharedPreferences(this) AppSettings.init(preferences) + ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) initKoin() - - ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) + initAcra() } + override fun newImageLoader(): ImageLoader = get() + private fun initKoin() { startKoin { androidLogger() @@ -34,5 +40,21 @@ class AppGlobal : Application(), ImageLoaderFactory { } } - override fun newImageLoader(): ImageLoader = get() + private fun initAcra() { + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + + mailSender { + mailTo = "lischenkodev@gmail.com" + reportAsFile = true + reportFileName = "Crash.txt" + } + + dialog { + text = "App crashed" + enabled = true + } + } + } } diff --git a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt index e102184e..99740b4c 100644 --- a/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt +++ b/app/src/main/kotlin/dev/meloda/fast/common/di/ApplicationModule.kt @@ -7,9 +7,7 @@ import androidx.preference.PreferenceManager import coil.ImageLoader import coil.annotation.ExperimentalCoilApi import dev.meloda.fast.MainViewModelImpl -import dev.meloda.fast.auth.captcha.di.captchaModule -import dev.meloda.fast.auth.login.di.loginModule -import dev.meloda.fast.auth.validation.di.validationModule +import dev.meloda.fast.auth.authModule import dev.meloda.fast.chatmaterials.di.chatMaterialsModule import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.LongPollControllerImpl @@ -38,9 +36,7 @@ import org.koin.dsl.module val applicationModule = module { includes(domainModule) includes( - loginModule, - validationModule, - captchaModule, + authModule, convosModule, settingsModule, messagesHistoryModule, diff --git a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt index f1e2622b..84c10c35 100644 --- a/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt +++ b/app/src/main/kotlin/dev/meloda/fast/navigation/MainGraph.kt @@ -8,9 +8,9 @@ import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.presentation.MainScreen import dev.meloda.fast.profile.navigation.Profile +import dev.meloda.fast.ui.R import dev.meloda.fast.ui.util.ImmutableList import kotlinx.serialization.Serializable -import dev.meloda.fast.ui.R @Serializable object MainGraph diff --git a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt index 232ca072..c0ef5bc4 100644 --- a/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt +++ b/app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt @@ -41,6 +41,7 @@ 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 @@ -48,6 +49,8 @@ import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.convos.navigation.createChatScreen import dev.meloda.fast.convos.navigation.navigateToCreateChat +import dev.meloda.fast.datastore.AppSettings +import dev.meloda.fast.datastore.CaptchaTokenResult import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker @@ -310,6 +313,9 @@ fun RootScreen( mutableStateOf, Int?>?>(null) } + val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow() + .collectAsStateWithLifecycle() + Box(modifier = Modifier.fillMaxSize()) { NavHost( navController = navController, @@ -334,7 +340,7 @@ fun RootScreen( photoViewerInfo = listOf(url) to null }, onMessageClicked = navController::navigateToMessagesHistory, - onNavigateToCreateChat = navController::navigateToCreateChat + onNavigateToCreateChat = navController::navigateToCreateChat, ) messagesHistoryScreen( @@ -381,6 +387,18 @@ fun RootScreen( }, onDismiss = { photoViewerInfo = null } ) + + CaptchaScreen( + captchaRedirectUri = captchaRedirectUri, + onBack = { + AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled) + }, + onResult = { result -> + AppSettings.setCaptchaResult( + CaptchaTokenResult.Success(result) + ) + }, + ) } } } diff --git a/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt b/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt index d6bf0dce..2bb3684e 100644 --- a/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt +++ b/app/src/main/kotlin/dev/meloda/fast/service/longpolling/LongPollingService.kt @@ -32,10 +32,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.koin.android.ext.android.inject import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlin.time.Duration.Companion.seconds class LongPollingService : Service() { @@ -204,7 +204,7 @@ class LongPollingService : Service() { } } - private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine { + private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine { longPollUseCase.getLongPollServer( needPts = true, version = VkConstants.LP_VERSION @@ -224,7 +224,7 @@ class LongPollingService : Service() { private suspend fun getUpdatesResponse( server: VkLongPollData - ): LongPollUpdates? = suspendCoroutine { + ): LongPollUpdates? = suspendCancellableCoroutine { longPollUseCase.getLongPollUpdates( serverUrl = "https://${server.server}", key = server.key, diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 618bb8c8..dab1dac8 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,5 +1,6 @@ import com.android.build.api.dsl.ApplicationExtension import dev.meloda.fast.configureKotlinAndroid +import dev.meloda.fast.getVersionInt import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -14,9 +15,9 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig { - targetSdk = 36 - compileSdk = 36 - minSdk = 23 + minSdk = getVersionInt("minSdk") + compileSdk = getVersionInt("compileSdk") + targetSdk = getVersionInt("targetSdk") } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index b409db82..7c698035 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -1,9 +1,10 @@ import com.android.build.api.dsl.LibraryExtension import dev.meloda.fast.configureAndroidCompose +import dev.meloda.fast.getVersionInt import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.apply -import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.configure class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) { @@ -12,9 +13,14 @@ class AndroidLibraryComposeConventionPlugin : Plugin { apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "com.github.skydoves.compose.stability.analyzer") - val extension = extensions.getByType() - extension.androidResources.enable = false - configureAndroidCompose(extension) + extensions.configure { + configureAndroidCompose(this) + androidResources.enable = false + defaultConfig { + minSdk = getVersionInt("minSdk") + compileSdk = getVersionInt("compileSdk") + } + } } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt index 22c0a9cc..04f0b263 100644 --- a/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt @@ -1,5 +1,6 @@ import com.android.build.api.dsl.TestExtension import dev.meloda.fast.configureKotlinAndroid +import dev.meloda.fast.getVersionInt import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -13,7 +14,11 @@ class AndroidTestConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.targetSdk = 36 + defaultConfig { + minSdk = getVersionInt("minSdk") + compileSdk = getVersionInt("compileSdk") + targetSdk = getVersionInt("targetSdk") + } } } } diff --git a/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt index 5664cb9e..71840190 100644 --- a/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt @@ -24,7 +24,7 @@ internal fun Project.configureKotlinAndroid( } commonExtension.apply { - compileSdk = 36 + compileSdk = getVersionInt("compileSdk") } configureKotlin() @@ -61,6 +61,7 @@ private inline fun Project.configureKotlin() = "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.FlowPreview", "-Xannotation-default-target=param-property", + "-Xcontext-parameters" ) } } diff --git a/build-logic/convention/src/main/kotlin/dev/meloda/fast/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/dev/meloda/fast/ProjectExtensions.kt index 6775041d..6a5a5a29 100644 --- a/build-logic/convention/src/main/kotlin/dev/meloda/fast/ProjectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/dev/meloda/fast/ProjectExtensions.kt @@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType val Project.libs get(): VersionCatalog = extensions.getByType().named("libs") + + +fun Project.getVersionInt(alias: String): Int { + return libs.findVersion(alias).get().requiredVersion.toInt() +} diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt index 8cc3c7a1..e010880e 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepository.kt @@ -23,5 +23,6 @@ interface OAuthRepository { validationCode: String?, captchaSid: String?, captchaKey: String?, + successToken: String? ): ApiResult } diff --git a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt index 4e22ae69..084e3a1d 100644 --- a/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/meloda/fast/data/api/oauth/OAuthRepositoryImpl.kt @@ -79,7 +79,8 @@ class OAuthRepositoryImpl( VkOAuthError.NEED_CAPTCHA -> { OAuthErrorDomain.CaptchaRequiredError( captchaSid = response.captchaSid.orEmpty(), - captchaImageUrl = response.captchaImage.orEmpty() + captchaImageUrl = response.captchaImage.orEmpty(), + redirectUri = response.redirectUri ) } @@ -122,6 +123,7 @@ class OAuthRepositoryImpl( validationCode: String?, captchaSid: String?, captchaKey: String?, + successToken: String? ): ApiResult = withContext(Dispatchers.IO) { val requestModel = AuthDirectRequest( @@ -135,6 +137,7 @@ class OAuthRepositoryImpl( validationCode = validationCode, captchaSid = captchaSid, captchaKey = captchaKey, + successToken = successToken ) oAuthService.getSilentToken(requestModel.map).mapResult( @@ -175,7 +178,8 @@ class OAuthRepositoryImpl( VkOAuthError.NEED_CAPTCHA -> { OAuthErrorDomain.CaptchaRequiredError( captchaSid = response.captchaSid.orEmpty(), - captchaImageUrl = response.captchaImage.orEmpty() + captchaImageUrl = response.captchaImage.orEmpty(), + redirectUri = response.redirectUri ) } diff --git a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt index ab7f7a1d..9eb39856 100644 --- a/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt +++ b/core/datastore/src/main/kotlin/dev/meloda/fast/datastore/AppSettings.kt @@ -4,13 +4,32 @@ import android.content.SharedPreferences import androidx.core.content.edit import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.LogLevel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlin.properties.Delegates import kotlin.reflect.KClass +sealed class CaptchaTokenResult { + data object Initial : CaptchaTokenResult() + data object Null : CaptchaTokenResult() + data object Cancelled : CaptchaTokenResult() + data class Success(val token: String) : CaptchaTokenResult() +} + object AppSettings { private var preferences: SharedPreferences by Delegates.notNull() + private val captchaResult = MutableStateFlow(CaptchaTokenResult.Initial) + fun getCaptchaResultFlow(): StateFlow = captchaResult.asStateFlow() + fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result } + + private val captchaRedirectUri = MutableStateFlow(null) + fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow() + fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri } + fun init(preferences: SharedPreferences) { this.preferences = preferences } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt index 5a9d6b6f..bb0308d7 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCase.kt @@ -21,7 +21,8 @@ interface OAuthUseCase { password: String, forceSms: Boolean, validationCode: String?, - captchaSid: String?, - captchaKey: String? + captchaSid: String? = null, + captchaKey: String? = null, + successToken: String? = null ): Flow> } diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt index e6e3da62..e848e5a6 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt @@ -48,7 +48,8 @@ class OAuthUseCaseImpl( forceSms: Boolean, validationCode: String?, captchaSid: String?, - captchaKey: String? + captchaKey: String?, + successToken: String? ): Flow> = flow { emit(State.Loading) @@ -58,7 +59,8 @@ class OAuthUseCaseImpl( forceSms = forceSms, validationCode = validationCode, captchaSid = captchaSid, - captchaKey = captchaKey + captchaKey = captchaKey, + successToken = successToken ).asState() emit(newState) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt index f4cb5ab6..7d31e650 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/data/VkWidgetData.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class VkWidgetData( - val id: Long + val id: Long? ) : VkAttachmentData { fun toDomain() = VkWidgetDomain(id) diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt index 8543c4f9..c9246bf5 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkWidgetDomain.kt @@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain import dev.meloda.fast.model.api.data.AttachmentType data class VkWidgetDomain( - val id: Long + val id: Long? ) : VkAttachment { override val type: AttachmentType = AttachmentType.WIDGET diff --git a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt index 0721239d..cd548a61 100644 --- a/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt +++ b/core/model/src/main/kotlin/dev/meloda/fast/model/api/requests/OAuthRequest.kt @@ -12,7 +12,8 @@ data class AuthDirectRequest( val validationCode: String? = null, val captchaSid: String? = null, val captchaKey: String? = null, - val trustedHash: String? = null + val trustedHash: String? = null, + val successToken: String? = null ) { val map @@ -31,6 +32,7 @@ data class AuthDirectRequest( captchaSid?.let { this["captcha_sid"] = it } captchaKey?.let { this["captcha_key"] = it } trustedHash?.let { this["trusted_hash"] = it } + successToken?.let { this["success_token"] = it } } } diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/OAuthErrorDomain.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/OAuthErrorDomain.kt index 61537aea..7454fd65 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/OAuthErrorDomain.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/OAuthErrorDomain.kt @@ -16,7 +16,8 @@ sealed class OAuthErrorDomain { data class CaptchaRequiredError( val captchaSid: String, - val captchaImageUrl: String + val captchaImageUrl: String, + val redirectUri: String? ) : OAuthErrorDomain() data class UserBannedError( diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt index af40f0f3..07f562f9 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt @@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter }, onFailure = { failure -> if (failure is JsonDataException) { + Log.d("ResponseBodyConverter", "convertJsonDataException: $failure") throw ApiException( RestApiError( errorCode = -1, diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt index 746c2d2b..f2264ed3 100644 --- a/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/di/NetworkModule.kt @@ -11,6 +11,7 @@ import dev.meloda.fast.network.JsonConverter import dev.meloda.fast.network.MoshiConverter import dev.meloda.fast.network.OAuthResultCallFactory import dev.meloda.fast.network.ResponseConverterFactory +import dev.meloda.fast.network.interceptor.Error14HandlingInterceptor import dev.meloda.fast.network.interceptor.LanguageInterceptor import dev.meloda.fast.network.interceptor.VersionInterceptor import dev.meloda.fast.network.service.account.AccountService @@ -45,6 +46,7 @@ val networkModule = module { single { ChuckerInterceptor.Builder(get()).collector(get()).build() } singleOf(::VersionInterceptor) singleOf(::LanguageInterceptor) + singleOf(::Error14HandlingInterceptor) single(named("auth")) { buildHttpClient(true) @@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient { addInterceptor(get(named("token_interceptor")) as Interceptor) } } + .addInterceptor(get()) .addInterceptor(get()) .addInterceptor(get()) .addInterceptor(get()) diff --git a/core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt b/core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt new file mode 100644 index 00000000..8e54710b --- /dev/null +++ b/core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt @@ -0,0 +1,145 @@ +package dev.meloda.fast.network.interceptor + +import android.util.Log +import dev.meloda.fast.common.extensions.listenValue +import dev.meloda.fast.datastore.AppSettings +import dev.meloda.fast.datastore.CaptchaTokenResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.json.JSONObject +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +class Error14HandlingInterceptor( +// private val domains: Set = emptySet(), +) : Interceptor { + + private val cookie = AtomicReference(null) + + private companion object { + private const val CAPTCHA_ERROR_CODE = 14 + private const val CAPTCHA_ERROR_KIND = "need_captcha" + private val executor = Executors.newSingleThreadExecutor() + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().withCookie() + val response = chain.proceed(request) + response.parseCookie() + if (request.shouldSkipCaptcha()) return response + val redirectUri = response.getRedirectUri() ?: return response + val token = passCaptchaAndGetToken(redirectUri) + return chain.proceed(chain.request().withCookie().withSuccessToken(token)) + } + + private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) { + val tokenResult = AtomicReference>(Result.failure(Exception("No result"))) + + executor.submit { + AppSettings.setCaptchaRedirectUri(redirectUri) + Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri") + + var job: Job? = null + job = AppSettings.getCaptchaResultFlow() + .listenValue(CoroutineScope(Dispatchers.IO)) { + Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it") + if (it != CaptchaTokenResult.Initial) { + synchronized(tokenResult) { + Log.d( + "Error14Interceptor", + "passCaptchaAndGetToken: SYNCHRONIZED: $it" + ) + tokenResult.set(wrapResult(it)) + tokenResult.notifyAll() + job?.cancel() + Log.d( + "Error14Interceptor", + "passCaptchaAndGetToken: NULL RESULT" + ) + AppSettings.setCaptchaResult(CaptchaTokenResult.Initial) + AppSettings.setCaptchaRedirectUri(null) + } + } + } + } + synchronized(tokenResult) { + if (tokenResult.get().getOrNull() == null) { + tokenResult.wait() + } + + Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE") + tokenResult.get().getOrThrow() + } + } + + private fun wrapResult(result: CaptchaTokenResult): Result { + return when (result) { + // TODO: 03/05/2026, Danil Nikolaev: check again? + CaptchaTokenResult.Null -> Result.success("") + + CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("") + + is CaptchaTokenResult.Success -> Result.success(result.token) + } + } + + private fun Request.withSuccessToken(token: String): Request { + return newBuilder() + .url(url.newBuilder().addQueryParameter("success_token", token).build()) + .build() + } + + private fun Response.getRedirectUri(): String? { + val responseBody = JSONObject(peekBody(Long.MAX_VALUE).string()) + return if (responseBody.has("error")) { + val stringError = try { + responseBody.getString("error") + } catch (ignored: Exception) { + null + } + + if (stringError != null) { + if (stringError == CAPTCHA_ERROR_KIND && responseBody.has("redirect_uri")) { + responseBody.getString("redirect_uri") + } else { + null + } + } else { + val error = responseBody.getJSONObject("error") + if (error.getInt("error_code") == CAPTCHA_ERROR_CODE) { + error.getString("redirect_uri") + } else { + null + } + } + } else { + null + } + } + + private fun Request.shouldSkipCaptcha(): Boolean { + return false +// return !domains.contains(url.toUrl().host) && domains.isNotEmpty() + } + + private fun Response.parseCookie() { + headers("Set-Cookie").firstOrNull { it.contains("remixstlid") }?.let(cookie::set) + } + + private fun Request.withCookie(): Request { + return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build() + } +} + +@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") +private inline fun Any.wait() = (this as Object).wait() + +@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") +private inline fun Any.notify() = (this as Object).notify() + +@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE") +private inline fun Any.notifyAll() = (this as Object).notifyAll() diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 6ff96f7b..fa25f3d9 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -303,4 +303,6 @@ Are you sure you want to create chat «%s»? Are you sure you want to create chat «%s» only with yourself? + + Edit message diff --git a/feature/auth/src/androidTest/kotlin/dev/meloda/fast/auth/login/LogoScreenTest.kt b/feature/auth/src/androidTest/kotlin/dev/meloda/fast/auth/login/LogoScreenTest.kt index 053293a7..e56acfd7 100644 --- a/feature/auth/src/androidTest/kotlin/dev/meloda/fast/auth/login/LogoScreenTest.kt +++ b/feature/auth/src/androidTest/kotlin/dev/meloda/fast/auth/login/LogoScreenTest.kt @@ -3,7 +3,6 @@ package dev.meloda.fast.auth.login import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import dev.meloda.fast.auth.login.presentation.LogoScreen import org.junit.Rule import org.junit.Test @@ -15,7 +14,7 @@ class LogoScreenTest { @Test fun goNextButton_isClickable() { composeTestRule.setContent { - LogoScreen() + } composeTestRule.onNodeWithTag(testTag = "go_next_fab").assertHasClickAction() diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt index 20f6785d..5c60f92c 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthGraph.kt @@ -3,9 +3,6 @@ package dev.meloda.fast.auth import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.navigation -import dev.meloda.fast.auth.captcha.navigation.captchaScreen -import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha -import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult import dev.meloda.fast.auth.login.navigation.Login import dev.meloda.fast.auth.login.navigation.loginScreen import dev.meloda.fast.auth.userbanned.model.UserBannedArguments @@ -28,11 +25,6 @@ fun NavGraphBuilder.authNavGraph( ) { navigation(startDestination = Login) { loginScreen( - onNavigateToCaptcha = { arguments -> - navController.navigateToCaptcha( - captchaImageUrl = URLEncoder.encode(arguments.captchaImageUrl, "utf-8") - ) - }, onNavigateToValidation = { arguments -> navController.navigateToValidation( ValidationArguments( @@ -70,17 +62,6 @@ fun NavGraphBuilder.authNavGraph( } ) - captchaScreen( - onBack = { - navController.setCaptchaResult(null) - navController.navigateUp() - }, - onResult = { code -> - navController.setCaptchaResult(code) - navController.popBackStack() - } - ) - userBannedRoute(onBack = navController::navigateUp) } } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthModule.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthModule.kt index 92adc9c9..9b401ca9 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthModule.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/AuthModule.kt @@ -1,6 +1,5 @@ package dev.meloda.fast.auth -import dev.meloda.fast.auth.captcha.di.captchaModule import dev.meloda.fast.auth.validation.di.validationModule import dev.meloda.fast.auth.login.di.loginModule import org.koin.dsl.module @@ -9,6 +8,5 @@ val authModule = module { includes( loginModule, validationModule, - captchaModule, ) } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/CaptchaViewModel.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/CaptchaViewModel.kt deleted file mode 100644 index fa5188f8..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/CaptchaViewModel.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.meloda.fast.auth.captcha - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import dev.meloda.fast.auth.captcha.model.CaptchaScreenState -import dev.meloda.fast.auth.captcha.navigation.Captcha -import dev.meloda.fast.auth.captcha.validation.CaptchaValidator -import dev.meloda.fast.common.extensions.setValue -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import java.net.URLDecoder - -interface CaptchaViewModel { - val screenState: StateFlow - val isNeedToOpenLogin: StateFlow - - fun onCodeInputChanged(newCode: String) - - fun onTextFieldDoneAction() - fun onDoneButtonClicked() - - fun onNavigatedToLogin() -} - -class CaptchaViewModelImpl( - private val validator: CaptchaValidator, - savedStateHandle: SavedStateHandle -) : CaptchaViewModel, ViewModel() { - - override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY) - override val isNeedToOpenLogin = MutableStateFlow(false) - - - init { - val captchaImage = Captcha.from(savedStateHandle).captchaImageUrl - - screenState.setValue { old -> - old.copy(captchaImageUrl = URLDecoder.decode(captchaImage, "utf-8")) - } - } - - override fun onCodeInputChanged(newCode: String) { - val newState = screenState.value.copy(code = newCode.trim()) - screenState.update { newState } - processValidation() - } - - override fun onTextFieldDoneAction() { - onDoneButtonClicked() - } - - override fun onDoneButtonClicked() { - if (!processValidation()) return - - isNeedToOpenLogin.update { true } - } - - override fun onNavigatedToLogin() { - screenState.update { CaptchaScreenState.EMPTY } - isNeedToOpenLogin.update { false } - } - - private fun processValidation(): Boolean { - val isValid = validator.validate(screenState.value).isValid() - screenState.setValue { old -> old.copy(codeError = !isValid) } - return isValid - } -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/di/CaptchaDI.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/di/CaptchaDI.kt deleted file mode 100644 index 3ea7203f..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/di/CaptchaDI.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.meloda.fast.auth.captcha.di - -import dev.meloda.fast.auth.captcha.CaptchaViewModel -import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl -import dev.meloda.fast.auth.captcha.validation.CaptchaValidator -import org.koin.core.module.dsl.singleOf -import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.bind -import org.koin.dsl.module - -val captchaModule = module { - singleOf(::CaptchaValidator) - viewModelOf(::CaptchaViewModelImpl) bind CaptchaViewModel::class -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaScreenState.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaScreenState.kt deleted file mode 100644 index 93e59754..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaScreenState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.meloda.fast.auth.captcha.model - -data class CaptchaScreenState( - val captchaImageUrl: String, - val code: String, - val codeError: Boolean -) { - - companion object { - val EMPTY = CaptchaScreenState( - captchaImageUrl = "", - code = "", - codeError = false - ) - } -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaValidationResult.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaValidationResult.kt deleted file mode 100644 index 84b4e0af..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/model/CaptchaValidationResult.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.meloda.fast.auth.captcha.model - -sealed class CaptchaValidationResult { - data object Empty : CaptchaValidationResult() - data object Valid : CaptchaValidationResult() - - fun isValid() = this == Valid -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt deleted file mode 100644 index abfd7d12..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/navigation/CaptchaNavigation.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.meloda.fast.auth.captcha.navigation - -import androidx.lifecycle.SavedStateHandle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import dev.meloda.fast.auth.captcha.presentation.CaptchaRoute -import kotlinx.serialization.Serializable - -@Serializable -data class Captcha(val captchaImageUrl: String) { - - companion object { - fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute() - } -} - - -fun NavGraphBuilder.captchaScreen( - onBack: () -> Unit, - onResult: (String) -> Unit -) { - composable { - CaptchaRoute( - onBack = onBack, - onResult = onResult - ) - } -} - -fun NavController.navigateToCaptcha(captchaImageUrl: String) { - this.navigate(Captcha(captchaImageUrl)) -} - -fun NavController.setCaptchaResult(code: String?) { - this.previousBackStackEntry - ?.savedStateHandle - ?.set("captcha_code", code) -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt index 3e30bee8..0eb41f42 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/presentation/CaptchaScreen.kt @@ -1,33 +1,22 @@ package dev.meloda.fast.auth.captcha.presentation +import android.graphics.Bitmap +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.union -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,237 +26,169 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner -import coil.compose.AsyncImage -import dev.meloda.fast.auth.captcha.CaptchaViewModel -import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl -import dev.meloda.fast.auth.captcha.model.CaptchaScreenState +import androidx.compose.ui.viewinterop.AndroidView import dev.meloda.fast.ui.R -import dev.meloda.fast.ui.common.FastPreview import dev.meloda.fast.ui.components.ActionInvokeDismiss +import dev.meloda.fast.ui.components.FullScreenDialog import dev.meloda.fast.ui.components.MaterialDialog -import dev.meloda.fast.ui.components.TextFieldErrorText -import dev.meloda.fast.ui.theme.AppTheme -import org.koin.androidx.compose.koinViewModel +import org.json.JSONObject -@Composable -fun CaptchaRoute( - onBack: () -> Unit, - onResult: (String) -> Unit, - viewModel: CaptchaViewModel = koinViewModel() -) { - LocalViewModelStoreOwner.current - val screenState by viewModel.screenState.collectAsStateWithLifecycle() - val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle() - - LaunchedEffect(isNeedToOpenLogin) { - if (isNeedToOpenLogin) { - viewModel.onNavigatedToLogin() - onResult(screenState.code) - } - } - - CaptchaScreen( - screenState = screenState, - onBack = onBack, - onCodeInputChanged = viewModel::onCodeInputChanged, - onTextFieldDoneAction = viewModel::onTextFieldDoneAction, - onDoneButtonClicked = viewModel::onDoneButtonClicked - ) -} +private const val TAG = "CaptchaScreen" @Composable fun CaptchaScreen( - screenState: CaptchaScreenState = CaptchaScreenState.EMPTY, + captchaRedirectUri: String?, onBack: () -> Unit = {}, - onCodeInputChanged: (String) -> Unit = {}, - onTextFieldDoneAction: () -> Unit = {}, - onDoneButtonClicked: () -> Unit = {} + onResult: (String) -> Unit = {} ) { - var confirmedExit by remember { - mutableStateOf(false) - } - - var showExitAlert by rememberSaveable { - mutableStateOf(false) - } - - LaunchedEffect(confirmedExit) { - if (confirmedExit) { - onBack() + if (captchaRedirectUri != null) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(true) { + focusManager.clearFocus(true) + keyboardController?.hide() } - } - BackHandler(enabled = !confirmedExit) { - if (!confirmedExit) { - showExitAlert = true + var confirmedExit by remember { + mutableStateOf(false) } - } - if (showExitAlert) { - MaterialDialog( - onDismissRequest = { showExitAlert = false }, - title = stringResource(id = R.string.warning_confirmation), - text = stringResource(id = R.string.captcha_exit_warning), - confirmAction = { confirmedExit = true }, - confirmText = stringResource(id = R.string.yes), - cancelText = stringResource(id = R.string.no), - actionInvokeDismiss = ActionInvokeDismiss.Always - ) - } + var showExitAlert by rememberSaveable { + mutableStateOf(false) + } - val focusManager = LocalFocusManager.current + var isWebViewLoading by remember { + mutableStateOf(true) + } - Scaffold( - contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime) - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(30.dp), - verticalArrangement = Arrangement.SpaceBetween - ) { - ExtendedFloatingActionButton( - onClick = onBack, - text = { - Text( - text = "Cancel", - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.ic_close_round_24), - contentDescription = "Close icon", - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - ) + LaunchedEffect(confirmedExit) { + if (confirmedExit) { + onBack() + } + } - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "Captcha", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground + BackHandler(enabled = !confirmedExit) { + if (!confirmedExit) { + showExitAlert = true + } + } + + FullScreenDialog(onDismiss = { showExitAlert = true }) { + if (showExitAlert) { + MaterialDialog( + onDismissRequest = { showExitAlert = false }, + title = stringResource(id = R.string.warning_confirmation), + text = stringResource(id = R.string.captcha_exit_warning), + confirmAction = { confirmedExit = true }, + confirmText = stringResource(id = R.string.yes), + cancelText = stringResource(id = R.string.no), + actionInvokeDismiss = ActionInvokeDismiss.Always ) - Spacer(modifier = Modifier.height(38.dp)) - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "To proceed with your action, enter a code from the picture", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.weight(0.5f) + } + + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { showExitAlert = true } ) - Spacer(modifier = Modifier.width(24.dp)) - - val imageModifier = Modifier - .border( - 2.dp, - MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(10.dp) - ) - .clip(RoundedCornerShape(10.dp)) - .height(48.dp) - .width(130.dp) - - if (LocalView.current.isInEditMode) { - Image( - painter = painterResource(id = R.drawable.img_test_captcha), - contentDescription = "Captcha image", - modifier = imageModifier - ) - } else { - AsyncImage( - model = screenState.captchaImageUrl, - contentDescription = "Captcha image", - contentScale = ContentScale.FillBounds, - modifier = imageModifier - ) - } - } - - Spacer(modifier = Modifier.height(30.dp)) - - var code by remember { mutableStateOf(TextFieldValue(screenState.code)) } - val showError = screenState.codeError - - TextField( - value = code, - onValueChange = { newText -> - code = newText - onCodeInputChanged(newText.text) - }, - label = { Text(text = "Code") }, - placeholder = { Text(text = "Code") }, + ) { + AndroidView( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)), - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.ic_qr_code_round_24), - contentDescription = "QR code icon", - tint = if (showError) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary + .fillMaxSize() + .align(Alignment.BottomCenter), + factory = { context -> + val webview = WebView(context) + webview.setBackgroundColor(0) + webview.settings.javaScriptEnabled = true + webview.webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + Log.i(TAG, "shouldOverrideUrlLoading: $request") + return false + } + + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap? + ) { + super.onPageStarted(view, url, favicon) + isWebViewLoading = true + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + isWebViewLoading = false } - ) - }, - shape = RoundedCornerShape(10.dp), - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - onTextFieldDoneAction() } - ), - isError = showError + webview.addJavascriptInterface( + WebCaptchaListener( + onSuccessTokenReceived = { + val response: String? = try { + JSONObject(it).getString("token") + } catch (e: Exception) { + e.printStackTrace() + null + } + + if (response != null) { + onResult(response) + } else { + // TODO: 03/05/2026, Danil Nikolaev: show error + } + }, + onCloseRequested = { showExitAlert = true } + ), + "AndroidBridge" + ) +// webview.loadUrl("https://id.vk.ru/not_robot_captcha?variant=block&session_token=test&domain=test.com") + webview.loadUrl(captchaRedirectUri) + webview + } ) - AnimatedVisibility(visible = showError) { - TextFieldErrorText(text = "Field must not be empty") + AnimatedVisibility( + visible = isWebViewLoading, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + CircularProgressIndicator( + modifier = Modifier.size(50.dp), + color = Color.White.copy(alpha = 0.85f) + ) } } - - FloatingActionButton( - onClick = onDoneButtonClicked, - containerColor = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Icon( - painter = painterResource(R.drawable.ic_check_round_24), - contentDescription = "Done icon", - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } } } } -@FastPreview -@Composable -private fun CaptchaScreenPreview() { - AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) { - CaptchaScreen( - screenState = CaptchaScreenState.EMPTY.copy( - code = "zcuecz" - ) - ) +class WebCaptchaListener( + private val onSuccessTokenReceived: (String) -> Unit, + private val onCloseRequested: (String) -> Unit +) { + private val tag = "WebCaptchaListener" + + @JavascriptInterface + fun VKCaptchaGetResult(arg: String) { + onSuccessTokenReceived(arg) + Log.i(tag, "VKCaptchaGetResult($arg)") + } + + @JavascriptInterface + fun VKCaptchaCloseCaptcha(arg: String) { + onCloseRequested(arg) + Log.i(tag, "VKCaptchaCloseCaptcha($arg)") } } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/validation/CaptchaValidator.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/validation/CaptchaValidator.kt deleted file mode 100644 index 8a3ffedd..00000000 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/captcha/validation/CaptchaValidator.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.meloda.fast.auth.captcha.validation - -import dev.meloda.fast.auth.captcha.model.CaptchaScreenState -import dev.meloda.fast.auth.captcha.model.CaptchaValidationResult - -class CaptchaValidator { - - fun validate(screenState: CaptchaScreenState): CaptchaValidationResult { - return when { - screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty - else -> CaptchaValidationResult.Valid - } - } -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt index f3ba6563..b1c15789 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt @@ -59,18 +59,12 @@ class LoginViewModel( private val _validationArguments = MutableStateFlow(null) val validationArguments = _validationArguments.asStateFlow() - private val _captchaArguments = MutableStateFlow(null) - val captchaArguments = _captchaArguments.asStateFlow() - private val _userBannedArguments = MutableStateFlow(null) val userBannedArguments = _userBannedArguments.asStateFlow() private val _isNeedToOpenMain = MutableStateFlow(false) val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow() - private val _isNeedToClearCaptchaCode = MutableStateFlow(false) - val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow() - private val _isNeedToClearValidationCode = MutableStateFlow(false) val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow() @@ -78,17 +72,10 @@ class LoginViewModel( screenState.map(loginValidator::validate) .stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty)) - private val captchaSid = MutableStateFlow(null) - private val captchaCode = MutableStateFlow(null) private val validationSid = MutableStateFlow(null) private val validationCode = MutableStateFlow(null) init { - captchaCode.listenValue(viewModelScope) { - if (it != null) { - login() - } - } validationCode.listenValue(viewModelScope) { if (it != null) { login() @@ -165,10 +152,6 @@ class LoginViewModel( _userBannedArguments.update { null } } - fun onNavigatedToCaptcha() { - _captchaArguments.update { null } - } - fun onNavigatedToValidation() { _validationArguments.update { null } } @@ -181,25 +164,9 @@ class LoginViewModel( _isNeedToClearValidationCode.update { false } } - fun onCaptchaCodeReceived(code: String?) { - captchaCode.update { code } - } - - fun onCaptchaCodeCleared() { - _isNeedToClearCaptchaCode.update { false } - } - private fun login(forceSms: Boolean = false) { val currentState = screenState.value.copy() - Log.d( - "LoginViewModel", - "auth: login: ${currentState.login}; " + - "password: ${currentState.password}; " + - "2fa code: ${validationCode.value}; " + - "captcha code: ${captchaCode.value}" - ) - processValidation() if (!validationState.value.contains(LoginValidationResult.Valid)) return @@ -207,23 +174,18 @@ class LoginViewModel( val currentValidationSid = validationSid.value val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null } - val currentCaptchaSid = captchaSid.value - val currentCaptchaCode = captchaCode.value?.takeIf { currentCaptchaSid != null } oAuthUseCase.getSilentToken( login = currentState.login, password = currentState.password, forceSms = forceSms, validationCode = currentValidationCode, - captchaSid = currentCaptchaSid, - captchaKey = currentCaptchaCode ).listenValue(viewModelScope) { state -> state.processState( error = { error -> Log.d("LoginViewModelImpl", "login: error: $error") _screenState.updateValue { copy(isLoading = false) } - captchaSid.setValue { null } parseError(error) }, @@ -286,7 +248,6 @@ class LoginViewModel( startLongPoll() - captchaSid.update { null } validationSid.update { null } loadUserByIdUseCase( @@ -333,11 +294,8 @@ class LoginViewModel( is OAuthErrorDomain.CaptchaRequiredError -> { val arguments = CaptchaArguments( - captchaSid = error.captchaSid, - captchaImageUrl = error.captchaImageUrl + redirectUri = error.redirectUri ) - _captchaArguments.update { arguments } - captchaSid.update { error.captchaSid } } OAuthErrorDomain.InvalidCredentialsError -> { diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/CaptchaArguments.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/CaptchaArguments.kt index 2737ed7d..eef93d82 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/CaptchaArguments.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/model/CaptchaArguments.kt @@ -7,6 +7,5 @@ import kotlinx.serialization.Serializable @Serializable @Parcelize data class CaptchaArguments( - val captchaSid: String, - val captchaImageUrl: String + val redirectUri: String? ) : Parcelable diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt index ae2df718..d6b49658 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/navigation/LoginNavigation.kt @@ -8,7 +8,6 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import dev.meloda.fast.auth.login.LoginViewModel -import dev.meloda.fast.auth.login.model.CaptchaArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.presentation.LoginRoute @@ -19,7 +18,6 @@ import kotlinx.serialization.Serializable object Login fun NavGraphBuilder.loginScreen( - onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToMain: () -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, @@ -31,7 +29,6 @@ fun NavGraphBuilder.loginScreen( backStackEntry.sharedViewModel(navController = navController) val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle() - val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle() LaunchedEffect(clearValidationCode) { if (clearValidationCode) { @@ -40,24 +37,14 @@ fun NavGraphBuilder.loginScreen( } } - LaunchedEffect(clearCaptchaCode) { - if (clearCaptchaCode) { - backStackEntry.savedStateHandle["captcha_code"] = null - viewModel.onCaptchaCodeCleared() - } - } - val validationCode = backStackEntry.getValidationResult() - val captchaCode = backStackEntry.getCaptchaResult() LoginRoute( onNavigateToUserBanned = onNavigateToUserBanned, onNavigateToMain = onNavigateToMain, - onNavigateToCaptcha = onNavigateToCaptcha, onNavigateToValidation = onNavigateToValidation, onNavigateToSettings = onNavigateToSettings, validationCode = validationCode, - captchaCode = captchaCode, viewModel = viewModel ) } @@ -66,7 +53,3 @@ fun NavGraphBuilder.loginScreen( fun NavBackStackEntry.getValidationResult(): String? { return savedStateHandle["validation_code"] } - -fun NavBackStackEntry.getCaptchaResult(): String? { - return savedStateHandle["captcha_code"] -} diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt index b9859905..df3725ca 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/presentation/LoginScreen.kt @@ -75,17 +75,14 @@ import org.koin.androidx.compose.koinViewModel fun LoginRoute( onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToMain: () -> Unit, - onNavigateToCaptcha: (CaptchaArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToSettings: () -> Unit, validationCode: String?, - captchaCode: String?, viewModel: LoginViewModel = koinViewModel() ) { val screenState by viewModel.screenState.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle() - val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle() val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle() val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle() @@ -107,12 +104,6 @@ fun LoginRoute( onNavigateToUserBanned(arguments) } } - LaunchedEffect(captchaArguments) { - captchaArguments?.let { arguments -> - viewModel.onNavigatedToCaptcha() - onNavigateToCaptcha(arguments) - } - } LaunchedEffect(validationArguments) { validationArguments?.let { arguments -> viewModel.onNavigatedToValidation() @@ -122,9 +113,6 @@ fun LoginRoute( LaunchedEffect(validationCode) { viewModel.onValidationCodeReceived(validationCode) } - LaunchedEffect(captchaCode) { - viewModel.onCaptchaCodeReceived(captchaCode) - } LoginScreen( screenState = screenState, diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/model/ValidationType.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/model/ValidationType.kt index fa0404d4..f6e22c0f 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/model/ValidationType.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/model/ValidationType.kt @@ -1,7 +1,8 @@ package dev.meloda.fast.auth.validation.model enum class ValidationType(val value: String) { - SMS("sms"), APP("2fa_app"); + SMS("2fa_sms"), + APP("2fa_app"); companion object { fun parse(value: String): ValidationType = entries.firstOrNull { it.value == value } diff --git a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt index 9f1342eb..ae45faf2 100644 --- a/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt +++ b/feature/auth/src/main/kotlin/dev/meloda/fast/auth/validation/presentation/ValidationScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.meloda.fast.auth.validation.ValidationViewModel diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt index c87d25d8..621c640a 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModel.kt @@ -19,7 +19,7 @@ interface MessagesHistoryViewModel { val dialog: StateFlow val selectedMessages: StateFlow> - val inputFieldFocusRequester: StateFlow + val showKeyboard: StateFlow val isNeedToScrollToIndex: StateFlow @@ -54,6 +54,7 @@ interface MessagesHistoryViewModel { fun onPinnedMessageClicked(messageId: Long) fun onUnpinMessageClicked() + fun onEditSelectedMessageClicked() fun onDeleteSelectedMessagesClicked() fun onBoldClicked() @@ -66,5 +67,7 @@ interface MessagesHistoryViewModel { fun onRequestReplyToMessage(cmId: Long) + fun onKeyboardShown() + suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt index 8a4d72f9..0293deee 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt @@ -45,6 +45,7 @@ import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractReplySummary +import dev.meloda.fast.domain.util.extractReplyTitle import dev.meloda.fast.domain.util.extractTitle import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.MessageDialog @@ -55,7 +56,6 @@ import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.api.domain.FormatDataType -import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.network.VkErrorCode @@ -65,6 +65,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonArray @@ -73,7 +74,6 @@ import kotlinx.serialization.json.put import java.io.File import java.io.FileOutputStream import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlin.math.abs import kotlin.random.Random @@ -94,7 +94,7 @@ class MessagesHistoryViewModelImpl( override val dialog = MutableStateFlow(null) override val selectedMessages = MutableStateFlow>(emptyList()) - override val inputFieldFocusRequester = MutableStateFlow(false) + override val showKeyboard = MutableStateFlow(false) override val isNeedToScrollToIndex = MutableStateFlow(null) @@ -115,6 +115,8 @@ class MessagesHistoryViewModelImpl( private var replyToCmId: Long? = null + private var editMessage: VkMessage? = null + init { val arguments = MessagesHistory.from(savedStateHandle).arguments @@ -229,7 +231,7 @@ class MessagesHistoryViewModelImpl( override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) { when (dialog) { is MessageDialog.MessageOptions -> { - val messageId = bundle.getLong("messageId") +// val messageId = bundle.getLong("messageId") val cmId = bundle.getLong("cmId") when (val option = bundle.getParcelableCompat("option", MessageOption::class)) { @@ -289,7 +291,10 @@ class MessagesHistoryViewModelImpl( } } - MessageOption.Edit -> {} + MessageOption.Edit -> { + editMessage(cmId) + syncUiMessages() + } MessageOption.Delete -> { this.dialog.setValue { @@ -313,7 +318,14 @@ class MessagesHistoryViewModelImpl( } override fun onCloseButtonClicked() { - selectedMessages.setValue { emptyList() } + if (selectedMessages.value.isNotEmpty()) { + selectedMessages.setValue { emptyList() } + } + + if (screenState.value.editCmId != null) { + stopEditMessage() + } + syncUiMessages() } @@ -329,8 +341,20 @@ class MessagesHistoryViewModelImpl( screenState.setValue { old -> old.copy( message = newText, - actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO - else ActionMode.SEND + actionMode = + when { + screenState.value.editCmId != null -> { + // TODO: 13/03/2026, Danil Nikolaev: also check if attachments is empty + if (newText.text.trim().isEmpty()) { + ActionMode.DELETE + } else { + ActionMode.EDIT + } + } + + newText.text.trim().isEmpty() -> ActionMode.RECORD_AUDIO + else -> ActionMode.SEND + } ) } updateStyles() @@ -347,13 +371,9 @@ class MessagesHistoryViewModelImpl( override fun onActionButtonClicked() { when (screenState.value.actionMode) { - ActionMode.DELETE -> { + ActionMode.DELETE -> confirmDeleteCurrentEditMessage() - } - - ActionMode.EDIT -> { - - } + ActionMode.EDIT -> editCurrentEditMessage() ActionMode.RECORD_AUDIO -> { screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) } @@ -429,6 +449,16 @@ class MessagesHistoryViewModelImpl( } } + override fun onEditSelectedMessageClicked() { + val cmId = selectedMessages.value.firstOrNull()?.cmId ?: return + + selectedMessages.setValue { emptyList() } + + editMessage(cmId) + + syncUiMessages() + } + override fun onDeleteSelectedMessagesClicked() { dialog.setValue { MessageDialog.MessagesDelete(selectedMessages.value) @@ -438,7 +468,7 @@ class MessagesHistoryViewModelImpl( private fun replyToMessage(cmId: Long) { val messageToReply = messages.value.find { it.cmId == cmId } ?: return - inputFieldFocusRequester.setValue { true } + showKeyboard.setValue { true } replyToCmId = cmId screenState.setValue { old -> old.copy( @@ -448,6 +478,56 @@ class MessagesHistoryViewModelImpl( } } + private fun editMessage(cmId: Long) { + this.screenState.setValue { old -> + old.copy(editCmId = cmId) + } + + val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return + editMessage = messageToEdit + + lastMessageText = screenState.value.message.text + + var newState = screenState.value.copy( + message = TextFieldValue( + text = messageToEdit.text.orEmpty(), + selection = TextRange(messageToEdit.text.orEmpty().length) + ), + actionMode = ActionMode.EDIT + ) + + messageToEdit.replyMessage?.let { reply -> + replyToCmId = reply.cmId + newState = newState.copy( + replyTitle = reply.extractReplyTitle(), + replyText = reply.extractReplySummary(resourceProvider.resources) + ) + } + + showKeyboard.setValue { true } + screenState.setValue { newState } + } + + private fun stopEditMessage() { + val lastText = lastMessageText.orEmpty().trim() + + screenState.setValue { old -> + old.copy( + editCmId = null, + message = TextFieldValue( + text = lastText, + selection = TextRange(lastText.length) + ), + actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO + else ActionMode.SEND, + + // TODO: 13/03/2026, Danil Nikolaev: use last reply + replyTitle = null, + replyText = null + ) + } + } + private var formatData = VkMessage.FormatData("1", emptyList()) private fun updateStyles() { @@ -580,23 +660,28 @@ class MessagesHistoryViewModelImpl( replyToMessage(cmId) } - override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = suspendCoroutine { - viewModelScope.launch { - getMessageReadPeersUseCase - .invoke(peerId = peerId, cmId = cmId) - .listenValue(viewModelScope) { state -> - state.processState( - error = { error -> - it.resume(-1) - }, - success = { count -> - it.resume(count) - } - ) - } - } + override fun onKeyboardShown() { + showKeyboard.setValue { false } } + override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = + suspendCancellableCoroutine { + viewModelScope.launch { + getMessageReadPeersUseCase + .invoke(peerId = peerId, cmId = cmId) + .listenValue(viewModelScope) { state -> + state.processState( + error = { error -> + it.resume(-1) + }, + success = { count -> + it.resume(count) + } + ) + } + } + } + private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { val message = event.message @@ -988,11 +1073,13 @@ class MessagesHistoryViewModelImpl( message = newMessage.text, forward = forward, attachments = null, - formatData = newMessage.formatData + formatData = newMessage.formatData, ).listenValue(viewModelScope) { state -> state.processState( any = { sendingMessages.remove(newMessage) }, error = { error -> + Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error") + val failedId = -500_000L - failedMessages.size val newFailedMessage = newMessage.copy(id = failedId) failedMessages += newFailedMessage @@ -1015,6 +1102,51 @@ class MessagesHistoryViewModelImpl( } } + private fun confirmDeleteCurrentEditMessage() { + val currentMessage = editMessage ?: return + + this.dialog.setValue { + MessageDialog.MessageDelete(currentMessage) + } + } + + private fun editCurrentEditMessage() { + replyToCmId = null + + val newText = screenState.value.message.text + + val lastText = lastMessageText.orEmpty().trim() + + screenState.setValue { old -> + old.copy( + editCmId = null, + message = TextFieldValue( + text = lastText, + selection = TextRange(lastText.length) + ), + actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO + else ActionMode.SEND, + + // TODO: 13/03/2026, Danil Nikolaev: save last reply + replyTitle = null, + replyText = null + ) + } + + syncUiMessages() + + // TODO: 13/03/2026, Danil Nikolaev: actually edit message + + val newMessage = editMessage?.copy( + replyMessage = if (replyToCmId == null) null else editMessage?.replyMessage, + text = newText + ) ?: return + + // TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit + + Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage") + } + private fun markAsImportant( messageIds: List, important: Boolean, @@ -1118,29 +1250,6 @@ class MessagesHistoryViewModelImpl( } } - fun editMessage( - originalMessage: VkMessage, - peerid: Long, - messageid: Long, - newText: String? = null, - attachments: List? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { -// sendRequest { -// messagesRepository.edit( -// MessagesEditRequest( -// peerId = peerId, -// messageId = messageId, -// message = newText, -// attachments = attachments -// ) -// ) -// } ?: return@launch - - // TODO: 25.08.2023, Danil Nikolaev: update message - } - } - private fun readMessage(message: VkMessage) { messagesUseCase.markAsRead( peerId = screenState.value.convoId, @@ -1237,7 +1346,8 @@ class MessagesHistoryViewModelImpl( nextMessage = messages.getOrNull(index - 1), showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages, convo = screenState.value.convo, - isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null + isSelected = screenState.value.editCmId == message.cmId || + selectedMessages.indexOfFirstOrNull { it.id == message.id } != null ) } uiMessages.setValue { newUiMessages } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt index 55f7ab03..6352b30d 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/model/MessagesHistoryScreenState.kt @@ -26,7 +26,8 @@ data class MessagesHistoryScreenState( val pinnedTitle: String?, val pinnedSummary: AnnotatedString?, val replyTitle: String?, - val replyText: AnnotatedString? + val replyText: AnnotatedString?, + val editCmId: Long?, ) { companion object { @@ -48,6 +49,7 @@ data class MessagesHistoryScreenState( pinnedSummary = null, replyTitle = null, replyText = null, + editCmId = null, ) } } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/InputBar.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/InputBar.kt index 3df61efd..34034d31 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/InputBar.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/InputBar.kt @@ -81,7 +81,7 @@ fun InputBar( actionMode: ActionMode, replyTitle: String?, replyText: AnnotatedString?, - inputFieldFocusRequester: Boolean, + showKeyboard: Boolean, onMessageInputChanged: (TextFieldValue) -> Unit = {}, onBoldRequested: () -> Unit = {}, onItalicRequested: () -> Unit = {}, @@ -92,7 +92,8 @@ fun InputBar( onEmojiButtonLongClicked: () -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {}, - onReplyCloseClicked: () -> Unit = {} + onReplyCloseClicked: () -> Unit = {}, + onKeyboardShown: () -> Unit ) { val view = LocalView.current val context = LocalContext.current @@ -106,8 +107,9 @@ fun InputBar( val focusRequester = remember { FocusRequester() } - LaunchedEffect(inputFieldFocusRequester) { - if (inputFieldFocusRequester) { + LaunchedEffect(showKeyboard) { + if (showKeyboard) { + onKeyboardShown() focusRequester.requestFocus() } } @@ -360,6 +362,7 @@ private fun InputBarPreview() { actionMode = ActionMode.SEND, replyTitle = "Иннокентий Панфилович", replyText = "Ого, ром!".annotated(), - inputFieldFocusRequester = false + showKeyboard = false, + onKeyboardShown = {} ) } diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt index 6c7b9312..06fcb09c 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryRoute.kt @@ -29,7 +29,7 @@ fun MessagesHistoryRoute( val baseError by viewModel.baseError.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() - val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle() + val showKeyboard by viewModel.showKeyboard.collectAsStateWithLifecycle() LaunchedEffect(navigationEvent) { val needToConsume = when (val navigation = navigationEvent) { @@ -55,7 +55,7 @@ fun MessagesHistoryRoute( canPaginate = canPaginate, showEmojiButton = AppSettings.General.showEmojiButton, showAttachmentButton = AppSettings.General.showAttachmentButton, - inputFieldFocusRequester = inputFieldFocusRequester, + showKeyboard = showKeyboard, onBack = onBack, onClose = viewModel::onCloseButtonClicked, onScrolledToIndex = viewModel::onScrolledToIndex, @@ -72,6 +72,7 @@ fun MessagesHistoryRoute( onPhotoClicked = onNavigateToPhotoViewer, onPinnedMessageClicked = viewModel::onPinnedMessageClicked, onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, + onEditSelectedMessageClicked = viewModel::onEditSelectedMessageClicked, onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked, onBoldRequested = viewModel::onBoldClicked, onItalicRequested = viewModel::onItalicClicked, @@ -80,6 +81,7 @@ fun MessagesHistoryRoute( onRegularRequested = viewModel::onRegularClicked, onReplyCloseClicked = viewModel::onReplyCloseClicked, onRequestReplyToMessage = viewModel::onRequestReplyToMessage, + onKeyboardShown = viewModel::onKeyboardShown ) HandleDialogs( diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt index 782ce70c..28bc2ed6 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryScreen.kt @@ -31,12 +31,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.data.UserConfig import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.domain.util.indexOfMessageByCmId @@ -69,13 +71,14 @@ fun MessagesHistoryScreen( canPaginate: Boolean = false, showEmojiButton: Boolean = false, showAttachmentButton: Boolean = false, - inputFieldFocusRequester: Boolean, + showKeyboard: Boolean, onBack: () -> Unit = {}, onClose: () -> Unit = {}, onScrolledToIndex: () -> Unit = {}, onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onTopBarClicked: () -> Unit = {}, onRefresh: () -> Unit = {}, + onEditSelectedMessageClicked: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {}, @@ -93,7 +96,8 @@ fun MessagesHistoryScreen( onUnderlineRequested: () -> Unit = {}, onRegularRequested: () -> Unit = {}, onReplyCloseClicked: () -> Unit = {}, - onRequestReplyToMessage: (cmId: Long) -> Unit = {} + onRequestReplyToMessage: (cmId: Long) -> Unit = {}, + onKeyboardShown: () -> Unit ) { val context = LocalContext.current val view = LocalView.current @@ -114,7 +118,7 @@ fun MessagesHistoryScreen( } BackHandler( - enabled = selectedMessages.isNotEmpty(), + enabled = selectedMessages.isNotEmpty() || screenState.editCmId != null, onBack = onClose ) @@ -162,6 +166,9 @@ fun MessagesHistoryScreen( derivedStateOf { selectedMessages.size == 1 } } + val isLoadingText = stringResource(R.string.title_loading) + val editMessageText = stringResource(R.string.title_edit_message) + Scaffold( modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.statusBars, @@ -169,7 +176,8 @@ fun MessagesHistoryScreen( val topBarTitle by remember(screenState, selectedMessages) { derivedStateOf { when { - screenState.isLoading -> context.getString(R.string.title_loading) + screenState.isLoading -> isLoadingText + screenState.editCmId != null -> editMessageText selectedMessages.isNotEmpty() -> "(${selectedMessages.size})" else -> screenState.title } @@ -179,13 +187,16 @@ fun MessagesHistoryScreen( MessagesHistoryTopBarContainer( hazeState = hazeState, showReplyAction = showReplyAction, + showEditAction = selectedMessages.size == 1, topBarContainerColor = topBarContainerColor, topBarContainerColorAlpha = topBarContainerColorAlpha, isClickable = !(screenState.isLoading && messages.isEmpty()), isMessagesSelecting = selectedMessages.isNotEmpty(), isPeerAccount = screenState.convoId == UserConfig.userId, - avatar = screenState.avatar, + avatarUrl = screenState.avatar.takeIf { it is UiImage.Url }?.extractUrl(), + avatarResourceId = screenState.avatar.takeIf { it is UiImage.Resource }?.extractResId(), title = topBarTitle, + isEditing = screenState.editCmId != null, showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(), showPinnedContainer = !screenState.isLoading && pinnedMessage != null, pinnedMessage = pinnedMessage, @@ -196,6 +207,7 @@ fun MessagesHistoryScreen( onBack = onBack, onClose = onClose, onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked, + onEditSelectedMessageClicked = onEditSelectedMessageClicked, onRefresh = onRefresh, onPinnedMessageClicked = onPinnedMessageClicked, onUnpinMessageButtonClicked = onUnpinMessageButtonClicked @@ -211,6 +223,7 @@ fun MessagesHistoryScreen( ) { MessagesList( modifier = Modifier.align(Alignment.BottomStart), + screenState = screenState, hazeState = hazeState, listState = listState, hasPinnedMessage = pinnedMessage != null, @@ -259,12 +272,13 @@ fun MessagesHistoryScreen( actionMode = screenState.actionMode, replyTitle = screenState.replyTitle, replyText = screenState.replyText, - inputFieldFocusRequester = inputFieldFocusRequester, + showKeyboard = showKeyboard, onSetMessageBarHeight = { messageBarHeight = it }, onEmojiButtonLongClicked = onEmojiButtonLongClicked, onAttachmentButtonClicked = onAttachmentButtonClicked, onActionButtonClicked = onActionButtonClicked, - onReplyCloseClicked = onReplyCloseClicked + onReplyCloseClicked = onReplyCloseClicked, + onKeyboardShown = onKeyboardShown ) when { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBar.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBar.kt index 39f101df..5866d2c4 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBar.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBar.kt @@ -2,6 +2,7 @@ package dev.meloda.fast.messageshistory.presentation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,7 +32,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -44,11 +44,9 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials -import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.ui.R import dev.meloda.fast.ui.theme.LocalThemeConfig -import dev.meloda.fast.ui.util.getImage @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable @@ -56,16 +54,20 @@ fun MessagesHistoryTopBar( modifier: Modifier = Modifier, hazeState: HazeState, showReplyAction: Boolean, + showEditAction: Boolean, isClickable: Boolean, isMessagesSelecting: Boolean, isPeerAccount: Boolean, - avatar: UiImage, + avatarUrl: String?, + avatarResourceId: Int?, title: String, + isEditing: Boolean, onTopBarClicked: () -> Unit = {}, onBack: () -> Unit = {}, onClose: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {}, - onRefresh: () -> Unit = {} + onRefresh: () -> Unit = {}, + onEditSelectedMessageClicked: () -> Unit = {} ) { val view = LocalView.current val theme = LocalThemeConfig.current @@ -96,50 +98,55 @@ fun MessagesHistoryTopBar( // modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - if (!isMessagesSelecting) { - if (isPeerAccount) { - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) { - Icon( - modifier = Modifier - .align(Alignment.Center) - .size(24.dp), - painter = painterResource(id = R.drawable.ic_bookmark_round_24), - contentDescription = "Favorites icon", - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } else { - val actualAvatar = avatar.getImage() - - if (actualAvatar is Painter) { - Image( - painter = actualAvatar, - contentDescription = null, + AnimatedVisibility(!isMessagesSelecting && !isEditing) { + Row { + if (isPeerAccount) { + Box( modifier = Modifier .size(36.dp) .clip(CircleShape) - ) + .background(MaterialTheme.colorScheme.primary) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(24.dp), + painter = painterResource(id = R.drawable.ic_bookmark_round_24), + contentDescription = "Favorites icon", + tint = MaterialTheme.colorScheme.onPrimary + ) + } } else { - AsyncImage( - model = actualAvatar, - contentDescription = "Profile Image", - modifier = Modifier - .size(36.dp) - .clip(CircleShape), - placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24), - ) - } - } + when { + avatarUrl != null -> { + AsyncImage( + model = avatarUrl, + contentDescription = "Profile Image", + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24), + ) + } - Spacer(modifier = Modifier.width(12.dp)) + avatarResourceId != null -> { + Image( + painter = painterResource(avatarResourceId), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + ) + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + } } Text( + modifier = Modifier.animateContentSize(), text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -150,11 +157,11 @@ fun MessagesHistoryTopBar( navigationIcon = { IconButton( onClick = { - if (!isMessagesSelecting) onBack() + if (!isMessagesSelecting && !isEditing) onBack() else onClose() } ) { - Crossfade(targetState = !isMessagesSelecting) { state -> + Crossfade(targetState = !isMessagesSelecting && !isEditing) { state -> Icon( painter = painterResource( if (state) { @@ -210,6 +217,16 @@ fun MessagesHistoryTopBar( contentDescription = null ) } + + AnimatedVisibility(showEditAction) { + IconButton(onClick = onEditSelectedMessageClicked) { + Icon( + painter = painterResource(R.drawable.ic_edit_round_24), + contentDescription = null + ) + } + } + IconButton(onClick = onDeleteSelectedButtonClicked) { Icon( painter = painterResource(R.drawable.ic_delete_round_24), diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBarContainer.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBarContainer.kt index b0e78a54..dcf35887 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBarContainer.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesHistoryTopBarContainer.kt @@ -16,7 +16,6 @@ import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.meloda.fast.common.extensions.orDots -import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.ui.theme.LocalThemeConfig @@ -26,13 +25,16 @@ fun MessagesHistoryTopBarContainer( modifier: Modifier = Modifier, hazeState: HazeState, showReplyAction: Boolean, + showEditAction: Boolean, topBarContainerColor: Color, topBarContainerColorAlpha: Float, isClickable: Boolean, isMessagesSelecting: Boolean, isPeerAccount: Boolean, - avatar: UiImage, + avatarUrl: String?, + avatarResourceId: Int?, title: String, + isEditing: Boolean, showHorizontalProgressBar: Boolean, showPinnedContainer: Boolean, pinnedMessage: VkMessage?, @@ -44,6 +46,7 @@ fun MessagesHistoryTopBarContainer( onClose: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {}, onRefresh: () -> Unit = {}, + onEditSelectedMessageClicked: () -> Unit = {}, onPinnedMessageClicked: (Long) -> Unit = {}, onUnpinMessageButtonClicked: () -> Unit = {} ) { @@ -66,16 +69,20 @@ fun MessagesHistoryTopBarContainer( modifier = modifier, hazeState = hazeState, showReplyAction = showReplyAction, + showEditAction = showEditAction, isClickable = isClickable, isMessagesSelecting = isMessagesSelecting, isPeerAccount = isPeerAccount, - avatar = avatar, + avatarUrl = avatarUrl, + avatarResourceId = avatarResourceId, title = title, + isEditing = isEditing, onTopBarClicked = onTopBarClicked, onBack = onBack, onClose = onClose, onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked, - onRefresh = onRefresh + onRefresh = onRefresh, + onEditSelectedMessageClicked = onEditSelectedMessageClicked ) if (showHorizontalProgressBar) { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt index 574a31a7..979c2533 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt @@ -46,6 +46,7 @@ import androidx.core.view.HapticFeedbackConstantsCompat import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.meloda.fast.datastore.AppSettings +import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkFileDomain import dev.meloda.fast.model.api.domain.VkLinkDomain @@ -60,6 +61,7 @@ import kotlinx.coroutines.launch @Composable fun MessagesList( modifier: Modifier = Modifier, + screenState: MessagesHistoryScreenState, hasPinnedMessage: Boolean, hazeState: HazeState, listState: LazyListState, @@ -226,47 +228,55 @@ fun MessagesList( fadeOutSpec = null ) else Modifier ) - .combinedClickable( - onLongClick = { - if (AppSettings.General.enableHaptic) { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - onMessageLongClicked(item.id) - }, - onClick = { onMessageClicked(item.id) } - ) - .pointerInput(item.cmId) { - detectHorizontalDragGestures( - onDragCancel = { - if (offsetX == -100f) { - onRequestMessageReply(item.cmId) - } + .then( + if (screenState.editCmId == null) { + Modifier + .combinedClickable( + onLongClick = { + if (AppSettings.General.enableHaptic) { + view.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS + ) + } + onMessageLongClicked(item.id) + }, + onClick = { onMessageClicked(item.id) } + ) + .pointerInput(item.cmId) { + detectHorizontalDragGestures( + onDragCancel = { + if (offsetX == -100f) { + onRequestMessageReply(item.cmId) + } - scope.launch { - animate = true - offsetX = 0f - offsetAnimatable.animateTo(0f) - animate = false - } - }, - onDragEnd = { - if (offsetX == -100f) { - onRequestMessageReply(item.cmId) - } + scope.launch { + animate = true + offsetX = 0f + offsetAnimatable.animateTo(0f) + animate = false + } + }, + onDragEnd = { + if (offsetX == -100f) { + onRequestMessageReply(item.cmId) + } - scope.launch { - animate = true - offsetX = 0f - offsetAnimatable.animateTo(0f) - animate = false + scope.launch { + animate = true + offsetX = 0f + offsetAnimatable.animateTo(0f) + animate = false + } + }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + offsetX = + (offsetX + dragAmount).coerceIn(-100f, 0f) + } + ) } - }, - onHorizontalDrag = { change, dragAmount -> - change.consume() - offsetX = (offsetX + dragAmount).coerceIn(-100f, 0f) - } - ) - }, + } else Modifier + ), color = backgroundColor ) { if (item.isOut) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49ba5235..fe8efa2f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,35 +1,50 @@ [versions] -agp = "9.0.0" +#noinspection UnusedVersionCatalogEntry +minSdk = "23" +#noinspection UnusedVersionCatalogEntry +compileSdk = "37" +#noinspection UnusedVersionCatalogEntry +targetSdk = "37" + +agp = "9.2.0" retrofit = "3.0.0" eithernet = "2.0.0" -haze = "1.7.1" -kotlin = "2.3.10" -ksp = "2.3.4" -moduleGraph = "2.9.0" -versions = "0.53.0" -stability-analyzer = "0.6.6" +haze = "1.7.2" +kotlin = "2.3.21" +ksp = "2.3.7" +moduleGraph = "2.9.1" +versions = "0.54.0" +stability-analyzer = "0.7.4" -compose-bom = "2026.01.01" -koin = "4.1.1" +compose-bom = "2026.04.01" +koin = "4.2.1" accompanist = "0.37.3" coil = "2.7.0" coroutines = "1.10.2" junit = "4.13.2" -chucker = "4.3.0" -guava = "33.5.0-jre" +chucker = "4.3.1" +guava = "33.6.0-jre" lifecycle = "2.10.0" -core-ktx = "1.17.0" +core-ktx = "1.18.0" material = "1.13.0" loggingInterceptor = "5.3.2" moshi = "1.15.2" room = "2.8.4" preference-ktx = "1.2.1" nanokt = "1.3.0" -androidx-navigation = "2.9.6" -serialization = "1.10.0" +androidx-navigation = "2.9.8" +serialization = "1.11.0" + +acra = "5.13.1" +okhttp = "5.3.2" [libraries] +acra-email = { module = "ch.acra:acra-mail", version.ref = "acra" } +acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } + +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } + accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 19a6bdeb..1a704683 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME