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
This commit is contained in:
2026-05-03 05:49:16 +03:00
parent 97c59a85b6
commit df2c61d8d7
51 changed files with 776 additions and 689 deletions
+3
View File
@@ -79,6 +79,9 @@ android {
}
dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
@@ -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
}
}
}
}
@@ -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,
@@ -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
@@ -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<Pair<List<String>, 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)
)
},
)
}
}
}
@@ -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,
@@ -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<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
targetSdk = 36
compileSdk = 36
minSdk = 23
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
}
}
@@ -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<Project> {
override fun apply(target: Project) {
@@ -12,9 +13,14 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false
configureAndroidCompose(extension)
extensions.configure<LibraryExtension> {
configureAndroidCompose(this)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
}
}
}
@@ -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<Project> {
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 36
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
}
}
}
@@ -24,7 +24,7 @@ internal fun Project.configureKotlinAndroid(
}
commonExtension.apply {
compileSdk = 36
compileSdk = getVersionInt("compileSdk")
}
configureKotlin<KotlinAndroidProjectExtension>()
@@ -61,6 +61,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property",
"-Xcontext-parameters"
)
}
}
@@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
fun Project.getVersionInt(alias: String): Int {
return libs.findVersion(alias).get().requiredVersion.toInt()
}
@@ -23,5 +23,6 @@ interface OAuthRepository {
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
}
@@ -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<GetSilentTokenResponse, OAuthErrorDomain> =
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
)
}
@@ -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>(CaptchaTokenResult.Initial)
fun getCaptchaResultFlow(): StateFlow<CaptchaTokenResult> = captchaResult.asStateFlow()
fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result }
private val captchaRedirectUri = MutableStateFlow<String?>(null)
fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow()
fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri }
fun init(preferences: SharedPreferences) {
this.preferences = preferences
}
@@ -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<State<GetSilentTokenResponse>>
}
@@ -48,7 +48,8 @@ class OAuthUseCaseImpl(
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
captchaKey: String?,
successToken: String?
): Flow<State<GetSilentTokenResponse>> = 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)
@@ -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)
@@ -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
@@ -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 }
}
}
@@ -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(
@@ -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,
@@ -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<OkHttpClient>(named("auth")) {
buildHttpClient(true)
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
addInterceptor(get(named("token_interceptor")) as Interceptor)
}
}
.addInterceptor(get<Error14HandlingInterceptor>())
.addInterceptor(get<VersionInterceptor>())
.addInterceptor(get<LanguageInterceptor>())
.addInterceptor(get<ChuckerInterceptor>())
@@ -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<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(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<String>>(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<String> {
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()
+2
View File
@@ -303,4 +303,6 @@
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string>
</resources>
@@ -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()
@@ -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<AuthGraph>(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)
}
}
@@ -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,
)
}
@@ -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<CaptchaScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
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
}
}
@@ -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
}
@@ -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
)
}
}
@@ -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
}
@@ -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<Captcha>()
}
}
fun NavGraphBuilder.captchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha> {
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)
}
@@ -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<CaptchaViewModelImpl>()
) {
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)")
}
}
@@ -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
}
}
}
@@ -59,18 +59,12 @@ class LoginViewModel(
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
val validationArguments = _validationArguments.asStateFlow()
private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
val captchaArguments = _captchaArguments.asStateFlow()
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(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<String?>(null)
private val captchaCode = MutableStateFlow<String?>(null)
private val validationSid = MutableStateFlow<String?>(null)
private val validationCode = MutableStateFlow<String?>(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 -> {
@@ -7,6 +7,5 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImageUrl: String
val redirectUri: String?
) : Parcelable
@@ -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<LoginViewModel>(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"]
}
@@ -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,
@@ -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 }
@@ -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
@@ -19,7 +19,7 @@ interface MessagesHistoryViewModel {
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
val inputFieldFocusRequester: StateFlow<Boolean>
val showKeyboard: StateFlow<Boolean>
val isNeedToScrollToIndex: StateFlow<Int?>
@@ -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
}
@@ -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<MessageDialog?>(null)
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
override val inputFieldFocusRequester = MutableStateFlow(false)
override val showKeyboard = MutableStateFlow(false)
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(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<Long>,
important: Boolean,
@@ -1118,29 +1250,6 @@ class MessagesHistoryViewModelImpl(
}
}
fun editMessage(
originalMessage: VkMessage,
peerid: Long,
messageid: Long,
newText: String? = null,
attachments: List<VkAttachment>? = 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 }
@@ -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,
)
}
}
@@ -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 = {}
)
}
@@ -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(
@@ -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 {
@@ -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),
@@ -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) {
@@ -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) {
+29 -14
View File
@@ -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" }
+1 -1
View File
@@ -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