feat: Enhance FCM token management by adding unsubscribe logic to prevent duplicate registrations
This commit is contained in:
@@ -41,6 +41,7 @@ import com.rosetta.messenger.ui.auth.AuthFlow
|
|||||||
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
||||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||||
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
||||||
|
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
|
||||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||||
import com.rosetta.messenger.ui.chats.SearchScreen
|
import com.rosetta.messenger.ui.chats.SearchScreen
|
||||||
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
||||||
@@ -506,6 +507,7 @@ sealed class Screen {
|
|||||||
data object CrashLogs : Screen()
|
data object CrashLogs : Screen()
|
||||||
data object Biometric : Screen()
|
data object Biometric : Screen()
|
||||||
data object Appearance : Screen()
|
data object Appearance : Screen()
|
||||||
|
data object DebugLogs : Screen()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -614,6 +616,9 @@ fun MainScreen(
|
|||||||
val isAppearanceVisible by remember {
|
val isAppearanceVisible by remember {
|
||||||
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
||||||
}
|
}
|
||||||
|
val isDebugLogsVisible by remember {
|
||||||
|
derivedStateOf { navStack.any { it is Screen.DebugLogs } }
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
fun pushScreen(screen: Screen) {
|
fun pushScreen(screen: Screen) {
|
||||||
@@ -635,7 +640,8 @@ fun MainScreen(
|
|||||||
it is Screen.Logs ||
|
it is Screen.Logs ||
|
||||||
it is Screen.CrashLogs ||
|
it is Screen.CrashLogs ||
|
||||||
it is Screen.Biometric ||
|
it is Screen.Biometric ||
|
||||||
it is Screen.Appearance
|
it is Screen.Appearance ||
|
||||||
|
it is Screen.DebugLogs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun popChatAndChildren() {
|
fun popChatAndChildren() {
|
||||||
@@ -712,6 +718,7 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSettingsClick = { pushScreen(Screen.Profile) },
|
onSettingsClick = { pushScreen(Screen.Profile) },
|
||||||
|
onLogsClick = { pushScreen(Screen.DebugLogs) },
|
||||||
onInviteFriendsClick = {
|
onInviteFriendsClick = {
|
||||||
// TODO: Share invite link
|
// TODO: Share invite link
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1010,17 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwipeBackContainer(
|
||||||
|
isVisible = isDebugLogsVisible,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.DebugLogs } },
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
) {
|
||||||
|
ConnectionLogsScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.DebugLogs } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) }
|
var isOtherProfileSwipeEnabled by remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(selectedOtherUser?.publicKey) {
|
LaunchedEffect(selectedOtherUser?.publicKey) {
|
||||||
isOtherProfileSwipeEnabled = true
|
isOtherProfileSwipeEnabled = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -713,7 +713,8 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
packet.attachments,
|
packet.attachments,
|
||||||
packet.chachaKey,
|
packet.chachaKey,
|
||||||
privateKey,
|
privateKey,
|
||||||
plainKeyAndNonce
|
plainKeyAndNonce,
|
||||||
|
messageId
|
||||||
)
|
)
|
||||||
|
|
||||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||||
@@ -1414,13 +1415,15 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
attachments: List<MessageAttachment>,
|
attachments: List<MessageAttachment>,
|
||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
plainKeyAndNonce: ByteArray? = null
|
plainKeyAndNonce: ByteArray? = null,
|
||||||
|
messageId: String = ""
|
||||||
) {
|
) {
|
||||||
val publicKey = currentAccount ?: return
|
val publicKey = currentAccount ?: return
|
||||||
|
|
||||||
for (attachment in attachments) {
|
for (attachment in attachments) {
|
||||||
// Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости)
|
// Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости)
|
||||||
if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) {
|
if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) {
|
||||||
|
MessageLogger.logPhotoDecryptStart(messageId, attachment.id, attachment.blob.length)
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
@@ -1445,9 +1448,17 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
|
|
||||||
if (saved) {} else {}
|
if (saved) {
|
||||||
} else {}
|
MessageLogger.logPhotoDecryptSuccess(messageId, attachment.id, true)
|
||||||
} catch (e: Exception) {}
|
} else {
|
||||||
|
MessageLogger.logPhotoSaveFailed(messageId, attachment.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MessageLogger.logPhotoDecryptFailed(messageId, attachment.id)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MessageLogger.logPhotoDecryptError(messageId, attachment.id, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlin.coroutines.resume
|
|||||||
*/
|
*/
|
||||||
object ProtocolManager {
|
object ProtocolManager {
|
||||||
private const val TAG = "ProtocolManager"
|
private const val TAG = "ProtocolManager"
|
||||||
|
private const val MAX_DEBUG_LOGS = 2000
|
||||||
|
|
||||||
// Server address - same as React Native version
|
// Server address - same as React Native version
|
||||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||||
@@ -94,8 +95,8 @@ object ProtocolManager {
|
|||||||
val timestamp = dateFormat.format(Date())
|
val timestamp = dateFormat.format(Date())
|
||||||
val logLine = "[$timestamp] $message"
|
val logLine = "[$timestamp] $message"
|
||||||
|
|
||||||
// Always keep logs in memory for the Logs screen (capped at 500)
|
// Always keep logs in memory for the Logs screen (opened via `...` in chat)
|
||||||
_debugLogs.value = (_debugLogs.value + logLine).takeLast(500)
|
_debugLogs.value = (_debugLogs.value + logLine).takeLast(MAX_DEBUG_LOGS)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun enableUILogs(enabled: Boolean) {
|
fun enableUILogs(enabled: Boolean) {
|
||||||
@@ -404,6 +405,10 @@ object ProtocolManager {
|
|||||||
* [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken]
|
* [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken]
|
||||||
* when Firebase rotates the token mid-session.
|
* when Firebase rotates the token mid-session.
|
||||||
*
|
*
|
||||||
|
* On each connect we send UNSUBSCRIBE first to clear any duplicate
|
||||||
|
* registrations that may have accumulated on the server, then SUBSCRIBE
|
||||||
|
* once — guaranteeing exactly one active push binding per device.
|
||||||
|
*
|
||||||
* @param forceToken if non-null, use this token instead of reading SharedPreferences
|
* @param forceToken if non-null, use this token instead of reading SharedPreferences
|
||||||
* (used by onNewToken which already has the fresh token).
|
* (used by onNewToken which already has the fresh token).
|
||||||
*/
|
*/
|
||||||
@@ -422,13 +427,23 @@ object ProtocolManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val packet = PacketPushNotification().apply {
|
// 1) UNSUBSCRIBE — clears ALL existing registrations for this token on the server.
|
||||||
|
// This removes duplicates that may have been created before the dedup fix.
|
||||||
|
val unsubPacket = PacketPushNotification().apply {
|
||||||
|
notificationsToken = token
|
||||||
|
action = PushNotificationAction.UNSUBSCRIBE
|
||||||
|
}
|
||||||
|
send(unsubPacket)
|
||||||
|
addLog("🔕 Push token UNSUBSCRIBE sent (clearing duplicates)")
|
||||||
|
|
||||||
|
// 2) SUBSCRIBE — register exactly once.
|
||||||
|
val subPacket = PacketPushNotification().apply {
|
||||||
notificationsToken = token
|
notificationsToken = token
|
||||||
action = PushNotificationAction.SUBSCRIBE
|
action = PushNotificationAction.SUBSCRIBE
|
||||||
}
|
}
|
||||||
send(packet)
|
send(subPacket)
|
||||||
lastSubscribedToken = token
|
lastSubscribedToken = token
|
||||||
addLog("🔔 Push token subscribe requested on AUTHENTICATED")
|
addLog("🔔 Push token SUBSCRIBE sent — single registration")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestSynchronize() {
|
private fun requestSynchronize() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
@@ -41,6 +42,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
// 🔥 Флаг - приложение в foreground (видимо пользователю)
|
// 🔥 Флаг - приложение в foreground (видимо пользователю)
|
||||||
@Volatile var isAppInForeground = false
|
@Volatile var isAppInForeground = false
|
||||||
|
|
||||||
|
// Dedup: suppress duplicate pushes from the server within a short window.
|
||||||
|
// Key = senderPublicKey (or "__simple__"), Value = timestamp of last shown notification.
|
||||||
|
private const val DEDUP_WINDOW_MS = 10_000L
|
||||||
|
private val lastNotifTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
/** Уникальный notification ID для каждого чата (по publicKey) */
|
/** Уникальный notification ID для каждого чата (по publicKey) */
|
||||||
fun getNotificationIdForChat(senderPublicKey: String): Int {
|
fun getNotificationIdForChat(senderPublicKey: String): Int {
|
||||||
return senderPublicKey.hashCode() and 0x7FFFFFFF // positive int
|
return senderPublicKey.hashCode() and 0x7FFFFFFF // positive int
|
||||||
@@ -77,6 +83,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
/** Вызывается когда получено push-уведомление */
|
/** Вызывается когда получено push-уведомление */
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
super.onMessageReceived(remoteMessage)
|
super.onMessageReceived(remoteMessage)
|
||||||
|
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
|
||||||
|
|
||||||
var handledMessageData = false
|
var handledMessageData = false
|
||||||
|
|
||||||
@@ -147,6 +154,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
if (isAppInForeground || !areNotificationsEnabled()) {
|
if (isAppInForeground || !areNotificationsEnabled()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Dedup: suppress duplicate pushes from the same sender within DEDUP_WINDOW_MS
|
||||||
|
val dedupKey = senderPublicKey?.trim().orEmpty().ifEmpty { "__no_sender__" }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastTs = lastNotifTimestamps[dedupKey]
|
||||||
|
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
|
||||||
|
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
|
||||||
|
return // duplicate push — skip
|
||||||
|
}
|
||||||
|
lastNotifTimestamps[dedupKey] = now
|
||||||
|
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
|
||||||
// Desktop parity: suppress notifications during sync (useDialogFiber.ts checks
|
// Desktop parity: suppress notifications during sync (useDialogFiber.ts checks
|
||||||
// protocolState != ProtocolState.SYNCHRONIZATION before calling notify()).
|
// protocolState != ProtocolState.SYNCHRONIZATION before calling notify()).
|
||||||
if (ProtocolManager.syncInProgress.value) {
|
if (ProtocolManager.syncInProgress.value) {
|
||||||
@@ -198,9 +215,20 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
if (isAppInForeground || !areNotificationsEnabled()) {
|
if (isAppInForeground || !areNotificationsEnabled()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
|
||||||
|
val dedupKey = "__simple__"
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastTs = lastNotifTimestamps[dedupKey]
|
||||||
|
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
|
||||||
|
return // duplicate push — skip
|
||||||
|
}
|
||||||
|
lastNotifTimestamps[dedupKey] = now
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
|
// Deterministic ID — duplicates replace each other instead of stacking
|
||||||
|
val notifId = (title + body).hashCode() and 0x7FFFFFFF
|
||||||
|
|
||||||
val intent =
|
val intent =
|
||||||
Intent(this, MainActivity::class.java).apply {
|
Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
@@ -209,7 +237,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val pendingIntent =
|
val pendingIntent =
|
||||||
PendingIntent.getActivity(
|
PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
notifId,
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
@@ -226,7 +254,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
val notificationManager =
|
val notificationManager =
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
|
notificationManager.notify(notifId, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создать notification channel для Android 8+ */
|
/** Создать notification channel для Android 8+ */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.rosetta.messenger.database.RosettaDatabase
|
|||||||
import com.rosetta.messenger.network.*
|
import com.rosetta.messenger.network.*
|
||||||
import com.rosetta.messenger.ui.chats.models.*
|
import com.rosetta.messenger.ui.chats.models.*
|
||||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
|
import com.rosetta.messenger.utils.MessageLogger
|
||||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -218,8 +219,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
|
// Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
|
||||||
private var lastReadMessageTimestamp = 0L
|
private var lastReadMessageTimestamp = 0L
|
||||||
// Флаг что read receipt уже отправлен для текущего диалога
|
|
||||||
private var readReceiptSentForCurrentDialog = false
|
|
||||||
|
|
||||||
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
|
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
|
||||||
messages.sortedWith(chatMessageAscComparator)
|
messages.sortedWith(chatMessageAscComparator)
|
||||||
@@ -580,7 +579,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
hasMoreMessages = true
|
hasMoreMessages = true
|
||||||
isLoadingMessages = false
|
isLoadingMessages = false
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
readReceiptSentForCurrentDialog = false
|
|
||||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
|
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
|
||||||
isDialogActive = true // 🔥 Диалог активен!
|
isDialogActive = true // 🔥 Диалог активен!
|
||||||
|
|
||||||
@@ -891,6 +889,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
dialogDao.updateDialogFromMessages(account, opponent)
|
dialogDao.updateDialogFromMessages(account, opponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 👁️ Отправляем read receipt — как в desktop (marks read + sends packet)
|
||||||
|
if (isDialogActive && !isSavedMessages) {
|
||||||
|
sendReadReceiptToOpponent()
|
||||||
|
}
|
||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,7 +1099,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
status =
|
status =
|
||||||
when (entity.delivered) {
|
when (entity.delivered) {
|
||||||
0 -> MessageStatus.SENDING
|
0 -> MessageStatus.SENDING
|
||||||
1 -> MessageStatus.DELIVERED
|
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
2 -> MessageStatus.SENT
|
2 -> MessageStatus.SENT
|
||||||
3 -> MessageStatus.READ
|
3 -> MessageStatus.READ
|
||||||
else -> MessageStatus.SENT
|
else -> MessageStatus.SENT
|
||||||
@@ -3755,11 +3758,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
val privateKey = myPrivateKey ?: return
|
val privateKey = myPrivateKey ?: return
|
||||||
|
|
||||||
// Обновляем timestamp последнего прочитанного
|
// 🔥 Проверяем timestamp ДО отправки — если не изменился, не шлём повторно
|
||||||
val lastIncoming = latestIncomingMessage(_messages.value)
|
val lastIncoming = latestIncomingMessage(_messages.value)
|
||||||
if (lastIncoming != null) {
|
if (lastIncoming == null) return
|
||||||
lastReadMessageTimestamp = lastIncoming.timestamp.time
|
val incomingTs = lastIncoming.timestamp.time
|
||||||
}
|
if (incomingTs <= lastReadMessageTimestamp) return
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -3774,8 +3777,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
readReceiptSentForCurrentDialog = true
|
// ✅ Обновляем timestamp ПОСЛЕ успешной отправки
|
||||||
} catch (e: Exception) {}
|
lastReadMessageTimestamp = incomingTs
|
||||||
|
MessageLogger.logReadReceiptSent(opponent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MessageLogger.logReadReceiptFailed(opponent, e)
|
||||||
|
// 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.delay(2000)
|
||||||
|
ProtocolManager.send(
|
||||||
|
PacketRead().apply {
|
||||||
|
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = opponent
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lastReadMessageTimestamp = incomingTs
|
||||||
|
MessageLogger.logReadReceiptSent(opponent, retry = true)
|
||||||
|
} catch (retryEx: Exception) {
|
||||||
|
MessageLogger.logReadReceiptFailed(opponent, retryEx, retry = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3908,7 +3930,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||||
|
|
||||||
lastReadMessageTimestamp = 0L
|
lastReadMessageTimestamp = 0L
|
||||||
readReceiptSentForCurrentDialog = false
|
|
||||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||||
opponentKey = null
|
opponentKey = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,8 @@ fun ChatsListScreen(
|
|||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
onAddAccount: () -> Unit = {},
|
onAddAccount: () -> Unit = {},
|
||||||
onSwitchAccount: (String) -> Unit = {},
|
onSwitchAccount: (String) -> Unit = {},
|
||||||
onDeleteAccountFromSidebar: (String) -> Unit = {}
|
onDeleteAccountFromSidebar: (String) -> Unit = {},
|
||||||
|
onLogsClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// Theme transition state
|
// Theme transition state
|
||||||
var hasInitialized by remember { mutableStateOf(false) }
|
var hasInitialized by remember { mutableStateOf(false) }
|
||||||
@@ -1148,6 +1149,22 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 📋 Logs
|
||||||
|
DrawerMenuItemEnhanced(
|
||||||
|
icon = TablerIcons.Bug,
|
||||||
|
text = "Logs",
|
||||||
|
iconColor = menuIconColor,
|
||||||
|
textColor = menuTextColor,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
kotlinx.coroutines
|
||||||
|
.delay(100)
|
||||||
|
onLogsClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -1840,6 +1857,7 @@ fun ChatsListScreen(
|
|||||||
pinnedChats
|
pinnedChats
|
||||||
) {
|
) {
|
||||||
chatsState.dialogs
|
chatsState.dialogs
|
||||||
|
.distinctBy { it.opponentKey }
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<
|
compareByDescending<
|
||||||
DialogUiModel> {
|
DialogUiModel> {
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_dialogs.value = decryptedDialogs
|
// Deduplicate by opponentKey to prevent LazyColumn crash
|
||||||
|
// (Key "X" was already used)
|
||||||
|
_dialogs.value = decryptedDialogs.distinctBy { it.opponentKey }
|
||||||
// 🚀 Убираем skeleton после первой загрузки
|
// 🚀 Убираем skeleton после первой загрузки
|
||||||
if (_isLoading.value) _isLoading.value = false
|
if (_isLoading.value) _isLoading.value = false
|
||||||
|
|
||||||
@@ -352,7 +354,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
.collect { decryptedRequests ->
|
||||||
|
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📊 Подписываемся на количество requests
|
// 📊 Подписываемся на количество requests
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import android.content.Intent
|
|||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
@@ -86,6 +87,15 @@ private fun logPhotoDebug(message: String) {
|
|||||||
AttachmentDownloadDebugLogger.log(message)
|
AttachmentDownloadDebugLogger.log(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shortDebugHash(bytes: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||||
|
digest.copyOfRange(0, 6).joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"shaerr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Анимированный текст с волнообразными точками.
|
* Анимированный текст с волнообразными точками.
|
||||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||||
@@ -1016,13 +1026,13 @@ fun ImageAttachment(
|
|||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
errorLabel = "Error"
|
errorLabel = "Error"
|
||||||
logPhotoDebug(
|
logPhotoDebug(
|
||||||
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}, stack=${e.stackTraceToString().take(200)}"
|
||||||
)
|
)
|
||||||
} catch (e: OutOfMemoryError) {
|
} catch (e: OutOfMemoryError) {
|
||||||
System.gc()
|
System.gc()
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
errorLabel = "Error"
|
errorLabel = "Error"
|
||||||
logPhotoDebug("Image OOM: id=$idShort")
|
logPhotoDebug("Image OOM: id=$idShort, availMem=${Runtime.getRuntime().freeMemory() / 1024}KB")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2341,21 +2351,57 @@ private suspend fun processDownloadedImage(
|
|||||||
onStatus(DownloadStatus.DECRYPTING)
|
onStatus(DownloadStatus.DECRYPTING)
|
||||||
|
|
||||||
// Расшифровываем ключ
|
// Расшифровываем ключ
|
||||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val keyCandidates: List<ByteArray>
|
||||||
logPhotoDebug("Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}")
|
try {
|
||||||
|
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||||
|
if (keyCandidates.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("empty key candidates")
|
||||||
|
}
|
||||||
|
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
|
||||||
|
keyCandidates.forEachIndexed { idx, candidate ->
|
||||||
|
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Error")
|
||||||
|
onStatus(DownloadStatus.ERROR)
|
||||||
|
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
|
||||||
|
logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Расшифровываем контент
|
// Расшифровываем контент
|
||||||
val decryptStartTime = System.currentTimeMillis()
|
val decryptStartTime = System.currentTimeMillis()
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
var successKeyIdx = -1
|
||||||
|
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
|
||||||
|
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||||
|
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
|
||||||
|
if (attempt.decrypted != null) {
|
||||||
|
successKeyIdx = idx
|
||||||
|
decryptDebug = attempt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Keep last trace for diagnostics if all fail.
|
||||||
|
decryptDebug = attempt
|
||||||
|
}
|
||||||
|
val decrypted = decryptDebug.decrypted
|
||||||
val decryptTime = System.currentTimeMillis() - decryptStartTime
|
val decryptTime = System.currentTimeMillis() - decryptStartTime
|
||||||
onProgress(0.8f)
|
onProgress(0.8f)
|
||||||
|
|
||||||
if (decrypted != null) {
|
if (decrypted != null) {
|
||||||
var decodedBitmap: Bitmap? = null
|
var decodedBitmap: Bitmap? = null
|
||||||
var saved = false
|
var saved = false
|
||||||
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
|
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms, decryptedLen=${decrypted.length}, keyIdx=$successKeyIdx")
|
||||||
|
decryptDebug.trace.lastOrNull { it.contains("SUCCESS") }?.let { successLine ->
|
||||||
|
logPhotoDebug("Blob decrypt trace: id=$idShort, $successLine")
|
||||||
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
decodedBitmap = base64ToBitmap(decrypted)
|
try {
|
||||||
|
decodedBitmap = base64ToBitmap(decrypted)
|
||||||
|
} catch (oom: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
logPhotoDebug("Bitmap OOM: id=$idShort, decryptedLen=${decrypted.length}")
|
||||||
|
}
|
||||||
if (decodedBitmap != null) {
|
if (decodedBitmap != null) {
|
||||||
onBitmap(decodedBitmap)
|
onBitmap(decodedBitmap)
|
||||||
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
|
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
|
||||||
@@ -2376,12 +2422,18 @@ private suspend fun processDownloadedImage(
|
|||||||
} else {
|
} else {
|
||||||
onError("Error")
|
onError("Error")
|
||||||
onStatus(DownloadStatus.ERROR)
|
onStatus(DownloadStatus.ERROR)
|
||||||
logPhotoDebug("Image decode FAILED: id=$idShort")
|
val preview = decrypted.take(30).replace("\n", "")
|
||||||
|
logPhotoDebug("Image decode FAILED: id=$idShort, decryptedLen=${decrypted.length}, preview=$preview")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onError("Error")
|
onError("Error")
|
||||||
onStatus(DownloadStatus.ERROR)
|
onStatus(DownloadStatus.ERROR)
|
||||||
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
|
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
|
||||||
|
val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
|
||||||
|
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
|
||||||
|
decryptDebug.trace.take(96).forEachIndexed { index, line ->
|
||||||
|
logPhotoDebug("Blob decrypt TRACE[$index]: id=$idShort, $line")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2415,20 +2467,39 @@ internal suspend fun downloadAndDecryptImage(
|
|||||||
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||||
)
|
)
|
||||||
|
|
||||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||||
|
if (keyCandidates.isEmpty()) return@withContext null
|
||||||
|
val plainKeyAndNonce = keyCandidates.first()
|
||||||
logPhotoDebug(
|
logPhotoDebug(
|
||||||
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
|
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
|
||||||
|
)
|
||||||
|
logPhotoDebug(
|
||||||
|
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Primary path for image attachments
|
// Primary path for image attachments
|
||||||
var decrypted =
|
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
var decrypted: String? = null
|
||||||
encryptedContent,
|
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||||
plainKeyAndNonce
|
val attempt =
|
||||||
)
|
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
|
||||||
|
encryptedContent,
|
||||||
|
keyCandidate
|
||||||
|
)
|
||||||
|
if (attempt.decrypted != null) {
|
||||||
|
decryptDebug = attempt
|
||||||
|
decrypted = attempt.decrypted
|
||||||
|
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
decryptDebug = attempt
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback for legacy payloads
|
// Fallback for legacy payloads
|
||||||
if (decrypted.isNullOrEmpty()) {
|
if (decrypted.isNullOrEmpty()) {
|
||||||
|
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
|
||||||
|
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
|
||||||
|
}
|
||||||
decrypted =
|
decrypted =
|
||||||
try {
|
try {
|
||||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
object AttachmentDownloadDebugLogger {
|
object AttachmentDownloadDebugLogger {
|
||||||
private const val MAX_LOGS = 200
|
private const val MAX_LOGS = 1000
|
||||||
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||||
@@ -19,6 +19,9 @@ object AttachmentDownloadDebugLogger {
|
|||||||
val timestamp = dateFormat.format(Date())
|
val timestamp = dateFormat.format(Date())
|
||||||
val line = "[$timestamp] 🖼️ $message"
|
val line = "[$timestamp] 🖼️ $message"
|
||||||
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
|
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
|
||||||
|
// Всегда дублируем в debug logs чата напрямую через ProtocolManager
|
||||||
|
// (не через MessageLogger, чтобы обойти isEnabled гейт)
|
||||||
|
com.rosetta.messenger.network.ProtocolManager.addLog("🖼️ $message")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import com.rosetta.messenger.network.ProtocolManager
|
|||||||
object MessageLogger {
|
object MessageLogger {
|
||||||
private const val TAG = "RosettaMsg"
|
private const val TAG = "RosettaMsg"
|
||||||
|
|
||||||
// Включить/выключить логирование (только в DEBUG)
|
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI),
|
||||||
private val isEnabled: Boolean = android.os.Build.TYPE != "user"
|
// не в logcat, безопасно для release
|
||||||
|
private val isEnabled: Boolean = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Добавить лог в UI (Debug Logs в чате)
|
* Добавить лог в UI (Debug Logs в чате)
|
||||||
@@ -252,6 +253,120 @@ object MessageLogger {
|
|||||||
addToUI(msg)
|
addToUI(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование расшифровки фото (inline blob)
|
||||||
|
*/
|
||||||
|
fun logPhotoDecryptStart(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
blobSize: Int
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortMsgId = messageId.take(8)
|
||||||
|
val shortAttId = attachmentId.take(8)
|
||||||
|
val msg = "🖼️ PHOTO DECRYPT | msg:$shortMsgId att:$shortAttId blob:${blobSize}b"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешной расшифровки фото
|
||||||
|
*/
|
||||||
|
fun logPhotoDecryptSuccess(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
saved: Boolean
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortMsgId = messageId.take(8)
|
||||||
|
val shortAttId = attachmentId.take(8)
|
||||||
|
val msg = "✅ PHOTO OK | msg:$shortMsgId att:$shortAttId saved:$saved"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование ошибки расшифровки фото (blob null)
|
||||||
|
*/
|
||||||
|
fun logPhotoDecryptFailed(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortMsgId = messageId.take(8)
|
||||||
|
val shortAttId = attachmentId.take(8)
|
||||||
|
val msg = "❌ PHOTO DECRYPT FAIL | msg:$shortMsgId att:$shortAttId (decryptedBlob=null)"
|
||||||
|
Log.e(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование ошибки сохранения фото
|
||||||
|
*/
|
||||||
|
fun logPhotoSaveFailed(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortMsgId = messageId.take(8)
|
||||||
|
val shortAttId = attachmentId.take(8)
|
||||||
|
val msg = "⚠️ PHOTO SAVE FAIL | msg:$shortMsgId att:$shortAttId"
|
||||||
|
Log.e(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование исключения при расшифровке фото
|
||||||
|
*/
|
||||||
|
fun logPhotoDecryptError(
|
||||||
|
messageId: String,
|
||||||
|
attachmentId: String,
|
||||||
|
error: Throwable
|
||||||
|
) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortMsgId = messageId.take(8)
|
||||||
|
val shortAttId = attachmentId.take(8)
|
||||||
|
val errMsg = error.message?.take(80) ?: "unknown"
|
||||||
|
val msg = "❌ PHOTO ERR | msg:$shortMsgId att:$shortAttId err:$errMsg"
|
||||||
|
Log.e(TAG, msg, error)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование CDN загрузки фото
|
||||||
|
*/
|
||||||
|
fun logPhotoCdnDownload(message: String) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val msg = "🖼️ $message"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование успешной отправки read receipt
|
||||||
|
*/
|
||||||
|
fun logReadReceiptSent(opponentKey: String, retry: Boolean = false) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortKey = opponentKey.take(12)
|
||||||
|
val retryStr = if (retry) " (retry)" else ""
|
||||||
|
val msg = "👁 READ RECEIPT SENT$retryStr | to:$shortKey"
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирование ошибки отправки read receipt
|
||||||
|
*/
|
||||||
|
fun logReadReceiptFailed(opponentKey: String, error: Throwable, retry: Boolean = false) {
|
||||||
|
if (!isEnabled) return
|
||||||
|
val shortKey = opponentKey.take(12)
|
||||||
|
val errMsg = error.message?.take(50) ?: "unknown"
|
||||||
|
val retryStr = if (retry) " (retry)" else ""
|
||||||
|
val msg = "❌ READ RECEIPT FAIL$retryStr | to:$shortKey err:$errMsg"
|
||||||
|
Log.e(TAG, msg, error)
|
||||||
|
addToUI(msg)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Общий debug лог
|
* Общий debug лог
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user