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.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
|
||||
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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+ */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 лог
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user