22 Commits

107 changed files with 5631 additions and 1070 deletions
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+2
View File
@@ -32,3 +32,5 @@ google-services.json
premortem-transcript-*.md premortem-transcript-*.md
premortem-report-*.html premortem-report-*.html
target/
+2 -7
View File
@@ -62,8 +62,8 @@ extensions.configure<ApplicationExtension> {
applicationId = "ru.fromchat" applicationId = "ru.fromchat"
minSdk = 24 minSdk = 24
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = rootProject.extra["versionCode"] as Int
versionName = "1.0" versionName = rootProject.extra["versionName"] as String
ndk { ndk {
abiFilters += listOf("arm64-v8a", "x86_64") abiFilters += listOf("arm64-v8a", "x86_64")
@@ -166,9 +166,4 @@ dependencies {
implementation(project(":app:shared")) implementation(project(":app:shared"))
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
testImplementation("junit:junit:4.13.2")
testImplementation(libs.androidx.compose.material3)
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
testImplementation("org.robolectric:robolectric:4.14.1")
} }
+15
View File
@@ -19,6 +19,12 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:name=".App" android:name=".App"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_fromchat" />
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -39,6 +45,15 @@
android:host="u" android:host="u"
android:pathPrefix="/" /> android:pathPrefix="/" />
</intent-filter> </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="/yandex" />
</intent-filter>
</activity> </activity>
<service <service
android:name=".fcm.FromChatFirebaseMessagingService" android:name=".fcm.FromChatFirebaseMessagingService"
@@ -37,6 +37,7 @@ class App: Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
UtilsLibrary.init(this) UtilsLibrary.init(this)
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
WebSocketManager.addGlobalMessageHandler { msg -> WebSocketManager.addGlobalMessageHandler { msg ->
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
@@ -228,6 +228,7 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Logger.i("MainActivity", "onCreate savedInstanceStateNull=${savedInstanceState == null}")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
installSplashScreen() installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
@@ -264,13 +265,25 @@ class MainActivity : ComponentActivity() {
} }
override fun onPause() { override fun onPause() {
Logger.i("MainActivity", "onPause")
super.onPause() super.onPause()
prevIsPublicChatVisible = isPublicChatVisible prevIsPublicChatVisible = isPublicChatVisible
isPublicChatVisible = false isPublicChatVisible = false
} }
override fun onResume() { override fun onResume() {
Logger.i("MainActivity", "onResume")
super.onResume() super.onResume()
isPublicChatVisible = prevIsPublicChatVisible ?: false isPublicChatVisible = prevIsPublicChatVisible ?: false
} }
override fun onDestroy() {
Logger.i("MainActivity", "onDestroy isFinishing=$isFinishing")
super.onDestroy()
}
override fun onSaveInstanceState(outState: Bundle) {
Logger.i("MainActivity", "onSaveInstanceState")
super.onSaveInstanceState(outState)
}
} }
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() { class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull() val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull() ?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull() val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] val sender = pushData["sender_display_name"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" ?.takeIf { it.isNotBlank() }
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" ?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message" val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true) val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) { if (ApiClient.token.isNullOrBlank()) {
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId") Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch return@launch
} }
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
NotificationHelper.showFallbackPushNotification(
context = applicationContext,
title = title,
body = body,
sender = sender,
messageId = fallbackMessageId,
isDirectMessage = false,
senderId = senderId,
)
}
if (isDirectMessage) { if (isDirectMessage) {
NotificationHelper.fetchAndNotify( NotificationHelper.fetchAndNotify(
applicationContext, applicationContext,
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
dmSenderName = sender, dmSenderName = sender,
) )
} else { } else {
NotificationHelper.fetchAndNotify(applicationContext) // Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -72,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
override fun onNewToken(token: String) { override fun onRegistered(installationId: String) {
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})") Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
try { try {
settings.putString("pending_fcm_token", token) settings.putString("pending_fcm_token", installationId)
uploadPendingFcmTokenIfAvailable() uploadPendingFcmTokenIfAvailable()
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance") Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
} catch (e: Exception) { } catch (e: Exception) {
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e) Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
} }
super.onNewToken(token)
} }
} }
} }
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview import ru.fromchat.api.local.messages.buildChatListPreview
@@ -53,13 +58,20 @@ object NotificationHelper {
private const val CHAT_TYPE_PUBLIC = "public" private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm" private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages" private const val CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val SUMMARY_NOTIFICATION_ID = 1000000
private const val PREF_SHOWN_KEY = "shown_message_ids" private const val PREF_SHOWN_KEY = "shown_message_ids"
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids" private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id" private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val publicFetchMutex = Mutex()
private var publicFetchJob: Job? = null
private fun listPreviewStrings(context: Context): ChatListPreviewStrings { private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji) val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings( return ChatListPreviewStrings(
@@ -72,6 +84,24 @@ object NotificationHelper {
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String = private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
private fun publicConversationTitle(context: Context): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim()
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: context.getString(R.string.public_chat)
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
ProfileCache.get(message.user_id)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return message.username.trim().ifBlank { "FromChat" }
}
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent( private fun createMessageIntent(
@@ -124,7 +154,6 @@ object NotificationHelper {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
) )
fun createChannel(context: Context) { fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context (context
@@ -141,6 +170,17 @@ object NotificationHelper {
} }
} }
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
fun schedulePublicFetchAndNotify(context: Context) {
publicFetchJob?.cancel()
publicFetchJob = helperScope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(context.applicationContext, includeDmMessages = false)
}
}
}
suspend fun fetchAndNotify( suspend fun fetchAndNotify(
context: Context, context: Context,
includeDmMessages: Boolean = false, includeDmMessages: Boolean = false,
@@ -168,10 +208,7 @@ object NotificationHelper {
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)") Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, messages) displayNotifications(context, messages)
}
} else { } else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned") Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
} }
@@ -194,13 +231,15 @@ object NotificationHelper {
"fetchAndNotify retry: fetched ${retryMessages.size} public messages" "fetchAndNotify retry: fetched ${retryMessages.size} public messages"
) )
if (retryMessages.isNotEmpty()) { if (retryMessages.isNotEmpty()) {
CoroutineScope(Dispatchers.Main).launch {
createChannel(context)
displayNotifications(context, retryMessages) displayNotifications(context, retryMessages)
} }
}
if (includeDmMessages) { if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName) fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
} }
return return
} catch (_: Exception) { } catch (_: Exception) {
@@ -305,10 +344,11 @@ object NotificationHelper {
val senderName = when { val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername !envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId) else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId) ?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty() }.orEmpty()
val dmConversationUserId = envelope.senderId val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope( val notificationBody = buildChatListPreviewFromEnvelope(
@@ -320,9 +360,9 @@ object NotificationHelper {
showFallbackPushNotification( showFallbackPushNotification(
context = context, context = context,
title = if (senderName.isNotBlank()) { title = if (senderName.isNotBlank()) {
"Direct message from $senderName" context.getString(R.string.notification_direct_message_from, senderName)
} else { } else {
"Direct message" context.getString(R.string.notification_direct_message)
}, },
body = notificationBody, body = notificationBody,
sender = senderName, sender = senderName,
@@ -330,7 +370,7 @@ object NotificationHelper {
allowWhenPublicChatVisible = true, allowWhenPublicChatVisible = true,
isDirectMessage = true, isDirectMessage = true,
targetDmUserId = dmConversationUserId, targetDmUserId = dmConversationUserId,
conversationTitle = "Direct Messages" conversationTitle = context.getString(R.string.notification_direct_messages_title)
) )
shownDm.add(shownDmKey) shownDm.add(shownDmKey)
} }
@@ -351,10 +391,10 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false, allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false, isDirectMessage: Boolean = false,
targetDmUserId: Int? = null, targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat", conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null, senderId: Int? = null,
) { ) {
CoroutineScope(Dispatchers.Main).launch { helperScope.launch(Dispatchers.Main) {
createChannel(context) createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1) val currentUserId = settings.getInt("current_user_id", -1)
@@ -400,16 +440,31 @@ object NotificationHelper {
} }
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat" val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
val groupKey = if (isDirectMessage && targetDmUserId != null) {
GROUP_DM_PREFIX + targetDmUserId
} else {
GROUP_PUBLIC
}
val notificationId = if (isDirectMessage && targetDmUserId != null) {
SUMMARY_NOTIFICATION_ID + targetDmUserId
} else {
SUMMARY_NOTIFICATION_ID
}
cancelStaleSystemTrayDuplicates(context)
notify( notify(
SUMMARY_NOTIFICATION_ID, notificationId,
NotificationCompat.Builder(context, CHANNEL_ID) NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big) .setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
.setGroup(groupKey)
.setStyle( .setStyle(
NotificationCompat.MessagingStyle( NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle(conversationTitle).addMessage( )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message( NotificationCompat.MessagingStyle.Message(
body, body,
System.currentTimeMillis(), System.currentTimeMillis(),
@@ -423,7 +478,7 @@ object NotificationHelper {
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = isDirectMessage, isDirectMessage = isDirectMessage,
@@ -433,7 +488,7 @@ object NotificationHelper {
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
@@ -457,37 +512,44 @@ object NotificationHelper {
} }
} }
} }
@OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) { private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages") Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
// Don't show notifications if user is currently viewing the public chat
if (isPublicChatVisible) { if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat") Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return return
} }
GlobalScope.launch { helperScope.launch(Dispatchers.Main.immediate) {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0 var newMessageCount = 0
val previewStrings = listPreviewStrings(context) val previewStrings = listPreviewStrings(context)
val conversationTitle = publicConversationTitle(context)
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
if ( if (
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED ) != PackageManager.PERMISSION_GRANTED
) { ) {
// Find new messages that are not from the current user Logger.w(
val currentUserId = settings.getInt("current_user_id", -1) "NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
return@launch
}
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId == -1) return@launch if (currentUserId == -1) return@launch
val newMessages = messages.filter { msg -> val newMessages = messages
!shown.contains(msg.id.toString()) && // Not already shown .filter { msg ->
msg.user_id != currentUserId // Not from current user !shown.contains(msg.id.toString()) && msg.user_id != currentUserId
} }
.sortedBy { it.id }
if (newMessages.isEmpty()) { if (newMessages.isEmpty()) {
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
@@ -495,50 +557,57 @@ object NotificationHelper {
) )
return@launch return@launch
} }
newMessages.apply { forEach { shown.add(it.id.toString()) } } newMessages.forEach { shown.add(it.id.toString()) }
newMessageCount = newMessages.size newMessageCount = newMessages.size
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" "displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
) )
notify( createChannel(context)
SUMMARY_NOTIFICATION_ID, cancelStaleSystemTrayDuplicates(context)
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big) val messagingStyle = NotificationCompat.MessagingStyle(
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style -> )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
for (msg in newMessages.takeLast(10)) { for (msg in newMessages.takeLast(10)) {
val timestamp = try { val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds() Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
messagingStyle.addMessage(
style.addMessage(
NotificationCompat.MessagingStyle.Message( NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings), notificationBodyForMessage(msg, previewStrings),
timestamp, timestamp,
Person.Builder() Person.Builder()
.setName(msg.username) .setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build() .build()
) )
) )
} }
style notify(
} SUMMARY_NOTIFICATION_ID,
) NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setLargeIcon(avatar)
.setContentTitle(conversationTitle)
.setStyle(messagingStyle)
.setGroup(GROUP_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.addAction( .addAction(
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", context.getString(R.string.notification_reply),
createReplyIntent( createReplyIntent(
context = context, context = context,
isDirectMessage = false, isDirectMessage = false,
@@ -547,27 +616,42 @@ object NotificationHelper {
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply to chat...") .setLabel(context.getString(R.string.notification_reply_hint))
.build() .build()
) )
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
.build() .build()
) )
.setContentIntent(createMessageIntent(context, newMessages.last().id)) .setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build() .build()
) )
} else {
Logger.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
}
} }
settings.putStringSet(PREF_SHOWN_KEY, shown) settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}") Logger.i(
"NotificationHelper",
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
)
}
}
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
private fun cancelStaleSystemTrayDuplicates(context: Context) {
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
manager.cancel(0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { status ->
status.notification.channelId == "fcm_fallback_notification_channel" ||
status.id == 0
}
.forEach { status ->
manager.cancel(status.tag, status.id)
}
}
} }
} }
} }
@@ -0,0 +1,78 @@
package ru.fromchat.notifications
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.Typeface
import kotlin.math.abs
/**
* Builds a circular initials avatar matching the in-app public-chat row (title initials +
* name-hash gradient), for use as a notification large icon.
*/
internal object PublicChatNotificationAvatar {
private const val SIZE_PX = 192
fun create(title: String): Bitmap {
val seed = title.ifBlank { "FromChat" }
val hash = seed.hashCode()
val r = abs(hash % 256)
val g = abs((hash / 256) % 256)
val b = abs((hash / 65536) % 256)
val colorStart = android.graphics.Color.rgb(
(r + 100).coerceIn(0, 255),
(g + 100).coerceIn(0, 255),
(b + 100).coerceIn(0, 255),
)
val colorEnd = android.graphics.Color.rgb(
(r + 50).coerceIn(0, 255),
(g + 50).coerceIn(0, 255),
(b + 50).coerceIn(0, 255),
)
val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f,
0f,
SIZE_PX.toFloat(),
SIZE_PX.toFloat(),
colorStart,
colorEnd,
Shader.TileMode.CLAMP,
)
}
val radius = SIZE_PX / 2f
canvas.drawCircle(radius, radius, radius, paint)
val initials = initialsFrom(seed)
if (initials.isNotBlank()) {
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.WHITE
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textSize = radius * 0.7f
}
val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, radius, textY, textPaint)
}
return bitmap
}
private fun initialsFrom(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
return when {
words.isEmpty() -> ""
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase()
}
else -> words.take(2).joinToString("") {
it.firstOrNull()?.uppercaseChar()?.toString().orEmpty()
}
}
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M494,221.5c43.2,0 85.8,0.1 128.4,-0 42.7,-0.1 81.5,12.2 114.8,39.8 30.5,25.2 49.7,58 57,97.6 1.8,9.4 2.6,19.1 2.6,28.7 0.3,47.8 0,95.6 0.1,143.4 0.1,32.6 -9.9,61.9 -28.3,88.2 -21.2,30.3 -48.8,52.2 -83.2,64.2 -12.9,4.5 -26.9,7.5 -40.5,7.6 -95.6,0.6 -191.2,0.2 -286.8,0.6 -5.2,0 -11.3,2.8 -15.6,6.2 -22.2,17.2 -43.8,35.1 -65.8,52.5 -10.9,8.6 -21.9,17.4 -33.8,24.4 -14.4,8.4 -31.4,2.4 -37.5,-12.1 -1.8,-4.1 -2.1,-9.2 -2.1,-13.8 -0.1,-97.8 -0.2,-195.6 -0.2,-293.4 0,-24.8 -0.9,-49.6 0.3,-74.4 1.8,-37.5 14.7,-71 39.1,-99.2 31.7,-36.6 71.7,-57 119.4,-59.2 38.1,-1.7 76.3,-0.9 114.5,-1.1 5.6,-0 11.1,0 17.3,0M330.4,269.1c-16,5.5 -30.2,14.3 -43,25.6 -30.2,26.8 -46.7,60.7 -46.8,101.7 -0.4,108 -0.2,216 -0.2,324 0,2.5 0.2,4.9 0.4,8.1 1.5,-0.8 2.4,-1.1 3.1,-1.6 22.5,-18 45.5,-35.4 67.3,-54.3 15.9,-13.9 33.5,-19.1 54,-19 88.1,0.4 176.1,0.1 264.2,0.2 22.8,0 44,-6.1 63.9,-17 22.3,-12.2 40.4,-29.1 52.5,-52.1 11.1,-21.2 14.6,-44 14.3,-67.8 -0.4,-42.7 0.2,-85.3 -1.3,-128 -1.1,-34.2 -15.6,-63.3 -39.1,-87.4 -24.5,-25.1 -54,-39.6 -88.9,-39.8 -84,-0.4 -168.1,-0.1 -252.1,-0.2 -16.3,0 -32.1,2 -48.4,7.7h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M401.4,512.8c-30.4,8.6 -59,-5.7 -73.3,-29.9 -13.2,-22.3 -11,-51.9 4.9,-72.2 23.3,-29.7 68.1,-35.5 96.6,-8.4 23.3,22.2 27,58.5 7,85.5 -8.9,12.1 -20.5,20.5 -35.2,25.1h0Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M484.5,471.4c-9.1,-6.2 -12.4,-14.2 -9.9,-22.8 2,-7 10.4,-13.8 20,-13.7 39.3,0.4 78.7,0.2 118,0.2h7.3c0.3,-0.5 0.5,-1 0.8,-1.5 -3.5,-3.7 -6.9,-7.5 -10.4,-11.2 -7.9,-8.4 -8.3,-20 -0.9,-27.5 8.2,-8.2 21.2,-8.7 29.1,-0.7 15.2,15.3 30.3,30.7 45.3,46.1 8.1,8.4 8.4,20.7 0.3,29.1 -16.3,16.7 -32.8,33.1 -49.3,49.5 -6.1,6.1 -18.3,5.5 -24.8,-0.8 -6.9,-6.7 -8,-17.9 -2,-25 4.5,-5.3 9.7,-10.1 14.5,-15.1 1.1,-1.2 2.2,-2.4 4.2,-4.6 -3.3,-0.2 -5.3,-0.4 -7.3,-0.4 -42,0 -84.1,0.1 -126.1,-0 -2.8,0 -5.6,-0.9 -8.8,-1.5h-0Z" />
</vector>
@@ -1,5 +1,11 @@
<resources> <resources>
<string name="public_chat">Общий чат</string>
<string name="chat_preview_attachment">Вложение</string> <string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string> <string name="chat_preview_image">%1$s 1 фото</string>
<string name="notification_reply">Ответить</string>
<string name="notification_reply_hint">Ответ в чат…</string>
<string name="notification_direct_message">Личное сообщение</string>
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
<string name="notification_direct_messages_title">Личные сообщения</string>
</resources> </resources>
@@ -1,6 +1,12 @@
<resources> <resources>
<string name="app_name" translatable="false">FromChat</string> <string name="app_name" translatable="false">FromChat</string>
<string name="public_chat">Main chat</string>
<string name="chat_preview_attachment">Attachment</string> <string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string> <string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string> <string name="chat_preview_image">%1$s 1 photo</string>
<string name="notification_reply">Reply</string>
<string name="notification_reply_hint">Reply to chat…</string>
<string name="notification_direct_message">Direct message</string>
<string name="notification_direct_message_from">Direct message from %1$s</string>
<string name="notification_direct_messages_title">Direct Messages</string>
</resources> </resources>
+63 -1
View File
@@ -1,3 +1,12 @@
@file:Suppress("TaskMissingDescription")
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
plugins { plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
@@ -7,6 +16,54 @@ plugins {
alias(libs.plugins.sqldelight) alias(libs.plugins.sqldelight)
} }
abstract class GenerateAppBuildInfoTask : DefaultTask() {
@get:Input
abstract val versionName: Property<String>
@get:Input
abstract val versionCode: Property<Int>
@get:Input
abstract val debugBuild: Property<Boolean>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun generate() {
val outRoot = outputDirectory.get().asFile
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
}
outRoot.deleteRecursively()
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
"""
|package ru.fromchat
|
|/** Injected by Gradle into generated sources (not under src/). */
|object AppBuildInfo {
| const val version = "${versionName.get()}"
| const val versionCode = ${versionCode.get()}
| const val isDebug = ${debugBuild.get()}
|}
|
""".trimMargin()
)
}
}
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
versionName.set(rootProject.extra["versionName"] as String)
versionCode.set(rootProject.extra["versionCode"] as Int)
debugBuild.set(
gradle.startParameter.taskNames.let { names ->
!names.any { it.contains("Release", ignoreCase = true) } ||
names.any { it.contains("Debug", ignoreCase = true) }
},
)
outputDirectory.set(layout.buildDirectory.dir("generated/sources/appBuildInfo/kotlin"))
}
kotlin { kotlin {
android { android {
namespace = "ru.fromchat.shared" namespace = "ru.fromchat.shared"
@@ -21,7 +78,6 @@ kotlin {
listOf( listOf(
iosArm64(), iosArm64(),
iosSimulatorArm64(), iosSimulatorArm64(),
iosX64(),
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
@@ -37,6 +93,10 @@ kotlin {
} }
} }
commonMain {
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
}
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@@ -94,6 +154,7 @@ kotlin {
implementation(libs.sqldelight.driver.android) implementation(libs.sqldelight.driver.android)
implementation(libs.livekit.android) implementation(libs.livekit.android)
implementation(libs.livekit.android.compose.components) implementation(libs.livekit.android.compose.components)
implementation(libs.androidx.webkit)
} }
iosMain.dependencies { iosMain.dependencies {
@@ -122,6 +183,7 @@ compose.resources {
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach { tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
dependsOn("generateResourceAccessorsForCommonMain") dependsOn("generateResourceAccessorsForCommonMain")
dependsOn(generateAppBuildInfo)
} }
tasks.register("generateResourceAccessors") { tasks.register("generateResourceAccessors") {
@@ -1,6 +1,7 @@
package ru.fromchat.api package ru.fromchat.api
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -13,19 +14,27 @@ import kotlin.coroutines.resumeWithException
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token" private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token" private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont -> private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { cont ->
FirebaseMessaging.getInstance().token addOnCompleteListener { task ->
.addOnCompleteListener { task: Task<String> ->
if (task.isSuccessful) { if (task.isSuccessful) {
cont.resume(task.result) cont.resume(task.result)
} else { } else {
cont.resumeWithException( cont.resumeWithException(
task.exception ?: IllegalStateException("Failed to fetch FCM token") task.exception ?: IllegalStateException("Firebase task failed"),
) )
} }
} }
} }
/** Registers with FCM and returns the Firebase Installation ID used for targeting. */
private suspend fun fetchCurrentFcmToken(): String? = runCatching {
FirebaseMessaging.getInstance().register().awaitResult()
FirebaseInstallations.getInstance().id.awaitResult()
}.getOrElse { e ->
Logger.e("FcmReg", "Failed to fetch FCM installation id: ${e.message}", e)
null
}
private suspend fun postFcmToken(token: String): Boolean { private suspend fun postFcmToken(token: String): Boolean {
return runCatching { return runCatching {
ApiClient.registerFcmToken(token) ApiClient.registerFcmToken(token)
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
outputFile.delete() outputFile.delete()
} }
val cipher = GCMBlockCipher.newInstance(AESEngine()) val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv)) cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES) val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
@@ -0,0 +1,27 @@
package ru.fromchat.notifications
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import ru.fromchat.Logger
actual object ChatNotificationDismissals {
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
@Volatile
private var appContext: Context? = null
fun install(context: Context) {
appContext = context.applicationContext
}
actual fun dismissAllMessageNotifications() {
val context = appContext ?: return
runCatching {
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
}.onFailure {
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
}
}
}
@@ -0,0 +1,14 @@
package ru.fromchat.notifications
import android.content.Context
/** White silhouette drawable for status-bar / notification small icons. */
object NotificationSmallIcon {
private const val DRAWABLE_NAME = "ic_stat_fromchat"
fun resId(context: Context): Int {
val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
return id
}
}
@@ -0,0 +1,246 @@
package ru.fromchat.ui.auth.captcha
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ru.fromchat.Logger
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun SmartCaptchaWebView(
sitekey: String,
languageTag: String,
modifier: Modifier,
onToken: (String) -> Unit,
onReady: () -> Unit,
onChallengeVisible: () -> Unit,
onChallengeHidden: () -> Unit,
onError: (String) -> Unit,
) {
val onTokenState = rememberUpdatedState(onToken)
val onReadyState = rememberUpdatedState(onReady)
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
val onErrorState = rememberUpdatedState(onError)
val lifecycleOwner = LocalLifecycleOwner.current
val backgroundArgb = MaterialTheme.colorScheme.surfaceContainer.toArgb()
var webView by remember { mutableStateOf<WebView?>(null) }
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val captchaUrl = remember(sitekey, lang) {
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
}
DisposableEffect(instanceId) {
Logger.i(
SmartCaptchaLog.TAG,
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
)
onDispose {
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
}
}
val bridge = remember {
val mainHandler = Handler(Looper.getMainLooper())
object {
@JavascriptInterface
fun onGetToken(token: String) {
val cleaned = token.trim()
Logger.i(
SmartCaptchaLog.TAG,
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
)
mainHandler.post {
if (cleaned.isNotEmpty()) {
onTokenState.value(cleaned)
} else {
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
onErrorState.value("")
}
}
}
@JavascriptInterface
fun onChallengeVisible() {
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
mainHandler.post { onChallengeVisibleState.value() }
}
@JavascriptInterface
fun onChallengeHidden() {
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
mainHandler.post { onChallengeHiddenState.value() }
}
}
}
DisposableEffect(webView, lifecycleOwner) {
val wv = webView ?: return@DisposableEffect onDispose { }
val observer = LifecycleEventObserver { _, event ->
Logger.d(
SmartCaptchaLog.TAG,
"lifecycle $event id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)} " +
"progress=${wv.progress}",
)
when (event) {
Lifecycle.Event.ON_PAUSE -> wv.onPause()
Lifecycle.Event.ON_RESUME -> wv.onResume()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
wv.onResume()
}
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
wv.onPause()
}
}
AndroidView(
factory = { context ->
Logger.i(SmartCaptchaLog.TAG, "AndroidView.factory id=$instanceId")
WebView(context).apply {
setBackgroundColor(backgroundArgb)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(bridge, "NativeClient")
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString()
val host = request?.url?.host?.lowercase().orEmpty()
val block = host.isNotEmpty() &&
!host.endsWith("yandex.ru") &&
!host.endsWith("yandex.com") &&
!host.endsWith("yandex.net")
Logger.d(
SmartCaptchaLog.TAG,
"shouldOverrideUrlLoading id=$instanceId block=$block " +
"host=$host url=${SmartCaptchaLog.shortUrl(url)}",
)
return block
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
Logger.i(
SmartCaptchaLog.TAG,
"onPageStarted id=$instanceId url=${SmartCaptchaLog.shortUrl(url)}",
)
}
override fun onPageFinished(view: WebView?, url: String?) {
Logger.i(
SmartCaptchaLog.TAG,
"onPageFinished id=$instanceId progress=${view?.progress} " +
"url=${SmartCaptchaLog.shortUrl(url)}",
)
Handler(Looper.getMainLooper()).post {
onReadyState.value()
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
Logger.w(
SmartCaptchaLog.TAG,
"onReceivedError id=$instanceId main=${request?.isForMainFrame} " +
"code=${error?.errorCode} desc=${error?.description} " +
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
)
if (request?.isForMainFrame == true) {
Handler(Looper.getMainLooper()).post {
onErrorState.value(error?.description?.toString().orEmpty())
}
}
}
override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?,
) {
Logger.w(
SmartCaptchaLog.TAG,
"onReceivedHttpError id=$instanceId main=${request?.isForMainFrame} " +
"status=${errorResponse?.statusCode} " +
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
)
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
Logger.e(
SmartCaptchaLog.TAG,
"onReceivedSslError id=$instanceId primary=${error?.primaryError} " +
"url=${SmartCaptchaLog.shortUrl(error?.url)}",
)
handler?.cancel()
Handler(Looper.getMainLooper()).post {
onErrorState.value("SSL error")
}
}
}
Logger.i(
SmartCaptchaLog.TAG,
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
)
loadUrl(captchaUrl)
webView = this
}
},
modifier = modifier.fillMaxSize(),
update = { wv ->
wv.setBackgroundColor(backgroundArgb)
webView = wv
},
onRelease = { wv ->
Logger.i(
SmartCaptchaLog.TAG,
"AndroidView.onRelease id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)}",
)
wv.removeJavascriptInterface("NativeClient")
wv.stopLoading()
wv.destroy()
if (webView === wv) webView = null
},
)
}
@@ -0,0 +1,706 @@
package ru.fromchat.ui.auth.yandex
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
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 LOADING_FADE_MS = 250
private const val LOADING_HIDE_DELAY_MS = 500L
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
/** View tag: last applied [darkTheme] so we do not re-set force-dark (that reloads the page). */
private const val TAG_APPLIED_DARK_THEME = 0x46C7_0A01
private fun shortUrl(url: String?): String {
if (url.isNullOrBlank()) return "null"
return if (url.length <= 120) url else url.take(117) + "..."
}
private const val FORCE_COLOR_SCHEME_JS = """
(function(scheme) {
try {
var meta = document.querySelector('meta[name="color-scheme"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'color-scheme';
(document.head || document.documentElement).appendChild(meta);
}
meta.content = scheme;
document.documentElement.style.colorScheme = scheme;
document.documentElement.setAttribute('data-theme', scheme);
document.documentElement.classList.toggle('theme_dark', scheme === 'dark');
document.documentElement.classList.toggle('Theme_color_dark', scheme === 'dark');
document.documentElement.classList.toggle('theme_light', scheme === 'light');
try { localStorage.setItem('color-scheme', scheme); } catch (e) {}
try { localStorage.setItem('theme', scheme); } catch (e) {}
} catch (e) {}
})
"""
private const val DISABLE_USER_SELECT_JS = """
(function() {
try {
var id = 'fromchat-no-select';
if (document.getElementById(id)) return;
var s = document.createElement('style');
s.id = id;
s.textContent = [
'*,*::before,*::after{',
'-webkit-user-select:none!important;',
'user-select:none!important;',
'-webkit-touch-callout:none!important;',
'}',
'input,textarea,select,[contenteditable],[contenteditable="true"],',
'input *,textarea *,[contenteditable] *,[contenteditable="true"] *{',
'-webkit-user-select:text!important;',
'user-select:text!important;',
'-webkit-touch-callout:default!important;',
'}'
].join('');
(document.head || document.documentElement).appendChild(s);
} catch (e) {}
})();
"""
/** Document scroll offset (not just window); >0 means the page start is off-screen. */
private const val PAGE_SCROLL_Y_JS = """
(function() {
var y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
var nodes = document.querySelectorAll('html,body,main,#root,#app,[data-scroll],.Scroll,.scroll,.passp-page');
for (var i = 0; i < nodes.length; i++) {
try { y = Math.max(y, nodes[i].scrollTop || 0); } catch (e) {}
}
return y;
})();
"""
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
actual fun YandexOAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
clearCookies: Boolean,
onPageBackgroundColor: (Color) -> Unit,
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
onCode: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
var webView by remember { mutableStateOf<WebView?>(null) }
var canGoBack by remember { mutableStateOf(false) }
var pageLoading by remember { mutableStateOf(true) }
var showLoadingOverlay by remember { mutableStateOf(true) }
var chromeColor by remember { mutableStateOf(fallbackColor) }
// Survives Activity recreate so the OAuth page is not reloaded from authorizeUrl.
val savedWebViewState = rememberSaveable { Bundle() }
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val scheme = if (darkTheme) "dark" else "light"
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
val onCodeState = rememberUpdatedState(onCode)
val onErrorState = rememberUpdatedState(onError)
val onCancelState = rememberUpdatedState(onCancel)
val lifecycleOwner = LocalLifecycleOwner.current
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
DisposableEffect(instanceId) {
Logger.i(
LOG_TAG,
"compose enter id=$instanceId darkTheme=$darkTheme lang=$lang " +
"savedBundleEmpty=${savedWebViewState.isEmpty} " +
"savedBundleSize=${savedWebViewState.size()} " +
"lifecycle=${lifecycleOwner.lifecycle.currentState} " +
"authorizeUrl=${shortUrl(authorizeUrl)}",
)
onDispose {
Logger.i(LOG_TAG, "compose dispose id=$instanceId")
}
}
fun applyChromeColor(color: Color) {
if (chromeColor == color) return
chromeColor = color
onPageBackgroundColorState.value(color)
webView?.setBackgroundColor(color.toArgb())
}
fun updateCanGoBack(value: Boolean) {
if (canGoBack != value) {
Logger.d(LOG_TAG, "canGoBack $canGoBack$value id=$instanceId")
canGoBack = value
onHistoryBackAvailabilityChangedState.value(value)
}
}
ApplyYandexWebViewSystemBars(
chromeColor = chromeColor,
darkTheme = darkTheme,
restoreSurfaceColor = fallbackColor,
)
LaunchedEffect(pageLoading) {
Logger.d(LOG_TAG, "pageLoading=$pageLoading → overlay scheduling id=$instanceId")
if (pageLoading) {
showLoadingOverlay = true
} else {
delay(LOADING_HIDE_DELAY_MS)
showLoadingOverlay = false
}
}
LaunchedEffect(webView, lifecycleOwner) {
val wv = webView ?: return@LaunchedEffect
// PixelCopy crashes if the window surface is gone (pause/stop). Only sample while resumed.
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
Logger.d(LOG_TAG, "color sample loop start id=$instanceId")
while (isActive) {
// Only sample when the document top is visible — not the current scrolled viewport.
if (pageScrollY(wv) <= 1.0) {
sampleTopRowColor(wv)?.let { applyChromeColor(it) }
}
delay(TOP_ROW_SAMPLE_INTERVAL_MS)
}
}
Logger.d(LOG_TAG, "color sample loop paused (not resumed) id=$instanceId")
}
DisposableEffect(webView, lifecycleOwner) {
val wv = webView
if (wv == null) {
return@DisposableEffect onDispose { }
}
val observer = LifecycleEventObserver { _, event ->
Logger.i(
LOG_TAG,
"lifecycle $event id=$instanceId wv=${Integer.toHexString(System.identityHashCode(wv))} " +
"url=${shortUrl(wv.url)} progress=${wv.progress} canGoBack=${wv.canGoBack()}",
)
when (event) {
Lifecycle.Event.ON_PAUSE -> wv.onPause()
Lifecycle.Event.ON_RESUME -> wv.onResume()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
wv.onResume()
}
onDispose {
Logger.d(LOG_TAG, "lifecycle observer dispose id=$instanceId")
lifecycleOwner.lifecycle.removeObserver(observer)
wv.onPause()
}
}
// Always consume predictive back here. If NavHost owns the gesture it scales this
// screen and WebView jumps scroll-to-top + leaves a gap under the content.
PredictiveBackHandler(
enabled = true,
onProgress = { },
onCommit = {
val wv = webView
if (wv != null && wv.canGoBack()) {
wv.goBack()
} else {
onCancelState.value()
}
},
onCancel = { },
)
val client = remember(authorizeUrl, lang, darkTheme) {
Logger.i(
LOG_TAG,
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
"authorizeUrl=${shortUrl(authorizeUrl)}",
)
object : WebViewClient() {
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
if (url.isNullOrBlank()) return false
if (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("")
}
return true
}
if (!isYandexAuthNavigation(url)) {
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
view?.context?.let { ctx ->
runCatching {
ctx.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
}
}
return true
}
return false
}
private fun applyPageChrome(view: WebView?) {
view ?: return
view.evaluateJavascript("$FORCE_COLOR_SCHEME_JS('$scheme');", null)
view.evaluateJavascript(DISABLE_USER_SELECT_JS, null)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
return handleSpecialUrl(view, url)
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
return handleSpecialUrl(view, url)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
Logger.i(
LOG_TAG,
"onPageStarted id=$instanceId url=${shortUrl(url)} " +
"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)) {
handleSpecialUrl(view, url)
}
pageLoading = true
applyPageChrome(view)
updateCanGoBack(view?.canGoBack() == true)
}
override fun onPageFinished(view: WebView?, url: String?) {
Logger.i(
LOG_TAG,
"onPageFinished id=$instanceId url=${shortUrl(url)} " +
"progress=${view?.progress} canGoBack=${view?.canGoBack()} " +
"historySize=${view?.copyBackForwardList()?.size}",
)
pageLoading = false
applyPageChrome(view)
updateCanGoBack(view?.canGoBack() == true)
}
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
Logger.d(
LOG_TAG,
"doUpdateVisitedHistory id=$instanceId isReload=$isReload url=${shortUrl(url)} " +
"canGoBack=${view?.canGoBack()}",
)
updateCanGoBack(view?.canGoBack() == true)
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(chromeColor),
) {
AndroidView(
factory = { context ->
val activity = context.findActivity() ?: context
Logger.i(
LOG_TAG,
"AndroidView.factory START id=$instanceId " +
"savedEmpty=${savedWebViewState.isEmpty} savedSize=${savedWebViewState.size()} " +
"darkTheme=$darkTheme clearCookies=$clearCookies " +
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
)
if (clearCookies) {
clearYandexWebViewCookies()
}
seedYandexThemeCookies(darkTheme)
WebView(activity).apply {
setBackgroundColor(fallbackColor.toArgb())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
applyYandexDarkSettingsIfNeeded(this, darkTheme)
webViewClient = client
webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
view: WebView?,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
val temp = WebView(activity).apply {
applyYandexDarkSettingsIfNeeded(this, darkTheme)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
v: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
if (!isYandexAuthNavigation(url)) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
)
}
} else {
view?.loadUrl(url)
}
return true
}
}
}
transport.webView = temp
resultMsg.sendToTarget()
return true
}
}
val hadSavedState = savedWebViewState.isEmpty.not()
val restoreList = if (hadSavedState) restoreState(savedWebViewState) else null
val historySize = copyBackForwardList().size
val restored = hadSavedState && restoreList != null && historySize > 0
Logger.i(
LOG_TAG,
"AndroidView.factory restore id=$instanceId hadSaved=$hadSavedState " +
"restoreListNull=${restoreList == null} historySize=$historySize " +
"restored=$restored wv=${Integer.toHexString(System.identityHashCode(this))}",
)
if (restored) {
pageLoading = false
showLoadingOverlay = false
updateCanGoBack(canGoBack())
} else {
savedWebViewState.clear()
Logger.w(
LOG_TAG,
"AndroidView.factory loadUrl (RESET) id=$instanceId url=${shortUrl(authorizeUrl)}",
)
loadUrl(
authorizeUrl,
mapOf("Accept-Language" to "$languageTag,$lang;q=0.9,en;q=0.8"),
)
}
webView = this
}
},
modifier = Modifier.fillMaxSize(),
update = { wv ->
val clientChanged = wv.webViewClient !== client
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
applyYandexDarkSettingsIfNeeded(wv, darkTheme)
if (clientChanged) {
Logger.i(
LOG_TAG,
"AndroidView.update reassign client id=$instanceId " +
"darkBefore=$darkBefore darkTheme=$darkTheme " +
"url=${shortUrl(wv.url)}",
)
wv.webViewClient = client
}
webView = wv
updateCanGoBack(wv.canGoBack())
},
onRelease = { wv ->
Logger.i(
LOG_TAG,
"AndroidView.onRelease id=$instanceId " +
"wv=${Integer.toHexString(System.identityHashCode(wv))} " +
"url=${shortUrl(wv.url)} historySize=${wv.copyBackForwardList().size} " +
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
)
savedWebViewState.clear()
wv.saveState(savedWebViewState)
Logger.i(
LOG_TAG,
"AndroidView.onRelease saved id=$instanceId " +
"bundleEmpty=${savedWebViewState.isEmpty} bundleSize=${savedWebViewState.size()}",
)
},
)
AnimatedVisibility(
visible = showLoadingOverlay,
enter = fadeIn(tween(0)),
exit = fadeOut(tween(LOADING_FADE_MS)),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor),
contentAlignment = Alignment.Center,
) {
LoadingIndicator()
}
}
}
}
@Composable
private fun ApplyYandexWebViewSystemBars(
chromeColor: Color,
darkTheme: Boolean,
restoreSurfaceColor: Color,
) {
val view = LocalView.current
val lightIcons = chromeColor.luminance() > 0.5f
val chromeArgb = chromeColor.toArgb()
val restoreArgb = restoreSurfaceColor.toArgb()
// Match page chrome while OAuth is open…
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
window.decorView.setBackgroundColor(chromeArgb)
@Suppress("DEPRECATION")
window.statusBarColor = chromeArgb
@Suppress("DEPRECATION")
window.navigationBarColor = chromeArgb
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightNavigationBars = lightIcons
isAppearanceLightStatusBars = lightIcons
}
}
// …and restore app theme bars when leaving (SideEffect alone won't re-run if theme deps unchanged).
DisposableEffect(darkTheme, restoreArgb) {
onDispose {
val window = (view.context as? Activity)?.window ?: return@onDispose
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
@Suppress("DEPRECATION")
window.statusBarColor = AndroidColor.TRANSPARENT
@Suppress("DEPRECATION")
window.navigationBarColor = AndroidColor.TRANSPARENT
window.decorView.setBackgroundColor(restoreArgb)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
}
}
}
private fun Context.findActivity(): Activity? {
var ctx: Context? = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
private fun clearYandexWebViewCookies() {
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.removeAllCookies(null)
cookieManager.flush()
Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
}
private fun seedYandexThemeCookies(darkTheme: Boolean) {
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=/")
cookieManager.setCookie(host, "yh=Theme=$theme; path=/")
}
cookieManager.flush()
}
/**
* Applies force-dark / algorithmic darkening only when [darkTheme] changed.
* 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) {
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
if (previous == darkTheme) return
Logger.w(
LOG_TAG,
"applyDarkSettings CHANGE previous=$previous$darkTheme " +
"wv=${Integer.toHexString(System.identityHashCode(webView))} url=${shortUrl(webView.url)}",
)
webView.setTag(TAG_APPLIED_DARK_THEME, darkTheme)
val settings = webView.settings
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, darkTheme)
}
if (darkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_ON)
} else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
}
}
private suspend fun pageScrollY(webView: WebView): Double =
suspendCancellableCoroutine { cont ->
webView.evaluateJavascript(PAGE_SCROLL_Y_JS) { raw ->
val value = raw?.trim()?.removeSurrounding("\"")?.toDoubleOrNull() ?: 0.0
cont.resume(value)
}
}
/**
* Samples the first visible pixel row of the WebView (mid-x).
* Caller must only invoke this when the document is scrolled to the page start
* and the activity is resumed (window still has a surface).
*/
private suspend fun sampleTopRowColor(webView: WebView): Color? {
if (webView.width <= 0 || webView.height <= 0) return null
val activity = webView.context.findActivity() ?: return null
if (activity is androidx.lifecycle.LifecycleOwner &&
!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
) {
return null
}
val window = activity.window ?: return null
val decor = window.decorView
if (!decor.isAttachedToWindow || decor.width <= 0 || decor.height <= 0) return null
val loc = IntArray(2)
webView.getLocationInWindow(loc)
val x = loc[0] + webView.width / 2
val y = loc[1]
if (x < 0 || y < 0) return null
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
val src = Rect(x, y, x + 1, y + 1)
return suspendCancellableCoroutine { cont ->
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
bitmap.recycle()
cont.resume(null)
return@suspendCancellableCoroutine
}
try {
PixelCopy.request(
window,
src,
bitmap,
{ result ->
if (result == PixelCopy.SUCCESS) {
val pixel = bitmap.getPixel(0, 0)
cont.resume(Color(pixel))
} else {
cont.resume(null)
}
bitmap.recycle()
},
Handler(Looper.getMainLooper()),
)
} catch (e: IllegalArgumentException) {
// e.g. "Window doesn't have a backing surface!" after ON_PAUSE/ON_STOP
Logger.w(LOG_TAG, "PixelCopy skipped: ${e.message}")
bitmap.recycle()
cont.resume(null)
}
}
}
/**
* 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 package ru.fromchat.ui.calls
import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.notifications.NotificationSmallIcon
/** /**
* Foreground call session: keeps camera / mic eligible in background. * Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel) ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService( val hangUpPi = PendingIntent.getService(
this, this,
RC_HANG_UP, RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build() .build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID) val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL) .setCategory(Notification.CATEGORY_CALL)
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
) )
nm.createNotificationChannel(ch) nm.createNotificationChannel(ch)
} }
val smallIcon = try {
context.packageManager.getApplicationInfo(context.packageName, 0).icon
} catch (_: Exception) {
R.drawable.stat_sys_upload
}
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID) return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle) .setContentTitle(updatedTitle)
.setContentText(updatedText) .setContentText(updatedText)
.setSmallIcon(smallIcon) .setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true) .setOngoing(true)
.build() .build()
} }
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Yandex “Я” mark only; circular brand background removed for tintable icons. -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M13.32,7.666h-0.924c-1.694,0 -2.585,0.858 -2.585,2.123c0,1.43 0.616,2.1 1.881,2.959l1.045,0.704l-3.003,4.487H7.49l2.695,-4.014c-1.55,-1.111 -2.42,-2.19 -2.42,-4.015c0,-2.288 1.595,-3.85 4.62,-3.85h3.003v11.868H13.32V7.666z" />
</vector>
@@ -5,7 +5,7 @@
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
<string name="home">Главная</string> <string name="home">Главная</string>
<string name="about">О приложении</string> <string name="about">О приложении</string>
<string name="about_version">Версия 1.0</string> <string name="about_version">Версия %1$s</string>
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string> <string name="about_link_website">Сайт</string>
@@ -31,6 +31,7 @@
<string name="display_name_error">От 1 до 64 символов</string> <string name="display_name_error">От 1 до 64 символов</string>
<string name="fill_all_fields">Заполните все поля</string> <string name="fill_all_fields">Заполните все поля</string>
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string> <string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
<string name="password_length_error">Пароль — от 5 до 50 символов</string> <string name="password_length_error">Пароль — от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string> <string name="passwords_dont_match">Пароли не совпадают</string>
<string name="auth_welcome_title">Добро пожаловать в FromChat</string> <string name="auth_welcome_title">Добро пожаловать в FromChat</string>
@@ -51,6 +52,15 @@
<string name="auth_step_password_body">Мы войдём в аккаунт или создадим новый.</string> <string name="auth_step_password_body">Мы войдём в аккаунт или создадим новый.</string>
<string name="auth_step_confirm_title">Подтвердите пароль</string> <string name="auth_step_confirm_title">Подтвердите пароль</string>
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string> <string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
<string name="auth_captcha_title">Быстрая проверка</string>
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
<string name="auth_yandex_webview_title">Яндекс ID</string>
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string> <string name="chats">Чаты</string>
<string name="contacts">Контакты</string> <string name="contacts">Контакты</string>
<string name="profile">Профиль</string> <string name="profile">Профиль</string>
@@ -163,6 +173,7 @@
<string name="profile_load_failed">Не получилось загрузить профиль</string> <string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string> <string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string> <string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
<string name="action_open_settings">Настройки</string> <string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string> <string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string> <string name="action_copy_link">Скопировать ссылку</string>
@@ -180,10 +191,28 @@
<string name="profile_headline_member_since">Дата регистрации</string> <string name="profile_headline_member_since">Дата регистрации</string>
<string name="profile_headline_bio">О себе</string> <string name="profile_headline_bio">О себе</string>
<string name="profile_headline_verification">Официальный аккаунт</string> <string name="profile_headline_verification">Официальный аккаунт</string>
<string name="profile_verified_support">Это официальный аккаунт</string> <string name="profile_verified_support">Этот аккаунт - официальное лицо FromChat.</string>
<string name="profile_verify_prompt_support">Нажмите, чтобы сделать аккаунт официальным (только админы)</string> <string name="profile_admin_actions_category">Действия администратора</string>
<string name="verify">Сделать официальным</string> <string name="profile_admin_verify">Подтвердить</string>
<string name="unverify">Снять официальный статус</string> <string name="profile_admin_unverify">Отменить подтверждение</string>
<string name="profile_admin_suspend">Заблокировать аккаунт</string>
<string name="profile_admin_unsuspend">Разблокировать аккаунт</string>
<string name="profile_admin_delete">Удалить аккаунт</string>
<string name="profile_admin_verify_confirm_title">Подтвердить аккаунт?</string>
<string name="profile_admin_verify_confirm_body">Отметить этот аккаунт как официальное лицо FromChat?</string>
<string name="profile_admin_unverify_confirm_title">Снять подтверждение?</string>
<string name="profile_admin_unverify_confirm_body">Убрать официальное подтверждение с этого аккаунта?</string>
<string name="profile_admin_suspend_confirm_title">Заблокировать аккаунт</string>
<string name="profile_admin_suspend_confirm_body">Укажите причину блокировки этого аккаунта:</string>
<string name="profile_admin_suspend_reason_label">Причина</string>
<string name="profile_admin_suspend_confirm">Заблокировать</string>
<string name="profile_admin_unsuspend_confirm_title">Разблокировать аккаунт?</string>
<string name="profile_admin_unsuspend_confirm_body">Вернуть этому аккаунту возможность отправлять сообщения?</string>
<string name="profile_admin_delete_confirm_title">Удалить аккаунт</string>
<string name="profile_admin_delete_confirm_body">Данные пользователя будут удалены навсегда, но сообщения и переписки сохранятся. Если пользователь в сети, он будет сразу разлогинен. Это действие нельзя отменить.</string>
<string name="profile_admin_delete_confirm">Удалить</string>
<string name="verify">Подтвердить</string>
<string name="unverify">Отменить подтверждение</string>
<string name="cd_verified_account">Официальный аккаунт</string> <string name="cd_verified_account">Официальный аккаунт</string>
<string name="cd_account_blocked">Аккаунт заблокирован</string> <string name="cd_account_blocked">Аккаунт заблокирован</string>
<string name="cd_similar_verified">Похож на официальный аккаунт</string> <string name="cd_similar_verified">Похож на официальный аккаунт</string>
@@ -400,6 +429,14 @@
<string name="settings_account_logout_confirm_body">Придётся войти снова.</string> <string name="settings_account_logout_confirm_body">Придётся войти снова.</string>
<string name="settings_account_delete">Удалить аккаунт</string> <string name="settings_account_delete">Удалить аккаунт</string>
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string> <string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
<string name="settings_account_change_yandex">Сменить Яндекс ID</string>
<string name="settings_account_change_yandex_d">Привязать другой аккаунт Яндекса</string>
<string name="settings_yandex_step_confirm_title">Сменить Яндекс ID?</string>
<string name="settings_yandex_step_confirm_body">Это не удаляет ваш аккаунт FromChat. Предыдущий Яндекс ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.</string>
<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_done">Готово</string>
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string> <string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string> <string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
<string name="settings_account_deleted">Аккаунт удалён</string> <string name="settings_account_deleted">Аккаунт удалён</string>
@@ -8,7 +8,7 @@
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="about">About</string> <string name="about">About</string>
<string name="about_version">Version 1.0</string> <string name="about_version">Version %1$s</string>
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string> <string name="about_link_website">Website</string>
@@ -38,6 +38,7 @@
<!-- Validation Errors --> <!-- Validation Errors -->
<string name="fill_all_fields">Please fill in every field</string> <string name="fill_all_fields">Please fill in every field</string>
<string name="username_length_error">Username must be 3 to 20 characters</string> <string name="username_length_error">Username must be 3 to 20 characters</string>
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
<string name="password_length_error">Password must be 5 to 50 characters</string> <string name="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">The two passwords dont match</string> <string name="passwords_dont_match">The two passwords dont match</string>
<string name="auth_welcome_title">Welcome to FromChat</string> <string name="auth_welcome_title">Welcome to FromChat</string>
@@ -58,6 +59,15 @@
<string name="auth_step_password_body">We will sign you in or create a new account.</string> <string name="auth_step_password_body">We will sign you in or create a new account.</string>
<string name="auth_step_confirm_title">Confirm your password</string> <string name="auth_step_confirm_title">Confirm your password</string>
<string name="auth_step_confirm_body">Enter the same password again.</string> <string name="auth_step_confirm_body">Enter the same password again.</string>
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we dont store it; sign-in is only used for security reasons.</string>
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
<string name="auth_captcha_title">Quick check</string>
<string name="auth_captcha_body">Confirm youre human to continue creating your account.</string>
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
<string name="auth_yandex_webview_title">Yandex ID</string>
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
<!-- Main Screen --> <!-- Main Screen -->
<string name="chats">Chats</string> <string name="chats">Chats</string>
<string name="contacts">Contacts</string> <string name="contacts">Contacts</string>
@@ -181,6 +191,7 @@
<string name="profile_load_failed">Couldnt load this profile</string> <string name="profile_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</string> <string name="profile_not_found">This profile could not be found</string>
<string name="profile_open_failed">Could not open this profile. Please try again.</string> <string name="profile_open_failed">Could not open this profile. Please try again.</string>
<string name="profile_invalid_link">Couldnt open the link</string>
<string name="action_open_settings">Settings</string> <string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string> <string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</string> <string name="action_copy_link">Copy link</string>
@@ -198,8 +209,28 @@
<string name="profile_headline_member_since">Joined</string> <string name="profile_headline_member_since">Joined</string>
<string name="profile_headline_bio">About</string> <string name="profile_headline_bio">About</string>
<string name="profile_headline_verification">Verified account</string> <string name="profile_headline_verification">Verified account</string>
<string name="profile_verified_support">This account is verified</string> <string name="profile_verified_support">This account is an official FromChat representative.</string>
<string name="profile_verify_prompt_support">Tap to verify (admins only)</string>
<!-- Admin actions (owner / user id 1) -->
<string name="profile_admin_actions_category">Admin actions</string>
<string name="profile_admin_verify">Verify</string>
<string name="profile_admin_unverify">Remove verification</string>
<string name="profile_admin_suspend">Suspend account</string>
<string name="profile_admin_unsuspend">Unsuspend account</string>
<string name="profile_admin_delete">Delete account</string>
<string name="profile_admin_verify_confirm_title">Verify account?</string>
<string name="profile_admin_verify_confirm_body">Mark this account as an official FromChat representative?</string>
<string name="profile_admin_unverify_confirm_title">Remove verification?</string>
<string name="profile_admin_unverify_confirm_body">Remove official verification from this account?</string>
<string name="profile_admin_suspend_confirm_title">Suspend account</string>
<string name="profile_admin_suspend_confirm_body">Enter the reason for suspending this account:</string>
<string name="profile_admin_suspend_reason_label">Reason</string>
<string name="profile_admin_suspend_confirm">Suspend</string>
<string name="profile_admin_unsuspend_confirm_title">Unsuspend account?</string>
<string name="profile_admin_unsuspend_confirm_body">Restore this accounts ability to send messages?</string>
<string name="profile_admin_delete_confirm_title">Delete account</string>
<string name="profile_admin_delete_confirm_body">This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.</string>
<string name="profile_admin_delete_confirm">Delete</string>
<!-- Verify (admin) --> <!-- Verify (admin) -->
<string name="verify">Verify</string> <string name="verify">Verify</string>
@@ -426,6 +457,14 @@
<string name="settings_account_logout_confirm_body">You will need to sign in again.</string> <string name="settings_account_logout_confirm_body">You will need to sign in again.</string>
<string name="settings_account_delete">Delete account</string> <string name="settings_account_delete">Delete account</string>
<string name="settings_account_delete_d">Permanently delete your account and data</string> <string name="settings_account_delete_d">Permanently delete your account and data</string>
<string name="settings_account_change_yandex">Change Yandex ID</string>
<string name="settings_account_change_yandex_d">Link a different Yandex account</string>
<string name="settings_yandex_step_confirm_title">Change Yandex ID?</string>
<string name="settings_yandex_step_confirm_body">This does not delete your FromChat account. Your previous Yandex ID will be freed, and the new one you sign in with will be linked instead.</string>
<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_done">Done</string>
<string name="settings_account_delete_confirm_title">Delete account?</string> <string name="settings_account_delete_confirm_title">Delete account?</string>
<string name="settings_account_delete_confirm_body">This cannot be undone.</string> <string name="settings_account_delete_confirm_body">This cannot be undone.</string>
<string name="settings_account_deleted">Account deleted</string> <string name="settings_account_deleted">Account deleted</string>
@@ -44,7 +44,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient.logout import ru.fromchat.api.ApiClient.logout
import ru.fromchat.api.ApiClient.persistSessionToStorage import ru.fromchat.api.ApiClient.persistSessionToStorage
import ru.fromchat.api.crypto.IdentityKeyManager import ru.fromchat.api.crypto.IdentityKeyManager
@@ -68,6 +73,7 @@ import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.schema.messages.MarkReadRequest import ru.fromchat.api.schema.messages.MarkReadRequest
import ru.fromchat.api.schema.messages.MessagesResponse import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmArchiveRequest
import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
@@ -98,16 +104,26 @@ import ru.fromchat.api.schema.user.FcmTokenRequest
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.UsersSearchResponse import ru.fromchat.api.schema.user.UsersSearchResponse
import ru.fromchat.api.schema.user.VerifyPasswordRequest 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.AccountYandexResponse
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.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterRequest import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
import ru.fromchat.api.schema.user.devices.DevicesListResponse import ru.fromchat.api.schema.user.devices.DevicesListResponse
import ru.fromchat.api.schema.user.keys.BackupBlobRequest import ru.fromchat.api.schema.user.keys.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.api.schema.user.profile.SuspendUserRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
@@ -256,7 +272,7 @@ object ApiClient {
} }
if (response.status.value == 401) { if (response.status.value == 401) {
val path = response.call.request.url.encodedPath val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") val isCredentialCheck = path.endsWith("/auth/steps/password") || path.endsWith("/auth/steps/register/confirm")
if (!isCredentialCheck && !logoutInProgress) { if (!isCredentialCheck && !logoutInProgress) {
MainScope().launch { MainScope().launch {
runCatching { WebSocketManager.disconnect() } runCatching { WebSocketManager.disconnect() }
@@ -467,17 +483,73 @@ object ApiClient {
} }
suspend fun loginRequest(request: LoginRequest): LoginResponse = suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
http httpProbe
.post("${ServerConfig.apiBaseUrl}/login") { .post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(AuthUsernameStepRequest(username = username.trim()))
} }
.body() .body()
suspend fun registerRequest(request: RegisterRequest): LoginResponse = sealed interface AuthPasswordStepOutcome {
http data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
.post("${ServerConfig.apiBaseUrl}/register") { data class NeedsRegister(
val yandexRequired: Boolean,
val yandex: YandexOAuthParams?,
val captchaRequired: Boolean,
val captcha: SmartCaptchaParams?,
) : AuthPasswordStepOutcome
}
suspend fun authPasswordStep(username: String, passwordDerived: String): AuthPasswordStepOutcome {
val raw = httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/password") {
contentType(ContentType.Application.Json)
setBody(
AuthPasswordStepRequest(
username = username.trim(),
password = passwordDerived,
),
)
}
.body<JsonObject>()
val status = raw["status"]?.jsonPrimitive?.contentOrNull
return when (status) {
"needs_register" -> {
val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
val captchaRequired = raw["captcha_required"]?.jsonPrimitive?.booleanOrNull == true
val captcha = raw["captcha"]?.let {
json.decodeFromJsonElement(SmartCaptchaParams.serializer(), it)
}
ru.fromchat.Logger.i(
"SmartCaptcha",
"authPasswordStep needs_register yandexRequired=$yandexRequired " +
"captchaRequired=$captchaRequired " +
"hasCaptchaObject=${captcha != null} " +
"clientKeyLen=${captcha?.client_key?.length ?: 0}",
)
AuthPasswordStepOutcome.NeedsRegister(
yandexRequired = yandexRequired,
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
captchaRequired = captchaRequired,
captcha = captcha,
)
}
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
}
}
suspend fun authYandexExchange(code: String, codeVerifier: String): YandexExchangeResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/yandex/exchange") {
contentType(ContentType.Application.Json)
setBody(YandexExchangeRequest(code = code, code_verifier = codeVerifier))
}
.body()
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -557,12 +629,18 @@ object ApiClient {
} }
.body() .body()
suspend fun getProfileById(userId: Int): UserProfile = suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
http if (!force) {
ProfileCache.get(userId)?.takeIf {
!it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
}?.let { return it }
}
return http
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") { .get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.body() .body()
}
suspend fun getProfileByUsername(username: String): UserProfile = suspend fun getProfileByUsername(username: String): UserProfile =
http http
@@ -637,6 +715,34 @@ object ApiClient {
.body<VerifyResponse>() .body<VerifyResponse>()
}.getOrNull() }.getOrNull()
suspend fun suspendUser(userId: Int, reason: String): SimpleStatusResponse? =
runCatching {
http
.post("${ServerConfig.apiBaseUrl}/user/$userId/suspend") {
contentType(ContentType.Application.Json)
setBody(SuspendUserRequest(reason = reason))
}
.body<SimpleStatusResponse>()
}.getOrNull()
suspend fun unsuspendUser(userId: Int): SimpleStatusResponse? =
runCatching {
http
.post("${ServerConfig.apiBaseUrl}/user/$userId/unsuspend") {
contentType(ContentType.Application.Json)
}
.body<SimpleStatusResponse>()
}.getOrNull()
suspend fun adminDeleteUser(userId: Int): SimpleStatusResponse? =
runCatching {
http
.post("${ServerConfig.apiBaseUrl}/user/$userId/delete") {
contentType(ContentType.Application.Json)
}
.body<SimpleStatusResponse>()
}.getOrNull()
suspend fun getDmConversations(): List<DmConversation> = suspend fun getDmConversations(): List<DmConversation> =
http http
.get("${ServerConfig.apiBaseUrl}/dm/conversations") { .get("${ServerConfig.apiBaseUrl}/dm/conversations") {
@@ -652,6 +758,13 @@ object ApiClient {
} }
} }
suspend fun archiveDmConversation(otherUserId: Int, archived: Boolean = true) {
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/archive") {
contentType(ContentType.Application.Json)
setBody(DmArchiveRequest(archived = archived))
}
}
suspend fun searchUsers(query: String): List<User> { suspend fun searchUsers(query: String): List<User> {
val trimmed = query.trim() val trimmed = query.trim()
if (trimmed.length < 2) return emptyList() if (trimmed.length < 2) return emptyList()
@@ -1435,6 +1548,19 @@ object ApiClient {
} }
} }
suspend fun getAccountYandex(): AccountYandexResponse =
http
.get("${ServerConfig.apiBaseUrl}/account/yandex")
.body()
suspend fun changeAccountYandex(registrationProof: String): ChangeYandexResponse =
http
.post("${ServerConfig.apiBaseUrl}/account/yandex") {
contentType(ContentType.Application.Json)
setBody(ChangeYandexRequest(registration_proof = registrationProof))
}
.body()
suspend fun verifyPasswordDerived(passwordDerived: String) { suspend fun verifyPasswordDerived(passwordDerived: String) {
http.post("${ServerConfig.apiBaseUrl}/verify-password") { http.post("${ServerConfig.apiBaseUrl}/verify-password") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
@@ -1589,9 +1715,7 @@ object ApiClient {
suspend fun deleteMessage(messageId: Int) { suspend fun deleteMessage(messageId: Int) {
if (_suspensionState.value.isSuspended) return if (_suspensionState.value.isSuspended) return
runCatching { // WS-only (like Web): HTTP-first would hard-delete before WS and skip messageDeleted broadcast.
http.delete("${ServerConfig.apiBaseUrl}/delete_message/$messageId")
}
WebSocketManager.send( WebSocketManager.send(
WebSocketMessage( WebSocketMessage(
type = "deleteMessage", type = "deleteMessage",
@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.serialization.json.JsonElement import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -16,14 +16,14 @@ import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.PublicInboxCoordinator
import ru.fromchat.api.local.messages.parseMessageTimestampMillis import ru.fromchat.api.local.messages.parseMessageTimestampMillis
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/** /**
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat * Keeps the chats tab list in sync: DM conversations from the server and public-chat
* message for list previews (without opening each chat first). * previews (cache is filled by the updates pipeline / history rebuild).
*/ */
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
object ChatListSync { object ChatListSync {
@@ -58,7 +58,14 @@ object ChatListSync {
suspend fun syncFromNetwork() { suspend fun syncFromNetwork() {
if (!canSync()) return if (!canSync()) return
syncDmConversations() syncDmConversations()
syncPublicChatPreview() // Preview only — never the sole catch-up path for missed messages.
refreshPublicChatPreviewFromLatest()
}
/** Used by tooLong rebuild before per-chat history fetch. */
suspend fun syncDmConversationsForRebuild() {
if (!canSync()) return
syncDmConversations()
} }
private fun canSync(): Boolean { private fun canSync(): Boolean {
@@ -78,13 +85,56 @@ object ChatListSync {
} }
} }
private suspend fun syncPublicChatPreview() { private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching { runCatching {
val response = ApiClient.getMessages(limit = 1) val cached = MessageRepository.loadPublicMessages()
val latest = response.messages.maxByOrNull { message -> val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
val latestResponse = ApiClient.getMessages(limit = 1)
val latest = latestResponse.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching } ?: return@runCatching
val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
val holeBelowLatest =
latest.id > 0 &&
maxCachedId > 0 &&
latest.id > maxCachedId + 1
val latestMissingWithPriorCache =
latest.id > 0 &&
latest.id !in cachedIds &&
maxCachedId > 0 &&
latest.id > maxCachedId
if (holeBelowLatest || latestMissingWithPriorCache) {
// Preview-only upsert would leave first+last holes; pull a page and merge.
Logger.i(
"ChatListSync",
"Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
"cachedCount=${cached.size} — fetching page to fill",
)
val page = ApiClient.getMessages(limit = 50)
val networkMessages = page.messages
if (networkMessages.isEmpty()) {
MessageRepository.upsertPublicMessage(latest) 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)
}
} }
} }
@@ -100,16 +150,14 @@ object ChatListSync {
} }
} }
"newMessage" -> message.data?.let { element -> "newMessage" -> message.data?.let { element ->
scope.launch { ingestPublicMessage(element) } scope.launch { PublicInboxCoordinator.processNew(element) }
}
"messageEdited" -> message.data?.let { element ->
scope.launch { PublicInboxCoordinator.processEdited(element) }
}
"messageDeleted" -> message.data?.let { element ->
scope.launch { PublicInboxCoordinator.processDeleted(element) }
} }
} }
} }
private suspend fun ingestPublicMessage(element: JsonElement) {
val newMsg = runCatching {
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
}.getOrNull() ?: return
ProfileCache.mergePreviewFromPublicMessage(newMsg)
MessageRepository.upsertPublicMessage(newMsg)
}
} }
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return val data = message.data ?: return
val updates = runCatching { val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return }.getOrNull() ?: run {
Logger.w("ProfileUpdateSync", "updates batch decode failed")
return
}
val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
if (profileUpdates > 0) {
Logger.d(
"ProfileUpdateSync",
"updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
)
}
updates.updates.forEach { update -> updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data)) handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
} }
} }
"profileUpdate" -> { "profileUpdate" -> {
val payload = message.data ?: return val payload = message.data ?: run {
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
return
}
onProfileUpdatePayload(payload) onProfileUpdatePayload(payload)
} }
} }
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d( Logger.d(
"ProfileUpdateSync", "ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " + "profileUpdate id=${profile.id} username='${profile.username}' " +
"bio='${profile.bio?.take(48)}'", "deleted=${profile.deleted} suspended=${profile.suspended} " +
"bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
) )
val hadCached = ProfileCache.get(profile.id)
ProfileCache.applyServerProfile(profile, force = true) ProfileCache.applyServerProfile(profile, force = true)
val after = ProfileCache.get(profile.id)
Logger.d(
"ProfileUpdateSync",
"profileUpdate applied id=${profile.id} " +
"wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
"revisionAfter=${ProfileCache.revision.value}",
)
UserStatusStore.update(profile.id, profile.online, profile.lastSeen) UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) { if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
} }
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) } runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
.onFailure {
Logger.w(
"ProfileUpdateSync",
"patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
it,
)
}
.onSuccess {
Logger.d(
"ProfileUpdateSync",
"patchDmConversationPeerProfile done id=${profile.id}",
)
}
} }
private fun parseProfileUpdate(data: JsonElement): UserProfile? { private fun parseProfileUpdate(data: JsonElement): UserProfile? {
@@ -1,28 +1,40 @@
package ru.fromchat.api package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.DmInboundMessageProcessor
import ru.fromchat.api.local.messages.UpdatesBatchApplier
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketCredentials import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
* Tracks the last seen WebSocket update sequence for the current user and * Per-device update cursor: advance + ack only after batches are applied successfully.
* persists it between sessions so we can ask the backend for missed updates.
*/ */
object UpdateSyncManager { object UpdateSyncManager {
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_" private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
private const val HISTORY_PAGE_SIZE = 50
private const val MAX_HISTORY_PAGES = 20
private val _lastSeq = MutableStateFlow(0) private val _lastSeq = MutableStateFlow(0)
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow() val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
@@ -30,6 +42,8 @@ object UpdateSyncManager {
private val _lastMissedCount = MutableStateFlow<Int?>(null) private val _lastMissedCount = MutableStateFlow<Int?>(null)
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow() val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
private val applyMutex = Mutex()
@Volatile @Volatile
private var gapDetectionInProgress: Boolean = false private var gapDetectionInProgress: Boolean = false
@@ -42,22 +56,26 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null) ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
} }
@OptIn(DelicateCoroutinesApi::class) /**
fun onUpdatesBatch(seq: Int) { * Apply a live or replayed updates envelope, then advance cursor and ack the server.
if (seq <= 0) return *
val currentUserId = ApiClient.user?.id ?: return * Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
val newSeq = seq.coerceAtLeast(_lastSeq.value) * receive loop (it would deadlock the ack response cannot be read while this call blocks).
if (newSeq == _lastSeq.value) return */
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
_lastSeq.value = newSeq applyMutex.withLock {
ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value) Logger.d("UpdateSync", "onUpdatesEnvelope begin")
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
GlobalScope.launch(Dispatchers.Default) { return@withLock
runCatching { }
settings.putInt(key, newSeq) Logger.d(
}.onFailure { "UpdateSync",
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it) "onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
)
if (seq > _lastSeq.value) {
persistLastSeq(seq)
sendAckFireAndForget(seq)
} }
} }
} }
@@ -81,9 +99,12 @@ object UpdateSyncManager {
} }
/** /**
* Ask the backend for missed updates between our lastSeq and the current sequence. * Catch up from [lastSeq]: chunked getUpdates, or tooLong history rebuild.
* This call is idempotent while in progress and will no-op if there is no active * Does not advance the cursor until apply/rebuild succeeds.
* WebSocket session or no authenticated user. *
* Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
* (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
* is not left with first+last holes filled only by slow incremental envelopes.
*/ */
suspend fun runGapDetectionIfNeeded() { suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) { if (gapDetectionInProgress) {
@@ -98,52 +119,219 @@ object UpdateSyncManager {
} }
gapDetectionInProgress = true gapDetectionInProgress = true
val startSeq = _lastSeq.value ConnectionStateStore.onUpdating(start = true)
try { try {
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq") var rounds = 0
if (startSeq > 0) { var consecutiveFailures = 0
ConnectionStateStore.onUpdating(start = true) while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
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 requestMessage = WebSocketMessage( val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
type = "getUpdates",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = ApiClient.json.encodeToJsonElement(
GetUpdatesRequest.serializer(),
GetUpdatesRequest(lastSeq = startSeq)
)
)
val response = WebSocketManager.request(requestMessage)
val data = response?.data
if (data != null) {
runCatching {
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
Logger.i( Logger.i(
"UpdateSyncManager", "UpdateSyncManager",
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}" "Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}, " +
"gapHint=$gapHint clientSeq=$startSeq",
) )
onUpdatesBatch(parsed.lastSeq) updateMissedCount(response.missedCount)
updateMissedCount(parsed.missedCount)
}.onFailure { when (response.status) {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it) "tooLong" -> {
} val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAckFireAndForget(response.lastSeq)
} else { } else {
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op") 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)
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
}
}
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
break
}
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t) Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally { } finally {
if (startSeq > 0) {
ConnectionStateStore.onUpdating(start = false) ConnectionStateStore.onUpdating(start = false)
}
gapDetectionInProgress = false gapDetectionInProgress = false
} }
} }
}
private suspend fun requestGetUpdates(token: String, lastSeq: Int): GetUpdatesResponse? {
val requestMessage = WebSocketMessage(
type = "getUpdates",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token,
),
data = ApiClient.json.encodeToJsonElement(
GetUpdatesRequest.serializer(),
GetUpdatesRequest(lastSeq = lastSeq),
),
)
val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null
return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
}.getOrNull()
}
/**
* 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.send(
WebSocketMessage(
type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
data = ApiClient.json.encodeToJsonElement(
AckUpdatesRequest.serializer(),
AckUpdatesRequest(lastSeq = seq),
),
),
)
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
}
}
private suspend fun persistLastSeq(seq: Int) {
val currentUserId = ApiClient.user?.id ?: return
if (seq <= _lastSeq.value) return
_lastSeq.value = seq
ConnectionStateStore.updateSeqAndMissed(lastSeq = seq, missedCount = _lastMissedCount.value)
withContext(Dispatchers.Default) {
runCatching {
settings.putInt(KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId, seq)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$seq: ${it.message}", it)
}
}
}
suspend fun rebuildStateFromHistory(): Boolean = withContext(Dispatchers.Default) {
Logger.i("UpdateSyncManager", "Rebuilding local state from history (tooLong)")
runCatching {
ChatListSync.syncDmConversationsForRebuild()
rebuildPublicHistory()
rebuildDmHistories()
true
}.onFailure {
Logger.w("UpdateSyncManager", "rebuildStateFromHistory failed: ${it.message}", it)
}.getOrDefault(false)
}
private suspend fun rebuildPublicHistory() {
val collected = LinkedHashMap<Int, Message>()
var beforeId: Int? = null
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getMessages(limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
page.messages.forEach { msg ->
ProfileCache.mergePreviewFromPublicMessage(msg)
collected[msg.id] = msg
}
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L
}
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)
}
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
}
}
}
@@ -3,11 +3,12 @@ package ru.fromchat.api.instance
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.config.ServerConfigData
import ru.fromchat.legal.DocumentRepository import ru.fromchat.legal.DocumentRepository
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource import kotlin.time.TimeSource
sealed interface ServerProbeResult { sealed interface ServerProbeResult {
@@ -51,20 +52,26 @@ fun Throwable.isSslProtocolError(): Boolean {
return false return false
} }
suspend fun probeCallsReachable(config: ServerConfigData): Boolean { suspend fun probeCallsReachable(config: ServerConfigData) = runCatching {
val urlScheme = if (config.httpsEnabled) "https" else "http" withTimeout(CALLS_PROBE_MS.milliseconds) {
val host = config.serverIp.trim() ApiClient.probeHttpGet(
val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host } buildString {
// Caddy rewrites this to LiveKit "/" (plain "OK"). GET /rtc is always 404 without a WS upgrade. append(if (config.httpsEnabled) "https" else "http")
val url = "$urlScheme://$authorityHost:${config.apiPort}/livekit-health" append("://")
return runCatching { append(
withTimeout(CALLS_PROBE_MS) { config.serverIp.trim().let {
ApiClient.probeHttpGet(url) it.removePrefix("[").removeSuffix("]").ifEmpty { it }
} }
}.getOrDefault(false) )
} append(":")
append(config.apiPort)
append("/livekit-health")
}
)
}
}.getOrDefault(false)
suspend fun probeServer(config: ServerConfigData): ServerProbeResult = suspend fun probeServer(config: ServerConfigData) =
probeServerEndpoint( probeServerEndpoint(
host = config.serverIp, host = config.serverIp,
port = config.apiPort, port = config.apiPort,
@@ -82,44 +89,51 @@ suspend fun probeServerEndpoint(
httpsPreferred: Boolean, httpsPreferred: Boolean,
schemeExplicit: Boolean, schemeExplicit: Boolean,
): Pair<ServerConfigData, ServerProbeResult> { ): Pair<ServerConfigData, ServerProbeResult> {
val schemes = when {
schemeExplicit -> listOf(httpsPreferred)
else -> listOf(true, false)
}
var lastConfig = ServerConfigData( var lastConfig = ServerConfigData(
serverIp = host, serverIp = host,
apiPort = port, apiPort = port,
callsPort = port, callsPort = port,
httpsEnabled = httpsPreferred, httpsEnabled = httpsPreferred,
) )
var lastResult: ServerProbeResult = ServerProbeResult.Unreachable var lastResult: ServerProbeResult = ServerProbeResult.Unreachable
for ((index, https) in schemes.withIndex()) {
when {
schemeExplicit -> listOf(httpsPreferred)
else -> listOf(true, false)
}.apply {
forEachIndexed { index, https ->
val config = ServerConfigData( val config = ServerConfigData(
serverIp = host, serverIp = host,
apiPort = port, apiPort = port,
callsPort = port, callsPort = port,
httpsEnabled = https, httpsEnabled = https,
) )
lastConfig = config lastConfig = config
val (result, failure) = probeServerOnce(config) val (result, failure) = probeServerOnce(config)
lastResult = result lastResult = result
when (result) { when (result) {
is ServerProbeResult.Supported, is ServerProbeResult.Supported,
ServerProbeResult.Unsupported, ServerProbeResult.Unsupported -> return config to result
-> return config to result
ServerProbeResult.Timeout, ServerProbeResult.Timeout,
ServerProbeResult.Unreachable, ServerProbeResult.Unreachable -> {
-> { if (!(!schemeExplicit && https && index < lastIndex)) return config to result
val hasHttpFallback = !schemeExplicit && https && index < schemes.lastIndex
if (!hasHttpFallback) return config to result
// Browser-like: only fall back to HTTP after a TLS/protocol failure. // Browser-like: only fall back to HTTP after a TLS/protocol failure.
if (failure?.isSslProtocolError() == true) continue if (failure?.isSslProtocolError() == true)
if (result is ServerProbeResult.Timeout) return config to result return@forEachIndexed
if (result is ServerProbeResult.Timeout)
return config to result
// Non-SSL Unreachable (e.g. LAN cleartext / connection refused on 443): try HTTP. // Non-SSL Unreachable (e.g. LAN cleartext / connection refused on 443): try HTTP.
continue return@forEachIndexed
} }
} }
} }
}
return lastConfig to lastResult return lastConfig to lastResult
} }
@@ -127,17 +141,21 @@ private suspend fun probeServerOnce(
config: ServerConfigData, config: ServerConfigData,
): Pair<ServerProbeResult, Throwable?> { ): Pair<ServerProbeResult, Throwable?> {
val apiBase = apiBaseUrlFor(config) val apiBase = apiBaseUrlFor(config)
val mark = TimeSource.Monotonic.markNow() val time = TimeSource.Monotonic.markNow()
InstanceIdGuard.probeConfig = config InstanceIdGuard.probeConfig = config
try { try {
val pingMs = time.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0)
return ServerProbeResult.Supported(
when (
val resolve = resolveInstanceId( val resolve = resolveInstanceId(
config = config, config = config,
apiBaseUrl = apiBase, apiBaseUrl = apiBase,
forceNetwork = true, forceNetwork = true,
allowCachedOnFailure = false, allowCachedOnFailure = false,
) )
val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0) ) {
val instanceId = when (resolve) {
is InstanceIdResolveResult.Cached -> resolve.instanceId is InstanceIdResolveResult.Cached -> resolve.instanceId
is InstanceIdResolveResult.Fetched -> resolve.instanceId is InstanceIdResolveResult.Fetched -> resolve.instanceId
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
@@ -151,9 +169,10 @@ private suspend fun probeServerOnce(
}.exceptionOrNull() }.exceptionOrNull()
return ServerProbeResult.Unreachable to failure return ServerProbeResult.Unreachable to failure
} }
} },
val callsOk = probeCallsReachable(config) probeCallsReachable(config),
return ServerProbeResult.Supported(instanceId, callsOk, pingMs) to null pingMs
) to null
} finally { } finally {
InstanceIdGuard.probeConfig = null InstanceIdGuard.probeConfig = null
} }
@@ -182,13 +201,15 @@ suspend fun applyServerAndNavigate(
): ApplyServerResult { ): ApplyServerResult {
val apiBase = apiBaseUrlFor(config) val apiBase = apiBaseUrlFor(config)
val token = bearer.trim() val token = bearer.trim()
if (token.isEmpty()) { if (token.isEmpty()) {
applyServerConfig(config, probe.instanceId, probe.callsOk) applyServerConfig(config, probe.instanceId, probe.callsOk)
WebSocketManager.disconnect() WebSocketManager.disconnect()
onNavigateLogin() onNavigateLogin()
return ApplyServerResult.Applied return ApplyServerResult.Applied
} }
when (val auth = ApiClient.checkAuthAt(apiBase, token)) {
when (ApiClient.checkAuthAt(apiBase, token)) {
ApiClient.CheckAuthResult.Authenticated -> { ApiClient.CheckAuthResult.Authenticated -> {
applyServerConfig(config, probe.instanceId, probe.callsOk) applyServerConfig(config, probe.instanceId, probe.callsOk)
WebSocketManager.disconnect() WebSocketManager.disconnect()
@@ -196,9 +217,11 @@ suspend fun applyServerAndNavigate(
onNavigateChat() onNavigateChat()
return ApplyServerResult.Applied return ApplyServerResult.Applied
} }
ApiClient.CheckAuthResult.Unreachable -> { ApiClient.CheckAuthResult.Unreachable -> {
return ApplyServerResult.ServerUnreachable return ApplyServerResult.ServerUnreachable
} }
ApiClient.CheckAuthResult.NotAuthenticated -> { ApiClient.CheckAuthResult.NotAuthenticated -> {
onLogoutOldHost() onLogoutOldHost()
applyServerConfig(config, probe.instanceId, probe.callsOk) applyServerConfig(config, probe.instanceId, probe.callsOk)
@@ -38,7 +38,6 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.websocket.WebSocketCredentials import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Clock import kotlin.time.Clock
@@ -234,12 +233,11 @@ object WebSocketManager {
val msg = when (messageType) { val msg = when (messageType) {
"updates" -> { "updates" -> {
// Apply add/edit/delete before advancing; ack happens inside.
runCatching { runCatching {
val updatesData = json.decodeFromJsonElement( UpdateSyncManager.onUpdatesEnvelope(jsonTree)
WebSocketUpdatesData.serializer(), jsonTree)
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
}.onFailure { }.onFailure {
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it) logW("Failed to apply updates envelope: ${it.message}", it)
} }
WebSocketMessage( WebSocketMessage(
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.local.messages.ChatListPreviewState
@@ -167,8 +168,12 @@ object MessageCacheStore {
} }
} }
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
Logger.d(
"MessageCache",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
)
val resolved = messages.map { it.resolvePublicAttachmentLayout() } val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved) ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged) purgeSupersededPendingRows(iid, convId, before, merged)
} }
replaceMessages(convId, merged) replaceMessages(convId, merged, replaceAll = replaceAll)
} }
suspend fun clearPublicMessages() { suspend fun clearPublicMessages() {
Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic()) clearConversationMessages(conversationIdForPublic())
} }
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId)) loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) { suspend fun clearDmMessages(otherUserId: Int) {
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(otherUserId)) clearConversationMessages(conversationIdForDm(otherUserId))
} }
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) { suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
Logger.d(
"MessageCache",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
"replaceAll=$replaceAll convId=$convId",
)
val pending = loadPendingMessages(convId) val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages) val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated) purgeSupersededPendingRows(iid, convId, before, hydrated)
} }
replaceMessages(convId, hydrated) replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations() pruneEmptyConversations()
} }
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) { suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout() val resolved = message.resolvePublicAttachmentLayout()
Logger.d(
"MessageCache",
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
"clientId=${resolved.client_message_id}",
)
ProfileCache.mergePreviewFromPublicMessage(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved) upsertSingle(conversationIdForPublic(), resolved)
} }
@@ -279,24 +296,40 @@ object MessageCacheStore {
} }
suspend fun upsertDmMessage(otherUserId: Int, message: Message) { suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
Logger.d(
"MessageCache",
"upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
"userId=${message.user_id} clientId=${message.client_message_id}",
)
ensureDmConversationRow(otherUserId) ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) { suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId) deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
} }
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) { suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
Logger.d(
"MessageCache",
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
)
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId) deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
} }
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId) deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageById(messageId: Int) {
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId)
}
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) { suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
deleteByClientMessageId(conversationId, clientMessageId) deleteByClientMessageId(conversationId, clientMessageId)
} }
@@ -353,7 +386,7 @@ object MessageCacheStore {
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) { if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
resolved = resolved.copy( resolved = resolved.copy(
fileAspectRatios = listOf(decodedAspect), fileAspectRatios = listOf(decodedAspect),
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions, fileDimensions = listOf(thumbDims.first to thumbDims.second),
) )
} }
} }
@@ -436,6 +469,10 @@ object MessageCacheStore {
suspend fun markMessageDeleted(conversationId: String, messageId: Int) { suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted( db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid, instanceId = iid,
@@ -535,7 +572,7 @@ object MessageCacheStore {
.filter { it.type == "dm" } .filter { it.type == "dm" }
localDm.forEach { row -> localDm.forEach { row ->
val otherId = row.otherUserId?.toInt() ?: return@forEach val otherId = row.otherUserId?.toInt() ?: return@forEach
if (otherId !in serverOtherUserIds) { if (otherId !in serverOtherUserIds && row.archived == 0L) {
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id) db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
} }
} }
@@ -607,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() ?: return@withContext .executeAsOneOrNull() ?: run {
val label = resolveDmConversationDisplayLabel(otherUserId, null) Logger.d(
if (label.isEmpty()) return@withContext "MessageCache",
if (label == existing.displayName) return@withContext "patchDmPeerProfile noConversation otherUserId=$otherUserId",
)
return@withContext
}
val profile = ProfileCache.get(otherUserId)
val isDeleted = profile?.deleted == true ||
profile?.username?.startsWith("#deleted") == true
val label = if (isDeleted) {
""
} else {
resolveDmConversationDisplayLabel(otherUserId, null)
}
if (!isDeleted && label.isEmpty()) {
Logger.d(
"MessageCache",
"patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
"deleted=${profile?.deleted}",
)
return@withContext
}
if (label == existing.displayName) {
Logger.d(
"MessageCache",
"patchDmPeerProfile unchanged otherUserId=$otherUserId " +
"deleted=$isDeleted labelEmpty=${label.isEmpty()}",
)
return@withContext
}
Logger.d(
"MessageCache",
"patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
"oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
"newLabelEmpty=${label.isEmpty()}",
)
db.messageDatabaseQueries.upsertConversation( db.messageDatabaseQueries.upsertConversation(
instanceId = iid, instanceId = iid,
id = existing.id, id = existing.id,
@@ -686,6 +756,7 @@ object MessageCacheStore {
id = convId, id = convId,
) )
} }
DmConversationListNotifier.notifyChanged()
} }
suspend fun deleteDmConversation(otherUserId: Int) { suspend fun deleteDmConversation(otherUserId: Int) {
@@ -942,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) { private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
} }
@@ -949,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) { private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId() val iid = instanceId()
Logger.d(
"MessageCache",
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
} }
@@ -956,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) { private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById( db.messageDatabaseQueries.deleteMessageById(
instanceId = iid, instanceId = iid,
@@ -963,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(), id = messageId.toLong(),
) )
} }
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"deleteMessageById convId=$conversationId messageId=$messageId " +
"rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
)
} }
private suspend fun upsertSingle(conversationId: String, msg: Message) { private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1244,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid) val profile = ProfileCache.get(uid)
val usernameResolved = when { val usernameResolved = when {
self != null && uid == self.id -> self.username self != null && uid == self.id -> self.username
else -> profile?.username?.takeIf { it.isNotBlank() } else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
?: profile?.displayName?.takeIf { it.isNotBlank() } }
?: "" val displayNameResolved = when {
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} }
val pictureResolved = when { val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture self != null && uid == self.id -> self.profile_picture
@@ -1261,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L, is_read = isRead != 0L,
is_edited = isEdited != 0L, is_edited = isEdited != 0L,
username = usernameResolved, username = usernameResolved,
displayName = displayNameResolved,
profile_picture = pictureResolved, profile_picture = pictureResolved,
verified = profile?.verified, verified = profile?.verified,
verificationStatus = profile?.verificationStatus, verificationStatus = profile?.verificationStatus,
@@ -1321,6 +1417,7 @@ object MessageCacheStore {
} }
suspend fun clearAll() { suspend fun clearAll() {
Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache() db.messageDatabaseQueries.purgeAllCache()
} }
@@ -1329,24 +1426,49 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> { private fun validatedOrEmpty(conversationId: String, messages: List<Message>): List<Message> {
val self = ApiClient.user?.id val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
)
return emptyList() return emptyList()
} }
return CacheValidator.filterMessages(conversationId, messages, self) return CacheValidator.filterMessages(conversationId, messages, self)
} }
private suspend fun replaceMessages(conversationId: String, messages: List<Message>) { private suspend fun replaceMessages(
conversationId: String,
messages: List<Message>,
replaceAll: Boolean = false,
) {
val self = ApiClient.user?.id val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) { if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
Logger.w(
"MessageCache",
"replaceMessages incoherent→clear convId=$conversationId " +
"count=${messages.size} replaceAll=$replaceAll",
)
clearConversationMessages(conversationId) clearConversationMessages(conversationId)
return return
} }
val validated = CacheValidator.filterMessages(conversationId, messages, self) val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId() val iid = instanceId()
val beforeCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages convId=$conversationId replaceAll=$replaceAll " +
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
.associate { it.id.toInt() to it.replyToId } .associate { it.id.toInt() to it.replyToId }
if (replaceAll) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId) db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
validated.forEach { msg: Message -> validated.forEach { msg: Message ->
@@ -1362,11 +1484,28 @@ object MessageCacheStore {
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]), replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id, clientMessageId = msg.client_message_id,
deletedFlag = 0L, deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent" sendStatus = if (msg.id < 0) "pending" else "sent",
) )
} }
} }
} else {
// Merge into existing rows — partial UI snapshots must not wipe full history.
validated.forEach { msg: Message ->
upsertSingle(conversationId, msg)
} }
}
}
val afterCount = withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.size
}
Logger.d(
"MessageCache",
"replaceMessages done convId=$conversationId replaceAll=$replaceAll " +
"rowsAfter=$afterCount",
)
dmOtherUserIdFromConversationId(conversationId)?.let { dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it) syncDmConversationPreviewFromCache(it)
pruneEmptyConversations() pruneEmptyConversations()
@@ -1,6 +1,7 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewState import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
@@ -21,7 +22,9 @@ object MessageRepository {
MessageCacheStore.observeMessages(activeInstance(), conversationId) MessageCacheStore.observeMessages(activeInstance(), conversationId)
fun observePublicMessages(): Flow<List<Message>> = fun observePublicMessages(): Flow<List<Message>> =
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)) observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)).map { rows ->
ProfileCache.enrichPublicMessagesForDisplay(rows)
}
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> = fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
observeMessages(conversationIdForDm(otherUserId)) observeMessages(conversationIdForDm(otherUserId))
@@ -56,8 +59,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow<List<CachedConversation>> = fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance()) MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) = suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replacePublicMessages(messages) ru.fromchat.Logger.d(
"MessageRepo",
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
}
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message) suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,8 +75,18 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) = suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId) MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
suspend fun markMessageDeleted(conversationId: String, messageId: Int) = suspend fun deletePublicMessageById(messageId: Int) {
ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
MessageCacheStore.deletePublicMessageById(messageId)
}
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"markMessageDeleted convId=$conversationId messageId=$messageId",
)
MessageCacheStore.markMessageDeleted(conversationId, messageId) MessageCacheStore.markMessageDeleted(conversationId, messageId)
}
suspend fun markPublicMessageDeleted(messageId: Int) = suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId) markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
@@ -76,8 +94,13 @@ object MessageRepository {
suspend fun loadDmMessages(otherUserId: Int): List<Message> = suspend fun loadDmMessages(otherUserId: Int): List<Message> =
MessageCacheStore.loadDmMessages(otherUserId) MessageCacheStore.loadDmMessages(otherUserId)
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) = suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
MessageCacheStore.replaceDmMessages(otherUserId, messages) ru.fromchat.Logger.d(
"MessageRepo",
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
)
MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) = suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message) MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -88,8 +111,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) = suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId) MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
ru.fromchat.Logger.d(
"MessageRepo",
"deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
)
MessageCacheStore.deleteDmMessageById(otherUserId, messageId) MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
}
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
@@ -114,6 +142,7 @@ object MessageRepository {
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) { suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) } runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId) MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
} }
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) { suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
@@ -134,6 +163,7 @@ object MessageRepository {
runCatching { ApiClient.markMessagesRead(ids) } runCatching { ApiClient.markMessagesRead(ids) }
} }
MessageCacheStore.markPublicMessagesReadLocally() MessageCacheStore.markPublicMessagesReadLocally()
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
} }
suspend fun archiveDmConversation(otherUserId: Int) = suspend fun archiveDmConversation(otherUserId: Int) =
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser import ru.fromchat.api.schema.messages.dm.DmConversationUser
@@ -20,13 +21,19 @@ import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/** /**
* Returns true when a profile entry should not expose its `username` field * Returns true when a profile entry should not expose its `username` field
* to the UI (for suspended or deleted users), except for the current user. * to the UI (for suspended or deleted users), except for the current user.
*/ */
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean = fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username)) id != currentUserId && (
deleted == true ||
isDeletedPlaceholderUsername(username) ||
(suspended == true && currentUserId != 1)
)
private fun isDeletedPlaceholderUsername(username: String?): Boolean = private fun isDeletedPlaceholderUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true username?.startsWith("#deleted") == true
@@ -52,18 +59,56 @@ object ProfileCache {
@Volatile @Volatile
private var loadedInstanceId: String = "" private var loadedInstanceId: String = ""
/** Epoch millis when a full (non-preview) profile was last applied from the server. */
@Volatile
private var fullProfileFetchedAtMs: Map<Int, Long> = emptyMap()
private val _revision = MutableStateFlow(0) private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow() val revision: StateFlow<Int> = _revision.asStateFlow()
private val persistMutex = Mutex() private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private fun bumpRevision() { /** Skip force=false network refetch when a full profile was fetched within this window. */
_revision.value++ const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
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] 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) ?: 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). */ /** Emits whenever this user's cached profile changes (including bio). */
fun observeUser(userId: Int): Flow<UserProfile?> = fun observeUser(userId: Int): Flow<UserProfile?> =
revision.map { get(userId) }.distinctUntilChanged() revision.map { get(userId) }.distinctUntilChanged()
@@ -88,7 +133,13 @@ object ProfileCache {
) { ) {
if (id <= 0) return if (id <= 0) return
val existing = get(id) val existing = get(id)
if (existing != null && !existing.isClientPreviewOnly) return if (existing != null && !existing.isClientPreviewOnly) {
Logger.d(
"ProfileCache",
"mergePreview skipFullExists id=$id deleted=${existing.deleted}",
)
return
}
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() } val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -98,11 +149,27 @@ object ProfileCache {
} else { } else {
displayName?.trim()?.takeIf { it.isNotEmpty() } displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() } ?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
} }
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
return
}
if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreview missingIdentity id=$id " +
"hasUsername=${!incomingUsername.isNullOrEmpty()} " +
"hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
)
}
Logger.d(
"ProfileCache",
"mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
"user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
)
put( put(
UserProfile( UserProfile(
id = id, id = id,
@@ -137,29 +204,49 @@ object ProfileCache {
val hasIdentity = val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank() profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) { if (!hasIdentity) {
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id) remove(profile.id)
return return
} }
} }
val cur = profiles val cur = profiles
val existing = cur[profile.id] val existing = cur[profile.id]
val deletedChanged = existing?.deleted != profile.deleted
val suspendedChanged = existing?.suspended != profile.suspended
val verificationChanged = existing?.verified != profile.verified ||
existing?.verificationStatus != profile.verificationStatus
if ( if (
existing != null && existing != null &&
!existing.isClientPreviewOnly && !existing.isClientPreviewOnly &&
existing.bio != profile.bio existing.bio != profile.bio
) { ) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " + "put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}", "'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
) )
} }
if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
Logger.d(
"ProfileCache",
"put ${profileSummary(profile)} hadExisting=${existing != null} " +
"deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
"verificationChanged=$verificationChanged",
)
}
profiles = cur + (profile.id to profile) profiles = cur + (profile.id to profile)
bumpRevision() bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) } runCatching { ProfileCacheStore.put(instanceId, profile) }
.onFailure {
Logger.w(
"ProfileCache",
"persist put failed id=${profile.id}: ${it.message}",
it,
)
}
} }
} }
} }
@@ -169,35 +256,79 @@ object ProfileCache {
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP * When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
* response cannot overwrite a fresher WebSocket update. * response cannot overwrite a fresher WebSocket update.
*/ */
@OptIn(ExperimentalTime::class)
fun applyServerProfile(profile: UserProfile, force: Boolean = false) { fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
if (profile.id <= 0) return if (profile.id <= 0) return
val normalized = profile.copy(isClientPreviewOnly = false) val normalized = profile.copy(isClientPreviewOnly = false)
val nowMs = Clock.System.now().toEpochMilliseconds()
if (!force) { if (!force) {
val existing = get(profile.id) val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) { if (existing != null && !existing.isClientPreviewOnly) {
val lifecycleMismatch =
existing.deleted != normalized.deleted ||
existing.suspended != normalized.suspended ||
isDeletedPlaceholderUsername(existing.username) !=
isDeletedPlaceholderUsername(normalized.username)
if (lifecycleMismatch) {
Logger.w(
"ProfileCache",
"applyServerProfile force=false lifecycleMismatch " +
"cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
"cachedSuspended=${existing.suspended} " +
"incomingSuspended=${normalized.suspended} " +
"cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
"— applying full server profile",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
val patched = existing.copy(
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) { if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " + "applyServerProfile skipped stale HTTP id=${profile.id} " +
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'", "cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
"deleted=${existing.deleted}",
) )
} else {
Logger.d(
"ProfileCache",
"applyServerProfile keepCached force=false id=${profile.id} " +
"deleted=${existing.deleted} verificationChanged=$verificationChanged",
)
}
if (!verificationChanged) {
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
} }
return return
} }
} }
ru.fromchat.Logger.d( Logger.d(
"ProfileCache", "ProfileCache",
"applyServerProfile applied force=$force id=${profile.id} " + "applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'", "bio='${normalized.bio?.take(48)}'",
) )
put(normalized) put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
} }
fun remove(userId: Int) { fun remove(userId: Int) {
val cur = profiles val cur = profiles
if (userId !in cur) return if (userId !in cur) {
Logger.d("ProfileCache", "remove miss id=$userId")
return
}
Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
profiles = cur - userId profiles = cur - userId
bumpRevision() bumpRevision("remove:$userId")
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
@@ -218,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return if (user.id <= 0) return
val incomingUsername = user.username.trim() val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return if (incomingUsername.isEmpty()) {
Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
return
}
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername) val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) { val incomingDisplayName = if (isDeleted) {
null null
} else { } else {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername user.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
)
} }
val existing = get(user.id) val existing = get(user.id)
Logger.d(
"ProfileCache",
"mergeFromDmUser id=${user.id} deleted=$isDeleted " +
"incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
"user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
)
if (existing != null && !existing.isClientPreviewOnly) { if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy( val patched = existing.copy(
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName, displayName = if (isDeleted) {
null
} else {
incomingDisplayName ?: existing.displayName
},
profilePicture = if (isDeleted) { profilePicture = if (isDeleted) {
null null
} else { } else {
@@ -245,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason, suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted, deleted = isDeleted,
) )
if (patched != existing) put(patched) if (patched != existing) {
Logger.d(
"ProfileCache",
"mergeFromDmUser patchFull id=${user.id} " +
"deleted ${existing.deleted}${patched.deleted}",
)
put(patched)
}
return return
} }
@@ -253,8 +411,11 @@ object ProfileCache {
UserProfile( UserProfile(
id = user.id, id = user.id,
username = incomingUsername, username = incomingUsername,
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } displayName = if (isDeleted) {
?: incomingDisplayName, null
} else {
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
},
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
@@ -293,14 +454,33 @@ object ProfileCache {
val uid = message.user_id val uid = message.user_id
if (uid <= 0) return if (uid <= 0) return
val existing = get(uid) val existing = get(uid)
if (existing != null && !existing.isClientPreviewOnly) return val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
val incomingPic = message.profile_picture?.takeIf { it.isNotBlank() }
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
verified = message.verified ?: existing.verified,
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
displayName = existing.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplay,
profilePicture = existing.profilePicture?.takeIf { it.isNotBlank() } ?: incomingPic,
username = existing.username.trim().ifBlank { message.username.trim() },
)
if (patched != existing) put(patched)
return
}
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return val displayName = incomingDisplay ?: existing?.displayName?.takeIf { it.isNotBlank() }
if (uname.isBlank() && displayName.isNullOrBlank()) return
if (uname.isBlank() || displayName.isNullOrBlank()) {
Logger.d(
"ProfileCache",
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!displayName.isNullOrBlank()}",
)
}
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val display = if (isDeleted) null else displayName
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() } val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
?: existing?.profilePicture
put( put(
UserProfile( UserProfile(
@@ -339,6 +519,8 @@ object ProfileCache {
val user = ApiClient.user val user = ApiClient.user
return message.copy( return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() }, username = message.username.trim().ifBlank { user?.username.orEmpty() },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture, ?: user?.profile_picture,
reply_to = enrichedReply, reply_to = enrichedReply,
@@ -349,6 +531,8 @@ object ProfileCache {
username = message.username.trim().ifBlank { username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty() profile?.visibleUsername(self).orEmpty()
}, },
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture, ?: profile?.profilePicture,
verified = message.verified ?: profile?.verified, verified = message.verified ?: profile?.verified,
@@ -371,8 +555,9 @@ object ProfileCache {
} else { } else {
emptyMap() emptyMap()
} }
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("onActiveInstanceChanged:$instanceId")
} }
} }
} }
@@ -386,6 +571,7 @@ object ProfileCache {
} else { } else {
emptyMap() emptyMap()
} }
val before = profiles.size
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
profiles = diskProfiles profiles = diskProfiles
} else { } else {
@@ -401,8 +587,14 @@ object ProfileCache {
} }
profiles = merged profiles = merged
} }
val deletedCount = profiles.values.count { it.deleted == true }
Logger.d(
"ProfileCache",
"hydrateFromDisk instanceId=$instanceId before=$before " +
"disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
)
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision() bumpRevision("hydrateFromDisk")
} }
} }
@@ -414,6 +606,7 @@ object ProfileCache {
p.displayName.isNullOrBlank() p.displayName.isNullOrBlank()
}.keys }.keys
if (toRemove.isEmpty()) return if (toRemove.isEmpty()) return
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles var cur = profiles
for (id in toRemove) { for (id in toRemove) {
cur = cur - id cur = cur - id
@@ -423,9 +616,11 @@ object ProfileCache {
suspend fun clear() { suspend fun clear() {
persistMutex.withLock { persistMutex.withLock {
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap() profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = "" loadedInstanceId = ""
bumpRevision() bumpRevision("clear")
} }
} }
} }
@@ -34,12 +34,11 @@ object UserStatusStore {
} }
} }
fun removeTyping(userId: Int, username: String) { fun removeTyping(userId: Int, username: String = "") {
val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
_status.update { current -> _status.update { current ->
val existing = current[userId] ?: return@update current val existing = current[userId] ?: return@update current
val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) } // Clear by userId: after suspend/delete the stop event may use #deleted{id}.
current + (userId to existing.copy(typingUsernames = typing)) current + (userId to existing.copy(typingUsernames = emptyList()))
} }
} }
} }
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) { suspend fun processDeleted(element: JsonElement) {
val data = runCatching { val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element) ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: run {
ru.fromchat.Logger.w("DmInbox", "processDeleted decode failed")
return
}
val currentUserId = ApiClient.user?.id ?: return val currentUserId = ApiClient.user?.id ?: return
if (data.senderId != currentUserId && data.recipientId != currentUserId) return if (data.senderId != currentUserId && data.recipientId != currentUserId) {
ru.fromchat.Logger.d(
"DmInbox",
"processDeleted skipNotParticipant messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} self=$currentUserId",
)
return
}
val otherUserId = when (currentUserId) { val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId data.senderId -> data.recipientId
else -> data.senderId else -> data.senderId
} ?: return } ?: return
ru.fromchat.Logger.i(
"DmInbox",
"processDeleted messageId=${data.id} otherUserId=$otherUserId " +
"senderId=${data.senderId} recipientId=${data.recipientId}",
)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
} }
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int, otherUserId: Int,
): Message { ): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) { if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName -> if (senderUsername != null || senderDisplayName != null) {
ProfileCache.mergePreview(id = envelope.senderId, username = senderName) ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername,
displayName = senderDisplayName,
)
} }
} }
val senderProfile = ProfileCache.get(envelope.senderId) val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } else {
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() } senderUsername
?: envelope.senderUsername?.takeIf { it.isNotBlank() } ?: senderProfile?.username?.trim()?.takeIf { it.isNotEmpty() }
?: "" ?: ""
} }
val displayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
senderDisplayName
?: senderProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
return Message( return Message(
id = envelope.id, id = envelope.id,
user_id = envelope.senderId, user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId, is_read = envelope.senderId == currentUserId,
is_edited = false, is_edited = false,
username = username, username = username,
displayName = displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
} }
"dmDeleted" -> message.data?.let { element -> "dmDeleted" -> message.data?.let { element ->
scope.launch { scope.launch {
ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element) DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged() DmConversationListNotifier.notifyChanged()
} }
@@ -4,16 +4,21 @@ import ru.fromchat.api.schema.messages.Message
/** /**
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom. * Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
* Avoids optimistic rows jumping mid-thread when client clock lags the server. * Pending rows sort by enqueue sequence (monotonic negative id), not hash or wall-clock.
*/ */
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> { internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
if (messages.size <= 1) return messages if (messages.size <= 1) return messages
val (pending, confirmed) = messages.partition { it.id < 0 } val (pending, confirmed) = messages.partition { it.id < 0 }
val comparator = compareBy<Message>( val confirmedComparator = compareBy<Message>(
{ messageSortEpochMillis(it) }, { messageSortEpochMillis(it) },
{ it.id.toLong() }, { it.id.toLong() },
) )
return confirmed.sortedWith(comparator) + pending.sortedWith(comparator) // Monotonic ids are -1, -2, … so -id ascending = enqueue order.
val pendingComparator = compareBy<Message>(
{ -it.id.toLong() },
{ it.client_message_id.orEmpty() },
)
return confirmed.sortedWith(confirmedComparator) + pending.sortedWith(pendingComparator)
} }
internal fun messageSortEpochMillis(message: Message): Long = internal fun messageSortEpochMillis(message: Message): Long =
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
* Parse message timestamps from server or client. * Parse message timestamps from server or client.
* *
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants. * - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
* - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`), * - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
* interpreted in the device zone so HH:mm matches the user's clock. * interpreted as UTC so local formatting shows the user's clock.
*/ */
internal fun parseMessageInstant(timestamp: String): Instant? { internal fun parseMessageInstant(timestamp: String): Instant? {
val raw = timestamp.trim() val raw = timestamp.trim()
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
} }
val local = parseLocalDateTimeOrNull(normalized) ?: return null val local = parseLocalDateTimeOrNull(normalized) ?: return null
return runCatching { return runCatching {
local.toInstant(TimeZone.currentSystemDefault()) local.toInstant(TimeZone.UTC)
}.getOrNull() }.getOrNull()
} }
@@ -1,13 +1,42 @@
package ru.fromchat.api.local.messages package ru.fromchat.api.local.messages
import kotlinx.coroutines.sync.Mutex
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import kotlin.math.abs
/** Stable negative row id for an optimistic / outbox [clientMessageId]. */ private val optimisticIdMutex = Mutex()
private var nextOptimisticSeq = 0
private val idByClientMessageId = mutableMapOf<String, Int>()
private inline fun <T> withOptimisticIdLock(block: () -> T): T {
while (!optimisticIdMutex.tryLock()) {
// spin — allocation is rare and short
}
try {
return block()
} finally {
optimisticIdMutex.unlock()
}
}
/**
* Negative row id for an optimistic / outbox [clientMessageId].
* Monotonic enqueue order (-1, -2, ); stable for the same client id within process.
*/
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int { fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
val hc = clientMessageId.hashCode() val key = clientMessageId.trim()
val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc) if (key.isEmpty()) return nextOptimisticMessageId()
return -(if (absHc == 0) 1 else absHc) return withOptimisticIdLock {
idByClientMessageId.getOrPut(key) {
nextOptimisticSeq += 1
-nextOptimisticSeq
}
}
}
/** Fresh monotonic negative id when no client message id is available yet. */
fun nextOptimisticMessageId(): Int = withOptimisticIdLock {
nextOptimisticSeq += 1
-nextOptimisticSeq
} }
fun Message.isQueuedOutbound(): Boolean = id < 0 fun Message.isQueuedOutbound(): Boolean = id < 0
@@ -0,0 +1,102 @@
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
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
/**
* Global public-chat inbox: persists add/edit/delete into [MessageRepository] even when
* no [ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel] is open.
*/
object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
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
if (currentUserId != null && message.user_id == currentUserId) {
if (clientId.isNotEmpty()) {
MessageRepository.confirmPublicMessage(clientId, message)
return@withContext
}
// Server omitted client_message_id — still clear a matching pending row if unique.
val pending = MessageRepository.loadPublicMessages()
.filter { it.user_id == currentUserId && it.id < 0 }
val matchCid = when {
pending.size == 1 -> pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
else -> {
val content = message.content.trim()
pending.singleOrNull {
it.content.trim() == content ||
(!it.files.isNullOrEmpty() && !message.files.isNullOrEmpty())
}?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
}
if (matchCid != null) {
MessageRepository.confirmPublicMessage(
matchCid,
message.copy(client_message_id = matchCid),
)
return@withContext
}
}
MessageRepository.upsertPublicMessage(message)
}
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
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 ->
if (current.id == edited.id) {
edited.copy(reply_to = edited.reply_to ?: current.reply_to)
} else {
current
}
}
if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited)
} else {
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() ?: 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)
}
private fun decodeMessage(element: JsonElement): Message? =
runCatching {
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
}.getOrNull()
}
@@ -0,0 +1,59 @@
package ru.fromchat.api.local.messages
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/**
* Applies a single updates batch in order (add/edit/delete) before the cursor advances.
*/
object UpdatesBatchApplier {
private val mutex = Mutex()
private val publicTypes = setOf("newMessage", "messageEdited", "messageDeleted")
private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited")
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.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))
}
envelope.seq
}
suspend fun applyOne(message: WebSocketMessage) {
when (message.type) {
"updates" -> {
val data = message.data ?: return
applyEnvelope(data)
}
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"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
}
}
fun isCacheAffecting(type: String): Boolean =
type in publicTypes || type in dmTypes
}
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean, val is_read: Boolean,
val is_edited: Boolean, val is_edited: Boolean,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null, val profile_picture: String? = null,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null, @SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ReactionUser( data class ReactionUser(
val id: Int, val id: Int,
val username: String val username: String,
@SerialName("display_name") val displayName: String? = null,
) )
@@ -9,6 +9,7 @@ data class DmEnvelope(
val senderId: Int, val senderId: Int,
val recipientId: Int, val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null, @SerialName("sender_username") val senderUsername: String? = null,
@SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String, @SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -0,0 +1,81 @@
package ru.fromchat.api.schema.user.auth
import kotlinx.serialization.Serializable
@Serializable
data class AuthUsernameStepRequest(
val username: String,
)
@Serializable
data class AuthUsernameStepResponse(
val ok: Boolean = true,
val exists: Boolean? = null,
)
@Serializable
data class AuthPasswordStepRequest(
val username: String,
val password: String,
)
@Serializable
data class YandexOAuthParams(
val client_id: String,
val redirect_uri: String,
val authorize_url: String,
val scope: String,
)
@Serializable
data class SmartCaptchaParams(
val client_key: String,
)
@Serializable
data class AuthNeedsRegisterResponse(
val status: String,
val yandex_required: Boolean = false,
val yandex: YandexOAuthParams? = null,
val captcha_required: Boolean = false,
val captcha: SmartCaptchaParams? = null,
)
@Serializable
data class YandexExchangeRequest(
val code: String,
val code_verifier: String,
)
@Serializable
data class YandexExchangeResponse(
val registration_proof: String,
)
@Serializable
data class AccountYandexResponse(
val linked: Boolean = false,
val yandex: YandexOAuthParams? = null,
)
@Serializable
data class ChangeYandexRequest(
val registration_proof: String,
)
@Serializable
data class ChangeYandexResponse(
val status: String? = null,
val unchanged: Boolean = false,
)
@Serializable
data class RegisterConfirmRequest(
val username: String,
val display_name: String,
val password: String,
val confirm_password: String,
val bio: String? = null,
val registration_proof: String? = null,
val captcha_token: String? = null,
)
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.user.profile
import kotlinx.serialization.Serializable
@Serializable
data class SuspendUserRequest(
val reason: String,
)
@@ -0,0 +1,9 @@
package ru.fromchat.api.schema.websocket.requests
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AckUpdatesRequest(
@SerialName("lastSeq") val lastSeq: Int,
)
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
data class GetUpdatesResponse( data class GetUpdatesResponse(
val status: String, val status: String,
@SerialName("lastSeq") val lastSeq: Int, @SerialName("lastSeq") val lastSeq: Int,
@SerialName("missedCount") val missedCount: Int @SerialName("missedCount") val missedCount: Int,
@SerialName("hasMore") val hasMore: Boolean = false,
) )
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types package ru.fromchat.api.schema.websocket.types
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String, val action: String,
val user_id: Int, val user_id: Int,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val reactions: List<ReactionData> val reactions: List<ReactionData>
) )
@@ -0,0 +1,166 @@
package ru.fromchat.auth.yandex
import korlibs.crypto.SHA256
import kotlin.random.Random
internal data class PkcePair(
val codeVerifier: String,
val codeChallenge: String,
)
internal fun generatePkcePair(): PkcePair {
val verifierBytes = ByteArray(32).also { Random.Default.nextBytes(it) }
val codeVerifier = base64UrlNoPad(verifierBytes)
val challenge = base64UrlNoPad(SHA256.digest(codeVerifier.encodeToByteArray()).bytes)
return PkcePair(codeVerifier = codeVerifier, codeChallenge = challenge)
}
private fun base64UrlNoPad(bytes: ByteArray): String {
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
val out = StringBuilder((bytes.size * 4 + 2) / 3)
var i = 0
while (i < bytes.size) {
val b0 = bytes[i].toInt() and 0xff
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xff else 0
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xff else 0
out.append(alphabet[b0 shr 2])
out.append(alphabet[((b0 and 0x03) shl 4) or (b1 shr 4)])
if (i + 1 < bytes.size) {
out.append(alphabet[((b1 and 0x0f) shl 2) or (b2 shr 6)])
}
if (i + 2 < bytes.size) {
out.append(alphabet[b2 and 0x3f])
}
i += 3
}
return out.toString()
}
internal const val OFFICIAL_API_HOST = "api.fromchat.ru"
/**
* Prod Yandex 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_YANDEX_OAUTH_CLIENT_ID = ""
internal const val YANDEX_OAUTH_REDIRECT_URI = "fromchat://oauth/yandex"
internal fun isOfficialApiHost(serverIp: String): Boolean {
val host = serverIp.trim().lowercase().substringBefore("/").substringBefore(":")
return host == OFFICIAL_API_HOST
}
/**
* Returns the client_id to use, or null if the official host sent a mismatched id.
*/
internal fun resolveYandexClientId(serverClientId: String, serverIp: String): String? {
val trimmed = serverClientId.trim()
if (trimmed.isEmpty()) return null
if (!isOfficialApiHost(serverIp)) return trimmed
val pinned = OFFICIAL_YANDEX_OAUTH_CLIENT_ID.trim()
if (pinned.isEmpty()) return trimmed
return if (pinned == trimmed) trimmed else null
}
/**
* Yandex UI language follows the authorize host (`.ru` vs `.com`), not OAuth `lang`.
* Keep `lang` anyway; rewrite host so passport matches the app locale.
*/
internal fun yandexAuthorizeHostForLanguage(languageTag: String): String {
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
return if (lang == "ru") "oauth.yandex.ru" else "oauth.yandex.com"
}
internal fun withYandexAuthorizeHost(authorizeUrl: String, languageTag: String): String {
val targetHost = yandexAuthorizeHostForLanguage(languageTag)
return Regex("""oauth\.yandex\.(com|ru)""", RegexOption.IGNORE_CASE)
.replace(authorizeUrl.trim()) { targetHost }
}
internal fun buildYandexAuthorizeUrl(
authorizeUrl: String,
clientId: String,
redirectUri: String,
scope: String,
codeChallenge: String,
languageTag: String = "en",
darkTheme: Boolean = false,
): String {
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
val base = withYandexAuthorizeHost(authorizeUrl, lang).trimEnd('?')
val theme = if (darkTheme) "dark" else "light"
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("force_confirm" to "yes")
add("lang" to lang)
add("theme" to theme)
add("color_scheme" to theme)
}.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())
}
}
}
}
}
internal fun extractOAuthCode(redirectUrl: String): String? {
val uri = redirectUrl.trim()
if (!uri.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true) &&
!uri.startsWith("fromchat://oauth/yandex?", ignoreCase = true)
) {
return null
}
val query = uri.substringAfter('?', missingDelimiterValue = "")
if (query.isEmpty()) return null
return query.split('&').firstNotNullOfOrNull { part ->
val key = part.substringBefore('=')
val raw = part.substringAfter('=', missingDelimiterValue = "")
if (key == "code" && raw.isNotEmpty()) decodeUrl(raw) else null
}
}
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()
}
@@ -0,0 +1,6 @@
package ru.fromchat.notifications
/** Platform bridge so shared mark-read can dismiss message notifications. */
expect object ChatNotificationDismissals {
fun dismissAllMessageNotifications()
}
@@ -79,6 +79,10 @@ import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType import ru.fromchat.legal.DocumentType
import ru.fromchat.notifications.NotificationLaunchCoordinator import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AuthScreen import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmNav
@@ -98,6 +102,9 @@ import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen 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.delete.DeleteAccountScreen import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField import ru.fromchat.ui.profile.EditProfileFocusField
@@ -457,6 +464,14 @@ fun App(
) )
} }
composable(YandexOAuthNav.ROUTE) {
YandexOAuthScreen()
}
composable(SmartCaptchaNav.ROUTE) {
SmartCaptchaScreen()
}
composable("chat") { composable("chat") {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
@@ -670,6 +685,26 @@ fun App(
) )
} }
settingsComposable(SettingsRoutes.AccountYandexFlow) {
ChangeYandexConfirmScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountYandexOAuth) {
ChangeYandexOAuthScreen(
onBack = { navController.navigateUp() },
)
}
settingsComposable(SettingsRoutes.AccountYandexDone) {
ChangeYandexDoneScreen(
onDone = {
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
},
)
}
settingsComposable(SettingsRoutes.Account) { settingsComposable(SettingsRoutes.Account) {
AccountScreen( AccountScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
@@ -679,6 +714,7 @@ fun App(
} }
}, },
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }, onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) }, onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
) )
} }
@@ -0,0 +1,38 @@
package ru.fromchat.ui.auth
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
/**
* Survives [AuthScreen] leaving composition when navigating to Yandex OAuth / SmartCaptcha routes.
* Cleared on welcome / successful auth / explicit reset to username.
*/
internal object AuthRegisterDraft {
var username: String = ""
var password: String = ""
var confirmPassword: String = ""
var displayName: String = ""
var bio: String = ""
var yandexRequired: Boolean = false
var yandexParams: YandexOAuthParams? = null
var captchaRequired: Boolean = false
var captchaParams: SmartCaptchaParams? = null
var registrationProof: String? = null
var captchaToken: String? = null
var page: Int = 0
fun clear() {
username = ""
password = ""
confirmPassword = ""
displayName = ""
bio = ""
yandexRequired = false
yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null
captchaToken = null
page = 0
}
}
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -19,22 +20,27 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.crypto.IdentityKeyManager import ru.fromchat.api.crypto.IdentityKeyManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.instance.ServerProbeResult import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.probeServer import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.schema.core.ErrorResponse import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterRequest import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.change_server import ru.fromchat.change_server
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
import ru.fromchat.ui.auth.register.confirmPasswordStepPage import ru.fromchat.ui.auth.register.confirmPasswordStepPage
import ru.fromchat.ui.auth.register.profileStepPage import ru.fromchat.ui.auth.register.profileStepPage
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.TextCta import ru.fromchat.ui.components.TextCta
@@ -47,12 +53,18 @@ private enum class AuthFlowStep {
Username, Username,
Password, Password,
ConfirmPassword, ConfirmPassword,
YandexId,
Profile, Profile,
} }
internal sealed interface PasswordStepResult { internal sealed interface PasswordStepResult {
data object LoginSuccess : PasswordStepResult data object LoginSuccess : PasswordStepResult
data object AdvanceToRegister : PasswordStepResult data class NeedsRegister(
val yandexRequired: Boolean,
val yandex: YandexOAuthParams?,
val captchaRequired: Boolean,
val captcha: SmartCaptchaParams?,
) : PasswordStepResult
data class WrongPassword(val message: String) : PasswordStepResult data class WrongPassword(val message: String) : PasswordStepResult
data class RateLimited(val message: String) : PasswordStepResult data class RateLimited(val message: String) : PasswordStepResult
data class Error(val message: String, val cause: Throwable? = null) : PasswordStepResult data class Error(val message: String, val cause: Throwable? = null) : PasswordStepResult
@@ -72,29 +84,6 @@ internal suspend fun probeCurrentServer() = runCatching {
} }
}.getOrDefault(false) }.getOrDefault(false)
internal suspend fun authBranch(
username: String,
password: String,
wrongPasswordMessage: String,
rateLimitMessage: String,
unexpectedError: String,
) = login(
username.trim(),
password,
wrongPasswordMessage,
rateLimitMessage,
unexpectedError,
).let { result ->
if (
result is PasswordStepResult.WrongPassword &&
!runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(true)
) {
PasswordStepResult.AdvanceToRegister
} else {
result
}
}
private suspend fun fullLogin( private suspend fun fullLogin(
username: String, username: String,
password: String, password: String,
@@ -123,20 +112,35 @@ private suspend fun fullLogin(
runCatching { ApiClient.refreshServerInstanceFingerprint() } runCatching { ApiClient.refreshServerInstanceFingerprint() }
} }
private suspend fun login( internal suspend fun authPasswordStep(
username: String, username: String,
password: String, password: String,
wrongPasswordMessage: String, wrongPasswordMessage: String,
rateLimitMessage: String, rateLimitMessage: String,
unexpectedError: String, unexpectedError: String,
) = try { ) = try {
fullLogin(username, password.trim()) { val derived = deriveAuthSecret(username.trim(), password.trim())
ApiClient.loginRequest( when (val outcome = ApiClient.authPasswordStep(username.trim(), derived)) {
LoginRequest(username, deriveAuthSecret(username, password.trim())), is ApiClient.AuthPasswordStepOutcome.LoggedIn -> {
) fullLogin(username.trim(), password.trim()) { outcome.response }
PasswordStepResult.LoginSuccess
} }
PasswordStepResult.LoginSuccess is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> {
Logger.i(
SmartCaptchaLog.TAG,
"password step needs_register yandexRequired=${outcome.yandexRequired} " +
"captchaRequired=${outcome.captchaRequired} " +
"clientKey=${SmartCaptchaLog.redactKey(outcome.captcha?.client_key)}",
)
PasswordStepResult.NeedsRegister(
yandexRequired = outcome.yandexRequired,
yandex = outcome.yandex,
captchaRequired = outcome.captchaRequired,
captcha = outcome.captcha,
)
}
}
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
when (e.response.status.value) { when (e.response.status.value) {
401 -> PasswordStepResult.WrongPassword( 401 -> PasswordStepResult.WrongPassword(
@@ -158,34 +162,45 @@ internal suspend fun register(
displayName: String, displayName: String,
password: String, password: String,
bio: String, bio: String,
registrationProof: String?,
captchaToken: String?,
unexpectedError: String, unexpectedError: String,
) = try { ) = try {
if (runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(false)) { Logger.i(
RegisterResult.UsernameTaken SmartCaptchaLog.TAG,
} else { "register confirm start username=${username.trim()} " +
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
)
fullLogin(username.trim(), password.trim()) { fullLogin(username.trim(), password.trim()) {
val derived = deriveAuthSecret(username.trim(), password.trim()) val derived = deriveAuthSecret(username.trim(), password.trim())
ApiClient.authRegisterConfirm(
ApiClient.registerRequest( RegisterConfirmRequest(
RegisterRequest(
username = username.trim(), username = username.trim(),
display_name = displayName.trim(), display_name = displayName.trim(),
password = derived, password = derived,
confirm_password = derived, confirm_password = derived,
bio = bio.trim().takeIf { it.isNotEmpty() }, bio = bio.trim().takeIf { it.isNotEmpty() },
registration_proof = registrationProof,
captcha_token = captchaToken,
), ),
) )
} }
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
RegisterResult.Success RegisterResult.Success
}
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
Logger.w(
SmartCaptchaLog.TAG,
"register confirm HTTP ${e.response.status.value} username=${username.trim()}",
e,
)
if (e.response.status.value == 400 && isUsernameTakenError(e)) { if (e.response.status.value == 400 && isUsernameTakenError(e)) {
RegisterResult.UsernameTaken RegisterResult.UsernameTaken
} else { } else {
RegisterResult.Error(parseClientError(e, unexpectedError)) RegisterResult.Error(parseClientError(e, unexpectedError))
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
RegisterResult.Error(unexpectedError, e) RegisterResult.Error(unexpectedError, e)
} }
@@ -225,11 +240,33 @@ fun AuthScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size) val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size)
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf(AuthRegisterDraft.username) }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf(AuthRegisterDraft.password) }
var confirmPassword by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
var displayName by remember { mutableStateOf("") } var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
var bio by remember { mutableStateOf("") } var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
var captchaRequired by remember { mutableStateOf(AuthRegisterDraft.captchaRequired) }
var captchaParams by remember { mutableStateOf(AuthRegisterDraft.captchaParams) }
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
val navController = LocalNavController.current
fun persistDraft() {
AuthRegisterDraft.username = username
AuthRegisterDraft.password = password
AuthRegisterDraft.confirmPassword = confirmPassword
AuthRegisterDraft.displayName = displayName
AuthRegisterDraft.bio = bio
AuthRegisterDraft.yandexRequired = yandexRequired
AuthRegisterDraft.yandexParams = yandexParams
AuthRegisterDraft.captchaRequired = captchaRequired
AuthRegisterDraft.captchaParams = captchaParams
AuthRegisterDraft.registrationProof = registrationProof
AuthRegisterDraft.captchaToken = captchaToken
AuthRegisterDraft.page = flowState.pagerState.currentPage
}
fun snackbar(text: String, cause: Throwable? = null) { fun snackbar(text: String, cause: Throwable? = null) {
scope.showLoggedSnackbar( scope.showLoggedSnackbar(
@@ -240,44 +277,113 @@ fun AuthScreen(
) )
} }
val wrappedAuthSuccess: () -> Unit = {
AuthRegisterDraft.clear()
onAuthSuccess()
}
val wrappedBackToWelcome: () -> Unit = {
AuthRegisterDraft.clear()
onBackToWelcome()
}
val resetToUsername: () -> Unit = { val resetToUsername: () -> Unit = {
username = "" username = ""
password = "" password = ""
confirmPassword = "" confirmPassword = ""
displayName = "" displayName = ""
bio = "" bio = ""
yandexRequired = false
yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null
captchaToken = null
AuthRegisterDraft.clear()
flowState.resetPredictiveState() flowState.resetPredictiveState()
scope.launch { scope.launch {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal)
} }
} }
LaunchedEffect(Unit) { DisposableEffect(Unit) {
username = "" onDispose { persistDraft() }
password = "" }
confirmPassword = ""
displayName = "" LaunchedEffect(
bio = "" username,
password,
confirmPassword,
displayName,
bio,
yandexRequired,
yandexParams,
captchaRequired,
captchaParams,
registrationProof,
captchaToken,
) {
persistDraft()
} }
LaunchedEffect(flowState.pagerState) { LaunchedEffect(flowState.pagerState) {
val restored = AuthRegisterDraft.page
if (restored in 1 until AuthFlowStep.entries.size &&
flowState.pagerState.currentPage != restored
) {
flowState.pagerState.scrollToPage(restored)
}
var settledPage = flowState.pagerState.currentPage var settledPage = flowState.pagerState.currentPage
snapshotFlow { flowState.pagerState.currentPage } snapshotFlow { flowState.pagerState.currentPage }
.collect { page -> .collect { page ->
AuthRegisterDraft.page = page
// Don't skip the Yandex slot mid predictive-back — that fights pager morph
// (Profile ↔ ConfirmPassword) and glitches when the gesture is cancelled.
if (page == AuthFlowStep.YandexId.ordinal &&
!yandexRequired &&
flowState.predictiveFromPage == null
) {
val target = if (page > settledPage) {
AuthFlowStep.Profile.ordinal
} else {
AuthFlowStep.ConfirmPassword.ordinal
}
settledPage = target
flowState.pagerState.scrollToPage(target)
return@collect
}
if (page < settledPage) { if (page < settledPage) {
when (page) { when (page) {
AuthFlowStep.Username.ordinal -> { AuthFlowStep.Username.ordinal -> {
password = "" password = ""
confirmPassword = "" confirmPassword = ""
yandexRequired = false
yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null
captchaToken = null
} }
AuthFlowStep.Password.ordinal -> { AuthFlowStep.Password.ordinal -> {
password = "" password = ""
confirmPassword = "" confirmPassword = ""
yandexRequired = false
yandexParams = null
captchaRequired = false
captchaParams = null
registrationProof = null
captchaToken = null
} }
AuthFlowStep.ConfirmPassword.ordinal -> { AuthFlowStep.ConfirmPassword.ordinal -> {
confirmPassword = "" // Keep confirm password when returning from Yandex ID / OAuth / captcha.
registrationProof = null
captchaToken = null
}
AuthFlowStep.YandexId.ordinal -> {
registrationProof = null
} }
} }
} }
@@ -285,6 +391,48 @@ fun AuthScreen(
} }
} }
// After predictive back settles on the skipped Yandex slot, jump to ConfirmPassword.
LaunchedEffect(flowState.predictiveFromPage, yandexRequired) {
if (flowState.predictiveFromPage != null || yandexRequired) return@LaunchedEffect
if (flowState.pagerState.currentPage == AuthFlowStep.YandexId.ordinal) {
flowState.pagerState.scrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_TOKEN, null).collect { token ->
if (token == null) return@collect
handle.remove<String>(SmartCaptchaNav.RESULT_TOKEN)
Logger.i(
SmartCaptchaLog.TAG,
"AuthScreen received token ${SmartCaptchaLog.redactToken(token)} → Profile",
)
captchaToken = token
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
LaunchedEffect(navController) {
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_ERROR, null).collect { message ->
if (message == null) return@collect
handle.remove<String>(SmartCaptchaNav.RESULT_ERROR)
Logger.w(SmartCaptchaLog.TAG, "AuthScreen captcha error: $message")
snackbar(message)
}
}
fun openCaptchaRoute(clientKey: String) {
Logger.i(
SmartCaptchaLog.TAG,
"navigate ${SmartCaptchaNav.ROUTE} clientKey=${SmartCaptchaLog.redactKey(clientKey)}",
)
SmartCaptchaNav.pending = SmartCaptchaNav.Session(clientKey = clientKey)
navController.navigate(SmartCaptchaNav.ROUTE)
}
val yandexStep = yandexParams
ExpressiveStepFlowScaffold( ExpressiveStepFlowScaffold(
flowState = flowState, flowState = flowState,
pages = listOf( pages = listOf(
@@ -300,8 +448,19 @@ fun AuthScreen(
username = username, username = username,
password = password, password = password,
onPasswordChange = { password = it }, onPasswordChange = { password = it },
onLoginSuccess = onAuthSuccess, onLoginSuccess = wrappedAuthSuccess,
onRegister = { onNeedsRegister = { required, params, captchaReq, captcha ->
Logger.i(
SmartCaptchaLog.TAG,
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
)
yandexRequired = required
yandexParams = params
captchaRequired = captchaReq
captchaParams = captcha
registrationProof = null
captchaToken = null
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
}, },
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
@@ -311,10 +470,56 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it }, onConfirmPasswordChange = { confirmPassword = it },
password = password, password = password,
onContinue = { onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
yandexRequired && yandexParams != null -> {
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
}
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
Logger.i(
SmartCaptchaLog.TAG,
"confirm → Profile captchaRequired=$captchaRequired " +
"hasToken=${!captchaToken.isNullOrBlank()}",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal) flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
}, },
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
), ),
if (yandexStep != null) {
yandexIdStepPage(
yandex = yandexStep,
onProof = { proof ->
registrationProof = proof
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
},
onSnackbar = ::snackbar,
)
} else {
confirmPasswordStepPage(
confirmPassword = confirmPassword,
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
},
onSnackbar = ::snackbar,
)
},
profileStepPage( profileStepPage(
username = username, username = username,
displayName = displayName, displayName = displayName,
@@ -322,12 +527,14 @@ fun AuthScreen(
bio = bio, bio = bio,
onBioChange = { bio = it }, onBioChange = { bio = it },
password = password, password = password,
onRegisterSuccess = onAuthSuccess, registrationProof = registrationProof,
captchaToken = captchaToken,
onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername, onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar, onSnackbar = ::snackbar,
), ),
), ),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
onBackAtFirstPage = onBackToWelcome, onBackAtFirstPage = wrappedBackToWelcome,
) )
} }
@@ -35,6 +35,8 @@ import ru.fromchat.login
import ru.fromchat.password import ru.fromchat.password
import ru.fromchat.password_length_error import ru.fromchat.password_length_error
import ru.fromchat.show_password import ru.fromchat.show_password
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
@@ -53,7 +55,12 @@ internal fun passwordStepPage(
password: String, password: String,
onPasswordChange: (String) -> Unit, onPasswordChange: (String) -> Unit,
onLoginSuccess: () -> Unit, onLoginSuccess: () -> Unit,
onRegister: suspend () -> Unit, onNeedsRegister: suspend (
yandexRequired: Boolean,
yandex: YandexOAuthParams?,
captchaRequired: Boolean,
captcha: SmartCaptchaParams?,
) -> Unit,
onSnackbar: (String, Throwable?) -> Unit, onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage { ): ExpressiveStepPage {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -121,7 +128,7 @@ internal fun passwordStepPage(
try { try {
when ( when (
val result = authBranch( val result = authPasswordStep(
username = username, username = username,
password = password, password = password,
wrongPasswordMessage = wrongPassword, wrongPasswordMessage = wrongPassword,
@@ -133,8 +140,13 @@ internal fun passwordStepPage(
onLoginSuccess() onLoginSuccess()
} }
is PasswordStepResult.AdvanceToRegister -> { is PasswordStepResult.NeedsRegister -> {
onRegister() onNeedsRegister(
result.yandexRequired,
result.yandex,
result.captchaRequired,
result.captcha,
)
} }
is PasswordStepResult.WrongPassword -> { is PasswordStepResult.WrongPassword -> {
@@ -17,12 +17,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.auth_server_connect_failed import ru.fromchat.auth_server_connect_failed
import ru.fromchat.auth_step_username_body import ru.fromchat.auth_step_username_body
import ru.fromchat.auth_step_username_title import ru.fromchat.auth_step_username_title
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields import ru.fromchat.fill_all_fields
import ru.fromchat.settings_next import ru.fromchat.settings_next
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
@@ -37,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.trackImeScrollTarget import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username import ru.fromchat.username
import ru.fromchat.username_chars_error
import ru.fromchat.username_length_error import ru.fromchat.username_length_error
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
private fun isAllowedUsernameChar(ch: Char) =
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
internal fun usernameStepPage( internal fun usernameStepPage(
@@ -54,8 +64,11 @@ internal fun usernameStepPage(
val fillAll = stringResource(Res.string.fill_all_fields) val fillAll = stringResource(Res.string.fill_all_fields)
val usernameLenError = stringResource(Res.string.username_length_error) val usernameLenError = stringResource(Res.string.username_length_error)
val usernameCharsError = stringResource(Res.string.username_chars_error)
val serverFail = stringResource(Res.string.auth_server_connect_failed) val serverFail = stringResource(Res.string.auth_server_connect_failed)
val unexpected = stringResource(Res.string.error_unexpected)
val nextLabel = stringResource(Res.string.settings_next) val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage( return ExpressiveStepPage(
hero = ExpressiveHeroSpec( hero = ExpressiveHeroSpec(
@@ -84,6 +97,12 @@ internal fun usernameStepPage(
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY) .trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding), .padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true, singleLine = true,
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(), colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape, shape = SettingsPasswordOutlineFieldShape,
) )
@@ -92,7 +111,7 @@ internal fun usernameStepPage(
button = { button = {
ActionButton( ActionButton(
onClick = { onClick = {
if (busy) return@ActionButton if (busy || hasProhibitedChars) return@ActionButton
val trimmed = username.trim() val trimmed = username.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
onSnackbar(fillAll, null) onSnackbar(fillAll, null)
@@ -109,14 +128,28 @@ internal fun usernameStepPage(
if (!probeCurrentServer()) { if (!probeCurrentServer()) {
onSnackbar(serverFail, null) onSnackbar(serverFail, null)
} else { } else {
try {
ApiClient.authUsernameStep(trimmed)
onContinue() onContinue()
} catch (e: ClientRequestException) {
val detail = if (e.response.status.value == 400) {
runCatching { e.response.body<ErrorResponse>().detail }
.getOrNull()
?.ifBlank { null }
} else {
null
}
onSnackbar(detail ?: unexpected, e)
} catch (e: Exception) {
onSnackbar(unexpected, e)
}
} }
} finally { } finally {
busy = false busy = false
} }
} }
}, },
enabled = !busy, enabled = !busy && !hasProhibitedChars,
loading = busy, loading = busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -0,0 +1,27 @@
package ru.fromchat.ui.auth.captcha
/** Safe summaries for SmartCaptcha logs (never dump full tokens/secrets). */
internal object SmartCaptchaLog {
const val TAG = "SmartCaptcha"
fun redactKey(value: String?): String {
val v = value?.trim().orEmpty()
if (v.isEmpty()) return "(empty)"
if (v.length <= 8) return "len=${v.length}"
return "len=${v.length} prefix=${v.take(4)}…suffix=${v.takeLast(4)}"
}
fun redactToken(value: String?): String {
val v = value?.trim().orEmpty()
if (v.isEmpty()) return "(empty)"
return "len=${v.length} prefix=${v.take(6)}"
}
fun shortUrl(url: String?): String {
if (url.isNullOrBlank()) return "null"
// Drop query sitekey from logs; keep path/host.
val q = url.indexOf('?')
val base = if (q >= 0) url.substring(0, q) else url
return if (base.length <= 120) base else base.take(117) + "..."
}
}
@@ -0,0 +1,32 @@
package ru.fromchat.ui.auth.captcha
import androidx.compose.runtime.saveable.listSaver
import kotlin.concurrent.Volatile
/**
* Root [androidx.navigation.NavController] route for SmartCaptcha.
* Client key is staged in [pending] before navigate (not embedded in the route).
*/
internal object SmartCaptchaNav {
const val ROUTE = "smartCaptcha"
const val RESULT_TOKEN = "smartcaptcha_token"
const val RESULT_ERROR = "smartcaptcha_error"
data class Session(
val clientKey: String,
)
val SessionSaver = listSaver<Session?, String>(
save = { session ->
if (session == null) emptyList()
else listOf(session.clientKey)
},
restore = { saved ->
if (saved.isEmpty()) null
else Session(clientKey = saved[0])
},
)
@Volatile
var pending: Session? = null
}
@@ -0,0 +1,176 @@
package ru.fromchat.ui.auth.captcha
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.intl.Locale
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.auth_captcha_failed
import ru.fromchat.auth_captcha_title
import ru.fromchat.back
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
/**
* Full-route SmartCaptcha screen (shown after password confirm when Yandex OAuth is off).
* Returns a token / error via [SmartCaptchaNav] saved-state results.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun SmartCaptchaScreen() {
val navController = LocalNavController.current
var session by rememberSaveable(stateSaver = SmartCaptchaNav.SessionSaver) {
mutableStateOf(SmartCaptchaNav.pending)
}
var pageReady by remember { mutableStateOf(false) }
val failedMessage = stringResource(Res.string.auth_captcha_failed)
val barColor = MaterialTheme.colorScheme.surfaceContainer
val languageTag = Locale.current.toLanguageTag()
val screenId = remember { (100000..999999).random().toString(16) }
DisposableEffect(screenId) {
Logger.i(
SmartCaptchaLog.TAG,
"route enter id=$screenId sessionNull=${session == null} " +
"pendingNull=${SmartCaptchaNav.pending == null} " +
"clientKey=${SmartCaptchaLog.redactKey(session?.clientKey)} languageTag=$languageTag",
)
onDispose {
Logger.i(SmartCaptchaLog.TAG, "route dispose id=$screenId pageReady=$pageReady")
}
}
LaunchedEffect(session) {
if (session == null) {
Logger.w(SmartCaptchaLog.TAG, "session null → popBackStack id=$screenId")
navController.popBackStack()
} else {
SmartCaptchaNav.pending = session
}
}
val active = session ?: return
fun finishWithToken(token: String) {
Logger.i(
SmartCaptchaLog.TAG,
"finishWithToken id=$screenId ${SmartCaptchaLog.redactToken(token)}",
)
SmartCaptchaNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(SmartCaptchaNav.RESULT_TOKEN, token)
navController.popBackStack()
}
fun finishWithError(message: String) {
Logger.w(SmartCaptchaLog.TAG, "finishWithError id=$screenId message=$message")
SmartCaptchaNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(SmartCaptchaNav.RESULT_ERROR, message)
navController.popBackStack()
}
fun cancel() {
Logger.i(SmartCaptchaLog.TAG, "cancel id=$screenId")
SmartCaptchaNav.pending = null
navController.popBackStack()
}
Scaffold(
modifier = Modifier.fillMaxSize(),
// Top bar draws into the status bar; bottom nav-bar inset keeps the WebView above it
// while [containerColor] still paints the gesture/nav area.
contentWindowInsets = WindowInsets.navigationBars,
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.auth_captcha_title)) },
navigationIcon = {
IconButton(onClick = { cancel() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = barColor,
scrolledContainerColor = barColor,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
),
)
},
containerColor = barColor,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.TopCenter,
) {
SmartCaptchaWebView(
sitekey = active.clientKey,
languageTag = languageTag,
modifier = Modifier.fillMaxSize(),
onToken = { finishWithToken(it) },
onReady = {
Logger.i(SmartCaptchaLog.TAG, "route pageReady id=$screenId")
pageReady = true
},
onChallengeVisible = {
Logger.i(SmartCaptchaLog.TAG, "route challengeVisible id=$screenId")
},
onChallengeHidden = {
Logger.i(SmartCaptchaLog.TAG, "route challengeHidden id=$screenId")
},
onError = { message ->
finishWithError(message.ifBlank { failedMessage })
},
)
androidx.compose.animation.AnimatedVisibility(
visible = !pageReady,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(barColor),
contentAlignment = Alignment.Center,
) {
CircularWavyProgressIndicator()
}
}
}
}
}
@@ -0,0 +1,19 @@
package ru.fromchat.ui.auth.captcha
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Platform WebView that loads Yandex SmartCaptcha and reports the verification token.
*/
@Composable
expect fun SmartCaptchaWebView(
sitekey: String,
languageTag: String,
modifier: Modifier = Modifier,
onToken: (String) -> Unit,
onReady: () -> Unit = {},
onChallengeVisible: () -> Unit = {},
onChallengeHidden: () -> Unit = {},
onError: (String) -> Unit = {},
)
@@ -55,6 +55,8 @@ internal fun profileStepPage(
bio: String, bio: String,
onBioChange: (String) -> Unit, onBioChange: (String) -> Unit,
password: String, password: String,
registrationProof: String?,
captchaToken: String?,
onRegisterSuccess: () -> Unit, onRegisterSuccess: () -> Unit,
onUsernameTaken: () -> Unit, onUsernameTaken: () -> Unit,
onSnackbar: (String, Throwable?) -> Unit, onSnackbar: (String, Throwable?) -> Unit,
@@ -147,6 +149,8 @@ internal fun profileStepPage(
displayName = displayName.trim(), displayName = displayName.trim(),
password = password, password = password,
bio = bio.trim(), bio = bio.trim(),
registrationProof = registrationProof,
captchaToken = captchaToken,
unexpectedError = unexpected, unexpectedError = unexpected,
) )
) { ) {
@@ -0,0 +1,130 @@
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)
}
},
)
}
@@ -0,0 +1,34 @@
package ru.fromchat.ui.auth.yandex
import androidx.compose.runtime.saveable.listSaver
import kotlin.concurrent.Volatile
/**
* Root [androidx.navigation.NavController] route for the Yandex OAuth WebView.
* Session is staged in [pending] before [navigate]; PKCE verifier must not go in the route.
* Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
*/
internal object YandexOAuthNav {
const val ROUTE = "yandexOAuth"
const val RESULT_PROOF = "yandex_registration_proof"
const val RESULT_ERROR = "yandex_oauth_error"
data class Session(
val authorizeUrl: String,
val codeVerifier: String,
)
val SessionSaver = listSaver<Session?, String>(
save = { session ->
if (session == null) emptyList()
else listOf(session.authorizeUrl, session.codeVerifier)
},
restore = { saved ->
if (saved.size < 2) null
else Session(authorizeUrl = saved[0], codeVerifier = saved[1])
},
)
@Volatile
var pending: Session? = null
}
@@ -0,0 +1,171 @@
package ru.fromchat.ui.auth.yandex
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_yandex_failed
import ru.fromchat.back
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.isAppInDarkTheme
private const val LOG_TAG = "YandexOAuthScreen"
@Composable
internal fun YandexOAuthScreen() {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
// Survive Activity recreate / process restore — in-memory [YandexOAuthNav.pending] alone does not.
var session by rememberSaveable(stateSaver = YandexOAuthNav.SessionSaver) {
mutableStateOf(YandexOAuthNav.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_yandex_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=${YandexOAuthNav.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 {
YandexOAuthNav.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")
YandexOAuthNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(YandexOAuthNav.RESULT_PROOF, proof)
navController.popBackStack()
}
fun finishWithError(message: String) {
Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
YandexOAuthNav.pending = null
navController.previousBackStackEntry
?.savedStateHandle
?.set(YandexOAuthNav.RESULT_ERROR, message)
navController.popBackStack()
}
fun cancel() {
if (busy) return
Logger.i(LOG_TAG, "cancel id=$screenId")
YandexOAuthNav.pending = null
navController.popBackStack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(chromeColor),
) {
YandexOAuthWebView(
authorizeUrl = active.authorizeUrl,
languageTag = Locale.current.toLanguageTag(),
darkTheme = darkTheme,
fallbackColor = fallbackColor,
onPageBackgroundColor = { chromeColor = it },
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
onCode = { code ->
if (busy) return@YandexOAuthWebView
scope.launch {
busy = true
try {
val proof = ApiClient.authYandexExchange(code, active.codeVerifier).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() },
)
// Visible when back exits to our Yandex ID step (WebView has no in-page history).
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,28 @@
package ru.fromchat.ui.auth.yandex
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* 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.
*/
@Composable
expect fun YandexOAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
clearCookies: Boolean = false,
onPageBackgroundColor: (Color) -> Unit = {},
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
onCode: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
)
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.messages.generateClientMessageId import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
@@ -93,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock { addMessageMutex.withLock {
batchStateUpdates { batchStateUpdates {
updateState { current -> updateState { current ->
val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState( val merged = mergeDatabaseMessagesWithPanelState(
panelMessagesForDbMerge(), panelSnap,
messages, messages,
) )
val withReplies = attachPublicReplyReferences(merged) val withReplies = attachPublicReplyReferences(merged)
if (current.messages.size != withReplies.size ||
current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
) {
val panelIds = panelSnap.map { it.id }.toSet()
val mergedIds = withReplies.map { it.id }.toSet()
val dbIds = messages.map { it.id }.toSet()
Logger.d(
"ChatPanel",
"syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
"merged=${withReplies.size} " +
"panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
"dbOnlyIds=${(dbIds - mergedIds).take(8)}",
)
}
if (current.messages == withReplies) current if (current.messages == withReplies) current
else current.copy(messages = withReplies) else current.copy(messages = withReplies)
} }
@@ -611,7 +627,7 @@ abstract class ChatPanel(
val tempId = generateClientMessageId() val tempId = generateClientMessageId()
val newOptimistic = message.copy( val newOptimistic = message.copy(
id = uniqueOptimisticMessageId(), id = optimisticMessageIdForClientMessageId(tempId),
client_message_id = tempId client_message_id = tempId
) )
@@ -691,14 +707,15 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
client_message_id = tempId, client_message_id = tempId,
reply_to = resolvedReply, reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 }, replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
) )
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic. // Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId()) val optimistic = tempMessage.copy(id = optimisticMessageIdForClientMessageId(tempId))
addMessage(optimistic) addMessage(optimistic)
Logger.d( Logger.d(
@@ -743,14 +760,6 @@ abstract class ChatPanel(
} }
} }
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
var id: Int
do {
id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
} while (_state.messages.any { it.id == id })
id
}
/** Persist optimistic row for offline / process death; no-op by default. */ /** Persist optimistic row for offline / process death; no-op by default. */
protected open suspend fun persistOptimisticMessage(message: Message) {} protected open suspend fun persistOptimisticMessage(message: Message) {}
@@ -71,11 +71,6 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import ru.fromchat.api.local.messages.formatChatDateSeparator import ru.fromchat.api.local.messages.formatChatDateSeparator
import ru.fromchat.chat_date_today import ru.fromchat.chat_date_today
@@ -200,49 +195,9 @@ fun ChatScreen(
LazyListState(0, 0) LazyListState(0, 0)
} }
val fakeScrollOffset = remember(panelId) { Animatable(0f) } val fakeScrollOffset = remember(panelId) { Animatable(0f) }
LaunchedEffect(panelId) {
// #region agent log
agentDebugLog(
hypothesisId = "H4",
location = "ChatScreen.kt:188",
message = "chat_screen_opened",
data = mapOf(
"panelId" to panelId,
"listStateHash" to listState.hashCode(),
),
)
// #endregion
}
val isNearBottom by remember(panelId) { val isNearBottom by remember(panelId) {
derivedStateOf { listState.isChatNearBottom() } derivedStateOf { listState.isChatNearBottom() }
} }
LaunchedEffect(listState, panelId) {
snapshotFlow {
val info = listState.layoutInfo
Triple(
info.totalItemsCount,
listState.firstVisibleItemIndex,
listState.firstVisibleItemScrollOffset,
)
}
.distinctUntilChanged()
.collect { (totalItems, firstIndex, firstOffset) ->
// #region agent log
agentDebugLog(
hypothesisId = "H1",
location = "ChatScreen.kt:196",
message = "scroll_state_changed",
data = mapOf(
"panelId" to panelId,
"totalItems" to totalItems,
"firstVisibleItemIndex" to firstIndex,
"firstVisibleItemScrollOffset" to firstOffset,
"isNearBottom" to listState.isChatNearBottom(),
),
)
// #endregion
}
}
val density = LocalDensity.current val density = LocalDensity.current
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -360,11 +315,20 @@ fun ChatScreen(
visitEnterSyncComplete = true visitEnterSyncComplete = true
} }
// First non-empty load for this visit: never animate existing history. // First non-empty load for this visit: seed history without animating.
// Exception: empty → single live/own message should animate (first bubble in chat).
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) { if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
val isLiveFirstMessage =
previousCount == 0 &&
messages.size == 1 &&
sizeDelta == 1
if (!isLiveFirstMessage) {
seedWithoutAnimating("seed_first_load") seedWithoutAnimating("seed_first_load")
return@LaunchedEffect return@LaunchedEffect
} }
enterAnimationsSeeded = true
visitEnterSyncComplete = true
}
previousNewestFingerprint = fingerprint previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size previousEnterMessageCount = messages.size
@@ -458,6 +422,7 @@ fun ChatScreen(
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ } val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val navController = LocalNavController.current val navController = LocalNavController.current
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId val profileUserId = panelState.profileUserId
@@ -485,6 +450,7 @@ fun ChatScreen(
userId = userId, userId = userId,
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = profile.deleted, deleted = profile.deleted,
suspended = profile.suspended,
username = profile.username, username = profile.username,
) )
} }
@@ -745,14 +711,25 @@ fun ChatScreen(
msg.id == menuMessage.id msg.id == menuMessage.id
} }
} }
if (liveMessage == null) { if (liveMessage == null) {
contextMenuState = contextMenuState.copy(isOpen = false, message = null) contextMenuState = contextMenuState.copy(isOpen = false, message = null)
return@LaunchedEffect return@LaunchedEffect
} }
val liveAuthor = liveMessage.user_id == currentUserId val liveAuthor = liveMessage.user_id == currentUserId
val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly) val liveFp = messageContextMenuFingerprint(
liveMessage,
liveAuthor,
liveAuthor || currentUserId == 1,
isReadOnly,
)
val menuAuthor = menuMessage.user_id == currentUserId val menuAuthor = menuMessage.user_id == currentUserId
val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly) val menuFp = messageContextMenuFingerprint(
menuMessage,
menuAuthor,
menuAuthor || currentUserId == 1,
isReadOnly,
)
if (menuFp != liveFp || liveMessage.id != menuMessage.id) { if (menuFp != liveFp || liveMessage.id != menuMessage.id) {
contextMenuState = contextMenuState.copy(message = liveMessage) contextMenuState = contextMenuState.copy(message = liveMessage)
} }
@@ -972,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(), timestamp = nowMessageTimestampIso(),
is_read = false, is_read = false,
is_edited = false, is_edited = false,
username = "You", username = ApiClient.user?.username.orEmpty(),
displayName = ApiClient.user?.displayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = replyTo, reply_to = replyTo,
@@ -1211,7 +1189,7 @@ fun ChatScreen(
.getOrNull(panelState.messages.lastIndex - 1) .getOrNull(panelState.messages.lastIndex - 1)
previous != null && previous != null &&
messageListKey(previous) == listKey && messageListKey(previous) == listKey &&
classifyEnterMode(previous, newest!!) == classifyEnterMode(previous, newest) ==
EnterMode.ExtendGroup EnterMode.ExtendGroup
} }
val showTimestamp = when { val showTimestamp = when {
@@ -1351,23 +1329,6 @@ fun ChatScreen(
val showScrollToBottomFab = !isNearBottom && val showScrollToBottomFab = !isNearBottom &&
panelState.messages.isNotEmpty() && panelState.messages.isNotEmpty() &&
!contextMenuState.isOpen !contextMenuState.isOpen
LaunchedEffect(showScrollToBottomFab) {
// #region agent log
agentDebugLog(
hypothesisId = "H2",
location = "ChatScreen.kt:1338",
message = "show_scroll_button_changed",
data = mapOf(
"panelId" to panelId,
"showScrollToBottomFab" to showScrollToBottomFab,
"isNearBottom" to isNearBottom,
"messageCount" to panelState.messages.size,
"contextMenuOpen" to contextMenuState.isOpen,
"usesPublicGroupSubtitle" to panel.usesPublicGroupSubtitle,
),
)
// #endregion
}
AnimatedVisibility( AnimatedVisibility(
visible = showScrollToBottomFab, visible = showScrollToBottomFab,
enter = fadeIn( enter = fadeIn(
@@ -1405,19 +1366,6 @@ fun ChatScreen(
ChatScrollToBottomButton( ChatScrollToBottomButton(
onClick = { onClick = {
scope.launch { scope.launch {
// #region agent log
agentDebugLog(
hypothesisId = "H5",
location = "ChatScreen.kt:1402",
message = "scroll_button_jump_to_bottom",
data = mapOf(
"panelId" to panelId,
"fromIndex" to listState.firstVisibleItemIndex,
"fromOffset" to listState.firstVisibleItemScrollOffset,
),
)
// #endregion
// Snap list to bottom first so content is correct. // Snap list to bottom first so content is correct.
listState.scrollChatToBottom() listState.scrollChatToBottom()
@@ -1440,7 +1388,9 @@ fun ChatScreen(
ChatTopBar( ChatTopBar(
hazeState = hazeState, hazeState = hazeState,
onBack = { navController.navigateUp() }, onBack = {
runNav { navController.navigateUp() }
},
backContentDescription = stringResource(Res.string.back), backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly, showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = { onCallClick = {
@@ -1455,7 +1405,9 @@ fun ChatScreen(
title = panelState.title, title = panelState.title,
titleAvatar = panelState.titleAvatar, titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId, profileUserId = profileUserId,
onTitleClick = onTitleClick, onTitleClick = onTitleClick?.let { click ->
{ runNav(click) }
},
hideTitleBarAvatar = hideTitleBarAvatar, hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds, onAvatarSlotBounds = onAvatarSlotBounds,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -1482,6 +1434,8 @@ fun ChatScreen(
MessageContextMenu( MessageContextMenu(
state = contextMenuState, state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId, isAuthor = contextMenuState.message?.user_id == currentUserId,
canDelete = contextMenuState.message?.user_id == currentUserId ||
currentUserId == 1,
isReadOnly = isReadOnly, isReadOnly = isReadOnly,
screenWidthPx = screenWidthPx, screenWidthPx = screenWidthPx,
screenHeightPx = screenHeightPx, screenHeightPx = screenHeightPx,
@@ -1636,16 +1590,6 @@ private fun ChatScrollToBottomButton(
.size(ChatScrollToBottomButtonSize) .size(ChatScrollToBottomButtonSize)
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
// #region agent log
agentDebugLog(
hypothesisId = "H3",
location = "ChatScreen.kt:1519",
message = "scroll_button_clicked",
data = mapOf(
"contentDescription" to contentDescription,
),
)
// #endregion
onClick() onClick()
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -1665,70 +1609,6 @@ private fun ChatScrollToBottomButton(
} }
} }
private fun agentDebugLog(
hypothesisId: String,
location: String,
message: String,
data: Map<String, Any?> = emptyMap(),
runId: String = "initial",
) {
val timestamp = kotlin.time.Clock.System.now().toEpochMilliseconds()
fun escape(value: String): String {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
}
val dataJson = buildString {
append("{")
data.entries.joinToString(",") { (key, rawValue) ->
val value = rawValue?.toString() ?: "null"
"\"${escape(key)}\":\"${escape(value)}\""
}.let { append(it) }
append("}")
}
val json = buildString {
append("{")
append("\"sessionId\":\"a042f7\",")
append("\"id\":\"log_")
append(timestamp)
append("_")
append(escape(hypothesisId))
append("\",")
append("\"timestamp\":")
append(timestamp)
append(",")
append("\"location\":\"")
append(escape(location))
append("\",")
append("\"message\":\"")
append(escape(message))
append("\",")
append("\"runId\":\"")
append(escape(runId))
append("\",")
append("\"hypothesisId\":\"")
append(escape(hypothesisId))
append("\",")
append("\"data\":")
append(dataJson)
append("}")
}
Logger.d("AgentDebug", json)
kotlinx.coroutines.GlobalScope.launch {
runCatching {
ApiClient.http.post("http://192.168.1.6:7629/ingest/edf4b1c4-ae73-4d46-9110-6235fc587a70") {
contentType(ContentType.Application.Json)
header("X-Debug-Session-Id", "a042f7")
setBody(json)
}
}
}
}
private suspend fun LazyListState.scrollChatMessageToCenter( private suspend fun LazyListState.scrollChatMessageToCenter(
lazyIndex: Int, lazyIndex: Int,
topClearancePx: Int, topClearancePx: Int,
@@ -32,8 +32,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavController
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@@ -68,6 +75,7 @@ import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI import kotlin.math.PI
@@ -98,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted( peerIsDeleted(
userId = userId, userId = userId,
currentUserId = ApiClient.user?.id, currentUserId = ApiClient.user?.id,
username = titleAvatar?.displayName ?: title, username = ProfileCache.get(userId)?.username,
) )
} == true } == true
Row( Row(
@@ -196,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
profileUserId?.let { userId -> profileUserId?.let { userId ->
val status = resolveVerificationStatus(userId) val profileCacheRevision by ProfileCache.revision.collectAsState()
val status = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
if (status != null) { if (status != null) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
StatusBadge( StatusBadge(
@@ -258,8 +269,12 @@ fun ChatTopBarInner(
} }
key == "typing" -> { key == "typing" -> {
val me = ApiClient.user?.id
TypingIndicator( TypingIndicator(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { user ->
ProfileCache.get(user.userId)?.visibleDisplayName(me)
?: user.username
},
showUsernames = typingShowsUsernames, showUsernames = typingShowsUsernames,
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
) )
@@ -447,3 +462,28 @@ fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
) )
} }
} }
/**
* Single-flight gate for chat title/avatar navigate vs back during shared-element transitions.
* Ignores taps while a transition is running or the current destination is not resumed.
*/
@Composable
fun rememberChatNavigationGate(
navController: NavController,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
): (() -> Unit) -> Unit {
var locked by remember { mutableStateOf(false) }
val entry = navController.currentBackStackEntry
LaunchedEffect(entry) {
locked = false
}
return { action ->
val transitionRunning = animatedVisibilityScope?.transition?.isRunning == true
val lifecycleOk = navController.currentBackStackEntry?.lifecycle?.currentState
?.let { it == Lifecycle.State.RESUMED } != false
if (!locked && !transitionRunning && lifecycleOk) {
locked = true
action()
}
}
}
@@ -18,13 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -711,25 +711,26 @@ fun ImageFullscreenPreview(
} }
// Top bar: back, display name + date/time, 3-dot menu // Top bar: back, display name + date/time, 3-dot menu
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
) {
AnimatedVisibility( AnimatedVisibility(
visible = effectiveMenusVisible, visible = effectiveMenusVisible,
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.statusBarsPadding(),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp), .padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
IconButton(onClick = { dismissRequested = true }) { IconButton(onClick = { dismissRequested = true }) {
Icon( Icon(
@@ -796,6 +797,9 @@ fun ImageFullscreenPreview(
}, },
) )
} }
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -808,42 +812,42 @@ fun ImageFullscreenPreview(
menuExpanded = false menuExpanded = false
onDelete(message) onDelete(message)
dismissRequested = true dismissRequested = true
} },
) )
} }
} }
} }
} }
} }
}
// Bottom: message text // Bottom: message text
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
) {
AnimatedVisibility( AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(), visible = effectiveMenusVisible && message.content.isNotBlank(),
enter = androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.fadeOut(), exit = androidx.compose.animation.fadeOut(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
) { ) {
if (message.content.isNotBlank()) { Column(
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = MENU_BG_ALPHA)) .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.navigationBarsPadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White color = Color.White,
) )
} }
} }
} }
} }
}
} }
@@ -75,6 +75,7 @@ data class ContextMenuState(
internal fun messageContextMenuFingerprint( internal fun messageContextMenuFingerprint(
message: Message, message: Message,
isAuthor: Boolean, isAuthor: Boolean,
canDelete: Boolean,
isReadOnly: Boolean, isReadOnly: Boolean,
): String { ): String {
val isQueued = message.isQueuedOutbound() && isAuthor val isQueued = message.isQueuedOutbound() && isAuthor
@@ -88,7 +89,7 @@ internal fun messageContextMenuFingerprint(
if (!isQueued && !isReadOnly) { if (!isQueued && !isReadOnly) {
append("|reply=1") append("|reply=1")
append("|edit=").append(isAuthor && !corrupted) append("|edit=").append(isAuthor && !corrupted)
append("|del=").append(isAuthor) append("|del=").append(canDelete)
} }
} }
} }
@@ -97,6 +98,7 @@ internal fun messageContextMenuFingerprint(
fun MessageContextMenu( fun MessageContextMenu(
state: ContextMenuState, state: ContextMenuState,
isAuthor: Boolean, isAuthor: Boolean,
canDelete: Boolean = isAuthor,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onReply: (Message) -> Unit, onReply: (Message) -> Unit,
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
@@ -161,6 +163,7 @@ fun MessageContextMenu(
ContextMenuContent( ContextMenuContent(
message = state.message, message = state.message,
isAuthor = isAuthor, isAuthor = isAuthor,
canDelete = canDelete,
onReply = {}, onReply = {},
onEdit = {}, onEdit = {},
onDelete = {}, onDelete = {},
@@ -242,6 +245,7 @@ fun MessageContextMenu(
ContextMenuContent( ContextMenuContent(
message = state.message, message = state.message,
isAuthor = isAuthor, isAuthor = isAuthor,
canDelete = canDelete,
onReply = { onReply = {
onReply(it) onReply(it)
onDismiss() onDismiss()
@@ -287,6 +291,7 @@ fun MessageContextMenu(
private fun ContextMenuContent( private fun ContextMenuContent(
message: Message, message: Message,
isAuthor: Boolean, isAuthor: Boolean,
canDelete: Boolean,
onReply: (Message) -> Unit, onReply: (Message) -> Unit,
onEdit: (Message) -> Unit, onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit, onDelete: (Message) -> Unit,
@@ -402,7 +407,7 @@ private fun ContextMenuContent(
onClick = { onEdit(message) } onClick = { onEdit(message) }
) )
} }
if (isAuthor) { if (canDelete) {
ContextMenuItem( ContextMenuItem(
icon = Icons.Rounded.Delete, icon = Icons.Rounded.Delete,
text = labelDelete, text = labelDelete,
@@ -10,12 +10,13 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.ui.profile.avatarLabelForInitials import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.message_sender_you import ru.fromchat.message_sender_you
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername import ru.fromchat.ui.profile.isDeletedAccountUsername
import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
/** /**
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name. * Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
* cached/server display name, or login username only as a last resort.
*/ */
@Composable @Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String { fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -23,26 +24,31 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
return stringResource(Res.string.message_sender_you) return stringResource(Res.string.message_sender_you)
} }
ProfileCache.get(message.user_id)?.let { profile -> ProfileCache.get(message.user_id)?.let { profile ->
if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) { if (profile.isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
} }
if (isDeletedAccountUsername(message.username)) { if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId) ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
if (cachedUsername != null) return cachedUsername ?.takeIf { it.isNotBlank() }
?.let { return it }
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) { if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
return message.username return message.username.trim()
} }
fun messageSenderProfilePicture( fun messageSenderProfilePicture(
message: Message, message: Message,
currentUserId: Int? = ApiClient.user?.id, currentUserId: Int? = ApiClient.user?.id,
): String? { ): String? {
if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) { if (ProfileCache.get(message.user_id)?.isRedactedPeerAccount(currentUserId) == true) {
return null
}
if (isDeletedAccountUsername(message.username)) {
return null return null
} }
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
@@ -72,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) { if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty() return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
} }
return message.username.trim() return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
} }
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -204,7 +205,6 @@ fun MessageItem(
isMessageCorrupted(message) isMessageCorrupted(message)
} }
val primaryIsImageMessage = remember( val primaryIsImageMessage = remember(
message.reply_to,
message.pendingFileUri, message.pendingFileUri,
message.pendingFilename, message.pendingFilename,
message.files, message.files,
@@ -219,10 +219,8 @@ fun MessageItem(
) )
else -> false else -> false
} }
message.reply_to == null && (
pendingIsImage || pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
} }
val formattedTime = remember(message.timestamp) { val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp) formatMessageTimeLocal(message.timestamp)
@@ -232,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed) val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo) val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id) val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture ?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId) val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
resolveVerificationStatus(message.user_id, message)
}
val isDeletedSender = messageSenderIsDeleted(message, currentUserId) val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to val replyRef = message.reply_to
@@ -569,10 +570,8 @@ fun MessageItem(
} else { } else {
null null
} }
val primaryIsImageContent = message.reply_to == null && ( val primaryIsImageContent = pendingIsImage ||
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
val hasImageCaption = val hasImageCaption =
message.content.isNotBlank() && message.content.isNotBlank() &&
!isFilenameOnlyMessageCaption(message) !isFilenameOnlyMessageCaption(message)
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
downloadPaused && canDownload -> file?.let { downloadFile -> downloadPaused && file != null && canDownload -> {
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = downloadFile, file = file,
dmEnvelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
} }
} }
} }
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile -> file != null && canDownload && !isDownloading && !downloadPaused -> {
{ {
AttachmentDownloadNotifier.beginDownload( AttachmentDownloadNotifier.beginDownload(
messageId = messageId, messageId = messageId,
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
val ok = downloadAttachmentToCache( val ok = downloadAttachmentToCache(
messageId = messageId, messageId = messageId,
fileIndex = fileIndex, fileIndex = fileIndex,
file = downloadFile, file = file,
dmEnvelope = dmEnvelope, dmEnvelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -46,6 +47,7 @@ fun DmChatRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) } val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" } val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
DmScreen( DmScreen(
@@ -54,8 +56,10 @@ fun DmChatRoute(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
onTitleClick = { onTitleClick = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened) haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId)) navController.navigate(DmNav.profileRoute(otherUserId))
}
}, },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -74,6 +78,7 @@ fun DmProfileRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) } val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" } val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
val stateSnapshot = panel.getState() val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() } val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
@@ -92,12 +97,16 @@ fun DmProfileRoute(
userId = otherUserId, userId = otherUserId,
showBackButton = true, showBackButton = true,
onBack = { onBack = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
onChat = { onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -30,7 +30,7 @@ import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.DmDeletedData import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.ui.profile.displayNameText import ru.fromchat.ui.profile.displayNameText
import ru.fromchat.ui.profile.isDeletedAccount import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
@@ -120,7 +120,7 @@ class DmPanel(
try { try {
val profile = ApiClient.getProfileById(otherUserId) val profile = ApiClient.getProfileById(otherUserId)
if ( if (
!profile.isDeletedAccount(ApiClient.user?.id) && !profile.isRedactedPeerAccount(ApiClient.user?.id) &&
profile.username.isBlank() && profile.username.isBlank() &&
profile.displayName.isNullOrBlank() profile.displayName.isNullOrBlank()
) { ) {
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId) val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty() val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
Logger.d(
"DmPanel",
"applyCachedPeerProfile otherUserId=$otherUserId " +
"deleted=${cached?.deleted} suspended=${cached?.suspended} " +
"revision=${ProfileCache.revision.value} " +
"titleBlank=${displayName.isBlank()}",
)
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) } cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) { if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile). // when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) { val hadCachedMessages = cached.isNotEmpty()
if (hadCachedMessages) {
batchStateUpdates { batchStateUpdates {
clearMessages() clearMessages()
addMessages(cached) addMessages(cached)
setLoading(false) setLoading(false)
} }
messagesLoaded = true } else {
return setLoading(true)
} }
setLoading(true)
try { try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(), CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use. // Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages val mergedForCache = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache) MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true messagesLoaded = true
} else { } else {
val error = historyResult.exceptionOrNull() val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages() clearMessages()
setHasMoreMessages(false) setHasMoreMessages(false)
messagesLoaded = true messagesLoaded = true
} else if (hadCachedMessages) {
messagesLoaded = true
} }
} }
} finally { } finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence) MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid) OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
} }
val snapshot = _state.messages
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
} }
} }
@@ -533,31 +540,46 @@ class DmPanel(
} }
val outcome = decryptDmEnvelopeForUi(envelope) val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext) val dec = parseDmMessageContent(outcome.plaintext)
updateMessage(envelope.id) { val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
it.copy(
content = dec.text, content = dec.text,
is_edited = true, is_edited = true,
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails, fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios, fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
fileSizes = dec.fileSizes ?: it.fileSizes, fileSizes = dec.fileSizes ?: previous?.fileSizes,
fileDimensions = dec.fileDimensions ?: it.fileDimensions, fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
isContentCorrupted = outcome.isCorrupted, isContentCorrupted = outcome.isCorrupted,
dmEnvelope = envelope, dmEnvelope = envelope,
reply_to = it.reply_to, reply_to = previous?.reply_to,
) )
updateMessage(envelope.id) {
editedForCache.copy(reply_to = it.reply_to)
} }
// Persist edit to cache MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
} }
} }
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message { private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val username = if (envelope.senderId == currentUserId) { val senderUsername = if (envelope.senderId == currentUserId) {
"You" ApiClient.user?.username.orEmpty()
} else { } else {
otherDisplayName envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(envelope.senderId)?.username?.trim().orEmpty()
}
val senderDisplayName = if (envelope.senderId == currentUserId) {
ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
} else {
envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
?: otherDisplayName.takeIf { it.isNotBlank() }
?: ProfileCache.get(envelope.senderId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
if (envelope.senderId != currentUserId) {
ProfileCache.mergePreview(
id = envelope.senderId,
username = senderUsername.takeIf { it.isNotEmpty() },
displayName = senderDisplayName,
)
} }
return Message( return Message(
id = envelope.id, id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId) else -> ActiveDmChatTracker.isActive(otherUserId)
}, },
is_edited = false, is_edited = false,
username = username, username = senderUsername,
displayName = senderDisplayName,
profile_picture = null, profile_picture = null,
verified = null, verified = null,
reply_to = null, reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) { private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching { val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element) json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: run {
Logger.w("DmPanel", "processDeletedEnvelope decode failed")
return
}
val involvesPeer = val involvesPeer =
data.senderId == otherUserId || data.senderId == otherUserId ||
data.recipientId == otherUserId || data.recipientId == otherUserId ||
data.senderId == currentUserId data.senderId == currentUserId
if (!involvesPeer) return if (!involvesPeer) {
Logger.d(
"DmPanel",
"processDeletedEnvelope skip messageId=${data.id} " +
"senderId=${data.senderId} recipientId=${data.recipientId} peer=$otherUserId",
)
return
}
Logger.i(
"DmPanel",
"processDeletedEnvelope messageId=${data.id} peer=$otherUserId " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == data.id }}",
)
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id) DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
} }
deleteMessageImmediately(data.id) deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
Logger.d(
"DmPanel",
"processDeletedEnvelope done messageId=${data.id} uiAfter=${_state.messages.size}",
)
} }
} }
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.profile.PublicChatProfileScreen import ru.fromchat.ui.profile.PublicChatProfileScreen
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
PublicChatScreen( PublicChatScreen(
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY, sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
onTitleClick = { onTitleClick = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened) haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE) navController.navigate(PublicChatNav.PROFILE_ROUTE)
}
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId) PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
} }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val stateSnapshot = panel.getState() val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() } val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() } ?: stateSnapshot.title.takeIf { it.isNotBlank() }
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
PublicChatProfileScreen( PublicChatProfileScreen(
showBackButton = true, showBackButton = true,
onBack = { onBack = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
onChat = { onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
}
}, },
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope scope = scope
) { ) {
private val typingHandler = PublicChatTypingHandler(scope) private val typingHandler = PublicChatTypingHandler(scope)
private var networkHistoryLoaded = false private val loadMessagesMutex = Mutex()
/** /**
* Whether replacing the list would change **structure or message body** (content / edited). * Whether replacing the list would change **structure or message body** (content / edited).
@@ -82,6 +84,7 @@ class PublicChatPanel(
ProfileCache.enrichPublicMessageForDisplay( ProfileCache.enrichPublicMessageForDisplay(
mergeMessageUiFields(fresh, message).copy( mergeMessageUiFields(fresh, message).copy(
username = fresh.username, username = fresh.username,
displayName = fresh.displayName,
profile_picture = fresh.profile_picture, profile_picture = fresh.profile_picture,
verified = fresh.verified, verified = fresh.verified,
verificationStatus = fresh.verificationStatus, verificationStatus = fresh.verificationStatus,
@@ -177,6 +180,8 @@ class PublicChatPanel(
} }
suspend fun hydrateFromLocalCache() { suspend fun hydrateFromLocalCache() {
// Sender display names live in ProfileCache (message rows only store userId).
runCatching { ProfileCache.hydrateFromDisk() }
hydrateMessagesFromLocalCache() hydrateMessagesFromLocalCache()
runCatching { PublicChatProfileCache.hydrateFromDisk() } runCatching { PublicChatProfileCache.hydrateFromDisk() }
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) } PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
@@ -236,6 +241,7 @@ class PublicChatPanel(
/** /**
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text). * Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
* When the server omits client_message_id, fall back to a single pending own optimistic.
*/ */
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) { private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val laidOut = newMsg.resolvePublicAttachmentLayout() val laidOut = newMsg.resolvePublicAttachmentLayout()
@@ -247,12 +253,56 @@ class PublicChatPanel(
return return
} }
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) { if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
// Already confirmed in UI — still try to clear a leftover optimistic.
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
handleMessageConfirmed(
pendingCid,
laidOut.copy(client_message_id = pendingCid),
)
}
return
}
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
handleMessageConfirmed(
pendingCid,
laidOut.copy(client_message_id = pendingCid),
)
return return
} }
} }
ingestIncomingPublicMessage(laidOut) ingestIncomingPublicMessage(laidOut)
} }
/** Best-effort: unique pending own optimistic that looks like [confirmed]. */
private fun matchPendingOptimisticClientId(confirmed: Message): String? {
val pending = snapshotPendingOptimisticMessages().filter {
it.user_id == confirmed.user_id && it.id < 0
}
if (pending.isEmpty()) return null
val confirmedCid = confirmed.client_message_id?.trim().orEmpty()
if (confirmedCid.isNotEmpty()) {
pending.firstOrNull { it.client_message_id?.trim() == confirmedCid }
?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
?.let { return it }
}
if (pending.size == 1) {
return pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
val confirmedTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(confirmed.timestamp)
val content = confirmed.content.trim()
val matches = pending.filter { opt ->
val optCid = opt.client_message_id?.trim().orEmpty()
if (optCid.isEmpty()) return@filter false
if (opt.content.trim() != content && confirmed.files.isNullOrEmpty() && opt.files.isNullOrEmpty()) {
return@filter false
}
val optTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(opt.timestamp)
confirmedTime == null || optTime == null ||
kotlin.math.abs(confirmedTime - optTime) <= 180_000L
}
return matches.singleOrNull()?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
private suspend fun ingestIncomingPublicMessage(newMsg: Message) { private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
ProfileCache.mergePreviewFromPublicMessage(newMsg) ProfileCache.mergePreviewFromPublicMessage(newMsg)
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last() val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
@@ -274,7 +324,12 @@ class PublicChatPanel(
mergeMessageUiFields(net, local) mergeMessageUiFields(net, local)
} }
} }
val ahead = shown.filter { it.id > 0 && it.id !in networkIds } val minNetworkId = fromNetwork.minOfOrNull { it.id } ?: Int.MAX_VALUE
val maxNetworkId = fromNetwork.maxOfOrNull { it.id } ?: Int.MIN_VALUE
// Keep only messages strictly newer/older than the network page — not in-window gaps
// (those are server-side deletes that must not be resurrected from cache).
val ahead = shown.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
val older = shown.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
val inFlight = shown.filter { msg -> val inFlight = shown.filter { msg ->
msg.id < 0 && ( msg.id < 0 && (
!msg.client_message_id.isNullOrBlank() || !msg.client_message_id.isNullOrBlank() ||
@@ -282,8 +337,8 @@ class PublicChatPanel(
!msg.uploadJobId.isNullOrBlank() !msg.uploadJobId.isNullOrBlank()
) )
} }
if (ahead.isEmpty() && inFlight.isEmpty()) return merged if (ahead.isEmpty() && older.isEmpty() && inFlight.isEmpty()) return merged
val combined = merged + ahead + inFlight val combined = merged + ahead + older + inFlight
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined), ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
) )
@@ -327,9 +382,8 @@ class PublicChatPanel(
} }
override suspend fun loadMessages() { override suspend fun loadMessages() {
loadMessagesMutex.withLock {
hydrateMessagesFromLocalCache() hydrateMessagesFromLocalCache()
if (networkHistoryLoaded) return
networkHistoryLoaded = true
val cached = _state.messages val cached = _state.messages
if (cached.isEmpty()) { if (cached.isEmpty()) {
@@ -338,7 +392,13 @@ class PublicChatPanel(
} }
} }
// Refresh from network; this may be fast or slow, but runs entirely off main. // Always refresh from network on open. A retained panel used to skip this after the
// first visit (networkHistoryLoaded), so offline bursts left first+last holes until
// slow WS catch-up filled them.
Logger.d(
"PublicChatPanel",
"loadMessages: network refresh cachedCount=${cached.size}",
)
val responseResult = withContext(Dispatchers.Default) { val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) } runCatching { ApiClient.getMessages(limit = 50) }
} }
@@ -350,12 +410,18 @@ class PublicChatPanel(
val optimisticSnapshot = snapshotPendingOptimisticMessages() val optimisticSnapshot = snapshotPendingOptimisticMessages()
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)" val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() } val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}" Logger.d(
Logger.d("PublicChatPanel", loadMsg) "PublicChatPanel",
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
)
var mergedForCache: List<Message>? = null var mergedForCache: List<Message>? = null
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = snapshotUiMessagesForNetworkMerge() val shown = snapshotUiMessagesForNetworkMerge()
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}") Logger.d(
"PublicChatPanel",
"loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
)
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) { if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages) val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
@@ -375,7 +441,11 @@ class PublicChatPanel(
addMessages( addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged), ProfileCache.enrichPublicMessagesForDisplay(merged),
) )
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}") Logger.d(
"PublicChatPanel",
"loadMessages: after addMessages mergedSize=${merged.size} " +
"restoring optimistic count=${optimisticSnapshot.size}",
)
restorePendingOptimisticMessages(optimisticSnapshot) restorePendingOptimisticMessages(optimisticSnapshot)
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false) setLoading(false)
@@ -389,8 +459,11 @@ class PublicChatPanel(
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val toPersist = mergedForCache val toPersist = mergedForCache
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages) ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}") Logger.d(
MessageCacheStore.replacePublicMessages(toPersist) "PublicChatPanel",
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
)
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
} }
} else if (responseResult.isFailure) { } else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull() val cause = responseResult.exceptionOrNull()
@@ -402,23 +475,26 @@ class PublicChatPanel(
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else if (cached.isEmpty()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else { } else {
// We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false)
} }
} }
} else if (cached.isEmpty()) { } else if (cached.isEmpty()) {
// Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
} }
} else {
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
}
}
} }
} }
@@ -444,7 +520,7 @@ class PublicChatPanel(
) )
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -479,30 +555,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id) DecryptedImageCache.invalidateForMessage(editedMsg.id)
updateMessage(editedMsg.id) { existing -> val existing = _state.messages.find { it.id == editedMsg.id }
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to) val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
updateMessage(editedMsg.id) { current ->
persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
} }
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
Logger.i(
"PublicChatPanel",
"messageDeleted messageId=${deletedData.message_id} " +
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
)
DecryptedImageCache.invalidateForMessage(deletedData.message_id) DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id) clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.markPublicMessageDeleted(deletedData.message_id) MessageRepository.deletePublicMessageById(deletedData.message_id)
MessageCacheStore.replacePublicMessages(_state.messages)
} }
Logger.d(
"PublicChatPanel",
"messageDeleted done messageId=${deletedData.message_id} " +
"uiAfter=${_state.messages.size}",
)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
val existing = _state.messages.find { it.id == reactionUpdate.message_id }
if (existing != null) {
val updated = existing.copy(reactions = reactionUpdate.reactions)
handleReactionUpdate(reactionUpdate) handleReactionUpdate(reactionUpdate)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages) MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
}
} else {
handleReactionUpdate(reactionUpdate)
} }
} }
"typing" -> { "typing" -> {
@@ -571,7 +664,11 @@ class PublicChatPanel(
cancelQueuedMessage(message) cancelQueuedMessage(message)
return return
} }
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message) beginMessageDissolve(message)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId)
}
ApiClient.deleteMessage(messageId) ApiClient.deleteMessage(messageId)
} }
@@ -35,10 +35,10 @@ internal fun messageDedupeKey(msg: Message): String {
/** /**
* Drops optimistic rows already represented by a confirmed message (same client id), * Drops optimistic rows already represented by a confirmed message (same client id),
* or legacy near-duplicate own rows that have no client id. * or near-duplicate own rows when the confirmed ack omitted client_message_id.
* *
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept * In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
* time-based heuristics must not remove them (that aborted enter animations mid-spring). * unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
*/ */
internal fun dropSupersededOptimisticMessages( internal fun dropSupersededOptimisticMessages(
messages: List<Message>, messages: List<Message>,
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
val confirmed = messages.filter { it.id > 0 } val confirmed = messages.filter { it.id > 0 }
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet() val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
val self = currentUserId val self = currentUserId
val usedConfirmedIds = mutableSetOf<Int>()
return messages.filter { msg -> return messages.filter { msg ->
if (msg.id >= 0) return@filter true if (msg.id >= 0) return@filter true
val cid = msg.client_message_id?.trim().orEmpty() val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
// Stable client id still in flight — never drop via time heuristics.
if (cid.isNotEmpty()) return@filter true
// In-flight uploads (file or image): keep until a confirmed row shares the same client id.
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
if (self == null || msg.user_id != self) return@filter true if (self == null || msg.user_id != self) return@filter true
// Match confirmed acks that omitted client_message_id (phantom duplicate).
if (cid.isNotEmpty()) {
val matched = findNearOwnConfirmedWithoutClientId(msg, confirmed, self, usedConfirmedIds)
if (matched != null) {
usedConfirmedIds.add(matched.id)
return@filter false
}
return@filter true
}
// Legacy: no client id on pending — time-based near-dup.
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
val msgTime = parseMessageTimestampMillis(msg.timestamp) val msgTime = parseMessageTimestampMillis(msg.timestamp)
val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg -> val nearOwnConfirmed = confirmed.filter { it.user_id == self && it.id !in usedConfirmedIds }.any { confirmedMsg ->
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
msgTime != null && confirmedTime != null && msgTime != null && confirmedTime != null &&
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
private const val NEAR_DUPLICATE_MS = 180_000L private const val NEAR_DUPLICATE_MS = 180_000L
private fun findNearOwnConfirmedWithoutClientId(
pending: Message,
confirmed: List<Message>,
self: Int,
usedConfirmedIds: Set<Int>,
): Message? {
val msgTime = parseMessageTimestampMillis(pending.timestamp) ?: return null
val pendingIsAttachment = !pending.files.isNullOrEmpty() ||
pending.pendingFileUri != null ||
!pending.uploadJobId.isNullOrBlank()
val candidates = confirmed.filter { confirmedMsg ->
if (confirmedMsg.user_id != self) return@filter false
if (confirmedMsg.id in usedConfirmedIds) return@filter false
if (!confirmedMsg.client_message_id.isNullOrBlank()) return@filter false
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) ?: return@filter false
if (abs(msgTime - confirmedTime) > NEAR_DUPLICATE_MS) return@filter false
val confirmedHasAttachment = !confirmedMsg.files.isNullOrEmpty()
val contentMatches = pending.content.trim() == confirmedMsg.content.trim()
when {
pendingIsAttachment && confirmedHasAttachment -> true
!pendingIsAttachment && !confirmedHasAttachment && contentMatches -> true
!pendingIsAttachment && confirmedHasAttachment && contentMatches -> true
else -> false
}
}
return candidates.minByOrNull { abs((parseMessageTimestampMillis(it.timestamp) ?: 0L) - msgTime) }
}
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message { private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
val preferred = when { val preferred = when {
incoming.id > 0 && existing.id < 0 -> incoming incoming.id > 0 && existing.id < 0 -> incoming
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
return messages.map { msg -> return messages.map { msg ->
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
val nested = msg.reply_to val nested = msg.reply_to
if (nested != null && nested.content.isNotBlank()) return@map msg if (
nested != null &&
(nested.content.isNotBlank() || !nested.files.isNullOrEmpty())
) {
return@map msg
}
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
} }
} }
@@ -67,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet() val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet() val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them. // Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
// Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
val droppedConfirmed = panelMessages.filter { panel ->
panel.id > 0 && panel.id !in mergedIds
}
if (droppedConfirmed.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
"ids=${droppedConfirmed.map { it.id }.take(12)} " +
"panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
)
}
val extraPanel = panelMessages.filter { panel -> val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() } val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
when { panel.id < 0 && cid != null && cid !in mergedClientIds
panel.id < 0 && cid != null && cid !in mergedClientIds -> true
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
else -> false
} }
if (extraPanel.isNotEmpty()) {
ru.fromchat.Logger.d(
"MessageCache",
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
"ids=${extraPanel.map { it.id }}",
)
} }
return dedupeMessagesByClientId( return dedupeMessagesByClientId(
@@ -109,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
?: db.pendingFileAspectRatio?.takeIf { it > 0f } ?: db.pendingFileAspectRatio?.takeIf { it > 0f }
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } ?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) } ?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
// DB rows only store userId; sender identity is reconstructed from ProfileCache and can
// briefly be blank. Keep non-blank panel fields (e.g. from a network payload) so text
// avatars / names are not wiped on every SQLDelight emission.
val merged = db.copy( val merged = db.copy(
username = db.username.trim().ifBlank { panel.username.trim() },
displayName = db.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: panel.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = db.profile_picture?.takeIf { it.isNotBlank() }
?: panel.profile_picture?.takeIf { it.isNotBlank() },
verified = db.verified ?: panel.verified,
verificationStatus = db.verificationStatus ?: panel.verificationStatus,
pendingFileUri = when { pendingFileUri = when {
confirmed -> localPreview confirmed -> localPreview
else -> panel.pendingFileUri ?: db.pendingFileUri else -> panel.pendingFileUri ?: db.pendingFileUri
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
flowState.resetPredictiveState() flowState.resetPredictiveState()
return@PredictiveBackHandler return@PredictiveBackHandler
} }
// Cancel must always reverse — never commit just because progress crossed the threshold.
scope.launch { scope.launch {
val commit = lastProgress >= predictiveThreshold
finishPredictiveMorph( finishPredictiveMorph(
flowState = flowState, flowState = flowState,
pagerState = pagerState, pagerState = pagerState,
startProgress = lastProgress, startProgress = lastProgress,
targetProgress = if (commit) 1f else 0f, targetProgress = 0f,
) )
} }
}, },
@@ -20,7 +20,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") } val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
val closeSheet: () -> Unit = { val closeSheet: () -> Unit = {
scope.launch { scope.launch {
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String, title: String,
userId: Int, userId: Int,
) { ) {
val profileCacheRevision by ProfileCache.revision.collectAsState()
val verificationStatus = remember(userId, profileCacheRevision) {
resolveVerificationStatus(userId)
}
DisplayName( DisplayName(
displayName = title, displayName = title,
verificationStatus = resolveVerificationStatus(userId), verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge, textStyle = MaterialTheme.typography.bodyLarge,
) )
} }
@@ -459,6 +464,7 @@ internal fun SearchConversationsList(
userId = user.id, userId = user.id,
currentUserId = ApiClient.user?.id, currentUserId = ApiClient.user?.id,
deleted = user.deleted ?: cached?.deleted, deleted = user.deleted ?: cached?.deleted,
suspended = user.suspended ?: cached?.suspended,
username = user.username, username = user.username,
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
@@ -926,18 +932,19 @@ internal fun DmConversationRowContent(
userId = conversation.otherUserId, userId = conversation.otherUserId,
currentUserId = currentUserId, currentUserId = currentUserId,
deleted = cached?.deleted, deleted = cached?.deleted,
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() }, suspended = cached?.suspended,
username = cached?.username,
) )
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when { val peerTitle = when {
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() !cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName conversation.displayName.isNotBlank() -> conversation.displayName
else -> cached?.visibleUsername(currentUserId).orEmpty() else -> cached?.visibleUsername(currentUserId).orEmpty()
} }
val avatarInitialsLabel = when { val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() !cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
conversation.displayName.isNotBlank() -> conversation.displayName conversation.displayName.isNotBlank() -> conversation.displayName
else -> "" else -> ""
} }
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
placeholder = searchBarHint, placeholder = searchBarHint,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding()
.padding(12.dp), .padding(12.dp),
leadingIcon = { leadingIcon = {
IconButton(onClick = { IconButton(onClick = {
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -529,7 +528,7 @@ fun ChatsTab(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val navController = LocalNavController.current val navController = LocalNavController.current
val clipboardManager = LocalClipboardManager.current val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
@@ -654,28 +653,12 @@ fun ChatsTab(
fun deleteChats(userIds: Set<Int>) { fun deleteChats(userIds: Set<Int>) {
scope.launch { scope.launch {
var failures = 0
userIds.forEach { otherUserId -> userIds.forEach { otherUserId ->
var ok = true
val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
.getOrDefault(emptyList())
.filter { it.id > 0 }
messages.forEach { msg ->
runCatching { runCatching {
ApiClient.deleteDm(msg.id, otherUserId) ApiClient.archiveDmConversation(otherUserId, archived = true)
}.onFailure { MessageRepository.archiveDmConversation(otherUserId)
ok = false
} }
} }
runCatching {
MessageRepository.deleteDmConversation(otherUserId)
}.onFailure { ok = false }
if (!ok) failures++
}
refreshDmList() refreshDmList()
exitSelectionMode() exitSelectionMode()
@@ -1108,7 +1091,9 @@ fun ChatsTab(
chatContextMenuOverlay.onLink = { chatContextMenuOverlay.onLink = {
when (contextMenuState.target) { when (contextMenuState.target) {
ChatContextMenuTarget.Public -> { ChatContextMenuTarget.Public -> {
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) } publicChatLink?.let { link ->
scope.launch { clipboard.setText(link) }
}
} }
ChatContextMenuTarget.Dm -> { ChatContextMenuTarget.Dm -> {
val link = contextMenuState.otherUserId?.let { userId -> val link = contextMenuState.otherUserId?.let { userId ->
@@ -1116,7 +1101,7 @@ fun ChatsTab(
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId" username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
} }
link?.let { clipboardManager.setText(AnnotatedString(it)) } link?.let { scope.launch { clipboard.setText(it) } }
} }
} }
} }
@@ -37,6 +37,7 @@ import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.ui.Website import com.pr0gramm3r101.ui.Website
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.AppBuildInfo
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.about_link_max import ru.fromchat.about_link_max
@@ -111,7 +112,10 @@ fun AboutScreen() {
BrandTitle(Modifier.padding(bottom = 4.dp)) BrandTitle(Modifier.padding(bottom = 4.dp))
Text( Text(
text = stringResource(Res.string.about_version), text = stringResource(
Res.string.about_version,
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -43,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -390,7 +394,7 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
val hadContent = devices.isNotEmpty() val hadContent = devices.isNotEmpty()
if (hadContent) refreshing = true if (!hadContent) refreshing = true
runCatching { ApiClient.listDevices() } runCatching { ApiClient.listDevices() }
.onSuccess { list -> .onSuccess { list ->
@@ -422,6 +426,8 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -479,10 +485,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .fillMaxSize()
.padding() .consumeWindowInsets(innerPadding)
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), .hazeSource(hazeState),
contentPadding = innerPadding contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding() + 8.dp,
bottom = innerPadding.calculateBottomPadding() + 24.dp,
),
) { ) {
item { item {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -569,12 +580,8 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
} }
if (refreshing && devices.isEmpty()) {
item { item {
AnimatedVisibility(
visible = refreshing,
enter = fadeIn(),
exit = fadeOut(),
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -589,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
sheetDevice?.let { d -> sheetDevice?.let { d ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null }, onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
sheetState = sheetState sheetState = sheetState,
) { ) {
DeviceSessionDetailBottomSheet( DeviceSessionDetailBottomSheet(
d = d, d = d,
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -389,13 +390,15 @@ fun LogFilesScreen(
} }
if (showShareSheet) { if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showShareSheet = false showShareSheet = false
pendingSharePaths = emptyList() pendingSharePaths = emptyList()
}, },
sheetState = sheetState, sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) { ) {
LogsShareBottomSheet( LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) }, onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -632,13 +633,15 @@ fun LogsScreen() {
} }
if (showShareSheet) { if (showShareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
showShareSheet = false showShareSheet = false
pendingShareRequest = null pendingShareRequest = null
}, },
sheetState = sheetState, sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
),
) { ) {
LogsShareBottomSheet( LogsShareBottomSheet(
onUncompressed = { performShare(LogShareCompression.Uncompressed) }, onUncompressed = { performShare(LogShareCompression.Uncompressed) },
@@ -648,7 +651,10 @@ fun LogsScreen() {
} }
if (showCleanSheet) { if (showCleanSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showCleanSheet = false }, onDismissRequest = { showCleanSheet = false },
sheetState = sheetState, sheetState = sheetState,
@@ -12,6 +12,10 @@ object SettingsRoutes {
const val SecurityPasswordFlow = "settings/security/password" const val SecurityPasswordFlow = "settings/security/password"
const val Account = "settings/account" const val Account = "settings/account"
const val AccountDeleteFlow = "settings/account/delete" const val AccountDeleteFlow = "settings/account/delete"
/** Confirm → OAuth WebView → Done (Done pops confirm+OAuth). */
const val AccountYandexFlow = "settings/account/yandex"
const val AccountYandexOAuth = "settings/account/yandex/oauth"
const val AccountYandexDone = "settings/account/yandex/done"
const val ServerConfig = "serverConfig" const val ServerConfig = "serverConfig"
const val About = "about" const val About = "about"
const val Logs = "settings/logs" const val Logs = "settings/logs"
@@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -37,11 +38,15 @@ import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.cancel import ru.fromchat.cancel
import ru.fromchat.ic_yandex
import ru.fromchat.logout import ru.fromchat.logout
import ru.fromchat.settings_account_change_yandex
import ru.fromchat.settings_account_change_yandex_d
import ru.fromchat.settings_account_delete import ru.fromchat.settings_account_delete
import ru.fromchat.settings_account_delete_d import ru.fromchat.settings_account_delete_d
import ru.fromchat.settings_account_logout_confirm_body import ru.fromchat.settings_account_logout_confirm_body
@@ -57,11 +62,18 @@ fun AccountScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onChangePassword: () -> Unit, onChangePassword: () -> Unit,
onChangeYandexId: () -> Unit,
onDeleteAccount: () -> Unit, onDeleteAccount: () -> Unit,
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) } var showLogoutConfirm by remember { mutableStateOf(false) }
var yandexAvailable by remember { mutableStateOf(false) }
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
LaunchedEffect(Unit) {
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
}
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -103,6 +115,24 @@ fun AccountScreen(
} }
) )
if (yandexAvailable) {
ListItem(
headline = stringResource(Res.string.settings_account_change_yandex),
supportingText = stringResource(Res.string.settings_account_change_yandex_d),
onClick = onChangeYandexId,
leadingContent = { Icon(yandexIcon, null) },
divider = true,
trailingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
ListItem( ListItem(
headline = stringResource(Res.string.settings_account_delete), headline = stringResource(Res.string.settings_account_delete),
supportingText = stringResource(Res.string.settings_account_delete_d), supportingText = stringResource(Res.string.settings_account_delete_d),
@@ -0,0 +1,155 @@
package ru.fromchat.ui.main.settings.account.changeyandex
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.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_yandex_client_mismatch
import ru.fromchat.config.Settings
import ru.fromchat.error_unexpected
import ru.fromchat.ic_yandex
import ru.fromchat.settings_next
import ru.fromchat.settings_yandex_step_confirm_body
import ru.fromchat.settings_yandex_step_confirm_cta
import ru.fromchat.settings_yandex_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 ChangeYandexConfirmScreen(onBack: () -> Unit) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val flowState = rememberExpressiveStepFlow(1)
var yandex by remember { mutableStateOf<YandexOAuthParams?>(null) }
var loadingParams by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) }
val darkTheme = isAppInDarkTheme()
val languageTag = Locale.current.toLanguageTag()
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
val title = stringResource(Res.string.settings_yandex_step_confirm_title)
val body = stringResource(Res.string.settings_yandex_step_confirm_body)
val cta = stringResource(Res.string.settings_yandex_step_confirm_cta)
val next = stringResource(Res.string.settings_next)
val clientMismatch = stringResource(Res.string.auth_yandex_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) {
ChangeYandexDraft.clear()
loadingParams = true
try {
yandex = ApiClient.getAccountYandex().yandex
} catch (e: Exception) {
showSnack(e.message ?: unexpected)
} finally {
loadingParams = false
}
}
val colorScheme = MaterialTheme.colorScheme
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
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 || loadingParams) return@ActionButton
val params = yandex
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 = resolveYandexClientId(params.client_id, serverIp)
if (clientId == null) {
showSnack(clientMismatch)
return@launch
}
val pkce = generatePkcePair()
ChangeYandexDraft.authorizeUrl = buildYandexAuthorizeUrl(
authorizeUrl = params.authorize_url,
clientId = clientId,
redirectUri = params.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
scope = params.scope,
codeChallenge = pkce.codeChallenge,
languageTag = languageTag,
darkTheme = darkTheme,
)
ChangeYandexDraft.codeVerifier = pkce.codeVerifier
navController.navigate(SettingsRoutes.AccountYandexOAuth)
} finally {
busy = false
}
}
},
enabled = !busy && !loadingParams && yandex != 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.changeyandex
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_yandex_step_done_body
import ru.fromchat.settings_yandex_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 ChangeYandexDoneScreen(onDone: () -> Unit) {
val flowState = rememberExpressiveStepFlow(1)
val snackbarHostState = remember { SnackbarHostState() }
val title = stringResource(Res.string.settings_yandex_step_done_title)
val body = stringResource(Res.string.settings_yandex_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,15 @@
package ru.fromchat.ui.main.settings.account.changeyandex
/**
* Stages PKCE + authorize URL while the change-Yandex OAuth WebView is open
* (settings composition can leave the confirm screen).
*/
internal object ChangeYandexDraft {
var authorizeUrl: String? = null
var codeVerifier: String? = null
fun clear() {
authorizeUrl = null
codeVerifier = null
}
}
@@ -0,0 +1,125 @@
package ru.fromchat.ui.main.settings.account.changeyandex
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.yandex.YandexOAuthWebView
import ru.fromchat.ui.isAppInDarkTheme
import ru.fromchat.ui.main.settings.SettingsRoutes
@Composable
fun ChangeYandexOAuthScreen(onBack: () -> Unit) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val authorizeUrl = remember { ChangeYandexDraft.authorizeUrl }
val codeVerifier = remember { ChangeYandexDraft.codeVerifier }
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) {
if (authorizeUrl.isNullOrBlank() || codeVerifier.isNullOrBlank()) {
onBack()
}
}
val url = authorizeUrl ?: return
val verifier = codeVerifier ?: return
fun finishSuccess() {
ChangeYandexDraft.clear()
navController.navigate(SettingsRoutes.AccountYandexDone) {
popUpTo(SettingsRoutes.AccountYandexFlow) { inclusive = true }
}
}
fun cancel() {
if (busy) return
ChangeYandexDraft.clear()
onBack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(chromeColor),
) {
YandexOAuthWebView(
authorizeUrl = url,
languageTag = Locale.current.toLanguageTag(),
darkTheme = isAppInDarkTheme(),
fallbackColor = fallbackColor,
clearCookies = true,
onPageBackgroundColor = { chromeColor = it },
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
onCode = { code ->
if (busy) return@YandexOAuthWebView
scope.launch {
busy = true
try {
val proof = ApiClient.authYandexExchange(code, verifier).registration_proof
ApiClient.changeAccountYandex(proof)
finishSuccess()
} catch (_: Exception) {
cancel()
} finally {
busy = false
}
}
},
onError = { cancel() },
onCancel = { cancel() },
)
// Same as register: show when back exits the flow (WebView has no in-page history).
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)
}
}
}
}
@@ -69,7 +69,10 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
@@ -79,11 +82,14 @@ import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -99,10 +105,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ContextMenuPressable import com.pr0gramm3r101.components.ContextMenuPressable
@@ -137,6 +140,8 @@ import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.cancel
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.feature_not_implemented import ru.fromchat.feature_not_implemented
import ru.fromchat.presence_online import ru.fromchat.presence_online
@@ -146,14 +151,33 @@ import ru.fromchat.profile_action_chat
import ru.fromchat.profile_action_link import ru.fromchat.profile_action_link
import ru.fromchat.profile_action_search import ru.fromchat.profile_action_search
import ru.fromchat.profile_action_settings import ru.fromchat.profile_action_settings
import ru.fromchat.profile_admin_actions_category
import ru.fromchat.profile_admin_delete
import ru.fromchat.profile_admin_delete_confirm
import ru.fromchat.profile_admin_delete_confirm_body
import ru.fromchat.profile_admin_delete_confirm_title
import ru.fromchat.profile_admin_suspend
import ru.fromchat.profile_admin_suspend_confirm
import ru.fromchat.profile_admin_suspend_confirm_body
import ru.fromchat.profile_admin_suspend_confirm_title
import ru.fromchat.profile_admin_suspend_reason_label
import ru.fromchat.profile_admin_unsuspend
import ru.fromchat.profile_admin_unsuspend_confirm_body
import ru.fromchat.profile_admin_unsuspend_confirm_title
import ru.fromchat.profile_admin_unverify
import ru.fromchat.profile_admin_unverify_confirm_body
import ru.fromchat.profile_admin_unverify_confirm_title
import ru.fromchat.profile_admin_verify
import ru.fromchat.profile_admin_verify_confirm_body
import ru.fromchat.profile_admin_verify_confirm_title
import ru.fromchat.profile_headline_bio import ru.fromchat.profile_headline_bio
import ru.fromchat.profile_headline_member_since import ru.fromchat.profile_headline_member_since
import ru.fromchat.profile_headline_username import ru.fromchat.profile_headline_username
import ru.fromchat.profile_headline_verification import ru.fromchat.profile_headline_verification
import ru.fromchat.profile_invalid_link
import ru.fromchat.profile_load_failed import ru.fromchat.profile_load_failed
import ru.fromchat.profile_not_found import ru.fromchat.profile_not_found
import ru.fromchat.profile_verified_support import ru.fromchat.profile_verified_support
import ru.fromchat.profile_verify_prompt_support
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.avatarLabelForInitials import ru.fromchat.ui.profile.avatarLabelForInitials
import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.displayNameForUi
@@ -241,7 +265,6 @@ fun ProfileScreen(
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
showBackButton: Boolean = false, showBackButton: Boolean = false,
) { ) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -351,7 +374,7 @@ fun ProfileScreen(
ApiClient.applyOwnProfile(refreshed) ApiClient.applyOwnProfile(refreshed)
state = latestUi.copy(profile = refreshed, error = null) state = latestUi.copy(profile = refreshed, error = null)
} catch (_: Exception) { } catch (_: Exception) {
ownUserId?.let { ProfileCache.get(it) }?.let { cached -> ownUserId.let { ProfileCache.get(it) }?.let { cached ->
state = latestUi.copy(profile = cached) state = latestUi.copy(profile = cached)
} }
} }
@@ -501,7 +524,12 @@ fun ProfileScreen(
val headlineBio = stringResource(Res.string.profile_headline_bio) val headlineBio = stringResource(Res.string.profile_headline_bio)
val headlineVerification = stringResource(Res.string.profile_headline_verification) val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support) val verifiedSupport = stringResource(Res.string.profile_verified_support)
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) val adminActionsCategory = stringResource(Res.string.profile_admin_actions_category)
val adminVerifyLabel = stringResource(Res.string.profile_admin_verify)
val adminUnverifyLabel = stringResource(Res.string.profile_admin_unverify)
val adminSuspendLabel = stringResource(Res.string.profile_admin_suspend)
val adminUnsuspendLabel = stringResource(Res.string.profile_admin_unsuspend)
val adminDeleteLabel = stringResource(Res.string.profile_admin_delete)
val profile = liveProfile val profile = liveProfile
?: state.profile ?: state.profile
@@ -531,6 +559,7 @@ fun ProfileScreen(
userId = resolvedUserId, userId = resolvedUserId,
currentUserId = viewerUserId, currentUserId = viewerUserId,
deleted = resolvedProfile?.deleted, deleted = resolvedProfile?.deleted,
suspended = resolvedProfile?.suspended,
username = resolvedProfile?.username, username = resolvedProfile?.username,
) )
val displayName = when { val displayName = when {
@@ -577,7 +606,7 @@ fun ProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
), ),
ProfileAction( ProfileAction(
label = labelSettings, label = labelSettings,
@@ -622,7 +651,7 @@ fun ProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
) )
) )
if (ServerConfig.callsEnabled) { if (ServerConfig.callsEnabled) {
@@ -655,9 +684,11 @@ fun ProfileScreen(
val showDetailsUsername = usernameForLinks != null val showDetailsUsername = usernameForLinks != null
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank() val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank() val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
val showDetailsVerify = !isDeletedProfile && ( val showDetailsVerify = !isDeletedProfile && resolvedProfile?.verified == true
resolvedProfile?.verified == true || ApiClient.user?.id == 1 val showAdminActions = !isOwnProfile &&
) !isDeletedProfile &&
resolvedProfile != null &&
ApiClient.user?.id == 1
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && ( val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
) )
@@ -779,23 +810,30 @@ fun ProfileScreen(
showDetailsMemberSince = showDetailsMemberSince, showDetailsMemberSince = showDetailsMemberSince,
showDetailsBio = showDetailsBio, showDetailsBio = showDetailsBio,
showDetailsVerify = showDetailsVerify, showDetailsVerify = showDetailsVerify,
showAdminActions = showAdminActions,
headlineUsername = headlineUsername, headlineUsername = headlineUsername,
headlineMemberSince = headlineMemberSince, headlineMemberSince = headlineMemberSince,
headlineBio = headlineBio, headlineBio = headlineBio,
headlineVerification = headlineVerification, headlineVerification = headlineVerification,
usernameForLinks = usernameForLinks, usernameForLinks = usernameForLinks,
verifiedSupport = verifiedSupport, verifiedSupport = verifiedSupport,
verifyPromptSupport = verifyPromptSupport, adminActionsCategory = adminActionsCategory,
adminVerifyLabel = adminVerifyLabel,
adminUnverifyLabel = adminUnverifyLabel,
adminSuspendLabel = adminSuspendLabel,
adminUnsuspendLabel = adminUnsuspendLabel,
adminDeleteLabel = adminDeleteLabel,
registrationDateStrings = registrationDateStrings, registrationDateStrings = registrationDateStrings,
listItemIconTint = listItemIconTint, listItemIconTint = listItemIconTint,
labelCopy = labelCopy, labelCopy = labelCopy,
labelEdit = labelEdit, labelEdit = labelEdit,
detailsBringIntoView = detailsBringIntoView, detailsBringIntoView = detailsBringIntoView,
clipboardManager = clipboardManager,
clipboard = clipboard, clipboard = clipboard,
navController = navController, navController = navController,
scope = scope, scope = scope,
snackbarHostState = snackbarHostState,
openContextMenuHaptic = openContextMenuHaptic, openContextMenuHaptic = openContextMenuHaptic,
onBack = onBack,
onProfileUpdated = { updated -> onProfileUpdated = { updated ->
state = state.copy(profile = updated) state = state.copy(profile = updated)
ProfileCache.put(updated) ProfileCache.put(updated)
@@ -839,7 +877,6 @@ fun PublicChatProfileScreen(
initialDisplayName: String? = null, initialDisplayName: String? = null,
showBackButton: Boolean = false, showBackButton: Boolean = false,
) { ) {
val clipboardManager = LocalClipboardManager.current
val clipboard = supportClipboardManagerImpl val clipboard = supportClipboardManagerImpl
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -921,7 +958,7 @@ fun PublicChatProfileScreen(
ProfileAction( ProfileAction(
label = labelLink, label = labelLink,
icon = Icons.Filled.Link, icon = Icons.Filled.Link,
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
), ),
ProfileAction( ProfileAction(
label = labelSearch, label = labelSearch,
@@ -962,15 +999,15 @@ fun PublicChatProfileScreen(
when { when {
useSharedAvatar && displayName.isNotBlank() -> { useSharedAvatar && displayName.isNotBlank() -> {
item { item {
with(sharedTransitionScope!!) { with(sharedTransitionScope) {
Avatar( Avatar(
profilePictureUrl = null, profilePictureUrl = null,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.sharedElement( .sharedElement(
rememberSharedContentState(key = sharedAvatarKey!!), rememberSharedContentState(key = sharedAvatarKey),
animatedVisibilityScope = animatedVisibilityScope!!, animatedVisibilityScope = animatedVisibilityScope,
) )
.size(104.dp), .size(104.dp),
) )
@@ -1010,6 +1047,7 @@ fun PublicChatProfileScreen(
labelCopy = labelCopy, labelCopy = labelCopy,
clipboard = clipboard, clipboard = clipboard,
scope = scope, scope = scope,
snackbarHostState = snackbarHostState,
) )
} }
} }
@@ -1049,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
labelCopy: String, labelCopy: String,
clipboard: SupportClipboardManager, clipboard: SupportClipboardManager,
scope: CoroutineScope, scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -1095,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
supportingSlot = { supportingSlot = {
ProfileBioMarkdown( ProfileBioMarkdown(
content = resolvedProfile.bio.orEmpty(), content = resolvedProfile.bio.orEmpty(),
snackbarHostState = snackbarHostState,
) )
}, },
position = ListItemPosition.START, position = ListItemPosition.START,
@@ -1261,26 +1301,40 @@ private fun ProfileLoadedBody(
showDetailsMemberSince: Boolean, showDetailsMemberSince: Boolean,
showDetailsBio: Boolean, showDetailsBio: Boolean,
showDetailsVerify: Boolean, showDetailsVerify: Boolean,
showAdminActions: Boolean,
headlineUsername: String, headlineUsername: String,
headlineMemberSince: String, headlineMemberSince: String,
headlineBio: String, headlineBio: String,
headlineVerification: String, headlineVerification: String,
usernameForLinks: String?, usernameForLinks: String?,
verifiedSupport: String, verifiedSupport: String,
verifyPromptSupport: String, adminActionsCategory: String,
adminVerifyLabel: String,
adminUnverifyLabel: String,
adminSuspendLabel: String,
adminUnsuspendLabel: String,
adminDeleteLabel: String,
registrationDateStrings: RegistrationDateFormatStrings, registrationDateStrings: RegistrationDateFormatStrings,
listItemIconTint: Color, listItemIconTint: Color,
labelCopy: String, labelCopy: String,
labelEdit: String, labelEdit: String,
detailsBringIntoView: BringIntoViewRequester, detailsBringIntoView: BringIntoViewRequester,
clipboardManager: ClipboardManager,
clipboard: SupportClipboardManager, clipboard: SupportClipboardManager,
navController: NavController, navController: NavController,
scope: CoroutineScope, scope: CoroutineScope,
snackbarHostState: SnackbarHostState,
openContextMenuHaptic: () -> Unit, openContextMenuHaptic: () -> Unit,
onBack: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit, onProfileUpdated: (UserProfile) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var showVerifyConfirm by remember { mutableStateOf(false) }
var showUnverifyConfirm by remember { mutableStateOf(false) }
var showSuspendConfirm by remember { mutableStateOf(false) }
var showUnsuspendConfirm by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var suspendReason by remember { mutableStateOf("") }
var adminActionInProgress by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -1294,7 +1348,7 @@ private fun ProfileLoadedBody(
onContextMenuOpen = openContextMenuHaptic, onContextMenuOpen = openContextMenuHaptic,
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(displayName)) scope.launch { clipboard.setText(displayName) }
} }
if (isOwnProfile) { if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) { item(Icons.Rounded.Edit, labelEdit) {
@@ -1398,9 +1452,9 @@ private fun ProfileLoadedBody(
}, },
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText( scope.launch {
AnnotatedString(usernameForLinks.orEmpty()), clipboard.setText(usernameForLinks.orEmpty())
) }
} }
if (isOwnProfile) { if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) { item(Icons.Rounded.Edit, labelEdit) {
@@ -1436,7 +1490,7 @@ private fun ProfileLoadedBody(
}, },
contextMenu = { contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) { item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(memberSinceText)) scope.launch { clipboard.setText(memberSinceText) }
} }
}, },
) )
@@ -1450,7 +1504,10 @@ private fun ProfileLoadedBody(
headline = headlineBio, headline = headlineBio,
supportingSlot = { supportingSlot = {
key(resolvedProfile.id, bioContent) { key(resolvedProfile.id, bioContent) {
ProfileBioMarkdown(content = bioContent) ProfileBioMarkdown(
content = bioContent,
snackbarHostState = snackbarHostState,
)
} }
}, },
divider = true, divider = true,
@@ -1486,11 +1543,7 @@ private fun ProfileLoadedBody(
detailIndex++ detailIndex++
ListItem( ListItem(
headline = headlineVerification, headline = headlineVerification,
supportingText = if (resolvedProfile.verified == true) { supportingText = verifiedSupport,
verifiedSupport
} else {
verifyPromptSupport
},
divider = true, divider = true,
position = position, position = position,
groupItemCount = detailCount, groupItemCount = detailCount,
@@ -1501,50 +1554,339 @@ private fun ProfileLoadedBody(
tint = listItemIconTint, tint = listItemIconTint,
) )
}, },
onClick = if (ApiClient.user?.id == 1) { )
{ }
}
}
if (showAdminActions) {
val isVerified = resolvedProfile.verified == true
val isSuspended = resolvedProfile.suspended == true
Category(
title = adminActionsCategory,
margin = PaddingValues(
start = 16.dp,
end = 16.dp,
top = if (showDetailsSection) 8.dp else 28.dp,
bottom = 20.dp,
),
roundedCorners = false,
) {
ListItem(
headline = if (isVerified) adminUnverifyLabel else adminVerifyLabel,
divider = true,
position = ListItemPosition.START,
groupItemCount = 3,
enabled = !adminActionInProgress,
leadingContent = {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
tint = listItemIconTint,
)
},
onClick = {
if (isVerified) showUnverifyConfirm = true else showVerifyConfirm = true
},
)
ListItem(
headline = if (isSuspended) adminUnsuspendLabel else adminSuspendLabel,
divider = true,
position = ListItemPosition.MIDDLE,
groupItemCount = 3,
enabled = !adminActionInProgress,
leadingContent = {
Icon(
imageVector = if (isSuspended) {
Icons.Filled.CheckCircle
} else {
Icons.Filled.Block
},
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
if (isSuspended) {
showUnsuspendConfirm = true
} else {
suspendReason = ""
showSuspendConfirm = true
}
},
)
ListItem(
headline = adminDeleteLabel,
divider = true,
position = ListItemPosition.END,
groupItemCount = 3,
enabled = !adminActionInProgress,
leadingContent = {
Icon(
imageVector = Icons.Filled.DeleteForever,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { showDeleteConfirm = true },
)
}
}
}
if (showVerifyConfirm) {
AlertDialog(
onDismissRequest = { if (!adminActionInProgress) showVerifyConfirm = false },
title = { Text(stringResource(Res.string.profile_admin_verify_confirm_title)) },
text = { Text(stringResource(Res.string.profile_admin_verify_confirm_body)) },
confirmButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = {
adminActionInProgress = true
scope.launch { scope.launch {
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
runCatching { runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
ApiClient.verifyUser(resolvedProfile.id)
}.getOrNull()
} }
adminActionInProgress = false
showVerifyConfirm = false
result?.let { response -> result?.let { response ->
onProfileUpdated( onProfileUpdated(
resolvedProfile.copy( resolvedProfile.copy(
verified = response.verified, verified = response.verified,
verificationStatus = response.verificationStatus verificationStatus = response.verificationStatus
?: response.verified.let { verified -> ?: if (response.verified) {
if (verified) { VerificationStatus.Verified
ru.fromchat.api.schema.user.profile.VerificationStatus.Verified
} else { } else {
ru.fromchat.api.schema.user.profile.VerificationStatus.None VerificationStatus.None
}
}, },
), ),
) )
} }
} }
},
) {
Text(stringResource(Res.string.profile_admin_verify))
} }
} else null, },
dismissButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = { showVerifyConfirm = false },
) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (showUnverifyConfirm) {
AlertDialog(
onDismissRequest = { if (!adminActionInProgress) showUnverifyConfirm = false },
title = { Text(stringResource(Res.string.profile_admin_unverify_confirm_title)) },
text = { Text(stringResource(Res.string.profile_admin_unverify_confirm_body)) },
confirmButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = {
adminActionInProgress = true
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
}
adminActionInProgress = false
showUnverifyConfirm = false
result?.let { response ->
onProfileUpdated(
resolvedProfile.copy(
verified = response.verified,
verificationStatus = response.verificationStatus
?: if (response.verified) {
VerificationStatus.Verified
} else {
VerificationStatus.None
},
),
) )
} }
} }
},
) {
Text(stringResource(Res.string.profile_admin_unverify))
} }
},
dismissButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = { showUnverifyConfirm = false },
) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (showSuspendConfirm) {
AlertDialog(
onDismissRequest = { if (!adminActionInProgress) showSuspendConfirm = false },
title = { Text(stringResource(Res.string.profile_admin_suspend_confirm_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(Res.string.profile_admin_suspend_confirm_body))
OutlinedTextField(
value = suspendReason,
onValueChange = { suspendReason = it },
label = { Text(stringResource(Res.string.profile_admin_suspend_reason_label)) },
enabled = !adminActionInProgress,
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = !adminActionInProgress && suspendReason.trim().isNotEmpty(),
onClick = {
val reason = suspendReason.trim()
adminActionInProgress = true
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching {
ApiClient.suspendUser(resolvedProfile.id, reason)
}.getOrNull()
}
adminActionInProgress = false
showSuspendConfirm = false
if (result != null) {
onProfileUpdated(
resolvedProfile.copy(
suspended = true,
suspensionReason = reason,
),
)
}
}
},
) {
Text(stringResource(Res.string.profile_admin_suspend_confirm))
}
},
dismissButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = { showSuspendConfirm = false },
) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (showUnsuspendConfirm) {
AlertDialog(
onDismissRequest = { if (!adminActionInProgress) showUnsuspendConfirm = false },
title = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_title)) },
text = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_body)) },
confirmButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = {
adminActionInProgress = true
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.unsuspendUser(resolvedProfile.id) }.getOrNull()
}
adminActionInProgress = false
showUnsuspendConfirm = false
if (result != null) {
onProfileUpdated(
resolvedProfile.copy(
suspended = false,
suspensionReason = null,
),
)
}
}
},
) {
Text(stringResource(Res.string.profile_admin_unsuspend))
}
},
dismissButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = { showUnsuspendConfirm = false },
) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { if (!adminActionInProgress) showDeleteConfirm = false },
title = { Text(stringResource(Res.string.profile_admin_delete_confirm_title)) },
text = { Text(stringResource(Res.string.profile_admin_delete_confirm_body)) },
confirmButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = {
adminActionInProgress = true
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.adminDeleteUser(resolvedProfile.id) }.getOrNull()
}
adminActionInProgress = false
showDeleteConfirm = false
if (result != null) {
onProfileUpdated(
resolvedProfile.copy(deleted = true),
)
onBack()
}
}
},
) {
Text(stringResource(Res.string.profile_admin_delete_confirm))
}
},
dismissButton = {
TextButton(
enabled = !adminActionInProgress,
onClick = { showDeleteConfirm = false },
) {
Text(stringResource(Res.string.cancel))
}
},
)
} }
} }
@Composable @Composable
private fun ProfileBioMarkdown( private fun ProfileBioMarkdown(
content: String, content: String,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
MarkdownPlain( MarkdownPlain(
content = content, content = content,
modifier = modifier, modifier = modifier,
onLinkClick = { uriHandler.openUri(it) }, onLinkClick = { uri ->
runCatching { uriHandler.openUri(uri) }.onFailure {
scope.launch {
snackbarHostState.showReplacingSnackbar(
message = invalidLinkMessage,
withDismissAction = false,
duration = SnackbarDuration.Short,
)
}
}
},
) )
} }
@@ -16,6 +16,14 @@ fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean =
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean = fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
suspended == true && deleted != true && id != currentUserId suspended == true && deleted != true && id != currentUserId
/** True when peers should see this profile as a deleted/hidden account. */
fun UserProfile.isRedactedPeerAccount(currentUserId: Int? = null): Boolean =
id != currentUserId && (
deleted == true ||
isDeletedAccountUsername(username) ||
(suspended == true && currentUserId != 1)
)
fun isDeletedAccountUsername(username: String?): Boolean = fun isDeletedAccountUsername(username: String?): Boolean =
username?.startsWith("#deleted") == true username?.startsWith("#deleted") == true
@@ -25,13 +33,15 @@ fun peerIsDeleted(
currentUserId: Int? = null, currentUserId: Int? = null,
deleted: Boolean? = null, deleted: Boolean? = null,
username: String? = null, username: String? = null,
suspended: Boolean? = null,
): Boolean { ): Boolean {
if (userId <= 0 || userId == currentUserId) return false if (userId <= 0 || userId == currentUserId) return false
if (deleted == true) return true if (deleted == true) return true
if (isDeletedAccountUsername(username)) return true if (isDeletedAccountUsername(username)) return true
// Suspended peers look deleted to everyone except admin (id 1).
if (suspended == true && currentUserId != 1) return true
ProfileCache.get(userId)?.let { profile -> ProfileCache.get(userId)?.let { profile ->
if (profile.isDeletedAccount(currentUserId)) return true if (profile.isRedactedPeerAccount(currentUserId)) return true
if (isDeletedAccountUsername(profile.username)) return true
} }
return false return false
} }
@@ -44,7 +54,7 @@ fun deletedUserDisplayNameForUi(): String =
stringResource(Res.string.deleted_account) stringResource(Res.string.deleted_account)
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String { suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
return deletedUserDisplayName() return deletedUserDisplayName()
} }
return visibleDisplayName(currentUserId).orEmpty() return visibleDisplayName(currentUserId).orEmpty()
@@ -52,7 +62,7 @@ suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
@Composable @Composable
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String = fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
deletedUserDisplayNameForUi() deletedUserDisplayNameForUi()
} else { } else {
visibleDisplayName(currentUserId).orEmpty() visibleDisplayName(currentUserId).orEmpty()
@@ -60,7 +70,7 @@ fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
/** Display name for avatar initials/gradient only; never falls back to username. */ /** Display name for avatar initials/gradient only; never falls back to username. */
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String = fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
if (isDeletedAccount(currentUserId)) { if (isRedactedPeerAccount(currentUserId)) {
"" ""
} else { } else {
displayName?.trim().orEmpty() displayName?.trim().orEmpty()
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
message: Message? = null, message: Message? = null,
user: User? = null, user: User? = null,
): VerificationStatus? { ): VerificationStatus? {
ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached -> ProfileCache.get(userId)?.let { cached ->
if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) { if (cached.verificationStatus != null || cached.verified != null) {
return cached return cached.effectiveVerificationStatus()
} }
} }
user?.effectiveVerificationStatus()?.let { return it } user?.let { u ->
if (u.verificationStatus != null || u.verified != null) {
return u.effectiveVerificationStatus()
}
}
message?.verificationStatus?.let { return it } message?.verificationStatus?.let { return it }
message?.verified?.let { return if (it) VerificationStatus.Verified else null } message?.verified?.let { return if (it) VerificationStatus.Verified else null }
@@ -0,0 +1,5 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}
@@ -0,0 +1,27 @@
package ru.fromchat.ui.auth.captcha
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import ru.fromchat.Logger
@Composable
actual fun SmartCaptchaWebView(
sitekey: String,
languageTag: String,
modifier: Modifier,
onToken: (String) -> Unit,
onReady: () -> Unit,
onChallengeVisible: () -> Unit,
onChallengeHidden: () -> Unit,
onError: (String) -> Unit,
) {
LaunchedEffect(sitekey) {
Logger.w(
SmartCaptchaLog.TAG,
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
"languageTag=$languageTag",
)
onError("Captcha is not available on this platform yet.")
}
}

Some files were not shown because too many files have changed in this diff Show More