mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Compare commits
16 Commits
@@ -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"
|
|
||||||
@@ -32,3 +32,5 @@ google-services.json
|
|||||||
|
|
||||||
premortem-transcript-*.md
|
premortem-transcript-*.md
|
||||||
premortem-report-*.html
|
premortem-report-*.html
|
||||||
|
|
||||||
|
target/
|
||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,10 @@
|
|||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
android:resource="@drawable/logo" />
|
android:resource="@drawable/ic_stat_fromchat" />
|
||||||
|
<meta-data
|
||||||
|
android:name="firebase_messaging_installation_id_enabled"
|
||||||
|
android:value="true" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.notifications.NotificationHelper
|
|
||||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
@@ -27,9 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
|
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
|
||||||
?: pushData["dm_id"]?.toIntOrNull()
|
?: pushData["dm_id"]?.toIntOrNull()
|
||||||
val senderId = pushData["sender_id"]?.toIntOrNull()
|
val senderId = pushData["sender_id"]?.toIntOrNull()
|
||||||
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
|
val sender = pushData["sender_display_name"]
|
||||||
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
|
?.takeIf { it.isNotBlank() }
|
||||||
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
|
?: pushData["sender_username"]
|
||||||
|
?: pushData["senderUsername"]
|
||||||
|
?: pushData["senderDisplayName"]
|
||||||
val messageType = pushData["type"] ?: "public_message"
|
val messageType = pushData["type"] ?: "public_message"
|
||||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||||
if (ApiClient.token.isNullOrBlank()) {
|
if (ApiClient.token.isNullOrBlank()) {
|
||||||
@@ -45,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
|
||||||
NotificationHelper.showFallbackPushNotification(
|
|
||||||
context = applicationContext,
|
|
||||||
title = title,
|
|
||||||
body = body,
|
|
||||||
sender = sender,
|
|
||||||
messageId = fallbackMessageId,
|
|
||||||
isDirectMessage = false,
|
|
||||||
senderId = senderId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
NotificationHelper.fetchAndNotify(
|
NotificationHelper.fetchAndNotify(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -64,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
dmSenderName = sender,
|
dmSenderName = sender,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
NotificationHelper.fetchAndNotify(applicationContext)
|
// Public: one debounced /messages/new → MessagingStyle. Never post a
|
||||||
|
// per-message fallback (that duplicated FCM tray entries with different labels).
|
||||||
|
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||||
@@ -72,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
override fun onRegistered(installationId: String) {
|
||||||
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
settings.putString("pending_fcm_token", token)
|
settings.putString("pending_fcm_token", installationId)
|
||||||
uploadPendingFcmTokenIfAvailable()
|
uploadPendingFcmTokenIfAvailable()
|
||||||
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onNewToken(token)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
|
|||||||
import io.ktor.client.plugins.ClientRequestException
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import ru.fromchat.MainActivity
|
import ru.fromchat.MainActivity
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.R
|
import ru.fromchat.R
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||||
@@ -53,13 +58,20 @@ object NotificationHelper {
|
|||||||
private const val CHAT_TYPE_PUBLIC = "public"
|
private const val CHAT_TYPE_PUBLIC = "public"
|
||||||
private const val CHAT_TYPE_DM = "dm"
|
private const val CHAT_TYPE_DM = "dm"
|
||||||
private const val CHANNEL_ID = "fromchat_messages"
|
private const val CHANNEL_ID = "fromchat_messages"
|
||||||
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
|
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
|
||||||
|
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1000000
|
||||||
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||||
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
||||||
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
||||||
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||||
|
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
|
||||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
|
|
||||||
|
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val publicFetchMutex = Mutex()
|
||||||
|
private var publicFetchJob: Job? = null
|
||||||
|
|
||||||
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
||||||
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
||||||
return ChatListPreviewStrings(
|
return ChatListPreviewStrings(
|
||||||
@@ -72,6 +84,24 @@ object NotificationHelper {
|
|||||||
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
||||||
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
||||||
|
|
||||||
|
private fun publicConversationTitle(context: Context): String =
|
||||||
|
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
|
||||||
|
?: runCatching {
|
||||||
|
PublicChatProfileCache.hydrateFromDiskImmediate(
|
||||||
|
CacheContext.activeInstanceId.value.trim()
|
||||||
|
)?.title?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
?: context.getString(R.string.public_chat)
|
||||||
|
|
||||||
|
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
|
||||||
|
ProfileCache.get(message.user_id)
|
||||||
|
?.visibleDisplayName(currentUserId)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { return it }
|
||||||
|
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||||
|
return message.username.trim().ifBlank { "FromChat" }
|
||||||
|
}
|
||||||
|
|
||||||
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
||||||
|
|
||||||
private fun createMessageIntent(
|
private fun createMessageIntent(
|
||||||
@@ -124,7 +154,6 @@ object NotificationHelper {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun createChannel(context: Context) {
|
fun createChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
(context
|
(context
|
||||||
@@ -141,6 +170,17 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
|
||||||
|
fun schedulePublicFetchAndNotify(context: Context) {
|
||||||
|
publicFetchJob?.cancel()
|
||||||
|
publicFetchJob = helperScope.launch {
|
||||||
|
delay(PUBLIC_FETCH_DEBOUNCE_MS)
|
||||||
|
publicFetchMutex.withLock {
|
||||||
|
fetchAndNotify(context.applicationContext, includeDmMessages = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun fetchAndNotify(
|
suspend fun fetchAndNotify(
|
||||||
context: Context,
|
context: Context,
|
||||||
includeDmMessages: Boolean = false,
|
includeDmMessages: Boolean = false,
|
||||||
@@ -168,10 +208,7 @@ object NotificationHelper {
|
|||||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
displayNotifications(context, messages)
|
||||||
createChannel(context)
|
|
||||||
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 {
|
displayNotifications(context, retryMessages)
|
||||||
createChannel(context)
|
|
||||||
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,22 +440,37 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
||||||
|
val groupKey = if (isDirectMessage && targetDmUserId != null) {
|
||||||
|
GROUP_DM_PREFIX + targetDmUserId
|
||||||
|
} else {
|
||||||
|
GROUP_PUBLIC
|
||||||
|
}
|
||||||
|
val notificationId = if (isDirectMessage && targetDmUserId != null) {
|
||||||
|
SUMMARY_NOTIFICATION_ID + targetDmUserId
|
||||||
|
} else {
|
||||||
|
SUMMARY_NOTIFICATION_ID
|
||||||
|
}
|
||||||
|
cancelStaleSystemTrayDuplicates(context)
|
||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
notificationId,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.logo)
|
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
.setGroup(groupKey)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
NotificationCompat.MessagingStyle(
|
NotificationCompat.MessagingStyle(
|
||||||
Person.Builder().setName("FromChat").build()
|
Person.Builder().setName("FromChat").build()
|
||||||
).setConversationTitle(conversationTitle).addMessage(
|
|
||||||
NotificationCompat.MessagingStyle.Message(
|
|
||||||
body,
|
|
||||||
System.currentTimeMillis(),
|
|
||||||
Person.Builder().setName(senderName).build()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.setConversationTitle(conversationTitle)
|
||||||
|
.setGroupConversation(true)
|
||||||
|
.addMessage(
|
||||||
|
NotificationCompat.MessagingStyle.Message(
|
||||||
|
body,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
Person.Builder().setName(senderName).build()
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
@@ -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,117 +512,146 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
if (currentUserId == -1) return@launch
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if (newMessages.isEmpty()) {
|
.sortedBy { it.id }
|
||||||
Logger.d(
|
if (newMessages.isEmpty()) {
|
||||||
"NotificationHelper",
|
|
||||||
"displayNotifications: no new messages after filters for user=$currentUserId"
|
|
||||||
)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
|
||||||
|
|
||||||
newMessageCount = newMessages.size
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
"displayNotifications: no new messages after filters for user=$currentUserId"
|
||||||
)
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
newMessages.forEach { shown.add(it.id.toString()) }
|
||||||
|
|
||||||
notify(
|
newMessageCount = newMessages.size
|
||||||
SUMMARY_NOTIFICATION_ID,
|
Logger.d(
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
"NotificationHelper",
|
||||||
.setSmallIcon(R.drawable.logo)
|
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
|
||||||
.setStyle(
|
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
|
||||||
NotificationCompat.MessagingStyle(
|
)
|
||||||
Person.Builder().setName("FromChat").build()
|
|
||||||
).setConversationTitle("Public Chat").let { style ->
|
|
||||||
for (msg in newMessages.takeLast(10)) {
|
|
||||||
val timestamp = try {
|
|
||||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
style.addMessage(
|
createChannel(context)
|
||||||
NotificationCompat.MessagingStyle.Message(
|
cancelStaleSystemTrayDuplicates(context)
|
||||||
notificationBodyForMessage(msg, previewStrings),
|
|
||||||
timestamp,
|
|
||||||
Person.Builder()
|
|
||||||
.setName(msg.username)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
style
|
val messagingStyle = NotificationCompat.MessagingStyle(
|
||||||
}
|
Person.Builder().setName("FromChat").build()
|
||||||
)
|
)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setConversationTitle(conversationTitle)
|
||||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
.setGroupConversation(true)
|
||||||
.setAutoCancel(true)
|
|
||||||
|
for (msg in newMessages.takeLast(10)) {
|
||||||
|
val timestamp = try {
|
||||||
|
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
messagingStyle.addMessage(
|
||||||
|
NotificationCompat.MessagingStyle.Message(
|
||||||
|
notificationBodyForMessage(msg, previewStrings),
|
||||||
|
timestamp,
|
||||||
|
Person.Builder()
|
||||||
|
.setName(senderDisplayLabel(msg, currentUserId))
|
||||||
|
.setKey(msg.user_id.toString())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(
|
||||||
|
SUMMARY_NOTIFICATION_ID,
|
||||||
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||||
|
.setLargeIcon(avatar)
|
||||||
|
.setContentTitle(conversationTitle)
|
||||||
|
.setStyle(messagingStyle)
|
||||||
|
.setGroup(GROUP_PUBLIC)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
|
.setAutoCancel(true)
|
||||||
.addAction(
|
.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,
|
||||||
parentMessageId = newMessages.last().id
|
parentMessageId = newMessages.last().id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.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))
|
||||||
.build()
|
.setShortcutId(GROUP_PUBLIC)
|
||||||
)
|
.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>
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
@file:Suppress("TaskMissingDescription")
|
||||||
|
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.file.DirectoryProperty
|
||||||
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.OutputDirectory
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
@@ -7,6 +16,54 @@ plugins {
|
|||||||
alias(libs.plugins.sqldelight)
|
alias(libs.plugins.sqldelight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val versionName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val versionCode: Property<Int>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val debugBuild: Property<Boolean>
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDirectory: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val outRoot = outputDirectory.get().asFile
|
||||||
|
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
|
||||||
|
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
|
||||||
|
}
|
||||||
|
outRoot.deleteRecursively()
|
||||||
|
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package ru.fromchat
|
||||||
|
|
|
||||||
|
|/** Injected by Gradle into generated sources (not under src/). */
|
||||||
|
|object AppBuildInfo {
|
||||||
|
| const val version = "${versionName.get()}"
|
||||||
|
| const val versionCode = ${versionCode.get()}
|
||||||
|
| const val isDebug = ${debugBuild.get()}
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
|
||||||
|
versionName.set(rootProject.extra["versionName"] as String)
|
||||||
|
versionCode.set(rootProject.extra["versionCode"] as Int)
|
||||||
|
debugBuild.set(
|
||||||
|
gradle.startParameter.taskNames.let { names ->
|
||||||
|
!names.any { it.contains("Release", ignoreCase = true) } ||
|
||||||
|
names.any { it.contains("Debug", ignoreCase = true) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
outputDirectory.set(layout.buildDirectory.dir("generated/sources/appBuildInfo/kotlin"))
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android {
|
android {
|
||||||
namespace = "ru.fromchat.shared"
|
namespace = "ru.fromchat.shared"
|
||||||
@@ -21,7 +78,6 @@ kotlin {
|
|||||||
listOf(
|
listOf(
|
||||||
iosArm64(),
|
iosArm64(),
|
||||||
iosSimulatorArm64(),
|
iosSimulatorArm64(),
|
||||||
iosX64(),
|
|
||||||
).forEach { iosTarget ->
|
).forEach { iosTarget ->
|
||||||
iosTarget.binaries.framework {
|
iosTarget.binaries.framework {
|
||||||
baseName = "ComposeApp"
|
baseName = "ComposeApp"
|
||||||
@@ -37,6 +93,10 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonMain {
|
||||||
|
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
|
||||||
|
}
|
||||||
|
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
@@ -123,6 +183,7 @@ compose.resources {
|
|||||||
|
|
||||||
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
||||||
dependsOn("generateResourceAccessorsForCommonMain")
|
dependsOn("generateResourceAccessorsForCommonMain")
|
||||||
|
dependsOn(generateAppBuildInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("generateResourceAccessors") {
|
tasks.register("generateResourceAccessors") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.firebase.installations.FirebaseInstallations
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -13,17 +14,25 @@ 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("Firebase task failed"),
|
||||||
task.exception ?: IllegalStateException("Failed to fetch FCM token")
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
|
|||||||
outputFile.delete()
|
outputFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
val cipher = GCMBlockCipher.newInstance(AESEngine())
|
val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
|
||||||
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
|
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
|
||||||
|
|
||||||
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
|
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/** White silhouette drawable for status-bar / notification small icons. */
|
||||||
|
object NotificationSmallIcon {
|
||||||
|
private const val DRAWABLE_NAME = "ic_stat_fromchat"
|
||||||
|
|
||||||
|
fun resId(context: Context): Int {
|
||||||
|
val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
|
||||||
|
check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
@@ -0,0 +1,246 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
actual fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
onChallengeVisible: () -> Unit,
|
||||||
|
onChallengeHidden: () -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val onTokenState = rememberUpdatedState(onToken)
|
||||||
|
val onReadyState = rememberUpdatedState(onReady)
|
||||||
|
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
|
||||||
|
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
|
||||||
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val backgroundArgb = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||||
|
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val captchaUrl = remember(sitekey, lang) {
|
||||||
|
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(instanceId) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||||
|
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bridge = remember {
|
||||||
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onGetToken(token: String) {
|
||||||
|
val cleaned = token.trim()
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
|
||||||
|
)
|
||||||
|
mainHandler.post {
|
||||||
|
if (cleaned.isNotEmpty()) {
|
||||||
|
onTokenState.value(cleaned)
|
||||||
|
} else {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
|
||||||
|
onErrorState.value("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onChallengeVisible() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
|
||||||
|
mainHandler.post { onChallengeVisibleState.value() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onChallengeHidden() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
|
||||||
|
mainHandler.post { onChallengeHiddenState.value() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(webView, lifecycleOwner) {
|
||||||
|
val wv = webView ?: return@DisposableEffect onDispose { }
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
Logger.d(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"lifecycle $event id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)} " +
|
||||||
|
"progress=${wv.progress}",
|
||||||
|
)
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_PAUSE -> wv.onPause()
|
||||||
|
Lifecycle.Event.ON_RESUME -> wv.onResume()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||||
|
wv.onResume()
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
wv.onPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "AndroidView.factory id=$instanceId")
|
||||||
|
WebView(context).apply {
|
||||||
|
setBackgroundColor(backgroundArgb)
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
addJavascriptInterface(bridge, "NativeClient")
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
): Boolean {
|
||||||
|
val url = request?.url?.toString()
|
||||||
|
val host = request?.url?.host?.lowercase().orEmpty()
|
||||||
|
val block = host.isNotEmpty() &&
|
||||||
|
!host.endsWith("yandex.ru") &&
|
||||||
|
!host.endsWith("yandex.com") &&
|
||||||
|
!host.endsWith("yandex.net")
|
||||||
|
Logger.d(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"shouldOverrideUrlLoading id=$instanceId block=$block " +
|
||||||
|
"host=$host url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onPageStarted id=$instanceId url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onPageFinished id=$instanceId progress=${view?.progress} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onReadyState.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
error: WebResourceError?,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedError id=$instanceId main=${request?.isForMainFrame} " +
|
||||||
|
"code=${error?.errorCode} desc=${error?.description} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||||
|
)
|
||||||
|
if (request?.isForMainFrame == true) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onErrorState.value(error?.description?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpError(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
errorResponse: WebResourceResponse?,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedHttpError id=$instanceId main=${request?.isForMainFrame} " +
|
||||||
|
"status=${errorResponse?.statusCode} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedSslError(
|
||||||
|
view: WebView?,
|
||||||
|
handler: SslErrorHandler?,
|
||||||
|
error: SslError?,
|
||||||
|
) {
|
||||||
|
Logger.e(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedSslError id=$instanceId primary=${error?.primaryError} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(error?.url)}",
|
||||||
|
)
|
||||||
|
handler?.cancel()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onErrorState.value("SSL error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
loadUrl(captchaUrl)
|
||||||
|
webView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
wv.setBackgroundColor(backgroundArgb)
|
||||||
|
webView = wv
|
||||||
|
},
|
||||||
|
onRelease = { wv ->
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"AndroidView.onRelease id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)}",
|
||||||
|
)
|
||||||
|
wv.removeJavascriptInterface("NativeClient")
|
||||||
|
wv.stopLoading()
|
||||||
|
wv.destroy()
|
||||||
|
if (webView === wv) webView = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package ru.fromchat.ui.calls
|
package ru.fromchat.ui.calls
|
||||||
|
|
||||||
import android.R
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -16,6 +15,7 @@ import androidx.core.app.Person
|
|||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import ru.fromchat.api.calls.CallStore
|
import ru.fromchat.api.calls.CallStore
|
||||||
|
import ru.fromchat.notifications.NotificationSmallIcon
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground call session: keeps camera / mic eligible in background.
|
* Foreground call session: keeps camera / mic eligible in background.
|
||||||
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
|
|||||||
|
|
||||||
ensureActiveCallChannel(nm, channelLabel)
|
ensureActiveCallChannel(nm, channelLabel)
|
||||||
|
|
||||||
val smallIcon = try {
|
|
||||||
packageManager.getApplicationInfo(packageName, 0).icon
|
|
||||||
} catch (_: Exception) {
|
|
||||||
R.drawable.sym_call_outgoing
|
|
||||||
}
|
|
||||||
|
|
||||||
val hangUpPi = PendingIntent.getService(
|
val hangUpPi = PendingIntent.getService(
|
||||||
this,
|
this,
|
||||||
RC_HANG_UP,
|
RC_HANG_UP,
|
||||||
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(smallIcon)
|
.setSmallIcon(NotificationSmallIcon.resId(this))
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setCategory(Notification.CATEGORY_CALL)
|
.setCategory(Notification.CATEGORY_CALL)
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.calls.CallStore
|
import ru.fromchat.api.calls.CallStore
|
||||||
import ru.fromchat.api.calls.LiveKitConnectSession
|
import ru.fromchat.api.calls.LiveKitConnectSession
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.notifications.NotificationSmallIcon
|
||||||
import ru.fromchat.call_status_connecting
|
import ru.fromchat.call_status_connecting
|
||||||
import ru.fromchat.call_status_reconnecting
|
import ru.fromchat.call_status_reconnecting
|
||||||
import ru.fromchat.call_status_reconnecting_with_detail
|
import ru.fromchat.call_status_reconnecting_with_detail
|
||||||
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
|
|||||||
)
|
)
|
||||||
nm.createNotificationChannel(ch)
|
nm.createNotificationChannel(ch)
|
||||||
}
|
}
|
||||||
val smallIcon = try {
|
|
||||||
context.packageManager.getApplicationInfo(context.packageName, 0).icon
|
|
||||||
} catch (_: Exception) {
|
|
||||||
R.drawable.stat_sys_upload
|
|
||||||
}
|
|
||||||
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
|
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
|
||||||
.setContentTitle(updatedTitle)
|
.setContentTitle(updatedTitle)
|
||||||
.setContentText(updatedText)
|
.setContentText(updatedText)
|
||||||
.setSmallIcon(smallIcon)
|
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<string name="settings">Настройки</string>
|
<string name="settings">Настройки</string>
|
||||||
<string name="home">Главная</string>
|
<string name="home">Главная</string>
|
||||||
<string name="about">О приложении</string>
|
<string name="about">О приложении</string>
|
||||||
<string name="about_version">Версия 1.0</string>
|
<string name="about_version">Версия %1$s</string>
|
||||||
<string name="about_link_telegram">Telegram</string>
|
<string name="about_link_telegram">Telegram</string>
|
||||||
<string name="about_link_max">MAX</string>
|
<string name="about_link_max">MAX</string>
|
||||||
<string name="about_link_website">Сайт</string>
|
<string name="about_link_website">Сайт</string>
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<string name="display_name_error">От 1 до 64 символов</string>
|
<string name="display_name_error">От 1 до 64 символов</string>
|
||||||
<string name="fill_all_fields">Заполните все поля</string>
|
<string name="fill_all_fields">Заполните все поля</string>
|
||||||
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
||||||
|
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
|
||||||
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
||||||
<string name="passwords_dont_match">Пароли не совпадают</string>
|
<string name="passwords_dont_match">Пароли не совпадают</string>
|
||||||
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
||||||
@@ -52,8 +53,11 @@
|
|||||||
<string name="auth_step_confirm_title">Подтвердите пароль</string>
|
<string name="auth_step_confirm_title">Подтвердите пароль</string>
|
||||||
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
|
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
|
||||||
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||||
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. Номер телефона нам недоступен — мы видим только имя, фамилию и пол. Эти данные мы не сохраняем — они нужны только для защиты и чтобы заранее заполнить поля на следующем шаге (не бойтесь, вы сможете подправить данные перед отправкой).</string>
|
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||||
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||||
|
<string name="auth_captcha_title">Быстрая проверка</string>
|
||||||
|
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
||||||
|
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
||||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
@@ -169,6 +173,7 @@
|
|||||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||||
<string name="profile_not_found">Профиль не найден</string>
|
<string name="profile_not_found">Профиль не найден</string>
|
||||||
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
||||||
|
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
|
||||||
<string name="action_open_settings">Настройки</string>
|
<string name="action_open_settings">Настройки</string>
|
||||||
<string name="action_chat">Написать</string>
|
<string name="action_chat">Написать</string>
|
||||||
<string name="action_copy_link">Скопировать ссылку</string>
|
<string name="action_copy_link">Скопировать ссылку</string>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="home">Home</string>
|
<string name="home">Home</string>
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
<string name="about_version">Version 1.0</string>
|
<string name="about_version">Version %1$s</string>
|
||||||
<string name="about_link_telegram">Telegram</string>
|
<string name="about_link_telegram">Telegram</string>
|
||||||
<string name="about_link_max">MAX</string>
|
<string name="about_link_max">MAX</string>
|
||||||
<string name="about_link_website">Website</string>
|
<string name="about_link_website">Website</string>
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<!-- Validation Errors -->
|
<!-- Validation Errors -->
|
||||||
<string name="fill_all_fields">Please fill in every field</string>
|
<string name="fill_all_fields">Please fill in every field</string>
|
||||||
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
||||||
|
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
|
||||||
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
||||||
<string name="passwords_dont_match">The two passwords don’t match</string>
|
<string name="passwords_dont_match">The two passwords don’t match</string>
|
||||||
<string name="auth_welcome_title">Welcome to FromChat</string>
|
<string name="auth_welcome_title">Welcome to FromChat</string>
|
||||||
@@ -59,8 +60,11 @@
|
|||||||
<string name="auth_step_confirm_title">Confirm your password</string>
|
<string name="auth_step_confirm_title">Confirm your password</string>
|
||||||
<string name="auth_step_confirm_body">Enter the same password again.</string>
|
<string name="auth_step_confirm_body">Enter the same password again.</string>
|
||||||
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||||
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. We can’t access your phone number — we only see your first name, last name, and gender. We don’t store that data; it’s only used for protection and to pre-fill the next step (don’t worry — you can edit everything before submitting).</string>
|
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||||
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||||
|
<string name="auth_captcha_title">Quick check</string>
|
||||||
|
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
||||||
|
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
||||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
@@ -187,6 +191,7 @@
|
|||||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
<string name="profile_load_failed">Couldn’t 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">Couldn’t open the link</string>
|
||||||
<string name="action_open_settings">Settings</string>
|
<string name="action_open_settings">Settings</string>
|
||||||
<string name="action_chat">Chat</string>
|
<string name="action_chat">Chat</string>
|
||||||
<string name="action_copy_link">Copy link</string>
|
<string name="action_copy_link">Copy link</string>
|
||||||
|
|||||||
@@ -112,10 +112,9 @@ import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
|
|||||||
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
|
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
@@ -273,7 +272,7 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
if (response.status.value == 401) {
|
if (response.status.value == 401) {
|
||||||
val path = response.call.request.url.encodedPath
|
val path = response.call.request.url.encodedPath
|
||||||
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
val isCredentialCheck = path.endsWith("/auth/steps/password") || path.endsWith("/auth/steps/register/confirm")
|
||||||
if (!isCredentialCheck && !logoutInProgress) {
|
if (!isCredentialCheck && !logoutInProgress) {
|
||||||
MainScope().launch {
|
MainScope().launch {
|
||||||
runCatching { WebSocketManager.disconnect() }
|
runCatching { WebSocketManager.disconnect() }
|
||||||
@@ -484,22 +483,6 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun loginRequest(request: LoginRequest): LoginResponse =
|
|
||||||
http
|
|
||||||
.post("${ServerConfig.apiBaseUrl}/login") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
.body()
|
|
||||||
|
|
||||||
suspend fun registerRequest(request: RegisterRequest): LoginResponse =
|
|
||||||
http
|
|
||||||
.post("${ServerConfig.apiBaseUrl}/register") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
setBody(request)
|
|
||||||
}
|
|
||||||
.body()
|
|
||||||
|
|
||||||
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
|
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
|
||||||
httpProbe
|
httpProbe
|
||||||
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
|
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
|
||||||
@@ -513,6 +496,8 @@ object ApiClient {
|
|||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val yandexRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val captchaRequired: Boolean,
|
||||||
|
val captcha: SmartCaptchaParams?,
|
||||||
) : AuthPasswordStepOutcome
|
) : AuthPasswordStepOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,10 +515,26 @@ object ApiClient {
|
|||||||
.body<JsonObject>()
|
.body<JsonObject>()
|
||||||
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||||
return when (status) {
|
return when (status) {
|
||||||
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
"needs_register" -> {
|
||||||
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
|
val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
val captchaRequired = raw["captcha_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||||
)
|
val captcha = raw["captcha"]?.let {
|
||||||
|
json.decodeFromJsonElement(SmartCaptchaParams.serializer(), it)
|
||||||
|
}
|
||||||
|
ru.fromchat.Logger.i(
|
||||||
|
"SmartCaptcha",
|
||||||
|
"authPasswordStep needs_register yandexRequired=$yandexRequired " +
|
||||||
|
"captchaRequired=$captchaRequired " +
|
||||||
|
"hasCaptchaObject=${captcha != null} " +
|
||||||
|
"clientKeyLen=${captcha?.client_key?.length ?: 0}",
|
||||||
|
)
|
||||||
|
AuthPasswordStepOutcome.NeedsRegister(
|
||||||
|
yandexRequired = yandexRequired,
|
||||||
|
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||||
|
captchaRequired = captchaRequired,
|
||||||
|
captcha = captcha,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
@@ -86,12 +87,54 @@ object ChatListSync {
|
|||||||
|
|
||||||
private suspend fun refreshPublicChatPreviewFromLatest() {
|
private suspend fun refreshPublicChatPreviewFromLatest() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val response = ApiClient.getMessages(limit = 1)
|
val cached = MessageRepository.loadPublicMessages()
|
||||||
val latest = response.messages.maxByOrNull { message ->
|
val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
|
||||||
|
val latestResponse = ApiClient.getMessages(limit = 1)
|
||||||
|
val latest = latestResponse.messages.maxByOrNull { message ->
|
||||||
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
||||||
} ?: return@runCatching
|
} ?: return@runCatching
|
||||||
// Upsert only — does not wipe older cached messages.
|
|
||||||
MessageRepository.upsertPublicMessage(latest)
|
val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
|
||||||
|
val holeBelowLatest =
|
||||||
|
latest.id > 0 &&
|
||||||
|
maxCachedId > 0 &&
|
||||||
|
latest.id > maxCachedId + 1
|
||||||
|
val latestMissingWithPriorCache =
|
||||||
|
latest.id > 0 &&
|
||||||
|
latest.id !in cachedIds &&
|
||||||
|
maxCachedId > 0 &&
|
||||||
|
latest.id > maxCachedId
|
||||||
|
|
||||||
|
if (holeBelowLatest || latestMissingWithPriorCache) {
|
||||||
|
// Preview-only upsert would leave first+last holes; pull a page and merge.
|
||||||
|
Logger.i(
|
||||||
|
"ChatListSync",
|
||||||
|
"Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
|
||||||
|
"cachedCount=${cached.size} — fetching page to fill",
|
||||||
|
)
|
||||||
|
val page = ApiClient.getMessages(limit = 50)
|
||||||
|
val networkMessages = page.messages
|
||||||
|
if (networkMessages.isEmpty()) {
|
||||||
|
MessageRepository.upsertPublicMessage(latest)
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
|
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
|
||||||
|
val networkIds = networkMessages.map { it.id }.toSet()
|
||||||
|
val minNetworkId = networkMessages.minOf { it.id }
|
||||||
|
val maxNetworkId = networkMessages.maxOf { it.id }
|
||||||
|
val older = cached.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
|
||||||
|
val ahead = cached.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
|
||||||
|
val merged = (networkMessages + older + ahead).distinctBy { it.id }
|
||||||
|
Logger.i(
|
||||||
|
"ChatListSync",
|
||||||
|
"Public gap fill: network=${networkMessages.size} older=${older.size} " +
|
||||||
|
"ahead=${ahead.size} merged=${merged.size} — replaceAll=true",
|
||||||
|
)
|
||||||
|
MessageRepository.replacePublicMessages(merged, replaceAll = true)
|
||||||
|
} else {
|
||||||
|
// Upsert only — does not wipe older cached messages.
|
||||||
|
MessageRepository.upsertPublicMessage(latest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
|
|||||||
val data = message.data ?: return
|
val data = message.data ?: return
|
||||||
val updates = runCatching {
|
val updates = runCatching {
|
||||||
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: run {
|
||||||
|
Logger.w("ProfileUpdateSync", "updates batch decode failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
|
||||||
|
if (profileUpdates > 0) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileUpdateSync",
|
||||||
|
"updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
|
||||||
|
)
|
||||||
|
}
|
||||||
updates.updates.forEach { update ->
|
updates.updates.forEach { update ->
|
||||||
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
|
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"profileUpdate" -> {
|
"profileUpdate" -> {
|
||||||
val payload = message.data ?: return
|
val payload = message.data ?: run {
|
||||||
|
Logger.w("ProfileUpdateSync", "profileUpdate missing data")
|
||||||
|
return
|
||||||
|
}
|
||||||
onProfileUpdatePayload(payload)
|
onProfileUpdatePayload(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
|
|||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileUpdateSync",
|
"ProfileUpdateSync",
|
||||||
"profileUpdate id=${profile.id} username='${profile.username}' " +
|
"profileUpdate id=${profile.id} username='${profile.username}' " +
|
||||||
"bio='${profile.bio?.take(48)}'",
|
"deleted=${profile.deleted} suspended=${profile.suspended} " +
|
||||||
|
"bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
|
||||||
)
|
)
|
||||||
|
val hadCached = ProfileCache.get(profile.id)
|
||||||
ProfileCache.applyServerProfile(profile, force = true)
|
ProfileCache.applyServerProfile(profile, force = true)
|
||||||
|
val after = ProfileCache.get(profile.id)
|
||||||
|
Logger.d(
|
||||||
|
"ProfileUpdateSync",
|
||||||
|
"profileUpdate applied id=${profile.id} " +
|
||||||
|
"wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
|
||||||
|
"revisionAfter=${ProfileCache.revision.value}",
|
||||||
|
)
|
||||||
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
|
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
|
||||||
|
|
||||||
if (ApiClient.user?.id == profile.id) {
|
if (ApiClient.user?.id == profile.id) {
|
||||||
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
|
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
|
||||||
|
.onFailure {
|
||||||
|
Logger.w(
|
||||||
|
"ProfileUpdateSync",
|
||||||
|
"patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileUpdateSync",
|
||||||
|
"patchDmConversationPeerProfile done id=${profile.id}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
|
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
|
||||||
|
|||||||
@@ -58,13 +58,24 @@ object UpdateSyncManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
|
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
|
||||||
|
*
|
||||||
|
* Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
|
||||||
|
* receive loop (it would deadlock — the ack response cannot be read while this call blocks).
|
||||||
*/
|
*/
|
||||||
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
|
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
|
||||||
applyMutex.withLock {
|
applyMutex.withLock {
|
||||||
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
|
Logger.d("UpdateSync", "onUpdatesEnvelope begin")
|
||||||
|
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
|
||||||
|
Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
Logger.d(
|
||||||
|
"UpdateSync",
|
||||||
|
"onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
|
||||||
|
)
|
||||||
if (seq > _lastSeq.value) {
|
if (seq > _lastSeq.value) {
|
||||||
persistLastSeq(seq)
|
persistLastSeq(seq)
|
||||||
sendAck(seq)
|
sendAckFireAndForget(seq)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +101,10 @@ object UpdateSyncManager {
|
|||||||
/**
|
/**
|
||||||
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
|
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
|
||||||
* Does not advance the cursor until apply/rebuild succeeds.
|
* Does not advance the cursor until apply/rebuild succeeds.
|
||||||
|
*
|
||||||
|
* Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
|
||||||
|
* (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
|
||||||
|
* is not left with first+last holes filled only by slow incremental envelopes.
|
||||||
*/
|
*/
|
||||||
suspend fun runGapDetectionIfNeeded() {
|
suspend fun runGapDetectionIfNeeded() {
|
||||||
if (gapDetectionInProgress) {
|
if (gapDetectionInProgress) {
|
||||||
@@ -108,16 +123,38 @@ object UpdateSyncManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var rounds = 0
|
var rounds = 0
|
||||||
|
var consecutiveFailures = 0
|
||||||
while (rounds < 100) {
|
while (rounds < 100) {
|
||||||
rounds++
|
rounds++
|
||||||
val startSeq = _lastSeq.value
|
val startSeq = _lastSeq.value
|
||||||
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
|
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
|
||||||
|
|
||||||
val response = requestGetUpdates(token, startSeq) ?: break
|
val response = requestGetUpdates(token, startSeq)
|
||||||
|
if (response == null) {
|
||||||
|
consecutiveFailures++
|
||||||
|
Logger.w(
|
||||||
|
"UpdateSyncManager",
|
||||||
|
"getUpdates returned null (timeout/disconnect) " +
|
||||||
|
"failures=$consecutiveFailures lastSeq=$startSeq",
|
||||||
|
)
|
||||||
|
if (consecutiveFailures >= 2) {
|
||||||
|
Logger.w(
|
||||||
|
"UpdateSyncManager",
|
||||||
|
"Gap catch-up stalled — rebuilding from history",
|
||||||
|
)
|
||||||
|
rebuildStateFromHistory()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consecutiveFailures = 0
|
||||||
|
|
||||||
|
val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
|
||||||
Logger.i(
|
Logger.i(
|
||||||
"UpdateSyncManager",
|
"UpdateSyncManager",
|
||||||
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
|
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
|
||||||
"missed=${response.missedCount}, hasMore=${response.hasMore}",
|
"missed=${response.missedCount}, hasMore=${response.hasMore}, " +
|
||||||
|
"gapHint=$gapHint clientSeq=$startSeq",
|
||||||
)
|
)
|
||||||
updateMissedCount(response.missedCount)
|
updateMissedCount(response.missedCount)
|
||||||
|
|
||||||
@@ -126,18 +163,43 @@ object UpdateSyncManager {
|
|||||||
val ok = rebuildStateFromHistory()
|
val ok = rebuildStateFromHistory()
|
||||||
if (ok) {
|
if (ok) {
|
||||||
persistLastSeq(response.lastSeq)
|
persistLastSeq(response.lastSeq)
|
||||||
sendAck(response.lastSeq)
|
sendAckFireAndForget(response.lastSeq)
|
||||||
} else {
|
} else {
|
||||||
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
|
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
"ok" -> {
|
"ok" -> {
|
||||||
|
// Envelopes for this chunk are applied on the receive path before this
|
||||||
|
// response is delivered; advance cursor here only when there was nothing to apply.
|
||||||
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
|
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
|
||||||
persistLastSeq(response.lastSeq)
|
persistLastSeq(response.lastSeq)
|
||||||
sendAck(response.lastSeq)
|
sendAckFireAndForget(response.lastSeq)
|
||||||
|
}
|
||||||
|
if (!response.hasMore) {
|
||||||
|
Logger.i(
|
||||||
|
"UpdateSyncManager",
|
||||||
|
"Gap catch-up complete after $rounds round(s) lastSeq=${_lastSeq.value}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (_lastSeq.value <= startSeq && response.missedCount > 0) {
|
||||||
|
// Chunk was announced but cursor did not advance — avoid tight spin.
|
||||||
|
Logger.w(
|
||||||
|
"UpdateSyncManager",
|
||||||
|
"Gap chunk did not advance cursor " +
|
||||||
|
"(start=$startSeq now=${_lastSeq.value} missed=${response.missedCount})",
|
||||||
|
)
|
||||||
|
consecutiveFailures++
|
||||||
|
if (consecutiveFailures >= 2) {
|
||||||
|
val ok = rebuildStateFromHistory()
|
||||||
|
if (ok) {
|
||||||
|
persistLastSeq(response.lastSeq)
|
||||||
|
sendAckFireAndForget(response.lastSeq)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!response.hasMore) break
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
|
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
|
||||||
@@ -165,7 +227,7 @@ object UpdateSyncManager {
|
|||||||
GetUpdatesRequest(lastSeq = lastSeq),
|
GetUpdatesRequest(lastSeq = lastSeq),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val response = WebSocketManager.request(requestMessage)
|
val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
|
||||||
val data = response?.data ?: return null
|
val data = response?.data ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
||||||
@@ -174,11 +236,15 @@ object UpdateSyncManager {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendAck(seq: Int) {
|
/**
|
||||||
|
* Send ack without waiting for a response. Must not use [WebSocketManager.request] from
|
||||||
|
* the receive/apply path — that deadlocks the incoming frame loop for ~10s per envelope.
|
||||||
|
*/
|
||||||
|
private suspend fun sendAckFireAndForget(seq: Int) {
|
||||||
val token = ApiClient.token ?: return
|
val token = ApiClient.token ?: return
|
||||||
if (seq <= 0) return
|
if (seq <= 0) return
|
||||||
runCatching {
|
runCatching {
|
||||||
WebSocketManager.request(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "ackUpdates",
|
type = "ackUpdates",
|
||||||
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
|
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
|
||||||
@@ -188,6 +254,7 @@ object UpdateSyncManager {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
|
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
|
||||||
}
|
}
|
||||||
@@ -236,18 +303,26 @@ object UpdateSyncManager {
|
|||||||
val ordered = collected.values.sortedBy {
|
val ordered = collected.values.sortedBy {
|
||||||
parseMessageTimestampMillis(it.timestamp) ?: 0L
|
parseMessageTimestampMillis(it.timestamp) ?: 0L
|
||||||
}
|
}
|
||||||
MessageRepository.replacePublicMessages(ordered)
|
MessageCacheStore.clearPublicMessages()
|
||||||
|
Logger.i(
|
||||||
|
"UpdateSync",
|
||||||
|
"rebuildPublicHistory messages=${ordered.size} — replaceAll=true",
|
||||||
|
)
|
||||||
|
MessageRepository.replacePublicMessages(ordered, replaceAll = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun rebuildDmHistories() {
|
private suspend fun rebuildDmHistories() {
|
||||||
val conversations = MessageRepository.loadCachedDmConversations()
|
val conversations = MessageRepository.loadCachedDmConversations()
|
||||||
|
Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
|
||||||
for (conversation in conversations) {
|
for (conversation in conversations) {
|
||||||
val otherId = conversation.otherUserId
|
val otherId = conversation.otherUserId
|
||||||
MessageCacheStore.clearDmMessages(otherId)
|
MessageCacheStore.clearDmMessages(otherId)
|
||||||
var beforeId: Int? = null
|
var beforeId: Int? = null
|
||||||
|
var pageCount = 0
|
||||||
repeat(MAX_HISTORY_PAGES) {
|
repeat(MAX_HISTORY_PAGES) {
|
||||||
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
||||||
if (page.messages.isEmpty()) return@repeat
|
if (page.messages.isEmpty()) return@repeat
|
||||||
|
pageCount++
|
||||||
for (envelope in page.messages) {
|
for (envelope in page.messages) {
|
||||||
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
|
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
|
||||||
DmInboundMessageProcessor.processNew(element)
|
DmInboundMessageProcessor.processNew(element)
|
||||||
@@ -256,6 +331,7 @@ object UpdateSyncManager {
|
|||||||
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
||||||
beforeId = oldest.id
|
beforeId = oldest.id
|
||||||
}
|
}
|
||||||
|
Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-29
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
|
|||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
|
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||||
@@ -167,8 +168,12 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun replacePublicMessages(messages: List<Message>) {
|
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
|
||||||
val convId = conversationIdForPublic()
|
val convId = conversationIdForPublic()
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
|
||||||
|
)
|
||||||
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
|
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
|
||||||
ProfileCache.mergePreviewFromPublicMessages(resolved)
|
ProfileCache.mergePreviewFromPublicMessages(resolved)
|
||||||
val pending = loadPendingMessages(convId)
|
val pending = loadPendingMessages(convId)
|
||||||
@@ -181,10 +186,11 @@ object MessageCacheStore {
|
|||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
purgeSupersededPendingRows(iid, convId, before, merged)
|
purgeSupersededPendingRows(iid, convId, before, merged)
|
||||||
}
|
}
|
||||||
replaceMessages(convId, merged)
|
replaceMessages(convId, merged, replaceAll = replaceAll)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearPublicMessages() {
|
suspend fun clearPublicMessages() {
|
||||||
|
Logger.d("MessageCache", "clearPublicMessages")
|
||||||
clearConversationMessages(conversationIdForPublic())
|
clearConversationMessages(conversationIdForPublic())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +198,17 @@ object MessageCacheStore {
|
|||||||
loadMessages(conversationIdForDm(otherUserId))
|
loadMessages(conversationIdForDm(otherUserId))
|
||||||
|
|
||||||
suspend fun clearDmMessages(otherUserId: Int) {
|
suspend fun clearDmMessages(otherUserId: Int) {
|
||||||
|
Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
|
||||||
clearConversationMessages(conversationIdForDm(otherUserId))
|
clearConversationMessages(conversationIdForDm(otherUserId))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) {
|
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
|
||||||
val convId = conversationIdForDm(otherUserId)
|
val convId = conversationIdForDm(otherUserId)
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
|
||||||
|
"replaceAll=$replaceAll convId=$convId",
|
||||||
|
)
|
||||||
val pending = loadPendingMessages(convId)
|
val pending = loadPendingMessages(convId)
|
||||||
val stillPending = filterStillPendingForReplace(convId, pending, messages)
|
val stillPending = filterStillPendingForReplace(convId, pending, messages)
|
||||||
val before = messages + stillPending
|
val before = messages + stillPending
|
||||||
@@ -208,7 +220,7 @@ object MessageCacheStore {
|
|||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
purgeSupersededPendingRows(iid, convId, before, hydrated)
|
purgeSupersededPendingRows(iid, convId, before, hydrated)
|
||||||
}
|
}
|
||||||
replaceMessages(convId, hydrated)
|
replaceMessages(convId, hydrated, replaceAll = replaceAll)
|
||||||
pruneEmptyConversations()
|
pruneEmptyConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +258,11 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
suspend fun upsertPublicMessage(message: Message) {
|
suspend fun upsertPublicMessage(message: Message) {
|
||||||
val resolved = message.resolvePublicAttachmentLayout()
|
val resolved = message.resolvePublicAttachmentLayout()
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
|
||||||
|
"clientId=${resolved.client_message_id}",
|
||||||
|
)
|
||||||
ProfileCache.mergePreviewFromPublicMessage(resolved)
|
ProfileCache.mergePreviewFromPublicMessage(resolved)
|
||||||
upsertSingle(conversationIdForPublic(), resolved)
|
upsertSingle(conversationIdForPublic(), resolved)
|
||||||
}
|
}
|
||||||
@@ -279,25 +296,37 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
|
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
|
||||||
|
"userId=${message.user_id} clientId=${message.client_message_id}",
|
||||||
|
)
|
||||||
ensureDmConversationRow(otherUserId)
|
ensureDmConversationRow(otherUserId)
|
||||||
upsertSingle(conversationIdForDm(otherUserId), message)
|
upsertSingle(conversationIdForDm(otherUserId), message)
|
||||||
syncDmConversationPreviewFromCache(otherUserId)
|
syncDmConversationPreviewFromCache(otherUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
|
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
|
||||||
|
Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
|
||||||
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
|
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
|
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
|
||||||
|
)
|
||||||
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
|
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
||||||
|
Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
|
||||||
deleteMessageById(conversationIdForDm(otherUserId), messageId)
|
deleteMessageById(conversationIdForDm(otherUserId), messageId)
|
||||||
syncDmConversationPreviewFromCache(otherUserId)
|
syncDmConversationPreviewFromCache(otherUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePublicMessageById(messageId: Int) {
|
suspend fun deletePublicMessageById(messageId: Int) {
|
||||||
|
Logger.d("MessageCache", "deletePublicById messageId=$messageId")
|
||||||
deleteMessageById(conversationIdForPublic(), messageId)
|
deleteMessageById(conversationIdForPublic(), messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +386,7 @@ object MessageCacheStore {
|
|||||||
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
||||||
resolved = resolved.copy(
|
resolved = resolved.copy(
|
||||||
fileAspectRatios = listOf(decodedAspect),
|
fileAspectRatios = listOf(decodedAspect),
|
||||||
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
|
fileDimensions = listOf(thumbDims.first to thumbDims.second),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,6 +469,10 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
|
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
|
||||||
|
)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.markMessageDeleted(
|
db.messageDatabaseQueries.markMessageDeleted(
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
@@ -611,10 +644,43 @@ object MessageCacheStore {
|
|||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val existing = db.messageDatabaseQueries
|
val existing = db.messageDatabaseQueries
|
||||||
.selectConversationById(iid, convId)
|
.selectConversationById(iid, convId)
|
||||||
.executeAsOneOrNull() ?: return@withContext
|
.executeAsOneOrNull() ?: run {
|
||||||
val label = resolveDmConversationDisplayLabel(otherUserId, null)
|
Logger.d(
|
||||||
if (label.isEmpty()) return@withContext
|
"MessageCache",
|
||||||
if (label == existing.displayName) return@withContext
|
"patchDmPeerProfile noConversation otherUserId=$otherUserId",
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
val profile = ProfileCache.get(otherUserId)
|
||||||
|
val isDeleted = profile?.deleted == true ||
|
||||||
|
profile?.username?.startsWith("#deleted") == true
|
||||||
|
val label = if (isDeleted) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
resolveDmConversationDisplayLabel(otherUserId, null)
|
||||||
|
}
|
||||||
|
if (!isDeleted && label.isEmpty()) {
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
|
||||||
|
"deleted=${profile?.deleted}",
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
if (label == existing.displayName) {
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"patchDmPeerProfile unchanged otherUserId=$otherUserId " +
|
||||||
|
"deleted=$isDeleted labelEmpty=${label.isEmpty()}",
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
|
||||||
|
"oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
|
||||||
|
"newLabelEmpty=${label.isEmpty()}",
|
||||||
|
)
|
||||||
db.messageDatabaseQueries.upsertConversation(
|
db.messageDatabaseQueries.upsertConversation(
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
id = existing.id,
|
id = existing.id,
|
||||||
@@ -947,6 +1013,7 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
private suspend fun clearConversationMessages(conversationId: String) {
|
private suspend fun clearConversationMessages(conversationId: String) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
|
Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
||||||
}
|
}
|
||||||
@@ -954,6 +1021,10 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
|
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
|
||||||
|
)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
|
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
|
||||||
}
|
}
|
||||||
@@ -961,6 +1032,12 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
|
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
|
val beforeCount = withContext(Dispatchers.Default) {
|
||||||
|
db.messageDatabaseQueries
|
||||||
|
.selectMessagesByConversation(iid, conversationId)
|
||||||
|
.executeAsList()
|
||||||
|
.size
|
||||||
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.deleteMessageById(
|
db.messageDatabaseQueries.deleteMessageById(
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
@@ -968,6 +1045,17 @@ object MessageCacheStore {
|
|||||||
id = messageId.toLong(),
|
id = messageId.toLong(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val afterCount = withContext(Dispatchers.Default) {
|
||||||
|
db.messageDatabaseQueries
|
||||||
|
.selectMessagesByConversation(iid, conversationId)
|
||||||
|
.executeAsList()
|
||||||
|
.size
|
||||||
|
}
|
||||||
|
Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"deleteMessageById convId=$conversationId messageId=$messageId " +
|
||||||
|
"rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
private suspend fun upsertSingle(conversationId: String, msg: Message) {
|
||||||
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
|
|||||||
val profile = ProfileCache.get(uid)
|
val profile = ProfileCache.get(uid)
|
||||||
val usernameResolved = when {
|
val usernameResolved = when {
|
||||||
self != null && uid == self.id -> self.username
|
self != null && uid == self.id -> self.username
|
||||||
else -> profile?.username?.takeIf { it.isNotBlank() }
|
else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
|
||||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
}
|
||||||
?: ""
|
val displayNameResolved = when {
|
||||||
|
self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
val pictureResolved = when {
|
val pictureResolved = when {
|
||||||
self != null && uid == self.id -> self.profile_picture
|
self != null && uid == self.id -> self.profile_picture
|
||||||
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
|
|||||||
is_read = isRead != 0L,
|
is_read = isRead != 0L,
|
||||||
is_edited = isEdited != 0L,
|
is_edited = isEdited != 0L,
|
||||||
username = usernameResolved,
|
username = usernameResolved,
|
||||||
|
displayName = displayNameResolved,
|
||||||
profile_picture = pictureResolved,
|
profile_picture = pictureResolved,
|
||||||
verified = profile?.verified,
|
verified = profile?.verified,
|
||||||
verificationStatus = profile?.verificationStatus,
|
verificationStatus = profile?.verificationStatus,
|
||||||
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearAll() {
|
suspend fun clearAll() {
|
||||||
|
Logger.d("MessageCache", "clearAll")
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries.purgeAllCache()
|
db.messageDatabaseQueries.purgeAllCache()
|
||||||
}
|
}
|
||||||
@@ -1334,44 +1426,86 @@ 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 }
|
||||||
db.messageDatabaseQueries.transaction {
|
if (replaceAll) {
|
||||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
db.messageDatabaseQueries.transaction {
|
||||||
|
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
|
||||||
|
validated.forEach { msg: Message ->
|
||||||
|
db.messageDatabaseQueries.upsertMessage(
|
||||||
|
instanceId = iid,
|
||||||
|
id = msg.id.toLong(),
|
||||||
|
conversationId = conversationId,
|
||||||
|
userId = msg.user_id.toLong(),
|
||||||
|
content = storedMessageContent(msg),
|
||||||
|
timestamp = msg.timestamp,
|
||||||
|
isRead = if (msg.is_read) 1L else 0L,
|
||||||
|
isEdited = if (msg.is_edited) 1L else 0L,
|
||||||
|
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
|
||||||
|
clientMessageId = msg.client_message_id,
|
||||||
|
deletedFlag = 0L,
|
||||||
|
sendStatus = if (msg.id < 0) "pending" else "sent",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Merge into existing rows — partial UI snapshots must not wipe full history.
|
||||||
validated.forEach { msg: Message ->
|
validated.forEach { msg: Message ->
|
||||||
db.messageDatabaseQueries.upsertMessage(
|
upsertSingle(conversationId, msg)
|
||||||
instanceId = iid,
|
|
||||||
id = msg.id.toLong(),
|
|
||||||
conversationId = conversationId,
|
|
||||||
userId = msg.user_id.toLong(),
|
|
||||||
content = storedMessageContent(msg),
|
|
||||||
timestamp = msg.timestamp,
|
|
||||||
isRead = if (msg.is_read) 1L else 0L,
|
|
||||||
isEdited = if (msg.is_edited) 1L else 0L,
|
|
||||||
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
|
|
||||||
clientMessageId = msg.client_message_id,
|
|
||||||
deletedFlag = 0L,
|
|
||||||
sendStatus = if (msg.id < 0) "pending" else "sent"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
|
|||||||
+35
-10
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat.api.local.db.store
|
package ru.fromchat.api.local.db.store
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||||
@@ -21,7 +22,9 @@ object MessageRepository {
|
|||||||
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
||||||
|
|
||||||
fun observePublicMessages(): Flow<List<Message>> =
|
fun observePublicMessages(): Flow<List<Message>> =
|
||||||
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
|
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)).map { rows ->
|
||||||
|
ProfileCache.enrichPublicMessagesForDisplay(rows)
|
||||||
|
}
|
||||||
|
|
||||||
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
||||||
observeMessages(conversationIdForDm(otherUserId))
|
observeMessages(conversationIdForDm(otherUserId))
|
||||||
@@ -56,8 +59,13 @@ object MessageRepository {
|
|||||||
fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
|
fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
|
||||||
MessageCacheStore.observeActiveDmConversations(activeInstance())
|
MessageCacheStore.observeActiveDmConversations(activeInstance())
|
||||||
|
|
||||||
suspend fun replacePublicMessages(messages: List<Message>) =
|
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
|
||||||
MessageCacheStore.replacePublicMessages(messages)
|
ru.fromchat.Logger.d(
|
||||||
|
"MessageRepo",
|
||||||
|
"replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
|
||||||
|
)
|
||||||
|
MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
|
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
|
||||||
|
|
||||||
@@ -67,20 +75,32 @@ object MessageRepository {
|
|||||||
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
|
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
|
||||||
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
|
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
|
||||||
|
|
||||||
suspend fun markMessageDeleted(conversationId: String, messageId: Int) =
|
suspend fun deletePublicMessageById(messageId: Int) {
|
||||||
|
ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
|
||||||
|
MessageCacheStore.deletePublicMessageById(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"MessageRepo",
|
||||||
|
"markMessageDeleted convId=$conversationId messageId=$messageId",
|
||||||
|
)
|
||||||
MessageCacheStore.markMessageDeleted(conversationId, messageId)
|
MessageCacheStore.markMessageDeleted(conversationId, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun markPublicMessageDeleted(messageId: Int) =
|
suspend fun markPublicMessageDeleted(messageId: Int) =
|
||||||
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
|
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
|
||||||
|
|
||||||
suspend fun deletePublicMessageById(messageId: Int) =
|
|
||||||
MessageCacheStore.deletePublicMessageById(messageId)
|
|
||||||
|
|
||||||
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
||||||
MessageCacheStore.loadDmMessages(otherUserId)
|
MessageCacheStore.loadDmMessages(otherUserId)
|
||||||
|
|
||||||
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>) =
|
suspend fun replaceDmMessages(otherUserId: Int, messages: List<Message>, replaceAll: Boolean = false) {
|
||||||
MessageCacheStore.replaceDmMessages(otherUserId, messages)
|
ru.fromchat.Logger.d(
|
||||||
|
"MessageRepo",
|
||||||
|
"replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
|
||||||
|
)
|
||||||
|
MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
|
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
|
||||||
MessageCacheStore.upsertDmMessage(otherUserId, message)
|
MessageCacheStore.upsertDmMessage(otherUserId, message)
|
||||||
@@ -91,8 +111,13 @@ object MessageRepository {
|
|||||||
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
|
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
|
||||||
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
|
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
|
||||||
|
|
||||||
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
|
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"MessageRepo",
|
||||||
|
"deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
|
||||||
|
)
|
||||||
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
|
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun replaceDmConversations(
|
suspend fun replaceDmConversations(
|
||||||
conversations: List<DmConversation>,
|
conversations: List<DmConversation>,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import ru.fromchat.api.schema.messages.dm.DmConversationUser
|
import ru.fromchat.api.schema.messages.dm.DmConversationUser
|
||||||
@@ -71,19 +72,41 @@ object ProfileCache {
|
|||||||
/** Skip force=false network refetch when a full profile was fetched within this window. */
|
/** Skip force=false network refetch when a full profile was fetched within this window. */
|
||||||
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
|
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
|
||||||
|
|
||||||
private fun bumpRevision() {
|
private fun bumpRevision(reason: String) {
|
||||||
_revision.value++
|
val next = _revision.value + 1
|
||||||
|
_revision.value = next
|
||||||
|
Logger.d("ProfileCache", "revision=$next reason=$reason size=${profiles.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun profileSummary(profile: UserProfile): String =
|
||||||
|
"id=${profile.id} user='${profile.username}' deleted=${profile.deleted} " +
|
||||||
|
"suspended=${profile.suspended} preview=${profile.isClientPreviewOnly} " +
|
||||||
|
"verified=${profile.verified} vStatus=${profile.verificationStatus}"
|
||||||
|
|
||||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||||
|
|
||||||
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
|
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
|
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
|
||||||
val profile = get(userId) ?: return false
|
val profile = get(userId) ?: run {
|
||||||
if (profile.isClientPreviewOnly) return false
|
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
|
||||||
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
|
return false
|
||||||
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
|
}
|
||||||
|
if (profile.isClientPreviewOnly) {
|
||||||
|
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=previewOnly")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val fetchedAt = fullProfileFetchedAtMs[userId] ?: run {
|
||||||
|
Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=noFetchTs")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val ageMs = Clock.System.now().toEpochMilliseconds() - fetchedAt
|
||||||
|
val fresh = ageMs <= maxAgeMs
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"hasFreshFullProfile id=$userId fresh=$fresh ageMs=$ageMs maxAgeMs=$maxAgeMs",
|
||||||
|
)
|
||||||
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emits whenever this user's cached profile changes (including bio). */
|
/** Emits whenever this user's cached profile changes (including bio). */
|
||||||
@@ -110,7 +133,13 @@ object ProfileCache {
|
|||||||
) {
|
) {
|
||||||
if (id <= 0) return
|
if (id <= 0) return
|
||||||
val existing = get(id)
|
val existing = get(id)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) return
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergePreview skipFullExists id=$id deleted=${existing.deleted}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
|
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
|
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
@@ -120,11 +149,27 @@ object ProfileCache {
|
|||||||
} else {
|
} else {
|
||||||
displayName?.trim()?.takeIf { it.isNotEmpty() }
|
displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: incomingUsername
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
|
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
|
||||||
|
Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergePreview missingIdentity id=$id " +
|
||||||
|
"hasUsername=${!incomingUsername.isNullOrEmpty()} " +
|
||||||
|
"hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
|
||||||
|
"user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
|
||||||
|
)
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -159,29 +204,49 @@ object ProfileCache {
|
|||||||
val hasIdentity =
|
val hasIdentity =
|
||||||
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
|
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
|
||||||
if (!hasIdentity) {
|
if (!hasIdentity) {
|
||||||
|
Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
|
||||||
remove(profile.id)
|
remove(profile.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
val existing = cur[profile.id]
|
val existing = cur[profile.id]
|
||||||
|
val deletedChanged = existing?.deleted != profile.deleted
|
||||||
|
val suspendedChanged = existing?.suspended != profile.suspended
|
||||||
|
val verificationChanged = existing?.verified != profile.verified ||
|
||||||
|
existing?.verificationStatus != profile.verificationStatus
|
||||||
if (
|
if (
|
||||||
existing != null &&
|
existing != null &&
|
||||||
!existing.isClientPreviewOnly &&
|
!existing.isClientPreviewOnly &&
|
||||||
existing.bio != profile.bio
|
existing.bio != profile.bio
|
||||||
) {
|
) {
|
||||||
ru.fromchat.Logger.d(
|
Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
|
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
|
||||||
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
|
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"put ${profileSummary(profile)} hadExisting=${existing != null} " +
|
||||||
|
"deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
|
||||||
|
"verificationChanged=$verificationChanged",
|
||||||
|
)
|
||||||
|
}
|
||||||
profiles = cur + (profile.id to profile)
|
profiles = cur + (profile.id to profile)
|
||||||
bumpRevision()
|
bumpRevision("put:${profile.id}")
|
||||||
val instanceId = loadedInstanceId
|
val instanceId = loadedInstanceId
|
||||||
if (instanceId.isNotEmpty()) {
|
if (instanceId.isNotEmpty()) {
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
runCatching { ProfileCacheStore.put(instanceId, profile) }
|
runCatching { ProfileCacheStore.put(instanceId, profile) }
|
||||||
|
.onFailure {
|
||||||
|
Logger.w(
|
||||||
|
"ProfileCache",
|
||||||
|
"persist put failed id=${profile.id}: ${it.message}",
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,27 +264,56 @@ object ProfileCache {
|
|||||||
if (!force) {
|
if (!force) {
|
||||||
val existing = get(profile.id)
|
val existing = get(profile.id)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
val lifecycleMismatch =
|
||||||
|
existing.deleted != normalized.deleted ||
|
||||||
|
existing.suspended != normalized.suspended ||
|
||||||
|
isDeletedPlaceholderUsername(existing.username) !=
|
||||||
|
isDeletedPlaceholderUsername(normalized.username)
|
||||||
|
if (lifecycleMismatch) {
|
||||||
|
Logger.w(
|
||||||
|
"ProfileCache",
|
||||||
|
"applyServerProfile force=false lifecycleMismatch " +
|
||||||
|
"cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
|
||||||
|
"cachedSuspended=${existing.suspended} " +
|
||||||
|
"incomingSuspended=${normalized.suspended} " +
|
||||||
|
"cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
|
||||||
|
"— applying full server profile",
|
||||||
|
)
|
||||||
|
put(normalized)
|
||||||
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
val patched = existing.copy(
|
val patched = existing.copy(
|
||||||
verified = normalized.verified ?: existing.verified,
|
verified = normalized.verified ?: existing.verified,
|
||||||
verificationStatus = normalized.verificationStatus
|
verificationStatus = normalized.verificationStatus
|
||||||
?: existing.verificationStatus,
|
?: existing.verificationStatus,
|
||||||
)
|
)
|
||||||
|
val verificationChanged = patched.verified != existing.verified ||
|
||||||
|
patched.verificationStatus != existing.verificationStatus
|
||||||
if (patched != existing) put(patched)
|
if (patched != existing) put(patched)
|
||||||
if (existing.bio != normalized.bio) {
|
if (existing.bio != normalized.bio) {
|
||||||
ru.fromchat.Logger.d(
|
Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
"applyServerProfile skipped stale HTTP id=${profile.id} " +
|
"applyServerProfile skipped stale HTTP id=${profile.id} " +
|
||||||
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
|
||||||
|
"deleted=${existing.deleted}",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"applyServerProfile keepCached force=false id=${profile.id} " +
|
||||||
|
"deleted=${existing.deleted} verificationChanged=$verificationChanged",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Refresh TTL so force=false callers stop refetching.
|
if (!verificationChanged) {
|
||||||
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ru.fromchat.Logger.d(
|
Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
"applyServerProfile applied force=$force id=${profile.id} " +
|
"applyServerProfile applied force=$force ${profileSummary(normalized)} " +
|
||||||
"bio='${normalized.bio?.take(48)}'",
|
"bio='${normalized.bio?.take(48)}'",
|
||||||
)
|
)
|
||||||
put(normalized)
|
put(normalized)
|
||||||
@@ -228,9 +322,13 @@ object ProfileCache {
|
|||||||
|
|
||||||
fun remove(userId: Int) {
|
fun remove(userId: Int) {
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
if (userId !in cur) return
|
if (userId !in cur) {
|
||||||
|
Logger.d("ProfileCache", "remove miss id=$userId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
|
||||||
profiles = cur - userId
|
profiles = cur - userId
|
||||||
bumpRevision()
|
bumpRevision("remove:$userId")
|
||||||
val instanceId = loadedInstanceId
|
val instanceId = loadedInstanceId
|
||||||
if (instanceId.isNotEmpty()) {
|
if (instanceId.isNotEmpty()) {
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
@@ -251,20 +349,40 @@ object ProfileCache {
|
|||||||
if (user.id <= 0) return
|
if (user.id <= 0) return
|
||||||
|
|
||||||
val incomingUsername = user.username.trim()
|
val incomingUsername = user.username.trim()
|
||||||
if (incomingUsername.isEmpty()) return
|
if (incomingUsername.isEmpty()) {
|
||||||
|
Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
|
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
|
||||||
val incomingDisplayName = if (isDeleted) {
|
val incomingDisplayName = if (isDeleted) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
|
user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = get(user.id)
|
val existing = get(user.id)
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergeFromDmUser id=${user.id} deleted=$isDeleted " +
|
||||||
|
"incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
|
||||||
|
"user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
|
||||||
|
)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
val patched = existing.copy(
|
val patched = existing.copy(
|
||||||
username = incomingUsername,
|
username = incomingUsername,
|
||||||
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
|
displayName = if (isDeleted) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
incomingDisplayName ?: existing.displayName
|
||||||
|
},
|
||||||
profilePicture = if (isDeleted) {
|
profilePicture = if (isDeleted) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
@@ -278,7 +396,14 @@ object ProfileCache {
|
|||||||
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
|
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
|
||||||
deleted = isDeleted,
|
deleted = isDeleted,
|
||||||
)
|
)
|
||||||
if (patched != existing) put(patched)
|
if (patched != existing) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergeFromDmUser patchFull id=${user.id} " +
|
||||||
|
"deleted ${existing.deleted}→${patched.deleted}",
|
||||||
|
)
|
||||||
|
put(patched)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +411,11 @@ object ProfileCache {
|
|||||||
UserProfile(
|
UserProfile(
|
||||||
id = user.id,
|
id = user.id,
|
||||||
username = incomingUsername,
|
username = incomingUsername,
|
||||||
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
|
displayName = if (isDeleted) {
|
||||||
?: incomingDisplayName,
|
null
|
||||||
|
} else {
|
||||||
|
incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
},
|
||||||
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
|
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: existing?.profilePicture,
|
?: existing?.profilePicture,
|
||||||
bio = existing?.bio,
|
bio = existing?.bio,
|
||||||
@@ -326,21 +454,33 @@ object ProfileCache {
|
|||||||
val uid = message.user_id
|
val uid = message.user_id
|
||||||
if (uid <= 0) return
|
if (uid <= 0) return
|
||||||
val existing = get(uid)
|
val existing = get(uid)
|
||||||
|
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val incomingPic = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
val patched = existing.copy(
|
val patched = existing.copy(
|
||||||
verified = message.verified ?: existing.verified,
|
verified = message.verified ?: existing.verified,
|
||||||
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
||||||
|
displayName = existing.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplay,
|
||||||
|
profilePicture = existing.profilePicture?.takeIf { it.isNotBlank() } ?: incomingPic,
|
||||||
|
username = existing.username.trim().ifBlank { message.username.trim() },
|
||||||
)
|
)
|
||||||
if (patched != existing) put(patched)
|
if (patched != existing) put(patched)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||||
if (uname.isBlank()) return
|
val displayName = incomingDisplay ?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
if (uname.isBlank() && displayName.isNullOrBlank()) return
|
||||||
|
if (uname.isBlank() || displayName.isNullOrBlank()) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
|
||||||
|
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!displayName.isNullOrBlank()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
||||||
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
|
val display = if (isDeleted) null else displayName
|
||||||
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
|
val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
|
||||||
?: existing?.profilePicture
|
|
||||||
|
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
@@ -379,6 +519,8 @@ object ProfileCache {
|
|||||||
val user = ApiClient.user
|
val user = ApiClient.user
|
||||||
return message.copy(
|
return message.copy(
|
||||||
username = message.username.trim().ifBlank { user?.username.orEmpty() },
|
username = message.username.trim().ifBlank { user?.username.orEmpty() },
|
||||||
|
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: user?.profile_picture,
|
?: user?.profile_picture,
|
||||||
reply_to = enrichedReply,
|
reply_to = enrichedReply,
|
||||||
@@ -389,6 +531,8 @@ object ProfileCache {
|
|||||||
username = message.username.trim().ifBlank {
|
username = message.username.trim().ifBlank {
|
||||||
profile?.visibleUsername(self).orEmpty()
|
profile?.visibleUsername(self).orEmpty()
|
||||||
},
|
},
|
||||||
|
displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: profile?.profilePicture,
|
?: profile?.profilePicture,
|
||||||
verified = message.verified ?: profile?.verified,
|
verified = message.verified ?: profile?.verified,
|
||||||
@@ -413,7 +557,7 @@ object ProfileCache {
|
|||||||
}
|
}
|
||||||
fullProfileFetchedAtMs = emptyMap()
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
pruneUnusableClientPreviewsLocked()
|
pruneUnusableClientPreviewsLocked()
|
||||||
bumpRevision()
|
bumpRevision("onActiveInstanceChanged:$instanceId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,6 +571,7 @@ object ProfileCache {
|
|||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
val before = profiles.size
|
||||||
if (profiles.isEmpty()) {
|
if (profiles.isEmpty()) {
|
||||||
profiles = diskProfiles
|
profiles = diskProfiles
|
||||||
} else {
|
} else {
|
||||||
@@ -442,8 +587,14 @@ object ProfileCache {
|
|||||||
}
|
}
|
||||||
profiles = merged
|
profiles = merged
|
||||||
}
|
}
|
||||||
|
val deletedCount = profiles.values.count { it.deleted == true }
|
||||||
|
Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"hydrateFromDisk instanceId=$instanceId before=$before " +
|
||||||
|
"disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
|
||||||
|
)
|
||||||
pruneUnusableClientPreviewsLocked()
|
pruneUnusableClientPreviewsLocked()
|
||||||
bumpRevision()
|
bumpRevision("hydrateFromDisk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +606,7 @@ object ProfileCache {
|
|||||||
p.displayName.isNullOrBlank()
|
p.displayName.isNullOrBlank()
|
||||||
}.keys
|
}.keys
|
||||||
if (toRemove.isEmpty()) return
|
if (toRemove.isEmpty()) return
|
||||||
|
Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
|
||||||
var cur = profiles
|
var cur = profiles
|
||||||
for (id in toRemove) {
|
for (id in toRemove) {
|
||||||
cur = cur - id
|
cur = cur - id
|
||||||
@@ -464,10 +616,11 @@ object ProfileCache {
|
|||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
|
Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
|
||||||
profiles = emptyMap()
|
profiles = emptyMap()
|
||||||
fullProfileFetchedAtMs = emptyMap()
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
loadedInstanceId = ""
|
loadedInstanceId = ""
|
||||||
bumpRevision()
|
bumpRevision("clear")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-8
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
|
|||||||
*/
|
*/
|
||||||
object PublicInboxCoordinator {
|
object PublicInboxCoordinator {
|
||||||
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
|
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
val message = decodeMessage(element) ?: return@withContext
|
val message = decodeMessage(element) ?: run {
|
||||||
|
Logger.w("PublicInbox", "processNew decode failed")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Logger.d(
|
||||||
|
"PublicInbox",
|
||||||
|
"processNew id=${message.id} userId=${message.user_id} " +
|
||||||
|
"clientId=${message.client_message_id}",
|
||||||
|
)
|
||||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||||
val clientId = message.client_message_id?.trim().orEmpty()
|
val clientId = message.client_message_id?.trim().orEmpty()
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
|
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
val edited = decodeMessage(element) ?: return@withContext
|
val edited = decodeMessage(element) ?: run {
|
||||||
|
Logger.w("PublicInbox", "processEdited decode failed")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Logger.d("PublicInbox", "processEdited id=${edited.id} userId=${edited.user_id}")
|
||||||
ProfileCache.mergePreviewFromPublicMessage(edited)
|
ProfileCache.mergePreviewFromPublicMessage(edited)
|
||||||
val existing = MessageRepository.loadPublicMessages()
|
val existing = MessageRepository.loadPublicMessages()
|
||||||
val merged = existing.map { current ->
|
val merged = existing.map { current ->
|
||||||
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
|
|||||||
if (merged.none { it.id == edited.id }) {
|
if (merged.none { it.id == edited.id }) {
|
||||||
MessageRepository.upsertPublicMessage(edited)
|
MessageRepository.upsertPublicMessage(edited)
|
||||||
} else {
|
} else {
|
||||||
MessageRepository.replacePublicMessages(merged)
|
MessageRepository.upsertPublicMessage(
|
||||||
|
merged.first { it.id == edited.id },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
|
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
val deleted = runCatching {
|
val deleted = runCatching {
|
||||||
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
|
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
|
||||||
}.getOrNull() ?: return@withContext
|
}.getOrNull() ?: run {
|
||||||
|
Logger.w("PublicInbox", "processDeleted decode failed element=${element.toString().take(120)}")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Logger.i(
|
||||||
|
"PublicInbox",
|
||||||
|
"processDeleted messageId=${deleted.message_id} — hard-deleting from cache",
|
||||||
|
)
|
||||||
MessageRepository.deletePublicMessageById(deleted.message_id)
|
MessageRepository.deletePublicMessageById(deleted.message_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
|
|||||||
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
|
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
|
||||||
val envelope = runCatching {
|
val envelope = runCatching {
|
||||||
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
}.getOrNull() ?: return@withLock null
|
}.getOrNull() ?: run {
|
||||||
|
Logger.w("UpdateSync", "applyEnvelope decode failed: ${data.toString().take(160)}")
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
|
||||||
|
val types = envelope.updates.map { it.type }
|
||||||
|
Logger.d(
|
||||||
|
"UpdateSync",
|
||||||
|
"applyEnvelope seq=${envelope.seq} count=${envelope.updates.size} types=$types",
|
||||||
|
)
|
||||||
for (update in envelope.updates) {
|
for (update in envelope.updates) {
|
||||||
applyOne(WebSocketMessage(type = update.type, data = update.data))
|
applyOne(WebSocketMessage(type = update.type, data = update.data))
|
||||||
}
|
}
|
||||||
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
|
|||||||
}
|
}
|
||||||
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
|
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
|
||||||
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
|
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
|
||||||
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
|
"messageDeleted" -> {
|
||||||
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
|
Logger.d("UpdateSync", "applyOne messageDeleted")
|
||||||
|
message.data?.let { PublicInboxCoordinator.processDeleted(it) }
|
||||||
|
}
|
||||||
|
"dmNew", "dmDeleted", "dmEdited" -> {
|
||||||
|
Logger.d("UpdateSync", "applyOne ${message.type}")
|
||||||
|
DmInboxCoordinator.handleMessage(message)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ data class Message(
|
|||||||
val is_read: Boolean,
|
val is_read: Boolean,
|
||||||
val is_edited: Boolean,
|
val is_edited: Boolean,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val profile_picture: String? = null,
|
val profile_picture: String? = null,
|
||||||
val verified: Boolean? = null,
|
val verified: Boolean? = null,
|
||||||
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
|
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package ru.fromchat.api.schema.messages
|
package ru.fromchat.api.schema.messages
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ReactionUser(
|
data class ReactionUser(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val username: String
|
val username: String,
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
)
|
)
|
||||||
@@ -9,6 +9,7 @@ data class DmEnvelope(
|
|||||||
val senderId: Int,
|
val senderId: Int,
|
||||||
val recipientId: Int,
|
val recipientId: Int,
|
||||||
@SerialName("sender_username") val senderUsername: String? = null,
|
@SerialName("sender_username") val senderUsername: String? = null,
|
||||||
|
@SerialName("sender_display_name") val senderDisplayName: String? = null,
|
||||||
@SerialName("iv_b64") val ivB64: String,
|
@SerialName("iv_b64") val ivB64: String,
|
||||||
@SerialName("ciphertext_b64") val ciphertextB64: String,
|
@SerialName("ciphertext_b64") val ciphertextB64: String,
|
||||||
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
|
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
|
||||||
|
|||||||
@@ -27,11 +27,18 @@ data class YandexOAuthParams(
|
|||||||
val scope: String,
|
val scope: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SmartCaptchaParams(
|
||||||
|
val client_key: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AuthNeedsRegisterResponse(
|
data class AuthNeedsRegisterResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
val yandex_required: Boolean = false,
|
val yandex_required: Boolean = false,
|
||||||
val yandex: YandexOAuthParams? = null,
|
val yandex: YandexOAuthParams? = null,
|
||||||
|
val captcha_required: Boolean = false,
|
||||||
|
val captcha: SmartCaptchaParams? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -70,4 +77,5 @@ data class RegisterConfirmRequest(
|
|||||||
val confirm_password: String,
|
val confirm_password: String,
|
||||||
val bio: String? = null,
|
val bio: String? = null,
|
||||||
val registration_proof: String? = null,
|
val registration_proof: String? = null,
|
||||||
|
val captcha_token: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -1,5 +1,6 @@
|
|||||||
package ru.fromchat.api.schema.websocket.types
|
package ru.fromchat.api.schema.websocket.types
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
|
|||||||
val action: String,
|
val action: String,
|
||||||
val user_id: Int,
|
val user_id: Int,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
val reactions: List<ReactionData>
|
val reactions: List<ReactionData>
|
||||||
)
|
)
|
||||||
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
|
|||||||
import ru.fromchat.legal.DocumentType
|
import ru.fromchat.legal.DocumentType
|
||||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
import ru.fromchat.ui.auth.AuthScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
@@ -466,6 +468,10 @@ fun App(
|
|||||||
YandexOAuthScreen()
|
YandexOAuthScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(SmartCaptchaNav.ROUTE) {
|
||||||
|
SmartCaptchaScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package ru.fromchat.ui.auth
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
|
* Survives [AuthScreen] leaving composition when navigating to Yandex OAuth / SmartCaptcha routes.
|
||||||
* Cleared on welcome / successful auth / explicit reset to username.
|
* Cleared on welcome / successful auth / explicit reset to username.
|
||||||
*/
|
*/
|
||||||
internal object AuthRegisterDraft {
|
internal object AuthRegisterDraft {
|
||||||
@@ -14,7 +15,10 @@ internal object AuthRegisterDraft {
|
|||||||
var bio: String = ""
|
var bio: String = ""
|
||||||
var yandexRequired: Boolean = false
|
var yandexRequired: Boolean = false
|
||||||
var yandexParams: YandexOAuthParams? = null
|
var yandexParams: YandexOAuthParams? = null
|
||||||
|
var captchaRequired: Boolean = false
|
||||||
|
var captchaParams: SmartCaptchaParams? = null
|
||||||
var registrationProof: String? = null
|
var registrationProof: String? = null
|
||||||
|
var captchaToken: String? = null
|
||||||
var page: Int = 0
|
var page: Int = 0
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -25,7 +29,10 @@ internal object AuthRegisterDraft {
|
|||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
page = 0
|
page = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,20 +20,24 @@ import io.ktor.client.plugins.ClientRequestException
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
|
||||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
|
||||||
import ru.fromchat.api.instance.ServerProbeResult
|
import ru.fromchat.api.instance.ServerProbeResult
|
||||||
import ru.fromchat.api.instance.probeServer
|
import ru.fromchat.api.instance.probeServer
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||||
import ru.fromchat.api.schema.core.ErrorResponse
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.change_server
|
import ru.fromchat.change_server
|
||||||
import ru.fromchat.config.Settings
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||||
import ru.fromchat.ui.auth.register.profileStepPage
|
import ru.fromchat.ui.auth.register.profileStepPage
|
||||||
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
||||||
@@ -58,6 +62,8 @@ internal sealed interface PasswordStepResult {
|
|||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val yandexRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val captchaRequired: Boolean,
|
||||||
|
val captcha: SmartCaptchaParams?,
|
||||||
) : PasswordStepResult
|
) : PasswordStepResult
|
||||||
data class WrongPassword(val message: String) : PasswordStepResult
|
data class WrongPassword(val message: String) : PasswordStepResult
|
||||||
data class RateLimited(val message: String) : PasswordStepResult
|
data class RateLimited(val message: String) : PasswordStepResult
|
||||||
@@ -120,10 +126,20 @@ internal suspend fun authPasswordStep(
|
|||||||
PasswordStepResult.LoginSuccess
|
PasswordStepResult.LoginSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> {
|
||||||
yandexRequired = outcome.yandexRequired,
|
Logger.i(
|
||||||
yandex = outcome.yandex,
|
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) {
|
||||||
@@ -147,8 +163,15 @@ internal suspend fun register(
|
|||||||
password: String,
|
password: String,
|
||||||
bio: String,
|
bio: String,
|
||||||
registrationProof: String?,
|
registrationProof: String?,
|
||||||
|
captchaToken: String?,
|
||||||
unexpectedError: String,
|
unexpectedError: String,
|
||||||
) = try {
|
) = try {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"register confirm start username=${username.trim()} " +
|
||||||
|
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
|
||||||
|
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
|
||||||
|
)
|
||||||
fullLogin(username.trim(), password.trim()) {
|
fullLogin(username.trim(), password.trim()) {
|
||||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||||
ApiClient.authRegisterConfirm(
|
ApiClient.authRegisterConfirm(
|
||||||
@@ -159,17 +182,25 @@ internal suspend fun register(
|
|||||||
confirm_password = derived,
|
confirm_password = derived,
|
||||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||||
registration_proof = registrationProof,
|
registration_proof = registrationProof,
|
||||||
|
captcha_token = captchaToken,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
|
||||||
RegisterResult.Success
|
RegisterResult.Success
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"register confirm HTTP ${e.response.status.value} username=${username.trim()}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||||
RegisterResult.UsernameTaken
|
RegisterResult.UsernameTaken
|
||||||
} else {
|
} else {
|
||||||
RegisterResult.Error(parseClientError(e, unexpectedError))
|
RegisterResult.Error(parseClientError(e, unexpectedError))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
|
||||||
RegisterResult.Error(unexpectedError, e)
|
RegisterResult.Error(unexpectedError, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +247,11 @@ fun AuthScreen(
|
|||||||
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||||
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
||||||
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||||
|
var captchaRequired by remember { mutableStateOf(AuthRegisterDraft.captchaRequired) }
|
||||||
|
var captchaParams by remember { mutableStateOf(AuthRegisterDraft.captchaParams) }
|
||||||
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
||||||
|
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
|
||||||
fun persistDraft() {
|
fun persistDraft() {
|
||||||
AuthRegisterDraft.username = username
|
AuthRegisterDraft.username = username
|
||||||
@@ -226,7 +261,10 @@ fun AuthScreen(
|
|||||||
AuthRegisterDraft.bio = bio
|
AuthRegisterDraft.bio = bio
|
||||||
AuthRegisterDraft.yandexRequired = yandexRequired
|
AuthRegisterDraft.yandexRequired = yandexRequired
|
||||||
AuthRegisterDraft.yandexParams = yandexParams
|
AuthRegisterDraft.yandexParams = yandexParams
|
||||||
|
AuthRegisterDraft.captchaRequired = captchaRequired
|
||||||
|
AuthRegisterDraft.captchaParams = captchaParams
|
||||||
AuthRegisterDraft.registrationProof = registrationProof
|
AuthRegisterDraft.registrationProof = registrationProof
|
||||||
|
AuthRegisterDraft.captchaToken = captchaToken
|
||||||
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +295,10 @@ fun AuthScreen(
|
|||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
AuthRegisterDraft.clear()
|
AuthRegisterDraft.clear()
|
||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -269,7 +310,19 @@ fun AuthScreen(
|
|||||||
onDispose { persistDraft() }
|
onDispose { persistDraft() }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
|
LaunchedEffect(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
yandexRequired,
|
||||||
|
yandexParams,
|
||||||
|
captchaRequired,
|
||||||
|
captchaParams,
|
||||||
|
registrationProof,
|
||||||
|
captchaToken,
|
||||||
|
) {
|
||||||
persistDraft()
|
persistDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +337,12 @@ fun AuthScreen(
|
|||||||
snapshotFlow { flowState.pagerState.currentPage }
|
snapshotFlow { flowState.pagerState.currentPage }
|
||||||
.collect { page ->
|
.collect { page ->
|
||||||
AuthRegisterDraft.page = page
|
AuthRegisterDraft.page = page
|
||||||
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
|
// Don't skip the Yandex slot mid predictive-back — that fights pager morph
|
||||||
|
// (Profile ↔ ConfirmPassword) and glitches when the gesture is cancelled.
|
||||||
|
if (page == AuthFlowStep.YandexId.ordinal &&
|
||||||
|
!yandexRequired &&
|
||||||
|
flowState.predictiveFromPage == null
|
||||||
|
) {
|
||||||
val target = if (page > settledPage) {
|
val target = if (page > settledPage) {
|
||||||
AuthFlowStep.Profile.ordinal
|
AuthFlowStep.Profile.ordinal
|
||||||
} else {
|
} else {
|
||||||
@@ -301,7 +359,10 @@ fun AuthScreen(
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.Password.ordinal -> {
|
AuthFlowStep.Password.ordinal -> {
|
||||||
@@ -309,12 +370,16 @@ fun AuthScreen(
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
AuthFlowStep.ConfirmPassword.ordinal -> {
|
||||||
// Keep confirm password when returning from Yandex ID / OAuth.
|
// Keep confirm password when returning from Yandex ID / OAuth / captcha.
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.YandexId.ordinal -> {
|
AuthFlowStep.YandexId.ordinal -> {
|
||||||
@@ -326,6 +391,47 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After predictive back settles on the skipped Yandex slot, jump to ConfirmPassword.
|
||||||
|
LaunchedEffect(flowState.predictiveFromPage, yandexRequired) {
|
||||||
|
if (flowState.predictiveFromPage != null || yandexRequired) return@LaunchedEffect
|
||||||
|
if (flowState.pagerState.currentPage == AuthFlowStep.YandexId.ordinal) {
|
||||||
|
flowState.pagerState.scrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_TOKEN, null).collect { token ->
|
||||||
|
if (token == null) return@collect
|
||||||
|
handle.remove<String>(SmartCaptchaNav.RESULT_TOKEN)
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"AuthScreen received token ${SmartCaptchaLog.redactToken(token)} → Profile",
|
||||||
|
)
|
||||||
|
captchaToken = token
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_ERROR, null).collect { message ->
|
||||||
|
if (message == null) return@collect
|
||||||
|
handle.remove<String>(SmartCaptchaNav.RESULT_ERROR)
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "AuthScreen captcha error: $message")
|
||||||
|
snackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openCaptchaRoute(clientKey: String) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"navigate ${SmartCaptchaNav.ROUTE} clientKey=${SmartCaptchaLog.redactKey(clientKey)}",
|
||||||
|
)
|
||||||
|
SmartCaptchaNav.pending = SmartCaptchaNav.Session(clientKey = clientKey)
|
||||||
|
navController.navigate(SmartCaptchaNav.ROUTE)
|
||||||
|
}
|
||||||
|
|
||||||
val yandexStep = yandexParams
|
val yandexStep = yandexParams
|
||||||
ExpressiveStepFlowScaffold(
|
ExpressiveStepFlowScaffold(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
@@ -343,10 +449,18 @@ fun AuthScreen(
|
|||||||
password = password,
|
password = password,
|
||||||
onPasswordChange = { password = it },
|
onPasswordChange = { password = it },
|
||||||
onLoginSuccess = wrappedAuthSuccess,
|
onLoginSuccess = wrappedAuthSuccess,
|
||||||
onNeedsRegister = { required, params ->
|
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||||
|
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||||
|
)
|
||||||
yandexRequired = required
|
yandexRequired = required
|
||||||
yandexParams = params
|
yandexParams = params
|
||||||
|
captchaRequired = captchaReq
|
||||||
|
captchaParams = captcha
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -356,10 +470,23 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
if (yandexRequired && yandexParams != null) {
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
when {
|
||||||
} else {
|
yandexRequired && yandexParams != null -> {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||||
|
}
|
||||||
|
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||||
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||||
|
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||||
|
)
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -379,7 +506,16 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
|
when {
|
||||||
|
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||||
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
)
|
)
|
||||||
@@ -392,6 +528,7 @@ fun AuthScreen(
|
|||||||
onBioChange = { bio = it },
|
onBioChange = { bio = it },
|
||||||
password = password,
|
password = password,
|
||||||
registrationProof = registrationProof,
|
registrationProof = registrationProof,
|
||||||
|
captchaToken = captchaToken,
|
||||||
onRegisterSuccess = wrappedAuthSuccess,
|
onRegisterSuccess = wrappedAuthSuccess,
|
||||||
onUsernameTaken = resetToUsername,
|
onUsernameTaken = resetToUsername,
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import ru.fromchat.login
|
|||||||
import ru.fromchat.password
|
import ru.fromchat.password
|
||||||
import ru.fromchat.password_length_error
|
import ru.fromchat.password_length_error
|
||||||
import ru.fromchat.show_password
|
import ru.fromchat.show_password
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
@@ -54,7 +55,12 @@ internal fun passwordStepPage(
|
|||||||
password: String,
|
password: String,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
|
onNeedsRegister: suspend (
|
||||||
|
yandexRequired: Boolean,
|
||||||
|
yandex: YandexOAuthParams?,
|
||||||
|
captchaRequired: Boolean,
|
||||||
|
captcha: SmartCaptchaParams?,
|
||||||
|
) -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
): ExpressiveStepPage {
|
): ExpressiveStepPage {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -135,7 +141,12 @@ internal fun passwordStepPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.NeedsRegister -> {
|
is PasswordStepResult.NeedsRegister -> {
|
||||||
onNeedsRegister(result.yandexRequired, result.yandex)
|
onNeedsRegister(
|
||||||
|
result.yandexRequired,
|
||||||
|
result.yandex,
|
||||||
|
result.captchaRequired,
|
||||||
|
result.captcha,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.WrongPassword -> {
|
is PasswordStepResult.WrongPassword -> {
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
|
|||||||
import ru.fromchat.ui.components.trackImeScrollTarget
|
import ru.fromchat.ui.components.trackImeScrollTarget
|
||||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||||
import ru.fromchat.username
|
import ru.fromchat.username
|
||||||
|
import ru.fromchat.username_chars_error
|
||||||
import ru.fromchat.username_length_error
|
import ru.fromchat.username_length_error
|
||||||
|
|
||||||
|
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
|
||||||
|
private fun isAllowedUsernameChar(ch: Char) =
|
||||||
|
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun usernameStepPage(
|
internal fun usernameStepPage(
|
||||||
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
|
|||||||
|
|
||||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||||
val usernameLenError = stringResource(Res.string.username_length_error)
|
val usernameLenError = stringResource(Res.string.username_length_error)
|
||||||
|
val usernameCharsError = stringResource(Res.string.username_chars_error)
|
||||||
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
||||||
val unexpected = stringResource(Res.string.error_unexpected)
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
val nextLabel = stringResource(Res.string.settings_next)
|
val nextLabel = stringResource(Res.string.settings_next)
|
||||||
|
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
|
||||||
|
|
||||||
return ExpressiveStepPage(
|
return ExpressiveStepPage(
|
||||||
hero = ExpressiveHeroSpec(
|
hero = ExpressiveHeroSpec(
|
||||||
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
|
|||||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
isError = hasProhibitedChars,
|
||||||
|
supportingText = if (hasProhibitedChars) {
|
||||||
|
{ Text(usernameCharsError) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
colors = expressiveStepFieldColors(),
|
colors = expressiveStepFieldColors(),
|
||||||
shape = SettingsPasswordOutlineFieldShape,
|
shape = SettingsPasswordOutlineFieldShape,
|
||||||
)
|
)
|
||||||
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
|
|||||||
button = {
|
button = {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (busy) return@ActionButton
|
if (busy || hasProhibitedChars) return@ActionButton
|
||||||
val trimmed = username.trim()
|
val trimmed = username.trim()
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
onSnackbar(fillAll, null)
|
onSnackbar(fillAll, null)
|
||||||
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !busy,
|
enabled = !busy && !hasProhibitedChars,
|
||||||
loading = busy,
|
loading = busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
/** Safe summaries for SmartCaptcha logs (never dump full tokens/secrets). */
|
||||||
|
internal object SmartCaptchaLog {
|
||||||
|
const val TAG = "SmartCaptcha"
|
||||||
|
|
||||||
|
fun redactKey(value: String?): String {
|
||||||
|
val v = value?.trim().orEmpty()
|
||||||
|
if (v.isEmpty()) return "(empty)"
|
||||||
|
if (v.length <= 8) return "len=${v.length}"
|
||||||
|
return "len=${v.length} prefix=${v.take(4)}…suffix=${v.takeLast(4)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun redactToken(value: String?): String {
|
||||||
|
val v = value?.trim().orEmpty()
|
||||||
|
if (v.isEmpty()) return "(empty)"
|
||||||
|
return "len=${v.length} prefix=${v.take(6)}…"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shortUrl(url: String?): String {
|
||||||
|
if (url.isNullOrBlank()) return "null"
|
||||||
|
// Drop query sitekey from logs; keep path/host.
|
||||||
|
val q = url.indexOf('?')
|
||||||
|
val base = if (q >= 0) url.substring(0, q) else url
|
||||||
|
return if (base.length <= 120) base else base.take(117) + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root [androidx.navigation.NavController] route for SmartCaptcha.
|
||||||
|
* Client key is staged in [pending] before navigate (not embedded in the route).
|
||||||
|
*/
|
||||||
|
internal object SmartCaptchaNav {
|
||||||
|
const val ROUTE = "smartCaptcha"
|
||||||
|
const val RESULT_TOKEN = "smartcaptcha_token"
|
||||||
|
const val RESULT_ERROR = "smartcaptcha_error"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val clientKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SessionSaver = listSaver<Session?, String>(
|
||||||
|
save = { session ->
|
||||||
|
if (session == null) emptyList()
|
||||||
|
else listOf(session.clientKey)
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
if (saved.isEmpty()) null
|
||||||
|
else Session(clientKey = saved[0])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pending: Session? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.auth_captcha_failed
|
||||||
|
import ru.fromchat.auth_captcha_title
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-route SmartCaptcha screen (shown after password confirm when Yandex OAuth is off).
|
||||||
|
* Returns a token / error via [SmartCaptchaNav] saved-state results.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SmartCaptchaScreen() {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
var session by rememberSaveable(stateSaver = SmartCaptchaNav.SessionSaver) {
|
||||||
|
mutableStateOf(SmartCaptchaNav.pending)
|
||||||
|
}
|
||||||
|
var pageReady by remember { mutableStateOf(false) }
|
||||||
|
val failedMessage = stringResource(Res.string.auth_captcha_failed)
|
||||||
|
val barColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val screenId = remember { (100000..999999).random().toString(16) }
|
||||||
|
|
||||||
|
DisposableEffect(screenId) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"route enter id=$screenId sessionNull=${session == null} " +
|
||||||
|
"pendingNull=${SmartCaptchaNav.pending == null} " +
|
||||||
|
"clientKey=${SmartCaptchaLog.redactKey(session?.clientKey)} languageTag=$languageTag",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route dispose id=$screenId pageReady=$pageReady")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
if (session == null) {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "session null → popBackStack id=$screenId")
|
||||||
|
navController.popBackStack()
|
||||||
|
} else {
|
||||||
|
SmartCaptchaNav.pending = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = session ?: return
|
||||||
|
|
||||||
|
fun finishWithToken(token: String) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"finishWithToken id=$screenId ${SmartCaptchaLog.redactToken(token)}",
|
||||||
|
)
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(SmartCaptchaNav.RESULT_TOKEN, token)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishWithError(message: String) {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "finishWithError id=$screenId message=$message")
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(SmartCaptchaNav.RESULT_ERROR, message)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "cancel id=$screenId")
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
// Top bar draws into the status bar; bottom nav-bar inset keeps the WebView above it
|
||||||
|
// while [containerColor] still paints the gesture/nav area.
|
||||||
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(Res.string.auth_captcha_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { cancel() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(Res.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = barColor,
|
||||||
|
scrolledContainerColor = barColor,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = barColor,
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
SmartCaptchaWebView(
|
||||||
|
sitekey = active.clientKey,
|
||||||
|
languageTag = languageTag,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onToken = { finishWithToken(it) },
|
||||||
|
onReady = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route pageReady id=$screenId")
|
||||||
|
pageReady = true
|
||||||
|
},
|
||||||
|
onChallengeVisible = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route challengeVisible id=$screenId")
|
||||||
|
},
|
||||||
|
onChallengeHidden = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route challengeHidden id=$screenId")
|
||||||
|
},
|
||||||
|
onError = { message ->
|
||||||
|
finishWithError(message.ifBlank { failedMessage })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = !pageReady,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(barColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularWavyProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform WebView that loads Yandex SmartCaptcha and reports the verification token.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit = {},
|
||||||
|
onChallengeVisible: () -> Unit = {},
|
||||||
|
onChallengeHidden: () -> Unit = {},
|
||||||
|
onError: (String) -> Unit = {},
|
||||||
|
)
|
||||||
@@ -56,6 +56,7 @@ internal fun profileStepPage(
|
|||||||
onBioChange: (String) -> Unit,
|
onBioChange: (String) -> Unit,
|
||||||
password: String,
|
password: String,
|
||||||
registrationProof: String?,
|
registrationProof: String?,
|
||||||
|
captchaToken: String?,
|
||||||
onRegisterSuccess: () -> Unit,
|
onRegisterSuccess: () -> Unit,
|
||||||
onUsernameTaken: () -> Unit,
|
onUsernameTaken: () -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
@@ -149,6 +150,7 @@ internal fun profileStepPage(
|
|||||||
password = password,
|
password = password,
|
||||||
bio = bio.trim(),
|
bio = bio.trim(),
|
||||||
registrationProof = registrationProof,
|
registrationProof = registrationProof,
|
||||||
|
captchaToken = captchaToken,
|
||||||
unexpectedError = unexpected,
|
unexpectedError = unexpected,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -94,11 +94,26 @@ abstract class ChatPanel(
|
|||||||
addMessageMutex.withLock {
|
addMessageMutex.withLock {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
updateState { current ->
|
updateState { current ->
|
||||||
|
val panelSnap = panelMessagesForDbMerge()
|
||||||
val merged = mergeDatabaseMessagesWithPanelState(
|
val merged = mergeDatabaseMessagesWithPanelState(
|
||||||
panelMessagesForDbMerge(),
|
panelSnap,
|
||||||
messages,
|
messages,
|
||||||
)
|
)
|
||||||
val withReplies = attachPublicReplyReferences(merged)
|
val withReplies = attachPublicReplyReferences(merged)
|
||||||
|
if (current.messages.size != withReplies.size ||
|
||||||
|
current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
|
||||||
|
) {
|
||||||
|
val panelIds = panelSnap.map { it.id }.toSet()
|
||||||
|
val mergedIds = withReplies.map { it.id }.toSet()
|
||||||
|
val dbIds = messages.map { it.id }.toSet()
|
||||||
|
Logger.d(
|
||||||
|
"ChatPanel",
|
||||||
|
"syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
|
||||||
|
"merged=${withReplies.size} " +
|
||||||
|
"panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
|
||||||
|
"dbOnlyIds=${(dbIds - mergedIds).take(8)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
if (current.messages == withReplies) current
|
if (current.messages == withReplies) current
|
||||||
else current.copy(messages = withReplies)
|
else current.copy(messages = withReplies)
|
||||||
}
|
}
|
||||||
@@ -692,7 +707,8 @@ abstract class ChatPanel(
|
|||||||
timestamp = nowMessageTimestampIso(),
|
timestamp = nowMessageTimestampIso(),
|
||||||
is_read = false,
|
is_read = false,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
username = "You",
|
username = ApiClient.user?.username.orEmpty(),
|
||||||
|
displayName = ApiClient.user?.displayName,
|
||||||
client_message_id = tempId,
|
client_message_id = tempId,
|
||||||
reply_to = resolvedReply,
|
reply_to = resolvedReply,
|
||||||
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
|
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
|
||||||
|
|||||||
@@ -949,7 +949,8 @@ fun ChatScreen(
|
|||||||
timestamp = nowMessageTimestampIso(),
|
timestamp = nowMessageTimestampIso(),
|
||||||
is_read = false,
|
is_read = false,
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
username = "You",
|
username = ApiClient.user?.username.orEmpty(),
|
||||||
|
displayName = ApiClient.user?.displayName,
|
||||||
profile_picture = null,
|
profile_picture = null,
|
||||||
verified = null,
|
verified = null,
|
||||||
reply_to = replyTo,
|
reply_to = replyTo,
|
||||||
@@ -1188,7 +1189,7 @@ fun ChatScreen(
|
|||||||
.getOrNull(panelState.messages.lastIndex - 1)
|
.getOrNull(panelState.messages.lastIndex - 1)
|
||||||
previous != null &&
|
previous != null &&
|
||||||
messageListKey(previous) == listKey &&
|
messageListKey(previous) == listKey &&
|
||||||
classifyEnterMode(previous, newest!!) ==
|
classifyEnterMode(previous, newest) ==
|
||||||
EnterMode.ExtendGroup
|
EnterMode.ExtendGroup
|
||||||
}
|
}
|
||||||
val showTimestamp = when {
|
val showTimestamp = when {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
|
|||||||
peerIsDeleted(
|
peerIsDeleted(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
currentUserId = ApiClient.user?.id,
|
currentUserId = ApiClient.user?.id,
|
||||||
username = titleAvatar?.displayName ?: title,
|
username = ProfileCache.get(userId)?.username,
|
||||||
)
|
)
|
||||||
} == true
|
} == true
|
||||||
Row(
|
Row(
|
||||||
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
profileUserId?.let { userId ->
|
profileUserId?.let { userId ->
|
||||||
val status = resolveVerificationStatus(userId)
|
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
||||||
|
val status = remember(userId, profileCacheRevision) {
|
||||||
|
resolveVerificationStatus(userId)
|
||||||
|
}
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
|
|||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.rounded.Reply
|
import androidx.compose.material.icons.automirrored.rounded.Reply
|
||||||
@@ -710,26 +711,27 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Top bar: back, display name + date/time, 3-dot menu
|
// Top bar: back, display name + date/time, 3-dot menu
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
|
visible = effectiveMenusVisible,
|
||||||
|
enter = androidx.compose.animation.fadeIn(),
|
||||||
|
exit = androidx.compose.animation.fadeOut(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
Column(
|
||||||
visible = effectiveMenusVisible,
|
modifier = Modifier
|
||||||
enter = androidx.compose.animation.fadeIn(),
|
.fillMaxWidth()
|
||||||
exit = androidx.compose.animation.fadeOut(),
|
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.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(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
@@ -820,32 +822,31 @@ fun ImageFullscreenPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bottom: message text
|
// Bottom: message text
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
|
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
||||||
|
enter = androidx.compose.animation.fadeIn(),
|
||||||
|
exit = androidx.compose.animation.fadeOut(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
Column(
|
||||||
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
modifier = Modifier
|
||||||
enter = androidx.compose.animation.fadeIn(),
|
.fillMaxWidth()
|
||||||
exit = androidx.compose.animation.fadeOut(),
|
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.navigationBarsPadding(),
|
||||||
) {
|
) {
|
||||||
if (message.content.isNotBlank()) {
|
Box(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.padding(16.dp),
|
||||||
.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
|
) {
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color.White
|
color = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import ru.fromchat.ui.profile.isRedactedPeerAccount
|
|||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
|
* Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
|
||||||
|
* cached/server display name, or login username only as a last resort.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
||||||
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
|||||||
if (isDeletedAccountUsername(message.username)) {
|
if (isDeletedAccountUsername(message.username)) {
|
||||||
return deletedUserDisplayNameForUi()
|
return deletedUserDisplayNameForUi()
|
||||||
}
|
}
|
||||||
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
|
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
|
||||||
if (cachedUsername != null) return cachedUsername
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { return it }
|
||||||
|
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||||
if (message.username.equals("deleted", ignoreCase = true)) {
|
if (message.username.equals("deleted", ignoreCase = true)) {
|
||||||
return deletedUserDisplayNameForUi()
|
return deletedUserDisplayNameForUi()
|
||||||
}
|
}
|
||||||
return message.username
|
return message.username.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun messageSenderProfilePicture(
|
fun messageSenderProfilePicture(
|
||||||
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
|
|||||||
if (currentUserId != null && message.user_id == currentUserId) {
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
|
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
|
||||||
}
|
}
|
||||||
return message.username.trim()
|
return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -229,11 +230,14 @@ fun MessageItem(
|
|||||||
val sendFailedLabel = stringResource(Res.string.message_send_failed)
|
val sendFailedLabel = stringResource(Res.string.message_send_failed)
|
||||||
val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
|
val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
|
||||||
val displayUsername = messageDisplayUsername(message, currentUserId)
|
val displayUsername = messageDisplayUsername(message, currentUserId)
|
||||||
|
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
||||||
val senderProfile = ProfileCache.get(message.user_id)
|
val senderProfile = ProfileCache.get(message.user_id)
|
||||||
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
|
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
|
||||||
?: message.profile_picture
|
?: message.profile_picture
|
||||||
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
|
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
|
||||||
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
|
val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
|
||||||
|
resolveVerificationStatus(message.user_id, message)
|
||||||
|
}
|
||||||
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
|
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
|
||||||
val replyRef = message.reply_to
|
val replyRef = message.reply_to
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
downloadPaused && canDownload -> file?.let { downloadFile ->
|
downloadPaused && file != null && canDownload -> {
|
||||||
{
|
{
|
||||||
AttachmentDownloadNotifier.beginDownload(
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
|
|||||||
val ok = downloadAttachmentToCache(
|
val ok = downloadAttachmentToCache(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
file = downloadFile,
|
file = file,
|
||||||
dmEnvelope = dmEnvelope,
|
dmEnvelope = dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
|
file != null && canDownload && !isDownloading && !downloadPaused -> {
|
||||||
{
|
{
|
||||||
AttachmentDownloadNotifier.beginDownload(
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
|
|||||||
val ok = downloadAttachmentToCache(
|
val ok = downloadAttachmentToCache(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
file = downloadFile,
|
file = file,
|
||||||
dmEnvelope = dmEnvelope,
|
dmEnvelope = dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ class DmPanel(
|
|||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val cached = ProfileCache.get(otherUserId)
|
val cached = ProfileCache.get(otherUserId)
|
||||||
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
|
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
|
||||||
|
Logger.d(
|
||||||
|
"DmPanel",
|
||||||
|
"applyCachedPeerProfile otherUserId=$otherUserId " +
|
||||||
|
"deleted=${cached?.deleted} suspended=${cached?.suspended} " +
|
||||||
|
"revision=${ProfileCache.revision.value} " +
|
||||||
|
"titleBlank=${displayName.isBlank()}",
|
||||||
|
)
|
||||||
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
|
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (displayName.isNotBlank()) {
|
if (displayName.isNotBlank()) {
|
||||||
@@ -236,17 +243,17 @@ class DmPanel(
|
|||||||
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
|
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
|
||||||
// when the chat screen re-entered composition (e.g. pop back from profile).
|
// when the chat screen re-entered composition (e.g. pop back from profile).
|
||||||
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
||||||
if (cached.isNotEmpty()) {
|
val hadCachedMessages = cached.isNotEmpty()
|
||||||
|
if (hadCachedMessages) {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
clearMessages()
|
clearMessages()
|
||||||
addMessages(cached)
|
addMessages(cached)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
messagesLoaded = true
|
} else {
|
||||||
return
|
setLoading(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
try {
|
||||||
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
|
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
|
||||||
CacheContext.requireActiveInstanceId(),
|
CacheContext.requireActiveInstanceId(),
|
||||||
@@ -289,7 +296,7 @@ class DmPanel(
|
|||||||
|
|
||||||
// Persist the most recent DM messages for offline use.
|
// Persist the most recent DM messages for offline use.
|
||||||
val mergedForCache = _state.messages
|
val mergedForCache = _state.messages
|
||||||
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
|
MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
|
||||||
messagesLoaded = true
|
messagesLoaded = true
|
||||||
} else {
|
} else {
|
||||||
val error = historyResult.exceptionOrNull()
|
val error = historyResult.exceptionOrNull()
|
||||||
@@ -299,6 +306,8 @@ class DmPanel(
|
|||||||
clearMessages()
|
clearMessages()
|
||||||
setHasMoreMessages(false)
|
setHasMoreMessages(false)
|
||||||
messagesLoaded = true
|
messagesLoaded = true
|
||||||
|
} else if (hadCachedMessages) {
|
||||||
|
messagesLoaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -510,8 +519,6 @@ class DmPanel(
|
|||||||
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
|
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
|
||||||
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
|
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
|
||||||
}
|
}
|
||||||
val snapshot = _state.messages
|
|
||||||
MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,31 +540,46 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
val outcome = decryptDmEnvelopeForUi(envelope)
|
val outcome = decryptDmEnvelopeForUi(envelope)
|
||||||
val dec = parseDmMessageContent(outcome.plaintext)
|
val dec = parseDmMessageContent(outcome.plaintext)
|
||||||
|
val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
|
||||||
|
content = dec.text,
|
||||||
|
is_edited = true,
|
||||||
|
fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
|
||||||
|
fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
|
||||||
|
fileSizes = dec.fileSizes ?: previous?.fileSizes,
|
||||||
|
fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
|
||||||
|
isContentCorrupted = outcome.isCorrupted,
|
||||||
|
dmEnvelope = envelope,
|
||||||
|
reply_to = previous?.reply_to,
|
||||||
|
)
|
||||||
updateMessage(envelope.id) {
|
updateMessage(envelope.id) {
|
||||||
it.copy(
|
editedForCache.copy(reply_to = it.reply_to)
|
||||||
content = dec.text,
|
|
||||||
is_edited = true,
|
|
||||||
fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
|
|
||||||
fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
|
|
||||||
fileSizes = dec.fileSizes ?: it.fileSizes,
|
|
||||||
fileDimensions = dec.fileDimensions ?: it.fileDimensions,
|
|
||||||
isContentCorrupted = outcome.isCorrupted,
|
|
||||||
dmEnvelope = envelope,
|
|
||||||
reply_to = it.reply_to,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-85
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
@@ -50,7 +52,7 @@ class PublicChatPanel(
|
|||||||
scope = scope
|
scope = scope
|
||||||
) {
|
) {
|
||||||
private val typingHandler = PublicChatTypingHandler(scope)
|
private val typingHandler = PublicChatTypingHandler(scope)
|
||||||
private var networkHistoryLoaded = false
|
private val loadMessagesMutex = Mutex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether replacing the list would change **structure or message body** (content / edited).
|
* Whether replacing the list would change **structure or message body** (content / edited).
|
||||||
@@ -82,6 +84,7 @@ class PublicChatPanel(
|
|||||||
ProfileCache.enrichPublicMessageForDisplay(
|
ProfileCache.enrichPublicMessageForDisplay(
|
||||||
mergeMessageUiFields(fresh, message).copy(
|
mergeMessageUiFields(fresh, message).copy(
|
||||||
username = fresh.username,
|
username = fresh.username,
|
||||||
|
displayName = fresh.displayName,
|
||||||
profile_picture = fresh.profile_picture,
|
profile_picture = fresh.profile_picture,
|
||||||
verified = fresh.verified,
|
verified = fresh.verified,
|
||||||
verificationStatus = fresh.verificationStatus,
|
verificationStatus = fresh.verificationStatus,
|
||||||
@@ -177,6 +180,8 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun hydrateFromLocalCache() {
|
suspend fun hydrateFromLocalCache() {
|
||||||
|
// Sender display names live in ProfileCache (message rows only store userId).
|
||||||
|
runCatching { ProfileCache.hydrateFromDisk() }
|
||||||
hydrateMessagesFromLocalCache()
|
hydrateMessagesFromLocalCache()
|
||||||
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
||||||
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
|
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
|
||||||
@@ -377,98 +382,119 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMessages() {
|
override suspend fun loadMessages() {
|
||||||
hydrateMessagesFromLocalCache()
|
loadMessagesMutex.withLock {
|
||||||
if (networkHistoryLoaded) return
|
hydrateMessagesFromLocalCache()
|
||||||
networkHistoryLoaded = true
|
|
||||||
|
|
||||||
val cached = _state.messages
|
val cached = _state.messages
|
||||||
if (cached.isEmpty()) {
|
if (cached.isEmpty()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh from network; this may be fast or slow, but runs entirely off main.
|
|
||||||
val responseResult = withContext(Dispatchers.Default) {
|
|
||||||
runCatching { ApiClient.getMessages(limit = 50) }
|
|
||||||
}
|
|
||||||
val response = responseResult.getOrNull()
|
|
||||||
|
|
||||||
if (response != null && response.messages.isNotEmpty()) {
|
|
||||||
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
|
|
||||||
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
|
|
||||||
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
|
||||||
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
|
|
||||||
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
|
|
||||||
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
|
|
||||||
Logger.d("PublicChatPanel", loadMsg)
|
|
||||||
var mergedForCache: List<Message>? = null
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val shown = snapshotUiMessagesForNetworkMerge()
|
|
||||||
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
|
|
||||||
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
|
|
||||||
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
|
||||||
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
|
|
||||||
if (withSenders != shown) {
|
|
||||||
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
|
|
||||||
}
|
|
||||||
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
|
||||||
if (_state.isLoading) setLoading(false)
|
|
||||||
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
|
|
||||||
} else {
|
|
||||||
batchStateUpdates {
|
|
||||||
val merged = preserveReplyToFromExisting(
|
|
||||||
shown,
|
|
||||||
mergeNetworkHistoryWithShown(shown, networkMessages),
|
|
||||||
)
|
|
||||||
clearMessages()
|
|
||||||
addMessages(
|
|
||||||
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
|
||||||
)
|
|
||||||
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
|
|
||||||
restorePendingOptimisticMessages(optimisticSnapshot)
|
|
||||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
|
||||||
setLoading(false)
|
|
||||||
mergedForCache = mergeNetworkHistoryWithShown(
|
|
||||||
panelMessagesForDbMerge(),
|
|
||||||
networkMessages,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
val toPersist = mergedForCache
|
// Always refresh from network on open. A retained panel used to skip this after the
|
||||||
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
|
// first visit (networkHistoryLoaded), so offline bursts left first+last holes until
|
||||||
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
|
// slow WS catch-up filled them.
|
||||||
MessageCacheStore.replacePublicMessages(toPersist)
|
Logger.d(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"loadMessages: network refresh cachedCount=${cached.size}",
|
||||||
|
)
|
||||||
|
val responseResult = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { ApiClient.getMessages(limit = 50) }
|
||||||
}
|
}
|
||||||
} else if (responseResult.isFailure) {
|
val response = responseResult.getOrNull()
|
||||||
val cause = responseResult.exceptionOrNull()
|
|
||||||
if (cause is ClientRequestException && cause.response.status.value == 403) {
|
if (response != null && response.messages.isNotEmpty()) {
|
||||||
MessageCacheStore.clearPublicMessages()
|
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
|
||||||
|
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
|
||||||
|
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
||||||
|
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
|
||||||
|
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
|
||||||
|
Logger.d(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
|
||||||
|
"stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
|
||||||
|
)
|
||||||
|
var mergedForCache: List<Message>? = null
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
clearMessages()
|
val shown = snapshotUiMessagesForNetworkMerge()
|
||||||
if (_state.isLoading) setLoading(false)
|
Logger.d(
|
||||||
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
"PublicChatPanel",
|
||||||
|
"loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
|
||||||
|
)
|
||||||
|
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
|
||||||
|
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
||||||
|
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
|
||||||
|
if (withSenders != shown) {
|
||||||
|
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
|
||||||
|
}
|
||||||
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
|
if (_state.isLoading) setLoading(false)
|
||||||
|
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
|
||||||
|
} else {
|
||||||
|
batchStateUpdates {
|
||||||
|
val merged = preserveReplyToFromExisting(
|
||||||
|
shown,
|
||||||
|
mergeNetworkHistoryWithShown(shown, networkMessages),
|
||||||
|
)
|
||||||
|
clearMessages()
|
||||||
|
addMessages(
|
||||||
|
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
||||||
|
)
|
||||||
|
Logger.d(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"loadMessages: after addMessages mergedSize=${merged.size} " +
|
||||||
|
"restoring optimistic count=${optimisticSnapshot.size}",
|
||||||
|
)
|
||||||
|
restorePendingOptimisticMessages(optimisticSnapshot)
|
||||||
|
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||||
|
setLoading(false)
|
||||||
|
mergedForCache = mergeNetworkHistoryWithShown(
|
||||||
|
panelMessagesForDbMerge(),
|
||||||
|
networkMessages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val toPersist = mergedForCache
|
||||||
|
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
|
||||||
|
Logger.d(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
|
||||||
|
)
|
||||||
|
MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
|
||||||
|
}
|
||||||
|
} else if (responseResult.isFailure) {
|
||||||
|
val cause = responseResult.exceptionOrNull()
|
||||||
|
if (cause is ClientRequestException && cause.response.status.value == 403) {
|
||||||
|
MessageCacheStore.clearPublicMessages()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
clearMessages()
|
||||||
|
if (_state.isLoading) setLoading(false)
|
||||||
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
|
}
|
||||||
|
} else if (cached.isEmpty()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (_state.isLoading) setLoading(false)
|
||||||
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
|
if (_state.isLoading) setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (cached.isEmpty()) {
|
} 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.isLoading) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (cached.isEmpty()) {
|
|
||||||
// Nothing to show at all; hide spinner so the user is not stuck.
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (_state.isLoading) setLoading(false)
|
|
||||||
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +520,7 @@ class PublicChatPanel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||||
@@ -529,29 +555,47 @@ class PublicChatPanel(
|
|||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||||
DecryptedImageCache.invalidateForMessage(editedMsg.id)
|
DecryptedImageCache.invalidateForMessage(editedMsg.id)
|
||||||
updateMessage(editedMsg.id) { existing ->
|
val existing = _state.messages.find { it.id == editedMsg.id }
|
||||||
editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to)
|
val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
|
||||||
|
updateMessage(editedMsg.id) { current ->
|
||||||
|
persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"messageDeleted" -> {
|
"messageDeleted" -> {
|
||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
||||||
|
Logger.i(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"messageDeleted messageId=${deletedData.message_id} " +
|
||||||
|
"uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
|
||||||
|
)
|
||||||
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
|
||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
clearReplyReferencesTo(deletedData.message_id)
|
clearReplyReferencesTo(deletedData.message_id)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.deletePublicMessageById(deletedData.message_id)
|
MessageRepository.deletePublicMessageById(deletedData.message_id)
|
||||||
}
|
}
|
||||||
|
Logger.d(
|
||||||
|
"PublicChatPanel",
|
||||||
|
"messageDeleted done messageId=${deletedData.message_id} " +
|
||||||
|
"uiAfter=${_state.messages.size}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
"reactionUpdate" -> {
|
"reactionUpdate" -> {
|
||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
|
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
|
||||||
handleReactionUpdate(reactionUpdate)
|
val existing = _state.messages.find { it.id == reactionUpdate.message_id }
|
||||||
withContext(Dispatchers.Default) {
|
if (existing != null) {
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
val updated = existing.copy(reactions = reactionUpdate.reactions)
|
||||||
|
handleReactionUpdate(reactionUpdate)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleReactionUpdate(reactionUpdate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"typing" -> {
|
"typing" -> {
|
||||||
@@ -620,6 +664,7 @@ class PublicChatPanel(
|
|||||||
cancelQueuedMessage(message)
|
cancelQueuedMessage(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
|
||||||
beginMessageDissolve(message)
|
beginMessageDissolve(message)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.deletePublicMessageById(messageId)
|
MessageRepository.deletePublicMessageById(messageId)
|
||||||
|
|||||||
@@ -72,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
|
|||||||
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
|
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
|
||||||
val mergedIds = mergedDb.map { it.id }.toSet()
|
val mergedIds = mergedDb.map { it.id }.toSet()
|
||||||
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
|
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
|
||||||
|
// Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
|
||||||
|
val droppedConfirmed = panelMessages.filter { panel ->
|
||||||
|
panel.id > 0 && panel.id !in mergedIds
|
||||||
|
}
|
||||||
|
if (droppedConfirmed.isNotEmpty()) {
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"MessageCache",
|
||||||
|
"mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
|
||||||
|
"ids=${droppedConfirmed.map { it.id }.take(12)} " +
|
||||||
|
"panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
|
||||||
|
)
|
||||||
|
}
|
||||||
val extraPanel = panelMessages.filter { panel ->
|
val extraPanel = panelMessages.filter { panel ->
|
||||||
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
when {
|
panel.id < 0 && cid != null && cid !in mergedClientIds
|
||||||
panel.id < 0 && cid != null && cid !in mergedClientIds -> true
|
}
|
||||||
panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
|
if (extraPanel.isNotEmpty()) {
|
||||||
else -> false
|
ru.fromchat.Logger.d(
|
||||||
}
|
"MessageCache",
|
||||||
|
"mergeDbPanel keepOptimistic count=${extraPanel.size} " +
|
||||||
|
"ids=${extraPanel.map { it.id }}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupeMessagesByClientId(
|
return dedupeMessagesByClientId(
|
||||||
@@ -114,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
|||||||
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
|
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
|
||||||
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||||
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||||
|
// DB rows only store userId; sender identity is reconstructed from ProfileCache and can
|
||||||
|
// briefly be blank. Keep non-blank panel fields (e.g. from a network payload) so text
|
||||||
|
// avatars / names are not wiped on every SQLDelight emission.
|
||||||
val merged = db.copy(
|
val merged = db.copy(
|
||||||
|
username = db.username.trim().ifBlank { panel.username.trim() },
|
||||||
|
displayName = db.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: panel.displayName?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
profile_picture = db.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
|
?: panel.profile_picture?.takeIf { it.isNotBlank() },
|
||||||
|
verified = db.verified ?: panel.verified,
|
||||||
|
verificationStatus = db.verificationStatus ?: panel.verificationStatus,
|
||||||
pendingFileUri = when {
|
pendingFileUri = when {
|
||||||
confirmed -> localPreview
|
confirmed -> localPreview
|
||||||
else -> panel.pendingFileUri ?: db.pendingFileUri
|
else -> panel.pendingFileUri ?: db.pendingFileUri
|
||||||
|
|||||||
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
|
|||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
return@PredictiveBackHandler
|
return@PredictiveBackHandler
|
||||||
}
|
}
|
||||||
|
// Cancel must always reverse — never commit just because progress crossed the threshold.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val commit = lastProgress >= predictiveThreshold
|
|
||||||
finishPredictiveMorph(
|
finishPredictiveMorph(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
startProgress = lastProgress,
|
startProgress = lastProgress,
|
||||||
targetProgress = if (commit) 1f else 0f,
|
targetProgress = 0f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-2
@@ -20,7 +20,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
|
|||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
|
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
|
|
||||||
val closeSheet: () -> Unit = {
|
val closeSheet: () -> Unit = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.ripple
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
|
|||||||
title: String,
|
title: String,
|
||||||
userId: Int,
|
userId: Int,
|
||||||
) {
|
) {
|
||||||
|
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
||||||
|
val verificationStatus = remember(userId, profileCacheRevision) {
|
||||||
|
resolveVerificationStatus(userId)
|
||||||
|
}
|
||||||
DisplayName(
|
DisplayName(
|
||||||
displayName = title,
|
displayName = title,
|
||||||
verificationStatus = resolveVerificationStatus(userId),
|
verificationStatus = verificationStatus,
|
||||||
textStyle = MaterialTheme.typography.bodyLarge,
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -928,18 +933,18 @@ internal fun DmConversationRowContent(
|
|||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
deleted = cached?.deleted,
|
deleted = cached?.deleted,
|
||||||
suspended = cached?.suspended,
|
suspended = cached?.suspended,
|
||||||
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
|
username = cached?.username,
|
||||||
)
|
)
|
||||||
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
||||||
val peerTitle = when {
|
val peerTitle = when {
|
||||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
||||||
}
|
}
|
||||||
val avatarInitialsLabel = when {
|
val avatarInitialsLabel = when {
|
||||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -529,7 +528,7 @@ fun ChatsTab(
|
|||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
) {
|
) {
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboard = supportClipboardManagerImpl
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||||
@@ -1092,7 +1091,9 @@ fun ChatsTab(
|
|||||||
chatContextMenuOverlay.onLink = {
|
chatContextMenuOverlay.onLink = {
|
||||||
when (contextMenuState.target) {
|
when (contextMenuState.target) {
|
||||||
ChatContextMenuTarget.Public -> {
|
ChatContextMenuTarget.Public -> {
|
||||||
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) }
|
publicChatLink?.let { link ->
|
||||||
|
scope.launch { clipboard.setText(link) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ChatContextMenuTarget.Dm -> {
|
ChatContextMenuTarget.Dm -> {
|
||||||
val link = contextMenuState.otherUserId?.let { userId ->
|
val link = contextMenuState.otherUserId?.let { userId ->
|
||||||
@@ -1100,7 +1101,7 @@ fun ChatsTab(
|
|||||||
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
|
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
|
||||||
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
|
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
|
||||||
}
|
}
|
||||||
link?.let { clipboardManager.setText(AnnotatedString(it)) }
|
link?.let { scope.launch { clipboard.setText(it) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import com.pr0gramm3r101.components.ListItem
|
|||||||
import com.pr0gramm3r101.ui.Website
|
import com.pr0gramm3r101.ui.Website
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.jetbrains.compose.resources.vectorResource
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
|
import ru.fromchat.AppBuildInfo
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.about
|
import ru.fromchat.about
|
||||||
import ru.fromchat.about_link_max
|
import ru.fromchat.about_link_max
|
||||||
@@ -111,7 +112,10 @@ fun AboutScreen() {
|
|||||||
BrandTitle(Modifier.padding(bottom = 4.dp))
|
BrandTitle(Modifier.padding(bottom = 4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.about_version),
|
text = stringResource(
|
||||||
|
Res.string.about_version,
|
||||||
|
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -43,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -422,6 +426,8 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -479,10 +485,15 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.fillMaxSize()
|
||||||
.padding()
|
.consumeWindowInsets(innerPadding)
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
.hazeSource(hazeState),
|
||||||
contentPadding = innerPadding
|
contentPadding = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = innerPadding.calculateTopPadding() + 8.dp,
|
||||||
|
bottom = innerPadding.calculateBottomPadding() + 24.dp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
@@ -569,9 +580,8 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
if (refreshing && devices.isEmpty()) {
|
||||||
// Initial load only — background poll must not add/remove list height (overscroll jump).
|
item {
|
||||||
if (refreshing && devices.isEmpty()) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -586,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sheetDevice?.let { d ->
|
sheetDevice?.let { d ->
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
|
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
|
||||||
sheetState = sheetState
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
DeviceSessionDetailBottomSheet(
|
DeviceSessionDetailBottomSheet(
|
||||||
d = d,
|
d = d,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -389,13 +390,15 @@ fun LogFilesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showShareSheet) {
|
if (showShareSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showShareSheet = false
|
showShareSheet = false
|
||||||
pendingSharePaths = emptyList()
|
pendingSharePaths = emptyList()
|
||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
LogsShareBottomSheet(
|
LogsShareBottomSheet(
|
||||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -632,13 +633,15 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showShareSheet) {
|
if (showShareSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showShareSheet = false
|
showShareSheet = false
|
||||||
pendingShareRequest = null
|
pendingShareRequest = null
|
||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
LogsShareBottomSheet(
|
LogsShareBottomSheet(
|
||||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||||
@@ -648,7 +651,10 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showCleanSheet) {
|
if (showCleanSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showCleanSheet = false },
|
onDismissRequest = { showCleanSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
|
|||||||
@@ -105,10 +105,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.ContextMenuPressable
|
import com.pr0gramm3r101.components.ContextMenuPressable
|
||||||
@@ -177,6 +174,7 @@ import ru.fromchat.profile_headline_bio
|
|||||||
import ru.fromchat.profile_headline_member_since
|
import ru.fromchat.profile_headline_member_since
|
||||||
import ru.fromchat.profile_headline_username
|
import ru.fromchat.profile_headline_username
|
||||||
import ru.fromchat.profile_headline_verification
|
import ru.fromchat.profile_headline_verification
|
||||||
|
import ru.fromchat.profile_invalid_link
|
||||||
import ru.fromchat.profile_load_failed
|
import ru.fromchat.profile_load_failed
|
||||||
import ru.fromchat.profile_not_found
|
import ru.fromchat.profile_not_found
|
||||||
import ru.fromchat.profile_verified_support
|
import ru.fromchat.profile_verified_support
|
||||||
@@ -267,7 +265,6 @@ fun ProfileScreen(
|
|||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
showBackButton: Boolean = false,
|
showBackButton: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
|
||||||
val clipboard = supportClipboardManagerImpl
|
val clipboard = supportClipboardManagerImpl
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -377,7 +374,7 @@ fun ProfileScreen(
|
|||||||
ApiClient.applyOwnProfile(refreshed)
|
ApiClient.applyOwnProfile(refreshed)
|
||||||
state = latestUi.copy(profile = refreshed, error = null)
|
state = latestUi.copy(profile = refreshed, error = null)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
ownUserId.let { ProfileCache.get(it) }?.let { cached ->
|
||||||
state = latestUi.copy(profile = cached)
|
state = latestUi.copy(profile = cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,7 +606,7 @@ fun ProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
),
|
),
|
||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelSettings,
|
label = labelSettings,
|
||||||
@@ -654,7 +651,7 @@ fun ProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (ServerConfig.callsEnabled) {
|
if (ServerConfig.callsEnabled) {
|
||||||
@@ -831,10 +828,10 @@ fun ProfileScreen(
|
|||||||
labelCopy = labelCopy,
|
labelCopy = labelCopy,
|
||||||
labelEdit = labelEdit,
|
labelEdit = labelEdit,
|
||||||
detailsBringIntoView = detailsBringIntoView,
|
detailsBringIntoView = detailsBringIntoView,
|
||||||
clipboardManager = clipboardManager,
|
|
||||||
clipboard = clipboard,
|
clipboard = clipboard,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
openContextMenuHaptic = openContextMenuHaptic,
|
openContextMenuHaptic = openContextMenuHaptic,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onProfileUpdated = { updated ->
|
onProfileUpdated = { updated ->
|
||||||
@@ -880,7 +877,6 @@ fun PublicChatProfileScreen(
|
|||||||
initialDisplayName: String? = null,
|
initialDisplayName: String? = null,
|
||||||
showBackButton: Boolean = false,
|
showBackButton: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
val clipboard = supportClipboardManagerImpl
|
val clipboard = supportClipboardManagerImpl
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -962,7 +958,7 @@ fun PublicChatProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
),
|
),
|
||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelSearch,
|
label = labelSearch,
|
||||||
@@ -1003,15 +999,15 @@ fun PublicChatProfileScreen(
|
|||||||
when {
|
when {
|
||||||
useSharedAvatar && displayName.isNotBlank() -> {
|
useSharedAvatar && displayName.isNotBlank() -> {
|
||||||
item {
|
item {
|
||||||
with(sharedTransitionScope!!) {
|
with(sharedTransitionScope) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = null,
|
profilePictureUrl = null,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = profileAvatarTop)
|
.padding(top = profileAvatarTop)
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
rememberSharedContentState(key = sharedAvatarKey!!),
|
rememberSharedContentState(key = sharedAvatarKey),
|
||||||
animatedVisibilityScope = animatedVisibilityScope!!,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
)
|
)
|
||||||
.size(104.dp),
|
.size(104.dp),
|
||||||
)
|
)
|
||||||
@@ -1051,6 +1047,7 @@ fun PublicChatProfileScreen(
|
|||||||
labelCopy = labelCopy,
|
labelCopy = labelCopy,
|
||||||
clipboard = clipboard,
|
clipboard = clipboard,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
|
|||||||
labelCopy: String,
|
labelCopy: String,
|
||||||
clipboard: SupportClipboardManager,
|
clipboard: SupportClipboardManager,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -1136,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
|
|||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
ProfileBioMarkdown(
|
ProfileBioMarkdown(
|
||||||
content = resolvedProfile.bio.orEmpty(),
|
content = resolvedProfile.bio.orEmpty(),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
position = ListItemPosition.START,
|
position = ListItemPosition.START,
|
||||||
@@ -1320,10 +1319,10 @@ private fun ProfileLoadedBody(
|
|||||||
labelCopy: String,
|
labelCopy: String,
|
||||||
labelEdit: String,
|
labelEdit: String,
|
||||||
detailsBringIntoView: BringIntoViewRequester,
|
detailsBringIntoView: BringIntoViewRequester,
|
||||||
clipboardManager: ClipboardManager,
|
|
||||||
clipboard: SupportClipboardManager,
|
clipboard: SupportClipboardManager,
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
openContextMenuHaptic: () -> Unit,
|
openContextMenuHaptic: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onProfileUpdated: (UserProfile) -> Unit,
|
onProfileUpdated: (UserProfile) -> Unit,
|
||||||
@@ -1349,7 +1348,7 @@ private fun ProfileLoadedBody(
|
|||||||
onContextMenuOpen = openContextMenuHaptic,
|
onContextMenuOpen = openContextMenuHaptic,
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(AnnotatedString(displayName))
|
scope.launch { clipboard.setText(displayName) }
|
||||||
}
|
}
|
||||||
if (isOwnProfile) {
|
if (isOwnProfile) {
|
||||||
item(Icons.Rounded.Edit, labelEdit) {
|
item(Icons.Rounded.Edit, labelEdit) {
|
||||||
@@ -1453,9 +1452,9 @@ private fun ProfileLoadedBody(
|
|||||||
},
|
},
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(
|
scope.launch {
|
||||||
AnnotatedString(usernameForLinks.orEmpty()),
|
clipboard.setText(usernameForLinks.orEmpty())
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
if (isOwnProfile) {
|
if (isOwnProfile) {
|
||||||
item(Icons.Rounded.Edit, labelEdit) {
|
item(Icons.Rounded.Edit, labelEdit) {
|
||||||
@@ -1491,7 +1490,7 @@ private fun ProfileLoadedBody(
|
|||||||
},
|
},
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(AnnotatedString(memberSinceText))
|
scope.launch { clipboard.setText(memberSinceText) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1505,7 +1504,10 @@ private fun ProfileLoadedBody(
|
|||||||
headline = headlineBio,
|
headline = headlineBio,
|
||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
key(resolvedProfile.id, bioContent) {
|
key(resolvedProfile.id, bioContent) {
|
||||||
ProfileBioMarkdown(content = bioContent)
|
ProfileBioMarkdown(
|
||||||
|
content = bioContent,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
divider = true,
|
divider = true,
|
||||||
@@ -1864,14 +1866,27 @@ private fun ProfileLoadedBody(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ProfileBioMarkdown(
|
private fun ProfileBioMarkdown(
|
||||||
content: String,
|
content: String,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
|
||||||
|
|
||||||
MarkdownPlain(
|
MarkdownPlain(
|
||||||
content = content,
|
content = content,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onLinkClick = { uriHandler.openUri(it) },
|
onLinkClick = { uri ->
|
||||||
|
runCatching { uriHandler.openUri(uri) }.onFailure {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showReplacingSnackbar(
|
||||||
|
message = invalidLinkMessage,
|
||||||
|
withDismissAction = false,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
onChallengeVisible: () -> Unit,
|
||||||
|
onChallengeHidden: () -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(sitekey) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||||
|
"languageTag=$languageTag",
|
||||||
|
)
|
||||||
|
onError("Captcha is not available on this platform yet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ plugins {
|
|||||||
alias(libs.plugins.google.services) apply false
|
alias(libs.plugins.google.services) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
|
||||||
|
extra["versionName"] = "1.1.4"
|
||||||
|
extra["versionCode"] = 114
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|||||||
+23
-25
@@ -1,52 +1,52 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.1"
|
agp = "9.3.1"
|
||||||
androidx-activityCompose = "1.13.0"
|
androidx-activityCompose = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-exifinterface = "1.3.7"
|
androidx-exifinterface = "1.4.2"
|
||||||
coilCompose = "3.4.0"
|
coilCompose = "3.5.0"
|
||||||
compose-multiplatform = "1.10.3"
|
compose-multiplatform = "1.11.1"
|
||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.8.0-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
firebaseMessaging = "25.0.2"
|
firebaseMessaging = "25.1.1"
|
||||||
googleServices = "4.4.4"
|
googleServices = "4.5.0"
|
||||||
haze = "1.7.2"
|
haze = "1.7.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.10"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
biometric = "1.4.0-alpha07"
|
biometric = "1.4.0-alpha07"
|
||||||
gson = "2.14.0"
|
gson = "2.14.0"
|
||||||
kotlinxIoBytestring = "0.9.0"
|
kotlinxIoBytestring = "0.9.1"
|
||||||
kotlinxCoroutinesCore = "1.11.0"
|
kotlinxCoroutinesCore = "1.11.0"
|
||||||
kotlinxIoCore = "0.9.0"
|
kotlinxIoCore = "0.9.1"
|
||||||
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
||||||
multiplatformSettings = "1.3.0"
|
multiplatformSettings = "1.3.0"
|
||||||
serialization = "2.3.21"
|
serialization = "2.4.10"
|
||||||
serialization-json = "1.11.0"
|
serialization-json = "1.11.0"
|
||||||
material = "1.13.0"
|
material = "1.14.0"
|
||||||
activityKtx = "1.13.0"
|
activityKtx = "1.13.0"
|
||||||
navigationCompose = "2.9.2"
|
navigationCompose = "2.9.2"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
security-crypto = "1.1.0"
|
security-crypto = "1.1.0"
|
||||||
ktor = "3.4.3"
|
ktor = "3.5.1"
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
kotlinxDatetime = "0.8.0"
|
kotlinxDatetime = "0.8.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.11.0"
|
||||||
composeBom = "2026.05.00"
|
composeBom = "2026.06.01"
|
||||||
composeMaterialIconsExtended = "1.7.3"
|
composeMaterialIconsExtended = "1.7.3"
|
||||||
composeMaterial3 = "1.10.0-alpha05"
|
composeMaterial3 = "1.12.0-alpha03"
|
||||||
composeComponents = "1.10.3"
|
composeComponents = "1.11.1"
|
||||||
playServicesBase = "18.10.0"
|
playServicesBase = "18.10.0"
|
||||||
tweetnaclJava = "1.1.3"
|
tweetnaclJava = "1.1.3"
|
||||||
androidxWork = "2.11.2"
|
androidxWork = "2.11.2"
|
||||||
cryptography-kotlin = "0.6.0"
|
cryptography-kotlin = "0.6.0"
|
||||||
krypto = "4.0.10"
|
krypto = "4.0.10"
|
||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
livekitAndroid = "2.25.2"
|
livekitAndroid = "2.27.0"
|
||||||
livekitAndroidComposeComponents = "2.3.0"
|
livekitAndroidComposeComponents = "2.4.0"
|
||||||
markdownRendererM3 = "0.41.0"
|
markdownRendererM3 = "0.43.0"
|
||||||
bouncycastle = "1.79"
|
bouncycastle = "1.85"
|
||||||
webkit = "1.14.0"
|
webkit = "1.16.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
@@ -87,7 +87,6 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
|
|||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
|
||||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||||
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
|
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||||
@@ -104,7 +103,6 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
|
|||||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
||||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
||||||
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
|
|
||||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
||||||
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
|
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
|
||||||
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
||||||
|
|||||||
+2
-5
@@ -1,9 +1,6 @@
|
|||||||
|
#Tue Jul 21 20:27:03 MSK 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
|
||||||
retries=0
|
|
||||||
retryBackOffMs=500
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ kotlin {
|
|||||||
|
|
||||||
listOf(
|
listOf(
|
||||||
iosArm64(),
|
iosArm64(),
|
||||||
iosSimulatorArm64(),
|
iosSimulatorArm64()
|
||||||
iosX64(),
|
|
||||||
).forEach {
|
).forEach {
|
||||||
it.binaries.framework {
|
it.binaries.framework {
|
||||||
baseName = "shared"
|
baseName = "shared"
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ actual fun Clipboard.toSupport(): SupportClipboardManager {
|
|||||||
|
|
||||||
override suspend fun getText(): String? {
|
override suspend fun getText(): String? {
|
||||||
val entry = clipboard.getClipEntry() ?: return null
|
val entry = clipboard.getClipEntry() ?: return null
|
||||||
return entry.clipData?.getItemAt(0)?.text?.toString()
|
return entry.clipData.getItemAt(0)?.text?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setTextListener(listener: (String) -> Unit) {
|
override fun setTextListener(listener: (String) -> Unit) {
|
||||||
|
|||||||
+8
-1
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.pr0gramm3r101.utils.settings
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -7,6 +9,12 @@ import com.pr0gramm3r101.utils.UtilsLibrary.context
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure prefs via EncryptedSharedPreferences.
|
||||||
|
*
|
||||||
|
* These APIs are deprecated in favor of DataStore + Tink (`datastore-tink`, DataStore 1.3+).
|
||||||
|
* Kept until that stack is stable enough to migrate auth/identity keys without risk.
|
||||||
|
*/
|
||||||
class AndroidSecureSettings : Settings {
|
class AndroidSecureSettings : Settings {
|
||||||
private val masterKey by lazy {
|
private val masterKey by lazy {
|
||||||
MasterKey.Builder(context)
|
MasterKey.Builder(context)
|
||||||
@@ -80,4 +88,3 @@ class AndroidSecureSettings : Settings {
|
|||||||
encryptedPrefs.contains(key)
|
encryptedPrefs.contains(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user