16 Commits

71 changed files with 2271 additions and 571 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"
+2
View File
@@ -32,3 +32,5 @@ google-services.json
premortem-transcript-*.md
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")
}
+4 -1
View File
@@ -21,7 +21,10 @@
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" />
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() {
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
val sender = pushData["sender_display_name"]
?.takeIf { it.isNotBlank() }
?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) {
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch
}
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
NotificationHelper.showFallbackPushNotification(
context = applicationContext,
title = title,
body = body,
sender = sender,
messageId = fallbackMessageId,
isDirectMessage = false,
senderId = senderId,
)
}
if (isDirectMessage) {
NotificationHelper.fetchAndNotify(
applicationContext,
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
dmSenderName = sender,
)
} else {
NotificationHelper.fetchAndNotify(applicationContext)
// Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
}
} catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -72,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)
}
}
}
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity
import ru.fromchat.Logger
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview
@@ -53,13 +58,20 @@ object NotificationHelper {
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val SUMMARY_NOTIFICATION_ID = 1000000
private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
const val KEY_TEXT_REPLY = "key_text_reply"
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publicFetchMutex = Mutex()
private var publicFetchJob: Job? = null
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings(
@@ -72,6 +84,24 @@ object NotificationHelper {
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
private fun publicConversationTitle(context: Context): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim()
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: context.getString(R.string.public_chat)
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
ProfileCache.get(message.user_id)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return message.username.trim().ifBlank { "FromChat" }
}
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent(
@@ -124,7 +154,6 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context
@@ -141,6 +170,17 @@ object NotificationHelper {
}
}
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
fun schedulePublicFetchAndNotify(context: Context) {
publicFetchJob?.cancel()
publicFetchJob = helperScope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(context.applicationContext, includeDmMessages = false)
}
}
}
suspend fun fetchAndNotify(
context: Context,
includeDmMessages: Boolean = false,
@@ -168,10 +208,7 @@ object NotificationHelper {
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages)
}
displayNotifications(context, messages)
} else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
}
@@ -194,13 +231,15 @@ object NotificationHelper {
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
)
if (retryMessages.isNotEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, retryMessages)
}
displayNotifications(context, retryMessages)
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName)
fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
}
return
} catch (_: Exception) {
@@ -305,10 +344,11 @@ object NotificationHelper {
val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
!envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty()
val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
@@ -320,9 +360,9 @@ object NotificationHelper {
showFallbackPushNotification(
context = context,
title = if (senderName.isNotBlank()) {
"Direct message from $senderName"
context.getString(R.string.notification_direct_message_from, senderName)
} else {
"Direct message"
context.getString(R.string.notification_direct_message)
},
body = notificationBody,
sender = senderName,
@@ -330,7 +370,7 @@ object NotificationHelper {
allowWhenPublicChatVisible = true,
isDirectMessage = true,
targetDmUserId = dmConversationUserId,
conversationTitle = "Direct Messages"
conversationTitle = context.getString(R.string.notification_direct_messages_title)
)
shownDm.add(shownDmKey)
}
@@ -351,10 +391,10 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false,
targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat",
conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null,
) {
CoroutineScope(Dispatchers.Main).launch {
helperScope.launch(Dispatchers.Main) {
createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1)
@@ -400,22 +440,37 @@ object NotificationHelper {
}
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
val groupKey = if (isDirectMessage && targetDmUserId != null) {
GROUP_DM_PREFIX + targetDmUserId
} else {
GROUP_PUBLIC
}
val notificationId = if (isDirectMessage && targetDmUserId != null) {
SUMMARY_NOTIFICATION_ID + targetDmUserId
} else {
SUMMARY_NOTIFICATION_ID
}
cancelStaleSystemTrayDuplicates(context)
notify(
SUMMARY_NOTIFICATION_ID,
notificationId,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title)
.setContentText(body)
.setGroup(groupKey)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle(conversationTitle).addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
)
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
@@ -423,7 +478,7 @@ object NotificationHelper {
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = isDirectMessage,
@@ -433,7 +488,7 @@ object NotificationHelper {
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
@@ -457,117 +512,146 @@ object NotificationHelper {
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
GlobalScope.launch {
helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0
val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) != PackageManager.PERMISSION_GRANTED
) {
// Find new messages that are not from the current user
val currentUserId = settings.getInt("current_user_id", -1)
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
if (currentUserId == -1) return@launch
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages.filter { msg ->
!shown.contains(msg.id.toString()) && // Not already shown
msg.user_id != currentUserId // Not from current user
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
}
if (newMessages.isEmpty()) {
Logger.d(
"NotificationHelper",
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.apply { forEach { shown.add(it.id.toString()) } }
newMessageCount = newMessages.size
.sortedBy { it.id }
if (newMessages.isEmpty()) {
Logger.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.forEach { shown.add(it.id.toString()) }
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style ->
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
newMessageCount = newMessages.size
Logger.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
)
style.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(msg.username)
.build()
)
)
}
createChannel(context)
cancelStaleSystemTrayDuplicates(context)
style
}
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
val messagingStyle = NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build()
)
)
}
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setLargeIcon(avatar)
.setContentTitle(conversationTitle)
.setStyle(messagingStyle)
.setGroup(GROUP_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = false,
parentMessageId = newMessages.last().id
)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.build()
)
} else {
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
}
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build()
)
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
Logger.i(
"NotificationHelper",
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
)
}
}
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
private fun cancelStaleSystemTrayDuplicates(context: Context) {
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
manager.cancel(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { status ->
status.notification.channelId == "fcm_fallback_notification_channel" ||
status.id == 0
}
.forEach { status ->
manager.cancel(status.tag, status.id)
}
}
}
}
}
@@ -0,0 +1,78 @@
package ru.fromchat.notifications
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import kotlin.math.abs
/**
* Builds a circular initials avatar matching the in-app public-chat row (title initials +
* name-hash gradient), for use as a notification large icon.
*/
internal object PublicChatNotificationAvatar {
private const val SIZE_PX = 192
fun create(title: String): Bitmap {
val seed = title.ifBlank { "FromChat" }
val hash = seed.hashCode()
val r = abs(hash % 256)
val g = abs((hash / 256) % 256)
val b = abs((hash / 65536) % 256)
val colorStart = android.graphics.Color.rgb(
(r + 100).coerceIn(0, 255),
(g + 100).coerceIn(0, 255),
(b + 100).coerceIn(0, 255),
)
val colorEnd = android.graphics.Color.rgb(
(r + 50).coerceIn(0, 255),
(g + 50).coerceIn(0, 255),
(b + 50).coerceIn(0, 255),
)
val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f,
0f,
SIZE_PX.toFloat(),
SIZE_PX.toFloat(),
colorStart,
colorEnd,
Shader.TileMode.CLAMP,
)
}
val radius = SIZE_PX / 2f
canvas.drawCircle(radius, radius, radius, paint)
val initials = initialsFrom(seed)
if (initials.isNotBlank()) {
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = radius * 0.7f
}
val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, radius, textY, textPaint)
}
return bitmap
}
private fun initialsFrom(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when {
words.isEmpty() -> ""
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase()
}
else -> words.take(2).joinToString("") {
it.firstOrNull()?.uppercaseChar()?.toString().orEmpty()
}
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" />
</vector>
@@ -1,5 +1,11 @@
<resources>
<string name="public_chat">Общий чат</string>
<string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string>
<string name="notification_reply">Ответить</string>
<string name="notification_reply_hint">Ответ в чат…</string>
<string name="notification_direct_message">Личное сообщение</string>
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
<string name="notification_direct_messages_title">Личные сообщения</string>
</resources>
@@ -1,6 +1,12 @@
<resources>
<string name="app_name" translatable="false">FromChat</string>
<string name="public_chat">Main chat</string>
<string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string>
<string name="notification_reply">Reply</string>
<string name="notification_reply_hint">Reply to chat…</string>
<string name="notification_direct_message">Direct message</string>
<string name="notification_direct_message_from">Direct message from %1$s</string>
<string name="notification_direct_messages_title">Direct Messages</string>
</resources>
+62 -1
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") {
@@ -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,14 @@
package ru.fromchat.notifications
import android.content.Context
/** White silhouette drawable for status-bar / notification small icons. */
object NotificationSmallIcon {
private const val DRAWABLE_NAME = "ic_stat_fromchat"
fun resId(context: Context): Int {
val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
return id
}
}
@@ -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
},
)
}
@@ -1,6 +1,5 @@
package ru.fromchat.ui.calls
import android.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore
import ru.fromchat.notifications.NotificationSmallIcon
/**
* Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService(
this,
RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon)
.setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL)
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
)
nm.createNotificationChannel(ch)
}
val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) {
R.drawable.stat_sys_upload
}
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle)
.setContentText(updatedText)
.setSmallIcon(smallIcon)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true)
.build()
}
@@ -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>
@@ -52,8 +53,11 @@
<string name="auth_step_confirm_title">Подтвердите пароль</string>
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. Номер телефона нам недоступен — мы видим только имя, фамилию и пол. Эти данные мы не сохраняем — они нужны только для защиты и чтобы заранее заполнить поля на следующем шаге (не бойтесь, вы сможете подправить данные перед отправкой).</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>
@@ -59,8 +60,11 @@
<string name="auth_step_confirm_title">Confirm your password</string>
<string name="auth_step_confirm_body">Enter the same password again.</string>
<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. We cant access your phone number — we only see your first name, last name, and gender. We dont store that data; its only used for protection and to pre-fill the next step (dont worry — you can edit everything before submitting).</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>
@@ -112,10 +112,9 @@ import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.RegisterRequest
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
@@ -273,7 +272,7 @@ object ApiClient {
}
if (response.status.value == 401) {
val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
val isCredentialCheck = path.endsWith("/auth/steps/password") || path.endsWith("/auth/steps/register/confirm")
if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch {
runCatching { WebSocketManager.disconnect() }
@@ -484,22 +483,6 @@ object ApiClient {
}
suspend fun loginRequest(request: LoginRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun registerRequest(request: RegisterRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
@@ -513,6 +496,8 @@ object ApiClient {
data class NeedsRegister(
val yandexRequired: Boolean,
val yandex: YandexOAuthParams?,
val captchaRequired: Boolean,
val captcha: SmartCaptchaParams?,
) : AuthPasswordStepOutcome
}
@@ -530,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))
}
}
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -86,12 +87,54 @@ object ChatListSync {
private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
val cached = MessageRepository.loadPublicMessages()
val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
val latestResponse = ApiClient.getMessages(limit = 1)
val latest = latestResponse.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
val holeBelowLatest =
latest.id > 0 &&
maxCachedId > 0 &&
latest.id > maxCachedId + 1
val latestMissingWithPriorCache =
latest.id > 0 &&
latest.id !in cachedIds &&
maxCachedId > 0 &&
latest.id > maxCachedId
if (holeBelowLatest || latestMissingWithPriorCache) {
// Preview-only upsert would leave first+last holes; pull a page and merge.
Logger.i(
"ChatListSync",
"Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
"cachedCount=${cached.size} — fetching page to fill",
)
val page = ApiClient.getMessages(limit = 50)
val networkMessages = page.messages
if (networkMessages.isEmpty()) {
MessageRepository.upsertPublicMessage(latest)
return@runCatching
}
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val networkIds = networkMessages.map { it.id }.toSet()
val minNetworkId = networkMessages.minOf { it.id }
val maxNetworkId = networkMessages.maxOf { it.id }
val older = cached.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
val ahead = cached.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
val merged = (networkMessages + older + ahead).distinctBy { it.id }
Logger.i(
"ChatListSync",
"Public gap fill: network=${networkMessages.size} older=${older.size} " +
"ahead=${ahead.size} merged=${merged.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(merged, replaceAll = true)
} else {
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
}
}
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return
val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return
}.getOrNull() ?: run {
Logger.w("ProfileUpdateSync", "updates batch decode failed")
return
}
val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
if (profileUpdates > 0) {
Logger.d(
"ProfileUpdateSync",
"updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
)
}
updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
}
}
"profileUpdate" -> {
val payload = message.data ?: return
val payload = message.data ?: run {
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
return
}
onProfileUpdatePayload(payload)
}
}
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d(
"ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " +
"bio='${profile.bio?.take(48)}'",
"deleted=${profile.deleted} suspended=${profile.suspended} " +
"bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
)
val hadCached = ProfileCache.get(profile.id)
ProfileCache.applyServerProfile(profile, force = true)
val after = ProfileCache.get(profile.id)
Logger.d(
"ProfileUpdateSync",
"profileUpdate applied id=${profile.id} " +
"wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
"revisionAfter=${ProfileCache.revision.value}",
)
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
}
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
.onFailure {
Logger.w(
"ProfileUpdateSync",
"patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
it,
)
}
.onSuccess {
Logger.d(
"ProfileUpdateSync",
"patchDmConversationPeerProfile done id=${profile.id}",
)
}
}
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
@@ -58,13 +58,24 @@ object UpdateSyncManager {
/**
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
*
* Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
* receive loop (it would deadlock the ack response cannot be read while this call blocks).
*/
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock {
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
Logger.d("UpdateSync", "onUpdatesEnvelope begin")
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
return@withLock
}
Logger.d(
"UpdateSync",
"onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
)
if (seq > _lastSeq.value) {
persistLastSeq(seq)
sendAck(seq)
sendAckFireAndForget(seq)
}
}
}
@@ -90,6 +101,10 @@ object UpdateSyncManager {
/**
* Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* Does not advance the cursor until apply/rebuild succeeds.
*
* Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
* (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
* is not left with first+last holes filled only by slow incremental envelopes.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -108,16 +123,38 @@ object UpdateSyncManager {
try {
var rounds = 0
var consecutiveFailures = 0
while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
val response = requestGetUpdates(token, startSeq) ?: break
val response = requestGetUpdates(token, startSeq)
if (response == null) {
consecutiveFailures++
Logger.w(
"UpdateSyncManager",
"getUpdates returned null (timeout/disconnect) " +
"failures=$consecutiveFailures lastSeq=$startSeq",
)
if (consecutiveFailures >= 2) {
Logger.w(
"UpdateSyncManager",
"Gap catch-up stalled — rebuilding from history",
)
rebuildStateFromHistory()
break
}
continue
}
consecutiveFailures = 0
val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}",
"missed=${response.missedCount}, hasMore=${response.hasMore}, " +
"gapHint=$gapHint clientSeq=$startSeq",
)
updateMissedCount(response.missedCount)
@@ -126,18 +163,43 @@ object UpdateSyncManager {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
} else {
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
}
break
}
"ok" -> {
// Envelopes for this chunk are applied on the receive path before this
// response is delivered; advance cursor here only when there was nothing to apply.
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
}
if (!response.hasMore) {
Logger.i(
"UpdateSyncManager",
"Gap catch-up complete after $rounds round(s) lastSeq=${_lastSeq.value}",
)
break
}
if (_lastSeq.value <= startSeq && response.missedCount > 0) {
// Chunk was announced but cursor did not advance — avoid tight spin.
Logger.w(
"UpdateSyncManager",
"Gap chunk did not advance cursor " +
"(start=$startSeq now=${_lastSeq.value} missed=${response.missedCount})",
)
consecutiveFailures++
if (consecutiveFailures >= 2) {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
}
break
}
}
if (!response.hasMore) break
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
@@ -165,7 +227,7 @@ object UpdateSyncManager {
GetUpdatesRequest(lastSeq = lastSeq),
),
)
val response = WebSocketManager.request(requestMessage)
val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null
return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
@@ -174,11 +236,15 @@ object UpdateSyncManager {
}.getOrNull()
}
private suspend fun sendAck(seq: Int) {
/**
* Send ack without waiting for a response. Must not use [WebSocketManager.request] from
* the receive/apply path that deadlocks the incoming frame loop for ~10s per envelope.
*/
private suspend fun sendAckFireAndForget(seq: Int) {
val token = ApiClient.token ?: return
if (seq <= 0) return
runCatching {
WebSocketManager.request(
WebSocketManager.send(
WebSocketMessage(
type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
@@ -188,6 +254,7 @@ object UpdateSyncManager {
),
),
)
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
}
@@ -236,18 +303,26 @@ object UpdateSyncManager {
val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L
}
MessageRepository.replacePublicMessages(ordered)
MessageCacheStore.clearPublicMessages()
Logger.i(
"UpdateSync",
"rebuildPublicHistory messages=${ordered.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(ordered, replaceAll = true)
}
private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations()
Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
for (conversation in conversations) {
val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null
var pageCount = 0
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
pageCount++
for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element)
@@ -256,6 +331,7 @@ object UpdateSyncManager {
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
}
}
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState
@@ -167,8 +168,12 @@ object MessageCacheStore {
}
}
suspend fun replacePublicMessages(messages: List<Message>) {
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForPublic()
Logger.d(
"MessageCache",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
)
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged)
}
replaceMessages(convId, merged)
replaceMessages(convId, merged, replaceAll = replaceAll)
}
suspend fun clearPublicMessages() {
Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic())
}
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) {
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(otherUserId))
}
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForDm(otherUserId)
Logger.d(
"MessageCache",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
"replaceAll=$replaceAll convId=$convId",
)
val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated)
}
replaceMessages(convId, hydrated)
replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations()
}
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout()
Logger.d(
"MessageCache",
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
"clientId=${resolved.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved)
}
@@ -279,25 +296,37 @@ object MessageCacheStore {
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
Logger.d(
"MessageCache",
"upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
"userId=${message.user_id} clientId=${message.client_message_id}",
)
ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
}
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
Logger.d(
"MessageCache",
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
)
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
}
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageById(messageId: Int) {
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId)
}
@@ -357,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),
)
}
}
@@ -440,6 +469,10 @@ object MessageCacheStore {
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId()
Logger.d(
"MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid,
@@ -611,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId)
.executeAsOneOrNull() ?: return@withContext
val label = resolveDmConversationDisplayLabel(otherUserId, null)
if (label.isEmpty()) return@withContext
if (label == existing.displayName) return@withContext
.executeAsOneOrNull() ?: run {
Logger.d(
"MessageCache",
"patchDmPeerProfile noConversation otherUserId=$otherUserId",
)
return@withContext
}
val profile = ProfileCache.get(otherUserId)
val isDeleted = profile?.deleted == true ||
profile?.username?.startsWith("#deleted") == true
val label = if (isDeleted) {
""
} else {
resolveDmConversationDisplayLabel(otherUserId, null)
}
if (!isDeleted && label.isEmpty()) {
Logger.d(
"MessageCache",
"patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
"deleted=${profile?.deleted}",
)
return@withContext
}
if (label == existing.displayName) {
Logger.d(
"MessageCache",
"patchDmPeerProfile unchanged otherUserId=$otherUserId " +
"deleted=$isDeleted labelEmpty=${label.isEmpty()}",
)
return@withContext
}
Logger.d(
"MessageCache",
"patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
"oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
"newLabelEmpty=${label.isEmpty()}",
)
db.messageDatabaseQueries.upsertConversation(
instanceId = iid,
id = existing.id,
@@ -947,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
}
@@ -954,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId()
Logger.d(
"MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
}
@@ -961,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById(
instanceId = iid,
@@ -968,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(),
)
}
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"deleteMessageById convId=$conversationId messageId=$messageId " +
"rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
)
}
private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid)
val usernameResolved = when {
self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: ""
else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
}
val displayNameResolved = when {
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L,
is_edited = isEdited != 0L,
username = usernameResolved,
displayName = displayNameResolved,
profile_picture = pictureResolved,
verified = profile?.verified,
verificationStatus = profile?.verificationStatus,
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
}
suspend fun clearAll() {
Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache()
}
@@ -1334,44 +1426,86 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
)
return emptyList()
}
return CacheValidator.filterMessages(conversationId, messages, self)
}
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) {
private suspend fun replaceMessages(
conversationId: String,
messages: List<Message>,
replaceAll: Boolean = false,
) {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"replaceMessages incoherent→clear convId=$conversationId " +
"count=${messages.size} replaceAll=$replaceAll",
)
clearConversationMessages(conversationId)
return
}
val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages convId=$conversationId replaceAll=$replaceAll " +
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
)
withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.associate { it.id.toInt() to it.replyToId }
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
if (replaceAll) {
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
validated.forEach { msg: Message ->
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
id = msg.id.toLong(),
conversationId = conversationId,
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent",
)
}
}
} else {
// Merge into existing rows — partial UI snapshots must not wipe full history.
validated.forEach { msg: Message ->
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
id = msg.id.toLong(),
conversationId = conversationId,
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent"
)
upsertSingle(conversationId, msg)
}
}
}
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages done convId=$conversationId replaceAll=$replaceAll " +
"rowsAfter=$afterCount",
)
dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it)
pruneEmptyConversations()
@@ -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))
@@ -56,8 +59,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) =
MessageCacheStore.replacePublicMessages(messages)
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
ru.fromchat.Logger.d(
"MessageRepo",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
}
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,20 +75,32 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
suspend fun markMessageDeleted(conversationId: String, messageId: Int) =
suspend fun deletePublicMessageById(messageId: Int) {
ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
MessageCacheStore.deletePublicMessageById(messageId)
}
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"markMessageDeleted convId=$conversationId messageId=$messageId",
)
MessageCacheStore.markMessageDeleted(conversationId, messageId)
}
suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
suspend fun deletePublicMessageById(messageId: Int) =
MessageCacheStore.deletePublicMessageById(messageId)
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) =
MessageCacheStore.replaceDmMessages(otherUserId, messages)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
ru.fromchat.Logger.d(
"MessageRepo",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -91,8 +111,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
)
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
}
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser
@@ -71,19 +72,41 @@ object ProfileCache {
/** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
private fun bumpRevision() {
_revision.value++
private fun bumpRevision(reason: String) {
val next = _revision.value + 1
_revision.value = next
Logger.d("ProfileCache", "revision=$next reason=$reason size=${profiles.size}")
}
private fun profileSummary(profile: UserProfile): String =
"id=${profile.id} user='${profile.username}' deleted=${profile.deleted} " +
"suspended=${profile.suspended} preview=${profile.isClientPreviewOnly} " +
"verified=${profile.verified} vStatus=${profile.verificationStatus}"
fun get(userId: Int): UserProfile? = profiles[userId]
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
val profile = get(userId) ?: return false
if (profile.isClientPreviewOnly) return false
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
val profile = get(userId) ?: run {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
return false
}
if (profile.isClientPreviewOnly) {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=previewOnly")
return false
}
val fetchedAt = fullProfileFetchedAtMs[userId] ?: run {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=noFetchTs")
return false
}
val ageMs = Clock.System.now().toEpochMilliseconds() - fetchedAt
val fresh = ageMs <= maxAgeMs
Logger.d(
"ProfileCache",
"hasFreshFullProfile id=$userId fresh=$fresh ageMs=$ageMs maxAgeMs=$maxAgeMs",
)
return fresh
}
/** Emits whenever this user's cached profile changes (including bio). */
@@ -110,7 +133,13 @@ object ProfileCache {
) {
if (id <= 0) return
val existing = get(id)
if (existing != null && !existing.isClientPreviewOnly) return
if (existing != null && !existing.isClientPreviewOnly) {
Logger.d(
"ProfileCache",
"mergePreview skipFullExists id=$id deleted=${existing.deleted}",
)
return
}
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -120,11 +149,27 @@ object ProfileCache {
} else {
displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
}
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
return
}
if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreview missingIdentity id=$id " +
"hasUsername=${!incomingUsername.isNullOrEmpty()} " +
"hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
)
}
Logger.d(
"ProfileCache",
"mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
"user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
)
put(
UserProfile(
id = id,
@@ -159,29 +204,49 @@ object ProfileCache {
val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) {
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id)
return
}
}
val cur = profiles
val existing = cur[profile.id]
val deletedChanged = existing?.deleted != profile.deleted
val suspendedChanged = existing?.suspended != profile.suspended
val verificationChanged = existing?.verified != profile.verified ||
existing?.verificationStatus != profile.verificationStatus
if (
existing != null &&
!existing.isClientPreviewOnly &&
existing.bio != profile.bio
) {
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
)
}
if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
Logger.d(
"ProfileCache",
"put ${profileSummary(profile)} hadExisting=${existing != null} " +
"deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
"verificationChanged=$verificationChanged",
)
}
profiles = cur + (profile.id to profile)
bumpRevision()
bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) }
.onFailure {
Logger.w(
"ProfileCache",
"persist put failed id=${profile.id}: ${it.message}",
it,
)
}
}
}
}
@@ -199,27 +264,56 @@ object ProfileCache {
if (!force) {
val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) {
val lifecycleMismatch =
existing.deleted != normalized.deleted ||
existing.suspended != normalized.suspended ||
isDeletedPlaceholderUsername(existing.username) !=
isDeletedPlaceholderUsername(normalized.username)
if (lifecycleMismatch) {
Logger.w(
"ProfileCache",
"applyServerProfile force=false lifecycleMismatch " +
"cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
"cachedSuspended=${existing.suspended} " +
"incomingSuspended=${normalized.suspended} " +
"cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
"— applying full server profile",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
val patched = existing.copy(
verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus
?: existing.verificationStatus,
)
val verificationChanged = patched.verified != existing.verified ||
patched.verificationStatus != existing.verificationStatus
if (patched != existing) put(patched)
if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " +
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
"deleted=${existing.deleted}",
)
} else {
Logger.d(
"ProfileCache",
"applyServerProfile keepCached force=false id=${profile.id} " +
"deleted=${existing.deleted} verificationChanged=$verificationChanged",
)
}
// Refresh TTL so force=false callers stop refetching.
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
if (!verificationChanged) {
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
return
}
}
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"applyServerProfile applied force=$force id=${profile.id} " +
"applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
@@ -228,9 +322,13 @@ object ProfileCache {
fun remove(userId: Int) {
val cur = profiles
if (userId !in cur) return
if (userId !in cur) {
Logger.d("ProfileCache", "remove miss id=$userId")
return
}
Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
profiles = cur - userId
bumpRevision()
bumpRevision("remove:$userId")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
@@ -251,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return
val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return
if (incomingUsername.isEmpty()) {
Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
return
}
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) {
null
} else {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
user.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
)
}
val existing = get(user.id)
Logger.d(
"ProfileCache",
"mergeFromDmUser id=${user.id} deleted=$isDeleted " +
"incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
"user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
)
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing.displayName
},
profilePicture = if (isDeleted) {
null
} else {
@@ -278,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted,
)
if (patched != existing) put(patched)
if (patched != existing) {
Logger.d(
"ProfileCache",
"mergeFromDmUser patchFull id=${user.id} " +
"deleted ${existing.deleted}${patched.deleted}",
)
put(patched)
}
return
}
@@ -286,8 +411,11 @@ object ProfileCache {
UserProfile(
id = user.id,
username = incomingUsername,
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingDisplayName,
displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
},
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
@@ -326,21 +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() }
if (uname.isBlank()) return
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=${!displayName.isNullOrBlank()}",
)
}
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
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(
@@ -379,6 +519,8 @@ object ProfileCache {
val user = ApiClient.user
return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture,
reply_to = enrichedReply,
@@ -389,6 +531,8 @@ object ProfileCache {
username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty()
},
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture,
verified = message.verified ?: profile?.verified,
@@ -413,7 +557,7 @@ object ProfileCache {
}
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
bumpRevision()
bumpRevision("onActiveInstanceChanged:$instanceId")
}
}
}
@@ -427,6 +571,7 @@ object ProfileCache {
} else {
emptyMap()
}
val before = profiles.size
if (profiles.isEmpty()) {
profiles = diskProfiles
} else {
@@ -442,8 +587,14 @@ object ProfileCache {
}
profiles = merged
}
val deletedCount = profiles.values.count { it.deleted == true }
Logger.d(
"ProfileCache",
"hydrateFromDisk instanceId=$instanceId before=$before " +
"disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
)
pruneUnusableClientPreviewsLocked()
bumpRevision()
bumpRevision("hydrateFromDisk")
}
}
@@ -455,6 +606,7 @@ object ProfileCache {
p.displayName.isNullOrBlank()
}.keys
if (toRemove.isEmpty()) return
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles
for (id in toRemove) {
cur = cur - id
@@ -464,10 +616,11 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
bumpRevision()
bumpRevision("clear")
}
}
}
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) {
val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return
}.getOrNull() ?: run {
ru.fromchat.Logger.w("DmInbox", "processDeleted decode failed")
return
}
val currentUserId = ApiClient.user?.id ?: return
if (data.senderId != currentUserId && data.recipientId != currentUserId) return
if (data.senderId != currentUserId && data.recipientId != currentUserId) {
ru.fromchat.Logger.d(
"DmInbox",
"processDeleted skipNotParticipant messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} self=$currentUserId",
)
return
}
val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId
else -> data.senderId
} ?: return
ru.fromchat.Logger.i(
"DmInbox",
"processDeleted messageId=${data.id} otherUserId=$otherUserId " +
"senderId=${data.senderId} recipientId=${data.recipientId}",
)
withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id)
}
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int,
): Message {
val dec = parseDmMessageContent(plaintext)
val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName ->
ProfileCache.mergePreview(id = envelope.senderId, username = senderName)
if (senderUsername != null || senderDisplayName != null) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername,
displayName = senderDisplayName,
)
}
}
val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) {
"You"
ApiClient.user?.username.orEmpty()
} else {
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
?: envelope.senderUsername?.takeIf { it.isNotBlank() }
senderUsername
?: senderProfile?.username?.trim()?.takeIf { it.isNotEmpty() }
?: ""
}
val displayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
senderDisplayName
?: senderProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
return Message(
id = envelope.id,
user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId,
is_edited = false,
username = username,
displayName = displayName,
profile_picture = null,
verified = null,
reply_to = null,
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
}
"dmDeleted" -> message.data?.let { element ->
scope.launch {
ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged()
}
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
*/
object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
val message = decodeMessage(element) ?: return@withContext
val message = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processNew decode failed")
return@withContext
}
Logger.d(
"PublicInbox",
"processNew id=${message.id} userId=${message.user_id} " +
"clientId=${message.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
}
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
val edited = decodeMessage(element) ?: return@withContext
val edited = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processEdited decode failed")
return@withContext
}
Logger.d("PublicInbox", "processEdited id=${edited.id} userId=${edited.user_id}")
ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current ->
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited)
} else {
MessageRepository.replacePublicMessages(merged)
MessageRepository.upsertPublicMessage(
merged.first { it.id == edited.id },
)
}
}
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
}.getOrNull() ?: return@withContext
}.getOrNull() ?: run {
Logger.w("PublicInbox", "processDeleted decode failed element=${element.toString().take(120)}")
return@withContext
}
Logger.i(
"PublicInbox",
"processDeleted messageId=${deleted.message_id} — hard-deleting from cache",
)
MessageRepository.deletePublicMessageById(deleted.message_id)
}
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return@withLock null
}.getOrNull() ?: run {
Logger.w("UpdateSync", "applyEnvelope decode failed: ${data.toString().take(160)}")
return@withLock null
}
val types = envelope.updates.map { it.type }
Logger.d(
"UpdateSync",
"applyEnvelope seq=${envelope.seq} count=${envelope.updates.size} types=$types",
)
for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data))
}
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
}
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
"messageDeleted" -> {
Logger.d("UpdateSync", "applyOne messageDeleted")
message.data?.let { PublicInboxCoordinator.processDeleted(it) }
}
"dmNew", "dmDeleted", "dmEdited" -> {
Logger.d("UpdateSync", "applyOne ${message.type}")
DmInboxCoordinator.handleMessage(message)
}
else -> Unit
}
}
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null,
val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReactionUser(
val id: Int,
val username: String
val username: String,
@SerialName("display_name") val displayName: String? = null,
)
@@ -9,6 +9,7 @@ data class DmEnvelope(
val senderId: Int,
val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null,
@SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -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,
)
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String,
val user_id: Int,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val reactions: List<ReactionData>
)
@@ -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,
)
) {
@@ -94,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock {
batchStateUpdates {
updateState { current ->
val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(),
panelSnap,
messages,
)
val withReplies = attachPublicReplyReferences(merged)
if (current.messages.size != withReplies.size ||
current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
) {
val panelIds = panelSnap.map { it.id }.toSet()
val mergedIds = withReplies.map { it.id }.toSet()
val dbIds = messages.map { it.id }.toSet()
Logger.d(
"ChatPanel",
"syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
"merged=${withReplies.size} " +
"panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
"dbOnlyIds=${(dbIds - mergedIds).take(8)}",
)
}
if (current.messages == withReplies) current
else current.copy(messages = withReplies)
}
@@ -692,7 +707,8 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
username = "You",
username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
client_message_id = tempId,
reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
@@ -949,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
username = "You",
username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
profile_picture = null,
verified = null,
reply_to = replyTo,
@@ -1188,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 {
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted(
userId = userId,
currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title,
username = ProfileCache.get(userId)?.username,
)
} == true
Row(
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis,
)
profileUserId?.let { userId ->
val status = resolveVerificationStatus(userId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val status = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
if (status != null) {
Spacer(modifier = Modifier.width(4.dp))
StatusBadge(
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -710,26 +711,27 @@ fun ImageFullscreenPreview(
}
// Top bar: back, display name + date/time, 3-dot menu
Box(
AnimatedVisibility(
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
.fillMaxWidth(),
) {
AnimatedVisibility(
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(),
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.statusBarsPadding(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(onClick = { dismissRequested = true }) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -820,32 +822,31 @@ fun ImageFullscreenPreview(
}
// Bottom: message text
Box(
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
.fillMaxWidth(),
) {
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(),
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.navigationBarsPadding(),
) {
if (message.content.isNotBlank()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(16.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color.White
color = Color.White,
)
}
}
}
}
}
@@ -15,7 +15,8 @@ import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted
/**
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
* Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
* cached/server display name, or login username only as a last resort.
*/
@Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi()
}
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi()
}
return message.username
return message.username.trim()
}
fun messageSenderProfilePicture(
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim()
return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -229,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
resolveVerificationStatus(message.user_id, message)
}
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to
@@ -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,
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
Logger.d(
"DmPanel",
"applyCachedPeerProfile otherUserId=$otherUserId " +
"deleted=${cached?.deleted} suspended=${cached?.suspended} " +
"revision=${ProfileCache.revision.value} " +
"titleBlank=${displayName.isBlank()}",
)
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) {
val hadCachedMessages = cached.isNotEmpty()
if (hadCachedMessages) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
messagesLoaded = true
return
} else {
setLoading(true)
}
setLoading(true)
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true
} else {
val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages()
setHasMoreMessages(false)
messagesLoaded = true
} else if (hadCachedMessages) {
messagesLoaded = true
}
}
} finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
}
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
}
}
@@ -533,31 +540,46 @@ class DmPanel(
}
val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext)
val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
content = dec.text,
is_edited = true,
fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
fileSizes = dec.fileSizes ?: previous?.fileSizes,
fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope,
reply_to = previous?.reply_to,
)
updateMessage(envelope.id) {
it.copy(
content = dec.text,
is_edited = true,
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope,
reply_to = it.reply_to,
)
editedForCache.copy(reply_to = it.reply_to)
}
// Persist edit to cache
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
}
}
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext)
val username = if (envelope.senderId == currentUserId) {
"You"
val senderUsername = if (envelope.senderId == currentUserId) {
ApiClient.user?.username.orEmpty()
} else {
otherDisplayName
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(envelope.senderId)?.username?.trim().orEmpty()
}
val senderDisplayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
?: otherDisplayName.takeIf { it.isNotBlank() }
?: ProfileCache.get(envelope.senderId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (envelope.senderId != currentUserId) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername.takeIf { it.isNotEmpty() },
displayName = senderDisplayName,
)
}
return Message(
id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId)
},
is_edited = false,
username = username,
username = senderUsername,
displayName = senderDisplayName,
profile_picture = null,
verified = null,
reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return
}.getOrNull() ?: run {
Logger.w("DmPanel", "processDeletedEnvelope decode failed")
return
}
val involvesPeer =
data.senderId == otherUserId ||
data.recipientId == otherUserId ||
data.senderId == currentUserId
if (!involvesPeer) return
if (!involvesPeer) {
Logger.d(
"DmPanel",
"processDeletedEnvelope skip messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} peer=$otherUserId",
)
return
}
Logger.i(
"DmPanel",
"processDeletedEnvelope messageId=${data.id} peer=$otherUserId " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == data.id }}",
)
scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
}
deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id)
Logger.d(
"DmPanel",
"processDeletedEnvelope done messageId=${data.id} uiAfter=${_state.messages.size}",
)
}
}
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope
) {
private val typingHandler = PublicChatTypingHandler(scope)
private var networkHistoryLoaded = false
private val loadMessagesMutex = Mutex()
/**
* Whether replacing the list would change **structure or message body** (content / edited).
@@ -82,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,
@@ -177,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) }
@@ -377,98 +382,119 @@ class PublicChatPanel(
}
override suspend fun loadMessages() {
hydrateMessagesFromLocalCache()
if (networkHistoryLoaded) return
networkHistoryLoaded = true
loadMessagesMutex.withLock {
hydrateMessagesFromLocalCache()
val cached = _state.messages
if (cached.isEmpty()) {
withContext(Dispatchers.Main) {
setLoading(true)
}
}
// Refresh from network; this may be fast or slow, but runs entirely off main.
val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }
}
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) {
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
Logger.d("PublicChatPanel", loadMsg)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) {
val shown = snapshotUiMessagesForNetworkMerge()
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
} else {
batchStateUpdates {
val merged = preserveReplyToFromExisting(
shown,
mergeNetworkHistoryWithShown(shown, networkMessages),
)
clearMessages()
addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(
panelMessagesForDbMerge(),
networkMessages,
)
}
val cached = _state.messages
if (cached.isEmpty()) {
withContext(Dispatchers.Main) {
setLoading(true)
}
}
withContext(Dispatchers.Default) {
val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
MessageCacheStore.replacePublicMessages(toPersist)
// Always refresh from network on open. A retained panel used to skip this after the
// first visit (networkHistoryLoaded), so offline bursts left first+last holes until
// slow WS catch-up filled them.
Logger.d(
"PublicChatPanel",
"loadMessages: network refresh cachedCount=${cached.size}",
)
val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) }
}
} else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull()
if (cause is ClientRequestException && cause.response.status.value == 403) {
MessageCacheStore.clearPublicMessages()
val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) {
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
Logger.d(
"PublicChatPanel",
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) {
clearMessages()
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
val shown = snapshotUiMessagesForNetworkMerge()
Logger.d(
"PublicChatPanel",
"loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
)
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
} else {
batchStateUpdates {
val merged = preserveReplyToFromExisting(
shown,
mergeNetworkHistoryWithShown(shown, networkMessages),
)
clearMessages()
addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
Logger.d(
"PublicChatPanel",
"loadMessages: after addMessages mergedSize=${merged.size} " +
"restoring optimistic count=${optimisticSnapshot.size}",
)
restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
mergedForCache = mergeNetworkHistoryWithShown(
panelMessagesForDbMerge(),
networkMessages,
)
}
}
}
withContext(Dispatchers.Default) {
val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d(
"PublicChatPanel",
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
)
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
}
} else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull()
if (cause is ClientRequestException && cause.response.status.value == 403) {
MessageCacheStore.clearPublicMessages()
withContext(Dispatchers.Main) {
clearMessages()
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else if (cached.isEmpty()) {
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
}
}
} else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
}
}
} else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
}
}
@@ -494,7 +520,7 @@ class PublicChatPanel(
)
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -529,29 +555,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { existing ->
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to)
val existing = _state.messages.find { it.id == editedMsg.id }
val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
updateMessage(editedMsg.id) { current ->
persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
}
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
Logger.i(
"PublicChatPanel",
"messageDeleted messageId=${deletedData.message_id} " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
)
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(deletedData.message_id)
}
Logger.d(
"PublicChatPanel",
"messageDeleted done messageId=${deletedData.message_id} " +
"uiAfter=${_state.messages.size}",
)
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
val existing = _state.messages.find { it.id == reactionUpdate.message_id }
if (existing != null) {
val updated = existing.copy(reactions = reactionUpdate.reactions)
handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
}
} else {
handleReactionUpdate(reactionUpdate)
}
}
"typing" -> {
@@ -620,6 +664,7 @@ class PublicChatPanel(
cancelQueuedMessage(message)
return
}
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId)
@@ -72,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
// Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
val droppedConfirmed = panelMessages.filter { panel ->
panel.id > 0 && panel.id !in mergedIds
}
if (droppedConfirmed.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
"ids=${droppedConfirmed.map { it.id }.take(12)} " +
"panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
)
}
val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when {
panel.id < 0 && cid != null && cid !in mergedClientIds -> true
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
else -> false
}
panel.id < 0 && cid != null && cid !in mergedClientIds
}
if (extraPanel.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
"ids=${extraPanel.map { it.id }}",
)
}
return dedupeMessagesByClientId(
@@ -114,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 {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String,
userId: Int,
) {
val profileCacheRevision by ProfileCache.revision.collectAsState()
val verificationStatus = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
DisplayName(
displayName = title,
verificationStatus = resolveVerificationStatus(userId),
verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@@ -928,18 +933,18 @@ internal fun DmConversationRowContent(
currentUserId = currentUserId,
deleted = cached?.deleted,
suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
username = cached?.username,
)
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)
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -43,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
@@ -422,6 +426,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
@@ -479,10 +485,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn(
modifier = Modifier
.hazeSource(hazeState)
.padding()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
contentPadding = innerPadding
.fillMaxSize()
.consumeWindowInsets(innerPadding)
.hazeSource(hazeState),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 24.dp,
),
) {
item {
Column(Modifier.fillMaxWidth()) {
@@ -569,9 +580,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
}
item {
// Initial load only — background poll must not add/remove list height (overscroll jump).
if (refreshing && devices.isEmpty()) {
if (refreshing && devices.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -586,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)
}
}