feat: Enhance FCM token management by adding unsubscribe logic to prevent duplicate registrations

This commit is contained in:
2026-02-26 14:56:21 +05:00
parent 48861633ee
commit f526a442b0
11 changed files with 1007 additions and 309 deletions

View File

@@ -41,6 +41,7 @@ import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen
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.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
@@ -506,6 +507,7 @@ sealed class Screen {
data object CrashLogs : Screen()
data object Biometric : Screen()
data object Appearance : Screen()
data object DebugLogs : Screen()
}
@Composable
@@ -614,6 +616,9 @@ fun MainScreen(
val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } }
}
val isDebugLogsVisible by remember {
derivedStateOf { navStack.any { it is Screen.DebugLogs } }
}
// Navigation helpers
fun pushScreen(screen: Screen) {
@@ -635,7 +640,8 @@ fun MainScreen(
it is Screen.Logs ||
it is Screen.CrashLogs ||
it is Screen.Biometric ||
it is Screen.Appearance
it is Screen.Appearance ||
it is Screen.DebugLogs
}
}
fun popChatAndChildren() {
@@ -712,6 +718,7 @@ fun MainScreen(
)
},
onSettingsClick = { pushScreen(Screen.Profile) },
onLogsClick = { pushScreen(Screen.DebugLogs) },
onInviteFriendsClick = {
// 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) }
LaunchedEffect(selectedOtherUser?.publicKey) {
isOtherProfileSwipeEnabled = true

View File

@@ -713,7 +713,8 @@ class MessageRepository private constructor(private val context: Context) {
packet.attachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce
plainKeyAndNonce,
messageId
)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
@@ -1414,13 +1415,15 @@ class MessageRepository private constructor(private val context: Context) {
attachments: List<MessageAttachment>,
encryptedKey: String,
privateKey: String,
plainKeyAndNonce: ByteArray? = null
plainKeyAndNonce: ByteArray? = null,
messageId: String = ""
) {
val publicKey = currentAccount ?: return
for (attachment in attachments) {
// Сохраняем только IMAGE, не FILE (файлы загружаются с CDN при необходимости)
if (attachment.type == AttachmentType.IMAGE && attachment.blob.isNotEmpty()) {
MessageLogger.logPhotoDecryptStart(messageId, attachment.id, attachment.blob.length)
try {
// 1. Расшифровываем blob с ChaCha ключом сообщения
@@ -1445,9 +1448,17 @@ class MessageRepository private constructor(private val context: Context) {
privateKey = privateKey
)
if (saved) {} else {}
} else {}
} catch (e: Exception) {}
if (saved) {
MessageLogger.logPhotoDecryptSuccess(messageId, attachment.id, true)
} else {
MessageLogger.logPhotoSaveFailed(messageId, attachment.id)
}
} else {
MessageLogger.logPhotoDecryptFailed(messageId, attachment.id)
}
} catch (e: Exception) {
MessageLogger.logPhotoDecryptError(messageId, attachment.id, e)
}
}
}
}

View File

@@ -22,6 +22,7 @@ import kotlin.coroutines.resume
*/
object ProtocolManager {
private const val TAG = "ProtocolManager"
private const val MAX_DEBUG_LOGS = 2000
// Server address - same as React Native version
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
@@ -94,8 +95,8 @@ object ProtocolManager {
val timestamp = dateFormat.format(Date())
val logLine = "[$timestamp] $message"
// Always keep logs in memory for the Logs screen (capped at 500)
_debugLogs.value = (_debugLogs.value + logLine).takeLast(500)
// Always keep logs in memory for the Logs screen (opened via `...` in chat)
_debugLogs.value = (_debugLogs.value + logLine).takeLast(MAX_DEBUG_LOGS)
}
fun enableUILogs(enabled: Boolean) {
@@ -404,6 +405,10 @@ object ProtocolManager {
* [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken]
* 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
* (used by onNewToken which already has the fresh token).
*/
@@ -422,13 +427,23 @@ object ProtocolManager {
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
action = PushNotificationAction.SUBSCRIBE
}
send(packet)
send(subPacket)
lastSubscribedToken = token
addLog("🔔 Push token subscribe requested on AUTHENTICATED")
addLog("🔔 Push token SUBSCRIBE sent — single registration")
}
private fun requestSynchronize() {

View File

@@ -6,6 +6,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@@ -41,6 +42,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// 🔥 Флаг - приложение в foreground (видимо пользователю)
@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) */
fun getNotificationIdForChat(senderPublicKey: String): Int {
return senderPublicKey.hashCode() and 0x7FFFFFFF // positive int
@@ -77,6 +83,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: 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
@@ -147,6 +154,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground || !areNotificationsEnabled()) {
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
// protocolState != ProtocolState.SYNCHRONIZATION before calling notify()).
if (ProtocolManager.syncInProgress.value) {
@@ -198,9 +215,20 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground || !areNotificationsEnabled()) {
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()
// Deterministic ID — duplicates replace each other instead of stacking
val notifId = (title + body).hashCode() and 0x7FFFFFFF
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@@ -209,7 +237,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val pendingIntent =
PendingIntent.getActivity(
this,
0,
notifId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -226,7 +254,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
notificationManager.notify(notifId, notification)
}
/** Создать notification channel для Android 8+ */

View File

@@ -14,6 +14,7 @@ import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.*
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager
import java.util.Date
import java.util.UUID
@@ -218,8 +219,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
private var lastReadMessageTimestamp = 0L
// Флаг что read receipt уже отправлен для текущего диалога
private var readReceiptSentForCurrentDialog = false
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
@@ -580,7 +579,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
hasMoreMessages = true
isLoadingMessages = false
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
@@ -891,6 +889,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
// 👁️ Отправляем read receipt — как в desktop (marks read + sends packet)
if (isDialogActive && !isSavedMessages) {
sendReadReceiptToOpponent()
}
} catch (e: Exception) {}
}
@@ -1096,7 +1099,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
status =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> MessageStatus.DELIVERED
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.SENT
3 -> MessageStatus.READ
else -> MessageStatus.SENT
@@ -3755,11 +3758,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return
// Обновляем timestamp последнего прочитанного
// 🔥 Проверяем timestamp ДО отправки — если не изменился, не шлём повторно
val lastIncoming = latestIncomingMessage(_messages.value)
if (lastIncoming != null) {
lastReadMessageTimestamp = lastIncoming.timestamp.time
}
if (lastIncoming == null) return
val incomingTs = lastIncoming.timestamp.time
if (incomingTs <= lastReadMessageTimestamp) return
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -3774,8 +3777,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
ProtocolManager.send(packet)
readReceiptSentForCurrentDialog = true
} catch (e: Exception) {}
// ✅ Обновляем timestamp ПОСЛЕ успешной отправки
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)
lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
opponentKey = null
}

View File

@@ -235,7 +235,8 @@ fun ChatsListScreen(
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
onDeleteAccountFromSidebar: (String) -> Unit = {},
onLogsClick: () -> Unit = {}
) {
// Theme transition state
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
) {
chatsState.dialogs
.distinctBy { it.opponentKey }
.sortedWith(
compareByDescending<
DialogUiModel> {

View File

@@ -261,7 +261,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// Deduplicate by opponentKey to prevent LazyColumn crash
// (Key "X" was already used)
_dialogs.value = decryptedDialogs.distinctBy { it.opponentKey }
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
@@ -352,7 +354,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests }
.collect { decryptedRequests ->
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
}
}
// 📊 Подписываемся на количество requests

View File

@@ -70,6 +70,7 @@ import android.content.Intent
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import kotlin.math.min
private const val TAG = "AttachmentComponents"
@@ -86,6 +87,15 @@ private fun logPhotoDebug(message: String) {
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
errorLabel = "Error"
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) {
System.gc()
downloadStatus = DownloadStatus.ERROR
errorLabel = "Error"
logPhotoDebug("Image OOM: id=$idShort")
logPhotoDebug("Image OOM: id=$idShort, availMem=${Runtime.getRuntime().freeMemory() / 1024}KB")
}
}
} else {
@@ -2341,21 +2351,57 @@ private suspend fun processDownloadedImage(
onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
logPhotoDebug("Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}")
val keyCandidates: List<ByteArray>
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 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
onProgress(0.8f)
if (decrypted != null) {
var decodedBitmap: Bitmap? = null
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) {
decodedBitmap = base64ToBitmap(decrypted)
try {
decodedBitmap = base64ToBitmap(decrypted)
} catch (oom: OutOfMemoryError) {
System.gc()
logPhotoDebug("Bitmap OOM: id=$idShort, decryptedLen=${decrypted.length}")
}
if (decodedBitmap != null) {
onBitmap(decodedBitmap)
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
@@ -2376,12 +2422,18 @@ private suspend fun processDownloadedImage(
} else {
onError("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 {
onError("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}"
)
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
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
var decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
plainKeyAndNonce
)
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var decrypted: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
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
if (decrypted.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
decrypted =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)

View File

@@ -9,7 +9,7 @@ import java.util.Date
import java.util.Locale
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 _logs = MutableStateFlow<List<String>>(emptyList())
@@ -19,6 +19,9 @@ object AttachmentDownloadDebugLogger {
val timestamp = dateFormat.format(Date())
val line = "[$timestamp] 🖼️ $message"
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
// Всегда дублируем в debug logs чата напрямую через ProtocolManager
// (не через MessageLogger, чтобы обойти isEnabled гейт)
com.rosetta.messenger.network.ProtocolManager.addLog("🖼️ $message")
}
fun clear() {

View File

@@ -14,8 +14,9 @@ import com.rosetta.messenger.network.ProtocolManager
object MessageLogger {
private const val TAG = "RosettaMsg"
// Включить/выключить логирование (только в DEBUG)
private val isEnabled: Boolean = android.os.Build.TYPE != "user"
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI),
// не в logcat, безопасно для release
private val isEnabled: Boolean = true
/**
* Добавить лог в UI (Debug Logs в чате)
@@ -252,6 +253,120 @@ object MessageLogger {
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 лог
*/