6 Commits

Author SHA1 Message Date
melod1n abfe25d051 fix signing 2026-05-22 17:46:56 +03:00
melod1n 574b230b26 feat: add channel message support and refactor UI components
- Implement `VkChannelMessage` domain and data models for channel message attachments
- Add `CHANNEL_MESSAGE` to `AttachmentType` and map it to relevant UI resources and strings
- Refactor `Sticker` and `Gift` composables to accept URL strings instead of domain objects for better decoupling
- Simplify `AppTheme` by removing redundant color animations and passing the color scheme directly to `MaterialExpressiveTheme`
- Update `LoginScreen` to include a theme toggle (classic vs. light) and improve back-button behavior by resetting error states
- Bump VK API version from 5.238 to 5.263
- Adjust layout and padding for sticker and gift attachment previews
2026-05-19 22:54:44 +03:00
melod1n b31c0f30c5 build: update gradle wrapper and build logic conventions 2026-05-19 13:28:10 +03:00
melod1n cb653eddc2 refactor(auth): reuse network ValidationType for validation flow
Remove duplicate auth ValidationType and use the shared network model instead.
Add support for both "sms" and "2fa_sms" SMS validation values.
2026-05-03 06:53:56 +03:00
melod1n df2c61d8d7 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
2026-05-03 05:49:16 +03:00
dependabot[bot] 97c59a85b6 Chore(deps): Bump actions/upload-artifact from 6 to 7 (#252)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 21:01:31 +03:00
70 changed files with 940 additions and 786 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name - name: Upload APK with original name
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
@@ -56,7 +56,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name - name: Upload APK with original name
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: ${{ env.APK_NAME }} name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Upload release APK - name: Upload release APK
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: app-release.apk name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk path: app/build/outputs/apk/release/app-release.apk
@@ -43,7 +43,7 @@ jobs:
run: ./gradlew bundleRelease run: ./gradlew bundleRelease
- name: Upload release Bundle - name: Upload release Bundle
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: app-release.aab name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab path: app/build/outputs/bundle/release/app-release.aab
+4 -1
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
named("release") { named("release") {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -79,6 +79,9 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) 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.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings 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.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@@ -20,12 +24,14 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences) AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra()
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
} }
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() { private fun initKoin() {
startKoin { startKoin {
androidLogger() 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.ImageLoader
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule import dev.meloda.fast.auth.authModule
import dev.meloda.fast.auth.login.di.loginModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl import dev.meloda.fast.common.LongPollControllerImpl
@@ -38,9 +36,7 @@ import org.koin.dsl.module
val applicationModule = module { val applicationModule = module {
includes(domainModule) includes(domainModule)
includes( includes(
loginModule, authModule,
validationModule,
captchaModule,
convosModule, convosModule,
settingsModule, settingsModule,
messagesHistoryModule, messagesHistoryModule,
@@ -8,9 +8,9 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.presentation.MainScreen import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R
@Serializable @Serializable
object MainGraph object MainGraph
@@ -41,6 +41,7 @@ import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials 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.common.model.LongPollState
import dev.meloda.fast.convos.navigation.createChatScreen import dev.meloda.fast.convos.navigation.createChatScreen
import dev.meloda.fast.convos.navigation.navigateToCreateChat 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.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
@@ -310,6 +313,9 @@ fun RootScreen(
mutableStateOf<Pair<List<String>, Int?>?>(null) mutableStateOf<Pair<List<String>, Int?>?>(null)
} }
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -334,7 +340,7 @@ fun RootScreen(
photoViewerInfo = listOf(url) to null photoViewerInfo = listOf(url) to null
}, },
onMessageClicked = navController::navigateToMessagesHistory, onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat onNavigateToCreateChat = navController::navigateToCreateChat,
) )
messagesHistoryScreen( messagesHistoryScreen(
@@ -381,6 +387,18 @@ fun RootScreen(
}, },
onDismiss = { photoViewerInfo = null } 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.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() { class LongPollingService : Service() {
@@ -204,7 +204,7 @@ class LongPollingService : Service() {
} }
} }
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine { private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
longPollUseCase.getLongPollServer( longPollUseCase.getLongPollServer(
needPts = true, needPts = true,
version = VkConstants.LP_VERSION version = VkConstants.LP_VERSION
@@ -224,7 +224,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse( private suspend fun getUpdatesResponse(
server: VkLongPollData server: VkLongPollData
): LongPollUpdates? = suspendCoroutine { ): LongPollUpdates? = suspendCancellableCoroutine {
longPollUseCase.getLongPollUpdates( longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}", serverUrl = "https://${server.server}",
key = server.key, key = server.key,
@@ -1,5 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -14,9 +15,12 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig { defaultConfig {
targetSdk = 36 minSdk = getVersionInt("minSdk")
compileSdk = 36 compileSdk = getVersionInt("compileSdk")
minSdk = 23 targetSdk = getVersionInt("targetSdk")
}
lint {
abortOnError = false
} }
} }
} }
@@ -1,9 +1,10 @@
import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.LibraryExtension
import dev.meloda.fast.configureAndroidCompose import dev.meloda.fast.configureAndroidCompose
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.configure
class AndroidLibraryComposeConventionPlugin : Plugin<Project> { class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
@@ -12,9 +13,14 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = "org.jetbrains.kotlin.plugin.compose") apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer") apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>() extensions.configure<LibraryExtension> {
extension.androidResources.enable = false configureAndroidCompose(this)
configureAndroidCompose(extension) androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
} }
} }
} }
@@ -2,6 +2,7 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -20,7 +21,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
@@ -1,5 +1,6 @@
import com.android.build.api.dsl.TestExtension import com.android.build.api.dsl.TestExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -13,7 +14,11 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) 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 { commonExtension.apply {
compileSdk = 36 compileSdk = getVersionInt("compileSdk")
} }
configureKotlin<KotlinAndroidProjectExtension>() configureKotlin<KotlinAndroidProjectExtension>()
@@ -61,6 +61,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property", "-Xannotation-default-target=param-property",
"-Xcontext-parameters"
) )
} }
} }
@@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType
val Project.libs val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs") get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
fun Project.getVersionInt(alias: String): Int {
return libs.findVersion(alias).get().requiredVersion.toInt()
}
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238" const val API_VERSION = "5.263"
const val URL_OAUTH = "https://oauth.vk.ru" const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.ru/method"
@@ -23,5 +23,6 @@ interface OAuthRepository {
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
} }
@@ -79,7 +79,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty() captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
) )
} }
@@ -122,6 +123,7 @@ class OAuthRepositoryImpl(
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String?, captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> = ): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest( val requestModel = AuthDirectRequest(
@@ -135,6 +137,7 @@ class OAuthRepositoryImpl(
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey, captchaKey = captchaKey,
successToken = successToken
) )
oAuthService.getSilentToken(requestModel.map).mapResult( oAuthService.getSilentToken(requestModel.map).mapResult(
@@ -175,7 +178,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> { VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError( OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(), 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 androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel 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.properties.Delegates
import kotlin.reflect.KClass 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 { object AppSettings {
private var preferences: SharedPreferences by Delegates.notNull() 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) { fun init(preferences: SharedPreferences) {
this.preferences = preferences this.preferences = preferences
} }
@@ -21,7 +21,8 @@ interface OAuthUseCase {
password: String, password: String,
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String? = null,
captchaKey: String? captchaKey: String? = null,
successToken: String? = null
): Flow<State<GetSilentTokenResponse>> ): Flow<State<GetSilentTokenResponse>>
} }
@@ -48,7 +48,8 @@ class OAuthUseCaseImpl(
forceSms: Boolean, forceSms: Boolean,
validationCode: String?, validationCode: String?,
captchaSid: String?, captchaSid: String?,
captchaKey: String? captchaKey: String?,
successToken: String?
): Flow<State<GetSilentTokenResponse>> = flow { ): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading) emit(State.Loading)
@@ -58,7 +59,8 @@ class OAuthUseCaseImpl(
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey captchaKey = captchaKey,
successToken = successToken
).asState() ).asState()
emit(newState) emit(newState)
@@ -598,6 +598,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24 AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -687,6 +688,7 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource) }.let(UiText::Resource)
} }
@@ -30,7 +30,8 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"), ARTICLE("article"),
VIDEO_MESSAGE("video_message"), VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"), GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview") STICKER_PACK_PREVIEW("sticker_pack_preview"),
CHANNEL_MESSAGE("channel_message")
; ;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -35,7 +35,8 @@ data class VkAttachmentItemData(
@Json(name = "article") val article: VkArticleData?, @Json(name = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?, @Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, @Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?,
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData? @Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?,
@Json(name = "channel_message") val channelMessageData: VkChannelMessageData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -66,5 +67,6 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment } ?: VkUnknownAttachment
} }
@@ -0,0 +1,40 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkChannelMessage
@JsonClass(generateAdapter = true)
data class VkChannelMessageData(
@Json(name = "channel_id") val channelId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "author_id") val authorId: Long,
@Json(name = "channel_info") val channelInfo: ChannelInfo,
@Json(name = "channel_type") val channelType: String,
@Json(name = "guid") val guid: String,
@Json(name = "text") val text: String?,
@Json(name = "time") val time: Long,
@Json(name = "attachments") val attachments: List<VkAttachmentItemData> = emptyList(),
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
data class ChannelInfo(
@Json(name = "photo_base") val photoBase: String?,
@Json(name = "title") val title: String
)
fun toDomain(): VkChannelMessage = VkChannelMessage(
channelId = channelId,
cmId = cmId,
authorId = authorId,
channelInfo = VkChannelMessage.ChannelInfo(
title = channelInfo.title,
photoBase = channelInfo.photoBase
),
channelType = channelType,
guid = guid,
text = text,
time = time,
attachments = attachments.map(VkAttachmentItemData::toDomain),
)
}
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class VkWidgetData( data class VkWidgetData(
val id: Long val id: Long?
) : VkAttachmentData { ) : VkAttachmentData {
fun toDomain() = VkWidgetDomain(id) fun toDomain() = VkWidgetDomain(id)
@@ -0,0 +1,23 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkChannelMessage(
val channelId: Long,
val cmId: Long,
val authorId: Long,
val channelInfo: ChannelInfo,
val channelType: String,
val guid: String,
val text: String?,
val time: Long,
val attachments: List<VkAttachment>?,
) : VkAttachment {
data class ChannelInfo(
val title: String,
val photoBase: String?
)
override val type: AttachmentType = AttachmentType.CHANNEL_MESSAGE
}
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType import dev.meloda.fast.model.api.data.AttachmentType
data class VkWidgetDomain( data class VkWidgetDomain(
val id: Long val id: Long?
) : VkAttachment { ) : VkAttachment {
override val type: AttachmentType = AttachmentType.WIDGET override val type: AttachmentType = AttachmentType.WIDGET
@@ -12,7 +12,8 @@ data class AuthDirectRequest(
val validationCode: String? = null, val validationCode: String? = null,
val captchaSid: String? = null, val captchaSid: String? = null,
val captchaKey: String? = null, val captchaKey: String? = null,
val trustedHash: String? = null val trustedHash: String? = null,
val successToken: String? = null
) { ) {
val map val map
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
captchaSid?.let { this["captcha_sid"] = it } captchaSid?.let { this["captcha_sid"] = it }
captchaKey?.let { this["captcha_key"] = it } captchaKey?.let { this["captcha_key"] = it }
trustedHash?.let { this["trusted_hash"] = it } trustedHash?.let { this["trusted_hash"] = it }
successToken?.let { this["success_token"] = it }
} }
} }
@@ -16,7 +16,8 @@ sealed class OAuthErrorDomain {
data class CaptchaRequiredError( data class CaptchaRequiredError(
val captchaSid: String, val captchaSid: String,
val captchaImageUrl: String val captchaImageUrl: String,
val redirectUri: String?
) : OAuthErrorDomain() ) : OAuthErrorDomain()
data class UserBannedError( data class UserBannedError(
@@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
}, },
onFailure = { failure -> onFailure = { failure ->
if (failure is JsonDataException) { if (failure is JsonDataException) {
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
throw ApiException( throw ApiException(
RestApiError( RestApiError(
errorCode = -1, errorCode = -1,
@@ -2,7 +2,8 @@ package dev.meloda.fast.network
enum class ValidationType(val value: String) { enum class ValidationType(val value: String) {
APP("2fa_app"), APP("2fa_app"),
SMS("2fa_sms"); SMS("sms"),
SMS2("2fa_sms");
companion object { companion object {
fun parse(value: String): ValidationType = fun parse(value: String): ValidationType =
@@ -11,6 +11,7 @@ import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter import dev.meloda.fast.network.MoshiConverter
import dev.meloda.fast.network.OAuthResultCallFactory import dev.meloda.fast.network.OAuthResultCallFactory
import dev.meloda.fast.network.ResponseConverterFactory 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.LanguageInterceptor
import dev.meloda.fast.network.interceptor.VersionInterceptor import dev.meloda.fast.network.interceptor.VersionInterceptor
import dev.meloda.fast.network.service.account.AccountService import dev.meloda.fast.network.service.account.AccountService
@@ -45,6 +46,7 @@ val networkModule = module {
single { ChuckerInterceptor.Builder(get()).collector(get()).build() } single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
singleOf(::VersionInterceptor) singleOf(::VersionInterceptor)
singleOf(::LanguageInterceptor) singleOf(::LanguageInterceptor)
singleOf(::Error14HandlingInterceptor)
single<OkHttpClient>(named("auth")) { single<OkHttpClient>(named("auth")) {
buildHttpClient(true) buildHttpClient(true)
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
addInterceptor(get(named("token_interceptor")) as Interceptor) addInterceptor(get(named("token_interceptor")) as Interceptor)
} }
} }
.addInterceptor(get<Error14HandlingInterceptor>())
.addInterceptor(get<VersionInterceptor>()) .addInterceptor(get<VersionInterceptor>())
.addInterceptor(get<LanguageInterceptor>()) .addInterceptor(get<LanguageInterceptor>())
.addInterceptor(get<ChuckerInterceptor>()) .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()
@@ -58,7 +58,7 @@ fun AppTheme(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val colorScheme: ColorScheme = when { val colorScheme: ColorScheme = predefinedColorScheme ?: when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context) else dynamicLightColorScheme(context)
@@ -82,10 +82,6 @@ fun AppTheme(
} }
} }
val colorPrimary by animateColorAsState(colorScheme.primary)
val colorSurface by animateColorAsState(colorScheme.surface)
val colorBackground by animateColorAsState(colorScheme.background)
val typography = if (useSystemFont) { val typography = if (useSystemFont) {
MaterialTheme.typography MaterialTheme.typography
} else { } else {
@@ -118,12 +114,7 @@ fun AppTheme(
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme) colorScheme = colorScheme,
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
typography = typography, typography = typography,
content = content content = content
) )
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -113,11 +114,12 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable @Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean { fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = context.resources.configuration.uiMode val systemUiNightMode = configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme = val isSystemUsingDarkTheme =
+3
View File
@@ -88,6 +88,7 @@
<string name="message_attachments_video_message">Video message</string> <string name="message_attachments_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</string> <string name="message_attachments_group_sticker">Group sticker</string>
<string name="message_attachments_sticker_pack_preview">Sticker pack preview</string> <string name="message_attachments_sticker_pack_preview">Sticker pack preview</string>
<string name="message_attachments_channel_message">Channel message</string>
<string name="chat_interaction_uploading_file">Uploading file</string> <string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string> <string name="chat_interaction_uploading_photo">Uploading photo</string>
@@ -303,4 +304,6 @@
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</string> <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="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> </resources>
@@ -3,7 +3,6 @@ package dev.meloda.fast.auth.login
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import dev.meloda.fast.auth.login.presentation.LogoScreen
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -15,7 +14,7 @@ class LogoScreenTest {
@Test @Test
fun goNextButton_isClickable() { fun goNextButton_isClickable() {
composeTestRule.setContent { composeTestRule.setContent {
LogoScreen()
} }
composeTestRule.onNodeWithTag(testTag = "go_next_fab").assertHasClickAction() composeTestRule.onNodeWithTag(testTag = "go_next_fab").assertHasClickAction()
@@ -3,9 +3,6 @@ package dev.meloda.fast.auth
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.navigation 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.Login
import dev.meloda.fast.auth.login.navigation.loginScreen import dev.meloda.fast.auth.login.navigation.loginScreen
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
@@ -28,11 +25,6 @@ fun NavGraphBuilder.authNavGraph(
) { ) {
navigation<AuthGraph>(startDestination = Login) { navigation<AuthGraph>(startDestination = Login) {
loginScreen( loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
captchaImageUrl = URLEncoder.encode(arguments.captchaImageUrl, "utf-8")
)
},
onNavigateToValidation = { arguments -> onNavigateToValidation = { arguments ->
navController.navigateToValidation( navController.navigateToValidation(
ValidationArguments( 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) userBannedRoute(onBack = navController::navigateUp)
} }
} }
@@ -1,6 +1,5 @@
package dev.meloda.fast.auth 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.validation.di.validationModule
import dev.meloda.fast.auth.login.di.loginModule import dev.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module import org.koin.dsl.module
@@ -9,6 +8,5 @@ val authModule = module {
includes( includes(
loginModule, loginModule,
validationModule, 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 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.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.animation.fadeIn
import androidx.compose.foundation.border import androidx.compose.animation.fadeOut
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.ime import androidx.compose.material3.CircularProgressIndicator
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,63 +26,34 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.viewinterop.AndroidView
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 dev.meloda.fast.ui.R 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.ActionInvokeDismiss
import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import org.json.JSONObject
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel
@Composable private const val TAG = "CaptchaScreen"
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
)
}
@Composable @Composable
fun CaptchaScreen( fun CaptchaScreen(
screenState: CaptchaScreenState = CaptchaScreenState.EMPTY, captchaRedirectUri: String?,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {}, onResult: (String) -> Unit = {}
onTextFieldDoneAction: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
) { ) {
if (captchaRedirectUri != null) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(true) {
focusManager.clearFocus(true)
keyboardController?.hide()
}
var confirmedExit by remember { var confirmedExit by remember {
mutableStateOf(false) mutableStateOf(false)
} }
@@ -102,6 +62,10 @@ fun CaptchaScreen(
mutableStateOf(false) mutableStateOf(false)
} }
var isWebViewLoading by remember {
mutableStateOf(true)
}
LaunchedEffect(confirmedExit) { LaunchedEffect(confirmedExit) {
if (confirmedExit) { if (confirmedExit) {
onBack() onBack()
@@ -114,6 +78,7 @@ fun CaptchaScreen(
} }
} }
FullScreenDialog(onDismiss = { showExitAlert = true }) {
if (showExitAlert) { if (showExitAlert) {
MaterialDialog( MaterialDialog(
onDismissRequest = { showExitAlert = false }, onDismissRequest = { showExitAlert = false },
@@ -126,148 +91,104 @@ fun CaptchaScreen(
) )
} }
val focusManager = LocalFocusManager.current Box(
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
) { padding ->
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .navigationBarsPadding()
.padding(30.dp), .clickable(
verticalArrangement = Arrangement.SpaceBetween interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { showExitAlert = true }
)
) { ) {
ExtendedFloatingActionButton( AndroidView(
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,
)
}
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Captcha",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
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)
)
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") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.clip(RoundedCornerShape(10.dp)), .align(Alignment.BottomCenter),
leadingIcon = { factory = { context ->
Icon( val webview = WebView(context)
painter = painterResource(id = R.drawable.ic_qr_code_round_24), webview.setBackgroundColor(0)
contentDescription = "QR code icon", webview.settings.javaScriptEnabled = true
tint = if (showError) { webview.webViewClient = object : WebViewClient() {
MaterialTheme.colorScheme.error override fun shouldOverrideUrlLoading(
} else { view: WebView?,
MaterialTheme.colorScheme.primary request: WebResourceRequest?
} ): Boolean {
) Log.i(TAG, "shouldOverrideUrlLoading: $request")
}, return false
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
onTextFieldDoneAction()
}
),
isError = showError
)
AnimatedVisibility(visible = showError) {
TextFieldErrorText(text = "Field must not be empty")
}
} }
FloatingActionButton( override fun onPageStarted(
onClick = onDoneButtonClicked, view: WebView?,
containerColor = MaterialTheme.colorScheme.secondaryContainer, url: String?,
modifier = Modifier.align(Alignment.CenterHorizontally) favicon: Bitmap?
) { ) {
Icon( super.onPageStarted(view, url, favicon)
painter = painterResource(R.drawable.ic_check_round_24), isWebViewLoading = true
contentDescription = "Done icon", }
tint = MaterialTheme.colorScheme.onSecondaryContainer
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
isWebViewLoading = false
}
}
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 = isWebViewLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
CircularProgressIndicator(
modifier = Modifier.size(50.dp),
color = Color.White.copy(alpha = 0.85f)
)
}
} }
} }
} }
} }
@FastPreview class WebCaptchaListener(
@Composable private val onSuccessTokenReceived: (String) -> Unit,
private fun CaptchaScreenPreview() { private val onCloseRequested: (String) -> Unit
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) { ) {
CaptchaScreen( private val tag = "WebCaptchaListener"
screenState = CaptchaScreenState.EMPTY.copy(
code = "zcuecz" @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) private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
val validationArguments = _validationArguments.asStateFlow() val validationArguments = _validationArguments.asStateFlow()
private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
val captchaArguments = _captchaArguments.asStateFlow()
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null) private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
val userBannedArguments = _userBannedArguments.asStateFlow() val userBannedArguments = _userBannedArguments.asStateFlow()
private val _isNeedToOpenMain = MutableStateFlow(false) private val _isNeedToOpenMain = MutableStateFlow(false)
val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow() val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow()
private val _isNeedToClearCaptchaCode = MutableStateFlow(false)
val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow()
private val _isNeedToClearValidationCode = MutableStateFlow(false) private val _isNeedToClearValidationCode = MutableStateFlow(false)
val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow() val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow()
@@ -78,17 +72,10 @@ class LoginViewModel(
screenState.map(loginValidator::validate) screenState.map(loginValidator::validate)
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty)) .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 validationSid = MutableStateFlow<String?>(null)
private val validationCode = MutableStateFlow<String?>(null) private val validationCode = MutableStateFlow<String?>(null)
init { init {
captchaCode.listenValue(viewModelScope) {
if (it != null) {
login()
}
}
validationCode.listenValue(viewModelScope) { validationCode.listenValue(viewModelScope) {
if (it != null) { if (it != null) {
login() login()
@@ -113,7 +100,13 @@ class LoginViewModel(
} }
fun onBackPressed() { fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old ->
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
} }
fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
@@ -165,10 +158,6 @@ class LoginViewModel(
_userBannedArguments.update { null } _userBannedArguments.update { null }
} }
fun onNavigatedToCaptcha() {
_captchaArguments.update { null }
}
fun onNavigatedToValidation() { fun onNavigatedToValidation() {
_validationArguments.update { null } _validationArguments.update { null }
} }
@@ -181,25 +170,9 @@ class LoginViewModel(
_isNeedToClearValidationCode.update { false } _isNeedToClearValidationCode.update { false }
} }
fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code }
}
fun onCaptchaCodeCleared() {
_isNeedToClearCaptchaCode.update { false }
}
private fun login(forceSms: Boolean = false) { private fun login(forceSms: Boolean = false) {
val currentState = screenState.value.copy() val currentState = screenState.value.copy()
Log.d(
"LoginViewModel",
"auth: login: ${currentState.login}; " +
"password: ${currentState.password}; " +
"2fa code: ${validationCode.value}; " +
"captcha code: ${captchaCode.value}"
)
processValidation() processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return if (!validationState.value.contains(LoginValidationResult.Valid)) return
@@ -207,23 +180,18 @@ class LoginViewModel(
val currentValidationSid = validationSid.value val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null } val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
val currentCaptchaSid = captchaSid.value
val currentCaptchaCode = captchaCode.value?.takeIf { currentCaptchaSid != null }
oAuthUseCase.getSilentToken( oAuthUseCase.getSilentToken(
login = currentState.login, login = currentState.login,
password = currentState.password, password = currentState.password,
forceSms = forceSms, forceSms = forceSms,
validationCode = currentValidationCode, validationCode = currentValidationCode,
captchaSid = currentCaptchaSid,
captchaKey = currentCaptchaCode
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error") Log.d("LoginViewModelImpl", "login: error: $error")
_screenState.updateValue { copy(isLoading = false) } _screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null }
parseError(error) parseError(error)
}, },
@@ -286,7 +254,6 @@ class LoginViewModel(
startLongPoll() startLongPoll()
captchaSid.update { null }
validationSid.update { null } validationSid.update { null }
loadUserByIdUseCase( loadUserByIdUseCase(
@@ -333,11 +300,8 @@ class LoginViewModel(
is OAuthErrorDomain.CaptchaRequiredError -> { is OAuthErrorDomain.CaptchaRequiredError -> {
val arguments = CaptchaArguments( val arguments = CaptchaArguments(
captchaSid = error.captchaSid, redirectUri = error.redirectUri
captchaImageUrl = error.captchaImageUrl
) )
_captchaArguments.update { arguments }
captchaSid.update { error.captchaSid }
} }
OAuthErrorDomain.InvalidCredentialsError -> { OAuthErrorDomain.InvalidCredentialsError -> {
@@ -7,6 +7,5 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
@Parcelize @Parcelize
data class CaptchaArguments( data class CaptchaArguments(
val captchaSid: String, val redirectUri: String?
val captchaImageUrl: String
) : Parcelable ) : Parcelable
@@ -8,7 +8,6 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import dev.meloda.fast.auth.login.LoginViewModel 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.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.auth.login.presentation.LoginRoute import dev.meloda.fast.auth.login.presentation.LoginRoute
@@ -19,7 +18,6 @@ import kotlinx.serialization.Serializable
object Login object Login
fun NavGraphBuilder.loginScreen( fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
@@ -31,7 +29,6 @@ fun NavGraphBuilder.loginScreen(
backStackEntry.sharedViewModel<LoginViewModel>(navController = navController) backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle() val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
LaunchedEffect(clearValidationCode) { LaunchedEffect(clearValidationCode) {
if (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 validationCode = backStackEntry.getValidationResult()
val captchaCode = backStackEntry.getCaptchaResult()
LoginRoute( LoginRoute(
onNavigateToUserBanned = onNavigateToUserBanned, onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation, onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings, onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode, validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -66,7 +53,3 @@ fun NavGraphBuilder.loginScreen(
fun NavBackStackEntry.getValidationResult(): String? { fun NavBackStackEntry.getValidationResult(): String? {
return savedStateHandle["validation_code"] return savedStateHandle["validation_code"]
} }
fun NavBackStackEntry.getCaptchaResult(): String? {
return savedStateHandle["captcha_code"]
}
@@ -31,9 +31,13 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
@@ -58,7 +62,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
@@ -67,6 +70,8 @@ import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.ClassicColorScheme
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -75,17 +80,14 @@ import org.koin.androidx.compose.koinViewModel
fun LoginRoute( fun LoginRoute(
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit, onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit, onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
validationCode: String?, validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel() viewModel: LoginViewModel = koinViewModel()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle() val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle() val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle() val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle() val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle()
@@ -107,12 +109,6 @@ fun LoginRoute(
onNavigateToUserBanned(arguments) onNavigateToUserBanned(arguments)
} }
} }
LaunchedEffect(captchaArguments) {
captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments)
}
}
LaunchedEffect(validationArguments) { LaunchedEffect(validationArguments) {
validationArguments?.let { arguments -> validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation() viewModel.onNavigatedToValidation()
@@ -122,10 +118,13 @@ fun LoginRoute(
LaunchedEffect(validationCode) { LaunchedEffect(validationCode) {
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
LaunchedEffect(captchaCode) {
viewModel.onCaptchaCodeReceived(captchaCode)
}
var useClassic by rememberSaveable { mutableStateOf(true) }
AppTheme(
predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
else lightColorScheme(),
) {
LoginScreen( LoginScreen(
screenState = screenState, screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged, onLoginInputChanged = viewModel::onLoginInputChanged,
@@ -134,9 +133,13 @@ fun LoginRoute(
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked, onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked, onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings onLogoLongClicked = onNavigateToSettings
) )
}
HandleDialogs( HandleDialogs(
loginDialog = loginDialog, loginDialog = loginDialog,
@@ -4,7 +4,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.validation.model.ValidationScreenState import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.auth.validation.navigation.Validation import dev.meloda.fast.auth.validation.navigation.Validation
import dev.meloda.fast.auth.validation.validation.ValidationValidator import dev.meloda.fast.auth.validation.validation.ValidationValidator
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
@@ -12,6 +11,7 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AuthUseCase import dev.meloda.fast.domain.AuthUseCase
import dev.meloda.fast.network.ValidationType
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,10 +0,0 @@
package dev.meloda.fast.auth.validation.model
enum class ValidationType(val value: String) {
SMS("sms"), APP("2fa_app");
companion object {
fun parse(value: String): ValidationType = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown validation type with value: $value")
}
}
@@ -47,13 +47,12 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.validation.ValidationViewModel import dev.meloda.fast.auth.validation.ValidationViewModel
import dev.meloda.fast.auth.validation.ValidationViewModelImpl import dev.meloda.fast.auth.validation.ValidationViewModelImpl
import dev.meloda.fast.auth.validation.model.ValidationScreenState import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType import dev.meloda.fast.network.ValidationType
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
@@ -119,7 +118,7 @@ fun ValidationScreen(
val validationText by remember(validationType) { val validationText by remember(validationType) {
mutableStateOf( mutableStateOf(
when (validationType) { when (validationType) {
ValidationType.SMS -> "SMS with the code is sent to ${screenState.phoneMask}" ValidationType.SMS, ValidationType.SMS2 -> "SMS with the code is sent to ${screenState.phoneMask}"
ValidationType.APP -> "Enter the code from the code generator application" ValidationType.APP -> "Enter the code from the code generator application"
null -> "" null -> ""
@@ -19,7 +19,7 @@ interface MessagesHistoryViewModel {
val dialog: StateFlow<MessageDialog?> val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>> val selectedMessages: StateFlow<List<VkMessage>>
val inputFieldFocusRequester: StateFlow<Boolean> val showKeyboard: StateFlow<Boolean>
val isNeedToScrollToIndex: StateFlow<Int?> val isNeedToScrollToIndex: StateFlow<Int?>
@@ -54,6 +54,7 @@ interface MessagesHistoryViewModel {
fun onPinnedMessageClicked(messageId: Long) fun onPinnedMessageClicked(messageId: Long)
fun onUnpinMessageClicked() fun onUnpinMessageClicked()
fun onEditSelectedMessageClicked()
fun onDeleteSelectedMessagesClicked() fun onDeleteSelectedMessagesClicked()
fun onBoldClicked() fun onBoldClicked()
@@ -66,5 +67,7 @@ interface MessagesHistoryViewModel {
fun onRequestReplyToMessage(cmId: Long) fun onRequestReplyToMessage(cmId: Long)
fun onKeyboardShown()
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int 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.asPresentation
import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.domain.util.extractReplySummary 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.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog 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.BaseError
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.FormatDataType 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.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
@@ -65,6 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
@@ -73,7 +74,6 @@ import kotlinx.serialization.json.put
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.abs import kotlin.math.abs
import kotlin.random.Random import kotlin.random.Random
@@ -94,7 +94,7 @@ class MessagesHistoryViewModelImpl(
override val dialog = MutableStateFlow<MessageDialog?>(null) override val dialog = MutableStateFlow<MessageDialog?>(null)
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList()) override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
override val inputFieldFocusRequester = MutableStateFlow(false) override val showKeyboard = MutableStateFlow(false)
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null) override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
@@ -115,6 +115,8 @@ class MessagesHistoryViewModelImpl(
private var replyToCmId: Long? = null private var replyToCmId: Long? = null
private var editMessage: VkMessage? = null
init { init {
val arguments = MessagesHistory.from(savedStateHandle).arguments val arguments = MessagesHistory.from(savedStateHandle).arguments
@@ -229,7 +231,7 @@ class MessagesHistoryViewModelImpl(
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) { override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
when (dialog) { when (dialog) {
is MessageDialog.MessageOptions -> { is MessageDialog.MessageOptions -> {
val messageId = bundle.getLong("messageId") // val messageId = bundle.getLong("messageId")
val cmId = bundle.getLong("cmId") val cmId = bundle.getLong("cmId")
when (val option = bundle.getParcelableCompat("option", MessageOption::class)) { when (val option = bundle.getParcelableCompat("option", MessageOption::class)) {
@@ -289,7 +291,10 @@ class MessagesHistoryViewModelImpl(
} }
} }
MessageOption.Edit -> {} MessageOption.Edit -> {
editMessage(cmId)
syncUiMessages()
}
MessageOption.Delete -> { MessageOption.Delete -> {
this.dialog.setValue { this.dialog.setValue {
@@ -313,7 +318,14 @@ class MessagesHistoryViewModelImpl(
} }
override fun onCloseButtonClicked() { override fun onCloseButtonClicked() {
if (selectedMessages.value.isNotEmpty()) {
selectedMessages.setValue { emptyList() } selectedMessages.setValue { emptyList() }
}
if (screenState.value.editCmId != null) {
stopEditMessage()
}
syncUiMessages() syncUiMessages()
} }
@@ -329,8 +341,20 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
message = newText, message = newText,
actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO actionMode =
else ActionMode.SEND 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() updateStyles()
@@ -347,13 +371,9 @@ class MessagesHistoryViewModelImpl(
override fun onActionButtonClicked() { override fun onActionButtonClicked() {
when (screenState.value.actionMode) { when (screenState.value.actionMode) {
ActionMode.DELETE -> { ActionMode.DELETE -> confirmDeleteCurrentEditMessage()
} ActionMode.EDIT -> editCurrentEditMessage()
ActionMode.EDIT -> {
}
ActionMode.RECORD_AUDIO -> { ActionMode.RECORD_AUDIO -> {
screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) } 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() { override fun onDeleteSelectedMessagesClicked() {
dialog.setValue { dialog.setValue {
MessageDialog.MessagesDelete(selectedMessages.value) MessageDialog.MessagesDelete(selectedMessages.value)
@@ -438,7 +468,7 @@ class MessagesHistoryViewModelImpl(
private fun replyToMessage(cmId: Long) { private fun replyToMessage(cmId: Long) {
val messageToReply = messages.value.find { it.cmId == cmId } ?: return val messageToReply = messages.value.find { it.cmId == cmId } ?: return
inputFieldFocusRequester.setValue { true } showKeyboard.setValue { true }
replyToCmId = cmId replyToCmId = cmId
screenState.setValue { old -> screenState.setValue { old ->
old.copy( 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 var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() { private fun updateStyles() {
@@ -580,7 +660,12 @@ class MessagesHistoryViewModelImpl(
replyToMessage(cmId) replyToMessage(cmId)
} }
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = suspendCoroutine { override fun onKeyboardShown() {
showKeyboard.setValue { false }
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int =
suspendCancellableCoroutine {
viewModelScope.launch { viewModelScope.launch {
getMessageReadPeersUseCase getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId) .invoke(peerId = peerId, cmId = cmId)
@@ -988,11 +1073,13 @@ class MessagesHistoryViewModelImpl(
message = newMessage.text, message = newMessage.text,
forward = forward, forward = forward,
attachments = null, attachments = null,
formatData = newMessage.formatData formatData = newMessage.formatData,
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
any = { sendingMessages.remove(newMessage) }, any = { sendingMessages.remove(newMessage) },
error = { error -> error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId) val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage 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( private fun markAsImportant(
messageIds: List<Long>, messageIds: List<Long>,
important: Boolean, 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) { private fun readMessage(message: VkMessage) {
messagesUseCase.markAsRead( messagesUseCase.markAsRead(
peerId = screenState.value.convoId, peerId = screenState.value.convoId,
@@ -1237,7 +1346,8 @@ class MessagesHistoryViewModelImpl(
nextMessage = messages.getOrNull(index - 1), nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages, showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
convo = screenState.value.convo, 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 } uiMessages.setValue { newUiMessages }
@@ -26,7 +26,8 @@ data class MessagesHistoryScreenState(
val pinnedTitle: String?, val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?, val pinnedSummary: AnnotatedString?,
val replyTitle: String?, val replyTitle: String?,
val replyText: AnnotatedString? val replyText: AnnotatedString?,
val editCmId: Long?,
) { ) {
companion object { companion object {
@@ -48,6 +49,7 @@ data class MessagesHistoryScreenState(
pinnedSummary = null, pinnedSummary = null,
replyTitle = null, replyTitle = null,
replyText = null, replyText = null,
editCmId = null,
) )
} }
} }
@@ -81,7 +81,7 @@ fun InputBar(
actionMode: ActionMode, actionMode: ActionMode,
replyTitle: String?, replyTitle: String?,
replyText: AnnotatedString?, replyText: AnnotatedString?,
inputFieldFocusRequester: Boolean, showKeyboard: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {}, onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {}, onItalicRequested: () -> Unit = {},
@@ -92,7 +92,8 @@ fun InputBar(
onEmojiButtonLongClicked: () -> Unit = {}, onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {}, onActionButtonClicked: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {} onReplyCloseClicked: () -> Unit = {},
onKeyboardShown: () -> Unit
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
@@ -106,8 +107,9 @@ fun InputBar(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) { LaunchedEffect(showKeyboard) {
if (inputFieldFocusRequester) { if (showKeyboard) {
onKeyboardShown()
focusRequester.requestFocus() focusRequester.requestFocus()
} }
} }
@@ -360,6 +362,7 @@ private fun InputBarPreview() {
actionMode = ActionMode.SEND, actionMode = ActionMode.SEND,
replyTitle = "Иннокентий Панфилович", replyTitle = "Иннокентий Панфилович",
replyText = "Ого, ром!".annotated(), replyText = "Ого, ром!".annotated(),
inputFieldFocusRequester = false showKeyboard = false,
onKeyboardShown = {}
) )
} }
@@ -29,7 +29,7 @@ fun MessagesHistoryRoute(
val baseError by viewModel.baseError.collectAsStateWithLifecycle() val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle() val showKeyboard by viewModel.showKeyboard.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) { LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) { val needToConsume = when (val navigation = navigationEvent) {
@@ -55,7 +55,7 @@ fun MessagesHistoryRoute(
canPaginate = canPaginate, canPaginate = canPaginate,
showEmojiButton = AppSettings.General.showEmojiButton, showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton, showAttachmentButton = AppSettings.General.showAttachmentButton,
inputFieldFocusRequester = inputFieldFocusRequester, showKeyboard = showKeyboard,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked, onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex, onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -72,6 +72,7 @@ fun MessagesHistoryRoute(
onPhotoClicked = onNavigateToPhotoViewer, onPhotoClicked = onNavigateToPhotoViewer,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked, onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onEditSelectedMessageClicked = viewModel::onEditSelectedMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked, onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked, onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked, onItalicRequested = viewModel::onItalicClicked,
@@ -80,6 +81,7 @@ fun MessagesHistoryRoute(
onRegularRequested = viewModel::onRegularClicked, onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked, onReplyCloseClicked = viewModel::onReplyCloseClicked,
onRequestReplyToMessage = viewModel::onRequestReplyToMessage, onRequestReplyToMessage = viewModel::onRequestReplyToMessage,
onKeyboardShown = viewModel::onKeyboardShown
) )
HandleDialogs( HandleDialogs(
@@ -31,12 +31,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.indexOfMessageByCmId import dev.meloda.fast.domain.util.indexOfMessageByCmId
@@ -69,13 +71,14 @@ fun MessagesHistoryScreen(
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false, showAttachmentButton: Boolean = false,
inputFieldFocusRequester: Boolean, showKeyboard: Boolean,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {}, onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {}, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onTopBarClicked: () -> Unit = {}, onTopBarClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {}, onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (TextFieldValue) -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {}, onAttachmentButtonClicked: () -> Unit = {},
@@ -93,7 +96,8 @@ fun MessagesHistoryScreen(
onUnderlineRequested: () -> Unit = {}, onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {}, onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}, onReplyCloseClicked: () -> Unit = {},
onRequestReplyToMessage: (cmId: Long) -> Unit = {} onRequestReplyToMessage: (cmId: Long) -> Unit = {},
onKeyboardShown: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
@@ -114,7 +118,7 @@ fun MessagesHistoryScreen(
} }
BackHandler( BackHandler(
enabled = selectedMessages.isNotEmpty(), enabled = selectedMessages.isNotEmpty() || screenState.editCmId != null,
onBack = onClose onBack = onClose
) )
@@ -162,6 +166,9 @@ fun MessagesHistoryScreen(
derivedStateOf { selectedMessages.size == 1 } derivedStateOf { selectedMessages.size == 1 }
} }
val isLoadingText = stringResource(R.string.title_loading)
val editMessageText = stringResource(R.string.title_edit_message)
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@@ -169,7 +176,8 @@ fun MessagesHistoryScreen(
val topBarTitle by remember(screenState, selectedMessages) { val topBarTitle by remember(screenState, selectedMessages) {
derivedStateOf { derivedStateOf {
when { when {
screenState.isLoading -> context.getString(R.string.title_loading) screenState.isLoading -> isLoadingText
screenState.editCmId != null -> editMessageText
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})" selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title else -> screenState.title
} }
@@ -179,13 +187,16 @@ fun MessagesHistoryScreen(
MessagesHistoryTopBarContainer( MessagesHistoryTopBarContainer(
hazeState = hazeState, hazeState = hazeState,
showReplyAction = showReplyAction, showReplyAction = showReplyAction,
showEditAction = selectedMessages.size == 1,
topBarContainerColor = topBarContainerColor, topBarContainerColor = topBarContainerColor,
topBarContainerColorAlpha = topBarContainerColorAlpha, topBarContainerColorAlpha = topBarContainerColorAlpha,
isClickable = !(screenState.isLoading && messages.isEmpty()), isClickable = !(screenState.isLoading && messages.isEmpty()),
isMessagesSelecting = selectedMessages.isNotEmpty(), isMessagesSelecting = selectedMessages.isNotEmpty(),
isPeerAccount = screenState.convoId == UserConfig.userId, 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, title = topBarTitle,
isEditing = screenState.editCmId != null,
showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(), showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
showPinnedContainer = !screenState.isLoading && pinnedMessage != null, showPinnedContainer = !screenState.isLoading && pinnedMessage != null,
pinnedMessage = pinnedMessage, pinnedMessage = pinnedMessage,
@@ -196,6 +207,7 @@ fun MessagesHistoryScreen(
onBack = onBack, onBack = onBack,
onClose = onClose, onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked, onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onEditSelectedMessageClicked = onEditSelectedMessageClicked,
onRefresh = onRefresh, onRefresh = onRefresh,
onPinnedMessageClicked = onPinnedMessageClicked, onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
@@ -211,6 +223,7 @@ fun MessagesHistoryScreen(
) { ) {
MessagesList( MessagesList(
modifier = Modifier.align(Alignment.BottomStart), modifier = Modifier.align(Alignment.BottomStart),
screenState = screenState,
hazeState = hazeState, hazeState = hazeState,
listState = listState, listState = listState,
hasPinnedMessage = pinnedMessage != null, hasPinnedMessage = pinnedMessage != null,
@@ -259,12 +272,13 @@ fun MessagesHistoryScreen(
actionMode = screenState.actionMode, actionMode = screenState.actionMode,
replyTitle = screenState.replyTitle, replyTitle = screenState.replyTitle,
replyText = screenState.replyText, replyText = screenState.replyText,
inputFieldFocusRequester = inputFieldFocusRequester, showKeyboard = showKeyboard,
onSetMessageBarHeight = { messageBarHeight = it }, onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked, onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked, onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked, onActionButtonClicked = onActionButtonClicked,
onReplyCloseClicked = onReplyCloseClicked onReplyCloseClicked = onReplyCloseClicked,
onKeyboardShown = onKeyboardShown
) )
when { when {
@@ -2,6 +2,7 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -31,7 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -44,11 +44,9 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.getImage
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -56,16 +54,20 @@ fun MessagesHistoryTopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeState: HazeState, hazeState: HazeState,
showReplyAction: Boolean, showReplyAction: Boolean,
showEditAction: Boolean,
isClickable: Boolean, isClickable: Boolean,
isMessagesSelecting: Boolean, isMessagesSelecting: Boolean,
isPeerAccount: Boolean, isPeerAccount: Boolean,
avatar: UiImage, avatarUrl: String?,
avatarResourceId: Int?,
title: String, title: String,
isEditing: Boolean,
onTopBarClicked: () -> Unit = {}, onTopBarClicked: () -> Unit = {},
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {} onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {}
) { ) {
val view = LocalView.current val view = LocalView.current
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
@@ -96,7 +98,8 @@ fun MessagesHistoryTopBar(
// modifier = Modifier.weight(1f), // modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (!isMessagesSelecting) { AnimatedVisibility(!isMessagesSelecting && !isEditing) {
Row {
if (isPeerAccount) { if (isPeerAccount) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -114,19 +117,10 @@ fun MessagesHistoryTopBar(
) )
} }
} else { } else {
val actualAvatar = avatar.getImage() when {
avatarUrl != null -> {
if (actualAvatar is Painter) {
Image(
painter = actualAvatar,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
} else {
AsyncImage( AsyncImage(
model = actualAvatar, model = avatarUrl,
contentDescription = "Profile Image", contentDescription = "Profile Image",
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
@@ -134,12 +128,25 @@ fun MessagesHistoryTopBar(
placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24), placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24),
) )
} }
avatarResourceId != null -> {
Image(
painter = painterResource(avatarResourceId),
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
}
}
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
} }
}
Text( Text(
modifier = Modifier.animateContentSize(),
text = title, text = title,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -150,11 +157,11 @@ fun MessagesHistoryTopBar(
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { onClick = {
if (!isMessagesSelecting) onBack() if (!isMessagesSelecting && !isEditing) onBack()
else onClose() else onClose()
} }
) { ) {
Crossfade(targetState = !isMessagesSelecting) { state -> Crossfade(targetState = !isMessagesSelecting && !isEditing) { state ->
Icon( Icon(
painter = painterResource( painter = painterResource(
if (state) { if (state) {
@@ -210,6 +217,16 @@ fun MessagesHistoryTopBar(
contentDescription = null contentDescription = null
) )
} }
AnimatedVisibility(showEditAction) {
IconButton(onClick = onEditSelectedMessageClicked) {
Icon(
painter = painterResource(R.drawable.ic_edit_round_24),
contentDescription = null
)
}
}
IconButton(onClick = onDeleteSelectedButtonClicked) { IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon( Icon(
painter = painterResource(R.drawable.ic_delete_round_24), 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.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.extensions.orDots 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.model.api.domain.VkMessage
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
@@ -26,13 +25,16 @@ fun MessagesHistoryTopBarContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeState: HazeState, hazeState: HazeState,
showReplyAction: Boolean, showReplyAction: Boolean,
showEditAction: Boolean,
topBarContainerColor: Color, topBarContainerColor: Color,
topBarContainerColorAlpha: Float, topBarContainerColorAlpha: Float,
isClickable: Boolean, isClickable: Boolean,
isMessagesSelecting: Boolean, isMessagesSelecting: Boolean,
isPeerAccount: Boolean, isPeerAccount: Boolean,
avatar: UiImage, avatarUrl: String?,
avatarResourceId: Int?,
title: String, title: String,
isEditing: Boolean,
showHorizontalProgressBar: Boolean, showHorizontalProgressBar: Boolean,
showPinnedContainer: Boolean, showPinnedContainer: Boolean,
pinnedMessage: VkMessage?, pinnedMessage: VkMessage?,
@@ -44,6 +46,7 @@ fun MessagesHistoryTopBarContainer(
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {}, onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {} onUnpinMessageButtonClicked: () -> Unit = {}
) { ) {
@@ -66,16 +69,20 @@ fun MessagesHistoryTopBarContainer(
modifier = modifier, modifier = modifier,
hazeState = hazeState, hazeState = hazeState,
showReplyAction = showReplyAction, showReplyAction = showReplyAction,
showEditAction = showEditAction,
isClickable = isClickable, isClickable = isClickable,
isMessagesSelecting = isMessagesSelecting, isMessagesSelecting = isMessagesSelecting,
isPeerAccount = isPeerAccount, isPeerAccount = isPeerAccount,
avatar = avatar, avatarUrl = avatarUrl,
avatarResourceId = avatarResourceId,
title = title, title = title,
isEditing = isEditing,
onTopBarClicked = onTopBarClicked, onTopBarClicked = onTopBarClicked,
onBack = onBack, onBack = onBack,
onClose = onClose, onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked, onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onRefresh = onRefresh onRefresh = onRefresh,
onEditSelectedMessageClicked = onEditSelectedMessageClicked
) )
if (showHorizontalProgressBar) { if (showHorizontalProgressBar) {
@@ -46,6 +46,7 @@ import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings 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.VkAttachment
import dev.meloda.fast.model.api.domain.VkFileDomain import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain import dev.meloda.fast.model.api.domain.VkLinkDomain
@@ -60,6 +61,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun MessagesList( fun MessagesList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
screenState: MessagesHistoryScreenState,
hasPinnedMessage: Boolean, hasPinnedMessage: Boolean,
hazeState: HazeState, hazeState: HazeState,
listState: LazyListState, listState: LazyListState,
@@ -226,10 +228,15 @@ fun MessagesList(
fadeOutSpec = null fadeOutSpec = null
) else Modifier ) else Modifier
) )
.then(
if (screenState.editCmId == null) {
Modifier
.combinedClickable( .combinedClickable(
onLongClick = { onLongClick = {
if (AppSettings.General.enableHaptic) { if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
} }
onMessageLongClicked(item.id) onMessageLongClicked(item.id)
}, },
@@ -263,10 +270,13 @@ fun MessagesList(
}, },
onHorizontalDrag = { change, dragAmount -> onHorizontalDrag = { change, dragAmount ->
change.consume() change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-100f, 0f) offsetX =
(offsetX + dragAmount).coerceIn(-100f, 0f)
} }
) )
}, }
} else Modifier
),
color = backgroundColor color = backgroundColor
) { ) {
if (item.isOut) { if (item.isOut) {
@@ -119,12 +119,15 @@ fun Attachments(
AttachmentType.STICKER -> { AttachmentType.STICKER -> {
Sticker( Sticker(
item = attachment as VkStickerDomain url = (attachment as VkStickerDomain).getUrl(
width = 256,
withBackground = false
)
) )
} }
AttachmentType.GIFT -> { AttachmentType.GIFT -> {
Gift(item = attachment as VkGiftDomain) Gift(url = (attachment as VkGiftDomain).getDefaultThumbSizeOrLess())
} }
AttachmentType.VIDEO_MESSAGE -> { AttachmentType.VIDEO_MESSAGE -> {
@@ -21,25 +21,24 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkGiftDomain
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun Gift( fun Gift(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkGiftDomain url: String
) { ) {
Column( Column(
modifier = modifier.width(192.dp), modifier = modifier
.width(208.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AsyncImage( AsyncImage(
model = item.getDefaultThumbSizeOrLess(), model = url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.size(192.dp)
.padding(8.dp)
.fillMaxWidth()
) )
Row( Row(
@@ -10,27 +10,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkStickerDomain
@Composable @Composable
fun Sticker( fun Sticker(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkStickerDomain url: String?
) { ) {
Box( Box(
modifier = modifier.size(192.dp), modifier = modifier
.size(208.dp)
.padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncImage( AsyncImage(
model = item.getUrl( model = url,
width = 256,
withBackground = false
),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize()
.padding(8.dp)
.fillMaxSize()
) )
} }
} }
+29 -14
View File
@@ -1,35 +1,50 @@
[versions] [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" retrofit = "3.0.0"
eithernet = "2.0.0" eithernet = "2.0.0"
haze = "1.7.1" haze = "1.7.2"
kotlin = "2.3.10" kotlin = "2.3.21"
ksp = "2.3.4" ksp = "2.3.7"
moduleGraph = "2.9.0" moduleGraph = "2.9.1"
versions = "0.53.0" versions = "0.54.0"
stability-analyzer = "0.6.6" stability-analyzer = "0.7.5"
compose-bom = "2026.01.01" compose-bom = "2026.04.01"
koin = "4.1.1" koin = "4.2.1"
accompanist = "0.37.3" accompanist = "0.37.3"
coil = "2.7.0" coil = "2.7.0"
coroutines = "1.10.2" coroutines = "1.10.2"
junit = "4.13.2" junit = "4.13.2"
chucker = "4.3.0" chucker = "4.3.1"
guava = "33.5.0-jre" guava = "33.6.0-jre"
lifecycle = "2.10.0" lifecycle = "2.10.0"
core-ktx = "1.17.0" core-ktx = "1.18.0"
material = "1.13.0" material = "1.13.0"
loggingInterceptor = "5.3.2" loggingInterceptor = "5.3.2"
moshi = "1.15.2" moshi = "1.15.2"
room = "2.8.4" room = "2.8.4"
preference-ktx = "1.2.1" preference-ktx = "1.2.1"
nanokt = "1.3.0" nanokt = "1.3.0"
androidx-navigation = "2.9.6" androidx-navigation = "2.9.8"
serialization = "1.10.0" serialization = "1.11.0"
acra = "5.13.1"
okhttp = "5.3.2"
[libraries] [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" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" } coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored Regular → Executable
+5 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega