11 Commits

81 changed files with 3003 additions and 805 deletions
+2 -7
View File
@@ -62,8 +62,8 @@ extensions.configure<ApplicationExtension> {
applicationId = "ru.fromchat"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
versionCode = rootProject.extra["versionCode"] as Int
versionName = rootProject.extra["versionName"] as String
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
@@ -166,9 +166,4 @@ dependencies {
implementation(project(":app:shared"))
implementation(project(":utils:shared"))
testImplementation("junit:junit:4.13.2")
testImplementation(libs.androidx.compose.material3)
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
testImplementation("org.robolectric:robolectric:4.14.1")
}
+13 -1
View File
@@ -21,7 +21,10 @@
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" />
android:resource="@drawable/ic_stat_fromchat" />
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -51,6 +54,15 @@
android:host="oauth"
android:pathPrefix="/yandex" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="fromchat"
android:host="oauth"
android:pathPrefix="/vk" />
</intent-filter>
</activity>
<service
android:name=".fcm.FromChatFirebaseMessagingService"
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
@OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
val sender = pushData["sender_display_name"]
?.takeIf { it.isNotBlank() }
?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) {
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch
}
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
NotificationHelper.showFallbackPushNotification(
context = applicationContext,
title = title,
body = body,
sender = sender,
messageId = fallbackMessageId,
isDirectMessage = false,
senderId = senderId,
)
}
if (isDirectMessage) {
NotificationHelper.fetchAndNotify(
applicationContext,
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
dmSenderName = sender,
)
} else {
NotificationHelper.fetchAndNotify(applicationContext)
// Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
}
} catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -72,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
}
}
override fun onNewToken(token: String) {
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
override fun onRegistered(installationId: String) {
Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
GlobalScope.launch(Dispatchers.IO) {
try {
settings.putString("pending_fcm_token", token)
settings.putString("pending_fcm_token", installationId)
uploadPendingFcmTokenIfAvailable()
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
} catch (e: Exception) {
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
}
super.onNewToken(token)
}
}
}
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity
import ru.fromchat.Logger
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview
@@ -53,13 +58,20 @@ object NotificationHelper {
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val SUMMARY_NOTIFICATION_ID = 1000000
private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
const val KEY_TEXT_REPLY = "key_text_reply"
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publicFetchMutex = Mutex()
private var publicFetchJob: Job? = null
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings(
@@ -72,6 +84,24 @@ object NotificationHelper {
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
private fun publicConversationTitle(context: Context): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim()
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: context.getString(R.string.public_chat)
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
ProfileCache.get(message.user_id)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return message.username.trim().ifBlank { "FromChat" }
}
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent(
@@ -124,7 +154,6 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context
@@ -141,6 +170,17 @@ object NotificationHelper {
}
}
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
fun schedulePublicFetchAndNotify(context: Context) {
publicFetchJob?.cancel()
publicFetchJob = helperScope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(context.applicationContext, includeDmMessages = false)
}
}
}
suspend fun fetchAndNotify(
context: Context,
includeDmMessages: Boolean = false,
@@ -168,10 +208,7 @@ object NotificationHelper {
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages)
}
} else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
}
@@ -194,13 +231,15 @@ object NotificationHelper {
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
)
if (retryMessages.isNotEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, retryMessages)
}
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName)
fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
}
return
} catch (_: Exception) {
@@ -305,10 +344,11 @@ object NotificationHelper {
val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
!envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty()
val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
@@ -320,9 +360,9 @@ object NotificationHelper {
showFallbackPushNotification(
context = context,
title = if (senderName.isNotBlank()) {
"Direct message from $senderName"
context.getString(R.string.notification_direct_message_from, senderName)
} else {
"Direct message"
context.getString(R.string.notification_direct_message)
},
body = notificationBody,
sender = senderName,
@@ -330,7 +370,7 @@ object NotificationHelper {
allowWhenPublicChatVisible = true,
isDirectMessage = true,
targetDmUserId = dmConversationUserId,
conversationTitle = "Direct Messages"
conversationTitle = context.getString(R.string.notification_direct_messages_title)
)
shownDm.add(shownDmKey)
}
@@ -351,10 +391,10 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false,
targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat",
conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null,
) {
CoroutineScope(Dispatchers.Main).launch {
helperScope.launch(Dispatchers.Main) {
createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1)
@@ -400,16 +440,31 @@ object NotificationHelper {
}
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
val groupKey = if (isDirectMessage && targetDmUserId != null) {
GROUP_DM_PREFIX + targetDmUserId
} else {
GROUP_PUBLIC
}
val notificationId = if (isDirectMessage && targetDmUserId != null) {
SUMMARY_NOTIFICATION_ID + targetDmUserId
} else {
SUMMARY_NOTIFICATION_ID
}
cancelStaleSystemTrayDuplicates(context)
notify(
SUMMARY_NOTIFICATION_ID,
notificationId,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title)
.setContentText(body)
.setGroup(groupKey)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle(conversationTitle).addMessage(
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
@@ -423,7 +478,7 @@ object NotificationHelper {
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = isDirectMessage,
@@ -433,7 +488,7 @@ object NotificationHelper {
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
@@ -457,37 +512,44 @@ object NotificationHelper {
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
GlobalScope.launch {
helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0
val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) != PackageManager.PERMISSION_GRANTED
) {
// Find new messages that are not from the current user
val currentUserId = settings.getInt("current_user_id", -1)
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch
val newMessages = messages.filter { msg ->
!shown.contains(msg.id.toString()) && // Not already shown
msg.user_id != currentUserId // Not from current user
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
}
.sortedBy { it.id }
if (newMessages.isEmpty()) {
Logger.d(
"NotificationHelper",
@@ -495,50 +557,57 @@ object NotificationHelper {
)
return@launch
}
newMessages.apply { forEach { shown.add(it.id.toString()) } }
newMessages.forEach { shown.add(it.id.toString()) }
newMessageCount = newMessages.size
Logger.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
)
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo)
.setStyle(
NotificationCompat.MessagingStyle(
createChannel(context)
cancelStaleSystemTrayDuplicates(context)
val messagingStyle = NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style ->
)
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
style.addMessage(
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(msg.username)
.setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.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)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
"Reply",
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = false,
@@ -547,27 +616,42 @@ object NotificationHelper {
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...")
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build()
)
} else {
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
}
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
Logger.i(
"NotificationHelper",
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
)
}
}
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
private fun cancelStaleSystemTrayDuplicates(context: Context) {
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
manager.cancel(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { status ->
status.notification.channelId == "fcm_fallback_notification_channel" ||
status.id == 0
}
.forEach { status ->
manager.cancel(status.tag, status.id)
}
}
}
}
}
@@ -0,0 +1,78 @@
package ru.fromchat.notifications
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import kotlin.math.abs
/**
* Builds a circular initials avatar matching the in-app public-chat row (title initials +
* name-hash gradient), for use as a notification large icon.
*/
internal object PublicChatNotificationAvatar {
private const val SIZE_PX = 192
fun create(title: String): Bitmap {
val seed = title.ifBlank { "FromChat" }
val hash = seed.hashCode()
val r = abs(hash % 256)
val g = abs((hash / 256) % 256)
val b = abs((hash / 65536) % 256)
val colorStart = android.graphics.Color.rgb(
(r + 100).coerceIn(0, 255),
(g + 100).coerceIn(0, 255),
(b + 100).coerceIn(0, 255),
)
val colorEnd = android.graphics.Color.rgb(
(r + 50).coerceIn(0, 255),
(g + 50).coerceIn(0, 255),
(b + 50).coerceIn(0, 255),
)
val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f,
0f,
SIZE_PX.toFloat(),
SIZE_PX.toFloat(),
colorStart,
colorEnd,
Shader.TileMode.CLAMP,
)
}
val radius = SIZE_PX / 2f
canvas.drawCircle(radius, radius, radius, paint)
val initials = initialsFrom(seed)
if (initials.isNotBlank()) {
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = radius * 0.7f
}
val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, radius, textY, textPaint)
}
return bitmap
}
private fun initialsFrom(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when {
words.isEmpty() -> ""
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase()
}
else -> words.take(2).joinToString("") {
it.firstOrNull()?.uppercaseChar()?.toString().orEmpty()
}
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" />
</vector>
@@ -1,5 +1,11 @@
<resources>
<string name="public_chat">Общий чат</string>
<string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string>
<string name="notification_reply">Ответить</string>
<string name="notification_reply_hint">Ответ в чат…</string>
<string name="notification_direct_message">Личное сообщение</string>
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
<string name="notification_direct_messages_title">Личные сообщения</string>
</resources>
@@ -1,6 +1,12 @@
<resources>
<string name="app_name" translatable="false">FromChat</string>
<string name="public_chat">Main chat</string>
<string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string>
<string name="notification_reply">Reply</string>
<string name="notification_reply_hint">Reply to chat…</string>
<string name="notification_direct_message">Direct message</string>
<string name="notification_direct_message_from">Direct message from %1$s</string>
<string name="notification_direct_messages_title">Direct Messages</string>
</resources>
+62 -1
View File
@@ -1,3 +1,12 @@
@file:Suppress("TaskMissingDescription")
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
@@ -7,6 +16,54 @@ plugins {
alias(libs.plugins.sqldelight)
}
abstract class GenerateAppBuildInfoTask : DefaultTask() {
@get:Input
abstract val versionName: Property<String>
@get:Input
abstract val versionCode: Property<Int>
@get:Input
abstract val debugBuild: Property<Boolean>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun generate() {
val outRoot = outputDirectory.get().asFile
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
}
outRoot.deleteRecursively()
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
"""
|package ru.fromchat
|
|/** Injected by Gradle into generated sources (not under src/). */
|object AppBuildInfo {
| const val version = "${versionName.get()}"
| const val versionCode = ${versionCode.get()}
| const val isDebug = ${debugBuild.get()}
|}
|
""".trimMargin()
)
}
}
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
versionName.set(rootProject.extra["versionName"] as String)
versionCode.set(rootProject.extra["versionCode"] as Int)
debugBuild.set(
gradle.startParameter.taskNames.let { names ->
!names.any { it.contains("Release", ignoreCase = true) } ||
names.any { it.contains("Debug", ignoreCase = true) }
},
)
outputDirectory.set(layout.buildDirectory.dir("generated/sources/appBuildInfo/kotlin"))
}
kotlin {
android {
namespace = "ru.fromchat.shared"
@@ -21,7 +78,6 @@ kotlin {
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64(),
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
@@ -37,6 +93,10 @@ kotlin {
}
}
commonMain {
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
@@ -123,6 +183,7 @@ compose.resources {
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
dependsOn("generateResourceAccessorsForCommonMain")
dependsOn(generateAppBuildInfo)
}
tasks.register("generateResourceAccessors") {
@@ -1,6 +1,7 @@
package ru.fromchat.api
import com.google.android.gms.tasks.Task
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.Dispatchers
@@ -13,19 +14,27 @@ import kotlin.coroutines.resumeWithException
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont ->
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task: Task<String> ->
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { cont ->
addOnCompleteListener { task ->
if (task.isSuccessful) {
cont.resume(task.result)
} else {
cont.resumeWithException(
task.exception ?: IllegalStateException("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 {
return runCatching {
ApiClient.registerFcmToken(token)
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
outputFile.delete()
}
val cipher = GCMBlockCipher.newInstance(AESEngine())
val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
@@ -0,0 +1,14 @@
package ru.fromchat.notifications
import android.content.Context
/** White silhouette drawable for status-bar / notification small icons. */
object NotificationSmallIcon {
private const val DRAWABLE_NAME = "ic_stat_fromchat"
fun resId(context: Context): Int {
val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
return id
}
}
@@ -1,4 +1,4 @@
package ru.fromchat.ui.auth.yandex
package ru.fromchat.ui.auth.oauth
import android.annotation.SuppressLint
import android.app.Activity
@@ -57,12 +57,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import ru.fromchat.Logger
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
import ru.fromchat.auth.yandex.extractOAuthCode
import ru.fromchat.ui.components.PredictiveBackHandler
import kotlin.coroutines.resume
private const val LOG_TAG = "YandexOAuthWV"
private const val LOG_TAG = "OAuthWebView"
private const val LOADING_FADE_MS = 250
private const val LOADING_HIDE_DELAY_MS = 500L
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
@@ -135,15 +133,18 @@ private const val PAGE_SCROLL_Y_JS = """
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun YandexOAuthWebView(
actual fun OAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
redirectUriPrefix: String,
isAuthNavigation: (url: String) -> Boolean,
clearCookies: Boolean,
themeCookieHosts: List<String>,
onPageBackgroundColor: (Color) -> Unit,
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
onCode: (String) -> Unit,
onRedirectUrl: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
@@ -158,8 +159,10 @@ actual fun YandexOAuthWebView(
val scheme = if (darkTheme) "dark" else "light"
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
val onCodeState = rememberUpdatedState(onCode)
val onRedirectUrlState = rememberUpdatedState(onRedirectUrl)
val onErrorState = rememberUpdatedState(onError)
val isAuthNavigationState = rememberUpdatedState(isAuthNavigation)
val redirectUriPrefixState = rememberUpdatedState(redirectUriPrefix)
val onCancelState = rememberUpdatedState(onCancel)
val lifecycleOwner = LocalLifecycleOwner.current
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
@@ -193,7 +196,7 @@ actual fun YandexOAuthWebView(
}
}
ApplyYandexWebViewSystemBars(
ApplyOAuthWebViewSystemBars(
chromeColor = chromeColor,
darkTheme = darkTheme,
restoreSurfaceColor = fallbackColor,
@@ -269,7 +272,7 @@ actual fun YandexOAuthWebView(
onCancel = { },
)
val client = remember(authorizeUrl, lang, darkTheme) {
val client = remember(authorizeUrl, lang, darkTheme, redirectUriPrefix) {
Logger.i(
LOG_TAG,
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
@@ -278,17 +281,16 @@ actual fun YandexOAuthWebView(
object : WebViewClient() {
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
if (url.isNullOrBlank()) return false
if (url.startsWith("fromchat://", ignoreCase = true)) {
val redirectPrefix = redirectUriPrefixState.value
// Intercept trusted HTTPS callback (and fromchat:// deep links) before any page paint.
if (url.startsWith(redirectPrefix, ignoreCase = true) ||
url.startsWith("fromchat://", ignoreCase = true)
) {
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
val code = extractOAuthCode(url)
if (code != null) {
onCodeState.value(code)
} else {
onErrorState.value("")
}
onRedirectUrlState.value(url)
return true
}
if (!isYandexAuthNavigation(url)) {
if (!isAuthNavigationState.value(url)) {
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
view?.context?.let { ctx ->
runCatching {
@@ -327,7 +329,11 @@ actual fun YandexOAuthWebView(
"wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
"canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
)
if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
if (url != null && (
url.startsWith(redirectUriPrefixState.value, ignoreCase = true) ||
url.startsWith("fromchat://", ignoreCase = true)
)
) {
handleSpecialUrl(view, url)
}
pageLoading = true
@@ -374,16 +380,16 @@ actual fun YandexOAuthWebView(
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
)
if (clearCookies) {
clearYandexWebViewCookies()
clearOAuthWebViewCookies()
}
seedYandexThemeCookies(darkTheme)
seedOAuthThemeCookies(darkTheme, themeCookieHosts)
WebView(activity).apply {
setBackgroundColor(fallbackColor.toArgb())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
applyYandexDarkSettingsIfNeeded(this, darkTheme)
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
webViewClient = client
webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
@@ -394,14 +400,14 @@ actual fun YandexOAuthWebView(
): Boolean {
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
val temp = WebView(activity).apply {
applyYandexDarkSettingsIfNeeded(this, darkTheme)
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
v: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
if (!isYandexAuthNavigation(url)) {
if (!isAuthNavigationState.value(url)) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
@@ -451,7 +457,7 @@ actual fun YandexOAuthWebView(
update = { wv ->
val clientChanged = wv.webViewClient !== client
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
applyYandexDarkSettingsIfNeeded(wv, darkTheme)
applyOAuthDarkSettingsIfNeeded(wv, darkTheme)
if (clientChanged) {
Logger.i(
LOG_TAG,
@@ -500,7 +506,7 @@ actual fun YandexOAuthWebView(
}
@Composable
private fun ApplyYandexWebViewSystemBars(
private fun ApplyOAuthWebViewSystemBars(
chromeColor: Color,
darkTheme: Boolean,
restoreSurfaceColor: Color,
@@ -557,26 +563,19 @@ private fun Context.findActivity(): Activity? {
return null
}
private fun clearYandexWebViewCookies() {
private fun clearOAuthWebViewCookies() {
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.removeAllCookies(null)
cookieManager.flush()
Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
Logger.i(LOG_TAG, "cleared all WebView cookies for OAuth re-auth")
}
private fun seedYandexThemeCookies(darkTheme: Boolean) {
private fun seedOAuthThemeCookies(darkTheme: Boolean, hosts: List<String>) {
if (hosts.isEmpty()) return
val theme = if (darkTheme) "dark" else "light"
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
val hosts = listOf(
"https://yandex.ru",
"https://yandex.com",
"https://passport.yandex.ru",
"https://passport.yandex.com",
"https://oauth.yandex.ru",
"https://oauth.yandex.com",
)
for (host in hosts) {
cookieManager.setCookie(host, "color_scheme=$theme; path=/")
cookieManager.setCookie(host, "theme=$theme; path=/")
@@ -590,7 +589,7 @@ private fun seedYandexThemeCookies(darkTheme: Boolean) {
* Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
*/
@Suppress("DEPRECATION")
private fun applyYandexDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
private fun applyOAuthDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
if (previous == darkTheme) return
Logger.w(
@@ -671,36 +670,3 @@ private suspend fun sampleTopRowColor(webView: WebView): Color? {
}
}
}
/**
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
*/
internal fun isYandexAuthNavigation(url: String): Boolean {
if (url.startsWith("fromchat://", ignoreCase = true)) return true
val uri = Uri.parse(url)
val host = uri.host?.lowercase() ?: return false
val path = uri.path.orEmpty().lowercase()
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
return path.contains("captcha") ||
path.startsWith("/auth") ||
path.startsWith("/showcaptcha") ||
path.startsWith("/checkcaptcha")
}
return host == "oauth.yandex.com" ||
host == "oauth.yandex.ru" ||
host.endsWith(".oauth.yandex.com") ||
host.endsWith(".oauth.yandex.ru") ||
host == "passport.yandex.ru" ||
host == "passport.yandex.com" ||
host.endsWith(".passport.yandex.ru") ||
host.endsWith(".passport.yandex.com") ||
host.startsWith("auth.yandex.") ||
host.startsWith("login.yandex.") ||
host.startsWith("id.yandex.") ||
host == "sso.passport.yandex.ru" ||
host == "captcha.yandex.net" ||
host.endsWith(".captcha.yandex.net") ||
(host.contains("captcha") && host.contains("yandex"))
}
@@ -1,6 +1,5 @@
package ru.fromchat.ui.calls
import android.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore
import ru.fromchat.notifications.NotificationSmallIcon
/**
* Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService(
this,
RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon)
.setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL)
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
)
nm.createNotificationChannel(ch)
}
val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) {
R.drawable.stat_sys_upload
}
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle)
.setContentText(updatedText)
.setSmallIcon(smallIcon)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true)
.build()
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- VK Compact Logo (2021present) letter mark only; brand blue background removed for tintable icons. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M25.54,34.5801C14.6,34.5801 8.3601,27.0801 8.1001,14.6001H13.5801C13.7601,23.7601 17.8,27.6401 21,28.4401V14.6001H26.1602V22.5001C29.3202,22.1601 32.6398,18.5601 33.7598,14.6001H38.9199C38.0599,19.4801 34.4599,23.0801 31.8999,24.5601C34.4599,25.7601 38.5601,28.9001 40.1201,34.5801H34.4399C33.2199,30.7801 30.1802,27.8401 26.1602,27.4401V34.5801H25.54Z" />
</vector>
@@ -5,7 +5,7 @@
<string name="settings">Настройки</string>
<string name="home">Главная</string>
<string name="about">О приложении</string>
<string name="about_version">Версия 1.0</string>
<string name="about_version">Версия %1$s</string>
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string>
@@ -31,6 +31,7 @@
<string name="display_name_error">От 1 до 64 символов</string>
<string name="fill_all_fields">Заполните все поля</string>
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string>
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
@@ -54,9 +55,14 @@
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
<string name="auth_step_verify_title">Подтвердите аккаунт</string>
<string name="auth_step_verify_body">Выберите Яндекс ID или VK ID. Мы используем это только против ботов и для соблюдения законов — email и профиль этих сервисов не сохраняем.</string>
<string name="auth_step_vk_cta">Продолжить через VK ID</string>
<string name="auth_yandex_webview_title">Яндекс ID</string>
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="auth_vk_client_mismatch">Сервер вернул неожиданный идентификатор приложения VK. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_vk_failed">Вход через VK ID отменён или не удался.</string>
<string name="chats">Чаты</string>
<string name="contacts">Контакты</string>
<string name="profile">Профиль</string>
@@ -169,6 +175,7 @@
<string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
<string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string>
@@ -431,6 +438,13 @@
<string name="settings_yandex_step_confirm_cta">Продолжить</string>
<string name="settings_yandex_step_done_title">Яндекс ID обновлён</string>
<string name="settings_yandex_step_done_body">Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.</string>
<string name="settings_account_change_vk">Сменить VK ID</string>
<string name="settings_account_change_vk_d">Привязать другой аккаунт VK</string>
<string name="settings_vk_step_confirm_title">Сменить VK ID?</string>
<string name="settings_vk_step_confirm_body">Это не удаляет ваш аккаунт FromChat. Предыдущий VK ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.</string>
<string name="settings_vk_step_confirm_cta">Продолжить</string>
<string name="settings_vk_step_done_title">VK ID обновлён</string>
<string name="settings_vk_step_done_body">Аккаунт теперь привязан к VK ID, с которым вы только что вошли.</string>
<string name="settings_done">Готово</string>
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
@@ -8,7 +8,7 @@
<string name="settings">Settings</string>
<string name="home">Home</string>
<string name="about">About</string>
<string name="about_version">Version 1.0</string>
<string name="about_version">Version %1$s</string>
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string>
@@ -38,6 +38,7 @@
<!-- Validation Errors -->
<string name="fill_all_fields">Please fill in every field</string>
<string name="username_length_error">Username must be 3 to 20 characters</string>
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
<string name="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">The two passwords dont match</string>
<string name="auth_welcome_title">Welcome to FromChat</string>
@@ -61,9 +62,14 @@
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we dont store it; sign-in is only used for security reasons.</string>
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
<string name="auth_step_verify_title">Verify your account</string>
<string name="auth_step_verify_body">Choose Yandex ID or VK ID. We only use this to fight bots and meet legal requirements — we dont store your email or profile from these providers.</string>
<string name="auth_step_vk_cta">Continue with VK 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_failed">Yandex sign-in was cancelled or failed.</string>
<string name="auth_vk_client_mismatch">This server returned an unexpected VK app id. Update the app or contact support.</string>
<string name="auth_vk_failed">VK sign-in was cancelled or failed.</string>
<!-- Main Screen -->
<string name="chats">Chats</string>
<string name="contacts">Contacts</string>
@@ -187,6 +193,7 @@
<string name="profile_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</string>
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
<string name="profile_invalid_link">Couldnt open the link</string>
<string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</string>
@@ -459,6 +466,13 @@
<string name="settings_yandex_step_confirm_cta">Continue</string>
<string name="settings_yandex_step_done_title">Yandex ID updated</string>
<string name="settings_yandex_step_done_body">Your account is now linked to the Yandex ID you just signed in with.</string>
<string name="settings_account_change_vk">Change VK ID</string>
<string name="settings_account_change_vk_d">Link a different VK account</string>
<string name="settings_vk_step_confirm_title">Change VK ID?</string>
<string name="settings_vk_step_confirm_body">This does not delete your FromChat account. Your previous VK ID will be freed, and the new one you sign in with will be linked instead.</string>
<string name="settings_vk_step_confirm_cta">Continue</string>
<string name="settings_vk_step_done_title">VK ID updated</string>
<string name="settings_vk_step_done_body">Your account is now linked to the VK ID you just signed in with.</string>
<string name="settings_done">Done</string>
<string name="settings_account_delete_confirm_title">Delete account?</string>
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
@@ -107,15 +107,19 @@ import ru.fromchat.api.schema.user.VerifyPasswordRequest
import ru.fromchat.api.schema.user.auth.AuthPasswordStepRequest
import ru.fromchat.api.schema.user.auth.AuthUsernameStepRequest
import ru.fromchat.api.schema.user.auth.AuthUsernameStepResponse
import ru.fromchat.api.schema.user.auth.AccountVkResponse
import ru.fromchat.api.schema.user.auth.AccountYandexResponse
import ru.fromchat.api.schema.user.auth.ChangeVkRequest
import ru.fromchat.api.schema.user.auth.ChangeVkResponse
import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.RegisterRequest
import ru.fromchat.api.schema.user.auth.VkExchangeRequest
import ru.fromchat.api.schema.user.auth.VkExchangeResponse
import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
@@ -273,7 +277,7 @@ object ApiClient {
}
if (response.status.value == 401) {
val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
val isCredentialCheck = path.endsWith("/auth/steps/password") || path.endsWith("/auth/steps/register/confirm")
if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch {
runCatching { WebSocketManager.disconnect() }
@@ -484,22 +488,6 @@ object ApiClient {
}
suspend fun loginRequest(request: LoginRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun registerRequest(request: RegisterRequest): LoginResponse =
http
.post("${ServerConfig.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
.body()
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
@@ -511,8 +499,9 @@ object ApiClient {
sealed interface AuthPasswordStepOutcome {
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
data class NeedsRegister(
val yandexRequired: Boolean,
val verificationRequired: Boolean,
val yandex: YandexOAuthParams?,
val vk: VkOAuthParams?,
) : AuthPasswordStepOutcome
}
@@ -531,8 +520,9 @@ object ApiClient {
val status = raw["status"]?.jsonPrimitive?.contentOrNull
return when (status) {
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
verificationRequired = raw["verification_required"]?.jsonPrimitive?.booleanOrNull == true,
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
vk = raw["vk"]?.let { json.decodeFromJsonElement(VkOAuthParams.serializer(), it) },
)
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
}
@@ -546,6 +536,26 @@ object ApiClient {
}
.body()
suspend fun authVkExchange(
code: String,
codeVerifier: String,
deviceId: String,
state: String,
): VkExchangeResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/vk/exchange") {
contentType(ContentType.Application.Json)
setBody(
VkExchangeRequest(
code = code,
code_verifier = codeVerifier,
device_id = deviceId,
state = state,
),
)
}
.body()
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
@@ -1560,6 +1570,19 @@ object ApiClient {
}
.body()
suspend fun getAccountVk(): AccountVkResponse =
http
.get("${ServerConfig.apiBaseUrl}/account/vk")
.body()
suspend fun changeAccountVk(registrationProof: String): ChangeVkResponse =
http
.post("${ServerConfig.apiBaseUrl}/account/vk") {
contentType(ContentType.Application.Json)
setBody(ChangeVkRequest(registration_proof = registrationProof))
}
.body()
suspend fun verifyPasswordDerived(passwordDerived: String) {
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
contentType(ContentType.Application.Json)
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -86,14 +87,56 @@ object ChatListSync {
private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
val cached = MessageRepository.loadPublicMessages()
val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
val latestResponse = ApiClient.getMessages(limit = 1)
val latest = latestResponse.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
val holeBelowLatest =
latest.id > 0 &&
maxCachedId > 0 &&
latest.id > maxCachedId + 1
val latestMissingWithPriorCache =
latest.id > 0 &&
latest.id !in cachedIds &&
maxCachedId > 0 &&
latest.id > maxCachedId
if (holeBelowLatest || latestMissingWithPriorCache) {
// Preview-only upsert would leave first+last holes; pull a page and merge.
Logger.i(
"ChatListSync",
"Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
"cachedCount=${cached.size} — fetching page to fill",
)
val page = ApiClient.getMessages(limit = 50)
val networkMessages = page.messages
if (networkMessages.isEmpty()) {
MessageRepository.upsertPublicMessage(latest)
return@runCatching
}
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
val networkIds = networkMessages.map { it.id }.toSet()
val minNetworkId = networkMessages.minOf { it.id }
val maxNetworkId = networkMessages.maxOf { it.id }
val older = cached.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
val ahead = cached.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
val merged = (networkMessages + older + ahead).distinctBy { it.id }
Logger.i(
"ChatListSync",
"Public gap fill: network=${networkMessages.size} older=${older.size} " +
"ahead=${ahead.size} merged=${merged.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(merged, replaceAll = true)
} else {
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
}
}
private fun handleWebSocketMessage(message: WebSocketMessage) {
when (message.type) {
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return
val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return
}.getOrNull() ?: run {
Logger.w("ProfileUpdateSync", "updates batch decode failed")
return
}
val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
if (profileUpdates > 0) {
Logger.d(
"ProfileUpdateSync",
"updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
)
}
updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
}
}
"profileUpdate" -> {
val payload = message.data ?: return
val payload = message.data ?: run {
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
return
}
onProfileUpdatePayload(payload)
}
}
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d(
"ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " +
"bio='${profile.bio?.take(48)}'",
"deleted=${profile.deleted} suspended=${profile.suspended} " +
"bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
)
val hadCached = ProfileCache.get(profile.id)
ProfileCache.applyServerProfile(profile, force = true)
val after = ProfileCache.get(profile.id)
Logger.d(
"ProfileUpdateSync",
"profileUpdate applied id=${profile.id} " +
"wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
"revisionAfter=${ProfileCache.revision.value}",
)
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
}
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
.onFailure {
Logger.w(
"ProfileUpdateSync",
"patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
it,
)
}
.onSuccess {
Logger.d(
"ProfileUpdateSync",
"patchDmConversationPeerProfile done id=${profile.id}",
)
}
}
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
@@ -58,13 +58,24 @@ object UpdateSyncManager {
/**
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
*
* Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
* receive loop (it would deadlock the ack response cannot be read while this call blocks).
*/
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock {
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
Logger.d("UpdateSync", "onUpdatesEnvelope begin")
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
return@withLock
}
Logger.d(
"UpdateSync",
"onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
)
if (seq > _lastSeq.value) {
persistLastSeq(seq)
sendAck(seq)
sendAckFireAndForget(seq)
}
}
}
@@ -90,6 +101,10 @@ object UpdateSyncManager {
/**
* Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* Does not advance the cursor until apply/rebuild succeeds.
*
* Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
* (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
* is not left with first+last holes filled only by slow incremental envelopes.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -108,16 +123,38 @@ object UpdateSyncManager {
try {
var rounds = 0
var consecutiveFailures = 0
while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
val response = requestGetUpdates(token, startSeq) ?: break
val response = requestGetUpdates(token, startSeq)
if (response == null) {
consecutiveFailures++
Logger.w(
"UpdateSyncManager",
"getUpdates returned null (timeout/disconnect) " +
"failures=$consecutiveFailures lastSeq=$startSeq",
)
if (consecutiveFailures >= 2) {
Logger.w(
"UpdateSyncManager",
"Gap catch-up stalled — rebuilding from history",
)
rebuildStateFromHistory()
break
}
continue
}
consecutiveFailures = 0
val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}",
"missed=${response.missedCount}, hasMore=${response.hasMore}, " +
"gapHint=$gapHint clientSeq=$startSeq",
)
updateMissedCount(response.missedCount)
@@ -126,18 +163,43 @@ object UpdateSyncManager {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
} else {
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
}
break
}
"ok" -> {
// Envelopes for this chunk are applied on the receive path before this
// response is delivered; advance cursor here only when there was nothing to apply.
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
}
if (!response.hasMore) {
Logger.i(
"UpdateSyncManager",
"Gap catch-up complete after $rounds round(s) lastSeq=${_lastSeq.value}",
)
break
}
if (_lastSeq.value <= startSeq && response.missedCount > 0) {
// Chunk was announced but cursor did not advance — avoid tight spin.
Logger.w(
"UpdateSyncManager",
"Gap chunk did not advance cursor " +
"(start=$startSeq now=${_lastSeq.value} missed=${response.missedCount})",
)
consecutiveFailures++
if (consecutiveFailures >= 2) {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
}
break
}
}
if (!response.hasMore) break
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
@@ -165,7 +227,7 @@ object UpdateSyncManager {
GetUpdatesRequest(lastSeq = lastSeq),
),
)
val response = WebSocketManager.request(requestMessage)
val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null
return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
@@ -174,11 +236,15 @@ object UpdateSyncManager {
}.getOrNull()
}
private suspend fun sendAck(seq: Int) {
/**
* Send ack without waiting for a response. Must not use [WebSocketManager.request] from
* the receive/apply path that deadlocks the incoming frame loop for ~10s per envelope.
*/
private suspend fun sendAckFireAndForget(seq: Int) {
val token = ApiClient.token ?: return
if (seq <= 0) return
runCatching {
WebSocketManager.request(
WebSocketManager.send(
WebSocketMessage(
type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
@@ -188,6 +254,7 @@ object UpdateSyncManager {
),
),
)
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
}
@@ -236,18 +303,26 @@ object UpdateSyncManager {
val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L
}
MessageRepository.replacePublicMessages(ordered)
MessageCacheStore.clearPublicMessages()
Logger.i(
"UpdateSync",
"rebuildPublicHistory messages=${ordered.size} — replaceAll=true",
)
MessageRepository.replacePublicMessages(ordered, replaceAll = true)
}
private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations()
Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
for (conversation in conversations) {
val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null
var pageCount = 0
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
pageCount++
for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element)
@@ -256,6 +331,7 @@ object UpdateSyncManager {
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
}
}
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState
@@ -167,8 +168,12 @@ object MessageCacheStore {
}
}
suspend fun replacePublicMessages(messages: List<Message>) {
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForPublic()
Logger.d(
"MessageCache",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
)
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged)
}
replaceMessages(convId, merged)
replaceMessages(convId, merged, replaceAll = replaceAll)
}
suspend fun clearPublicMessages() {
Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic())
}
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) {
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(otherUserId))
}
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForDm(otherUserId)
Logger.d(
"MessageCache",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
"replaceAll=$replaceAll convId=$convId",
)
val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated)
}
replaceMessages(convId, hydrated)
replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations()
}
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout()
Logger.d(
"MessageCache",
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
"clientId=${resolved.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved)
}
@@ -279,25 +296,37 @@ object MessageCacheStore {
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
Logger.d(
"MessageCache",
"upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
"userId=${message.user_id} clientId=${message.client_message_id}",
)
ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
}
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
Logger.d(
"MessageCache",
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
)
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
}
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageById(messageId: Int) {
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId)
}
@@ -357,7 +386,7 @@ object MessageCacheStore {
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
resolved = resolved.copy(
fileAspectRatios = listOf(decodedAspect),
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
fileDimensions = listOf(thumbDims.first to thumbDims.second),
)
}
}
@@ -440,6 +469,10 @@ object MessageCacheStore {
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId()
Logger.d(
"MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid,
@@ -611,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId)
.executeAsOneOrNull() ?: return@withContext
val label = resolveDmConversationDisplayLabel(otherUserId, null)
if (label.isEmpty()) return@withContext
if (label == existing.displayName) return@withContext
.executeAsOneOrNull() ?: run {
Logger.d(
"MessageCache",
"patchDmPeerProfile noConversation otherUserId=$otherUserId",
)
return@withContext
}
val profile = ProfileCache.get(otherUserId)
val isDeleted = profile?.deleted == true ||
profile?.username?.startsWith("#deleted") == true
val label = if (isDeleted) {
""
} else {
resolveDmConversationDisplayLabel(otherUserId, null)
}
if (!isDeleted && label.isEmpty()) {
Logger.d(
"MessageCache",
"patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
"deleted=${profile?.deleted}",
)
return@withContext
}
if (label == existing.displayName) {
Logger.d(
"MessageCache",
"patchDmPeerProfile unchanged otherUserId=$otherUserId " +
"deleted=$isDeleted labelEmpty=${label.isEmpty()}",
)
return@withContext
}
Logger.d(
"MessageCache",
"patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
"oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
"newLabelEmpty=${label.isEmpty()}",
)
db.messageDatabaseQueries.upsertConversation(
instanceId = iid,
id = existing.id,
@@ -947,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
}
@@ -954,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId()
Logger.d(
"MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
}
@@ -961,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById(
instanceId = iid,
@@ -968,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(),
)
}
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"deleteMessageById convId=$conversationId messageId=$messageId " +
"rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
)
}
private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid)
val usernameResolved = when {
self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: ""
else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
}
val displayNameResolved = when {
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L,
is_edited = isEdited != 0L,
username = usernameResolved,
displayName = displayNameResolved,
profile_picture = pictureResolved,
verified = profile?.verified,
verificationStatus = profile?.verificationStatus,
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
}
suspend fun clearAll() {
Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache()
}
@@ -1334,24 +1426,49 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
)
return emptyList()
}
return CacheValidator.filterMessages(conversationId, messages, self)
}
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) {
private suspend fun replaceMessages(
conversationId: String,
messages: List<Message>,
replaceAll: Boolean = false,
) {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"replaceMessages incoherent→clear convId=$conversationId " +
"count=${messages.size} replaceAll=$replaceAll",
)
clearConversationMessages(conversationId)
return
}
val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages convId=$conversationId replaceAll=$replaceAll " +
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
)
withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.associate { it.id.toInt() to it.replyToId }
if (replaceAll) {
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
validated.forEach { msg: Message ->
@@ -1367,11 +1484,28 @@ object MessageCacheStore {
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
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 {
syncDmConversationPreviewFromCache(it)
pruneEmptyConversations()
@@ -1,6 +1,7 @@
package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings
@@ -21,7 +22,9 @@ object MessageRepository {
MessageCacheStore.observeMessages(activeInstance(), conversationId)
fun observePublicMessages(): Flow<List<Message>> =
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)).map { rows ->
ProfileCache.enrichPublicMessagesForDisplay(rows)
}
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
observeMessages(conversationIdForDm(otherUserId))
@@ -56,8 +59,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) =
MessageCacheStore.replacePublicMessages(messages)
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
ru.fromchat.Logger.d(
"MessageRepo",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
}
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,20 +75,32 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
suspend fun markMessageDeleted(conversationId: String, messageId: Int) =
suspend fun deletePublicMessageById(messageId: Int) {
ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
MessageCacheStore.deletePublicMessageById(messageId)
}
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"markMessageDeleted convId=$conversationId messageId=$messageId",
)
MessageCacheStore.markMessageDeleted(conversationId, messageId)
}
suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
suspend fun deletePublicMessageById(messageId: Int) =
MessageCacheStore.deletePublicMessageById(messageId)
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) =
MessageCacheStore.replaceDmMessages(otherUserId, messages)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
ru.fromchat.Logger.d(
"MessageRepo",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -91,8 +111,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
)
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
}
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser
@@ -71,19 +72,41 @@ object ProfileCache {
/** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
private fun bumpRevision() {
_revision.value++
private fun bumpRevision(reason: String) {
val next = _revision.value + 1
_revision.value = next
Logger.d("ProfileCache", "revision=$next reason=$reason size=${profiles.size}")
}
private fun profileSummary(profile: UserProfile): String =
"id=${profile.id} user='${profile.username}' deleted=${profile.deleted} " +
"suspended=${profile.suspended} preview=${profile.isClientPreviewOnly} " +
"verified=${profile.verified} vStatus=${profile.verificationStatus}"
fun get(userId: Int): UserProfile? = profiles[userId]
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
val profile = get(userId) ?: return false
if (profile.isClientPreviewOnly) return false
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
val profile = get(userId) ?: run {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
return false
}
if (profile.isClientPreviewOnly) {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=previewOnly")
return false
}
val fetchedAt = fullProfileFetchedAtMs[userId] ?: run {
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=noFetchTs")
return false
}
val ageMs = Clock.System.now().toEpochMilliseconds() - fetchedAt
val fresh = ageMs <= maxAgeMs
Logger.d(
"ProfileCache",
"hasFreshFullProfile id=$userId fresh=$fresh ageMs=$ageMs maxAgeMs=$maxAgeMs",
)
return fresh
}
/** Emits whenever this user's cached profile changes (including bio). */
@@ -110,7 +133,13 @@ object ProfileCache {
) {
if (id <= 0) return
val existing = get(id)
if (existing != null && !existing.isClientPreviewOnly) return
if (existing != null && !existing.isClientPreviewOnly) {
Logger.d(
"ProfileCache",
"mergePreview skipFullExists id=$id deleted=${existing.deleted}",
)
return
}
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -120,11 +149,27 @@ object ProfileCache {
} else {
displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
}
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
return
}
if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreview missingIdentity id=$id " +
"hasUsername=${!incomingUsername.isNullOrEmpty()} " +
"hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
)
}
Logger.d(
"ProfileCache",
"mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
"user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
)
put(
UserProfile(
id = id,
@@ -159,29 +204,49 @@ object ProfileCache {
val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) {
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id)
return
}
}
val cur = profiles
val existing = cur[profile.id]
val deletedChanged = existing?.deleted != profile.deleted
val suspendedChanged = existing?.suspended != profile.suspended
val verificationChanged = existing?.verified != profile.verified ||
existing?.verificationStatus != profile.verificationStatus
if (
existing != null &&
!existing.isClientPreviewOnly &&
existing.bio != profile.bio
) {
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
)
}
if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
Logger.d(
"ProfileCache",
"put ${profileSummary(profile)} hadExisting=${existing != null} " +
"deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
"verificationChanged=$verificationChanged",
)
}
profiles = cur + (profile.id to profile)
bumpRevision()
bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) }
.onFailure {
Logger.w(
"ProfileCache",
"persist put failed id=${profile.id}: ${it.message}",
it,
)
}
}
}
}
@@ -199,27 +264,56 @@ object ProfileCache {
if (!force) {
val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) {
val lifecycleMismatch =
existing.deleted != normalized.deleted ||
existing.suspended != normalized.suspended ||
isDeletedPlaceholderUsername(existing.username) !=
isDeletedPlaceholderUsername(normalized.username)
if (lifecycleMismatch) {
Logger.w(
"ProfileCache",
"applyServerProfile force=false lifecycleMismatch " +
"cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
"cachedSuspended=${existing.suspended} " +
"incomingSuspended=${normalized.suspended} " +
"cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
"— applying full server profile",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
val patched = existing.copy(
verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus
?: existing.verificationStatus,
)
val verificationChanged = patched.verified != existing.verified ||
patched.verificationStatus != existing.verificationStatus
if (patched != existing) put(patched)
if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " +
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
"deleted=${existing.deleted}",
)
} else {
Logger.d(
"ProfileCache",
"applyServerProfile keepCached force=false id=${profile.id} " +
"deleted=${existing.deleted} verificationChanged=$verificationChanged",
)
}
// Refresh TTL so force=false callers stop refetching.
if (!verificationChanged) {
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
return
}
}
ru.fromchat.Logger.d(
Logger.d(
"ProfileCache",
"applyServerProfile applied force=$force id=${profile.id} " +
"applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
@@ -228,9 +322,13 @@ object ProfileCache {
fun remove(userId: Int) {
val cur = profiles
if (userId !in cur) return
if (userId !in cur) {
Logger.d("ProfileCache", "remove miss id=$userId")
return
}
Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
profiles = cur - userId
bumpRevision()
bumpRevision("remove:$userId")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
@@ -251,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return
val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return
if (incomingUsername.isEmpty()) {
Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
return
}
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) {
null
} else {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
user.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
)
}
val existing = get(user.id)
Logger.d(
"ProfileCache",
"mergeFromDmUser id=${user.id} deleted=$isDeleted " +
"incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
"user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
)
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing.displayName
},
profilePicture = if (isDeleted) {
null
} else {
@@ -278,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted,
)
if (patched != existing) put(patched)
if (patched != existing) {
Logger.d(
"ProfileCache",
"mergeFromDmUser patchFull id=${user.id} " +
"deleted ${existing.deleted}${patched.deleted}",
)
put(patched)
}
return
}
@@ -286,8 +411,11 @@ object ProfileCache {
UserProfile(
id = user.id,
username = incomingUsername,
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingDisplayName,
displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
},
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
@@ -326,21 +454,33 @@ object ProfileCache {
val uid = message.user_id
if (uid <= 0) return
val existing = get(uid)
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
val incomingPic = message.profile_picture?.takeIf { it.isNotBlank() }
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
verified = message.verified ?: existing.verified,
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
displayName = existing.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplay,
profilePicture = existing.profilePicture?.takeIf { it.isNotBlank() } ?: incomingPic,
username = existing.username.trim().ifBlank { message.username.trim() },
)
if (patched != existing) put(patched)
return
}
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
val displayName = incomingDisplay ?: existing?.displayName?.takeIf { it.isNotBlank() }
if (uname.isBlank() && displayName.isNullOrBlank()) return
if (uname.isBlank() || displayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!displayName.isNullOrBlank()}",
)
}
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture
val display = if (isDeleted) null else displayName
val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
put(
UserProfile(
@@ -379,6 +519,8 @@ object ProfileCache {
val user = ApiClient.user
return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture,
reply_to = enrichedReply,
@@ -389,6 +531,8 @@ object ProfileCache {
username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty()
},
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture,
verified = message.verified ?: profile?.verified,
@@ -413,7 +557,7 @@ object ProfileCache {
}
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
bumpRevision()
bumpRevision("onActiveInstanceChanged:$instanceId")
}
}
}
@@ -427,6 +571,7 @@ object ProfileCache {
} else {
emptyMap()
}
val before = profiles.size
if (profiles.isEmpty()) {
profiles = diskProfiles
} else {
@@ -442,8 +587,14 @@ object ProfileCache {
}
profiles = merged
}
val deletedCount = profiles.values.count { it.deleted == true }
Logger.d(
"ProfileCache",
"hydrateFromDisk instanceId=$instanceId before=$before " +
"disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
)
pruneUnusableClientPreviewsLocked()
bumpRevision()
bumpRevision("hydrateFromDisk")
}
}
@@ -455,6 +606,7 @@ object ProfileCache {
p.displayName.isNullOrBlank()
}.keys
if (toRemove.isEmpty()) return
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles
for (id in toRemove) {
cur = cur - id
@@ -464,10 +616,11 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
bumpRevision()
bumpRevision("clear")
}
}
}
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) {
val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return
}.getOrNull() ?: run {
ru.fromchat.Logger.w("DmInbox", "processDeleted decode failed")
return
}
val currentUserId = ApiClient.user?.id ?: return
if (data.senderId != currentUserId && data.recipientId != currentUserId) return
if (data.senderId != currentUserId && data.recipientId != currentUserId) {
ru.fromchat.Logger.d(
"DmInbox",
"processDeleted skipNotParticipant messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} self=$currentUserId",
)
return
}
val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId
else -> data.senderId
} ?: return
ru.fromchat.Logger.i(
"DmInbox",
"processDeleted messageId=${data.id} otherUserId=$otherUserId " +
"senderId=${data.senderId} recipientId=${data.recipientId}",
)
withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id)
}
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int,
): Message {
val dec = parseDmMessageContent(plaintext)
val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName ->
ProfileCache.mergePreview(id = envelope.senderId, username = senderName)
if (senderUsername != null || senderDisplayName != null) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername,
displayName = senderDisplayName,
)
}
}
val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) {
"You"
ApiClient.user?.username.orEmpty()
} else {
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
?: envelope.senderUsername?.takeIf { it.isNotBlank() }
senderUsername
?: senderProfile?.username?.trim()?.takeIf { it.isNotEmpty() }
?: ""
}
val displayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
senderDisplayName
?: senderProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
return Message(
id = envelope.id,
user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId,
is_edited = false,
username = username,
displayName = displayName,
profile_picture = null,
verified = null,
reply_to = null,
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
}
"dmDeleted" -> message.data?.let { element ->
scope.launch {
ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged()
}
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
*/
object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
val message = decodeMessage(element) ?: return@withContext
val message = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processNew decode failed")
return@withContext
}
Logger.d(
"PublicInbox",
"processNew id=${message.id} userId=${message.user_id} " +
"clientId=${message.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
}
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
val edited = decodeMessage(element) ?: return@withContext
val edited = decodeMessage(element) ?: run {
Logger.w("PublicInbox", "processEdited decode failed")
return@withContext
}
Logger.d("PublicInbox", "processEdited id=${edited.id} userId=${edited.user_id}")
ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current ->
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited)
} else {
MessageRepository.replacePublicMessages(merged)
MessageRepository.upsertPublicMessage(
merged.first { it.id == edited.id },
)
}
}
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
}.getOrNull() ?: return@withContext
}.getOrNull() ?: run {
Logger.w("PublicInbox", "processDeleted decode failed element=${element.toString().take(120)}")
return@withContext
}
Logger.i(
"PublicInbox",
"processDeleted messageId=${deleted.message_id} — hard-deleting from cache",
)
MessageRepository.deletePublicMessageById(deleted.message_id)
}
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return@withLock null
}.getOrNull() ?: run {
Logger.w("UpdateSync", "applyEnvelope decode failed: ${data.toString().take(160)}")
return@withLock null
}
val types = envelope.updates.map { it.type }
Logger.d(
"UpdateSync",
"applyEnvelope seq=${envelope.seq} count=${envelope.updates.size} types=$types",
)
for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data))
}
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
}
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
"messageDeleted" -> {
Logger.d("UpdateSync", "applyOne messageDeleted")
message.data?.let { PublicInboxCoordinator.processDeleted(it) }
}
"dmNew", "dmDeleted", "dmEdited" -> {
Logger.d("UpdateSync", "applyOne ${message.type}")
DmInboxCoordinator.handleMessage(message)
}
else -> Unit
}
}
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null,
val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReactionUser(
val id: Int,
val username: String
val username: String,
@SerialName("display_name") val displayName: String? = null,
)
@@ -9,6 +9,7 @@ data class DmEnvelope(
val senderId: Int,
val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null,
@SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -27,11 +27,20 @@ data class YandexOAuthParams(
val scope: String,
)
@Serializable
data class VkOAuthParams(
val client_id: String,
val redirect_uri: String,
val authorize_url: String,
val scope: String,
)
@Serializable
data class AuthNeedsRegisterResponse(
val status: String,
val yandex_required: Boolean = false,
val verification_required: Boolean = false,
val yandex: YandexOAuthParams? = null,
val vk: VkOAuthParams? = null,
)
@Serializable
@@ -45,6 +54,19 @@ data class YandexExchangeResponse(
val registration_proof: String,
)
@Serializable
data class VkExchangeRequest(
val code: String,
val code_verifier: String,
val device_id: String,
val state: String,
)
@Serializable
data class VkExchangeResponse(
val registration_proof: String,
)
@Serializable
data class AccountYandexResponse(
val linked: Boolean = false,
@@ -62,6 +84,23 @@ data class ChangeYandexResponse(
val unchanged: Boolean = false,
)
@Serializable
data class AccountVkResponse(
val linked: Boolean = false,
val vk: VkOAuthParams? = null,
)
@Serializable
data class ChangeVkRequest(
val registration_proof: String,
)
@Serializable
data class ChangeVkResponse(
val status: String? = null,
val unchanged: Boolean = false,
)
@Serializable
data class RegisterConfirmRequest(
val username: String,
@@ -70,4 +109,5 @@ data class RegisterConfirmRequest(
val confirm_password: String,
val bio: String? = null,
val registration_proof: String? = null,
val vk_registration_proof: String? = null,
)
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String,
val user_id: Int,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val reactions: List<ReactionData>
)
@@ -0,0 +1,36 @@
package ru.fromchat.auth.vk
/**
* Keep VK ID / OAuth / captcha flows in the WebView; open everything else externally.
*/
internal fun isVkAuthNavigation(url: String): Boolean {
if (url.startsWith("fromchat://", ignoreCase = true)) return true
val withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
val host = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#').lowercase()
return host == "id.vk.ru" ||
host == "id.vk.com" ||
host.endsWith(".id.vk.ru") ||
host.endsWith(".id.vk.com") ||
host == "login.vk.ru" ||
host == "login.vk.com" ||
host == "oauth.vk.com" ||
host == "oauth.vk.ru" ||
host == "m.vk.ru" ||
host == "m.vk.com" ||
host == "vk.ru" ||
host == "vk.com" ||
host == "www.vk.ru" ||
host == "www.vk.com" ||
host == "api.fromchat.ru" ||
(host.contains("captcha") && host.contains("vk"))
}
internal val VK_OAUTH_THEME_COOKIE_HOSTS = listOf(
"https://id.vk.ru",
"https://id.vk.com",
"https://vk.ru",
"https://vk.com",
"https://login.vk.ru",
"https://oauth.vk.com",
)
@@ -0,0 +1,150 @@
package ru.fromchat.auth.vk
import kotlin.random.Random
import ru.fromchat.auth.yandex.PkcePair
import ru.fromchat.auth.yandex.generatePkcePair
import ru.fromchat.auth.yandex.isOfficialApiHost
/**
* Prod VK OAuth client id (identity-only app). When non-empty and the API host is
* [OFFICIAL_API_HOST], the server-supplied client_id must match this value.
*/
internal const val OFFICIAL_VK_OAUTH_CLIENT_ID = ""
internal const val VK_OAUTH_REDIRECT_URI = "https://api.fromchat.ru/oauth/vk"
internal const val VK_OAUTH_DEEP_LINK = "fromchat://oauth/vk"
private const val OAUTH_STATE_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
/**
* Returns the client_id to use, or null if the official host sent a mismatched id.
*/
internal fun resolveVkClientId(serverClientId: String, serverIp: String): String? {
val trimmed = serverClientId.trim()
if (trimmed.isEmpty()) return null
if (!isOfficialApiHost(serverIp)) return trimmed
val pinned = OFFICIAL_VK_OAUTH_CLIENT_ID.trim()
if (pinned.isEmpty()) return trimmed
return if (pinned == trimmed) trimmed else null
}
internal fun generateOAuthState(length: Int = 43): String {
require(length >= 32)
val bytes = ByteArray(length).also { Random.Default.nextBytes(it) }
return buildString(length) {
for (b in bytes) {
append(OAUTH_STATE_ALPHABET[(b.toInt() and 0x7f) % OAUTH_STATE_ALPHABET.length])
}
}
}
internal fun buildVkAuthorizeUrl(
authorizeUrl: String,
clientId: String,
redirectUri: String,
scope: String,
codeChallenge: String,
state: String,
languageTag: String = "en",
darkTheme: Boolean = false,
): String {
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val langId = if (lang == "ru") "0" else "3"
val scheme = if (darkTheme) "dark" else "light"
val base = authorizeUrl.trim().trimEnd('?')
val params = buildList {
add("response_type" to "code")
add("client_id" to clientId)
add("redirect_uri" to redirectUri)
if (scope.isNotBlank()) {
add("scope" to scope.trim())
}
add("code_challenge" to codeChallenge)
add("code_challenge_method" to "S256")
add("state" to state)
add("lang_id" to langId)
add("scheme" to scheme)
}.joinToString("&") { (k, v) ->
"${encodeUrl(k)}=${encodeUrl(v)}"
}
return "$base?$params"
}
private fun encodeUrl(value: String): String = buildString(value.length) {
for (ch in value) {
when {
ch.isLetterOrDigit() || ch in "-_.~" -> append(ch)
else -> {
val bytes = ch.toString().encodeToByteArray()
for (b in bytes) {
append('%')
append(((b.toInt() shr 4) and 0xf).toString(16).uppercase())
append((b.toInt() and 0xf).toString(16).uppercase())
}
}
}
}
}
data class VkOAuthRedirect(
val code: String,
val deviceId: String,
val state: String,
)
internal fun extractVkOAuthRedirect(redirectUrl: String, redirectUri: String = VK_OAUTH_REDIRECT_URI): VkOAuthRedirect? {
val uri = redirectUrl.trim()
val expectedHttps = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
val matchesPrefix = uri.startsWith(expectedHttps, ignoreCase = true) ||
uri.startsWith(VK_OAUTH_DEEP_LINK, ignoreCase = true) ||
uri.contains("/oauth/vk?", ignoreCase = true) ||
uri.substringBefore('?', missingDelimiterValue = uri).endsWith("/oauth/vk", ignoreCase = true)
if (!matchesPrefix) return null
val query = uri.substringAfter('?', missingDelimiterValue = "")
if (query.isEmpty()) return null
var code: String? = null
var deviceId: String? = null
var state: String? = null
for (part in query.split('&')) {
val key = part.substringBefore('=')
val raw = part.substringAfter('=', missingDelimiterValue = "")
if (raw.isEmpty()) continue
val decoded = decodeUrl(raw)
when (key) {
"code" -> code = decoded
"device_id" -> deviceId = decoded
"state" -> state = decoded
}
}
val c = code ?: return null
val d = deviceId ?: return null
val s = state ?: return null
return VkOAuthRedirect(code = c, deviceId = d, state = s)
}
private fun decodeUrl(value: String): String {
val bytes = ArrayList<Byte>()
var i = 0
while (i < value.length) {
val c = value[i]
when {
c == '+' -> {
bytes.add(' '.code.toByte())
i++
}
c == '%' && i + 2 < value.length -> {
val hex = value.substring(i + 1, i + 3)
bytes.add(hex.toInt(16).toByte())
i += 3
}
else -> {
bytes.add(c.code.toByte())
i++
}
}
}
return bytes.toByteArray().decodeToString()
}
// Re-export PKCE helpers used by VK flows (same module as Yandex).
internal fun generateVkPkcePair(): PkcePair = generatePkcePair()
@@ -0,0 +1,44 @@
package ru.fromchat.auth.yandex
/**
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
*/
internal fun isYandexAuthNavigation(url: String): Boolean {
if (url.startsWith("fromchat://", ignoreCase = true)) return true
val withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
val hostAndPath = withoutScheme.substringBefore('#').substringBefore('?')
val host = hostAndPath.substringBefore('/').lowercase()
val path = hostAndPath.substringAfter('/', missingDelimiterValue = "").lowercase().let { "/$it" }
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
return path.contains("captcha") ||
path.startsWith("/auth") ||
path.startsWith("/showcaptcha") ||
path.startsWith("/checkcaptcha")
}
return host == "oauth.yandex.com" ||
host == "oauth.yandex.ru" ||
host.endsWith(".oauth.yandex.com") ||
host.endsWith(".oauth.yandex.ru") ||
host == "passport.yandex.ru" ||
host == "passport.yandex.com" ||
host.endsWith(".passport.yandex.ru") ||
host.endsWith(".passport.yandex.com") ||
host.startsWith("auth.yandex.") ||
host.startsWith("login.yandex.") ||
host.startsWith("id.yandex.") ||
host == "sso.passport.yandex.ru" ||
host == "captcha.yandex.net" ||
host.endsWith(".captcha.yandex.net") ||
(host.contains("captcha") && host.contains("yandex"))
}
internal val YANDEX_OAUTH_THEME_COOKIE_HOSTS = listOf(
"https://yandex.ru",
"https://yandex.com",
"https://passport.yandex.ru",
"https://passport.yandex.com",
"https://oauth.yandex.ru",
"https://oauth.yandex.com",
)
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType
import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.auth.vk.VkOAuthNav
import ru.fromchat.ui.auth.vk.VkOAuthScreen
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay
@@ -103,6 +105,9 @@ import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkConfirmScreen
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkDoneScreen
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkOAuthScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
@@ -466,6 +471,10 @@ fun App(
YandexOAuthScreen()
}
composable(VkOAuthNav.ROUTE) {
VkOAuthScreen()
}
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
@@ -699,6 +708,26 @@ fun App(
)
}
settingsComposable(SettingsRoutes.AccountVkFlow) {
ChangeVkConfirmScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountVkOAuth) {
ChangeVkOAuthScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountVkDone) {
ChangeVkDoneScreen(
onDone = {
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
},
)
}
settingsComposable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
@@ -709,6 +738,7 @@ fun App(
},
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
onChangeVkId = { navController.navigate(SettingsRoutes.AccountVkFlow) },
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
)
}
@@ -1,9 +1,10 @@
package ru.fromchat.ui.auth
import ru.fromchat.api.schema.user.auth.VkOAuthParams
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 an OAuth route.
* Cleared on welcome / successful auth / explicit reset to username.
*/
internal object AuthRegisterDraft {
@@ -12,9 +13,11 @@ internal object AuthRegisterDraft {
var confirmPassword: String = ""
var displayName: String = ""
var bio: String = ""
var yandexRequired: Boolean = false
var verificationRequired: Boolean = false
var yandexParams: YandexOAuthParams? = null
var registrationProof: String? = null
var vkParams: VkOAuthParams? = null
var yandexRegistrationProof: String? = null
var vkRegistrationProof: String? = null
var page: Int = 0
fun clear() {
@@ -23,9 +26,11 @@ internal object AuthRegisterDraft {
confirmPassword = ""
displayName = ""
bio = ""
yandexRequired = false
verificationRequired = false
yandexParams = null
registrationProof = null
vkParams = null
yandexRegistrationProof = null
vkRegistrationProof = null
page = 0
}
}
@@ -30,13 +30,13 @@ import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.change_server
import ru.fromchat.config.Settings
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
import ru.fromchat.ui.auth.register.profileStepPage
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.TextCta
@@ -49,15 +49,16 @@ private enum class AuthFlowStep {
Username,
Password,
ConfirmPassword,
YandexId,
IdentityVerify,
Profile,
}
internal sealed interface PasswordStepResult {
data object LoginSuccess : PasswordStepResult
data class NeedsRegister(
val yandexRequired: Boolean,
val verificationRequired: Boolean,
val yandex: YandexOAuthParams?,
val vk: VkOAuthParams?,
) : PasswordStepResult
data class WrongPassword(val message: String) : PasswordStepResult
data class RateLimited(val message: String) : PasswordStepResult
@@ -121,8 +122,9 @@ internal suspend fun authPasswordStep(
}
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
yandexRequired = outcome.yandexRequired,
verificationRequired = outcome.verificationRequired,
yandex = outcome.yandex,
vk = outcome.vk,
)
}
} catch (e: ClientRequestException) {
@@ -146,7 +148,8 @@ internal suspend fun register(
displayName: String,
password: String,
bio: String,
registrationProof: String?,
yandexRegistrationProof: String?,
vkRegistrationProof: String?,
unexpectedError: String,
) = try {
fullLogin(username.trim(), password.trim()) {
@@ -158,7 +161,8 @@ internal suspend fun register(
password = derived,
confirm_password = derived,
bio = bio.trim().takeIf { it.isNotEmpty() },
registration_proof = registrationProof,
registration_proof = yandexRegistrationProof,
vk_registration_proof = vkRegistrationProof,
),
)
}
@@ -214,9 +218,11 @@ fun AuthScreen(
var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
var verificationRequired by remember { mutableStateOf(AuthRegisterDraft.verificationRequired) }
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
var vkParams by remember { mutableStateOf(AuthRegisterDraft.vkParams) }
var yandexRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.yandexRegistrationProof) }
var vkRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.vkRegistrationProof) }
fun persistDraft() {
AuthRegisterDraft.username = username
@@ -224,9 +230,11 @@ fun AuthScreen(
AuthRegisterDraft.confirmPassword = confirmPassword
AuthRegisterDraft.displayName = displayName
AuthRegisterDraft.bio = bio
AuthRegisterDraft.yandexRequired = yandexRequired
AuthRegisterDraft.verificationRequired = verificationRequired
AuthRegisterDraft.yandexParams = yandexParams
AuthRegisterDraft.registrationProof = registrationProof
AuthRegisterDraft.vkParams = vkParams
AuthRegisterDraft.yandexRegistrationProof = yandexRegistrationProof
AuthRegisterDraft.vkRegistrationProof = vkRegistrationProof
AuthRegisterDraft.page = flowState.pagerState.currentPage
}
@@ -255,9 +263,11 @@ fun AuthScreen(
confirmPassword = ""
displayName = ""
bio = ""
yandexRequired = false
verificationRequired = false
yandexParams = null
registrationProof = null
vkParams = null
yandexRegistrationProof = null
vkRegistrationProof = null
AuthRegisterDraft.clear()
flowState.resetPredictiveState()
scope.launch {
@@ -265,11 +275,27 @@ fun AuthScreen(
}
}
fun clearProofs() {
yandexRegistrationProof = null
vkRegistrationProof = null
}
DisposableEffect(Unit) {
onDispose { persistDraft() }
}
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
LaunchedEffect(
username,
password,
confirmPassword,
displayName,
bio,
verificationRequired,
yandexParams,
vkParams,
yandexRegistrationProof,
vkRegistrationProof,
) {
persistDraft()
}
@@ -284,7 +310,7 @@ fun AuthScreen(
snapshotFlow { flowState.pagerState.currentPage }
.collect { page ->
AuthRegisterDraft.page = page
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
if (page == AuthFlowStep.IdentityVerify.ordinal && !verificationRequired) {
val target = if (page > settledPage) {
AuthFlowStep.Profile.ordinal
} else {
@@ -296,37 +322,27 @@ fun AuthScreen(
}
if (page < settledPage) {
when (page) {
AuthFlowStep.Username.ordinal -> {
AuthFlowStep.Username.ordinal,
AuthFlowStep.Password.ordinal,
-> {
password = ""
confirmPassword = ""
yandexRequired = false
verificationRequired = false
yandexParams = null
registrationProof = null
vkParams = null
clearProofs()
}
AuthFlowStep.Password.ordinal -> {
password = ""
confirmPassword = ""
yandexRequired = false
yandexParams = null
registrationProof = null
}
AuthFlowStep.ConfirmPassword.ordinal -> {
// Keep confirm password when returning from Yandex ID / OAuth.
registrationProof = null
}
AuthFlowStep.YandexId.ordinal -> {
registrationProof = null
}
AuthFlowStep.ConfirmPassword.ordinal,
AuthFlowStep.IdentityVerify.ordinal,
-> clearProofs()
}
}
settledPage = page
}
}
val yandexStep = yandexParams
val showVerify = verificationRequired && (yandexParams != null || vkParams != null)
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
@@ -343,10 +359,11 @@ fun AuthScreen(
password = password,
onPasswordChange = { password = it },
onLoginSuccess = wrappedAuthSuccess,
onNeedsRegister = { required, params ->
yandexRequired = required
yandexParams = params
registrationProof = null
onNeedsRegister = { required, yandex, vk ->
verificationRequired = required
yandexParams = yandex
vkParams = vk
clearProofs()
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
},
onSnackbar = ::snackbar,
@@ -356,19 +373,29 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
if (yandexRequired && yandexParams != null) {
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
if (showVerify) {
flowState.pagerState.animateScrollToPage(AuthFlowStep.IdentityVerify.ordinal)
} else {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
},
onSnackbar = ::snackbar,
),
if (yandexStep != null) {
yandexIdStepPage(
yandex = yandexStep,
onProof = { proof ->
registrationProof = proof
if (showVerify) {
identityVerifyStepPage(
yandex = yandexParams,
vk = vkParams,
onProof = { provider, proof ->
when (provider) {
IdentityProvider.Yandex -> {
yandexRegistrationProof = proof
vkRegistrationProof = null
}
IdentityProvider.Vk -> {
vkRegistrationProof = proof
yandexRegistrationProof = null
}
}
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
},
onSnackbar = ::snackbar,
@@ -391,7 +418,8 @@ fun AuthScreen(
bio = bio,
onBioChange = { bio = it },
password = password,
registrationProof = registrationProof,
yandexRegistrationProof = yandexRegistrationProof,
vkRegistrationProof = vkRegistrationProof,
onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar,
@@ -0,0 +1,217 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.intl.Locale
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
import ru.fromchat.auth.vk.buildVkAuthorizeUrl
import ru.fromchat.auth.vk.generateOAuthState
import ru.fromchat.auth.vk.generateVkPkcePair
import ru.fromchat.auth.vk.resolveVkClientId
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
import ru.fromchat.auth.yandex.generatePkcePair
import ru.fromchat.auth.yandex.resolveYandexClientId
import ru.fromchat.auth_step_verify_body
import ru.fromchat.auth_step_verify_title
import ru.fromchat.auth_step_vk_cta
import ru.fromchat.auth_step_yandex_cta
import ru.fromchat.auth_vk_client_mismatch
import ru.fromchat.auth_yandex_client_mismatch
import ru.fromchat.config.Settings
import ru.fromchat.error_unexpected
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.vk.VkOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepPage
import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.isAppInDarkTheme
enum class IdentityProvider {
Yandex,
Vk,
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun identityVerifyStepPage(
yandex: YandexOAuthParams?,
vk: VkOAuthParams?,
onProof: suspend (provider: IdentityProvider, proof: String) -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage {
val navController = LocalNavController.current
val colorScheme = MaterialTheme.colorScheme
var busy by remember { mutableStateOf(false) }
val languageTag = Locale.current.toLanguageTag()
val darkTheme = isAppInDarkTheme()
val onProofState = rememberUpdatedState(onProof)
val onSnackbarState = rememberUpdatedState(onSnackbar)
val title = stringResource(Res.string.auth_step_verify_title)
val body = stringResource(Res.string.auth_step_verify_body)
val yandexCta = stringResource(Res.string.auth_step_yandex_cta)
val vkCta = stringResource(Res.string.auth_step_vk_cta)
val yandexMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
val vkMismatch = stringResource(Res.string.auth_vk_client_mismatch)
val unexpected = stringResource(Res.string.error_unexpected)
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
if (proof == null) return@collect
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
busy = true
try {
onProofState.value(IdentityProvider.Yandex, proof)
} finally {
busy = false
}
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
if (message == null) return@collect
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
onSnackbarState.value(message, null)
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(VkOAuthNav.RESULT_PROOF, null).collect { proof ->
if (proof == null) return@collect
handle.remove<String>(VkOAuthNav.RESULT_PROOF)
busy = true
try {
onProofState.value(IdentityProvider.Vk, proof)
} finally {
busy = false
}
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(VkOAuthNav.RESULT_ERROR, null).collect { message ->
if (message == null) return@collect
handle.remove<String>(VkOAuthNav.RESULT_ERROR)
onSnackbarState.value(message, null)
}
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.VerifiedUser,
polygon = MaterialShapes.Cookie9Sided.normalized(),
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
),
content = {
ExpressiveStepPageHeader(title = title, body = body)
},
button = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (yandex != null) {
ActionButton(
onClick = {
if (busy) return@ActionButton
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
onSnackbar(unexpected, it)
return@ActionButton
}
val clientId = resolveYandexClientId(yandex.client_id, serverIp)
if (clientId == null) {
onSnackbar(yandexMismatch, null)
return@ActionButton
}
val pkce = generatePkcePair()
YandexOAuthNav.pending = YandexOAuthNav.Session(
authorizeUrl = buildYandexAuthorizeUrl(
authorizeUrl = yandex.authorize_url,
clientId = clientId,
redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
scope = yandex.scope,
codeChallenge = pkce.codeChallenge,
languageTag = languageTag,
darkTheme = darkTheme,
),
codeVerifier = pkce.codeVerifier,
)
navController.navigate(YandexOAuthNav.ROUTE)
},
enabled = !busy,
loading = busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(yandexCta)
}
}
if (vk != null) {
ActionButton(
onClick = {
if (busy) return@ActionButton
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
onSnackbar(unexpected, it)
return@ActionButton
}
val clientId = resolveVkClientId(vk.client_id, serverIp)
if (clientId == null) {
onSnackbar(vkMismatch, null)
return@ActionButton
}
val pkce = generateVkPkcePair()
val state = generateOAuthState()
val redirectUri = vk.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
VkOAuthNav.pending = VkOAuthNav.Session(
authorizeUrl = buildVkAuthorizeUrl(
authorizeUrl = vk.authorize_url,
clientId = clientId,
redirectUri = redirectUri,
scope = vk.scope,
codeChallenge = pkce.codeChallenge,
state = state,
languageTag = languageTag,
darkTheme = darkTheme,
),
codeVerifier = pkce.codeVerifier,
state = state,
redirectUri = redirectUri,
)
navController.navigate(VkOAuthNav.ROUTE)
},
enabled = !busy,
loading = busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(vkCta)
}
}
}
},
)
}
@@ -35,6 +35,7 @@ import ru.fromchat.login
import ru.fromchat.password
import ru.fromchat.password_length_error
import ru.fromchat.show_password
import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
@@ -54,7 +55,11 @@ internal fun passwordStepPage(
password: String,
onPasswordChange: (String) -> Unit,
onLoginSuccess: () -> Unit,
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
onNeedsRegister: suspend (
verificationRequired: Boolean,
yandex: YandexOAuthParams?,
vk: VkOAuthParams?,
) -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage {
val scope = rememberCoroutineScope()
@@ -135,7 +140,11 @@ internal fun passwordStepPage(
}
is PasswordStepResult.NeedsRegister -> {
onNeedsRegister(result.yandexRequired, result.yandex)
onNeedsRegister(
result.verificationRequired,
result.yandex,
result.vk,
)
}
is PasswordStepResult.WrongPassword -> {
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username
import ru.fromchat.username_chars_error
import ru.fromchat.username_length_error
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
private fun isAllowedUsernameChar(ch: Char) =
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun usernameStepPage(
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
val fillAll = stringResource(Res.string.fill_all_fields)
val usernameLenError = stringResource(Res.string.username_length_error)
val usernameCharsError = stringResource(Res.string.username_chars_error)
val serverFail = stringResource(Res.string.auth_server_connect_failed)
val unexpected = stringResource(Res.string.error_unexpected)
val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
button = {
ActionButton(
onClick = {
if (busy) return@ActionButton
if (busy || hasProhibitedChars) return@ActionButton
val trimmed = username.trim()
if (trimmed.isBlank()) {
onSnackbar(fillAll, null)
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
}
}
},
enabled = !busy,
enabled = !busy && !hasProhibitedChars,
loading = busy,
modifier = Modifier.fillMaxWidth(),
) {
@@ -0,0 +1,28 @@
package ru.fromchat.ui.auth.oauth
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Platform WebView for identity OAuth (Yandex, VK ID, ).
* Intercepts [redirectUriPrefix] and reports the full redirect URL via [onRedirectUrl].
*
* @param isAuthNavigation Keep matching hosts in-WebView; open everything else externally.
* @param themeCookieHosts Optional hosts that receive light/dark theme cookies before load.
*/
@Composable
expect fun OAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
redirectUriPrefix: String,
isAuthNavigation: (url: String) -> Boolean,
clearCookies: Boolean = false,
themeCookieHosts: List<String> = emptyList(),
onPageBackgroundColor: (Color) -> Unit = {},
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
onRedirectUrl: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
)
@@ -55,7 +55,8 @@ internal fun profileStepPage(
bio: String,
onBioChange: (String) -> Unit,
password: String,
registrationProof: String?,
yandexRegistrationProof: String?,
vkRegistrationProof: String?,
onRegisterSuccess: () -> Unit,
onUsernameTaken: () -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
@@ -148,7 +149,8 @@ internal fun profileStepPage(
displayName = displayName.trim(),
password = password,
bio = bio.trim(),
registrationProof = registrationProof,
yandexRegistrationProof = yandexRegistrationProof,
vkRegistrationProof = vkRegistrationProof,
unexpectedError = unexpected,
)
) {
@@ -0,0 +1,41 @@
package ru.fromchat.ui.auth.vk
import androidx.compose.runtime.saveable.listSaver
import kotlin.concurrent.Volatile
/**
* Root [androidx.navigation.NavController] route for the VK OAuth WebView.
* Session is staged in [pending] before [navigate]; PKCE verifier and state must not go in the route.
* Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
*/
internal object VkOAuthNav {
const val ROUTE = "vkOAuth"
const val RESULT_PROOF = "vk_registration_proof"
const val RESULT_ERROR = "vk_oauth_error"
data class Session(
val authorizeUrl: String,
val codeVerifier: String,
val state: String,
val redirectUri: String,
)
val SessionSaver = listSaver<Session?, String>(
save = { session ->
if (session == null) emptyList()
else listOf(session.authorizeUrl, session.codeVerifier, session.state, session.redirectUri)
},
restore = { saved ->
if (saved.size < 4) null
else Session(
authorizeUrl = saved[0],
codeVerifier = saved[1],
state = saved[2],
redirectUri = saved[3],
)
},
)
@Volatile
var pending: Session? = null
}
@@ -0,0 +1,180 @@
package ru.fromchat.ui.auth.vk
import androidx.compose.animation.AnimatedVisibility
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
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.rememberCoroutineScope
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 androidx.compose.ui.unit.dp
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.auth_vk_failed
import ru.fromchat.back
import ru.fromchat.ui.auth.vk.VkOAuthWebView
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.isAppInDarkTheme
private const val LOG_TAG = "VkOAuthScreen"
@Composable
internal fun VkOAuthScreen() {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
var session by rememberSaveable(stateSaver = VkOAuthNav.SessionSaver) {
mutableStateOf(VkOAuthNav.pending)
}
val fallbackColor = MaterialTheme.colorScheme.background
var chromeColor by remember { mutableStateOf(fallbackColor) }
var busy by remember { mutableStateOf(false) }
var webViewCanGoBack by remember { mutableStateOf(false) }
val failedMessage = stringResource(Res.string.auth_vk_failed)
val backLabel = stringResource(Res.string.back)
val darkTheme = isAppInDarkTheme()
val screenId = remember { (100000..999999).random().toString(16) }
DisposableEffect(screenId) {
Logger.i(
LOG_TAG,
"compose enter id=$screenId sessionNull=${session == null} " +
"pendingNull=${VkOAuthNav.pending == null} darkTheme=$darkTheme " +
"route=${navController.currentBackStackEntry?.destination?.route}",
)
onDispose {
Logger.i(LOG_TAG, "compose dispose id=$screenId")
}
}
LaunchedEffect(session) {
if (session == null) {
Logger.w(LOG_TAG, "session null → popBackStack id=$screenId")
navController.popBackStack()
} else {
VkOAuthNav.pending = session
Logger.d(LOG_TAG, "session kept id=$screenId urlLen=${session!!.authorizeUrl.length}")
}
}
val active = session ?: return
fun finishWithProof(proof: String) {
Logger.i(LOG_TAG, "finishWithProof id=$screenId")
VkOAuthNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(VkOAuthNav.RESULT_PROOF, proof)
navController.popBackStack()
}
fun finishWithError(message: String) {
Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
VkOAuthNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(VkOAuthNav.RESULT_ERROR, message)
navController.popBackStack()
}
fun cancel() {
if (busy) return
Logger.i(LOG_TAG, "cancel id=$screenId")
VkOAuthNav.pending = null
navController.popBackStack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(chromeColor),
) {
VkOAuthWebView(
authorizeUrl = active.authorizeUrl,
redirectUri = active.redirectUri,
languageTag = Locale.current.toLanguageTag(),
darkTheme = darkTheme,
fallbackColor = fallbackColor,
onPageBackgroundColor = { chromeColor = it },
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
onRedirect = { redirect ->
if (busy) return@VkOAuthWebView
if (redirect.state != active.state) {
finishWithError(failedMessage)
return@VkOAuthWebView
}
scope.launch {
busy = true
try {
val proof = ApiClient.authVkExchange(
code = redirect.code,
codeVerifier = active.codeVerifier,
deviceId = redirect.deviceId,
state = redirect.state,
).registration_proof
finishWithProof(proof)
} catch (e: ClientRequestException) {
val detail = if (e.response.status.value == 400) {
runCatching { e.response.body<ErrorResponse>().detail }
.getOrNull()
?.ifBlank { null }
} else {
null
}
finishWithError(detail ?: failedMessage)
} catch (_: Exception) {
finishWithError(failedMessage)
} finally {
busy = false
}
}
},
onError = { finishWithError(it.ifBlank { failedMessage }) },
onCancel = { cancel() },
)
AnimatedVisibility(
visible = !webViewCanGoBack && !busy,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.safeDrawingPadding()
.padding(start = 12.dp, top = 12.dp),
) {
FilledIconButton(
onClick = { cancel() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
}
}
}
}
@@ -0,0 +1,50 @@
package ru.fromchat.ui.auth.vk
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
import ru.fromchat.auth.vk.VK_OAUTH_THEME_COOKIE_HOSTS
import ru.fromchat.auth.vk.VkOAuthRedirect
import ru.fromchat.auth.vk.extractVkOAuthRedirect
import ru.fromchat.auth.vk.isVkAuthNavigation
import ru.fromchat.ui.auth.oauth.OAuthWebView
/**
* VK-specific wrapper around [OAuthWebView].
*
* @param redirectUri Trusted HTTPS redirect from the server (must match VK ID cabinet).
*/
@Composable
internal fun VkOAuthWebView(
authorizeUrl: String,
redirectUri: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
clearCookies: Boolean = false,
onPageBackgroundColor: (Color) -> Unit = {},
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
onRedirect: (VkOAuthRedirect) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
val resolvedRedirect = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
OAuthWebView(
authorizeUrl = authorizeUrl,
languageTag = languageTag,
darkTheme = darkTheme,
fallbackColor = fallbackColor,
redirectUriPrefix = resolvedRedirect,
isAuthNavigation = ::isVkAuthNavigation,
clearCookies = clearCookies,
themeCookieHosts = VK_OAUTH_THEME_COOKIE_HOSTS,
onPageBackgroundColor = onPageBackgroundColor,
onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
onRedirectUrl = { url ->
val redirect = extractVkOAuthRedirect(url, resolvedRedirect)
if (redirect != null) onRedirect(redirect) else onError("")
},
onError = onError,
onCancel = onCancel,
)
}
@@ -1,130 +0,0 @@
package ru.fromchat.ui.auth.yandex
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.intl.Locale
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
import ru.fromchat.auth.yandex.generatePkcePair
import ru.fromchat.auth.yandex.resolveYandexClientId
import ru.fromchat.auth_step_yandex_body
import ru.fromchat.auth_step_yandex_cta
import ru.fromchat.auth_step_yandex_title
import ru.fromchat.auth_yandex_client_mismatch
import ru.fromchat.config.Settings
import ru.fromchat.error_unexpected
import ru.fromchat.ic_yandex
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepPage
import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.isAppInDarkTheme
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun yandexIdStepPage(
yandex: YandexOAuthParams,
onProof: suspend (String) -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage {
val navController = LocalNavController.current
val colorScheme = MaterialTheme.colorScheme
var busy by remember { mutableStateOf(false) }
val languageTag = Locale.current.toLanguageTag()
val darkTheme = isAppInDarkTheme()
val onProofState = rememberUpdatedState(onProof)
val onSnackbarState = rememberUpdatedState(onSnackbar)
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
val title = stringResource(Res.string.auth_step_yandex_title)
val body = stringResource(Res.string.auth_step_yandex_body)
val cta = stringResource(Res.string.auth_step_yandex_cta)
val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
val unexpected = stringResource(Res.string.error_unexpected)
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
if (proof == null) return@collect
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
busy = true
try {
onProofState.value(proof)
} finally {
busy = false
}
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
if (message == null) return@collect
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
onSnackbarState.value(message, null)
}
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = yandexIcon,
polygon = MaterialShapes.Cookie9Sided.normalized(),
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
),
content = {
ExpressiveStepPageHeader(title = title, body = body)
},
button = {
ActionButton(
onClick = {
if (busy) return@ActionButton
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
onSnackbar(unexpected, it)
return@ActionButton
}
val clientId = resolveYandexClientId(yandex.client_id, serverIp)
if (clientId == null) {
onSnackbar(clientMismatch, null)
return@ActionButton
}
val pkce = generatePkcePair()
YandexOAuthNav.pending = YandexOAuthNav.Session(
authorizeUrl = buildYandexAuthorizeUrl(
authorizeUrl = yandex.authorize_url,
clientId = clientId,
redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
scope = yandex.scope,
codeChallenge = pkce.codeChallenge,
languageTag = languageTag,
darkTheme = darkTheme,
),
codeVerifier = pkce.codeVerifier,
)
navController.navigate(YandexOAuthNav.ROUTE)
},
enabled = !busy,
loading = busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(cta)
}
},
)
}
@@ -2,19 +2,17 @@ package ru.fromchat.ui.auth.yandex
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
import ru.fromchat.auth.yandex.YANDEX_OAUTH_THEME_COOKIE_HOSTS
import ru.fromchat.auth.yandex.extractOAuthCode
import ru.fromchat.auth.yandex.isYandexAuthNavigation
import ru.fromchat.ui.auth.oauth.OAuthWebView
/**
* Platform WebView that loads [authorizeUrl] and reports the OAuth redirect.
* Intercepts `fromchat://oauth/yandex` and returns the authorization code.
*
* @param languageTag BCP-47 tag for Accept-Language / WebView locale.
* @param darkTheme Pins WebView `prefers-color-scheme` via configuration night mode.
* @param fallbackColor Shown until the page background can be sampled.
* @param onPageBackgroundColor Reported whenever a non-transparent page background is detected.
* @param onHistoryBackAvailabilityChanged `true` when the WebView can go back in its own history.
* Yandex-specific wrapper around [OAuthWebView].
*/
@Composable
expect fun YandexOAuthWebView(
internal fun YandexOAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
@@ -25,4 +23,23 @@ expect fun YandexOAuthWebView(
onCode: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
)
) {
OAuthWebView(
authorizeUrl = authorizeUrl,
languageTag = languageTag,
darkTheme = darkTheme,
fallbackColor = fallbackColor,
redirectUriPrefix = YANDEX_OAUTH_REDIRECT_URI,
isAuthNavigation = ::isYandexAuthNavigation,
clearCookies = clearCookies,
themeCookieHosts = YANDEX_OAUTH_THEME_COOKIE_HOSTS,
onPageBackgroundColor = onPageBackgroundColor,
onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
onRedirectUrl = { url ->
val code = extractOAuthCode(url)
if (code != null) onCode(code) else onError("")
},
onError = onError,
onCancel = onCancel,
)
}
@@ -94,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock {
batchStateUpdates {
updateState { current ->
val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(),
panelSnap,
messages,
)
val withReplies = attachPublicReplyReferences(merged)
if (current.messages.size != withReplies.size ||
current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
) {
val panelIds = panelSnap.map { it.id }.toSet()
val mergedIds = withReplies.map { it.id }.toSet()
val dbIds = messages.map { it.id }.toSet()
Logger.d(
"ChatPanel",
"syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
"merged=${withReplies.size} " +
"panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
"dbOnlyIds=${(dbIds - mergedIds).take(8)}",
)
}
if (current.messages == withReplies) current
else current.copy(messages = withReplies)
}
@@ -692,7 +707,8 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
username = "You",
username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
client_message_id = tempId,
reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
@@ -949,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
username = "You",
username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
profile_picture = null,
verified = null,
reply_to = replyTo,
@@ -1188,7 +1189,7 @@ fun ChatScreen(
.getOrNull(panelState.messages.lastIndex - 1)
previous != null &&
messageListKey(previous) == listKey &&
classifyEnterMode(previous, newest!!) ==
classifyEnterMode(previous, newest) ==
EnterMode.ExtendGroup
}
val showTimestamp = when {
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted(
userId = userId,
currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title,
username = ProfileCache.get(userId)?.username,
)
} == true
Row(
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis,
)
profileUserId?.let { userId ->
val status = resolveVerificationStatus(userId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val status = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
if (status != null) {
Spacer(modifier = Modifier.width(4.dp))
StatusBadge(
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -710,25 +711,26 @@ fun ImageFullscreenPreview(
}
// Top bar: back, display name + date/time, 3-dot menu
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(),
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(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(onClick = { dismissRequested = true }) {
Icon(
@@ -820,33 +822,32 @@ fun ImageFullscreenPreview(
}
// Bottom: message text
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
) {
if (message.content.isNotBlank()) {
Box(
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.navigationBarsPadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color.White
color = Color.White,
)
}
}
}
}
}
}
@@ -15,7 +15,8 @@ import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted
/**
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
* Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
* cached/server display name, or login username only as a last resort.
*/
@Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi()
}
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi()
}
return message.username
return message.username.trim()
}
fun messageSenderProfilePicture(
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim()
return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -229,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
resolveVerificationStatus(message.user_id, message)
}
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
}
}
}
downloadPaused && canDownload -> file?.let { downloadFile ->
downloadPaused && file != null && canDownload -> {
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache(
messageId = messageId,
fileIndex = fileIndex,
file = downloadFile,
file = file,
dmEnvelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
}
}
}
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
file != null && canDownload && !isDownloading && !downloadPaused -> {
{
AttachmentDownloadNotifier.beginDownload(
messageId = messageId,
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache(
messageId = messageId,
fileIndex = fileIndex,
file = downloadFile,
file = file,
dmEnvelope = dmEnvelope,
currentUserId = currentUserId,
clientMessageId = clientMessageId,
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
Logger.d(
"DmPanel",
"applyCachedPeerProfile otherUserId=$otherUserId " +
"deleted=${cached?.deleted} suspended=${cached?.suspended} " +
"revision=${ProfileCache.revision.value} " +
"titleBlank=${displayName.isBlank()}",
)
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) {
val hadCachedMessages = cached.isNotEmpty()
if (hadCachedMessages) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
messagesLoaded = true
return
} else {
setLoading(true)
}
setLoading(true)
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true
} else {
val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages()
setHasMoreMessages(false)
messagesLoaded = true
} else if (hadCachedMessages) {
messagesLoaded = true
}
}
} finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
}
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
}
}
@@ -533,31 +540,46 @@ class DmPanel(
}
val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext)
updateMessage(envelope.id) {
it.copy(
val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
content = dec.text,
is_edited = true,
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
fileSizes = dec.fileSizes ?: previous?.fileSizes,
fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope,
reply_to = it.reply_to,
reply_to = previous?.reply_to,
)
updateMessage(envelope.id) {
editedForCache.copy(reply_to = it.reply_to)
}
// Persist edit to cache
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
}
}
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext)
val username = if (envelope.senderId == currentUserId) {
"You"
val senderUsername = if (envelope.senderId == currentUserId) {
ApiClient.user?.username.orEmpty()
} else {
otherDisplayName
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(envelope.senderId)?.username?.trim().orEmpty()
}
val senderDisplayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
?: otherDisplayName.takeIf { it.isNotBlank() }
?: ProfileCache.get(envelope.senderId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (envelope.senderId != currentUserId) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername.takeIf { it.isNotEmpty() },
displayName = senderDisplayName,
)
}
return Message(
id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId)
},
is_edited = false,
username = username,
username = senderUsername,
displayName = senderDisplayName,
profile_picture = null,
verified = null,
reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return
}.getOrNull() ?: run {
Logger.w("DmPanel", "processDeletedEnvelope decode failed")
return
}
val involvesPeer =
data.senderId == otherUserId ||
data.recipientId == otherUserId ||
data.senderId == currentUserId
if (!involvesPeer) return
if (!involvesPeer) {
Logger.d(
"DmPanel",
"processDeletedEnvelope skip messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} peer=$otherUserId",
)
return
}
Logger.i(
"DmPanel",
"processDeletedEnvelope messageId=${data.id} peer=$otherUserId " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == data.id }}",
)
scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
}
deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id)
Logger.d(
"DmPanel",
"processDeletedEnvelope done messageId=${data.id} uiAfter=${_state.messages.size}",
)
}
}
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope
) {
private val typingHandler = PublicChatTypingHandler(scope)
private var networkHistoryLoaded = false
private val loadMessagesMutex = Mutex()
/**
* Whether replacing the list would change **structure or message body** (content / edited).
@@ -82,6 +84,7 @@ class PublicChatPanel(
ProfileCache.enrichPublicMessageForDisplay(
mergeMessageUiFields(fresh, message).copy(
username = fresh.username,
displayName = fresh.displayName,
profile_picture = fresh.profile_picture,
verified = fresh.verified,
verificationStatus = fresh.verificationStatus,
@@ -177,6 +180,8 @@ class PublicChatPanel(
}
suspend fun hydrateFromLocalCache() {
// Sender display names live in ProfileCache (message rows only store userId).
runCatching { ProfileCache.hydrateFromDisk() }
hydrateMessagesFromLocalCache()
runCatching { PublicChatProfileCache.hydrateFromDisk() }
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
@@ -377,9 +382,8 @@ class PublicChatPanel(
}
override suspend fun loadMessages() {
loadMessagesMutex.withLock {
hydrateMessagesFromLocalCache()
if (networkHistoryLoaded) return
networkHistoryLoaded = true
val cached = _state.messages
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) {
runCatching { ApiClient.getMessages(limit = 50) }
}
@@ -400,12 +410,18 @@ class PublicChatPanel(
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
Logger.d("PublicChatPanel", loadMsg)
Logger.d(
"PublicChatPanel",
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
)
var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) {
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)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
@@ -425,7 +441,11 @@ class PublicChatPanel(
addMessages(
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)
setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false)
@@ -439,8 +459,11 @@ class PublicChatPanel(
withContext(Dispatchers.Default) {
val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
MessageCacheStore.replacePublicMessages(toPersist)
Logger.d(
"PublicChatPanel",
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
)
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
}
} else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull()
@@ -452,23 +475,26 @@ class PublicChatPanel(
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
}
}
} else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
}
}
}
}
@@ -494,7 +520,7 @@ class PublicChatPanel(
)
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -529,29 +555,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { existing ->
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to)
val existing = _state.messages.find { it.id == editedMsg.id }
val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
updateMessage(editedMsg.id) { current ->
persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
}
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
Logger.i(
"PublicChatPanel",
"messageDeleted messageId=${deletedData.message_id} " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
)
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(deletedData.message_id)
}
Logger.d(
"PublicChatPanel",
"messageDeleted done messageId=${deletedData.message_id} " +
"uiAfter=${_state.messages.size}",
)
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
val existing = _state.messages.find { it.id == reactionUpdate.message_id }
if (existing != null) {
val updated = existing.copy(reactions = reactionUpdate.reactions)
handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
}
} else {
handleReactionUpdate(reactionUpdate)
}
}
"typing" -> {
@@ -620,6 +664,7 @@ class PublicChatPanel(
cancelQueuedMessage(message)
return
}
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId)
@@ -72,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
// Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
val droppedConfirmed = panelMessages.filter { panel ->
panel.id > 0 && panel.id !in mergedIds
}
if (droppedConfirmed.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
"ids=${droppedConfirmed.map { it.id }.take(12)} " +
"panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
)
}
val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when {
panel.id < 0 && cid != null && cid !in mergedClientIds -> true
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
else -> false
panel.id < 0 && cid != null && cid !in mergedClientIds
}
if (extraPanel.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
"ids=${extraPanel.map { it.id }}",
)
}
return dedupeMessagesByClientId(
@@ -114,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
// DB rows only store userId; sender identity is reconstructed from ProfileCache and can
// briefly be blank. Keep non-blank panel fields (e.g. from a network payload) so text
// avatars / names are not wiped on every SQLDelight emission.
val merged = db.copy(
username = db.username.trim().ifBlank { panel.username.trim() },
displayName = db.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: panel.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = db.profile_picture?.takeIf { it.isNotBlank() }
?: panel.profile_picture?.takeIf { it.isNotBlank() },
verified = db.verified ?: panel.verified,
verificationStatus = db.verificationStatus ?: panel.verificationStatus,
pendingFileUri = when {
confirmed -> localPreview
else -> panel.pendingFileUri ?: db.pendingFileUri
@@ -20,7 +20,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Icon
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import ru.fromchat.ui.components.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
val uriHandler = LocalUriHandler.current
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val closeSheet: () -> Unit = {
scope.launch {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String,
userId: Int,
) {
val profileCacheRevision by ProfileCache.revision.collectAsState()
val verificationStatus = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
DisplayName(
displayName = title,
verificationStatus = resolveVerificationStatus(userId),
verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@@ -928,18 +933,18 @@ internal fun DmConversationRowContent(
currentUserId = currentUserId,
deleted = cached?.deleted,
suspended = cached?.suspended,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
username = cached?.username,
)
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when {
isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName
else -> cached?.visibleUsername(currentUserId).orEmpty()
}
val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName
else -> ""
}
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -529,7 +528,7 @@ fun ChatsTab(
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val navController = LocalNavController.current
val clipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope()
val connectionStatus by ConnectionStateStore.status.collectAsState()
@@ -1092,7 +1091,9 @@ fun ChatsTab(
chatContextMenuOverlay.onLink = {
when (contextMenuState.target) {
ChatContextMenuTarget.Public -> {
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) }
publicChatLink?.let { link ->
scope.launch { clipboard.setText(link) }
}
}
ChatContextMenuTarget.Dm -> {
val link = contextMenuState.otherUserId?.let { userId ->
@@ -1100,7 +1101,7 @@ fun ChatsTab(
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
}
link?.let { clipboardManager.setText(AnnotatedString(it)) }
link?.let { scope.launch { clipboard.setText(it) } }
}
}
}
@@ -37,6 +37,7 @@ import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.ui.Website
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.about_link_max
@@ -111,7 +112,10 @@ fun AboutScreen() {
BrandTitle(Modifier.padding(bottom = 4.dp))
Text(
text = stringResource(Res.string.about_version),
text = stringResource(
Res.string.about_version,
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -43,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -422,6 +426,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
@@ -479,10 +485,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn(
modifier = Modifier
.hazeSource(hazeState)
.padding()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
contentPadding = innerPadding
.fillMaxSize()
.consumeWindowInsets(innerPadding)
.hazeSource(hazeState),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 24.dp,
),
) {
item {
Column(Modifier.fillMaxWidth()) {
@@ -569,9 +580,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
}
item {
// Initial load only — background poll must not add/remove list height (overscroll jump).
if (refreshing && devices.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -586,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
}
sheetDevice?.let { d ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet(
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
sheetState = sheetState
sheetState = sheetState,
) {
DeviceSessionDetailBottomSheet(
d = d,
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -389,13 +390,15 @@ fun LogFilesScreen(
}
if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = {
showShareSheet = false
pendingSharePaths = emptyList()
},
sheetState = sheetState,
sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) {
LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -632,13 +633,15 @@ fun LogsScreen() {
}
if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = {
showShareSheet = false
pendingShareRequest = null
},
sheetState = sheetState,
sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) {
LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -648,7 +651,10 @@ fun LogsScreen() {
}
if (showCleanSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet(
onDismissRequest = { showCleanSheet = false },
sheetState = sheetState,
@@ -16,6 +16,9 @@ object SettingsRoutes {
const val AccountYandexFlow = "settings/account/yandex"
const val AccountYandexOAuth = "settings/account/yandex/oauth"
const val AccountYandexDone = "settings/account/yandex/done"
const val AccountVkFlow = "settings/account/vk"
const val AccountVkOAuth = "settings/account/vk/oauth"
const val AccountVkDone = "settings/account/vk/done"
const val ServerConfig = "serverConfig"
const val About = "about"
const val Logs = "settings/logs"
@@ -43,8 +43,11 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.cancel
import ru.fromchat.ic_vk
import ru.fromchat.ic_yandex
import ru.fromchat.logout
import ru.fromchat.settings_account_change_vk
import ru.fromchat.settings_account_change_vk_d
import ru.fromchat.settings_account_change_yandex
import ru.fromchat.settings_account_change_yandex_d
import ru.fromchat.settings_account_delete
@@ -63,16 +66,20 @@ fun AccountScreen(
onLogout: () -> Unit,
onChangePassword: () -> Unit,
onChangeYandexId: () -> Unit,
onChangeVkId: () -> Unit,
onDeleteAccount: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
var yandexAvailable by remember { mutableStateOf(false) }
var vkAvailable by remember { mutableStateOf(false) }
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
val vkIcon = vectorResource(Res.drawable.ic_vk)
LaunchedEffect(Unit) {
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
vkAvailable = runCatching { ApiClient.getAccountVk() }.isSuccess
}
Scaffold(
@@ -133,6 +140,24 @@ fun AccountScreen(
)
}
if (vkAvailable) {
ListItem(
headline = stringResource(Res.string.settings_account_change_vk),
supportingText = stringResource(Res.string.settings_account_change_vk_d),
onClick = onChangeVkId,
leadingContent = { Icon(vkIcon, null) },
divider = true,
trailingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
ListItem(
headline = stringResource(Res.string.settings_account_delete),
supportingText = stringResource(Res.string.settings_account_delete_d),
@@ -0,0 +1,161 @@
package ru.fromchat.ui.main.settings.account.changevk
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.intl.Locale
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
import ru.fromchat.auth.vk.buildVkAuthorizeUrl
import ru.fromchat.auth.vk.generateOAuthState
import ru.fromchat.auth.vk.generateVkPkcePair
import ru.fromchat.auth.vk.resolveVkClientId
import ru.fromchat.auth_vk_client_mismatch
import ru.fromchat.config.Settings
import ru.fromchat.error_unexpected
import ru.fromchat.ic_vk
import ru.fromchat.settings_next
import ru.fromchat.settings_vk_step_confirm_body
import ru.fromchat.settings_vk_step_confirm_cta
import ru.fromchat.settings_vk_step_confirm_title
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
import ru.fromchat.ui.components.ExpressiveStepPage
import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.rememberExpressiveStepFlow
import ru.fromchat.ui.components.showReplacingSnackbar
import ru.fromchat.ui.isAppInDarkTheme
import ru.fromchat.ui.main.settings.SettingsRoutes
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChangeVkConfirmScreen(onBack: () -> Unit) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val flowState = rememberExpressiveStepFlow(1)
var vk by remember { mutableStateOf<VkOAuthParams?>(null) }
var loadingParams by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) }
val darkTheme = isAppInDarkTheme()
val languageTag = Locale.current.toLanguageTag()
val vkIcon = vectorResource(Res.drawable.ic_vk)
val title = stringResource(Res.string.settings_vk_step_confirm_title)
val body = stringResource(Res.string.settings_vk_step_confirm_body)
val cta = stringResource(Res.string.settings_vk_step_confirm_cta)
val next = stringResource(Res.string.settings_next)
val clientMismatch = stringResource(Res.string.auth_vk_client_mismatch)
val unexpected = stringResource(Res.string.error_unexpected)
fun showSnack(text: String) {
scope.launch {
snackbarHostState.showReplacingSnackbar(
message = text,
withDismissAction = false,
duration = SnackbarDuration.Short,
)
}
}
LaunchedEffect(Unit) {
ChangeVkDraft.clear()
loadingParams = true
try {
vk = ApiClient.getAccountVk().vk
} catch (e: Exception) {
showSnack(e.message ?: unexpected)
} finally {
loadingParams = false
}
}
val colorScheme = MaterialTheme.colorScheme
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = vkIcon,
polygon = MaterialShapes.Cookie9Sided.normalized(),
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
),
content = {
ExpressiveStepPageHeader(title = title, body = body)
},
button = {
ActionButton(
onClick = {
if (busy || loadingParams) return@ActionButton
val params = vk
if (params == null) {
showSnack(unexpected)
return@ActionButton
}
busy = true
scope.launch {
try {
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
showSnack(it.message ?: unexpected)
return@launch
}
val clientId = resolveVkClientId(params.client_id, serverIp)
if (clientId == null) {
showSnack(clientMismatch)
return@launch
}
val pkce = generateVkPkcePair()
val state = generateOAuthState()
val redirectUri = params.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
ChangeVkDraft.authorizeUrl = buildVkAuthorizeUrl(
authorizeUrl = params.authorize_url,
clientId = clientId,
redirectUri = redirectUri,
scope = params.scope,
codeChallenge = pkce.codeChallenge,
state = state,
languageTag = languageTag,
darkTheme = darkTheme,
)
ChangeVkDraft.codeVerifier = pkce.codeVerifier
ChangeVkDraft.state = state
ChangeVkDraft.redirectUri = redirectUri
navController.navigate(SettingsRoutes.AccountVkOAuth)
} finally {
busy = false
}
}
},
enabled = !busy && !loadingParams && vk != null,
loading = busy || loadingParams,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy || loadingParams) next else cta)
}
},
),
),
snackbarHostState = snackbarHostState,
onBackAtFirstPage = onBack,
)
}
@@ -0,0 +1,62 @@
package ru.fromchat.ui.main.settings.account.changevk
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.settings_done
import ru.fromchat.settings_vk_step_done_body
import ru.fromchat.settings_vk_step_done_title
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
import ru.fromchat.ui.components.ExpressiveStepPage
import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.rememberExpressiveStepFlow
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChangeVkDoneScreen(onDone: () -> Unit) {
val flowState = rememberExpressiveStepFlow(1)
val snackbarHostState = remember { SnackbarHostState() }
val title = stringResource(Res.string.settings_vk_step_done_title)
val body = stringResource(Res.string.settings_vk_step_done_body)
val done = stringResource(Res.string.settings_done)
val colorScheme = MaterialTheme.colorScheme
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.CheckCircle,
polygon = MaterialShapes.Cookie9Sided.normalized(),
containerColor = colorScheme.tertiaryContainer,
contentColor = colorScheme.onTertiaryContainer,
),
content = {
ExpressiveStepPageHeader(title = title, body = body)
},
button = {
ActionButton(
onClick = onDone,
modifier = Modifier.fillMaxWidth(),
) {
Text(done)
}
},
),
),
snackbarHostState = snackbarHostState,
onBackAtFirstPage = onDone,
)
}
@@ -0,0 +1,19 @@
package ru.fromchat.ui.main.settings.account.changevk
/**
* Stages PKCE + authorize URL + OAuth state while the change-VK OAuth WebView is open
* (settings composition can leave the confirm screen).
*/
internal object ChangeVkDraft {
var authorizeUrl: String? = null
var codeVerifier: String? = null
var state: String? = null
var redirectUri: String? = null
fun clear() {
authorizeUrl = null
codeVerifier = null
state = null
redirectUri = null
}
}
@@ -0,0 +1,142 @@
package ru.fromchat.ui.main.settings.account.changevk
import androidx.compose.animation.AnimatedVisibility
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.vk.VkOAuthWebView
import ru.fromchat.ui.isAppInDarkTheme
import ru.fromchat.ui.main.settings.SettingsRoutes
@Composable
fun ChangeVkOAuthScreen(onBack: () -> Unit) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val authorizeUrl = remember { ChangeVkDraft.authorizeUrl }
val codeVerifier = remember { ChangeVkDraft.codeVerifier }
val expectedState = remember { ChangeVkDraft.state }
val redirectUri = remember { ChangeVkDraft.redirectUri }
val fallbackColor = MaterialTheme.colorScheme.background
var chromeColor by remember { mutableStateOf(fallbackColor) }
var busy by remember { mutableStateOf(false) }
var webViewCanGoBack by remember { mutableStateOf(false) }
val backLabel = stringResource(Res.string.back)
LaunchedEffect(authorizeUrl, codeVerifier, expectedState, redirectUri) {
if (authorizeUrl.isNullOrBlank() ||
codeVerifier.isNullOrBlank() ||
expectedState.isNullOrBlank() ||
redirectUri.isNullOrBlank()
) {
onBack()
}
}
val url = authorizeUrl ?: return
val verifier = codeVerifier ?: return
val state = expectedState ?: return
val callbackUri = redirectUri ?: return
fun finishSuccess() {
ChangeVkDraft.clear()
navController.navigate(SettingsRoutes.AccountVkDone) {
popUpTo(SettingsRoutes.AccountVkFlow) { inclusive = true }
}
}
fun cancel() {
if (busy) return
ChangeVkDraft.clear()
onBack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(chromeColor),
) {
VkOAuthWebView(
authorizeUrl = url,
redirectUri = callbackUri,
languageTag = Locale.current.toLanguageTag(),
darkTheme = isAppInDarkTheme(),
fallbackColor = fallbackColor,
clearCookies = true,
onPageBackgroundColor = { chromeColor = it },
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
onRedirect = { redirect ->
if (busy) return@VkOAuthWebView
if (redirect.state != state) {
cancel()
return@VkOAuthWebView
}
scope.launch {
busy = true
try {
val proof = ApiClient.authVkExchange(
code = redirect.code,
codeVerifier = verifier,
deviceId = redirect.deviceId,
state = redirect.state,
).registration_proof
ApiClient.changeAccountVk(proof)
finishSuccess()
} catch (_: Exception) {
cancel()
} finally {
busy = false
}
}
},
onError = { cancel() },
onCancel = { cancel() },
)
AnimatedVisibility(
visible = !webViewCanGoBack && !busy,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.safeDrawingPadding()
.padding(start = 12.dp, top = 12.dp),
) {
FilledIconButton(
onClick = { cancel() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
}
}
}
}
@@ -105,10 +105,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ContextMenuPressable
@@ -177,6 +174,7 @@ import ru.fromchat.profile_headline_bio
import ru.fromchat.profile_headline_member_since
import ru.fromchat.profile_headline_username
import ru.fromchat.profile_headline_verification
import ru.fromchat.profile_invalid_link
import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support
@@ -267,7 +265,6 @@ fun ProfileScreen(
onOpenSettings: () -> Unit = {},
showBackButton: Boolean = false,
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
@@ -377,7 +374,7 @@ fun ProfileScreen(
ApiClient.applyOwnProfile(refreshed)
state = latestUi.copy(profile = refreshed, error = null)
} catch (_: Exception) {
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
ownUserId.let { ProfileCache.get(it) }?.let { cached ->
state = latestUi.copy(profile = cached)
}
}
@@ -609,7 +606,7 @@ fun ProfileScreen(
ProfileAction(
label = labelLink,
icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
),
ProfileAction(
label = labelSettings,
@@ -654,7 +651,7 @@ fun ProfileScreen(
ProfileAction(
label = labelLink,
icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
)
)
if (ServerConfig.callsEnabled) {
@@ -831,10 +828,10 @@ fun ProfileScreen(
labelCopy = labelCopy,
labelEdit = labelEdit,
detailsBringIntoView = detailsBringIntoView,
clipboardManager = clipboardManager,
clipboard = clipboard,
navController = navController,
scope = scope,
snackbarHostState = snackbarHostState,
openContextMenuHaptic = openContextMenuHaptic,
onBack = onBack,
onProfileUpdated = { updated ->
@@ -880,7 +877,6 @@ fun PublicChatProfileScreen(
initialDisplayName: String? = null,
showBackButton: Boolean = false,
) {
val clipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -962,7 +958,7 @@ fun PublicChatProfileScreen(
ProfileAction(
label = labelLink,
icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
),
ProfileAction(
label = labelSearch,
@@ -1003,15 +999,15 @@ fun PublicChatProfileScreen(
when {
useSharedAvatar && displayName.isNotBlank() -> {
item {
with(sharedTransitionScope!!) {
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = null,
displayName = displayName,
modifier = Modifier
.padding(top = profileAvatarTop)
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey!!),
animatedVisibilityScope = animatedVisibilityScope!!,
rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope,
)
.size(104.dp),
)
@@ -1051,6 +1047,7 @@ fun PublicChatProfileScreen(
labelCopy = labelCopy,
clipboard = clipboard,
scope = scope,
snackbarHostState = snackbarHostState,
)
}
}
@@ -1090,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
labelCopy: String,
clipboard: SupportClipboardManager,
scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
Column(
@@ -1136,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
supportingSlot = {
ProfileBioMarkdown(
content = resolvedProfile.bio.orEmpty(),
snackbarHostState = snackbarHostState,
)
},
position = ListItemPosition.START,
@@ -1320,10 +1319,10 @@ private fun ProfileLoadedBody(
labelCopy: String,
labelEdit: String,
detailsBringIntoView: BringIntoViewRequester,
clipboardManager: ClipboardManager,
clipboard: SupportClipboardManager,
navController: NavController,
scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
openContextMenuHaptic: () -> Unit,
onBack: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit,
@@ -1349,7 +1348,7 @@ private fun ProfileLoadedBody(
onContextMenuOpen = openContextMenuHaptic,
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(displayName))
scope.launch { clipboard.setText(displayName) }
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
@@ -1453,9 +1452,9 @@ private fun ProfileLoadedBody(
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(
AnnotatedString(usernameForLinks.orEmpty()),
)
scope.launch {
clipboard.setText(usernameForLinks.orEmpty())
}
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
@@ -1491,7 +1490,7 @@ private fun ProfileLoadedBody(
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(memberSinceText))
scope.launch { clipboard.setText(memberSinceText) }
}
},
)
@@ -1505,7 +1504,10 @@ private fun ProfileLoadedBody(
headline = headlineBio,
supportingSlot = {
key(resolvedProfile.id, bioContent) {
ProfileBioMarkdown(content = bioContent)
ProfileBioMarkdown(
content = bioContent,
snackbarHostState = snackbarHostState,
)
}
},
divider = true,
@@ -1864,14 +1866,27 @@ private fun ProfileLoadedBody(
@Composable
private fun ProfileBioMarkdown(
content: String,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
MarkdownPlain(
content = content,
modifier = modifier,
onLinkClick = { uriHandler.openUri(it) },
onLinkClick = { uri ->
runCatching { uriHandler.openUri(uri) }.onFailure {
scope.launch {
snackbarHostState.showReplacingSnackbar(
message = invalidLinkMessage,
withDismissAction = false,
duration = SnackbarDuration.Short,
)
}
}
},
)
}
@@ -1,23 +1,26 @@
package ru.fromchat.ui.auth.yandex
package ru.fromchat.ui.auth.oauth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
@Composable
actual fun YandexOAuthWebView(
actual fun OAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
redirectUriPrefix: String,
isAuthNavigation: (url: String) -> Boolean,
clearCookies: Boolean,
themeCookieHosts: List<String>,
onPageBackgroundColor: (Color) -> Unit,
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
onCode: (String) -> Unit,
onRedirectUrl: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
LaunchedEffect(authorizeUrl) {
onError("Yandex sign-in is not available on this platform yet.")
onError("OAuth sign-in is not available on this platform yet.")
}
}
+4
View File
@@ -8,6 +8,10 @@ plugins {
alias(libs.plugins.google.services) apply false
}
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
extra["versionName"] = "1.1.3"
extra["versionCode"] = 113
buildscript {
repositories {
google()
+23 -25
View File
@@ -1,52 +1,52 @@
[versions]
agp = "9.1.1"
agp = "9.3.0"
androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.18.0"
androidx-exifinterface = "1.3.7"
coilCompose = "3.4.0"
compose-multiplatform = "1.10.3"
androidx-core-ktx = "1.19.0"
androidx-exifinterface = "1.4.2"
coilCompose = "3.5.0"
compose-multiplatform = "1.11.1"
#noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded"
constraintlayout = "0.8.0-shaded"
coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.2"
googleServices = "4.4.4"
firebaseMessaging = "25.1.1"
googleServices = "4.5.0"
haze = "1.7.2"
kotlin = "2.3.21"
kotlin = "2.4.10"
adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha07"
gson = "2.14.0"
kotlinxIoBytestring = "0.9.0"
kotlinxIoBytestring = "0.9.1"
kotlinxCoroutinesCore = "1.11.0"
kotlinxIoCore = "0.9.0"
kotlinxIoCore = "0.9.1"
multiplatformCryptoLibsodiumBindings = "0.9.5"
multiplatformSettings = "1.3.0"
serialization = "2.3.21"
serialization = "2.4.10"
serialization-json = "1.11.0"
material = "1.13.0"
material = "1.14.0"
activityKtx = "1.13.0"
navigationCompose = "2.9.2"
datastore = "1.2.1"
security-crypto = "1.1.0"
ktor = "3.4.3"
ktor = "3.5.1"
slf4j = "1.7.36"
kotlinxDatetime = "0.8.0"
lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.05.00"
lifecycleRuntimeKtx = "2.11.0"
composeBom = "2026.06.01"
composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.10.0-alpha05"
composeComponents = "1.10.3"
composeMaterial3 = "1.12.0-alpha03"
composeComponents = "1.11.1"
playServicesBase = "18.10.0"
tweetnaclJava = "1.1.3"
androidxWork = "2.11.2"
cryptography-kotlin = "0.6.0"
krypto = "4.0.10"
sqldelight = "2.3.2"
livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0"
markdownRendererM3 = "0.41.0"
bouncycastle = "1.79"
webkit = "1.14.0"
livekitAndroid = "2.27.0"
livekitAndroidComposeComponents = "2.4.0"
markdownRendererM3 = "0.43.0"
bouncycastle = "1.85"
webkit = "1.16.0"
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -87,7 +87,6 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
@@ -104,7 +103,6 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
+2 -5
View File
@@ -1,9 +1,6 @@
#Tue Jul 21 20:27:03 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+1 -2
View File
@@ -18,8 +18,7 @@ kotlin {
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
@@ -17,7 +17,7 @@ actual fun Clipboard.toSupport(): SupportClipboardManager {
override suspend fun getText(): String? {
val entry = clipboard.getClipEntry() ?: return null
return entry.clipData?.getItemAt(0)?.text?.toString()
return entry.clipData.getItemAt(0)?.text?.toString()
}
override fun setTextListener(listener: (String) -> Unit) {
@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package com.pr0gramm3r101.utils.settings
import androidx.core.content.edit
@@ -7,6 +9,12 @@ import com.pr0gramm3r101.utils.UtilsLibrary.context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Secure prefs via EncryptedSharedPreferences.
*
* These APIs are deprecated in favor of DataStore + Tink (`datastore-tink`, DataStore 1.3+).
* Kept until that stack is stable enough to migrate auth/identity keys without risk.
*/
class AndroidSecureSettings : Settings {
private val masterKey by lazy {
MasterKey.Builder(context)
@@ -80,4 +88,3 @@ class AndroidSecureSettings : Settings {
encryptedPrefs.contains(key)
}
}