12 Commits

46 changed files with 1016 additions and 158 deletions
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+3 -1
View File
@@ -31,4 +31,6 @@ google-services.json
*.log
premortem-transcript-*.md
premortem-report-*.html
premortem-report-*.html
target/
+2 -7
View File
@@ -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")
}
+3
View File
@@ -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)
}
}
}
+63 -2
View File
@@ -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 {
@@ -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)
@@ -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 dont 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 dont 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 youre 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">Couldnt 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">Couldnt 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))
}
}
@@ -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),
)
}
}
@@ -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 {
@@ -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,
@@ -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,
)
}
},
@@ -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.")
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -2
View File
@@ -18,8 +18,7 @@ kotlin {
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
@@ -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) {
@@ -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)
}
}