mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 11:35:06 +03:00
Compare commits
10 Commits
@@ -62,8 +62,8 @@ extensions.configure<ApplicationExtension> {
|
||||
applicationId = "ru.fromchat"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = rootProject.extra["versionCode"] as Int
|
||||
versionName = rootProject.extra["versionName"] as String
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
@@ -166,9 +166,4 @@ dependencies {
|
||||
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":utils:shared"))
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation(libs.androidx.compose.material3)
|
||||
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_fromchat" />
|
||||
<meta-data
|
||||
android:name="firebase_messaging_installation_id_enabled"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
@@ -65,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
||||
override fun onRegistered(installationId: String) {
|
||||
Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
settings.putString("pending_fcm_token", token)
|
||||
settings.putString("pending_fcm_token", installationId)
|
||||
uploadPendingFcmTokenIfAvailable()
|
||||
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
||||
Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
|
||||
} catch (e: Exception) {
|
||||
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
||||
Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
|
||||
}
|
||||
|
||||
super.onNewToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
@file:Suppress("TaskMissingDescription")
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
@@ -7,6 +16,54 @@ plugins {
|
||||
alias(libs.plugins.sqldelight)
|
||||
}
|
||||
|
||||
abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||
@get:Input
|
||||
abstract val versionName: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val versionCode: Property<Int>
|
||||
|
||||
@get:Input
|
||||
abstract val debugBuild: Property<Boolean>
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDirectory: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val outRoot = outputDirectory.get().asFile
|
||||
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
|
||||
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
|
||||
}
|
||||
outRoot.deleteRecursively()
|
||||
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
|
||||
"""
|
||||
|package ru.fromchat
|
||||
|
|
||||
|/** Injected by Gradle into generated sources (not under src/). */
|
||||
|object AppBuildInfo {
|
||||
| const val version = "${versionName.get()}"
|
||||
| const val versionCode = ${versionCode.get()}
|
||||
| const val isDebug = ${debugBuild.get()}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
|
||||
versionName.set(rootProject.extra["versionName"] as String)
|
||||
versionCode.set(rootProject.extra["versionCode"] as Int)
|
||||
debugBuild.set(
|
||||
gradle.startParameter.taskNames.let { names ->
|
||||
!names.any { it.contains("Release", ignoreCase = true) } ||
|
||||
names.any { it.contains("Debug", ignoreCase = true) }
|
||||
},
|
||||
)
|
||||
outputDirectory.set(layout.buildDirectory.dir("generated/sources/appBuildInfo/kotlin"))
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.fromchat.shared"
|
||||
@@ -21,7 +78,6 @@ kotlin {
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64(),
|
||||
iosX64(),
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
@@ -37,6 +93,10 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
commonMain {
|
||||
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
|
||||
}
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
@@ -123,6 +183,7 @@ compose.resources {
|
||||
|
||||
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
||||
dependsOn("generateResourceAccessorsForCommonMain")
|
||||
dependsOn(generateAppBuildInfo)
|
||||
}
|
||||
|
||||
tasks.register("generateResourceAccessors") {
|
||||
@@ -134,4 +195,4 @@ tasks.register("generateResourceAccessors") {
|
||||
}.toTypedArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import com.google.android.gms.tasks.Task
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -13,17 +14,25 @@ import kotlin.coroutines.resumeWithException
|
||||
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
|
||||
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
|
||||
|
||||
private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont ->
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task: Task<String> ->
|
||||
if (task.isSuccessful) {
|
||||
cont.resume(task.result)
|
||||
} else {
|
||||
cont.resumeWithException(
|
||||
task.exception ?: IllegalStateException("Failed to fetch FCM token")
|
||||
)
|
||||
}
|
||||
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { cont ->
|
||||
addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
cont.resume(task.result)
|
||||
} else {
|
||||
cont.resumeWithException(
|
||||
task.exception ?: IllegalStateException("Firebase task failed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers with FCM and returns the Firebase Installation ID used for targeting. */
|
||||
private suspend fun fetchCurrentFcmToken(): String? = runCatching {
|
||||
FirebaseMessaging.getInstance().register().awaitResult()
|
||||
FirebaseInstallations.getInstance().id.awaitResult()
|
||||
}.getOrElse { e ->
|
||||
Logger.e("FcmReg", "Failed to fetch FCM installation id: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun postFcmToken(token: String): Boolean {
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
|
||||
outputFile.delete()
|
||||
}
|
||||
|
||||
val cipher = GCMBlockCipher.newInstance(AESEngine())
|
||||
val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
|
||||
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
|
||||
|
||||
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.net.http.SslError
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ru.fromchat.Logger
|
||||
|
||||
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
actual fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit,
|
||||
onChallengeVisible: () -> Unit,
|
||||
onChallengeHidden: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val onTokenState = rememberUpdatedState(onToken)
|
||||
val onReadyState = rememberUpdatedState(onReady)
|
||||
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
|
||||
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
|
||||
val onErrorState = rememberUpdatedState(onError)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val backgroundArgb = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||
|
||||
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||
val captchaUrl = remember(sitekey, lang) {
|
||||
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
|
||||
}
|
||||
|
||||
DisposableEffect(instanceId) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||
)
|
||||
onDispose {
|
||||
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
|
||||
}
|
||||
}
|
||||
|
||||
val bridge = remember {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun onGetToken(token: String) {
|
||||
val cleaned = token.trim()
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
|
||||
)
|
||||
mainHandler.post {
|
||||
if (cleaned.isNotEmpty()) {
|
||||
onTokenState.value(cleaned)
|
||||
} else {
|
||||
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
|
||||
onErrorState.value("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onChallengeVisible() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
|
||||
mainHandler.post { onChallengeVisibleState.value() }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onChallengeHidden() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
|
||||
mainHandler.post { onChallengeHiddenState.value() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(webView, lifecycleOwner) {
|
||||
val wv = webView ?: return@DisposableEffect onDispose { }
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
Logger.d(
|
||||
SmartCaptchaLog.TAG,
|
||||
"lifecycle $event id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)} " +
|
||||
"progress=${wv.progress}",
|
||||
)
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wv.onPause()
|
||||
Lifecycle.Event.ON_RESUME -> wv.onResume()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
wv.onResume()
|
||||
}
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
wv.onPause()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
Logger.i(SmartCaptchaLog.TAG, "AndroidView.factory id=$instanceId")
|
||||
WebView(context).apply {
|
||||
setBackgroundColor(backgroundArgb)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(bridge, "NativeClient")
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString()
|
||||
val host = request?.url?.host?.lowercase().orEmpty()
|
||||
val block = host.isNotEmpty() &&
|
||||
!host.endsWith("yandex.ru") &&
|
||||
!host.endsWith("yandex.com") &&
|
||||
!host.endsWith("yandex.net")
|
||||
Logger.d(
|
||||
SmartCaptchaLog.TAG,
|
||||
"shouldOverrideUrlLoading id=$instanceId block=$block " +
|
||||
"host=$host url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
return block
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onPageStarted id=$instanceId url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onPageFinished id=$instanceId progress=${view?.progress} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onReadyState.value()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedError id=$instanceId main=${request?.isForMainFrame} " +
|
||||
"code=${error?.errorCode} desc=${error?.description} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||
)
|
||||
if (request?.isForMainFrame == true) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onErrorState.value(error?.description?.toString().orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
errorResponse: WebResourceResponse?,
|
||||
) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedHttpError id=$instanceId main=${request?.isForMainFrame} " +
|
||||
"status=${errorResponse?.statusCode} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?,
|
||||
) {
|
||||
Logger.e(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedSslError id=$instanceId primary=${error?.primaryError} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(error?.url)}",
|
||||
)
|
||||
handler?.cancel()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onErrorState.value("SSL error")
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||
)
|
||||
loadUrl(captchaUrl)
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
update = { wv ->
|
||||
wv.setBackgroundColor(backgroundArgb)
|
||||
webView = wv
|
||||
},
|
||||
onRelease = { wv ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"AndroidView.onRelease id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)}",
|
||||
)
|
||||
wv.removeJavascriptInterface("NativeClient")
|
||||
wv.stopLoading()
|
||||
wv.destroy()
|
||||
if (webView === wv) webView = null
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="home">Главная</string>
|
||||
<string name="about">О приложении</string>
|
||||
<string name="about_version">Версия 1.0</string>
|
||||
<string name="about_version">Версия %1$s</string>
|
||||
<string name="about_link_telegram">Telegram</string>
|
||||
<string name="about_link_max">MAX</string>
|
||||
<string name="about_link_website">Сайт</string>
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="display_name_error">От 1 до 64 символов</string>
|
||||
<string name="fill_all_fields">Заполните все поля</string>
|
||||
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
||||
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
|
||||
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
||||
<string name="passwords_dont_match">Пароли не совпадают</string>
|
||||
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
||||
@@ -54,6 +55,9 @@
|
||||
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||
<string name="auth_captcha_title">Быстрая проверка</string>
|
||||
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
||||
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||
@@ -169,6 +173,7 @@
|
||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||
<string name="profile_not_found">Профиль не найден</string>
|
||||
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
||||
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
|
||||
<string name="action_open_settings">Настройки</string>
|
||||
<string name="action_chat">Написать</string>
|
||||
<string name="action_copy_link">Скопировать ссылку</string>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<string name="settings">Settings</string>
|
||||
<string name="home">Home</string>
|
||||
<string name="about">About</string>
|
||||
<string name="about_version">Version 1.0</string>
|
||||
<string name="about_version">Version %1$s</string>
|
||||
<string name="about_link_telegram">Telegram</string>
|
||||
<string name="about_link_max">MAX</string>
|
||||
<string name="about_link_website">Website</string>
|
||||
@@ -38,6 +38,7 @@
|
||||
<!-- Validation Errors -->
|
||||
<string name="fill_all_fields">Please fill in every field</string>
|
||||
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
||||
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
|
||||
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
||||
<string name="passwords_dont_match">The two passwords don’t match</string>
|
||||
<string name="auth_welcome_title">Welcome to FromChat</string>
|
||||
@@ -61,6 +62,9 @@
|
||||
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||
<string name="auth_captcha_title">Quick check</string>
|
||||
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
||||
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||
@@ -187,6 +191,7 @@
|
||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
||||
<string name="profile_not_found">This profile could not be found</string>
|
||||
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
|
||||
<string name="profile_invalid_link">Couldn’t open the link</string>
|
||||
<string name="action_open_settings">Settings</string>
|
||||
<string name="action_chat">Chat</string>
|
||||
<string name="action_copy_link">Copy link</string>
|
||||
|
||||
@@ -114,6 +114,7 @@ import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
@@ -495,6 +496,8 @@ object ApiClient {
|
||||
data class NeedsRegister(
|
||||
val yandexRequired: Boolean,
|
||||
val yandex: YandexOAuthParams?,
|
||||
val captchaRequired: Boolean,
|
||||
val captcha: SmartCaptchaParams?,
|
||||
) : AuthPasswordStepOutcome
|
||||
}
|
||||
|
||||
@@ -512,10 +515,26 @@ object ApiClient {
|
||||
.body<JsonObject>()
|
||||
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||
return when (status) {
|
||||
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
||||
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
|
||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||
)
|
||||
"needs_register" -> {
|
||||
val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||
val captchaRequired = raw["captcha_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||
val captcha = raw["captcha"]?.let {
|
||||
json.decodeFromJsonElement(SmartCaptchaParams.serializer(), it)
|
||||
}
|
||||
ru.fromchat.Logger.i(
|
||||
"SmartCaptcha",
|
||||
"authPasswordStep needs_register yandexRequired=$yandexRequired " +
|
||||
"captchaRequired=$captchaRequired " +
|
||||
"hasCaptchaObject=${captcha != null} " +
|
||||
"clientKeyLen=${captcha?.client_key?.length ?: 0}",
|
||||
)
|
||||
AuthPasswordStepOutcome.NeedsRegister(
|
||||
yandexRequired = yandexRequired,
|
||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||
captchaRequired = captchaRequired,
|
||||
captcha = captcha,
|
||||
)
|
||||
}
|
||||
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -386,7 +386,7 @@ object MessageCacheStore {
|
||||
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
||||
resolved = resolved.copy(
|
||||
fileAspectRatios = listOf(decodedAspect),
|
||||
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
|
||||
fileDimensions = listOf(thumbDims.first to thumbDims.second),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
@@ -21,7 +22,9 @@ object MessageRepository {
|
||||
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
||||
|
||||
fun observePublicMessages(): Flow<List<Message>> =
|
||||
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
|
||||
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)).map { rows ->
|
||||
ProfileCache.enrichPublicMessagesForDisplay(rows)
|
||||
}
|
||||
|
||||
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
||||
observeMessages(conversationIdForDm(otherUserId))
|
||||
|
||||
@@ -454,30 +454,33 @@ object ProfileCache {
|
||||
val uid = message.user_id
|
||||
if (uid <= 0) return
|
||||
val existing = get(uid)
|
||||
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val incomingPic = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||
if (existing != null && !existing.isClientPreviewOnly) {
|
||||
val patched = existing.copy(
|
||||
verified = message.verified ?: existing.verified,
|
||||
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
||||
displayName = existing.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplay,
|
||||
profilePicture = existing.profilePicture?.takeIf { it.isNotBlank() } ?: incomingPic,
|
||||
username = existing.username.trim().ifBlank { message.username.trim() },
|
||||
)
|
||||
if (patched != existing) put(patched)
|
||||
return
|
||||
}
|
||||
|
||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||
if (uname.isBlank() && incomingDisplay.isNullOrBlank()) return
|
||||
if (uname.isBlank() || incomingDisplay.isNullOrBlank()) {
|
||||
val displayName = incomingDisplay ?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||
if (uname.isBlank() && displayName.isNullOrBlank()) return
|
||||
if (uname.isBlank() || displayName.isNullOrBlank()) {
|
||||
Logger.d(
|
||||
"ProfileCache",
|
||||
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
|
||||
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!incomingDisplay.isNullOrBlank()}",
|
||||
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!displayName.isNullOrBlank()}",
|
||||
)
|
||||
}
|
||||
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
||||
val display = if (isDeleted) null else incomingDisplay
|
||||
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
|
||||
?: existing?.profilePicture
|
||||
val display = if (isDeleted) null else displayName
|
||||
val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
|
||||
|
||||
put(
|
||||
UserProfile(
|
||||
|
||||
@@ -27,11 +27,18 @@ data class YandexOAuthParams(
|
||||
val scope: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SmartCaptchaParams(
|
||||
val client_key: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthNeedsRegisterResponse(
|
||||
val status: String,
|
||||
val yandex_required: Boolean = false,
|
||||
val yandex: YandexOAuthParams? = null,
|
||||
val captcha_required: Boolean = false,
|
||||
val captcha: SmartCaptchaParams? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -70,4 +77,5 @@ data class RegisterConfirmRequest(
|
||||
val confirm_password: String,
|
||||
val bio: String? = null,
|
||||
val registration_proof: String? = null,
|
||||
val captcha_token: String? = null,
|
||||
)
|
||||
|
||||
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.ui.auth.AuthScreen
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||
import ru.fromchat.ui.calls.CallOverlay
|
||||
@@ -466,6 +468,10 @@ fun App(
|
||||
YandexOAuthScreen()
|
||||
}
|
||||
|
||||
composable(SmartCaptchaNav.ROUTE) {
|
||||
SmartCaptchaScreen()
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
MainScreen(
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
|
||||
/**
|
||||
* Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
|
||||
* Survives [AuthScreen] leaving composition when navigating to Yandex OAuth / SmartCaptcha routes.
|
||||
* Cleared on welcome / successful auth / explicit reset to username.
|
||||
*/
|
||||
internal object AuthRegisterDraft {
|
||||
@@ -14,7 +15,10 @@ internal object AuthRegisterDraft {
|
||||
var bio: String = ""
|
||||
var yandexRequired: Boolean = false
|
||||
var yandexParams: YandexOAuthParams? = null
|
||||
var captchaRequired: Boolean = false
|
||||
var captchaParams: SmartCaptchaParams? = null
|
||||
var registrationProof: String? = null
|
||||
var captchaToken: String? = null
|
||||
var page: Int = 0
|
||||
|
||||
fun clear() {
|
||||
@@ -25,7 +29,10 @@ internal object AuthRegisterDraft {
|
||||
bio = ""
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
page = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,20 +20,24 @@ import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||
import ru.fromchat.api.instance.ServerProbeResult
|
||||
import ru.fromchat.api.instance.probeServer
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||
import ru.fromchat.api.schema.core.ErrorResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||
import ru.fromchat.ui.auth.register.profileStepPage
|
||||
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
||||
@@ -58,6 +62,8 @@ internal sealed interface PasswordStepResult {
|
||||
data class NeedsRegister(
|
||||
val yandexRequired: Boolean,
|
||||
val yandex: YandexOAuthParams?,
|
||||
val captchaRequired: Boolean,
|
||||
val captcha: SmartCaptchaParams?,
|
||||
) : PasswordStepResult
|
||||
data class WrongPassword(val message: String) : PasswordStepResult
|
||||
data class RateLimited(val message: String) : PasswordStepResult
|
||||
@@ -120,10 +126,20 @@ internal suspend fun authPasswordStep(
|
||||
PasswordStepResult.LoginSuccess
|
||||
}
|
||||
|
||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
||||
yandexRequired = outcome.yandexRequired,
|
||||
yandex = outcome.yandex,
|
||||
)
|
||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"password step needs_register yandexRequired=${outcome.yandexRequired} " +
|
||||
"captchaRequired=${outcome.captchaRequired} " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(outcome.captcha?.client_key)}",
|
||||
)
|
||||
PasswordStepResult.NeedsRegister(
|
||||
yandexRequired = outcome.yandexRequired,
|
||||
yandex = outcome.yandex,
|
||||
captchaRequired = outcome.captchaRequired,
|
||||
captcha = outcome.captcha,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: ClientRequestException) {
|
||||
when (e.response.status.value) {
|
||||
@@ -147,8 +163,15 @@ internal suspend fun register(
|
||||
password: String,
|
||||
bio: String,
|
||||
registrationProof: String?,
|
||||
captchaToken: String?,
|
||||
unexpectedError: String,
|
||||
) = try {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"register confirm start username=${username.trim()} " +
|
||||
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
|
||||
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
|
||||
)
|
||||
fullLogin(username.trim(), password.trim()) {
|
||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||
ApiClient.authRegisterConfirm(
|
||||
@@ -159,17 +182,25 @@ internal suspend fun register(
|
||||
confirm_password = derived,
|
||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||
registration_proof = registrationProof,
|
||||
captcha_token = captchaToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
|
||||
RegisterResult.Success
|
||||
} catch (e: ClientRequestException) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"register confirm HTTP ${e.response.status.value} username=${username.trim()}",
|
||||
e,
|
||||
)
|
||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||
RegisterResult.UsernameTaken
|
||||
} else {
|
||||
RegisterResult.Error(parseClientError(e, unexpectedError))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
|
||||
RegisterResult.Error(unexpectedError, e)
|
||||
}
|
||||
|
||||
@@ -216,7 +247,11 @@ fun AuthScreen(
|
||||
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
||||
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||
var captchaRequired by remember { mutableStateOf(AuthRegisterDraft.captchaRequired) }
|
||||
var captchaParams by remember { mutableStateOf(AuthRegisterDraft.captchaParams) }
|
||||
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
||||
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
|
||||
val navController = LocalNavController.current
|
||||
|
||||
fun persistDraft() {
|
||||
AuthRegisterDraft.username = username
|
||||
@@ -226,7 +261,10 @@ fun AuthScreen(
|
||||
AuthRegisterDraft.bio = bio
|
||||
AuthRegisterDraft.yandexRequired = yandexRequired
|
||||
AuthRegisterDraft.yandexParams = yandexParams
|
||||
AuthRegisterDraft.captchaRequired = captchaRequired
|
||||
AuthRegisterDraft.captchaParams = captchaParams
|
||||
AuthRegisterDraft.registrationProof = registrationProof
|
||||
AuthRegisterDraft.captchaToken = captchaToken
|
||||
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||
}
|
||||
|
||||
@@ -257,7 +295,10 @@ fun AuthScreen(
|
||||
bio = ""
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
AuthRegisterDraft.clear()
|
||||
flowState.resetPredictiveState()
|
||||
scope.launch {
|
||||
@@ -269,7 +310,19 @@ fun AuthScreen(
|
||||
onDispose { persistDraft() }
|
||||
}
|
||||
|
||||
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
|
||||
LaunchedEffect(
|
||||
username,
|
||||
password,
|
||||
confirmPassword,
|
||||
displayName,
|
||||
bio,
|
||||
yandexRequired,
|
||||
yandexParams,
|
||||
captchaRequired,
|
||||
captchaParams,
|
||||
registrationProof,
|
||||
captchaToken,
|
||||
) {
|
||||
persistDraft()
|
||||
}
|
||||
|
||||
@@ -284,7 +337,12 @@ fun AuthScreen(
|
||||
snapshotFlow { flowState.pagerState.currentPage }
|
||||
.collect { page ->
|
||||
AuthRegisterDraft.page = page
|
||||
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
|
||||
// Don't skip the Yandex slot mid predictive-back — that fights pager morph
|
||||
// (Profile ↔ ConfirmPassword) and glitches when the gesture is cancelled.
|
||||
if (page == AuthFlowStep.YandexId.ordinal &&
|
||||
!yandexRequired &&
|
||||
flowState.predictiveFromPage == null
|
||||
) {
|
||||
val target = if (page > settledPage) {
|
||||
AuthFlowStep.Profile.ordinal
|
||||
} else {
|
||||
@@ -301,7 +359,10 @@ fun AuthScreen(
|
||||
confirmPassword = ""
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.Password.ordinal -> {
|
||||
@@ -309,12 +370,16 @@ fun AuthScreen(
|
||||
confirmPassword = ""
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
||||
// Keep confirm password when returning from Yandex ID / OAuth.
|
||||
// Keep confirm password when returning from Yandex ID / OAuth / captcha.
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.YandexId.ordinal -> {
|
||||
@@ -326,6 +391,47 @@ fun AuthScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// After predictive back settles on the skipped Yandex slot, jump to ConfirmPassword.
|
||||
LaunchedEffect(flowState.predictiveFromPage, yandexRequired) {
|
||||
if (flowState.predictiveFromPage != null || yandexRequired) return@LaunchedEffect
|
||||
if (flowState.pagerState.currentPage == AuthFlowStep.YandexId.ordinal) {
|
||||
flowState.pagerState.scrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_TOKEN, null).collect { token ->
|
||||
if (token == null) return@collect
|
||||
handle.remove<String>(SmartCaptchaNav.RESULT_TOKEN)
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"AuthScreen received token ${SmartCaptchaLog.redactToken(token)} → Profile",
|
||||
)
|
||||
captchaToken = token
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_ERROR, null).collect { message ->
|
||||
if (message == null) return@collect
|
||||
handle.remove<String>(SmartCaptchaNav.RESULT_ERROR)
|
||||
Logger.w(SmartCaptchaLog.TAG, "AuthScreen captcha error: $message")
|
||||
snackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun openCaptchaRoute(clientKey: String) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"navigate ${SmartCaptchaNav.ROUTE} clientKey=${SmartCaptchaLog.redactKey(clientKey)}",
|
||||
)
|
||||
SmartCaptchaNav.pending = SmartCaptchaNav.Session(clientKey = clientKey)
|
||||
navController.navigate(SmartCaptchaNav.ROUTE)
|
||||
}
|
||||
|
||||
val yandexStep = yandexParams
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
@@ -343,10 +449,18 @@ fun AuthScreen(
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onLoginSuccess = wrappedAuthSuccess,
|
||||
onNeedsRegister = { required, params ->
|
||||
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||
)
|
||||
yandexRequired = required
|
||||
yandexParams = params
|
||||
captchaRequired = captchaReq
|
||||
captchaParams = captcha
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
@@ -356,10 +470,23 @@ fun AuthScreen(
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
if (yandexRequired && yandexParams != null) {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||
} else {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
yandexRequired && yandexParams != null -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||
}
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||
)
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
@@ -379,7 +506,16 @@ fun AuthScreen(
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
@@ -392,6 +528,7 @@ fun AuthScreen(
|
||||
onBioChange = { bio = it },
|
||||
password = password,
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
onRegisterSuccess = wrappedAuthSuccess,
|
||||
onUsernameTaken = resetToUsername,
|
||||
onSnackbar = ::snackbar,
|
||||
|
||||
@@ -35,6 +35,7 @@ import ru.fromchat.login
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.show_password
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
@@ -54,7 +55,12 @@ internal fun passwordStepPage(
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
|
||||
onNeedsRegister: suspend (
|
||||
yandexRequired: Boolean,
|
||||
yandex: YandexOAuthParams?,
|
||||
captchaRequired: Boolean,
|
||||
captcha: SmartCaptchaParams?,
|
||||
) -> Unit,
|
||||
onSnackbar: (String, Throwable?) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -135,7 +141,12 @@ internal fun passwordStepPage(
|
||||
}
|
||||
|
||||
is PasswordStepResult.NeedsRegister -> {
|
||||
onNeedsRegister(result.yandexRequired, result.yandex)
|
||||
onNeedsRegister(
|
||||
result.yandexRequired,
|
||||
result.yandex,
|
||||
result.captchaRequired,
|
||||
result.captcha,
|
||||
)
|
||||
}
|
||||
|
||||
is PasswordStepResult.WrongPassword -> {
|
||||
|
||||
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
|
||||
import ru.fromchat.ui.components.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.username_chars_error
|
||||
import ru.fromchat.username_length_error
|
||||
|
||||
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
|
||||
private fun isAllowedUsernameChar(ch: Char) =
|
||||
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun usernameStepPage(
|
||||
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val usernameLenError = stringResource(Res.string.username_length_error)
|
||||
val usernameCharsError = stringResource(Res.string.username_chars_error)
|
||||
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
||||
val unexpected = stringResource(Res.string.error_unexpected)
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
singleLine = true,
|
||||
isError = hasProhibitedChars,
|
||||
supportingText = if (hasProhibitedChars) {
|
||||
{ Text(usernameCharsError) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
if (busy || hasProhibitedChars) return@ActionButton
|
||||
val trimmed = username.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
onSnackbar(fillAll, null)
|
||||
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
enabled = !busy && !hasProhibitedChars,
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
/** Safe summaries for SmartCaptcha logs (never dump full tokens/secrets). */
|
||||
internal object SmartCaptchaLog {
|
||||
const val TAG = "SmartCaptcha"
|
||||
|
||||
fun redactKey(value: String?): String {
|
||||
val v = value?.trim().orEmpty()
|
||||
if (v.isEmpty()) return "(empty)"
|
||||
if (v.length <= 8) return "len=${v.length}"
|
||||
return "len=${v.length} prefix=${v.take(4)}…suffix=${v.takeLast(4)}"
|
||||
}
|
||||
|
||||
fun redactToken(value: String?): String {
|
||||
val v = value?.trim().orEmpty()
|
||||
if (v.isEmpty()) return "(empty)"
|
||||
return "len=${v.length} prefix=${v.take(6)}…"
|
||||
}
|
||||
|
||||
fun shortUrl(url: String?): String {
|
||||
if (url.isNullOrBlank()) return "null"
|
||||
// Drop query sitekey from logs; keep path/host.
|
||||
val q = url.indexOf('?')
|
||||
val base = if (q >= 0) url.substring(0, q) else url
|
||||
return if (base.length <= 120) base else base.take(117) + "..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Root [androidx.navigation.NavController] route for SmartCaptcha.
|
||||
* Client key is staged in [pending] before navigate (not embedded in the route).
|
||||
*/
|
||||
internal object SmartCaptchaNav {
|
||||
const val ROUTE = "smartCaptcha"
|
||||
const val RESULT_TOKEN = "smartcaptcha_token"
|
||||
const val RESULT_ERROR = "smartcaptcha_error"
|
||||
|
||||
data class Session(
|
||||
val clientKey: String,
|
||||
)
|
||||
|
||||
val SessionSaver = listSaver<Session?, String>(
|
||||
save = { session ->
|
||||
if (session == null) emptyList()
|
||||
else listOf(session.clientKey)
|
||||
},
|
||||
restore = { saved ->
|
||||
if (saved.isEmpty()) null
|
||||
else Session(clientKey = saved[0])
|
||||
},
|
||||
)
|
||||
|
||||
@Volatile
|
||||
var pending: Session? = null
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_captcha_failed
|
||||
import ru.fromchat.auth_captcha_title
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
/**
|
||||
* Full-route SmartCaptcha screen (shown after password confirm when Yandex OAuth is off).
|
||||
* Returns a token / error via [SmartCaptchaNav] saved-state results.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun SmartCaptchaScreen() {
|
||||
val navController = LocalNavController.current
|
||||
var session by rememberSaveable(stateSaver = SmartCaptchaNav.SessionSaver) {
|
||||
mutableStateOf(SmartCaptchaNav.pending)
|
||||
}
|
||||
var pageReady by remember { mutableStateOf(false) }
|
||||
val failedMessage = stringResource(Res.string.auth_captcha_failed)
|
||||
val barColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
val languageTag = Locale.current.toLanguageTag()
|
||||
val screenId = remember { (100000..999999).random().toString(16) }
|
||||
|
||||
DisposableEffect(screenId) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"route enter id=$screenId sessionNull=${session == null} " +
|
||||
"pendingNull=${SmartCaptchaNav.pending == null} " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(session?.clientKey)} languageTag=$languageTag",
|
||||
)
|
||||
onDispose {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route dispose id=$screenId pageReady=$pageReady")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(session) {
|
||||
if (session == null) {
|
||||
Logger.w(SmartCaptchaLog.TAG, "session null → popBackStack id=$screenId")
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
SmartCaptchaNav.pending = session
|
||||
}
|
||||
}
|
||||
|
||||
val active = session ?: return
|
||||
|
||||
fun finishWithToken(token: String) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"finishWithToken id=$screenId ${SmartCaptchaLog.redactToken(token)}",
|
||||
)
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(SmartCaptchaNav.RESULT_TOKEN, token)
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
fun finishWithError(message: String) {
|
||||
Logger.w(SmartCaptchaLog.TAG, "finishWithError id=$screenId message=$message")
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(SmartCaptchaNav.RESULT_ERROR, message)
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "cancel id=$screenId")
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Top bar draws into the status bar; bottom nav-bar inset keeps the WebView above it
|
||||
// while [containerColor] still paints the gesture/nav area.
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.auth_captcha_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { cancel() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = barColor,
|
||||
scrolledContainerColor = barColor,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = barColor,
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
SmartCaptchaWebView(
|
||||
sitekey = active.clientKey,
|
||||
languageTag = languageTag,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onToken = { finishWithToken(it) },
|
||||
onReady = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route pageReady id=$screenId")
|
||||
pageReady = true
|
||||
},
|
||||
onChallengeVisible = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route challengeVisible id=$screenId")
|
||||
},
|
||||
onChallengeHidden = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route challengeHidden id=$screenId")
|
||||
},
|
||||
onError = { message ->
|
||||
finishWithError(message.ifBlank { failedMessage })
|
||||
},
|
||||
)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = !pageReady,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(barColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularWavyProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Platform WebView that loads Yandex SmartCaptcha and reports the verification token.
|
||||
*/
|
||||
@Composable
|
||||
expect fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit = {},
|
||||
onChallengeVisible: () -> Unit = {},
|
||||
onChallengeHidden: () -> Unit = {},
|
||||
onError: (String) -> Unit = {},
|
||||
)
|
||||
@@ -56,6 +56,7 @@ internal fun profileStepPage(
|
||||
onBioChange: (String) -> Unit,
|
||||
password: String,
|
||||
registrationProof: String?,
|
||||
captchaToken: String?,
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onUsernameTaken: () -> Unit,
|
||||
onSnackbar: (String, Throwable?) -> Unit,
|
||||
@@ -149,6 +150,7 @@ internal fun profileStepPage(
|
||||
password = password,
|
||||
bio = bio.trim(),
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -1189,7 +1189,7 @@ fun ChatScreen(
|
||||
.getOrNull(panelState.messages.lastIndex - 1)
|
||||
previous != null &&
|
||||
messageListKey(previous) == listKey &&
|
||||
classifyEnterMode(previous, newest!!) ==
|
||||
classifyEnterMode(previous, newest) ==
|
||||
EnterMode.ExtendGroup
|
||||
}
|
||||
val showTimestamp = when {
|
||||
|
||||
+4
-4
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadPaused && canDownload -> file?.let { downloadFile ->
|
||||
downloadPaused && file != null && canDownload -> {
|
||||
{
|
||||
AttachmentDownloadNotifier.beginDownload(
|
||||
messageId = messageId,
|
||||
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
|
||||
val ok = downloadAttachmentToCache(
|
||||
messageId = messageId,
|
||||
fileIndex = fileIndex,
|
||||
file = downloadFile,
|
||||
file = file,
|
||||
dmEnvelope = dmEnvelope,
|
||||
currentUserId = currentUserId,
|
||||
clientMessageId = clientMessageId,
|
||||
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
|
||||
}
|
||||
}
|
||||
}
|
||||
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
|
||||
file != null && canDownload && !isDownloading && !downloadPaused -> {
|
||||
{
|
||||
AttachmentDownloadNotifier.beginDownload(
|
||||
messageId = messageId,
|
||||
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
|
||||
val ok = downloadAttachmentToCache(
|
||||
messageId = messageId,
|
||||
fileIndex = fileIndex,
|
||||
file = downloadFile,
|
||||
file = file,
|
||||
dmEnvelope = dmEnvelope,
|
||||
currentUserId = currentUserId,
|
||||
clientMessageId = clientMessageId,
|
||||
|
||||
+3
@@ -84,6 +84,7 @@ class PublicChatPanel(
|
||||
ProfileCache.enrichPublicMessageForDisplay(
|
||||
mergeMessageUiFields(fresh, message).copy(
|
||||
username = fresh.username,
|
||||
displayName = fresh.displayName,
|
||||
profile_picture = fresh.profile_picture,
|
||||
verified = fresh.verified,
|
||||
verificationStatus = fresh.verificationStatus,
|
||||
@@ -179,6 +180,8 @@ class PublicChatPanel(
|
||||
}
|
||||
|
||||
suspend fun hydrateFromLocalCache() {
|
||||
// Sender display names live in ProfileCache (message rows only store userId).
|
||||
runCatching { ProfileCache.hydrateFromDisk() }
|
||||
hydrateMessagesFromLocalCache()
|
||||
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
||||
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
|
||||
|
||||
@@ -129,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
||||
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
|
||||
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||
// DB rows only store userId; sender identity is reconstructed from ProfileCache and can
|
||||
// briefly be blank. Keep non-blank panel fields (e.g. from a network payload) so text
|
||||
// avatars / names are not wiped on every SQLDelight emission.
|
||||
val merged = db.copy(
|
||||
username = db.username.trim().ifBlank { panel.username.trim() },
|
||||
displayName = db.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: panel.displayName?.trim()?.takeIf { it.isNotEmpty() },
|
||||
profile_picture = db.profile_picture?.takeIf { it.isNotBlank() }
|
||||
?: panel.profile_picture?.takeIf { it.isNotBlank() },
|
||||
verified = db.verified ?: panel.verified,
|
||||
verificationStatus = db.verificationStatus ?: panel.verificationStatus,
|
||||
pendingFileUri = when {
|
||||
confirmed -> localPreview
|
||||
else -> panel.pendingFileUri ?: db.pendingFileUri
|
||||
|
||||
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
|
||||
flowState.resetPredictiveState()
|
||||
return@PredictiveBackHandler
|
||||
}
|
||||
// Cancel must always reverse — never commit just because progress crossed the threshold.
|
||||
scope.launch {
|
||||
val commit = lastProgress >= predictiveThreshold
|
||||
finishPredictiveMorph(
|
||||
flowState = flowState,
|
||||
pagerState = pagerState,
|
||||
startProgress = lastProgress,
|
||||
targetProgress = if (commit) 1f else 0f,
|
||||
targetProgress = 0f,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+6
-2
@@ -20,7 +20,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||
)
|
||||
|
||||
val closeSheet: () -> Unit = {
|
||||
scope.launch {
|
||||
|
||||
@@ -938,13 +938,13 @@ internal fun DmConversationRowContent(
|
||||
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
||||
val peerTitle = when {
|
||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
||||
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
||||
}
|
||||
val avatarInitialsLabel = when {
|
||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
||||
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -529,7 +528,7 @@ fun ChatsTab(
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val clipboard = supportClipboardManagerImpl
|
||||
val haptic = rememberHapticFeedback()
|
||||
val scope = rememberCoroutineScope()
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
@@ -1092,7 +1091,9 @@ fun ChatsTab(
|
||||
chatContextMenuOverlay.onLink = {
|
||||
when (contextMenuState.target) {
|
||||
ChatContextMenuTarget.Public -> {
|
||||
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) }
|
||||
publicChatLink?.let { link ->
|
||||
scope.launch { clipboard.setText(link) }
|
||||
}
|
||||
}
|
||||
ChatContextMenuTarget.Dm -> {
|
||||
val link = contextMenuState.otherUserId?.let { userId ->
|
||||
@@ -1100,7 +1101,7 @@ fun ChatsTab(
|
||||
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
|
||||
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
|
||||
}
|
||||
link?.let { clipboardManager.setText(AnnotatedString(it)) }
|
||||
link?.let { scope.launch { clipboard.setText(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.pr0gramm3r101.components.ListItem
|
||||
import com.pr0gramm3r101.ui.Website
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
import ru.fromchat.AppBuildInfo
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about
|
||||
import ru.fromchat.about_link_max
|
||||
@@ -111,7 +112,10 @@ fun AboutScreen() {
|
||||
BrandTitle(Modifier.padding(bottom = 4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.about_version),
|
||||
text = stringResource(
|
||||
Res.string.about_version,
|
||||
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
|
||||
@@ -46,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -595,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
sheetDevice?.let { d ->
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||
)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
|
||||
sheetState = sheetState
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
DeviceSessionDetailBottomSheet(
|
||||
d = d,
|
||||
|
||||
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -389,13 +390,15 @@ fun LogFilesScreen(
|
||||
}
|
||||
|
||||
if (showShareSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showShareSheet = false
|
||||
pendingSharePaths = emptyList()
|
||||
},
|
||||
sheetState = sheetState,
|
||||
sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||
),
|
||||
) {
|
||||
LogsShareBottomSheet(
|
||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||
|
||||
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -632,13 +633,15 @@ fun LogsScreen() {
|
||||
}
|
||||
|
||||
if (showShareSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showShareSheet = false
|
||||
pendingShareRequest = null
|
||||
},
|
||||
sheetState = sheetState,
|
||||
sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||
),
|
||||
) {
|
||||
LogsShareBottomSheet(
|
||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||
@@ -648,7 +651,10 @@ fun LogsScreen() {
|
||||
}
|
||||
|
||||
if (showCleanSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCleanSheet = false },
|
||||
sheetState = sheetState,
|
||||
|
||||
@@ -105,10 +105,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ContextMenuPressable
|
||||
@@ -177,6 +174,7 @@ import ru.fromchat.profile_headline_bio
|
||||
import ru.fromchat.profile_headline_member_since
|
||||
import ru.fromchat.profile_headline_username
|
||||
import ru.fromchat.profile_headline_verification
|
||||
import ru.fromchat.profile_invalid_link
|
||||
import ru.fromchat.profile_load_failed
|
||||
import ru.fromchat.profile_not_found
|
||||
import ru.fromchat.profile_verified_support
|
||||
@@ -267,7 +265,6 @@ fun ProfileScreen(
|
||||
onOpenSettings: () -> Unit = {},
|
||||
showBackButton: Boolean = false,
|
||||
) {
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val clipboard = supportClipboardManagerImpl
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -377,7 +374,7 @@ fun ProfileScreen(
|
||||
ApiClient.applyOwnProfile(refreshed)
|
||||
state = latestUi.copy(profile = refreshed, error = null)
|
||||
} catch (_: Exception) {
|
||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
||||
ownUserId.let { ProfileCache.get(it) }?.let { cached ->
|
||||
state = latestUi.copy(profile = cached)
|
||||
}
|
||||
}
|
||||
@@ -609,7 +606,7 @@ fun ProfileScreen(
|
||||
ProfileAction(
|
||||
label = labelLink,
|
||||
icon = Icons.Filled.Link,
|
||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
||||
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||
),
|
||||
ProfileAction(
|
||||
label = labelSettings,
|
||||
@@ -654,7 +651,7 @@ fun ProfileScreen(
|
||||
ProfileAction(
|
||||
label = labelLink,
|
||||
icon = Icons.Filled.Link,
|
||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
||||
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||
)
|
||||
)
|
||||
if (ServerConfig.callsEnabled) {
|
||||
@@ -831,10 +828,10 @@ fun ProfileScreen(
|
||||
labelCopy = labelCopy,
|
||||
labelEdit = labelEdit,
|
||||
detailsBringIntoView = detailsBringIntoView,
|
||||
clipboardManager = clipboardManager,
|
||||
clipboard = clipboard,
|
||||
navController = navController,
|
||||
scope = scope,
|
||||
snackbarHostState = snackbarHostState,
|
||||
openContextMenuHaptic = openContextMenuHaptic,
|
||||
onBack = onBack,
|
||||
onProfileUpdated = { updated ->
|
||||
@@ -880,7 +877,6 @@ fun PublicChatProfileScreen(
|
||||
initialDisplayName: String? = null,
|
||||
showBackButton: Boolean = false,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val clipboard = supportClipboardManagerImpl
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -962,7 +958,7 @@ fun PublicChatProfileScreen(
|
||||
ProfileAction(
|
||||
label = labelLink,
|
||||
icon = Icons.Filled.Link,
|
||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
||||
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||
),
|
||||
ProfileAction(
|
||||
label = labelSearch,
|
||||
@@ -1003,15 +999,15 @@ fun PublicChatProfileScreen(
|
||||
when {
|
||||
useSharedAvatar && displayName.isNotBlank() -> {
|
||||
item {
|
||||
with(sharedTransitionScope!!) {
|
||||
with(sharedTransitionScope) {
|
||||
Avatar(
|
||||
profilePictureUrl = null,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.sharedElement(
|
||||
rememberSharedContentState(key = sharedAvatarKey!!),
|
||||
animatedVisibilityScope = animatedVisibilityScope!!,
|
||||
rememberSharedContentState(key = sharedAvatarKey),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
.size(104.dp),
|
||||
)
|
||||
@@ -1051,6 +1047,7 @@ fun PublicChatProfileScreen(
|
||||
labelCopy = labelCopy,
|
||||
clipboard = clipboard,
|
||||
scope = scope,
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1090,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
|
||||
labelCopy: String,
|
||||
clipboard: SupportClipboardManager,
|
||||
scope: CoroutineScope,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -1136,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
|
||||
supportingSlot = {
|
||||
ProfileBioMarkdown(
|
||||
content = resolvedProfile.bio.orEmpty(),
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
},
|
||||
position = ListItemPosition.START,
|
||||
@@ -1320,10 +1319,10 @@ private fun ProfileLoadedBody(
|
||||
labelCopy: String,
|
||||
labelEdit: String,
|
||||
detailsBringIntoView: BringIntoViewRequester,
|
||||
clipboardManager: ClipboardManager,
|
||||
clipboard: SupportClipboardManager,
|
||||
navController: NavController,
|
||||
scope: CoroutineScope,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
openContextMenuHaptic: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
@@ -1349,7 +1348,7 @@ private fun ProfileLoadedBody(
|
||||
onContextMenuOpen = openContextMenuHaptic,
|
||||
contextMenu = {
|
||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||
clipboardManager.setText(AnnotatedString(displayName))
|
||||
scope.launch { clipboard.setText(displayName) }
|
||||
}
|
||||
if (isOwnProfile) {
|
||||
item(Icons.Rounded.Edit, labelEdit) {
|
||||
@@ -1453,9 +1452,9 @@ private fun ProfileLoadedBody(
|
||||
},
|
||||
contextMenu = {
|
||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(usernameForLinks.orEmpty()),
|
||||
)
|
||||
scope.launch {
|
||||
clipboard.setText(usernameForLinks.orEmpty())
|
||||
}
|
||||
}
|
||||
if (isOwnProfile) {
|
||||
item(Icons.Rounded.Edit, labelEdit) {
|
||||
@@ -1491,7 +1490,7 @@ private fun ProfileLoadedBody(
|
||||
},
|
||||
contextMenu = {
|
||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||
clipboardManager.setText(AnnotatedString(memberSinceText))
|
||||
scope.launch { clipboard.setText(memberSinceText) }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1505,7 +1504,10 @@ private fun ProfileLoadedBody(
|
||||
headline = headlineBio,
|
||||
supportingSlot = {
|
||||
key(resolvedProfile.id, bioContent) {
|
||||
ProfileBioMarkdown(content = bioContent)
|
||||
ProfileBioMarkdown(
|
||||
content = bioContent,
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
}
|
||||
},
|
||||
divider = true,
|
||||
@@ -1864,14 +1866,27 @@ private fun ProfileLoadedBody(
|
||||
@Composable
|
||||
private fun ProfileBioMarkdown(
|
||||
content: String,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
|
||||
|
||||
MarkdownPlain(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
onLinkClick = { uriHandler.openUri(it) },
|
||||
onLinkClick = { uri ->
|
||||
runCatching { uriHandler.openUri(uri) }.onFailure {
|
||||
scope.launch {
|
||||
snackbarHostState.showReplacingSnackbar(
|
||||
message = invalidLinkMessage,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.fromchat.Logger
|
||||
|
||||
@Composable
|
||||
actual fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit,
|
||||
onChallengeVisible: () -> Unit,
|
||||
onChallengeHidden: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(sitekey) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||
"languageTag=$languageTag",
|
||||
)
|
||||
onError("Captcha is not available on this platform yet.")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ plugins {
|
||||
alias(libs.plugins.google.services) apply false
|
||||
}
|
||||
|
||||
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
|
||||
extra["versionName"] = "1.1.4"
|
||||
extra["versionCode"] = 114
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
|
||||
+23
-25
@@ -1,52 +1,52 @@
|
||||
[versions]
|
||||
agp = "9.1.1"
|
||||
agp = "9.3.1"
|
||||
androidx-activityCompose = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core-ktx = "1.18.0"
|
||||
androidx-exifinterface = "1.3.7"
|
||||
coilCompose = "3.4.0"
|
||||
compose-multiplatform = "1.10.3"
|
||||
androidx-core-ktx = "1.19.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
coilCompose = "3.5.0"
|
||||
compose-multiplatform = "1.11.1"
|
||||
#noinspection NewerVersionAvailable
|
||||
constraintlayout = "0.6.1-shaded"
|
||||
constraintlayout = "0.8.0-shaded"
|
||||
coreSplashscreen = "1.2.0"
|
||||
firebaseMessaging = "25.0.2"
|
||||
googleServices = "4.4.4"
|
||||
firebaseMessaging = "25.1.1"
|
||||
googleServices = "4.5.0"
|
||||
haze = "1.7.2"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.10"
|
||||
adaptiveAndroid = "1.2.0"
|
||||
biometric = "1.4.0-alpha07"
|
||||
gson = "2.14.0"
|
||||
kotlinxIoBytestring = "0.9.0"
|
||||
kotlinxIoBytestring = "0.9.1"
|
||||
kotlinxCoroutinesCore = "1.11.0"
|
||||
kotlinxIoCore = "0.9.0"
|
||||
kotlinxIoCore = "0.9.1"
|
||||
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
||||
multiplatformSettings = "1.3.0"
|
||||
serialization = "2.3.21"
|
||||
serialization = "2.4.10"
|
||||
serialization-json = "1.11.0"
|
||||
material = "1.13.0"
|
||||
material = "1.14.0"
|
||||
activityKtx = "1.13.0"
|
||||
navigationCompose = "2.9.2"
|
||||
datastore = "1.2.1"
|
||||
security-crypto = "1.1.0"
|
||||
ktor = "3.4.3"
|
||||
ktor = "3.5.1"
|
||||
slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.8.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
composeBom = "2026.05.00"
|
||||
lifecycleRuntimeKtx = "2.11.0"
|
||||
composeBom = "2026.06.01"
|
||||
composeMaterialIconsExtended = "1.7.3"
|
||||
composeMaterial3 = "1.10.0-alpha05"
|
||||
composeComponents = "1.10.3"
|
||||
composeMaterial3 = "1.12.0-alpha03"
|
||||
composeComponents = "1.11.1"
|
||||
playServicesBase = "18.10.0"
|
||||
tweetnaclJava = "1.1.3"
|
||||
androidxWork = "2.11.2"
|
||||
cryptography-kotlin = "0.6.0"
|
||||
krypto = "4.0.10"
|
||||
sqldelight = "2.3.2"
|
||||
livekitAndroid = "2.25.2"
|
||||
livekitAndroidComposeComponents = "2.3.0"
|
||||
markdownRendererM3 = "0.41.0"
|
||||
bouncycastle = "1.79"
|
||||
webkit = "1.14.0"
|
||||
livekitAndroid = "2.27.0"
|
||||
livekitAndroidComposeComponents = "2.4.0"
|
||||
markdownRendererM3 = "0.43.0"
|
||||
bouncycastle = "1.85"
|
||||
webkit = "1.16.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
@@ -87,7 +87,6 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
|
||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
|
||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
@@ -104,7 +103,6 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
||||
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
|
||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
||||
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
|
||||
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
||||
|
||||
+2
-5
@@ -1,9 +1,6 @@
|
||||
#Tue Jul 21 20:27:03 MSK 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -18,8 +18,7 @@ kotlin {
|
||||
|
||||
listOf(
|
||||
iosArm64(),
|
||||
iosSimulatorArm64(),
|
||||
iosX64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "shared"
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ actual fun Clipboard.toSupport(): SupportClipboardManager {
|
||||
|
||||
override suspend fun getText(): String? {
|
||||
val entry = clipboard.getClipEntry() ?: return null
|
||||
return entry.clipData?.getItemAt(0)?.text?.toString()
|
||||
return entry.clipData.getItemAt(0)?.text?.toString()
|
||||
}
|
||||
|
||||
override fun setTextListener(listener: (String) -> Unit) {
|
||||
|
||||
+8
-1
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.pr0gramm3r101.utils.settings
|
||||
|
||||
import androidx.core.content.edit
|
||||
@@ -7,6 +9,12 @@ import com.pr0gramm3r101.utils.UtilsLibrary.context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Secure prefs via EncryptedSharedPreferences.
|
||||
*
|
||||
* These APIs are deprecated in favor of DataStore + Tink (`datastore-tink`, DataStore 1.3+).
|
||||
* Kept until that stack is stable enough to migrate auth/identity keys without risk.
|
||||
*/
|
||||
class AndroidSecureSettings : Settings {
|
||||
private val masterKey by lazy {
|
||||
MasterKey.Builder(context)
|
||||
@@ -80,4 +88,3 @@ class AndroidSecureSettings : Settings {
|
||||
encryptedPrefs.contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user