forked from melod1n/fast-messenger
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:
@@ -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>())
|
||||
|
||||
+145
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-8
@@ -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
|
||||
}
|
||||
-40
@@ -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)
|
||||
}
|
||||
+146
-225
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
-14
@@ -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,
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+165
-55
@@ -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 }
|
||||
|
||||
+3
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
+21
-7
@@ -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 {
|
||||
|
||||
+60
-43
@@ -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),
|
||||
|
||||
+11
-4
@@ -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) {
|
||||
|
||||
+48
-38
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user