feat: implement device verification flow with new UI components and protocol handling
This commit is contained in:
@@ -49,6 +49,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val messageDao = database.messageDao()
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -95,6 +96,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MessageRepository? = null
|
||||
|
||||
const val SYSTEM_SAFE_PUBLIC_KEY = "0x000000000000000000000000000000000000000002"
|
||||
const val SYSTEM_SAFE_TITLE = "Safe"
|
||||
const val SYSTEM_SAFE_USERNAME = "safe"
|
||||
|
||||
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
|
||||
// LRU кэш с ограничением 1000 элементов - защита от race conditions
|
||||
private val processedMessageIds =
|
||||
@@ -136,6 +141,86 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addDeviceLoginSystemMessage(
|
||||
ipAddress: String,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
deviceOs: String
|
||||
) {
|
||||
val account = currentAccount ?: return
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
|
||||
val safeIp = ipAddress.ifBlank { "unknown" }
|
||||
val safeDeviceName = deviceName.ifBlank { "Unknown device" }
|
||||
val safeDeviceOs = deviceOs.ifBlank { "unknown" }
|
||||
val safeDeviceId = dotCenterIfNeeded(deviceId.ifBlank { "unknown" }, maxLength = 12, side = 4)
|
||||
|
||||
val messageText =
|
||||
"""
|
||||
Attempt to login from a new device
|
||||
|
||||
We detected a login to your account from $safeIp a new device by seed phrase. If this was you, you can safely ignore this message.
|
||||
|
||||
Arch: $safeDeviceOs
|
||||
IP: $safeIp
|
||||
Device: $safeDeviceName
|
||||
ID: $safeDeviceId
|
||||
"""
|
||||
.trimIndent()
|
||||
|
||||
val encryptedPlainMessage =
|
||||
try {
|
||||
CryptoManager.encryptWithPassword(messageText, privateKey)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val dialogKey = getDialogKey(SYSTEM_SAFE_PUBLIC_KEY)
|
||||
|
||||
val inserted =
|
||||
messageDao.insertMessage(
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
id = existing?.id ?: 0,
|
||||
account = account,
|
||||
opponentKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_SAFE_TITLE } ?: SYSTEM_SAFE_TITLE,
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
||||
?: SYSTEM_SAFE_USERNAME,
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
iHaveSent = 1
|
||||
)
|
||||
)
|
||||
|
||||
dialogDao.updateDialogFromMessages(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
}
|
||||
|
||||
/** Инициализация с текущим аккаунтом */
|
||||
fun initialize(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
@@ -163,6 +248,20 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return currentAccount != null && currentPrivateKey != null
|
||||
}
|
||||
|
||||
suspend fun getLastSyncTimestamp(): Long {
|
||||
val account = currentAccount ?: return 0L
|
||||
return syncTimeDao.getLastSync(account) ?: 0L
|
||||
}
|
||||
|
||||
suspend fun updateLastSyncTimestamp(timestamp: Long) {
|
||||
if (timestamp <= 0) return
|
||||
val account = currentAccount ?: return
|
||||
val existing = syncTimeDao.getLastSync(account) ?: 0L
|
||||
if (timestamp > existing) {
|
||||
syncTimeDao.upsert(AccountSyncTimeEntity(account = account, lastSync = timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Получить поток сообщений для диалога */
|
||||
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
@@ -238,6 +337,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val aesChachaKey =
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
@@ -344,6 +448,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.toPublicKey = toPublicKey
|
||||
this.content = encryptedContent
|
||||
this.chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
this.messageId = messageId
|
||||
@@ -397,11 +502,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
timestamp = packet.timestamp
|
||||
)
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
|
||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
return
|
||||
if (!isOwnMessage) {
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Генерируем messageId если он пустой (как в Архиве - generateRandomKeyFormSeed)
|
||||
@@ -435,17 +544,37 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
|
||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||
|
||||
try {
|
||||
// 🔑 КРИТИЧНО: Сохраняем ЗАШИФРОВАННЫЙ chacha_key (как в Desktop!)
|
||||
// Desktop: хранит зашифрованный ключ, расшифровывает только при использовании
|
||||
// Buffer.from(await decrypt(message.chacha_key, privatePlain),
|
||||
// "binary").toString('utf-8')
|
||||
val plainKeyAndNonce =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
?.toByteArray(Charsets.ISO_8859_1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
|
||||
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
|
||||
)
|
||||
}
|
||||
|
||||
// Расшифровываем
|
||||
val plainText =
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
}
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
MessageLogger.logDecryptionSuccess(
|
||||
@@ -459,18 +588,25 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
serializeAttachmentsWithDecryption(
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
processImageAttachments(packet.attachments, packet.chachaKey, privateKey)
|
||||
processImageAttachments(
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
processAvatarAttachments(
|
||||
packet.attachments,
|
||||
packet.fromPublicKey,
|
||||
packet.chachaKey,
|
||||
privateKey
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
@@ -486,7 +622,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
timestamp = packet.timestamp,
|
||||
chachaKey = packet.chachaKey,
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
fromMe = if (isOwnMessage) 1 else 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
@@ -506,10 +642,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
|
||||
|
||||
// 🔥 Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||
val dialog = dialogDao.getDialog(account, dialogOpponentKey)
|
||||
MessageLogger.logDialogUpdate(
|
||||
dialogKey = dialogKey,
|
||||
lastMessage = dialog?.lastMessage,
|
||||
@@ -517,7 +653,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
requestUserInfo(packet.fromPublicKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
|
||||
// Обновляем кэш только если сообщение новое
|
||||
if (!stillExists) {
|
||||
@@ -534,6 +670,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка обработки
|
||||
MessageLogger.logDecryptionError(messageId, e)
|
||||
ProtocolManager.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
|
||||
)
|
||||
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
|
||||
processedMessageIds.remove(messageId)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -573,17 +714,24 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
MessageLogger.debug("👁 READ PACKET from: ${packet.fromPublicKey.take(16)}...")
|
||||
|
||||
// Синхронизация read может прийти как:
|
||||
// 1) from=opponent, to=account (обычный read от собеседника)
|
||||
// 2) from=account, to=opponent (read c другого устройства этого же аккаунта)
|
||||
val opponentKey =
|
||||
if (packet.fromPublicKey == account) packet.toPublicKey else packet.fromPublicKey
|
||||
if (opponentKey.isBlank()) return
|
||||
|
||||
// Проверяем последнее сообщение ДО обновления
|
||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
||||
val lastMsgBefore = messageDao.getLastMessageDebug(account, opponentKey)
|
||||
|
||||
// Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные
|
||||
messageDao.markAllAsRead(account, packet.fromPublicKey)
|
||||
messageDao.markAllAsRead(account, opponentKey)
|
||||
|
||||
// 🔥 DEBUG: Проверяем последнее сообщение ПОСЛЕ обновления
|
||||
val lastMsgAfter = messageDao.getLastMessageDebug(account, packet.fromPublicKey)
|
||||
val lastMsgAfter = messageDao.getLastMessageDebug(account, opponentKey)
|
||||
|
||||
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
|
||||
val dialogKey = getDialogKey(packet.fromPublicKey)
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
@@ -596,13 +744,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
|
||||
// 📝 LOG: Статус прочтения
|
||||
MessageLogger.logReadStatus(fromPublicKey = packet.fromPublicKey, messagesCount = readCount)
|
||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
||||
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог чтобы lastMessageRead обновился
|
||||
dialogDao.updateDialogFromMessages(account, packet.fromPublicKey)
|
||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||
|
||||
// Логируем что записалось в диалог
|
||||
val dialog = dialogDao.getDialog(account, packet.fromPublicKey)
|
||||
val dialog = dialogDao.getDialog(account, opponentKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -719,6 +867,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun dotCenterIfNeeded(value: String, maxLength: Int, side: Int): String {
|
||||
if (value.length <= maxLength) return value
|
||||
val safeSide = side.coerceAtLeast(1)
|
||||
if (safeSide * 2 >= value.length) return value
|
||||
return value.take(safeSide) + "..." + value.takeLast(safeSide)
|
||||
}
|
||||
|
||||
private suspend fun updateDialog(
|
||||
opponentKey: String,
|
||||
lastMessage: String,
|
||||
@@ -919,7 +1074,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
attachments: List<MessageAttachment>,
|
||||
fromPublicKey: String,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
) {
|
||||
|
||||
for (attachment in attachments) {
|
||||
@@ -929,11 +1085,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем аватар в кэш
|
||||
@@ -964,7 +1123,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private fun processImageAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
) {
|
||||
val publicKey = currentAccount ?: return
|
||||
|
||||
@@ -975,11 +1135,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем в файл (как в desktop)
|
||||
@@ -1008,7 +1171,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private fun serializeAttachmentsWithDecryption(
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
): String {
|
||||
if (attachments.isEmpty()) return "[]"
|
||||
|
||||
@@ -1022,11 +1186,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
|
||||
|
||||
@@ -49,8 +49,9 @@ class PreferencesManager(private val context: Context) {
|
||||
// Language
|
||||
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
|
||||
|
||||
// Appearance / Customization
|
||||
val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
// Appearance / Customization (legacy global key)
|
||||
val BACKGROUND_BLUR_COLOR_ID =
|
||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
|
||||
// Pinned Chats (max 3)
|
||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||
@@ -219,6 +220,12 @@ class PreferencesManager(private val context: Context) {
|
||||
// 🎨 APPEARANCE / CUSTOMIZATION
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
private fun buildBackgroundBlurColorKey(account: String): Preferences.Key<String>? {
|
||||
val trimmedAccount = account.trim()
|
||||
if (trimmedAccount.isBlank()) return null
|
||||
return stringPreferencesKey("background_blur_color_id::$trimmedAccount")
|
||||
}
|
||||
|
||||
val backgroundBlurColorId: Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur
|
||||
@@ -228,6 +235,21 @@ class PreferencesManager(private val context: Context) {
|
||||
context.dataStore.edit { preferences -> preferences[BACKGROUND_BLUR_COLOR_ID] = value }
|
||||
}
|
||||
|
||||
fun backgroundBlurColorIdForAccount(account: String): Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
val scopedKey = buildBackgroundBlurColorKey(account)
|
||||
if (scopedKey != null) preferences[scopedKey] ?: "avatar" else "avatar"
|
||||
}
|
||||
|
||||
suspend fun setBackgroundBlurColorId(account: String, value: String) {
|
||||
val scopedKey = buildBackgroundBlurColorKey(account)
|
||||
context.dataStore.edit { preferences ->
|
||||
if (scopedKey != null) {
|
||||
preferences[scopedKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 📌 PINNED CHATS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user