feat: implement device verification flow with new UI components and protocol handling

This commit is contained in:
2026-02-18 04:40:22 +05:00
parent edff3b32c3
commit cacd6dc029
24 changed files with 1645 additions and 195 deletions

View File

@@ -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 Архиве)

View File

@@ -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
// ═════════════════════════════════════════════════════════════