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-transcript-*.md
premortem-report-*.html premortem-report-*.html
target/
+2 -7
View File
@@ -62,8 +62,8 @@ extensions.configure<ApplicationExtension> {
applicationId = "ru.fromchat" applicationId = "ru.fromchat"
minSdk = 24 minSdk = 24
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = rootProject.extra["versionCode"] as Int
versionName = "1.0" versionName = rootProject.extra["versionName"] as String
ndk { ndk {
abiFilters += listOf("arm64-v8a", "x86_64") abiFilters += listOf("arm64-v8a", "x86_64")
@@ -166,9 +166,4 @@ dependencies {
implementation(project(":app:shared")) implementation(project(":app:shared"))
implementation(project(":utils: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"> android:networkSecurityConfig="@xml/network_security_config">
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() { class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull() val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull() ?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull() val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] val sender = pushData["sender_display_name"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" ?.takeIf { it.isNotBlank() }
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" ?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message" val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true) val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) { if (ApiClient.token.isNullOrBlank()) {
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId") Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch 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) { if (isDirectMessage) {
NotificationHelper.fetchAndNotify( NotificationHelper.fetchAndNotify(
applicationContext, applicationContext,
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
dmSenderName = sender, dmSenderName = sender,
) )
} else { } 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) { } catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -72,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
override fun onNewToken(token: String) { override fun onRegistered(installationId: String) {
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})") Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
try { try {
settings.putString("pending_fcm_token", token) settings.putString("pending_fcm_token", installationId)
uploadPendingFcmTokenIfAvailable() 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) { } 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.plugins.ClientRequestException
import io.ktor.client.request.get import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient 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.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview 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_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm" private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages" 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_KEY = "shown_message_ids"
private const val PREF_SHOWN_DM_KEY = "shown_dm_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_DM_MESSAGE_ID = "last_dm_message_id"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" 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" 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 { private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji) val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings( return ChatListPreviewStrings(
@@ -72,6 +84,24 @@ object NotificationHelper {
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String = private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content 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 fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent( private fun createMessageIntent(
@@ -124,7 +154,6 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
) )
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context (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( suspend fun fetchAndNotify(
context: Context, context: Context,
includeDmMessages: Boolean = false, includeDmMessages: Boolean = false,
@@ -168,10 +208,7 @@ object NotificationHelper {
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)") Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages) displayNotifications(context, messages)
}
} else { } else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned") Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
} }
@@ -194,13 +231,15 @@ object NotificationHelper {
"fetchAndNotify retry: fetched ${retryMessages.size} public messages" "fetchAndNotify retry: fetched ${retryMessages.size} public messages"
) )
if (retryMessages.isNotEmpty()) { if (retryMessages.isNotEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, retryMessages) displayNotifications(context, retryMessages)
} }
}
if (includeDmMessages) { if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName) fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
} }
return return
} catch (_: Exception) { } catch (_: Exception) {
@@ -305,10 +344,11 @@ object NotificationHelper {
val senderName = when { val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername !envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId) else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId) ?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty() }.orEmpty()
val dmConversationUserId = envelope.senderId val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope( val notificationBody = buildChatListPreviewFromEnvelope(
@@ -320,9 +360,9 @@ object NotificationHelper {
showFallbackPushNotification( showFallbackPushNotification(
context = context, context = context,
title = if (senderName.isNotBlank()) { title = if (senderName.isNotBlank()) {
"Direct message from $senderName" context.getString(R.string.notification_direct_message_from, senderName)
} else { } else {
"Direct message" context.getString(R.string.notification_direct_message)
}, },
body = notificationBody, body = notificationBody,
sender = senderName, sender = senderName,
@@ -330,7 +370,7 @@ object NotificationHelper {
allowWhenPublicChatVisible = true, allowWhenPublicChatVisible = true,
isDirectMessage = true, isDirectMessage = true,
targetDmUserId = dmConversationUserId, targetDmUserId = dmConversationUserId,
conversationTitle = "Direct Messages" conversationTitle = context.getString(R.string.notification_direct_messages_title)
) )
shownDm.add(shownDmKey) shownDm.add(shownDmKey)
} }
@@ -351,10 +391,10 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false, allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false, isDirectMessage: Boolean = false,
targetDmUserId: Int? = null, targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat", conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null, senderId: Int? = null,
) { ) {
CoroutineScope(Dispatchers.Main).launch { helperScope.launch(Dispatchers.Main) {
createChannel(context) createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1) val currentUserId = settings.getInt("current_user_id", -1)
@@ -400,16 +440,31 @@ object NotificationHelper {
} }
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat" 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( notify(
SUMMARY_NOTIFICATION_ID, notificationId,
NotificationCompat.Builder(context, CHANNEL_ID) NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo) .setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setGroup(groupKey)
.setStyle( .setStyle(
NotificationCompat.MessagingStyle( NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle(conversationTitle).addMessage( )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message( NotificationCompat.MessagingStyle.Message(
body, body,
System.currentTimeMillis(), System.currentTimeMillis(),
@@ -423,7 +478,7 @@ object NotificationHelper {
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = isDirectMessage, isDirectMessage = isDirectMessage,
@@ -433,7 +488,7 @@ object NotificationHelper {
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
@@ -457,37 +512,44 @@ object NotificationHelper {
} }
} }
} }
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) { private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages") Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) { if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat") Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return return
} }
GlobalScope.launch { helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0 var newMessageCount = 0
val previewStrings = listPreviewStrings(context) val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
if ( if (
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) { ) {
// Find new messages that are not from the current user Logger.w(
val currentUserId = settings.getInt("current_user_id", -1) "NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch if (currentUserId == -1) return@launch
val newMessages = messages.filter { msg -> val newMessages = messages
!shown.contains(msg.id.toString()) && // Not already shown .filter { msg ->
msg.user_id != currentUserId // Not from current user !shown.contains(msg.id.toString()) && msg.user_id != currentUserId
} }
.sortedBy { it.id }
if (newMessages.isEmpty()) { if (newMessages.isEmpty()) {
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
@@ -495,50 +557,57 @@ object NotificationHelper {
) )
return@launch return@launch
} }
newMessages.apply { forEach { shown.add(it.id.toString()) } } newMessages.forEach { shown.add(it.id.toString()) }
newMessageCount = newMessages.size newMessageCount = newMessages.size
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" "displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
) )
notify( createChannel(context)
SUMMARY_NOTIFICATION_ID, cancelStaleSystemTrayDuplicates(context)
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo) val messagingStyle = NotificationCompat.MessagingStyle(
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style -> )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
for (msg in newMessages.takeLast(10)) { for (msg in newMessages.takeLast(10)) {
val timestamp = try { val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds() Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
messagingStyle.addMessage(
style.addMessage(
NotificationCompat.MessagingStyle.Message( NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings), notificationBodyForMessage(msg, previewStrings),
timestamp, timestamp,
Person.Builder() Person.Builder()
.setName(msg.username) .setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build() .build()
) )
) )
} }
style 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) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = false, isDirectMessage = false,
@@ -547,27 +616,42 @@ object NotificationHelper {
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
.build() .build()
) )
.setContentIntent(createMessageIntent(context, newMessages.last().id)) .setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build() .build()
) )
} else {
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
}
} }
settings.putStringSet(PREF_SHOWN_KEY, shown) 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> <resources>
<string name="public_chat">Общий чат</string>
<string name="chat_preview_attachment">Вложение</string> <string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</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> </resources>
@@ -1,6 +1,12 @@
<resources> <resources>
<string name="app_name" translatable="false">FromChat</string> <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_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</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> </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 { plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
@@ -7,6 +16,54 @@ plugins {
alias(libs.plugins.sqldelight) 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 { kotlin {
android { android {
namespace = "ru.fromchat.shared" namespace = "ru.fromchat.shared"
@@ -21,7 +78,6 @@ kotlin {
listOf( listOf(
iosArm64(), iosArm64(),
iosSimulatorArm64(), iosSimulatorArm64(),
iosX64(),
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
@@ -37,6 +93,10 @@ kotlin {
} }
} }
commonMain {
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
}
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@@ -123,6 +183,7 @@ compose.resources {
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach { tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
dependsOn("generateResourceAccessorsForCommonMain") dependsOn("generateResourceAccessorsForCommonMain")
dependsOn(generateAppBuildInfo)
} }
tasks.register("generateResourceAccessors") { tasks.register("generateResourceAccessors") {
@@ -1,6 +1,7 @@
package ru.fromchat.api package ru.fromchat.api
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -13,19 +14,27 @@ import kotlin.coroutines.resumeWithException
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token" private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token" private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont -> private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { cont ->
FirebaseMessaging.getInstance().token addOnCompleteListener { task ->
.addOnCompleteListener { task: Task<String> ->
if (task.isSuccessful) { if (task.isSuccessful) {
cont.resume(task.result) cont.resume(task.result)
} else { } else {
cont.resumeWithException( cont.resumeWithException(
task.exception ?: IllegalStateException("Failed to fetch FCM token") 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 { private suspend fun postFcmToken(token: String): Boolean {
return runCatching { return runCatching {
ApiClient.registerFcmToken(token) ApiClient.registerFcmToken(token)
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
outputFile.delete() outputFile.delete()
} }
val cipher = GCMBlockCipher.newInstance(AESEngine()) val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv)) cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES) 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 package ru.fromchat.ui.calls
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.notifications.NotificationSmallIcon
/** /**
* Foreground call session: keeps camera / mic eligible in background. * Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel) ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService( val hangUpPi = PendingIntent.getService(
this, this,
RC_HANG_UP, RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build() .build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID) val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL) .setCategory(Notification.CATEGORY_CALL)
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
) )
nm.createNotificationChannel(ch) 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) return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle) .setContentTitle(updatedTitle)
.setContentText(updatedText) .setContentText(updatedText)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true) .setOngoing(true)
.build() .build()
} }
@@ -5,7 +5,7 @@
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
<string name="home">Главная</string> <string name="home">Главная</string>
<string name="about">О приложении</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_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string> <string name="about_link_website">Сайт</string>
@@ -31,6 +31,7 @@
<string name="display_name_error">От 1 до 64 символов</string> <string name="display_name_error">От 1 до 64 символов</string>
<string name="fill_all_fields">Заполните все поля</string> <string name="fill_all_fields">Заполните все поля</string>
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</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="password_length_error">Пароль — от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string> <string name="passwords_dont_match">Пароли не совпадают</string>
<string name="auth_welcome_title">Добро пожаловать в FromChat</string> <string name="auth_welcome_title">Добро пожаловать в FromChat</string>
@@ -52,8 +53,11 @@
<string name="auth_step_confirm_title">Подтвердите пароль</string> <string name="auth_step_confirm_title">Подтвердите пароль</string>
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string> <string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
<string name="auth_step_yandex_title">Войдите через Яндекс ID</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_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_webview_title">Яндекс ID</string>
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string> <string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string> <string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
@@ -169,6 +173,7 @@
<string name="profile_load_failed">Не получилось загрузить профиль</string> <string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string> <string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string> <string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
<string name="action_open_settings">Настройки</string> <string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string> <string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string> <string name="action_copy_link">Скопировать ссылку</string>
@@ -8,7 +8,7 @@
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="about">About</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_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string> <string name="about_link_website">Website</string>
@@ -38,6 +38,7 @@
<!-- Validation Errors --> <!-- Validation Errors -->
<string name="fill_all_fields">Please fill in every field</string> <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_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="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">The two passwords dont match</string> <string name="passwords_dont_match">The two passwords dont match</string>
<string name="auth_welcome_title">Welcome to FromChat</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_title">Confirm your password</string>
<string name="auth_step_confirm_body">Enter the same password again.</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_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_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_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_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> <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_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</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_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_open_settings">Settings</string>
<string name="action_chat">Chat</string> <string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</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.ChangeYandexResponse
import ru.fromchat.api.schema.user.auth.CheckAuthResponse import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse 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.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest 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.YandexExchangeRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
import ru.fromchat.api.schema.user.auth.YandexOAuthParams import ru.fromchat.api.schema.user.auth.YandexOAuthParams
@@ -273,7 +272,7 @@ object ApiClient {
} }
if (response.status.value == 401) { if (response.status.value == 401) {
val path = response.call.request.url.encodedPath 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) { if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch { MainScope().launch {
runCatching { WebSocketManager.disconnect() } 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 = suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
httpProbe httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") { .post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
@@ -513,6 +496,8 @@ object ApiClient {
data class NeedsRegister( data class NeedsRegister(
val yandexRequired: Boolean, val yandexRequired: Boolean,
val yandex: YandexOAuthParams?, val yandex: YandexOAuthParams?,
val captchaRequired: Boolean,
val captcha: SmartCaptchaParams?,
) : AuthPasswordStepOutcome ) : AuthPasswordStepOutcome
} }
@@ -530,10 +515,26 @@ object ApiClient {
.body<JsonObject>() .body<JsonObject>()
val status = raw["status"]?.jsonPrimitive?.contentOrNull val status = raw["status"]?.jsonPrimitive?.contentOrNull
return when (status) { return when (status) {
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister( "needs_register" -> {
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true, val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) }, 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)) 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.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -86,14 +87,56 @@ object ChatListSync {
private suspend fun refreshPublicChatPreviewFromLatest() { private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching { runCatching {
val response = ApiClient.getMessages(limit = 1) val cached = MessageRepository.loadPublicMessages()
val latest = response.messages.maxByOrNull { message -> 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 parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching } ?: return@runCatching
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. // Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest) MessageRepository.upsertPublicMessage(latest)
} }
} }
}
private fun handleWebSocketMessage(message: WebSocketMessage) { private fun handleWebSocketMessage(message: WebSocketMessage) {
when (message.type) { when (message.type) {
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return val data = message.data ?: return
val updates = runCatching { val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) 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 -> updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data)) handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
} }
} }
"profileUpdate" -> { "profileUpdate" -> {
val payload = message.data ?: return val payload = message.data ?: run {
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
return
}
onProfileUpdatePayload(payload) onProfileUpdatePayload(payload)
} }
} }
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d( Logger.d(
"ProfileUpdateSync", "ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " + "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) 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) UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) { if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
} }
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) } 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? { 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. * 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) { suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock { 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) { if (seq > _lastSeq.value) {
persistLastSeq(seq) persistLastSeq(seq)
sendAck(seq) sendAckFireAndForget(seq)
} }
} }
} }
@@ -90,6 +101,10 @@ object UpdateSyncManager {
/** /**
* Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild. * Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* Does not advance the cursor until apply/rebuild succeeds. * 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() { suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) { if (gapDetectionInProgress) {
@@ -108,16 +123,38 @@ object UpdateSyncManager {
try { try {
var rounds = 0 var rounds = 0
var consecutiveFailures = 0
while (rounds < 100) { while (rounds < 100) {
rounds++ rounds++
val startSeq = _lastSeq.value val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)") 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( Logger.i(
"UpdateSyncManager", "UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " + "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) updateMissedCount(response.missedCount)
@@ -126,18 +163,43 @@ object UpdateSyncManager {
val ok = rebuildStateFromHistory() val ok = rebuildStateFromHistory()
if (ok) { if (ok) {
persistLastSeq(response.lastSeq) persistLastSeq(response.lastSeq)
sendAck(response.lastSeq) sendAckFireAndForget(response.lastSeq)
} else { } else {
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq") Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
} }
break break
} }
"ok" -> { "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) { if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq) 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 -> { else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}") Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
@@ -165,7 +227,7 @@ object UpdateSyncManager {
GetUpdatesRequest(lastSeq = lastSeq), GetUpdatesRequest(lastSeq = lastSeq),
), ),
) )
val response = WebSocketManager.request(requestMessage) val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null val data = response?.data ?: return null
return runCatching { return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data) ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
@@ -174,11 +236,15 @@ object UpdateSyncManager {
}.getOrNull() }.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 val token = ApiClient.token ?: return
if (seq <= 0) return if (seq <= 0) return
runCatching { runCatching {
WebSocketManager.request( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "ackUpdates", type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token), credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
@@ -188,6 +254,7 @@ object UpdateSyncManager {
), ),
), ),
) )
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure { }.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it) Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
} }
@@ -236,18 +303,26 @@ object UpdateSyncManager {
val ordered = collected.values.sortedBy { val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L 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() { private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations() val conversations = MessageRepository.loadCachedDmConversations()
Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
for (conversation in conversations) { for (conversation in conversations) {
val otherId = conversation.otherUserId val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId) MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null var beforeId: Int? = null
var pageCount = 0
repeat(MAX_HISTORY_PAGES) { repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId) val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat if (page.messages.isEmpty()) return@repeat
pageCount++
for (envelope in page.messages) { for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope) val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element) DmInboundMessageProcessor.processNew(element)
@@ -256,6 +331,7 @@ object UpdateSyncManager {
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id 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.flow.merge
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState 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() val convId = conversationIdForPublic()
Logger.d(
"MessageCache",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
)
val resolved = messages.map { it.resolvePublicAttachmentLayout() } val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved) ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged) purgeSupersededPendingRows(iid, convId, before, merged)
} }
replaceMessages(convId, merged) replaceMessages(convId, merged, replaceAll = replaceAll)
} }
suspend fun clearPublicMessages() { suspend fun clearPublicMessages() {
Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic()) clearConversationMessages(conversationIdForPublic())
} }
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId)) loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) { suspend fun clearDmMessages(otherUserId: Int) {
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(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) val convId = conversationIdForDm(otherUserId)
Logger.d(
"MessageCache",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
"replaceAll=$replaceAll convId=$convId",
)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages) val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated) purgeSupersededPendingRows(iid, convId, before, hydrated)
} }
replaceMessages(convId, hydrated) replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations() pruneEmptyConversations()
} }
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) { suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout() val resolved = message.resolvePublicAttachmentLayout()
Logger.d(
"MessageCache",
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
"clientId=${resolved.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved) upsertSingle(conversationIdForPublic(), resolved)
} }
@@ -279,25 +296,37 @@ object MessageCacheStore {
} }
suspend fun upsertDmMessage(otherUserId: Int, message: Message) { 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) ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) { suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId) deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
} }
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) { suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
Logger.d(
"MessageCache",
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
)
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId) deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
} }
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId) deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageById(messageId: Int) { suspend fun deletePublicMessageById(messageId: Int) {
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId) deleteMessageById(conversationIdForPublic(), messageId)
} }
@@ -357,7 +386,7 @@ object MessageCacheStore {
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) { if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
resolved = resolved.copy( resolved = resolved.copy(
fileAspectRatios = listOf(decodedAspect), 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) { suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted( db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid, instanceId = iid,
@@ -611,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() ?: return@withContext .executeAsOneOrNull() ?: run {
val label = resolveDmConversationDisplayLabel(otherUserId, null) Logger.d(
if (label.isEmpty()) return@withContext "MessageCache",
if (label == existing.displayName) return@withContext "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( db.messageDatabaseQueries.upsertConversation(
instanceId = iid, instanceId = iid,
id = existing.id, id = existing.id,
@@ -947,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) { private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
} }
@@ -954,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
} }
@@ -961,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) { private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById( db.messageDatabaseQueries.deleteMessageById(
instanceId = iid, instanceId = iid,
@@ -968,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(), 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) { private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid) val profile = ProfileCache.get(uid)
val usernameResolved = when { val usernameResolved = when {
self != null && uid == self.id -> self.username self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() } else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
?: profile?.displayName?.takeIf { it.isNotBlank() } }
?: "" val displayNameResolved = when {
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} }
val pictureResolved = when { val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture self != null && uid == self.id -> self.profile_picture
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L, is_read = isRead != 0L,
is_edited = isEdited != 0L, is_edited = isEdited != 0L,
username = usernameResolved, username = usernameResolved,
displayName = displayNameResolved,
profile_picture = pictureResolved, profile_picture = pictureResolved,
verified = profile?.verified, verified = profile?.verified,
verificationStatus = profile?.verificationStatus, verificationStatus = profile?.verificationStatus,
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
} }
suspend fun clearAll() { suspend fun clearAll() {
Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
} }
@@ -1334,24 +1426,49 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> { private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> {
val self = ApiClient.user?.id val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
)
return emptyList() return emptyList()
} }
return CacheValidator.filterMessages(conversationId, messages, self) 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 val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"replaceMessages incoherent→clear convId=$conversationId " +
"count=${messages.size} replaceAll=$replaceAll",
)
clearConversationMessages(conversationId) clearConversationMessages(conversationId)
return return
} }
val validated = CacheValidator.filterMessages(conversationId, messages, self) val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId() 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) { withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
.associate { it.id.toInt() to it.replyToId } .associate { it.id.toInt() to it.replyToId }
if (replaceAll) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
validated.forEach { msg: Message -> validated.forEach { msg: Message ->
@@ -1367,11 +1484,28 @@ object MessageCacheStore {
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]), replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id, clientMessageId = msg.client_message_id,
deletedFlag = 0L, deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent" 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 ->
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 { dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it) syncDmConversationPreviewFromCache(it)
pruneEmptyConversations() pruneEmptyConversations()
@@ -1,6 +1,7 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
@@ -21,7 +22,9 @@ object MessageRepository {
MessageCacheStore.observeMessages(activeInstance(), conversationId) MessageCacheStore.observeMessages(activeInstance(), conversationId)
fun observePublicMessages(): Flow<List<Message>> = 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>> = fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
observeMessages(conversationIdForDm(otherUserId)) observeMessages(conversationIdForDm(otherUserId))
@@ -56,8 +59,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow<List<CachedConversation>> = fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance()) MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) = suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replacePublicMessages(messages) ru.fromchat.Logger.d(
"MessageRepo",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
}
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message) suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,20 +75,32 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) = suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId) 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) MessageCacheStore.markMessageDeleted(conversationId, messageId)
}
suspend fun markPublicMessageDeleted(messageId: Int) = suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId) markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
suspend fun deletePublicMessageById(messageId: Int) =
MessageCacheStore.deletePublicMessageById(messageId)
suspend fun loadDmMessages(otherUserId: Int): List<Message> = suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId) MessageCacheStore.loadDmMessages(otherUserId)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) = suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replaceDmMessages(otherUserId, messages) 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) = suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message) MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -91,8 +111,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) = suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId) 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) MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
}
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser 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. */ /** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
private fun bumpRevision() { private fun bumpRevision(reason: String) {
_revision.value++ 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] fun get(userId: Int): UserProfile? = profiles[userId]
/** True when a full non-preview profile was fetched recently enough to skip refetch. */ /** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean { fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
val profile = get(userId) ?: return false val profile = get(userId) ?: run {
if (profile.isClientPreviewOnly) return false Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false return false
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs }
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). */ /** Emits whenever this user's cached profile changes (including bio). */
@@ -110,7 +133,13 @@ object ProfileCache {
) { ) {
if (id <= 0) return if (id <= 0) return
val existing = get(id) 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() } val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -120,11 +149,27 @@ object ProfileCache {
} else { } else {
displayName?.trim()?.takeIf { it.isNotEmpty() } displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() } ?: 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( put(
UserProfile( UserProfile(
id = id, id = id,
@@ -159,29 +204,49 @@ object ProfileCache {
val hasIdentity = val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank() profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) { if (!hasIdentity) {
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id) remove(profile.id)
return return
} }
} }
val cur = profiles val cur = profiles
val existing = cur[profile.id] 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 ( if (
existing != null && existing != null &&
!existing.isClientPreviewOnly && !existing.isClientPreviewOnly &&
existing.bio != profile.bio existing.bio != profile.bio
) { ) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " + "put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}", "'${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) profiles = cur + (profile.id to profile)
bumpRevision() bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) } 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) { if (!force) {
val existing = get(profile.id) val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) { 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( val patched = existing.copy(
verified = normalized.verified ?: existing.verified, verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus verificationStatus = normalized.verificationStatus
?: existing.verificationStatus, ?: existing.verificationStatus,
) )
val verificationChanged = patched.verified != existing.verified ||
patched.verificationStatus != existing.verificationStatus
if (patched != existing) put(patched) if (patched != existing) put(patched)
if (existing.bio != normalized.bio) { if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " + "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. if (!verificationChanged) {
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs) fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
return return
} }
} }
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile applied force=$force id=${profile.id} " + "applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'", "bio='${normalized.bio?.take(48)}'",
) )
put(normalized) put(normalized)
@@ -228,9 +322,13 @@ object ProfileCache {
fun remove(userId: Int) { fun remove(userId: Int) {
val cur = profiles 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 profiles = cur - userId
bumpRevision() bumpRevision("remove:$userId")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
@@ -251,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return if (user.id <= 0) return
val incomingUsername = user.username.trim() 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 isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) { val incomingDisplayName = if (isDeleted) {
null null
} else { } 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) 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) { if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy( val patched = existing.copy(
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName, displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing.displayName
},
profilePicture = if (isDeleted) { profilePicture = if (isDeleted) {
null null
} else { } else {
@@ -278,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason, suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted, 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 return
} }
@@ -286,8 +411,11 @@ object ProfileCache {
UserProfile( UserProfile(
id = user.id, id = user.id,
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } displayName = if (isDeleted) {
?: incomingDisplayName, null
} else {
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
},
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
@@ -326,21 +454,33 @@ object ProfileCache {
val uid = message.user_id val uid = message.user_id
if (uid <= 0) return if (uid <= 0) return
val existing = get(uid) 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) { if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy( val patched = existing.copy(
verified = message.verified ?: existing.verified, verified = message.verified ?: existing.verified,
verificationStatus = message.verificationStatus ?: existing.verificationStatus, 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) if (patched != existing) put(patched)
return return
} }
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } 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 isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val display = if (isDeleted) null else displayName
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() } val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
?: existing?.profilePicture
put( put(
UserProfile( UserProfile(
@@ -379,6 +519,8 @@ object ProfileCache {
val user = ApiClient.user val user = ApiClient.user
return message.copy( return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() }, 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() } profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture, ?: user?.profile_picture,
reply_to = enrichedReply, reply_to = enrichedReply,
@@ -389,6 +531,8 @@ object ProfileCache {
username = message.username.trim().ifBlank { username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty() 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_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture, ?: profile?.profilePicture,
verified = message.verified ?: profile?.verified, verified = message.verified ?: profile?.verified,
@@ -413,7 +557,7 @@ object ProfileCache {
} }
fullProfileFetchedAtMs = emptyMap() fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("onActiveInstanceChanged:$instanceId")
} }
} }
} }
@@ -427,6 +571,7 @@ object ProfileCache {
} else { } else {
emptyMap() emptyMap()
} }
val before = profiles.size
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
profiles = diskProfiles profiles = diskProfiles
} else { } else {
@@ -442,8 +587,14 @@ object ProfileCache {
} }
profiles = merged 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() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("hydrateFromDisk")
} }
} }
@@ -455,6 +606,7 @@ object ProfileCache {
p.displayName.isNullOrBlank() p.displayName.isNullOrBlank()
}.keys }.keys
if (toRemove.isEmpty()) return if (toRemove.isEmpty()) return
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles var cur = profiles
for (id in toRemove) { for (id in toRemove) {
cur = cur - id cur = cur - id
@@ -464,10 +616,11 @@ object ProfileCache {
suspend fun clear() { suspend fun clear() {
persistMutex.withLock { persistMutex.withLock {
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap() profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap() fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = "" 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.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache 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.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) { suspend fun processDeleted(element: JsonElement) {
val data = runCatching { val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element) 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 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) { val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId data.senderId -> data.recipientId
else -> data.senderId else -> data.senderId
} ?: return } ?: return
ru.fromchat.Logger.i(
"DmInbox",
"processDeleted messageId=${data.id} otherUserId=$otherUserId " +
"senderId=${data.senderId} recipientId=${data.recipientId}",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
} }
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int, otherUserId: Int,
): Message { ): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) { if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName -> if (senderUsername != null || senderDisplayName != null) {
ProfileCache.mergePreview(id = envelope.senderId, username = senderName) ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername,
displayName = senderDisplayName,
)
} }
} }
val senderProfile = ProfileCache.get(envelope.senderId) val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } else {
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() } senderUsername
?: envelope.senderUsername?.takeIf { it.isNotBlank() } ?: 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( return Message(
id = envelope.id, id = envelope.id,
user_id = envelope.senderId, user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId, is_read = envelope.senderId == currentUserId,
is_edited = false, is_edited = false,
username = username, username = username,
displayName = displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
} }
"dmDeleted" -> message.data?.let { element -> "dmDeleted" -> message.data?.let { element ->
scope.launch { scope.launch {
ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element) DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged() DmConversationListNotifier.notifyChanged()
} }
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
*/ */
object PublicInboxCoordinator { object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) { 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) ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty() val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
} }
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) { 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) ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages() val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current -> val merged = existing.map { current ->
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
if (merged.none { it.id == edited.id }) { if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited) MessageRepository.upsertPublicMessage(edited)
} else { } else {
MessageRepository.replacePublicMessages(merged) MessageRepository.upsertPublicMessage(
merged.first { it.id == edited.id },
)
} }
} }
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) { suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching { val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element) 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) MessageRepository.deletePublicMessageById(deleted.message_id)
} }
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock { suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching { val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) 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) { for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data)) applyOne(WebSocketMessage(type = update.type, data = update.data))
} }
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
} }
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) } "newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) } "messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) } "messageDeleted" -> {
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message) 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 else -> Unit
} }
} }
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean, val is_read: Boolean,
val is_edited: Boolean, val is_edited: Boolean,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null, val profile_picture: String? = null,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null, @SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ReactionUser( data class ReactionUser(
val id: Int, 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 senderId: Int,
val recipientId: Int, val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null, @SerialName("sender_username") val senderUsername: String? = null,
@SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String, @SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -27,11 +27,18 @@ data class YandexOAuthParams(
val scope: String, val scope: String,
) )
@Serializable
data class SmartCaptchaParams(
val client_key: String,
)
@Serializable @Serializable
data class AuthNeedsRegisterResponse( data class AuthNeedsRegisterResponse(
val status: String, val status: String,
val yandex_required: Boolean = false, val yandex_required: Boolean = false,
val yandex: YandexOAuthParams? = null, val yandex: YandexOAuthParams? = null,
val captcha_required: Boolean = false,
val captcha: SmartCaptchaParams? = null,
) )
@Serializable @Serializable
@@ -70,4 +77,5 @@ data class RegisterConfirmRequest(
val confirm_password: String, val confirm_password: String,
val bio: String? = null, val bio: String? = null,
val registration_proof: String? = null, val registration_proof: String? = null,
val captcha_token: String? = null,
) )
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types package ru.fromchat.api.schema.websocket.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String, val action: String,
val user_id: Int, val user_id: Int,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val reactions: List<ReactionData> val reactions: List<ReactionData>
) )
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType import ru.fromchat.legal.DocumentType
import ru.fromchat.notifications.NotificationLaunchCoordinator import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AuthScreen 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.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.calls.CallOverlay
@@ -466,6 +468,10 @@ fun App(
YandexOAuthScreen() YandexOAuthScreen()
} }
composable(SmartCaptchaNav.ROUTE) {
SmartCaptchaScreen()
}
composable("chat") { composable("chat") {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
@@ -1,9 +1,10 @@
package ru.fromchat.ui.auth package ru.fromchat.ui.auth
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams 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. * Cleared on welcome / successful auth / explicit reset to username.
*/ */
internal object AuthRegisterDraft { internal object AuthRegisterDraft {
@@ -14,7 +15,10 @@ internal object AuthRegisterDraft {
var bio: String = "" var bio: String = ""
var yandexRequired: Boolean = false var yandexRequired: Boolean = false
var yandexParams: YandexOAuthParams? = null var yandexParams: YandexOAuthParams? = null
var captchaRequired: Boolean = false
var captchaParams: SmartCaptchaParams? = null
var registrationProof: String? = null var registrationProof: String? = null
var captchaToken: String? = null
var page: Int = 0 var page: Int = 0
fun clear() { fun clear() {
@@ -25,7 +29,10 @@ internal object AuthRegisterDraft {
bio = "" bio = ""
yandexRequired = false yandexRequired = false
yandexParams = null yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null registrationProof = null
captchaToken = null
page = 0 page = 0
} }
} }
@@ -20,20 +20,24 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.crypto.IdentityKeyManager 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.ServerProbeResult
import ru.fromchat.api.instance.probeServer 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.core.ErrorResponse
import ru.fromchat.api.schema.user.auth.LoginResponse import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest 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.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.change_server import ru.fromchat.change_server
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.ui.LocalNavController 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.confirmPasswordStepPage
import ru.fromchat.ui.auth.register.profileStepPage import ru.fromchat.ui.auth.register.profileStepPage
import ru.fromchat.ui.auth.yandex.yandexIdStepPage import ru.fromchat.ui.auth.yandex.yandexIdStepPage
@@ -58,6 +62,8 @@ internal sealed interface PasswordStepResult {
data class NeedsRegister( data class NeedsRegister(
val yandexRequired: Boolean, val yandexRequired: Boolean,
val yandex: YandexOAuthParams?, val yandex: YandexOAuthParams?,
val captchaRequired: Boolean,
val captcha: SmartCaptchaParams?,
) : PasswordStepResult ) : PasswordStepResult
data class WrongPassword(val message: String) : PasswordStepResult data class WrongPassword(val message: String) : PasswordStepResult
data class RateLimited(val message: String) : PasswordStepResult data class RateLimited(val message: String) : PasswordStepResult
@@ -120,11 +126,21 @@ internal suspend fun authPasswordStep(
PasswordStepResult.LoginSuccess PasswordStepResult.LoginSuccess
} }
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister( 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, yandexRequired = outcome.yandexRequired,
yandex = outcome.yandex, yandex = outcome.yandex,
captchaRequired = outcome.captchaRequired,
captcha = outcome.captcha,
) )
} }
}
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
when (e.response.status.value) { when (e.response.status.value) {
401 -> PasswordStepResult.WrongPassword( 401 -> PasswordStepResult.WrongPassword(
@@ -147,8 +163,15 @@ internal suspend fun register(
password: String, password: String,
bio: String, bio: String,
registrationProof: String?, registrationProof: String?,
captchaToken: String?,
unexpectedError: String, unexpectedError: String,
) = try { ) = try {
Logger.i(
SmartCaptchaLog.TAG,
"register confirm start username=${username.trim()} " +
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
)
fullLogin(username.trim(), password.trim()) { fullLogin(username.trim(), password.trim()) {
val derived = deriveAuthSecret(username.trim(), password.trim()) val derived = deriveAuthSecret(username.trim(), password.trim())
ApiClient.authRegisterConfirm( ApiClient.authRegisterConfirm(
@@ -159,17 +182,25 @@ internal suspend fun register(
confirm_password = derived, confirm_password = derived,
bio = bio.trim().takeIf { it.isNotEmpty() }, bio = bio.trim().takeIf { it.isNotEmpty() },
registration_proof = registrationProof, registration_proof = registrationProof,
captcha_token = captchaToken,
), ),
) )
} }
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
RegisterResult.Success RegisterResult.Success
} catch (e: ClientRequestException) { } 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)) { if (e.response.status.value == 400 && isUsernameTakenError(e)) {
RegisterResult.UsernameTaken RegisterResult.UsernameTaken
} else { } else {
RegisterResult.Error(parseClientError(e, unexpectedError)) RegisterResult.Error(parseClientError(e, unexpectedError))
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
RegisterResult.Error(unexpectedError, e) RegisterResult.Error(unexpectedError, e)
} }
@@ -216,7 +247,11 @@ fun AuthScreen(
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) } var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) } var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) } 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 registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
val navController = LocalNavController.current
fun persistDraft() { fun persistDraft() {
AuthRegisterDraft.username = username AuthRegisterDraft.username = username
@@ -226,7 +261,10 @@ fun AuthScreen(
AuthRegisterDraft.bio = bio AuthRegisterDraft.bio = bio
AuthRegisterDraft.yandexRequired = yandexRequired AuthRegisterDraft.yandexRequired = yandexRequired
AuthRegisterDraft.yandexParams = yandexParams AuthRegisterDraft.yandexParams = yandexParams
AuthRegisterDraft.captchaRequired = captchaRequired
AuthRegisterDraft.captchaParams = captchaParams
AuthRegisterDraft.registrationProof = registrationProof AuthRegisterDraft.registrationProof = registrationProof
AuthRegisterDraft.captchaToken = captchaToken
AuthRegisterDraft.page = flowState.pagerState.currentPage AuthRegisterDraft.page = flowState.pagerState.currentPage
} }
@@ -257,7 +295,10 @@ fun AuthScreen(
bio = "" bio = ""
yandexRequired = false yandexRequired = false
yandexParams = null yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null registrationProof = null
captchaToken = null
AuthRegisterDraft.clear() AuthRegisterDraft.clear()
flowState.resetPredictiveState() flowState.resetPredictiveState()
scope.launch { scope.launch {
@@ -269,7 +310,19 @@ fun AuthScreen(
onDispose { persistDraft() } onDispose { persistDraft() }
} }
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) { LaunchedEffect(
username,
password,
confirmPassword,
displayName,
bio,
yandexRequired,
yandexParams,
captchaRequired,
captchaParams,
registrationProof,
captchaToken,
) {
persistDraft() persistDraft()
} }
@@ -284,7 +337,12 @@ fun AuthScreen(
snapshotFlow { flowState.pagerState.currentPage } snapshotFlow { flowState.pagerState.currentPage }
.collect { page -> .collect { page ->
AuthRegisterDraft.page = 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) { val target = if (page > settledPage) {
AuthFlowStep.Profile.ordinal AuthFlowStep.Profile.ordinal
} else { } else {
@@ -301,7 +359,10 @@ fun AuthScreen(
confirmPassword = "" confirmPassword = ""
yandexRequired = false yandexRequired = false
yandexParams = null yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null registrationProof = null
captchaToken = null
} }
AuthFlowStep.Password.ordinal -> { AuthFlowStep.Password.ordinal -> {
@@ -309,12 +370,16 @@ fun AuthScreen(
confirmPassword = "" confirmPassword = ""
yandexRequired = false yandexRequired = false
yandexParams = null yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null registrationProof = null
captchaToken = null
} }
AuthFlowStep.ConfirmPassword.ordinal -> { AuthFlowStep.ConfirmPassword.ordinal -> {
// Keep confirm password when returning from Yandex ID / OAuth. // Keep confirm password when returning from Yandex ID / OAuth / captcha.
registrationProof = null registrationProof = null
captchaToken = null
} }
AuthFlowStep.YandexId.ordinal -> { 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 val yandexStep = yandexParams
ExpressiveStepFlowScaffold( ExpressiveStepFlowScaffold(
flowState = flowState, flowState = flowState,
@@ -343,10 +449,18 @@ fun AuthScreen(
password = password, password = password,
onPasswordChange = { password = it }, onPasswordChange = { password = it },
onLoginSuccess = wrappedAuthSuccess, 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 yandexRequired = required
yandexParams = params yandexParams = params
captchaRequired = captchaReq
captchaParams = captcha
registrationProof = null registrationProof = null
captchaToken = null
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
}, },
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
@@ -356,11 +470,24 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it }, onConfirmPasswordChange = { confirmPassword = it },
password = password, password = password,
onContinue = { onContinue = {
if (yandexRequired && yandexParams != null) { 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) flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
} else { }
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) flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
} }
}
}, },
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
), ),
@@ -379,7 +506,16 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it }, onConfirmPasswordChange = { confirmPassword = it },
password = password, password = password,
onContinue = { onContinue = {
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) flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
}, },
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
) )
@@ -392,6 +528,7 @@ fun AuthScreen(
onBioChange = { bio = it }, onBioChange = { bio = it },
password = password, password = password,
registrationProof = registrationProof, registrationProof = registrationProof,
captchaToken = captchaToken,
onRegisterSuccess = wrappedAuthSuccess, onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername, onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
@@ -35,6 +35,7 @@ import ru.fromchat.login
import ru.fromchat.password import ru.fromchat.password
import ru.fromchat.password_length_error import ru.fromchat.password_length_error
import ru.fromchat.show_password import ru.fromchat.show_password
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec import ru.fromchat.ui.components.ExpressiveHeroSpec
@@ -54,7 +55,12 @@ internal fun passwordStepPage(
password: String, password: String,
onPasswordChange: (String) -> Unit, onPasswordChange: (String) -> Unit,
onLoginSuccess: () -> 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, onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage { ): ExpressiveStepPage {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -135,7 +141,12 @@ internal fun passwordStepPage(
} }
is PasswordStepResult.NeedsRegister -> { is PasswordStepResult.NeedsRegister -> {
onNeedsRegister(result.yandexRequired, result.yandex) onNeedsRegister(
result.yandexRequired,
result.yandex,
result.captchaRequired,
result.captcha,
)
} }
is PasswordStepResult.WrongPassword -> { is PasswordStepResult.WrongPassword -> {
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.trackImeScrollTarget import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username import ru.fromchat.username
import ru.fromchat.username_chars_error
import ru.fromchat.username_length_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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
internal fun usernameStepPage( internal fun usernameStepPage(
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
val fillAll = stringResource(Res.string.fill_all_fields) val fillAll = stringResource(Res.string.fill_all_fields)
val usernameLenError = stringResource(Res.string.username_length_error) 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 serverFail = stringResource(Res.string.auth_server_connect_failed)
val unexpected = stringResource(Res.string.error_unexpected) val unexpected = stringResource(Res.string.error_unexpected)
val nextLabel = stringResource(Res.string.settings_next) val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage( return ExpressiveStepPage(
hero = ExpressiveHeroSpec( hero = ExpressiveHeroSpec(
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY) .trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding), .padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true, singleLine = true,
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(), colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape, shape = SettingsPasswordOutlineFieldShape,
) )
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
button = { button = {
ActionButton( ActionButton(
onClick = { onClick = {
if (busy) return@ActionButton if (busy || hasProhibitedChars) return@ActionButton
val trimmed = username.trim() val trimmed = username.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
onSnackbar(fillAll, null) onSnackbar(fillAll, null)
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
} }
} }
}, },
enabled = !busy, enabled = !busy && !hasProhibitedChars,
loading = busy, loading = busy,
modifier = Modifier.fillMaxWidth(), 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, onBioChange: (String) -> Unit,
password: String, password: String,
registrationProof: String?, registrationProof: String?,
captchaToken: String?,
onRegisterSuccess: () -> Unit, onRegisterSuccess: () -> Unit,
onUsernameTaken: () -> Unit, onUsernameTaken: () -> Unit,
onSnackbar: (String, Throwable?) -> Unit, onSnackbar: (String, Throwable?) -> Unit,
@@ -149,6 +150,7 @@ internal fun profileStepPage(
password = password, password = password,
bio = bio.trim(), bio = bio.trim(),
registrationProof = registrationProof, registrationProof = registrationProof,
captchaToken = captchaToken,
unexpectedError = unexpected, unexpectedError = unexpected,
) )
) { ) {
@@ -94,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock { addMessageMutex.withLock {
batchStateUpdates { batchStateUpdates {
updateState { current -> updateState { current ->
val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState( val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(), panelSnap,
messages, messages,
) )
val withReplies = attachPublicReplyReferences(merged) 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 if (current.messages == withReplies) current
else current.copy(messages = withReplies) else current.copy(messages = withReplies)
} }
@@ -692,7 +707,8 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
client_message_id = tempId, client_message_id = tempId,
reply_to = resolvedReply, reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 }, replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
@@ -949,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = replyTo, reply_to = replyTo,
@@ -1188,7 +1189,7 @@ fun ChatScreen(
.getOrNull(panelState.messages.lastIndex - 1) .getOrNull(panelState.messages.lastIndex - 1)
previous != null && previous != null &&
messageListKey(previous) == listKey && messageListKey(previous) == listKey &&
classifyEnterMode(previous, newest!!) == classifyEnterMode(previous, newest) ==
EnterMode.ExtendGroup EnterMode.ExtendGroup
} }
val showTimestamp = when { val showTimestamp = when {
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted( peerIsDeleted(
userId = userId, userId = userId,
currentUserId = ApiClient.user?.id, currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title, username = ProfileCache.get(userId)?.username,
) )
} == true } == true
Row( Row(
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
profileUserId?.let { userId -> profileUserId?.let { userId ->
val status = resolveVerificationStatus(userId) val profileCacheRevision by ProfileCache.revision.collectAsState()
val status = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
if (status != null) { if (status != null) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
StatusBadge( StatusBadge(
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -710,25 +711,26 @@ fun ImageFullscreenPreview(
} }
// Top bar: back, display name + date/time, 3-dot menu // Top bar: back, display name + date/time, 3-dot menu
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility( AnimatedVisibility(
visible = effectiveMenusVisible, visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.statusBarsPadding(),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp), .padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
IconButton(onClick = { dismissRequested = true }) { IconButton(onClick = { dismissRequested = true }) {
Icon( Icon(
@@ -820,33 +822,32 @@ fun ImageFullscreenPreview(
} }
// Bottom: message text // Bottom: message text
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility( AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(), visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
) { ) {
if (message.content.isNotBlank()) { Column(
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA)) .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.navigationBarsPadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, 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 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 @Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String { fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (isDeletedAccountUsername(message.username)) { if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId) ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername ?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) { if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
return message.username return message.username.trim()
} }
fun messageSenderProfilePicture( fun messageSenderProfilePicture(
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty() 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -229,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed) val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo) val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id) val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture ?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId) 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 isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to val replyRef = message.reply_to
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
downloadPaused && canDownload -> file?.let { downloadFile -> downloadPaused && file != null && canDownload -> {
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = downloadFile, file = file,
dmEnvelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile -> file != null && canDownload && !isDownloading && !downloadPaused -> {
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = downloadFile, file = file,
dmEnvelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId) val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty() 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) } cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) { if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner // 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). // when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) { val hadCachedMessages = cached.isNotEmpty()
if (hadCachedMessages) {
batchStateUpdates { batchStateUpdates {
clearMessages() clearMessages()
addMessages(cached) addMessages(cached)
setLoading(false) setLoading(false)
} }
messagesLoaded = true } else {
return setLoading(true)
} }
setLoading(true)
try { try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(), CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use. // Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true messagesLoaded = true
} else { } else {
val error = historyResult.exceptionOrNull() val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages() clearMessages()
setHasMoreMessages(false) setHasMoreMessages(false)
messagesLoaded = true messagesLoaded = true
} else if (hadCachedMessages) {
messagesLoaded = true
} }
} }
} finally { } finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence) MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid) OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
} }
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
} }
} }
@@ -533,31 +540,46 @@ class DmPanel(
} }
val outcome = decryptDmEnvelopeForUi(envelope) val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext) val dec = parseDmMessageContent(outcome.plaintext)
updateMessage(envelope.id) { val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
it.copy(
content = dec.text, content = dec.text,
is_edited = true, is_edited = true,
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails, fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios, fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes, fileSizes = dec.fileSizes ?: previous?.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions, fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
isContentCorrupted = outcome.isCorrupted, isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope, dmEnvelope = envelope,
reply_to = it.reply_to, reply_to = previous?.reply_to,
) )
updateMessage(envelope.id) {
editedForCache.copy(reply_to = it.reply_to)
} }
// Persist edit to cache MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
} }
} }
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message { private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val senderUsername = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } 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( return Message(
id = envelope.id, id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId) else -> ActiveDmChatTracker.isActive(otherUserId)
}, },
is_edited = false, is_edited = false,
username = username, username = senderUsername,
displayName = senderDisplayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) { private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching { val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element) json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: run {
Logger.w("DmPanel", "processDeletedEnvelope decode failed")
return
}
val involvesPeer = val involvesPeer =
data.senderId == otherUserId || data.senderId == otherUserId ||
data.recipientId == otherUserId || data.recipientId == otherUserId ||
data.senderId == currentUserId 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) { scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id) DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
} }
deleteMessageImmediately(data.id) deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope scope = scope
) { ) {
private val typingHandler = PublicChatTypingHandler(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). * Whether replacing the list would change **structure or message body** (content / edited).
@@ -82,6 +84,7 @@ class PublicChatPanel(
ProfileCache.enrichPublicMessageForDisplay( ProfileCache.enrichPublicMessageForDisplay(
mergeMessageUiFields(fresh, message).copy( mergeMessageUiFields(fresh, message).copy(
username = fresh.username, username = fresh.username,
displayName = fresh.displayName,
profile_picture = fresh.profile_picture, profile_picture = fresh.profile_picture,
verified = fresh.verified, verified = fresh.verified,
verificationStatus = fresh.verificationStatus, verificationStatus = fresh.verificationStatus,
@@ -177,6 +180,8 @@ class PublicChatPanel(
} }
suspend fun hydrateFromLocalCache() { suspend fun hydrateFromLocalCache() {
// Sender display names live in ProfileCache (message rows only store userId).
runCatching { ProfileCache.hydrateFromDisk() }
hydrateMessagesFromLocalCache() hydrateMessagesFromLocalCache()
runCatching { PublicChatProfileCache.hydrateFromDisk() } runCatching { PublicChatProfileCache.hydrateFromDisk() }
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) } PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
@@ -377,9 +382,8 @@ class PublicChatPanel(
} }
override suspend fun loadMessages() { override suspend fun loadMessages() {
loadMessagesMutex.withLock {
hydrateMessagesFromLocalCache() hydrateMessagesFromLocalCache()
if (networkHistoryLoaded) return
networkHistoryLoaded = true
val cached = _state.messages val cached = _state.messages
if (cached.isEmpty()) { if (cached.isEmpty()) {
@@ -388,7 +392,13 @@ class PublicChatPanel(
} }
} }
// Refresh from network; this may be fast or slow, but runs entirely off main. // 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) { val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) } runCatching { ApiClient.getMessages(limit = 50) }
} }
@@ -400,12 +410,18 @@ class PublicChatPanel(
val optimisticSnapshot = snapshotPendingOptimisticMessages() val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)" val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() } val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}" Logger.d(
Logger.d("PublicChatPanel", loadMsg) "PublicChatPanel",
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
)
var mergedForCache: List<Message>? = null var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = snapshotUiMessagesForNetworkMerge() val shown = snapshotUiMessagesForNetworkMerge()
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}") Logger.d(
"PublicChatPanel",
"loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
)
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) { if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages) val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
@@ -425,7 +441,11 @@ class PublicChatPanel(
addMessages( addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged), ProfileCache.enrichPublicMessagesForDisplay(merged),
) )
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}") Logger.d(
"PublicChatPanel",
"loadMessages: after addMessages mergedSize=${merged.size} " +
"restoring optimistic count=${optimisticSnapshot.size}",
)
restorePendingOptimisticMessages(optimisticSnapshot) restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false) setLoading(false)
@@ -439,8 +459,11 @@ class PublicChatPanel(
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val toPersist = mergedForCache val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages) ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}") Logger.d(
MessageCacheStore.replacePublicMessages(toPersist) "PublicChatPanel",
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
)
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
} }
} else if (responseResult.isFailure) { } else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull() val cause = responseResult.exceptionOrNull()
@@ -452,23 +475,26 @@ class PublicChatPanel(
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else if (cached.isEmpty()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else { } else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
} }
} }
} else if (cached.isEmpty()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else {
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
}
}
} }
} }
@@ -494,7 +520,7 @@ class PublicChatPanel(
) )
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -529,29 +555,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id) DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { existing -> val existing = _state.messages.find { it.id == editedMsg.id }
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to) 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) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
} }
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) 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) DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id) clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(deletedData.message_id) MessageRepository.deletePublicMessageById(deletedData.message_id)
} }
Logger.d(
"PublicChatPanel",
"messageDeleted done messageId=${deletedData.message_id} " +
"uiAfter=${_state.messages.size}",
)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
val existing = _state.messages.find { it.id == reactionUpdate.message_id }
if (existing != null) {
val updated = existing.copy(reactions = reactionUpdate.reactions)
handleReactionUpdate(reactionUpdate) handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
}
} else {
handleReactionUpdate(reactionUpdate)
} }
} }
"typing" -> { "typing" -> {
@@ -620,6 +664,7 @@ class PublicChatPanel(
cancelQueuedMessage(message) cancelQueuedMessage(message)
return return
} }
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message) beginMessageDissolve(message)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId) 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 mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet() val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them. // 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 extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when { panel.id < 0 && cid != null && cid !in mergedClientIds
panel.id < 0 && cid != null && cid !in mergedClientIds -> true
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
else -> false
} }
if (extraPanel.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
"ids=${extraPanel.map { it.id }}",
)
} }
return dedupeMessagesByClientId( return dedupeMessagesByClientId(
@@ -114,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
?: db.pendingFileAspectRatio?.takeIf { it > 0f } ?: db.pendingFileAspectRatio?.takeIf { it > 0f }
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } ?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.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( 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 { pendingFileUri = when {
confirmed -> localPreview confirmed -> localPreview
else -> panel.pendingFileUri ?: db.pendingFileUri else -> panel.pendingFileUri ?: db.pendingFileUri
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
flowState.resetPredictiveState() flowState.resetPredictiveState()
return@PredictiveBackHandler return@PredictiveBackHandler
} }
// Cancel must always reverse — never commit just because progress crossed the threshold.
scope.launch { scope.launch {
val commit = lastProgress >= predictiveThreshold
finishPredictiveMorph( finishPredictiveMorph(
flowState = flowState, flowState = flowState,
pagerState = pagerState, pagerState = pagerState,
startProgress = lastProgress, 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.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Icon 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 ru.fromchat.ui.components.Text
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") } val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val closeSheet: () -> Unit = { val closeSheet: () -> Unit = {
scope.launch { scope.launch {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String, title: String,
userId: Int, userId: Int,
) { ) {
val profileCacheRevision by ProfileCache.revision.collectAsState()
val verificationStatus = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
DisplayName( DisplayName(
displayName = title, displayName = title,
verificationStatus = resolveVerificationStatus(userId), verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge, textStyle = MaterialTheme.typography.bodyLarge,
) )
} }
@@ -928,18 +933,18 @@ internal fun DmConversationRowContent(
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = cached?.deleted, deleted = cached?.deleted,
suspended = cached?.suspended, suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() }, username = cached?.username,
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when { val peerTitle = when {
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() !cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName conversation.displayName.isNotBlank() -> conversation.displayName
else -> cached?.visibleUsername(currentUserId).orEmpty() else -> cached?.visibleUsername(currentUserId).orEmpty()
} }
val avatarInitialsLabel = when { val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() !cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName conversation.displayName.isNotBlank() -> conversation.displayName
else -> "" else -> ""
} }
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -529,7 +528,7 @@ fun ChatsTab(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val navController = LocalNavController.current val navController = LocalNavController.current
val clipboardManager = LocalClipboardManager.current val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
@@ -1092,7 +1091,9 @@ fun ChatsTab(
chatContextMenuOverlay.onLink = { chatContextMenuOverlay.onLink = {
when (contextMenuState.target) { when (contextMenuState.target) {
ChatContextMenuTarget.Public -> { ChatContextMenuTarget.Public -> {
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) } publicChatLink?.let { link ->
scope.launch { clipboard.setText(link) }
}
} }
ChatContextMenuTarget.Dm -> { ChatContextMenuTarget.Dm -> {
val link = contextMenuState.otherUserId?.let { userId -> val link = contextMenuState.otherUserId?.let { userId ->
@@ -1100,7 +1101,7 @@ fun ChatsTab(
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId" 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 com.pr0gramm3r101.ui.Website
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.about_link_max import ru.fromchat.about_link_max
@@ -111,7 +112,10 @@ fun AboutScreen() {
BrandTitle(Modifier.padding(bottom = 4.dp)) BrandTitle(Modifier.padding(bottom = 4.dp))
Text( Text(
text = stringResource(Res.string.about_version), text = stringResource(
Res.string.about_version,
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp) 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -43,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -422,6 +426,8 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -479,10 +485,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .fillMaxSize()
.padding() .consumeWindowInsets(innerPadding)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), .hazeSource(hazeState),
contentPadding = innerPadding contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 24.dp,
),
) { ) {
item { item {
Column(Modifier.fillMaxWidth()) { 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -586,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
sheetDevice?.let { d -> sheetDevice?.let { d ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null }, onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
sheetState = sheetState sheetState = sheetState,
) { ) {
DeviceSessionDetailBottomSheet( DeviceSessionDetailBottomSheet(
d = d, d = d,
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -389,13 +390,15 @@ fun LogFilesScreen(
} }
if (showShareSheet) { if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showShareSheet = false showShareSheet = false
pendingSharePaths = emptyList() pendingSharePaths = emptyList()
}, },
sheetState = sheetState, sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) { ) {
LogsShareBottomSheet( LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) }, onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState 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.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -632,13 +633,15 @@ fun LogsScreen() {
} }
if (showShareSheet) { if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showShareSheet = false showShareSheet = false
pendingShareRequest = null pendingShareRequest = null
}, },
sheetState = sheetState, sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) { ) {
LogsShareBottomSheet( LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) }, onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -648,7 +651,10 @@ fun LogsScreen() {
} }
if (showCleanSheet) { if (showCleanSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showCleanSheet = false }, onDismissRequest = { showCleanSheet = false },
sheetState = sheetState, sheetState = sheetState,
@@ -105,10 +105,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot 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.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ContextMenuPressable 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_member_since
import ru.fromchat.profile_headline_username import ru.fromchat.profile_headline_username
import ru.fromchat.profile_headline_verification import ru.fromchat.profile_headline_verification
import ru.fromchat.profile_invalid_link
import ru.fromchat.profile_load_failed import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support import ru.fromchat.profile_verified_support
@@ -267,7 +265,6 @@ fun ProfileScreen(
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
showBackButton: Boolean = false, showBackButton: Boolean = false,
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -377,7 +374,7 @@ fun ProfileScreen(
ApiClient.applyOwnProfile(refreshed) ApiClient.applyOwnProfile(refreshed)
state = latestUi.copy(profile = refreshed, error = null) state = latestUi.copy(profile = refreshed, error = null)
} catch (_: Exception) { } catch (_: Exception) {
ownUserId?.let { ProfileCache.get(it) }?.let { cached -> ownUserId.let { ProfileCache.get(it) }?.let { cached ->
state = latestUi.copy(profile = cached) state = latestUi.copy(profile = cached)
} }
} }
@@ -609,7 +606,7 @@ fun ProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
), ),
ProfileAction( ProfileAction(
label = labelSettings, label = labelSettings,
@@ -654,7 +651,7 @@ fun ProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
) )
) )
if (ServerConfig.callsEnabled) { if (ServerConfig.callsEnabled) {
@@ -831,10 +828,10 @@ fun ProfileScreen(
labelCopy = labelCopy, labelCopy = labelCopy,
labelEdit = labelEdit, labelEdit = labelEdit,
detailsBringIntoView = detailsBringIntoView, detailsBringIntoView = detailsBringIntoView,
clipboardManager = clipboardManager,
clipboard = clipboard, clipboard = clipboard,
navController = navController, navController = navController,
scope = scope, scope = scope,
snackbarHostState = snackbarHostState,
openContextMenuHaptic = openContextMenuHaptic, openContextMenuHaptic = openContextMenuHaptic,
onBack = onBack, onBack = onBack,
onProfileUpdated = { updated -> onProfileUpdated = { updated ->
@@ -880,7 +877,6 @@ fun PublicChatProfileScreen(
initialDisplayName: String? = null, initialDisplayName: String? = null,
showBackButton: Boolean = false, showBackButton: Boolean = false,
) { ) {
val clipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -962,7 +958,7 @@ fun PublicChatProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
), ),
ProfileAction( ProfileAction(
label = labelSearch, label = labelSearch,
@@ -1003,15 +999,15 @@ fun PublicChatProfileScreen(
when { when {
useSharedAvatar && displayName.isNotBlank() -> { useSharedAvatar && displayName.isNotBlank() -> {
item { item {
with(sharedTransitionScope!!) { with(sharedTransitionScope) {
Avatar( Avatar(
profilePictureUrl = null, profilePictureUrl = null,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.sharedElement( .sharedElement(
rememberSharedContentState(key = sharedAvatarKey!!), rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope!!, animatedVisibilityScope = animatedVisibilityScope,
) )
.size(104.dp), .size(104.dp),
) )
@@ -1051,6 +1047,7 @@ fun PublicChatProfileScreen(
labelCopy = labelCopy, labelCopy = labelCopy,
clipboard = clipboard, clipboard = clipboard,
scope = scope, scope = scope,
snackbarHostState = snackbarHostState,
) )
} }
} }
@@ -1090,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
labelCopy: String, labelCopy: String,
clipboard: SupportClipboardManager, clipboard: SupportClipboardManager,
scope: CoroutineScope, scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -1136,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
supportingSlot = { supportingSlot = {
ProfileBioMarkdown( ProfileBioMarkdown(
content = resolvedProfile.bio.orEmpty(), content = resolvedProfile.bio.orEmpty(),
snackbarHostState = snackbarHostState,
) )
}, },
position = ListItemPosition.START, position = ListItemPosition.START,
@@ -1320,10 +1319,10 @@ private fun ProfileLoadedBody(
labelCopy: String, labelCopy: String,
labelEdit: String, labelEdit: String,
detailsBringIntoView: BringIntoViewRequester, detailsBringIntoView: BringIntoViewRequester,
clipboardManager: ClipboardManager,
clipboard: SupportClipboardManager, clipboard: SupportClipboardManager,
navController: NavController, navController: NavController,
scope: CoroutineScope, scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
openContextMenuHaptic: () -> Unit, openContextMenuHaptic: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit, onProfileUpdated: (UserProfile) -> Unit,
@@ -1349,7 +1348,7 @@ private fun ProfileLoadedBody(
onContextMenuOpen = openContextMenuHaptic, onContextMenuOpen = openContextMenuHaptic,
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(displayName)) scope.launch { clipboard.setText(displayName) }
} }
if (isOwnProfile) { if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) { item(Icons.Rounded.Edit, labelEdit) {
@@ -1453,9 +1452,9 @@ private fun ProfileLoadedBody(
}, },
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText( scope.launch {
AnnotatedString(usernameForLinks.orEmpty()), clipboard.setText(usernameForLinks.orEmpty())
) }
} }
if (isOwnProfile) { if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) { item(Icons.Rounded.Edit, labelEdit) {
@@ -1491,7 +1490,7 @@ private fun ProfileLoadedBody(
}, },
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(memberSinceText)) scope.launch { clipboard.setText(memberSinceText) }
} }
}, },
) )
@@ -1505,7 +1504,10 @@ private fun ProfileLoadedBody(
headline = headlineBio, headline = headlineBio,
supportingSlot = { supportingSlot = {
key(resolvedProfile.id, bioContent) { key(resolvedProfile.id, bioContent) {
ProfileBioMarkdown(content = bioContent) ProfileBioMarkdown(
content = bioContent,
snackbarHostState = snackbarHostState,
)
} }
}, },
divider = true, divider = true,
@@ -1864,14 +1866,27 @@ private fun ProfileLoadedBody(
@Composable @Composable
private fun ProfileBioMarkdown( private fun ProfileBioMarkdown(
content: String, content: String,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
MarkdownPlain( MarkdownPlain(
content = content, content = content,
modifier = modifier, 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 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 { buildscript {
repositories { repositories {
google() google()
+23 -25
View File
@@ -1,52 +1,52 @@
[versions] [versions]
agp = "9.1.1" agp = "9.3.1"
androidx-activityCompose = "1.13.0" androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.18.0" androidx-core-ktx = "1.19.0"
androidx-exifinterface = "1.3.7" androidx-exifinterface = "1.4.2"
coilCompose = "3.4.0" coilCompose = "3.5.0"
compose-multiplatform = "1.10.3" compose-multiplatform = "1.11.1"
#noinspection NewerVersionAvailable #noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded" constraintlayout = "0.8.0-shaded"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.2" firebaseMessaging = "25.1.1"
googleServices = "4.4.4" googleServices = "4.5.0"
haze = "1.7.2" haze = "1.7.2"
kotlin = "2.3.21" kotlin = "2.4.10"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha07" biometric = "1.4.0-alpha07"
gson = "2.14.0" gson = "2.14.0"
kotlinxIoBytestring = "0.9.0" kotlinxIoBytestring = "0.9.1"
kotlinxCoroutinesCore = "1.11.0" kotlinxCoroutinesCore = "1.11.0"
kotlinxIoCore = "0.9.0" kotlinxIoCore = "0.9.1"
multiplatformCryptoLibsodiumBindings = "0.9.5" multiplatformCryptoLibsodiumBindings = "0.9.5"
multiplatformSettings = "1.3.0" multiplatformSettings = "1.3.0"
serialization = "2.3.21" serialization = "2.4.10"
serialization-json = "1.11.0" serialization-json = "1.11.0"
material = "1.13.0" material = "1.14.0"
activityKtx = "1.13.0" activityKtx = "1.13.0"
navigationCompose = "2.9.2" navigationCompose = "2.9.2"
datastore = "1.2.1" datastore = "1.2.1"
security-crypto = "1.1.0" security-crypto = "1.1.0"
ktor = "3.4.3" ktor = "3.5.1"
slf4j = "1.7.36" slf4j = "1.7.36"
kotlinxDatetime = "0.8.0" kotlinxDatetime = "0.8.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.11.0"
composeBom = "2026.05.00" composeBom = "2026.06.01"
composeMaterialIconsExtended = "1.7.3" composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.10.0-alpha05" composeMaterial3 = "1.12.0-alpha03"
composeComponents = "1.10.3" composeComponents = "1.11.1"
playServicesBase = "18.10.0" playServicesBase = "18.10.0"
tweetnaclJava = "1.1.3" tweetnaclJava = "1.1.3"
androidxWork = "2.11.2" androidxWork = "2.11.2"
cryptography-kotlin = "0.6.0" cryptography-kotlin = "0.6.0"
krypto = "4.0.10" krypto = "4.0.10"
sqldelight = "2.3.2" sqldelight = "2.3.2"
livekitAndroid = "2.25.2" livekitAndroid = "2.27.0"
livekitAndroidComposeComponents = "2.3.0" livekitAndroidComposeComponents = "2.4.0"
markdownRendererM3 = "0.41.0" markdownRendererM3 = "0.43.0"
bouncycastle = "1.79" bouncycastle = "1.85"
webkit = "1.14.0" webkit = "1.16.0"
[libraries] [libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } 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-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", 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-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" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" } slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 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-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" } 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-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" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" } tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+1 -2
View File
@@ -18,8 +18,7 @@ kotlin {
listOf( listOf(
iosArm64(), iosArm64(),
iosSimulatorArm64(), iosSimulatorArm64()
iosX64(),
).forEach { ).forEach {
it.binaries.framework { it.binaries.framework {
baseName = "shared" baseName = "shared"
@@ -17,7 +17,7 @@ actual fun Clipboard.toSupport(): SupportClipboardManager {
override suspend fun getText(): String? { override suspend fun getText(): String? {
val entry = clipboard.getClipEntry() ?: return null 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) { override fun setTextListener(listener: (String) -> Unit) {
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package com.pr0gramm3r101.utils.settings package com.pr0gramm3r101.utils.settings
import androidx.core.content.edit import androidx.core.content.edit
@@ -7,6 +9,12 @@ import com.pr0gramm3r101.utils.UtilsLibrary.context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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 { class AndroidSecureSettings : Settings {
private val masterKey by lazy { private val masterKey by lazy {
MasterKey.Builder(context) MasterKey.Builder(context)
@@ -80,4 +88,3 @@ class AndroidSecureSettings : Settings {
encryptedPrefs.contains(key) encryptedPrefs.contains(key)
} }
} }