Compare commits
6 Commits
0.2.3
...
abfe25d051
| Author | SHA1 | Date | |
|---|---|---|---|
| abfe25d051 | |||
| 574b230b26 | |||
| b31c0f30c5 | |||
| cb653eddc2 | |||
| df2c61d8d7 | |||
| 97c59a85b6 |
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>())
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package dev.meloda.fast.network.interceptor
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dev.meloda.fast.common.extensions.listenValue
|
||||||
|
import dev.meloda.fast.datastore.AppSettings
|
||||||
|
import dev.meloda.fast.datastore.CaptchaTokenResult
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class Error14HandlingInterceptor(
|
||||||
|
// private val domains: Set<String> = emptySet(),
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
private val cookie = AtomicReference<String?>(null)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val CAPTCHA_ERROR_CODE = 14
|
||||||
|
private const val CAPTCHA_ERROR_KIND = "need_captcha"
|
||||||
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request().withCookie()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
response.parseCookie()
|
||||||
|
if (request.shouldSkipCaptcha()) return response
|
||||||
|
val redirectUri = response.getRedirectUri() ?: return response
|
||||||
|
val token = passCaptchaAndGetToken(redirectUri)
|
||||||
|
return chain.proceed(chain.request().withCookie().withSuccessToken(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) {
|
||||||
|
val tokenResult = AtomicReference<Result<String>>(Result.failure(Exception("No result")))
|
||||||
|
|
||||||
|
executor.submit {
|
||||||
|
AppSettings.setCaptchaRedirectUri(redirectUri)
|
||||||
|
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri")
|
||||||
|
|
||||||
|
var job: Job? = null
|
||||||
|
job = AppSettings.getCaptchaResultFlow()
|
||||||
|
.listenValue(CoroutineScope(Dispatchers.IO)) {
|
||||||
|
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it")
|
||||||
|
if (it != CaptchaTokenResult.Initial) {
|
||||||
|
synchronized(tokenResult) {
|
||||||
|
Log.d(
|
||||||
|
"Error14Interceptor",
|
||||||
|
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
|
||||||
|
)
|
||||||
|
tokenResult.set(wrapResult(it))
|
||||||
|
tokenResult.notifyAll()
|
||||||
|
job?.cancel()
|
||||||
|
Log.d(
|
||||||
|
"Error14Interceptor",
|
||||||
|
"passCaptchaAndGetToken: NULL RESULT"
|
||||||
|
)
|
||||||
|
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
|
||||||
|
AppSettings.setCaptchaRedirectUri(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(tokenResult) {
|
||||||
|
if (tokenResult.get().getOrNull() == null) {
|
||||||
|
tokenResult.wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE")
|
||||||
|
tokenResult.get().getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun wrapResult(result: CaptchaTokenResult): Result<String> {
|
||||||
|
return when (result) {
|
||||||
|
// TODO: 03/05/2026, Danil Nikolaev: check again?
|
||||||
|
CaptchaTokenResult.Null -> Result.success("")
|
||||||
|
|
||||||
|
CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("")
|
||||||
|
|
||||||
|
is CaptchaTokenResult.Success -> Result.success(result.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.withSuccessToken(token: String): Request {
|
||||||
|
return newBuilder()
|
||||||
|
.url(url.newBuilder().addQueryParameter("success_token", token).build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.getRedirectUri(): String? {
|
||||||
|
val responseBody = JSONObject(peekBody(Long.MAX_VALUE).string())
|
||||||
|
return if (responseBody.has("error")) {
|
||||||
|
val stringError = try {
|
||||||
|
responseBody.getString("error")
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringError != null) {
|
||||||
|
if (stringError == CAPTCHA_ERROR_KIND && responseBody.has("redirect_uri")) {
|
||||||
|
responseBody.getString("redirect_uri")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val error = responseBody.getJSONObject("error")
|
||||||
|
if (error.getInt("error_code") == CAPTCHA_ERROR_CODE) {
|
||||||
|
error.getString("redirect_uri")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.shouldSkipCaptcha(): Boolean {
|
||||||
|
return false
|
||||||
|
// return !domains.contains(url.toUrl().host) && domains.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Response.parseCookie() {
|
||||||
|
headers("Set-Cookie").firstOrNull { it.contains("remixstlid") }?.let(cookie::set)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.withCookie(): Request {
|
||||||
|
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
|
||||||
|
private inline fun Any.wait() = (this as Object).wait()
|
||||||
|
|
||||||
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
|
||||||
|
private inline fun Any.notify() = (this as Object).notify()
|
||||||
|
|
||||||
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
|
||||||
|
private inline fun Any.notifyAll() = (this as Object).notifyAll()
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package dev.meloda.fast.auth.captcha.model
|
|
||||||
|
|
||||||
sealed class CaptchaValidationResult {
|
|
||||||
data object Empty : CaptchaValidationResult()
|
|
||||||
data object Valid : CaptchaValidationResult()
|
|
||||||
|
|
||||||
fun isValid() = this == Valid
|
|
||||||
}
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
package dev.meloda.fast.auth.captcha.navigation
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.NavGraphBuilder
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.toRoute
|
|
||||||
import dev.meloda.fast.auth.captcha.presentation.CaptchaRoute
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Captcha(val captchaImageUrl: String) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute<Captcha>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun NavGraphBuilder.captchaScreen(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onResult: (String) -> Unit
|
|
||||||
) {
|
|
||||||
composable<Captcha> {
|
|
||||||
CaptchaRoute(
|
|
||||||
onBack = onBack,
|
|
||||||
onResult = onResult
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.navigateToCaptcha(captchaImageUrl: String) {
|
|
||||||
this.navigate(Captcha(captchaImageUrl))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.setCaptchaResult(code: String?) {
|
|
||||||
this.previousBackStackEntry
|
|
||||||
?.savedStateHandle
|
|
||||||
?.set("captcha_code", code)
|
|
||||||
}
|
|
||||||
+121
-200
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package dev.meloda.fast.auth.captcha.validation
|
|
||||||
|
|
||||||
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
|
|
||||||
import dev.meloda.fast.auth.captcha.model.CaptchaValidationResult
|
|
||||||
|
|
||||||
class CaptchaValidator {
|
|
||||||
|
|
||||||
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
|
|
||||||
return when {
|
|
||||||
screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty
|
|
||||||
else -> CaptchaValidationResult.Valid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,18 +59,12 @@ class LoginViewModel(
|
|||||||
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
|
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"]
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-14
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-3
@@ -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 -> ""
|
||||||
|
|||||||
+4
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-40
@@ -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 }
|
||||||
|
|||||||
+3
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-5
@@ -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 = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -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(
|
||||||
|
|||||||
+21
-7
@@ -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 {
|
||||||
|
|||||||
+37
-20
@@ -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),
|
||||||
|
|||||||
+11
-4
@@ -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) {
|
||||||
|
|||||||
+13
-3
@@ -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) {
|
||||||
|
|||||||
+5
-2
@@ -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 -> {
|
||||||
|
|||||||
+6
-7
@@ -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(
|
||||||
|
|||||||
+6
-10
@@ -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
@@ -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" }
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user