Compare commits

...

27 Commits

Author SHA1 Message Date
676c205666 Release 1.3.9: fix calls, verified badge on call screens, dark wallpapers
All checks were successful
Android Kernel Build / build (push) Successful in 19m11s
- Revert CallManager to 1.3.6 base (fix broken call encryption)
- Add 45s incoming ring timeout with proper peer notification
- Add verified badge on call history and active call screens
- Add dark wallpapers to theme selector
- Fix wallpaper selector item sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:03:32 +05:00
b9ac7791f6 feat: add wallpapers
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-31 19:57:44 +05:00
20bef53869 Фикс звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m36s
2026-03-31 19:03:30 +05:00
2ff1383b13 Bump Android version to 1.3.8 (versionCode 40)
All checks were successful
Android Kernel Build / build (push) Successful in 19m27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:15:16 +05:00
727b902df7 Push: поддержка новых типов и супер-уведомления для звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m0s
2026-03-30 22:46:59 +05:00
89259b2a46 Релиз 1.3.7: новый Stream, транспорт вложений и фиксы совместимости
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s
2026-03-29 23:16:38 +05:00
ce6bc985be Пуши: учитывать mute и имя отправителя из payload 2026-03-29 23:12:29 +05:00
ff854e919e Улучшена обработка звонков и вложений: нормализация входящих аттачментов, обновление UI карточек звонков
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:45:25 +05:00
434ccef30c Устранены лишние многоточия в статусных сообщениях для исходящих и подключающихся звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m23s
2026-03-29 14:12:13 +05:00
26f4597c3b Релиз 1.3.6: hotfix качества звонков (возврат call-core к 1.3.3)
Some checks failed
Android Kernel Build / build (push) Failing after 12m11s
2026-03-29 13:30:00 +05:00
fa1288479f Релиз 1.3.5: ForegroundService звонков и фиксы клавиатуры
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s
2026-03-28 17:24:08 +05:00
46b1b3a6f1 Релиз 1.3.4: sticky-плашка звонка и поиск сообщений в диалоге
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s
2026-03-28 15:17:58 +05:00
aa40f5287c Релиз 1.3.3: merge dev в master
Some checks failed
Android Kernel Build / build (push) Failing after 11m18s
2026-03-28 13:07:36 +05:00
b271917594 Обновлены ReleaseNotes для версии 1.3.3 2026-03-28 13:07:08 +05:00
4cfa9f1d48 Фикс сборки 2026-03-28 13:05:49 +05:00
20c6696fdf Закрытие клавиатуры на звонке 2026-03-28 13:05:48 +05:00
3eac17d9a8 Оптимизация 2026-03-27 23:10:13 +05:00
84aad5f094 Добавлен модуль macrobenchmark и сценарии замера производительности 2026-03-27 22:30:50 +05:00
e7efe0856c Оптимизация приложения 2026-03-27 19:19:15 +05:00
93a2de315a Слияние dev: экран Calls и обновления звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m41s
2026-03-27 18:22:53 +05:00
c3e97eee56 Добавлен экран Calls в сайдбар и улучшено управление историей звонков 2026-03-27 18:22:21 +05:00
39b0b0e107 Поправлен визуал пузырьков со звонками 2026-03-27 17:22:51 +05:00
51f76b5073 Возврат dev к legacy-формату Stream 2026-03-27 15:20:31 +05:00
0fc637b42a Merge dev into master for release 1.3.2
All checks were successful
Android Kernel Build / build (push) Successful in 20m4s
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
2026-03-27 03:16:20 +05:00
59addf4373 Фикс оптимизации
All checks were successful
Android Kernel Build / build (push) Successful in 17m14s
2026-03-26 03:01:52 +05:00
3953d93207 Фикс error parsing: 1 frame = 1 packet и safe handshake fallback
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:53:21 +05:00
de958e10a1 Стабилизация sync и логов: heartbeat антиспам + Connection Logs через rosettadev2
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:45:16 +05:00
44 changed files with 3997 additions and 907 deletions

View File

@@ -1,5 +1,27 @@
# Release Notes # Release Notes
## 1.3.4
### Звонки и UI
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
### Поиск в диалоге
- В kebab-меню каждого чата добавлен пункт `Search`.
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
## 1.3.3
### E2EE, чаты и производительность
- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug).
- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`).
- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`.
- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений.
- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей.
## 1.2.3 ## 1.2.3
### Групповые чаты и медиа ### Групповые чаты и медиа

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.2" val rosettaVersionName = "1.3.9"
val rosettaVersionCode = 34 // Increment on each release val rosettaVersionCode = 41 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {
@@ -83,6 +83,14 @@ android {
// Enable baseline profiles in debug builds too for testing // Enable baseline profiles in debug builds too for testing
// Remove this in production // Remove this in production
} }
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("release")
matchingFallbacks += listOf("release")
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
}
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
@@ -192,7 +200,7 @@ dependencies {
} }
// Baseline Profiles for startup performance // Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Firebase Cloud Messaging // Firebase Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:32.7.0")) implementation(platform("com.google.firebase:firebase-bom:32.7.0"))

View File

@@ -9,6 +9,9 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -68,6 +71,11 @@
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".network.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" />
<!-- Firebase notification icon (optional, for better looking notifications) --> <!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"

View File

@@ -4,11 +4,13 @@ import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -20,7 +22,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
@@ -34,6 +40,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
@@ -586,11 +593,17 @@ fun MainScreen(
// Load username AND name from AccountManager (persisted in DataStore) // Load username AND name from AccountManager (persisted in DataStore)
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val rootView = LocalView.current
val callScope = rememberCoroutineScope() val callScope = rememberCoroutineScope()
val callUiState by CallManager.state.collectAsState() val callUiState by CallManager.state.collectAsState()
var isCallOverlayExpanded by remember { mutableStateOf(true) }
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) } var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
var pendingIncomingAccept by remember { mutableStateOf(false) } var pendingIncomingAccept by remember { mutableStateOf(false) }
var callPermissionsRequestedOnce by remember { mutableStateOf(false) } var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
val isCallScreenVisible =
callUiState.isVisible &&
(isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING)
val mandatoryCallPermissions = remember { val mandatoryCallPermissions = remember {
listOf(Manifest.permission.RECORD_AUDIO) listOf(Manifest.permission.RECORD_AUDIO)
@@ -757,6 +770,56 @@ fun MainScreen(
CallManager.bindAccount(accountPublicKey) CallManager.bindAccount(accountPublicKey)
} }
LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) {
isCallOverlayExpanded = true
} else {
isCallOverlayExpanded = false
}
}
val forceHideCallKeyboard: () -> Unit = {
focusManager.clearFocus(force = true)
rootView.findFocus()?.clearFocus()
rootView.clearFocus()
val activity = rootView.context as? android.app.Activity
activity?.window?.let { window ->
WindowCompat.getInsetsController(window, rootView)
.hide(WindowInsetsCompat.Type.ime())
}
}
DisposableEffect(isCallScreenVisible) {
val activity = rootView.context as? android.app.Activity
val previousSoftInputMode = activity?.window?.attributes?.softInputMode
if (isCallScreenVisible) {
forceHideCallKeyboard()
activity?.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
}
onDispose {
if (isCallScreenVisible && previousSoftInputMode != null) {
activity.window.setSoftInputMode(previousSoftInputMode)
}
}
}
if (isCallScreenVisible) {
SideEffect {
forceHideCallKeyboard()
}
}
// Telegram-style behavior: while call screen is open, Back should minimize call to top banner.
BackHandler(
enabled = callUiState.isVisible &&
isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
) {
isCallOverlayExpanded = false
}
LaunchedEffect(accountPublicKey, reloadTrigger) { LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) { if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
@@ -1015,6 +1078,9 @@ fun MainScreen(
onUserSelect = { selectedChatUser -> onUserSelect = { selectedChatUser ->
pushScreen(Screen.ChatDetail(selectedChatUser)) pushScreen(Screen.ChatDetail(selectedChatUser))
}, },
onStartCall = { user ->
startCallWithPermission(user)
},
backgroundBlurColorId = backgroundBlurColorId, backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats, pinnedChats = pinnedChats,
onTogglePin = { opponentKey -> onTogglePin = { opponentKey ->
@@ -1022,6 +1088,9 @@ fun MainScreen(
}, },
chatsViewModel = chatsListViewModel, chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
callUiState = callUiState,
isCallOverlayExpanded = isCallOverlayExpanded,
onOpenCallOverlay = { isCallOverlayExpanded = true },
onAddAccount = { onAddAccount = {
onAddAccount() onAddAccount()
}, },
@@ -1277,7 +1346,8 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chatWallpaperId = chatWallpaperId, chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked } onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
isCallActive = callUiState.isVisible
) )
} }
} }
@@ -1558,11 +1628,17 @@ fun MainScreen(
state = callUiState, state = callUiState,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
onAccept = { acceptCallWithPermission() }, onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() }, onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() }, onEnd = { CallManager.endCall() },
onToggleMute = { CallManager.toggleMute() }, onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() } onToggleSpeaker = { CallManager.toggleSpeaker() },
onMinimize = {
if (callUiState.phase != CallPhase.INCOMING) {
isCallOverlayExpanded = false
}
}
) )
} }
} }

View File

@@ -1327,15 +1327,17 @@ object MessageCrypto {
/** /**
* Собираем пароль-кандидаты для полной desktop совместимости: * Собираем пароль-кандидаты для полной desktop совместимости:
* - full key+nonce (56 bytes) и legacy key-only (32 bytes) * - full key+nonce (56 bytes) и legacy key-only (32 bytes)
* - hex password (актуальный desktop формат для attachments)
* - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer") * - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
* - WHATWG/Node UTF-8 decode * - WHATWG/Node UTF-8 decode
* - JVM UTF-8 / Latin1 fallback * - JVM UTF-8 / Latin1 fallback
*/ */
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> { private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
val candidates = LinkedHashSet<String>(12) val candidates = LinkedHashSet<String>(16)
fun addVariants(bytes: ByteArray) { fun addVariants(bytes: ByteArray) {
if (bytes.isEmpty()) return if (bytes.isEmpty()) return
candidates.add(bytes.joinToString("") { "%02x".format(it) })
candidates.add(bytesToBufferPolyfillUtf8String(bytes)) candidates.add(bytesToBufferPolyfillUtf8String(bytes))
candidates.add(bytesToJsUtf8String(bytes)) candidates.add(bytesToJsUtf8String(bytes))
candidates.add(String(bytes, Charsets.UTF_8)) candidates.add(String(bytes, Charsets.UTF_8))

View File

@@ -456,6 +456,7 @@ class GroupRepository private constructor(context: Context) {
messageId = UUID.randomUUID().toString().replace("-", "").take(32), messageId = UUID.randomUUID().toString().replace("-", "").take(32),
plainMessage = encryptedPlainMessage, plainMessage = encryptedPlainMessage,
attachments = "[]", attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogPublicKey dialogKey = dialogPublicKey
) )
) )

View File

@@ -8,6 +8,7 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -51,6 +52,7 @@ class MessageRepository private constructor(private val context: Context) {
private val avatarDao = database.avatarDao() private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao() private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao() private val groupDao = database.groupDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -207,12 +209,32 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, plainMessage = encryptedPlainMessage,
attachments = "[]", attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey dialogKey = dialogKey
) )
) )
if (inserted == -1L) return if (inserted == -1L) return
val insertedMessage =
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 = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
upsertSearchIndex(account, insertedMessage, messageText)
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY) val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
dialogDao.insertDialog( dialogDao.insertDialog(
DialogEntity( DialogEntity(
@@ -266,12 +288,32 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, plainMessage = encryptedPlainMessage,
attachments = "[]", attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey dialogKey = dialogKey
) )
) )
if (inserted == -1L) return null if (inserted == -1L) return null
val insertedMessage =
MessageEntity(
account = account,
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
upsertSearchIndex(account, insertedMessage, messageText)
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY) val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog( dialogDao.insertDialog(
DialogEntity( DialogEntity(
@@ -528,10 +570,13 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(attachments),
replyToMessageId = replyToMessageId, replyToMessageId = replyToMessageId,
dialogKey = dialogKey dialogKey = dialogKey
) )
messageDao.insertMessage(entity) messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, text.trim())
// 📝 LOG: Сохранено в БД // 📝 LOG: Сохранено в БД
MessageLogger.logDbSave(messageId, dialogKey, isNew = true) MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
@@ -559,6 +604,17 @@ class MessageRepository private constructor(private val context: Context) {
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
iHaveSent = 1, iHaveSent = 1,
hasContent =
if (
encryptedPlainMessage.isNotBlank() ||
attachments.isNotEmpty()
) {
1
} else {
0
},
lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
lastSenderKey = account,
lastMessageFromMe = 1, lastMessageFromMe = 1,
lastMessageDelivered = 1, lastMessageDelivered = 1,
lastMessageRead = 1, lastMessageRead = 1,
@@ -788,17 +844,20 @@ class MessageRepository private constructor(private val context: Context) {
} }
} }
val normalizedIncomingAttachments =
normalizeIncomingAttachments(packet.attachments, plainText)
// 📝 LOG: Расшифровка успешна // 📝 LOG: Расшифровка успешна
MessageLogger.logDecryptionSuccess( MessageLogger.logDecryptionSuccess(
messageId = messageId, messageId = messageId,
plainTextLength = plainText.length, plainTextLength = plainText.length,
attachmentsCount = packet.attachments.size attachmentsCount = normalizedIncomingAttachments.size
) )
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob // Сериализуем attachments в JSON с расшифровкой MESSAGES blob
val attachmentsJson = val attachmentsJson =
serializeAttachmentsWithDecryption( serializeAttachmentsWithDecryption(
packet.attachments, normalizedIncomingAttachments,
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
plainKeyAndNonce, plainKeyAndNonce,
@@ -807,7 +866,7 @@ class MessageRepository private constructor(private val context: Context) {
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
processImageAttachments( processImageAttachments(
packet.attachments, normalizedIncomingAttachments,
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
plainKeyAndNonce, plainKeyAndNonce,
@@ -820,7 +879,7 @@ class MessageRepository private constructor(private val context: Context) {
val avatarOwnerKey = val avatarOwnerKey =
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
processAvatarAttachments( processAvatarAttachments(
packet.attachments, normalizedIncomingAttachments,
avatarOwnerKey, avatarOwnerKey,
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
@@ -860,6 +919,8 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId, // 🔥 Используем сгенерированный messageId! messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(normalizedIncomingAttachments),
dialogKey = dialogKey dialogKey = dialogKey
) )
@@ -869,6 +930,7 @@ class MessageRepository private constructor(private val context: Context) {
if (!stillExists) { if (!stillExists) {
// Сохраняем в БД только если сообщения нет // Сохраняем в БД только если сообщения нет
messageDao.insertMessage(entity) messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, plainText)
MessageLogger.logDbSave(messageId, dialogKey, isNew = true) MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
} else { } else {
MessageLogger.logDbSave(messageId, dialogKey, isNew = false) MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
@@ -1405,7 +1467,8 @@ class MessageRepository private constructor(private val context: Context) {
opponentKey = opponentKey, opponentKey = opponentKey,
lastMessage = encryptedLastMessage, lastMessage = encryptedLastMessage,
lastMessageTimestamp = timestamp, lastMessageTimestamp = timestamp,
unreadCount = unreadCount unreadCount = unreadCount,
hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0
) )
) )
} }
@@ -1632,12 +1695,83 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview) put("preview", attachment.preview)
put("width", attachment.width) put("width", attachment.width)
put("height", attachment.height) put("height", attachment.height)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
} }
jsonArray.put(jsonObj) jsonArray.put(jsonObj)
} }
return jsonArray.toString() return jsonArray.toString()
} }
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
if (attachments.isEmpty()) return -1
return attachments.first().type.value
}
/**
* Desktop иногда присылает attachment звонка с некорректным type при поврежденном/пограничном
* пакете (в UI это превращается в пустой пузырь). Для attachment-only сообщения мягко
* нормализуем такой кейс к CALL.
*/
private fun normalizeIncomingAttachments(
attachments: List<MessageAttachment>,
plainText: String
): List<MessageAttachment> {
if (attachments.isEmpty() || plainText.isNotBlank() || attachments.size != 1) {
return attachments
}
val first = attachments.first()
if (!isLikelyCallAttachment(first, plainText)) {
return attachments
}
return when (first.type) {
AttachmentType.CALL -> attachments
else -> {
MessageLogger.debug(
"📥 ATTACHMENT FIXUP: coerced ${first.type} -> CALL for ${first.id.take(8)}..."
)
listOf(first.copy(type = AttachmentType.CALL))
}
}
}
private fun isLikelyCallAttachment(attachment: MessageAttachment, plainText: String): Boolean {
if (plainText.isNotBlank()) return false
if (attachment.blob.isNotBlank()) return false
if (attachment.width > 0 || attachment.height > 0) return false
val preview = attachment.preview.trim()
if (preview.isEmpty()) return true
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
}
private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) {
val opponentKey =
if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim()
val normalized = plainText.lowercase(Locale.ROOT)
searchIndexDao.upsert(
listOf(
MessageSearchIndexEntity(
account = account,
messageId = entity.messageId,
dialogKey = entity.dialogKey,
opponentKey = opponentKey,
timestamp = entity.timestamp,
fromMe = entity.fromMe,
plainText = plainText,
plainTextNormalized = normalized
)
)
)
}
/** /**
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при * 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
* получении attachment с типом AVATAR - сохраняем в avatar_cache * получении attachment с типом AVATAR - сохраняем в avatar_cache
@@ -1805,6 +1939,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else { } else {
// Fallback - пустой blob для IMAGE/FILE // Fallback - пустой blob для IMAGE/FILE
jsonObj.put("id", attachment.id) jsonObj.put("id", attachment.id)
@@ -1813,6 +1949,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Fallback - пустой blob // Fallback - пустой blob
@@ -1822,6 +1960,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
} else { } else {
// Для IMAGE/FILE - НЕ сохраняем blob (пустой) // Для IMAGE/FILE - НЕ сохраняем blob (пустой)
@@ -1831,6 +1971,8 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} }
jsonArray.put(jsonObj) jsonArray.put(jsonObj)

View File

@@ -17,10 +17,15 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Защищенные звонки и диагностика E2EE Протокол и вложения
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop - Обновлен Stream под новый серверный формат сериализации
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports) - Добавлена поддержка transportServer/transportTag во вложениях
- В Crash Reports добавлена кнопка копирования полного лога одним действием - Исправлена совместимость шифрования вложений Android -> Desktop
- Улучшена обработка call-аттачментов и рендер карточек звонков
Push-уведомления
- Пуши теперь учитывают mute-чаты корректно
- Заголовок уведомления берет имя отправителя из payload сервера
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -81,7 +81,9 @@ data class LastMessageStatus(
[ [
Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]), Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]),
Index(value = ["account", "message_id"], unique = true), Index(value = ["account", "message_id"], unique = true),
Index(value = ["account", "dialog_key", "timestamp"])] Index(value = ["account", "dialog_key", "timestamp"]),
Index(value = ["account", "timestamp"]),
Index(value = ["account", "primary_attachment_type", "timestamp"])]
) )
data class MessageEntity( data class MessageEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -99,18 +101,47 @@ data class MessageEntity(
@ColumnInfo(name = "plain_message") @ColumnInfo(name = "plain_message")
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений @ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
@ColumnInfo(name = "primary_attachment_type", defaultValue = "-1")
val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет)
@ColumnInfo(name = "reply_to_message_id") @ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: String? = null, // ID цитируемого сообщения val replyToMessageId: String? = null, // ID цитируемого сообщения
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки @ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки
) )
/** Локальный денормализованный индекс для поиска по сообщениям без повторной дешифровки. */
@Entity(
tableName = "message_search_index",
primaryKeys = ["account", "message_id"],
indices =
[
Index(value = ["account", "timestamp"]),
Index(value = ["account", "opponent_key", "timestamp"])]
)
data class MessageSearchIndexEntity(
@ColumnInfo(name = "account") val account: String,
@ColumnInfo(name = "message_id") val messageId: String,
@ColumnInfo(name = "dialog_key") val dialogKey: String,
@ColumnInfo(name = "opponent_key") val opponentKey: String,
@ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "from_me") val fromMe: Int = 0,
@ColumnInfo(name = "plain_text") val plainText: String,
@ColumnInfo(name = "plain_text_normalized") val plainTextNormalized: String
)
/** Entity для диалогов (кэш последнего сообщения) */ /** Entity для диалогов (кэш последнего сообщения) */
@Entity( @Entity(
tableName = "dialogs", tableName = "dialogs",
indices = indices =
[ [
Index(value = ["account", "opponent_key"], unique = true), Index(value = ["account", "opponent_key"], unique = true),
Index(value = ["account", "last_message_timestamp"])] Index(value = ["account", "last_message_timestamp"]),
Index(
value =
[
"account",
"i_have_sent",
"has_content",
"last_message_timestamp"])]
) )
data class DialogEntity( data class DialogEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0, @PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -129,6 +160,12 @@ data class DialogEntity(
@ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован @ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован
@ColumnInfo(name = "i_have_sent", defaultValue = "0") @ColumnInfo(name = "i_have_sent", defaultValue = "0")
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1) val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1)
@ColumnInfo(name = "has_content", defaultValue = "0")
val hasContent: Int = 0, // Есть ли контент в диалоге (0/1)
@ColumnInfo(name = "last_message_attachment_type", defaultValue = "-1")
val lastMessageAttachmentType: Int = -1, // Денормализованный тип вложения последнего сообщения
@ColumnInfo(name = "last_sender_key", defaultValue = "''")
val lastSenderKey: String = "", // Для групп: публичный ключ последнего отправителя
@ColumnInfo(name = "last_message_from_me", defaultValue = "0") @ColumnInfo(name = "last_message_from_me", defaultValue = "0")
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1) val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
@ColumnInfo(name = "last_message_delivered", defaultValue = "0") @ColumnInfo(name = "last_message_delivered", defaultValue = "0")
@@ -174,6 +211,16 @@ interface GroupDao {
suspend fun deleteAllByAccount(account: String): Int suspend fun deleteAllByAccount(account: String): Int
} }
/** Строка истории звонков (messages + данные собеседника из dialogs) */
data class CallHistoryRow(
@Embedded val message: MessageEntity,
@ColumnInfo(name = "peer_key") val peerKey: String,
@ColumnInfo(name = "peer_title") val peerTitle: String?,
@ColumnInfo(name = "peer_username") val peerUsername: String?,
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
@ColumnInfo(name = "peer_online") val peerOnline: Int?
)
/** DAO для работы с сообщениями */ /** DAO для работы с сообщениями */
@Dao @Dao
interface MessageDao { interface MessageDao {
@@ -535,8 +582,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND primary_attachment_type = 0
AND attachments LIKE '%"type":0%'
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -551,14 +597,69 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account WHERE account = :account
AND attachments != '[]' AND primary_attachment_type = 2
AND attachments LIKE '%"type":2%'
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity> suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* 📞 История звонков на основе CALL attachments (type: 4)
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
*/
@Query(
"""
SELECT
m.*,
CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END AS peer_key,
d.opponent_title AS peer_title,
d.opponent_username AS peer_username,
d.verified AS peer_verified,
d.is_online AS peer_online
FROM messages m
LEFT JOIN dialogs d
ON d.account = m.account
AND d.opponent_key = CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END
WHERE m.account = :account
AND m.primary_attachment_type = 4
ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT :limit
"""
)
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
@Query(
"""
SELECT DISTINCT
CASE
WHEN from_me = 1 THEN to_public_key
ELSE from_public_key
END AS peer_key
FROM messages
WHERE account = :account
AND primary_attachment_type = 4
"""
)
suspend fun getCallHistoryPeers(account: String): List<String>
/** Удалить все call events из messages для аккаунта. */
@Query(
"""
DELETE FROM messages
WHERE account = :account
AND primary_attachment_type = 4
"""
)
suspend fun deleteAllCallMessages(account: String): Int
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */ /** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
@Query( @Query(
""" """
@@ -572,6 +673,65 @@ interface MessageDao {
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity> suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
} }
@Dao
interface MessageSearchIndexDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<MessageSearchIndexEntity>)
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun search(
account: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND dialog_key = :dialogKey
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun searchInDialog(
account: String,
dialogKey: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT m.* FROM messages m
LEFT JOIN message_search_index s
ON s.account = m.account
AND s.message_id = m.message_id
WHERE m.account = :account
AND m.plain_message != ''
AND s.message_id IS NULL
ORDER BY m.timestamp DESC
LIMIT :limit
"""
)
suspend fun getUnindexedMessages(account: String, limit: Int): List<MessageEntity>
@Query("DELETE FROM message_search_index WHERE account = :account")
suspend fun deleteByAccount(account: String): Int
}
/** DAO для работы с диалогами */ /** DAO для работы с диалогами */
@Dao @Dao
interface DialogDao { interface DialogDao {
@@ -593,7 +753,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002' OR opponent_key = '0x000000000000000000000000000000000000000002'
) )
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -610,7 +770,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001' OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002' OR opponent_key = '0x000000000000000000000000000000000000000002'
) )
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -628,7 +788,7 @@ interface DialogDao {
AND i_have_sent = 0 AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002' AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
ORDER BY last_message_timestamp DESC ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -643,7 +803,7 @@ interface DialogDao {
AND i_have_sent = 0 AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001' AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002' AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
""" """
) )
fun getRequestsCountFlow(account: String): Flow<Int> fun getRequestsCountFlow(account: String): Flow<Int>
@@ -656,7 +816,7 @@ interface DialogDao {
@Query(""" @Query("""
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND (last_message != '' OR last_message_attachments != '[]') AND has_content = 1
AND opponent_key NOT LIKE '#group:%' AND opponent_key NOT LIKE '#group:%'
AND ( AND (
opponent_title = '' opponent_title = ''
@@ -687,7 +847,8 @@ interface DialogDao {
""" """
UPDATE dialogs SET UPDATE dialogs SET
last_message = :lastMessage, last_message = :lastMessage,
last_message_timestamp = :timestamp last_message_timestamp = :timestamp,
has_content = CASE WHEN TRIM(:lastMessage) != '' THEN 1 ELSE has_content END
WHERE account = :account AND opponent_key = :opponentKey WHERE account = :account AND opponent_key = :opponentKey
""" """
) )
@@ -916,6 +1077,16 @@ interface DialogDao {
val hasSent = hasSentByDialogKey(account, dialogKey) val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными // 5. Один INSERT OR REPLACE с вычисленными данными
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog( insertDialog(
DialogEntity( DialogEntity(
id = existing?.id ?: 0, id = existing?.id ?: 0,
@@ -931,6 +1102,9 @@ interface DialogDao {
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
// Desktop parity: request flag is always derived from message history. // Desktop parity: request flag is always derived from message history.
iHaveSent = if (hasSent || isSystemDialog) 1 else 0, iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = lastMsg.fromMe, lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0, lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0, lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
@@ -950,6 +1124,16 @@ interface DialogDao {
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
val existing = getDialog(account, account) val existing = getDialog(account, account)
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog( insertDialog(
DialogEntity( DialogEntity(
@@ -965,6 +1149,9 @@ interface DialogDao {
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0, verified = existing?.verified ?: 0,
iHaveSent = 1, iHaveSent = 1,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = 1, lastMessageFromMe = 1,
lastMessageDelivered = 1, lastMessageDelivered = 1,
lastMessageRead = 1, lastMessageRead = 1,

View File

@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
[ [
EncryptedAccountEntity::class, EncryptedAccountEntity::class,
MessageEntity::class, MessageEntity::class,
MessageSearchIndexEntity::class,
DialogEntity::class, DialogEntity::class,
BlacklistEntity::class, BlacklistEntity::class,
AvatarCacheEntity::class, AvatarCacheEntity::class,
AccountSyncTimeEntity::class, AccountSyncTimeEntity::class,
GroupEntity::class, GroupEntity::class,
PinnedMessageEntity::class], PinnedMessageEntity::class],
version = 14, version = 17,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun syncTimeDao(): SyncTimeDao abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao abstract fun pinnedMessageDao(): PinnedMessageDao
abstract fun messageSearchIndexDao(): MessageSearchIndexDao
companion object { companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null @Volatile private var INSTANCE: RosettaDatabase? = null
@@ -202,6 +204,154 @@ abstract class RosettaDatabase : RoomDatabase() {
} }
} }
/**
* 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls)
*/
private val MIGRATION_14_15 =
object : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)"
)
// Best-effort backfill для уже сохраненных сообщений.
database.execSQL(
"""
UPDATE messages
SET primary_attachment_type = CASE
WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1
WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0
WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1
WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2
WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3
WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4
ELSE -1
END
"""
)
}
}
/**
* 🧱 МИГРАЦИЯ 15->16: Денормализованный has_content для быстрых выборок dialogs/requests
*/
private val MIGRATION_15_16 =
object : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN has_content INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_dialogs_account_i_have_sent_has_content_last_message_timestamp ON dialogs (account, i_have_sent, has_content, last_message_timestamp)"
)
database.execSQL(
"""
UPDATE dialogs
SET has_content = CASE
WHEN TRIM(last_message) != '' THEN 1
WHEN last_message_attachments IS NOT NULL
AND TRIM(last_message_attachments) != ''
AND TRIM(last_message_attachments) != '[]' THEN 1
ELSE 0
END
"""
)
}
}
/**
* 🧱 МИГРАЦИЯ 16->17:
* - dialogs: last_message_attachment_type + last_sender_key
* - messages: индекс (account, timestamp)
* - локальный message_search_index для поиска без повторной дешифровки
*/
private val MIGRATION_16_17 =
object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_attachment_type INTEGER NOT NULL DEFAULT -1"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_sender_key TEXT NOT NULL DEFAULT ''"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_messages_account_timestamp ON messages (account, timestamp)"
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS message_search_index (
account TEXT NOT NULL,
message_id TEXT NOT NULL,
dialog_key TEXT NOT NULL,
opponent_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
from_me INTEGER NOT NULL DEFAULT 0,
plain_text TEXT NOT NULL,
plain_text_normalized TEXT NOT NULL,
PRIMARY KEY(account, message_id)
)
"""
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_timestamp ON message_search_index (account, timestamp)"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_opponent_key_timestamp ON message_search_index (account, opponent_key, timestamp)"
)
database.execSQL(
"""
UPDATE dialogs
SET last_message_attachment_type = CASE
WHEN last_message_attachments IS NULL
OR TRIM(last_message_attachments) = ''
OR TRIM(last_message_attachments) = '[]' THEN -1
WHEN last_message_attachments LIKE '%"type":0%' OR last_message_attachments LIKE '%"type": 0%' THEN 0
WHEN last_message_attachments LIKE '%"type":1%' OR last_message_attachments LIKE '%"type": 1%' THEN 1
WHEN last_message_attachments LIKE '%"type":2%' OR last_message_attachments LIKE '%"type": 2%' THEN 2
WHEN last_message_attachments LIKE '%"type":3%' OR last_message_attachments LIKE '%"type": 3%' THEN 3
WHEN last_message_attachments LIKE '%"type":4%' OR last_message_attachments LIKE '%"type": 4%' THEN 4
ELSE -1
END
"""
)
database.execSQL(
"""
UPDATE dialogs
SET last_sender_key = COALESCE(
(
SELECT m.from_public_key
FROM messages m
WHERE m.account = dialogs.account
AND m.dialog_key = CASE
WHEN dialogs.opponent_key = dialogs.account THEN dialogs.account
WHEN LOWER(dialogs.opponent_key) LIKE '#group:%' OR LOWER(dialogs.opponent_key) LIKE 'group:%'
THEN dialogs.opponent_key
WHEN dialogs.account < dialogs.opponent_key
THEN dialogs.account || ':' || dialogs.opponent_key
ELSE dialogs.opponent_key || ':' || dialogs.account
END
ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT 1
),
''
)
"""
)
database.execSQL(
"""
CREATE TRIGGER IF NOT EXISTS trg_message_search_index_delete
AFTER DELETE ON messages
BEGIN
DELETE FROM message_search_index
WHERE account = OLD.account AND message_id = OLD.message_id;
END
"""
)
}
}
fun getDatabase(context: Context): RosettaDatabase { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -224,7 +374,10 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_10_11, MIGRATION_10_11,
MIGRATION_11_12, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_12_13,
MIGRATION_13_14 MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17
) )
.fallbackToDestructiveMigration() // Для разработки - только .fallbackToDestructiveMigration() // Для разработки - только
// если миграция не // если миграция не

View File

@@ -0,0 +1,341 @@
package com.rosetta.messenger.network
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
/**
* Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/
class CallForegroundService : Service() {
private data class Snapshot(
val phase: CallPhase,
val displayName: String,
val statusText: String,
val durationSec: Int
)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext)
when (action) {
ACTION_STOP -> {
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_END -> {
CallManager.endCall()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_DECLINE -> {
CallManager.declineIncomingCall()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_ACCEPT -> {
val result = CallManager.acceptIncomingCall()
if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) {
openCallUi()
} else {
Log.w(TAG, "Accept action ignored: $result")
}
}
else -> Unit
}
val snapshot = extractSnapshot(intent)
if (snapshot.phase == CallPhase.IDLE) {
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ensureNotificationChannel()
val notification = buildNotification(snapshot)
startForegroundCompat(notification, snapshot.phase)
return START_STICKY
}
private fun extractSnapshot(intent: Intent?): Snapshot {
val state = CallManager.state.value
val payloadIntent = intent
if (payloadIntent == null || !payloadIntent.hasExtra(EXTRA_PHASE)) {
return Snapshot(
phase = state.phase,
displayName = state.displayName,
statusText = state.statusText,
durationSec = state.durationSec
)
}
val rawPhase = payloadIntent.getStringExtra(EXTRA_PHASE).orEmpty()
val phase = runCatching { CallPhase.valueOf(rawPhase) }.getOrElse { state.phase }
val displayName =
payloadIntent.getStringExtra(EXTRA_DISPLAY_NAME)
.orEmpty()
.ifBlank { state.displayName }
.ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
return Snapshot(
phase = phase,
displayName = displayName,
statusText = statusText,
durationSec = durationSec.coerceAtLeast(0)
)
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Ongoing call controls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(snapshot: Snapshot): Notification {
val openAppPendingIntent = PendingIntent.getActivity(
this,
REQUEST_OPEN_APP,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val endCallPendingIntent = PendingIntent.getService(
this,
REQUEST_END_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_END),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val declinePendingIntent = PendingIntent.getService(
this,
REQUEST_DECLINE_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_DECLINE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val answerPendingIntent = PendingIntent.getService(
this,
REQUEST_ACCEPT_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_ACCEPT),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val defaultStatus =
when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call"
CallPhase.OUTGOING -> "Calling"
CallPhase.CONNECTING -> "Connecting"
CallPhase.ACTIVE -> "Call in progress"
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build()
val style =
if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall(
person,
declinePendingIntent,
answerPendingIntent
)
} else {
Notification.CallStyle.forOngoingCall(person, endCallPendingIntent)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_CALL)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.apply {
if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
} else {
addAction(android.R.drawable.ic_menu_close_clear_cancel, "End", endCallPendingIntent)
}
}
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
private fun startForegroundCompat(notification: Notification, phase: CallPhase) {
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val preferredType =
when (phase) {
CallPhase.INCOMING -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.OUTGOING,
CallPhase.CONNECTING,
CallPhase.ACTIVE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.IDLE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
}
startForegroundTyped(notification, preferredType) ||
startForegroundTyped(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) ||
startForegroundUntyped(notification)
} else {
startForegroundUntyped(notification)
}
if (!started) {
Log.e(TAG, "Failed to start foreground service safely; stopping service")
stopSelf()
}
}
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification, type)
true
} catch (error: Throwable) {
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false
}
}
private fun startForegroundUntyped(notification: Notification): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification)
true
} catch (error: Throwable) {
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false
}
}
@Suppress("DEPRECATION")
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
}
companion object {
private const val TAG = "CallForegroundService"
private const val CHANNEL_ID = "rosetta_calls"
private const val NOTIFICATION_ID = 9010
private const val REQUEST_OPEN_APP = 9011
private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
private const val ACTION_DECLINE = "com.rosetta.messenger.call.ACTION_DECLINE"
private const val ACTION_ACCEPT = "com.rosetta.messenger.call.ACTION_ACCEPT"
private const val ACTION_STOP = "com.rosetta.messenger.call.ACTION_STOP"
private const val EXTRA_PHASE = "extra_phase"
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) {
val appContext = context.applicationContext
if (state.phase == CallPhase.IDLE) {
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
return
}
val intent =
Intent(appContext, CallForegroundService::class.java)
.setAction(ACTION_SYNC)
.putExtra(EXTRA_PHASE, state.phase.name)
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error ->
Log.w(TAG, "Failed to start foreground service: ${error.message}")
}
}
fun stop(context: Context) {
val appContext = context.applicationContext
val intent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
runCatching { appContext.startService(intent) }
.onFailure {
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
}
}
}
private fun openCallUi() {
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
runCatching { startActivity(intent) }
.onFailure { error -> Log.w(TAG, "Failed to open call UI: ${error.message}") }
}
}

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
@@ -94,6 +95,7 @@ object CallManager {
private const val TAIL_LINES = 300 private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180 private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180 private const val MAX_LOG_PREFIX = 180
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
@@ -122,6 +124,7 @@ object CallManager {
private var durationJob: Job? = null private var durationJob: Job? = null
private var protocolStateJob: Job? = null private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null private var signalWaiter: ((Packet) -> Unit)? = null
private var webRtcWaiter: ((Packet) -> Unit)? = null private var webRtcWaiter: ((Packet) -> Unit)? = null
@@ -199,7 +202,7 @@ object CallManager {
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.OUTGOING, phase = CallPhase.OUTGOING,
statusText = "Calling..." statusText = "Calling"
) )
} }
@@ -230,6 +233,8 @@ object CallManager {
sharedPublic = localPublic.toHex() sharedPublic = localPublic.toHex()
) )
keyExchangeSent = true keyExchangeSent = true
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
updateState { updateState {
it.copy( it.copy(
@@ -244,6 +249,8 @@ object CallManager {
fun declineIncomingCall() { fun declineIncomingCall() {
val snapshot = _state.value val snapshot = _state.value
if (snapshot.phase != CallPhase.INCOMING) return if (snapshot.phase != CallPhase.INCOMING) return
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) { if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) {
ProtocolManager.sendCallSignal( ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL, signalType = SignalType.END_CALL,
@@ -342,6 +349,18 @@ object CallManager {
} }
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
resolvePeerIdentity(incomingPeer) resolvePeerIdentity(incomingPeer)
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob =
scope.launch {
delay(INCOMING_RING_TIMEOUT_MS)
val pending = _state.value
if (pending.phase == CallPhase.INCOMING &&
pending.peerPublicKey == incomingPeer
) {
breadcrumb("SIG: incoming timeout (${INCOMING_RING_TIMEOUT_MS}ms) → auto-decline")
declineIncomingCall()
}
}
} }
SignalType.KEY_EXCHANGE -> { SignalType.KEY_EXCHANGE -> {
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange") breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
@@ -832,6 +851,8 @@ object CallManager {
durationJob = null durationJob = null
disconnectResetJob?.cancel() disconnectResetJob?.cancel()
disconnectResetJob = null disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
setSpeakerphone(false) setSpeakerphone(false)
_state.value = CallUiState() _state.value = CallUiState()
} }
@@ -872,13 +893,15 @@ object CallManager {
} }
sharedKeyBytes = keyBytes.copyOf(32) sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}") breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Open native diagnostics file for frame-level logging // Frame-level diagnostics are enabled only for debug builds.
if (BuildConfig.DEBUG) {
try { try {
val dir = java.io.File(appContext!!.filesDir, "crash_reports") val dir = java.io.File(appContext!!.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
XChaCha20E2EE.nativeOpenDiagFile(diagPath) XChaCha20E2EE.nativeOpenDiagFile(diagPath)
} catch (_: Throwable) {} } catch (_: Throwable) {}
}
// If sender track already exists, bind encryptor now. // If sender track already exists, bind encryptor now.
val existingSender = val existingSender =
pendingAudioSenderForE2ee pendingAudioSenderForE2ee

View File

@@ -45,6 +45,7 @@ object FileDownloadManager {
private data class DownloadRequest( private data class DownloadRequest(
val attachmentId: String, val attachmentId: String,
val downloadTag: String, val downloadTag: String,
val transportServer: String,
val chachaKey: String, val chachaKey: String,
val privateKey: String, val privateKey: String,
val accountPublicKey: String, val accountPublicKey: String,
@@ -112,6 +113,7 @@ object FileDownloadManager {
fun download( fun download(
attachmentId: String, attachmentId: String,
downloadTag: String, downloadTag: String,
transportServer: String = "",
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
accountPublicKey: String, accountPublicKey: String,
@@ -121,6 +123,7 @@ object FileDownloadManager {
val request = DownloadRequest( val request = DownloadRequest(
attachmentId = attachmentId, attachmentId = attachmentId,
downloadTag = downloadTag, downloadTag = downloadTag,
transportServer = transportServer.trim(),
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
accountPublicKey = accountPublicKey.trim(), accountPublicKey = accountPublicKey.trim(),
@@ -238,6 +241,7 @@ object FileDownloadManager {
downloadGroupFile( downloadGroupFile(
attachmentId = attachmentId, attachmentId = attachmentId,
downloadTag = request.downloadTag, downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey, chachaKey = request.chachaKey,
privateKey = request.privateKey, privateKey = request.privateKey,
fileName = request.fileName, fileName = request.fileName,
@@ -250,6 +254,7 @@ object FileDownloadManager {
downloadDirectFile( downloadDirectFile(
attachmentId = attachmentId, attachmentId = attachmentId,
downloadTag = request.downloadTag, downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey, chachaKey = request.chachaKey,
privateKey = request.privateKey, privateKey = request.privateKey,
fileName = request.fileName, fileName = request.fileName,
@@ -351,6 +356,7 @@ object FileDownloadManager {
private suspend fun downloadGroupFile( private suspend fun downloadGroupFile(
attachmentId: String, attachmentId: String,
downloadTag: String, downloadTag: String,
transportServer: String,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
fileName: String, fileName: String,
@@ -365,7 +371,8 @@ object FileDownloadManager {
id = attachmentId, id = attachmentId,
tag = downloadTag, tag = downloadTag,
targetFile = encryptedPartFile, targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes resumeFromBytes = resumeBytes,
transportServer = transportServer
) )
val encryptedContent = withContext(Dispatchers.IO) { val encryptedContent = withContext(Dispatchers.IO) {
encryptedFile.readText(Charsets.UTF_8) encryptedFile.readText(Charsets.UTF_8)
@@ -420,6 +427,7 @@ object FileDownloadManager {
private suspend fun downloadDirectFile( private suspend fun downloadDirectFile(
attachmentId: String, attachmentId: String,
downloadTag: String, downloadTag: String,
transportServer: String,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
fileName: String, fileName: String,
@@ -434,7 +442,8 @@ object FileDownloadManager {
id = attachmentId, id = attachmentId,
tag = downloadTag, tag = downloadTag,
targetFile = encryptedPartFile, targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes resumeFromBytes = resumeBytes,
transportServer = transportServer
) )
update( update(
attachmentId, attachmentId,

View File

@@ -10,5 +10,9 @@ data class MessageAttachment(
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename" val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
val width: Int = 0, val width: Int = 0,
val height: Int = 0, val height: Int = 0,
val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI) val localUri: String = "", // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
val transportTag: String = "",
val transportServer: String = "",
val encodedFor: String = "",
val encoder: String = ""
) )

View File

@@ -15,29 +15,48 @@ class PacketMessage : Packet() {
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
var attachments: List<MessageAttachment> = emptyList() var attachments: List<MessageAttachment> = emptyList()
private data class ParsedPacketMessage(
val fromPublicKey: String,
val toPublicKey: String,
val content: String,
val chachaKey: String,
val timestamp: Long,
val privateKey: String,
val messageId: String,
val attachments: List<MessageAttachment>,
val aesChachaKey: String
)
override fun getPacketId(): Int = 0x06 override fun getPacketId(): Int = 0x06
override fun receive(stream: Stream) { override fun receive(stream: Stream) {
fromPublicKey = stream.readString() val startPointer = stream.getReadPointerBits()
toPublicKey = stream.readString() val parsed =
content = stream.readString() listOf(4, 2, 0)
chachaKey = stream.readString() .asSequence()
timestamp = stream.readInt64() .mapNotNull { attachmentMetaFieldCount ->
privateKey = stream.readString() stream.setReadPointerBits(startPointer)
messageId = stream.readString() parseFromStream(stream, attachmentMetaFieldCount)
?.takeIf { !stream.hasRemainingBits() }
val attachmentCount = stream.readInt8()
val attachmentsList = mutableListOf<MessageAttachment>()
for (i in 0 until attachmentCount) {
attachmentsList.add(MessageAttachment(
id = stream.readString(),
preview = stream.readString(),
blob = stream.readString(),
type = AttachmentType.fromInt(stream.readInt8())
))
} }
attachments = attachmentsList .firstOrNull()
aesChachaKey = stream.readString() ?: run {
stream.setReadPointerBits(startPointer)
parseFromStream(stream, 2)
?: throw IllegalStateException(
"Failed to parse PacketMessage payload"
)
}
fromPublicKey = parsed.fromPublicKey
toPublicKey = parsed.toPublicKey
content = parsed.content
chachaKey = parsed.chachaKey
timestamp = parsed.timestamp
privateKey = parsed.privateKey
messageId = parsed.messageId
attachments = parsed.attachments
aesChachaKey = parsed.aesChachaKey
} }
override fun send(): Stream { override fun send(): Stream {
@@ -57,9 +76,80 @@ class PacketMessage : Packet() {
stream.writeString(attachment.preview) stream.writeString(attachment.preview)
stream.writeString(attachment.blob) stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.value) stream.writeInt8(attachment.type.value)
stream.writeString(attachment.transportTag)
stream.writeString(attachment.transportServer)
} }
stream.writeString(aesChachaKey) stream.writeString(aesChachaKey)
return stream return stream
} }
private fun parseFromStream(
parser: Stream,
attachmentMetaFieldCount: Int
): ParsedPacketMessage? {
return runCatching {
val parsedFromPublicKey = parser.readString()
val parsedToPublicKey = parser.readString()
val parsedContent = parser.readString()
val parsedChachaKey = parser.readString()
val parsedTimestamp = parser.readInt64()
val parsedPrivateKey = parser.readString()
val parsedMessageId = parser.readString()
val attachmentCount = parser.readInt8().coerceAtLeast(0)
val parsedAttachments = ArrayList<MessageAttachment>(attachmentCount)
repeat(attachmentCount) {
val id = parser.readString()
val preview = parser.readString()
val blob = parser.readString()
val type = AttachmentType.fromInt(parser.readInt8())
val transportTag: String
val transportServer: String
val encodedFor: String
val encoder: String
if (attachmentMetaFieldCount >= 2) {
transportTag = parser.readString()
transportServer = parser.readString()
} else {
transportTag = ""
transportServer = ""
}
if (attachmentMetaFieldCount >= 4) {
encodedFor = parser.readString()
encoder = parser.readString()
} else {
encodedFor = ""
encoder = ""
}
parsedAttachments.add(
MessageAttachment(
id = id,
preview = preview,
blob = blob,
type = type,
transportTag = transportTag,
transportServer = transportServer,
encodedFor = encodedFor,
encoder = encoder
)
)
}
val parsedAesChachaKey = parser.readString()
ParsedPacketMessage(
fromPublicKey = parsedFromPublicKey,
toPublicKey = parsedToPublicKey,
content = parsedContent,
chachaKey = parsedChachaKey,
timestamp = parsedTimestamp,
privateKey = parsedPrivateKey,
messageId = parsedMessageId,
attachments = parsedAttachments,
aesChachaKey = parsedAesChachaKey
)
}.getOrNull()
}
} }

View File

@@ -32,7 +32,7 @@ class Protocol(
private const val TAG = "RosettaProtocol" private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15 private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L

View File

@@ -2,36 +2,24 @@ package com.rosetta.messenger.network
/** /**
* Binary stream for protocol packets. * Binary stream for protocol packets.
* Ported from desktop/dev stream.ts implementation. *
* Parity with desktop/server:
* - signed: Int8/16/32/64 (two's complement)
* - unsigned: UInt8/16/32/64
* - String: length(UInt32) + chars(UInt16)
* - byte[]: length(UInt32) + raw bytes
*/ */
class Stream(stream: ByteArray = ByteArray(0)) { class Stream(initial: ByteArray = byteArrayOf()) {
private var stream: ByteArray private var stream: ByteArray = initial.copyOf()
private var readPointer = 0 // bits private var readPointer: Int = 0 // bits
private var writePointer = 0 // bits private var writePointer: Int = stream.size shl 3 // bits
init { fun getStream(): ByteArray = stream.copyOf(length())
if (stream.isEmpty()) {
this.stream = ByteArray(0)
} else {
this.stream = stream.copyOf()
this.writePointer = this.stream.size shl 3
}
}
fun getStream(): ByteArray { fun setStream(value: ByteArray) {
return stream.copyOf(length()) stream = value.copyOf()
} readPointer = 0
writePointer = stream.size shl 3
fun setStream(stream: ByteArray = ByteArray(0)) {
if (stream.isEmpty()) {
this.stream = ByteArray(0)
this.readPointer = 0
this.writePointer = 0
return
}
this.stream = stream.copyOf()
this.readPointer = 0
this.writePointer = this.stream.size shl 3
} }
fun getBuffer(): ByteArray = getStream() fun getBuffer(): ByteArray = getStream()
@@ -42,14 +30,18 @@ class Stream(stream: ByteArray = ByteArray(0)) {
fun getReadPointerBits(): Int = readPointer fun getReadPointerBits(): Int = readPointer
fun setReadPointerBits(bits: Int) {
readPointer = bits.coerceIn(0, writePointer)
}
fun getTotalBits(): Int = writePointer fun getTotalBits(): Int = writePointer
fun getRemainingBits(): Int = writePointer - readPointer fun getRemainingBits(): Int = (writePointer - readPointer).coerceAtLeast(0)
fun hasRemainingBits(): Boolean = readPointer < writePointer fun hasRemainingBits(): Boolean = readPointer < writePointer
fun writeBit(value: Int) { fun writeBit(value: Int) {
writeBits((value and 1).toULong(), 1) writeBits((value and 1).toLong(), 1)
} }
fun readBit(): Int = readBits(1).toInt() fun readBit(): Int = readBits(1).toInt()
@@ -60,18 +52,16 @@ class Stream(stream: ByteArray = ByteArray(0)) {
fun readBoolean(): Boolean = readBit() == 1 fun readBoolean(): Boolean = readBit() == 1
fun writeByte(value: Int) { fun writeByte(value: Byte) {
writeUInt8(value and 0xFF) writeUInt8(value.toInt() and 0xFF)
} }
fun readByte(): Int { fun readByte(): Byte = readUInt8().toByte()
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
fun writeUInt8(value: Int) { fun writeUInt8(value: Int) {
val v = value and 0xFF val v = value and 0xFF
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) { if ((writePointer and 7) == 0) {
reserveBits(8) reserveBits(8)
stream[writePointer shr 3] = v.toByte() stream[writePointer shr 3] = v.toByte()
@@ -79,7 +69,7 @@ class Stream(stream: ByteArray = ByteArray(0)) {
return return
} }
writeBits(v.toULong(), 8) writeBits(v.toLong(), 8)
} }
fun readUInt8(): Int { fun readUInt8(): Int {
@@ -87,23 +77,21 @@ class Stream(stream: ByteArray = ByteArray(0)) {
throw IllegalStateException("Not enough bits to read UInt8") throw IllegalStateException("Not enough bits to read UInt8")
} }
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) { if ((readPointer and 7) == 0) {
val value = stream[readPointer shr 3].toInt() and 0xFF val value = stream[readPointer shr 3].toInt() and 0xFF
readPointer += 8 readPointer += 8
return value return value
} }
return readBits(8).toInt() return readBits(8).toInt() and 0xFF
} }
fun writeInt8(value: Int) { fun writeInt8(value: Int) {
writeUInt8(value) writeUInt8(value)
} }
fun readInt8(): Int { fun readInt8(): Int = readUInt8().toByte().toInt()
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
fun writeUInt16(value: Int) { fun writeUInt16(value: Int) {
val v = value and 0xFFFF val v = value and 0xFFFF
@@ -121,10 +109,7 @@ class Stream(stream: ByteArray = ByteArray(0)) {
writeUInt16(value) writeUInt16(value)
} }
fun readInt16(): Int { fun readInt16(): Int = readUInt16().toShort().toInt()
val value = readUInt16()
return if (value >= 0x8000) value - 0x10000 else value
}
fun writeUInt32(value: Long) { fun writeUInt32(value: Long) {
if (value < 0L || value > 0xFFFF_FFFFL) { if (value < 0L || value > 0xFFFF_FFFFL) {
@@ -138,11 +123,11 @@ class Stream(stream: ByteArray = ByteArray(0)) {
} }
fun readUInt32(): Long { fun readUInt32(): Long {
val b1 = readUInt8().toLong() val b1 = readUInt8().toLong() and 0xFFL
val b2 = readUInt8().toLong() val b2 = readUInt8().toLong() and 0xFFL
val b3 = readUInt8().toLong() val b3 = readUInt8().toLong() and 0xFFL
val b4 = readUInt8().toLong() val b4 = readUInt8().toLong() and 0xFFL
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL return (b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4
} }
fun writeInt32(value: Int) { fun writeInt32(value: Int) {
@@ -151,109 +136,112 @@ class Stream(stream: ByteArray = ByteArray(0)) {
fun readInt32(): Int = readUInt32().toInt() fun readInt32(): Int = readUInt32().toInt()
fun writeUInt64(value: ULong) { /** Writes raw 64-bit pattern (UInt64 bit-pattern in Long). */
writeUInt8(((value shr 56) and 0xFFu).toInt()) fun writeUInt64(value: Long) {
writeUInt8(((value shr 48) and 0xFFu).toInt()) writeUInt8(((value ushr 56) and 0xFF).toInt())
writeUInt8(((value shr 40) and 0xFFu).toInt()) writeUInt8(((value ushr 48) and 0xFF).toInt())
writeUInt8(((value shr 32) and 0xFFu).toInt()) writeUInt8(((value ushr 40) and 0xFF).toInt())
writeUInt8(((value shr 24) and 0xFFu).toInt()) writeUInt8(((value ushr 32) and 0xFF).toInt())
writeUInt8(((value shr 16) and 0xFFu).toInt()) writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value shr 8) and 0xFFu).toInt()) writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8((value and 0xFFu).toInt()) writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
} }
fun readUInt64(): ULong { /** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
val high = readUInt32().toULong() fun readUInt64(): Long {
val low = readUInt32().toULong() val high = readUInt32() and 0xFFFF_FFFFL
val low = readUInt32() and 0xFFFF_FFFFL
return (high shl 32) or low return (high shl 32) or low
} }
fun writeInt64(value: Long) { fun writeInt64(value: Long) {
writeUInt64(value.toULong()) writeUInt64(value)
} }
fun readInt64(): Long = readUInt64().toLong() fun readInt64(): Long = readUInt64()
fun writeFloat32(value: Float) { fun writeFloat32(value: Float) {
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL writeInt32(java.lang.Float.floatToIntBits(value))
writeUInt32(bits)
} }
fun readFloat32(): Float { fun readFloat32(): Float = java.lang.Float.intBitsToFloat(readInt32())
val bits = readUInt32().toInt()
return Float.fromBits(bits)
}
fun writeString(value: String?) { /** String: length(UInt32) + chars(UInt16). */
val str = value ?: "" fun writeString(value: String) {
writeUInt32(str.length.toLong()) writeUInt32(value.length.toLong())
if (str.isEmpty()) return if (value.isEmpty()) return
reserveBits(str.length.toLong() * 16L) reserveBits(value.length.toLong() * 16L)
for (i in str.indices) { for (i in value.indices) {
writeUInt16(str[i].code and 0xFFFF) writeUInt16(value[i].code and 0xFFFF)
} }
} }
fun readString(): String { fun readString(): String {
val len = readUInt32() val lenLong = readUInt32()
if (len > Int.MAX_VALUE.toLong()) { if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("String length too large: $len") throw IllegalStateException("String length too large: $lenLong")
} }
val requiredBits = len * 16L val length = lenLong.toInt()
val requiredBits = length.toLong() * 16L
if (requiredBits > remainingBits()) { if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string") throw IllegalStateException("Not enough bits to read string")
} }
val chars = CharArray(len.toInt()) val sb = StringBuilder(length)
for (i in chars.indices) { repeat(length) {
chars[i] = readUInt16().toChar() sb.append(readUInt16().toChar())
} }
return String(chars) return sb.toString()
} }
fun writeBytes(value: ByteArray?) { /** byte[]: length(UInt32) + payload. */
val bytes = value ?: ByteArray(0) fun writeBytes(value: ByteArray) {
writeUInt32(bytes.size.toLong()) writeUInt32(value.size.toLong())
if (bytes.isEmpty()) return
reserveBits(bytes.size.toLong() * 8L) if (value.isEmpty()) return
reserveBits(value.size.toLong() * 8L)
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) { if ((writePointer and 7) == 0) {
val byteIndex = writePointer shr 3 val byteIndex = writePointer shr 3
ensureCapacity(byteIndex + bytes.size - 1) ensureCapacity(byteIndex + value.size - 1)
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size) System.arraycopy(value, 0, stream, byteIndex, value.size)
writePointer += bytes.size shl 3 writePointer += value.size shl 3
return return
} }
for (b in bytes) { value.forEach { writeUInt8(it.toInt() and 0xFF) }
writeUInt8(b.toInt() and 0xFF)
}
} }
fun readBytes(): ByteArray { fun readBytes(): ByteArray {
val len = readUInt32() val lenLong = readUInt32()
if (len == 0L) return ByteArray(0) if (lenLong == 0L) return byteArrayOf()
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0) if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Byte array too large: $lenLong")
val requiredBits = len * 8L
if (requiredBits > remainingBits()) {
return ByteArray(0)
} }
val out = ByteArray(len.toInt()) val length = lenLong.toInt()
val requiredBits = length.toLong() * 8L
if (requiredBits > remainingBits()) {
return byteArrayOf()
}
val out = ByteArray(length)
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) { if ((readPointer and 7) == 0) {
val byteIndex = readPointer shr 3 val byteIndex = readPointer shr 3
System.arraycopy(stream, byteIndex, out, 0, out.size) System.arraycopy(stream, byteIndex, out, 0, length)
readPointer += out.size shl 3 readPointer += length shl 3
return out return out
} }
for (i in out.indices) { for (i in 0 until length) {
out[i] = readUInt8().toByte() out[i] = readUInt8().toByte()
} }
return out return out
@@ -261,36 +249,40 @@ class Stream(stream: ByteArray = ByteArray(0)) {
private fun remainingBits(): Long = (writePointer - readPointer).toLong() private fun remainingBits(): Long = (writePointer - readPointer).toLong()
private fun writeBits(value: ULong, bits: Int) { private fun writeBits(value: Long, bits: Int) {
if (bits <= 0) return if (bits <= 0) return
reserveBits(bits.toLong()) reserveBits(bits.toLong())
for (i in bits - 1 downTo 0) { for (i in bits - 1 downTo 0) {
val bit = ((value shr i) and 1u).toInt() val bit = ((value ushr i) and 1L).toInt()
val byteIndex = writePointer shr 3 val byteIndex = writePointer shr 3
val shift = 7 - (writePointer and 7) val shift = 7 - (writePointer and 7)
stream[byteIndex] =
if (bit == 1) { if (bit == 1) {
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte() (stream[byteIndex].toInt() or (1 shl shift)).toByte()
} else { } else {
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte() (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
} }
writePointer++ writePointer++
} }
} }
private fun readBits(bits: Int): ULong { private fun readBits(bits: Int): Long {
if (bits <= 0) return 0u if (bits <= 0) return 0L
if (remainingBits() < bits.toLong()) { if (remainingBits() < bits.toLong()) {
throw IllegalStateException("Not enough bits to read") throw IllegalStateException("Not enough bits to read")
} }
var value = 0uL var value = 0L
repeat(bits) { repeat(bits) {
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1 val bit =
value = (value shl 1) or bit.toULong() (stream[readPointer shr 3].toInt() ushr
(7 - (readPointer and 7))) and
1
value = (value shl 1) or bit.toLong()
readPointer++ readPointer++
} }
return value return value
@@ -312,21 +304,15 @@ class Stream(stream: ByteArray = ByteArray(0)) {
ensureCapacity(byteIndex.toInt()) ensureCapacity(byteIndex.toInt())
} }
private fun ensureCapacity(index: Int) { private fun ensureCapacity(byteIndex: Int) {
val requiredSize = index + 1 val requiredSize = byteIndex + 1
if (requiredSize <= stream.size) return if (requiredSize <= stream.size) return
var newSize = if (stream.isEmpty()) 32 else stream.size var newSize = if (stream.isEmpty()) 32 else stream.size
while (newSize < requiredSize) { while (newSize < requiredSize) {
if (newSize > (Int.MAX_VALUE shr 1)) { newSize = if (newSize <= Int.MAX_VALUE / 2) newSize shl 1 else requiredSize
newSize = requiredSize
break
}
newSize = newSize shl 1
} }
val next = ByteArray(newSize) stream = stream.copyOf(newSize)
System.arraycopy(stream, 0, next, 0, stream.size)
stream = next
} }
} }

View File

@@ -79,7 +79,11 @@ object TransportManager {
* Получить активный сервер для скачивания/загрузки. * Получить активный сервер для скачивания/загрузки.
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN. * Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
*/ */
private suspend fun getActiveServer(): String { private suspend fun getActiveServer(serverOverride: String? = null): String {
val normalizedOverride = serverOverride?.trim()?.trimEnd('/').orEmpty()
if (normalizedOverride.isNotEmpty()) {
return normalizedOverride
}
transportServer?.let { return it } transportServer?.let { return it }
requestTransportServer() requestTransportServer()
repeat(40) { // 10s total repeat(40) { // 10s total
@@ -269,8 +273,12 @@ object TransportManager {
* @param tag Tag файла на сервере * @param tag Tag файла на сервере
* @return Содержимое файла * @return Содержимое файла
*/ */
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) { suspend fun downloadFile(
val server = getActiveServer() id: String,
tag: String,
transportServer: String? = null
): String = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer)
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний // Добавляем в список скачиваний
@@ -385,7 +393,11 @@ object TransportManager {
* @param tag Tag файла на сервере * @param tag Tag файла на сервере
* @return Временный файл с зашифрованным содержимым * @return Временный файл с зашифрованным содержимым
*/ */
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) { suspend fun downloadFileRaw(
id: String,
tag: String,
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context") val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp") val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try { try {
@@ -393,7 +405,8 @@ object TransportManager {
id = id, id = id,
tag = tag, tag = tag,
targetFile = tempFile, targetFile = tempFile,
resumeFromBytes = 0L resumeFromBytes = 0L,
transportServer = transportServer
) )
} catch (e: Exception) { } catch (e: Exception) {
tempFile.delete() tempFile.delete()
@@ -410,9 +423,10 @@ object TransportManager {
id: String, id: String,
tag: String, tag: String,
targetFile: File, targetFile: File,
resumeFromBytes: Long = 0L resumeFromBytes: Long = 0L,
transportServer: String? = null
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer(transportServer)
ProtocolManager.addLog( ProtocolManager.addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes" "📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
) )

View File

@@ -14,6 +14,9 @@ import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -38,6 +41,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "RosettaFCM" private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages" private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages" private const val CHANNEL_NAME = "Messages"
private const val CALL_CHANNEL_ID = "rosetta_calls_push"
private const val CALL_CHANNEL_NAME = "Calls"
private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call"
private const val PUSH_TYPE_READ = "read"
// 🔥 Флаг - приложение в foreground (видимо пользователю) // 🔥 Флаг - приложение в foreground (видимо пользователю)
@Volatile var isAppInForeground = false @Volatile var isAppInForeground = false
@@ -56,14 +65,43 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
fun cancelNotificationForChat(context: Context, senderPublicKey: String) { fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
val notificationManager = val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val normalizedKey = senderPublicKey.trim() val variants = buildDialogKeyVariants(senderPublicKey)
if (normalizedKey.isNotEmpty()) { for (key in variants) {
notificationManager.cancel(getNotificationIdForChat(normalizedKey)) notificationManager.cancel(getNotificationIdForChat(key))
} }
// Fallback: некоторые серверные payload могут прийти без sender key. // Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог. // Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat("")) notificationManager.cancel(getNotificationIdForChat(""))
} }
private fun buildDialogKeyVariants(rawKey: String): Set<String> {
val trimmed = rawKey.trim()
if (trimmed.isBlank()) return emptySet()
val variants = linkedSetOf(trimmed)
val lower = trimmed.lowercase(Locale.ROOT)
when {
lower.startsWith("#group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("group:$groupId")
variants.add(groupId)
}
}
lower.startsWith("group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("#group:$groupId")
variants.add(groupId)
}
}
else -> {
variants.add("#group:$trimmed")
variants.add("group:$trimmed")
}
}
return variants
}
} }
/** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */ /** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */
@@ -85,54 +123,100 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
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}") Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
var handledMessageData = false var handledByData = false
// Обрабатываем data payload
if (remoteMessage.data.isNotEmpty()) {
val data = remoteMessage.data val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
// Обрабатываем data payload (новый server формат + legacy fallback)
if (data.isNotEmpty()) {
val type = val type =
firstNonBlank(data, "type", "event", "action") firstNonBlank(data, "type", "event", "action")
?.lowercase(Locale.ROOT) ?.lowercase(Locale.ROOT)
.orEmpty() .orEmpty()
val dialogKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
val senderPublicKey = val senderPublicKey =
firstNonBlank( firstNonBlank(
data, data,
"sender_public_key", "sender_public_key",
"from_public_key", "from_public_key",
"fromPublicKey", "fromPublicKey",
"from",
"public_key", "public_key",
"publicKey" "publicKey"
) )
val senderName = val senderName =
firstNonBlank(data, "sender_name", "from_title", "sender", "title", "name") firstNonBlank(
data,
"sender_name",
"sender_title",
"from_title",
"sender",
"title",
"name"
)
?: notificationTitle.takeIf { it.isNotBlank() }
?: dialogKey?.take(10)
?: senderPublicKey?.take(10) ?: senderPublicKey?.take(10)
?: "Rosetta" ?: "Rosetta"
val messagePreview = val messagePreview =
firstNonBlank(data, "message_preview", "message", "text", "body") firstNonBlank(data, "message_preview", "message", "text", "body")
?: "New message" ?: notificationBody.takeIf { it.isNotBlank() }
?: when (type) {
PUSH_TYPE_GROUP_MESSAGE -> "New group message"
PUSH_TYPE_CALL -> "Incoming call"
else -> "New message"
}
val isReadEvent = type == "message_read" || type == "read" val isReadEvent = type == "message_read" || type == PUSH_TYPE_READ
val isMessageEvent = val isMessageEvent =
type == "new_message" || type == "new_message" ||
type == "message" || type == "message" ||
type == "newmessage" || type == "newmessage" ||
type == "msg_new" type == "msg_new" ||
type == PUSH_TYPE_PERSONAL_MESSAGE ||
type == PUSH_TYPE_GROUP_MESSAGE
when { when {
isMessageEvent -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
}
isReadEvent -> { isReadEvent -> {
handledMessageData = true if (!dialogKey.isNullOrBlank()) {
cancelNotificationForChat(applicationContext, dialogKey)
}
handledByData = true
}
type == PUSH_TYPE_CALL -> {
handleIncomingCallPush(
dialogKey = dialogKey ?: senderPublicKey.orEmpty(),
title = senderName,
body = messagePreview
)
handledByData = true
}
isMessageEvent -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
} }
// Fallback for servers sending data-only payload without explicit "type". // Fallback for servers sending data-only payload without explicit "type".
senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> { dialogKey != null || senderPublicKey != null ||
showMessageNotification(senderPublicKey, senderName, messagePreview) data.containsKey("message_preview") ||
handledMessageData = true data.containsKey("message") ||
data.containsKey("text") -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
} }
} }
if (!handledByData) {
val looksLikeMessagePayload = val looksLikeMessagePayload =
type.contains("message") || type.contains("message") ||
data.keys.any { key -> data.keys.any { key ->
@@ -141,18 +225,32 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
lower.contains("text") || lower.contains("text") ||
lower.contains("body") lower.contains("body")
} }
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) { if (looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview, senderPublicKey) showSimpleNotification(senderName, messagePreview, dialogKey ?: senderPublicKey)
handledMessageData = true handledByData = true
}
} }
} }
// Обрабатываем notification payload (если есть). // Обрабатываем notification payload (если data-ветка не сработала).
// Для new_message используем data-ветку выше, чтобы не показывать дубликаты
// с неуправляемым notification id.
remoteMessage.notification?.let { remoteMessage.notification?.let {
if (!handledMessageData) { if (!handledByData) {
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message") val senderPublicKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
showSimpleNotification(
it.title ?: "Rosetta",
it.body ?: "New message",
senderPublicKey
)
} }
} }
} }
@@ -223,8 +321,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (isAppInForeground || !areNotificationsEnabled()) { if (isAppInForeground || !areNotificationsEnabled()) {
return return
} }
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS // Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
val dedupKey = senderPublicKey?.trim()?.ifEmpty { null } ?: "__simple__" val dedupKey = senderKey.ifEmpty { "__simple__" }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
@@ -235,8 +337,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление // Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (!senderPublicKey.isNullOrBlank()) { val notifId = if (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderPublicKey.trim()) getNotificationIdForChat(senderKey)
} else { } else {
(title + body).hashCode() and 0x7FFFFFFF (title + body).hashCode() and 0x7FFFFFFF
} }
@@ -269,6 +371,78 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.notify(notifId, notification) notificationManager.notify(notifId, notification)
} }
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return
val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return
if (CallManager.state.value.phase != CallPhase.IDLE) return
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return
lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel()
val notifId =
if (normalizedDialog.isNotEmpty()) {
getNotificationIdForChat(normalizedDialog)
} else {
("call:$title:$body").hashCode() and 0x7FFFFFFF
}
val openIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_chat", normalizedDialog)
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification =
NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title.ifBlank { "Incoming call" })
.setContentText(body.ifBlank { "Incoming call" })
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
}
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) {
runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext)
CallManager.initialize(applicationContext)
if (account.isNotBlank()) {
CallManager.bindAccount(account)
}
ProtocolManager.reconnectNowIfNeeded("push_$reason")
}.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
}
/** Создать notification channel для Android 8+ */ /** Создать notification channel для Android 8+ */
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -289,6 +463,26 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
/** Отдельный канал для входящих звонков */
private fun createCallNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
CALL_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Incoming call notifications"
enableVibration(true)
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/** Сохранить FCM токен в SharedPreferences */ /** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) { private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
@@ -318,7 +512,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val accountManager = AccountManager(applicationContext) val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty() val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey) val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key)
}
} }
}.getOrDefault(false) }.getOrDefault(false)
} }

View File

@@ -48,10 +48,12 @@ import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -125,6 +127,27 @@ private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
private enum class ChatHeaderMode {
NORMAL,
SEARCH,
SELECTION
}
private fun buildDialogKeyForSearch(account: String, opponent: String): String {
val normalizedAccount = account.trim()
val normalizedOpponent = opponent.trim()
val normalizedLower = normalizedOpponent.lowercase(Locale.ROOT)
val isGroup =
normalizedLower.startsWith("#group:") || normalizedLower.startsWith("group:")
if (isGroup) return normalizedOpponent
if (normalizedAccount == normalizedOpponent) return normalizedAccount
return if (normalizedAccount < normalizedOpponent) {
"$normalizedAccount:$normalizedOpponent"
} else {
"$normalizedOpponent:$normalizedAccount"
}
}
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean { private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar = val firstCalendar =
java.util.Calendar.getInstance().apply { java.util.Calendar.getInstance().apply {
@@ -295,7 +318,8 @@ fun ChatDetailScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
chatWallpaperId: String = "", chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {} onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current val context = LocalContext.current
@@ -307,6 +331,7 @@ fun ChatDetailScreen(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val database = RosettaDatabase.getDatabase(context) val database = RosettaDatabase.getDatabase(context)
val searchIndexDao = remember(database) { database.messageSearchIndexDao() }
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
// 🔇 Mute state — read from PreferencesManager // 🔇 Mute state — read from PreferencesManager
@@ -381,6 +406,13 @@ fun ChatDetailScreen(
// Логирование изменений selection mode // Логирование изменений selection mode
LaunchedEffect(isSelectionMode, selectedMessages.size) {} LaunchedEffect(isSelectionMode, selectedMessages.size) {}
// Сброс выделения при начале звонка
LaunchedEffect(isCallActive) {
if (isCallActive) {
selectedMessages = emptySet()
}
}
// 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался // 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался
// (клавиатура уже должна быть закрыта в onLongClick, это только backup) // (клавиатура уже должна быть закрыта в onLongClick, это только backup)
LaunchedEffect(isSelectionMode) { LaunchedEffect(isSelectionMode) {
@@ -764,6 +796,18 @@ fun ChatDetailScreen(
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) }
var isInChatSearchMode by rememberSaveable(user.publicKey) { mutableStateOf(false) }
var inChatSearchQuery by rememberSaveable(user.publicKey) { mutableStateOf("") }
var inChatSearchResultIds by remember(user.publicKey) { mutableStateOf<List<String>>(emptyList()) }
var inChatSearchResultIndex by rememberSaveable(user.publicKey) { mutableIntStateOf(-1) }
val searchFieldFocusRequester = remember(user.publicKey) { FocusRequester() }
val searchDialogKey =
remember(currentUserPublicKey, user.publicKey) {
buildDialogKeyForSearch(
account = currentUserPublicKey,
opponent = user.publicKey
)
}
// Наблюдаем за статусом блокировки в реальном времени через Flow // Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by val isBlocked by
database.blacklistDao() database.blacklistDao()
@@ -1209,6 +1253,90 @@ fun ChatDetailScreen(
} }
} }
} }
val closeInChatSearch: () -> Unit = {
isInChatSearchMode = false
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
hideInputOverlays()
}
val jumpToSearchResult: (Int) -> Unit = jump@{ targetIndex ->
val ids = inChatSearchResultIds
if (ids.isEmpty()) return@jump
val normalizedIndex =
if (targetIndex >= 0) {
targetIndex % ids.size
} else {
((targetIndex % ids.size) + ids.size) % ids.size
}
inChatSearchResultIndex = normalizedIndex
scrollToMessage(ids[normalizedIndex])
}
val searchResultCounterText =
remember(inChatSearchResultIds, inChatSearchResultIndex) {
if (inChatSearchResultIds.isEmpty() || inChatSearchResultIndex < 0) {
"0/0"
} else {
"${inChatSearchResultIndex + 1}/${inChatSearchResultIds.size}"
}
}
LaunchedEffect(isInChatSearchMode) {
if (isInChatSearchMode) {
delay(80)
searchFieldFocusRequester.requestFocus()
} else {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
}
LaunchedEffect(isSelectionMode) {
if (isSelectionMode && isInChatSearchMode) {
closeInChatSearch()
}
}
LaunchedEffect(
isInChatSearchMode,
inChatSearchQuery,
currentUserPublicKey,
searchDialogKey
) {
if (!isInChatSearchMode) return@LaunchedEffect
val account = currentUserPublicKey.trim()
val normalizedQuery = inChatSearchQuery.trim().lowercase(Locale.ROOT)
if (account.isBlank() || normalizedQuery.length < 2) {
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
return@LaunchedEffect
}
delay(250)
val resultIds =
withContext(Dispatchers.IO) {
searchIndexDao
.searchInDialog(
account = account,
dialogKey = searchDialogKey,
queryNormalized = normalizedQuery,
limit = 200,
offset = 0
)
.map { it.messageId }
.distinct()
}
inChatSearchResultIds = resultIds
if (resultIds.isEmpty()) {
inChatSearchResultIndex = -1
} else {
inChatSearchResultIndex = 0
scrollToMessage(resultIds.first())
}
}
// Динамический subtitle: typing > online > offline // Динамический subtitle: typing > online > offline
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey) val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
@@ -1233,7 +1361,13 @@ fun ChatDetailScreen(
} }
// 🔥 Обработка системной кнопки назад // 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() } BackHandler {
if (isInChatSearchMode) {
closeInChatSearch()
} else {
hideKeyboardAndBack()
}
}
// 🔥 Lifecycle-aware отслеживание активности экрана // 🔥 Lifecycle-aware отслеживание активности экрана
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -1425,12 +1559,18 @@ fun ChatDetailScreen(
) { ) {
// Контент хедера с Crossfade для плавной смены - ускоренная // Контент хедера с Crossfade для плавной смены - ускоренная
// анимация // анимация
val headerMode =
when {
isSelectionMode -> ChatHeaderMode.SELECTION
isInChatSearchMode -> ChatHeaderMode.SEARCH
else -> ChatHeaderMode.NORMAL
}
Crossfade( Crossfade(
targetState = isSelectionMode, targetState = headerMode,
animationSpec = tween(150), animationSpec = tween(150),
label = "headerContent" label = "headerContent"
) { selectionMode -> ) { currentHeaderMode ->
if (selectionMode) { if (currentHeaderMode == ChatHeaderMode.SELECTION) {
// SELECTION MODE CONTENT // SELECTION MODE CONTENT
Row( Row(
modifier = modifier =
@@ -1593,6 +1733,129 @@ fun ChatDetailScreen(
} }
} }
} }
} else if (currentHeaderMode == ChatHeaderMode.SEARCH) {
// SEARCH MODE CONTENT
Row(
modifier =
Modifier.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { closeInChatSearch() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Close search",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
TextField(
value = inChatSearchQuery,
onValueChange = { value ->
inChatSearchQuery = value
},
singleLine = true,
modifier =
Modifier.weight(1f)
.focusRequester(searchFieldFocusRequester),
textStyle =
LocalTextStyle.current.copy(
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
),
placeholder = {
Text(
text = "Search in chat",
color = Color.White.copy(alpha = 0.65f),
fontSize = 16.sp
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f)
)
},
trailingIcon = {
if (inChatSearchQuery.isNotBlank()) {
IconButton(
onClick = {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = Color.White.copy(alpha = 0.85f)
)
}
}
},
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White
)
)
Text(
text = searchResultCounterText,
color = Color.White.copy(alpha = 0.78f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 4.dp)
)
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex - 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronUp,
contentDescription = "Previous result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex + 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Next result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
}
} else { } else {
// NORMAL HEADER CONTENT // NORMAL HEADER CONTENT
Row( Row(
@@ -1935,6 +2198,14 @@ fun ChatDetailScreen(
isSystemAccount, isSystemAccount,
isBlocked = isBlocked =
isBlocked, isBlocked,
onSearchInChatClick = {
showMenu = false
hideInputOverlays()
isInChatSearchMode = true
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
},
onGroupInfoClick = { onGroupInfoClick = {
showMenu = showMenu =
false false
@@ -2974,7 +3245,7 @@ fun ChatDetailScreen(
avatarRepository = avatarRepository =
avatarRepository, avatarRepository,
onLongClick = { onLongClick = {
if (simplePickerPreviewUri != null) { if (simplePickerPreviewUri != null || isCallActive) {
return@MessageBubble return@MessageBubble
} }
// 📳 Haptic feedback при долгом нажатии // 📳 Haptic feedback при долгом нажатии
@@ -3017,7 +3288,7 @@ fun ChatDetailScreen(
) )
}, },
onClick = { onClick = {
if (simplePickerPreviewUri != null) { if (simplePickerPreviewUri != null || isCallActive) {
return@MessageBubble return@MessageBubble
} }
if (shouldIgnoreTapAfterLongPress( if (shouldIgnoreTapAfterLongPress(
@@ -3039,12 +3310,17 @@ fun ChatDetailScreen(
message.attachments.all { message.attachments.all {
it.type == AttachmentType.IMAGE it.type == AttachmentType.IMAGE
} }
val isCallMessage =
message.attachments.isNotEmpty() &&
message.attachments.all {
it.type == AttachmentType.CALL
}
if (isSelectionMode) { if (isSelectionMode) {
toggleMessageSelection( toggleMessageSelection(
selectionKey, selectionKey,
!hasAvatar !hasAvatar
) )
} else if (!hasAvatar && !isPhotoOnly) { } else if (!hasAvatar && (!isPhotoOnly || isCallMessage)) {
// 💬 Tap = context menu // 💬 Tap = context menu
contextMenuMessage = message contextMenuMessage = message
showContextMenu = true showContextMenu = true

View File

@@ -98,6 +98,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val groupDao = database.groupDao() private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao() private val pinnedMessageDao = database.pinnedMessageDao()
@@ -126,6 +127,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// UI State // UI State
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList()) private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow() val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
@Volatile private var normalizedMessagesDescCache: List<ChatMessage> = emptyList()
/** /**
* Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces * Pre-computed messages with date headers — runs dedup + sort on Dispatchers.Default. Replaces
@@ -143,20 +145,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
.debounce(16) // coalesce rapid updates (1 frame) .debounce(16) // coalesce rapid updates (1 frame)
.mapLatest { rawMessages -> .mapLatest { rawMessages ->
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val unique = rawMessages.distinctBy { it.id } val normalized =
val sorted = unique.sortedWith(chatMessageDescComparator) normalizeMessagesDescendingIncremental(
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size) previous = normalizedMessagesDescCache,
var prevDateStr: String? = null incoming = rawMessages
for (i in sorted.indices) { )
val msg = sorted[i] normalizedMessagesDescCache = normalized
val dateStr = _dateFmt.format(msg.timestamp) buildMessagesWithDateHeaders(normalized)
val nextMsg = sorted.getOrNull(i + 1)
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
val showDate = nextDateStr == null || nextDateStr != dateStr
result.add(msg to showDate)
prevDateStr = dateStr
}
result as List<Pair<ChatMessage, Boolean>>
} }
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -234,6 +229,98 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> = private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator) messages.sortedWith(chatMessageAscComparator)
private fun isSortedDescending(messages: List<ChatMessage>): Boolean {
if (messages.size < 2) return true
for (i in 0 until messages.lastIndex) {
if (chatMessageDescComparator.compare(messages[i], messages[i + 1]) > 0) {
return false
}
}
return true
}
private fun insertIntoSortedDescending(
existing: List<ChatMessage>,
message: ChatMessage
): List<ChatMessage> {
if (existing.isEmpty()) return listOf(message)
val result = ArrayList<ChatMessage>(existing.size + 1)
var inserted = false
existing.forEach { current ->
if (!inserted && chatMessageDescComparator.compare(message, current) <= 0) {
result.add(message)
inserted = true
}
result.add(current)
}
if (!inserted) result.add(message)
return result
}
private fun normalizeMessagesDescendingIncremental(
previous: List<ChatMessage>,
incoming: List<ChatMessage>
): List<ChatMessage> {
if (incoming.isEmpty()) return emptyList()
val dedupedById = LinkedHashMap<String, ChatMessage>(incoming.size)
incoming.forEach { message -> dedupedById[message.id] = message }
if (previous.isNotEmpty() && dedupedById.size == previous.size) {
var unchanged = true
previous.forEach { message ->
if (dedupedById[message.id] != message) {
unchanged = false
return@forEach
}
}
if (unchanged && isSortedDescending(previous)) {
return previous
}
}
if (previous.isNotEmpty() && dedupedById.size == previous.size + 1) {
val previousIds = HashSet<String>(previous.size)
previous.forEach { previousIds.add(it.id) }
val addedIds = dedupedById.keys.filter { it !in previousIds }
if (addedIds.size == 1) {
var previousUnchanged = true
previous.forEach { message ->
if (dedupedById[message.id] != message) {
previousUnchanged = false
return@forEach
}
}
if (previousUnchanged) {
val addedMessage = dedupedById.getValue(addedIds.first())
return insertIntoSortedDescending(previous, addedMessage)
}
}
}
val normalized = ArrayList<ChatMessage>(dedupedById.values)
if (!isSortedDescending(normalized)) {
normalized.sortWith(chatMessageDescComparator)
}
return normalized
}
private fun buildMessagesWithDateHeaders(
sortedMessagesDesc: List<ChatMessage>
): List<Pair<ChatMessage, Boolean>> {
val result = ArrayList<Pair<ChatMessage, Boolean>>(sortedMessagesDesc.size)
for (i in sortedMessagesDesc.indices) {
val msg = sortedMessagesDesc[i]
val dateStr = _dateFmt.format(msg.timestamp)
val nextMsg = sortedMessagesDesc.getOrNull(i + 1)
val nextDateStr = nextMsg?.let { _dateFmt.format(it.timestamp) }
val showDate = nextDateStr == null || nextDateStr != dateStr
result.add(msg to showDate)
}
return result
}
private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? = private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator) messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
@@ -1227,7 +1314,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {} } else {}
// Парсим все attachments (IMAGE, FILE, AVATAR) // Парсим все attachments (IMAGE, FILE, AVATAR)
val parsedAttachments = parseAllAttachments(entity.attachments) val parsedAttachments =
normalizeIncomingCallAttachments(
parseAllAttachments(entity.attachments),
displayText
)
val finalAttachments =
if (parsedAttachments.isEmpty() && displayText.isBlank()) {
parseCallAttachmentFallback(entity.attachments, entity.messageId)?.let {
listOf(it)
} ?: parsedAttachments
} else {
parsedAttachments
}
val myKey = myPublicKey.orEmpty().trim() val myKey = myPublicKey.orEmpty().trim()
val senderKey = entity.fromPublicKey.trim() val senderKey = entity.fromPublicKey.trim()
@@ -1258,7 +1357,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}, },
replyData = if (forwardedMessages.isNotEmpty()) null else replyData, replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages, forwardedMessages = forwardedMessages,
attachments = parsedAttachments, attachments = finalAttachments,
chachaKey = entity.chachaKey, chachaKey = entity.chachaKey,
senderPublicKey = senderKey, senderPublicKey = senderKey,
senderName = senderName senderName = senderName
@@ -1377,6 +1476,130 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
private fun parseAttachmentType(attachment: JSONObject): AttachmentType {
val rawType = attachment.opt("type")
val typeValue =
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> AttachmentType.IMAGE.value
"messages", "reply", "forward" -> AttachmentType.MESSAGES.value
"file" -> AttachmentType.FILE.value
"avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value
else -> -1
}
}
else -> -1
}
return AttachmentType.fromInt(typeValue)
}
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim()
if (normalized.isEmpty()) return true
val tail = normalized.substringAfterLast("::", normalized).trim()
if (tail.toIntOrNull() != null) return true
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(normalized)
}
private fun normalizeIncomingCallAttachments(
attachments: List<MessageAttachment>,
messageText: String
): List<MessageAttachment> {
if (attachments.isEmpty() || messageText.isNotBlank() || attachments.size != 1) {
return attachments
}
val first = attachments.first()
if (first.blob.isNotBlank() || first.width > 0 || first.height > 0) {
return attachments
}
if (!isLikelyCallAttachmentPreview(first.preview)) {
return attachments
}
return when (first.type) {
AttachmentType.CALL -> attachments
else -> listOf(first.copy(type = AttachmentType.CALL))
}
}
private fun parseCallAttachmentFallback(
attachmentsJson: String,
fallbackId: String
): MessageAttachment? {
val array = parseAttachmentsJsonArray(attachmentsJson) ?: return null
if (array.length() != 1) return null
val first = array.optJSONObject(0) ?: return null
if (first.optString("blob", "").isNotBlank()) return null
if (first.optInt("width", 0) > 0 || first.optInt("height", 0) > 0) return null
val preview = first.optString("preview", "")
if (!isLikelyCallAttachmentPreview(preview)) return null
val transportObj = first.optJSONObject("transport")
val transportTag =
first.optString(
"transportTag",
first.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
first.optString(
"transportServer",
first.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
return MessageAttachment(
id = first.optString("id", "").ifBlank { "call-$fallbackId" },
blob = "",
type = AttachmentType.CALL,
preview = preview,
width = 0,
height = 0,
transportTag = transportTag,
transportServer = transportServer
)
}
private fun parseAttachmentsJsonArray(attachmentsJson: String): JSONArray? {
val normalized = attachmentsJson.trim()
if (normalized.isEmpty() || normalized == "[]") return null
val parsedDirectArray = runCatching { JSONArray(normalized) }.getOrNull()
if (parsedDirectArray != null) return parsedDirectArray
val parsedDirectObject = runCatching { JSONObject(normalized) }.getOrNull()
if (parsedDirectObject != null) {
return JSONArray().put(parsedDirectObject)
}
if (normalized.length >= 2 && normalized.first() == '"' && normalized.last() == '"') {
val unescaped =
runCatching { JSONObject("{\"v\":$normalized}").optString("v", "") }
.getOrDefault("")
.trim()
if (unescaped.isNotEmpty() && unescaped != normalized) {
runCatching { JSONArray(unescaped) }.getOrNull()?.let { return it }
runCatching { JSONObject(unescaped) }.getOrNull()?.let { return JSONArray().put(it) }
}
}
return null
}
/** /**
* Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для
* IMAGE - загружает blob из файловой системы если пустой в БД * IMAGE - загружает blob из файловой системы если пустой в БД
@@ -1387,25 +1610,54 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
return try { return try {
val attachments = JSONArray(attachmentsJson) val attachments = parseAttachmentsJsonArray(attachmentsJson) ?: return emptyList()
val result = mutableListOf<MessageAttachment>() val result = mutableListOf<MessageAttachment>()
val publicKey = myPublicKey ?: "" val publicKey = myPublicKey ?: ""
val privateKey = myPrivateKey ?: "" val privateKey = myPrivateKey ?: ""
for (i in 0 until attachments.length()) { for (i in 0 until attachments.length()) {
val attachment = attachments.getJSONObject(i) val attachment = attachments.getJSONObject(i)
val type = attachment.optInt("type", 0) val attachmentType = parseAttachmentType(attachment)
val preview = attachment.optString("preview", "")
val hasBlob = attachment.optString("blob", "").isNotBlank()
val hasSize = attachment.optInt("width", 0) > 0 || attachment.optInt("height", 0) > 0
val effectiveType =
if (attachmentType == AttachmentType.MESSAGES &&
!hasBlob &&
!hasSize &&
isLikelyCallAttachmentPreview(preview)
) {
AttachmentType.CALL
} else {
attachmentType
}
// Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно // Пропускаем MESSAGES (1) - это reply, обрабатывается отдельно
if (type == 1) continue if (effectiveType == AttachmentType.MESSAGES) continue
var blob = attachment.optString("blob", "") var blob = attachment.optString("blob", "")
val attachmentId = attachment.optString("id", "") val attachmentId = attachment.optString("id", "")
val attachmentType = AttachmentType.fromInt(type) val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой // 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
if ((attachmentType == AttachmentType.IMAGE || if ((effectiveType == AttachmentType.IMAGE ||
attachmentType == AttachmentType.AVATAR) && effectiveType == AttachmentType.AVATAR) &&
blob.isEmpty() && blob.isEmpty() &&
attachmentId.isNotEmpty() attachmentId.isNotEmpty()
) { ) {
@@ -1425,15 +1677,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = blob, blob = blob,
type = attachmentType, type = effectiveType,
preview = attachment.optString("preview", ""), preview = preview,
width = attachment.optInt("width", 0), width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0), height = attachment.optInt("height", 0),
localUri = localUri =
attachment.optString( attachment.optString(
"localUri", "localUri",
"" ""
) // 🔥 Поддержка localUri из БД ), // 🔥 Поддержка localUri из БД
transportTag = transportTag,
transportServer = transportServer
) )
) )
} }
@@ -1654,11 +1908,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "") val attLocalUri = attJson.optString("localUri", "")
val transportObj = attJson.optJSONObject("transport")
val attTransportTag =
attJson.optString(
"transportTag",
attJson.optString(
"transport_tag",
transportObj?.optString(
"transport_tag",
""
)
?: ""
)
)
val attTransportServer =
attJson.optString(
"transportServer",
attJson.optString(
"transport_server",
transportObj?.optString(
"transport_server",
""
)
?: ""
)
)
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment( fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview, id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight, blob = attBlob, width = attWidth, height = attHeight,
localUri = attLocalUri localUri = attLocalUri,
transportTag = attTransportTag,
transportServer = attTransportServer
)) ))
} }
} }
@@ -1736,6 +2017,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attWidth = attJson.optInt("width", 0) val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0) val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "") val attLocalUri = attJson.optString("localUri", "")
val transportObj = attJson.optJSONObject("transport")
val attTransportTag =
attJson.optString(
"transportTag",
attJson.optString(
"transport_tag",
transportObj?.optString(
"transport_tag",
""
)
?: ""
)
)
val attTransportServer =
attJson.optString(
"transportServer",
attJson.optString(
"transport_server",
transportObj?.optString(
"transport_server",
""
)
?: ""
)
)
if (attId.isNotEmpty()) { if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add( replyAttachmentsFromJson.add(
@@ -1746,7 +2052,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
blob = attBlob, blob = attBlob,
width = attWidth, width = attWidth,
height = attHeight, height = attHeight,
localUri = attLocalUri localUri = attLocalUri,
transportTag = attTransportTag,
transportServer = attTransportServer
) )
) )
} }
@@ -1964,21 +2272,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptResult.encryptedKey, encryptedKey = encryptResult.encryptedKey,
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey), aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
plainKeyAndNonce = encryptResult.plainKeyAndNonce, plainKeyAndNonce = encryptResult.plainKeyAndNonce,
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1), // Desktop parity: attachments are encrypted with key+nonce HEX password.
attachmentPassword =
encryptResult.plainKeyAndNonce.joinToString("") { "%02x".format(it) },
isGroup = false isGroup = false
) )
} }
} }
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String { private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return if (context.isGroup) { return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
} else {
val plainKeyAndNonce =
context.plainKeyAndNonce
?: throw IllegalStateException("Missing key+nonce for direct message")
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
}
} }
/** Обновить текст ввода */ /** Обновить текст ввода */
@@ -2504,8 +2807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob // 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
// Map: originalAttId → (newAttId, newPreview) — для подстановки в reply JSON // Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
val context = getApplication<Application>() val context = getApplication<Application>()
val isSaved = (sender == recipient) val isSaved = (sender == recipient)
@@ -2530,10 +2833,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob) uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
} }
val blurhash = att.preview.substringAfter("::", "") val blurhash = att.preview.substringAfter("::", att.preview)
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] = Pair(newAttId, newPreview) forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID // Сохраняем локально с новым ID
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments // publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -2558,10 +2873,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyMsgsToSend.forEach { msg -> replyMsgsToSend.forEach { msg ->
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
msg.attachments.forEach { att -> msg.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag) // Для forward IMAGE: подставляем НОВЫЙ id/preview/transport.
val fwdInfo = forwardedAttMap[att.id] val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.second ?: att.preview val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put( attachmentsArray.put(
JSONObject().apply { JSONObject().apply {
@@ -2575,6 +2892,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (att.type == AttachmentType.MESSAGES) att.blob if (att.type == AttachmentType.MESSAGES) att.blob
else "" else ""
) )
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
}
)
} }
) )
} }
@@ -2660,6 +2986,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("preview", att.preview) put("preview", att.preview)
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("transportTag", att.transportTag)
put("transportServer", att.transportServer)
// Только для MESSAGES сохраняем blob (reply // Только для MESSAGES сохраняем blob (reply
// data небольшие) // data небольшие)
// Для IMAGE/FILE - пустой blob // Для IMAGE/FILE - пустой blob
@@ -2759,7 +3087,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyAttachmentId = "reply_${timestamp}" val replyAttachmentId = "reply_${timestamp}"
fun buildForwardReplyJson( fun buildForwardReplyJson(
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(), forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
includeLocalUri: Boolean includeLocalUri: Boolean
): JSONArray { ): JSONArray {
val replyJsonArray = JSONArray() val replyJsonArray = JSONArray()
@@ -2767,8 +3095,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
fm.attachments.forEach { att -> fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id] val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.second ?: att.preview val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put( attachmentsArray.put(
JSONObject().apply { JSONObject().apply {
@@ -2778,6 +3108,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "") put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
}
)
if (includeLocalUri && att.localUri.isNotEmpty()) { if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri) put("localUri", att.localUri)
} }
@@ -2883,8 +3222,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag // 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview) // Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>() val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
var fwdIdx = 0 var fwdIdx = 0
for (fm in forwardMessages) { for (fm in forwardMessages) {
for (att in fm.attachments) { for (att in fm.attachments) {
@@ -2905,10 +3244,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob) uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
} }
val blurhash = att.preview.substringAfter("::", "") val blurhash = att.preview.substringAfter("::", att.preview)
val newPreview = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash val transportServer =
if (uploadTag.isNotEmpty()) {
forwardedAttMap[att.id] = Pair(newAttId, newPreview) TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID // Сохраняем локально с новым ID
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments // publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
@@ -3276,17 +3626,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "saved-messages mode: upload skipped") logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
} }
// Preview содержит tag::blurhash val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment = val imageAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", blob = "",
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = width, width = width,
height = height height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -3329,10 +3686,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") put("blob", "")
put("width", width) put("width", width)
put("height", height) put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -3459,18 +3818,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
} }
// Preview содержит tag::blurhash (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) {
if (uploadTag.isNotEmpty()) "$uploadTag::$blurhash" else blurhash TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = blurhash
val imageAttachment = val imageAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server! blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = width, width = width,
height = height height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -3509,10 +3874,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! put("blob", "") // Пустой blob - не сохраняем в БД!
put("width", width) put("width", width)
put("height", height) put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -3753,9 +4120,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"" ""
} }
val previewWithTag = val attachmentTransportServer =
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}" if (uploadTag.isNotEmpty()) {
else imageData.blurhash TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = imageData.blurhash
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
context = context, context = context,
@@ -3770,10 +4141,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attachmentId, id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob, blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = imageData.width, width = imageData.width,
height = imageData.height, height = imageData.height,
localUri = "" localUri = "",
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
networkAttachments.add(finalAttachment) networkAttachments.add(finalAttachment)
finalAttachmentsById[attachmentId] = finalAttachment finalAttachmentsById[attachmentId] = finalAttachment
@@ -3782,10 +4155,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") put("blob", "")
put("width", imageData.width) put("width", imageData.width)
put("height", imageData.height) put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -3959,12 +4334,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Загружаем на Transport Server // Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
val previewWithTag = val attachmentTransportServer =
if (uploadTag != null) { if (uploadTag.isNotEmpty()) {
"$uploadTag::${imageData.blurhash}" TransportManager.getTransportServer().orEmpty()
} else { } else {
imageData.blurhash ""
} }
val previewValue = imageData.blurhash
logPhotoPipeline( logPhotoPipeline(
messageId, messageId,
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}" "group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
@@ -3983,11 +4359,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
networkAttachments.add( networkAttachments.add(
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = if (uploadTag != null) "" else encryptedImageBlob, blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE, type = AttachmentType.IMAGE,
preview = previewWithTag, preview = previewValue,
width = imageData.width, width = imageData.width,
height = imageData.height height = imageData.height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
) )
@@ -3996,10 +4374,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.IMAGE.value) put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - изображения в файловой системе put("blob", "") // Пустой blob - изображения в файловой системе
put("width", imageData.width) put("width", imageData.width)
put("height", imageData.height) put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -4168,15 +4548,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob) uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob)
} }
// Preview содержит tag::size::name (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) "$uploadTag::$preview" else preview if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val previewValue = preview
val fileAttachment = val fileAttachment =
MessageAttachment( MessageAttachment(
id = attachmentId, id = attachmentId,
blob = "", // 🔥 Пустой blob - файл на Transport Server! blob = "", // 🔥 Пустой blob - файл на Transport Server!
type = AttachmentType.FILE, type = AttachmentType.FILE,
preview = previewWithTag preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
val packet = val packet =
@@ -4206,8 +4593,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", attachmentId) put("id", attachmentId)
put("type", AttachmentType.FILE.value) put("type", AttachmentType.FILE.value)
put("preview", previewWithTag) put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! put("blob", "") // Пустой blob - не сохраняем в БД!
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -4391,17 +4780,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Preview содержит tag::blurhash (как в desktop) val attachmentTransportServer =
val previewWithTag = if (uploadTag.isNotEmpty()) {
if (uploadTag.isNotEmpty()) "$uploadTag::$avatarBlurhash" TransportManager.getTransportServer().orEmpty()
else avatarBlurhash } else {
""
}
val previewValue = avatarBlurhash
val avatarAttachment = val avatarAttachment =
MessageAttachment( MessageAttachment(
id = avatarAttachmentId, id = avatarAttachmentId,
blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server! blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server!
type = AttachmentType.AVATAR, type = AttachmentType.AVATAR,
preview = previewWithTag preview = previewValue,
transportTag = uploadTag,
transportServer = attachmentTransportServer
) )
// 3. Отправляем пакет (с ПУСТЫМ blob!) // 3. Отправляем пакет (с ПУСТЫМ blob!)
@@ -4439,8 +4833,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
JSONObject().apply { JSONObject().apply {
put("id", avatarAttachmentId) put("id", avatarAttachmentId)
put("type", AttachmentType.AVATAR.value) put("type", AttachmentType.AVATAR.value)
put("preview", previewWithTag) // tag::blurhash put("preview", previewValue)
put("blob", "") // Пустой blob - не сохраняем в БД! put("blob", "") // Пустой blob - не сохраняем в БД!
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
} }
) )
} }
@@ -4641,14 +5037,51 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
// БД // БД
attachments = attachmentsJson, attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
replyToMessageId = null, replyToMessageId = null,
dialogKey = dialogKey dialogKey = dialogKey
) )
val insertedId = messageDao.insertMessage(entity) val insertedId = messageDao.insertMessage(entity)
searchIndexDao.upsert(
listOf(
com.rosetta.messenger.database.MessageSearchIndexEntity(
account = account,
messageId = finalMessageId,
dialogKey = dialogKey,
opponentKey = opponent.trim(),
timestamp = timestamp,
fromMe = if (isFromMe) 1 else 0,
plainText = text,
plainTextNormalized = text.lowercase(Locale.ROOT)
)
)
)
} catch (e: Exception) {} } catch (e: Exception) {}
} }
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
return try {
val array = parseAttachmentsJsonArray(attachmentsJson) ?: return -1
if (array.length() == 0) return -1
val first = array.optJSONObject(0) ?: return -1
val parsedType = parseAttachmentType(first)
if (parsedType != AttachmentType.UNKNOWN) {
parsedType.value
} else if (array.length() == 1 &&
isLikelyCallAttachmentPreview(first.optString("preview", ""))
) {
AttachmentType.CALL.value
} else {
-1
}
} catch (_: Throwable) {
-1
}
}
private fun showTypingIndicator() { private fun showTypingIndicator() {
_opponentTyping.value = true _opponentTyping.value = true
// Отменяем предыдущий таймер, чтобы избежать race condition // Отменяем предыдущий таймер, чтобы избежать race condition

View File

@@ -63,10 +63,15 @@ import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.DeviceEntry import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
@@ -260,11 +265,15 @@ fun ChatsListScreen(
onRequestsClick: () -> Unit = {}, onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit, onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar", backgroundBlurColorId: String = "avatar",
pinnedChats: Set<String> = emptySet(), pinnedChats: Set<String> = emptySet(),
onTogglePin: (String) -> Unit = {}, onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
callUiState: CallUiState = CallUiState(),
isCallOverlayExpanded: Boolean = true,
onOpenCallOverlay: () -> Unit = {},
onAddAccount: () -> Unit = {}, onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {}, onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {} onDeleteAccountFromSidebar: (String) -> Unit = {}
@@ -283,9 +292,6 @@ fun ChatsListScreen(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sduUpdateState by UpdateManager.updateState.collectAsState() val sduUpdateState by UpdateManager.updateState.collectAsState()
val sduDownloadProgress by UpdateManager.downloadProgress.collectAsState()
val sduDebugLogs by UpdateManager.debugLogs.collectAsState()
var showSduLogs by remember { mutableStateOf(false) }
val themeRevealRadius = remember { Animatable(0f) } val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) } var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) } var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
@@ -294,73 +300,6 @@ fun ChatsListScreen(
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) } var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) } var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
// ═══════════════ SDU Debug Log Dialog ═══════════════
if (showSduLogs) {
AlertDialog(
onDismissRequest = { showSduLogs = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("SDU Logs", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.weight(1f))
Text(
"state: ${sduUpdateState::class.simpleName}",
fontSize = 11.sp,
color = Color.Gray
)
}
},
text = {
val scrollState = rememberScrollState()
LaunchedEffect(sduDebugLogs.size) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(scrollState)
) {
if (sduDebugLogs.isEmpty()) {
Text(
"Нет логов. SDU ещё не инициализирован\nили пакет 0x0A не пришёл.",
fontSize = 13.sp,
color = Color.Gray
)
} else {
sduDebugLogs.forEach { line ->
Text(
text = line,
fontSize = 11.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = when {
"ERROR" in line || "EXCEPTION" in line -> Color(0xFFFF5555)
"WARNING" in line -> Color(0xFFFFAA33)
"State ->" in line -> Color(0xFF55BB55)
else -> if (isDarkTheme) Color(0xFFCCCCCC) else Color(0xFF333333)
},
modifier = Modifier.padding(vertical = 1.dp)
)
}
}
}
},
confirmButton = {
Row {
TextButton(onClick = {
// Retry: force re-request SDU
UpdateManager.requestSduServer()
}) {
Text("Retry SDU")
}
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = { showSduLogs = false }) {
Text("Close")
}
}
}
)
}
fun startThemeReveal() { fun startThemeReveal() {
if (themeRevealActive) { if (themeRevealActive) {
return return
@@ -477,6 +416,7 @@ fun ChatsListScreen(
} }
.sortedByDescending { it.progress } .sortedByDescending { it.progress }
} }
val database = remember(context) { RosettaDatabase.getDatabase(context) }
val activeFileDownloads = remember(accountFileDownloads) { val activeFileDownloads = remember(accountFileDownloads) {
accountFileDownloads.filter { accountFileDownloads.filter {
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED || it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
@@ -520,14 +460,19 @@ fun ChatsListScreen(
// 📬 Requests screen state // 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) } var showRequestsScreen by remember { mutableStateOf(false) }
var showCallsScreen by remember { mutableStateOf(false) }
var showCallsMenu by remember { mutableStateOf(false) }
var showDownloadsScreen by remember { mutableStateOf(false) } var showDownloadsScreen by remember { mutableStateOf(false) }
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) } var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
var isInlineCallsTransitionLocked by remember { mutableStateOf(false) }
var isRequestsRouteTapLocked by remember { mutableStateOf(false) } var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
val inlineRequestsTransitionLockMs = 340L val inlineRequestsTransitionLockMs = 340L
val inlineCallsTransitionLockMs = 340L
val requestsRouteTapLockMs = 420L val requestsRouteTapLockMs = 420L
fun setInlineRequestsVisible(visible: Boolean) { fun setInlineRequestsVisible(visible: Boolean) {
if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return
if (visible) showCallsScreen = false
isInlineRequestsTransitionLocked = true isInlineRequestsTransitionLocked = true
showRequestsScreen = visible showRequestsScreen = visible
scope.launch { scope.launch {
@@ -536,6 +481,52 @@ fun ChatsListScreen(
} }
} }
fun setInlineCallsVisible(visible: Boolean) {
if (showCallsScreen == visible || isInlineCallsTransitionLocked) return
if (visible) showRequestsScreen = false
isInlineCallsTransitionLocked = true
showCallsScreen = visible
if (!visible) showCallsMenu = false
scope.launch {
kotlinx.coroutines.delay(inlineCallsTransitionLockMs)
isInlineCallsTransitionLocked = false
}
}
suspend fun clearAllCallsHistory(): Int {
if (accountPublicKey.isBlank()) return 0
val messageDao = database.messageDao()
val dialogDao = database.dialogDao()
val peers = messageDao.getCallHistoryPeers(accountPublicKey).map { it.trim() }
.filter { it.isNotBlank() }.distinct()
val deletedCount = messageDao.deleteAllCallMessages(accountPublicKey)
if (deletedCount <= 0) return 0
peers.forEach { peerKey ->
val dialogKey =
if (accountPublicKey == peerKey) {
accountPublicKey
} else if (accountPublicKey < peerKey) {
"$accountPublicKey:$peerKey"
} else {
"$peerKey:$accountPublicKey"
}
val remaining = messageDao.getMessageCount(accountPublicKey, dialogKey)
if (remaining > 0) {
if (peerKey == accountPublicKey) {
dialogDao.updateSavedMessagesDialogFromMessages(accountPublicKey)
} else {
dialogDao.updateDialogFromMessages(accountPublicKey, peerKey)
}
} else {
dialogDao.deleteDialog(accountPublicKey, peerKey)
}
}
return deletedCount
}
fun openRequestsRouteSafely() { fun openRequestsRouteSafely() {
if (isRequestsRouteTapLocked) return if (isRequestsRouteTapLocked) return
isRequestsRouteTapLocked = true isRequestsRouteTapLocked = true
@@ -548,6 +539,7 @@ fun ChatsListScreen(
LaunchedEffect(currentAccountKey) { LaunchedEffect(currentAccountKey) {
showDownloadsScreen = false showDownloadsScreen = false
showCallsScreen = false
} }
// 📂 Accounts section expanded state (arrow toggle) // 📂 Accounts section expanded state (arrow toggle)
@@ -571,6 +563,7 @@ fun ChatsListScreen(
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) } var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
var showDeleteCallsDialog by remember { mutableStateOf(false) }
var deviceResolveRequest by var deviceResolveRequest by
remember { remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null) mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
@@ -587,9 +580,11 @@ fun ChatsListScreen(
// Back: drawer → закрыть, selection → сбросить // Back: drawer → закрыть, selection → сбросить
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно // Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) { BackHandler(enabled = showDownloadsScreen || showCallsScreen || isSelectionMode || drawerState.isOpen) {
if (showDownloadsScreen) { if (showDownloadsScreen) {
showDownloadsScreen = false showDownloadsScreen = false
} else if (showCallsScreen) {
setInlineCallsVisible(false)
} else if (isSelectionMode) { } else if (isSelectionMode) {
selectedChatKeys = emptySet() selectedChatKeys = emptySet()
} else if (drawerState.isOpen) { } else if (drawerState.isOpen) {
@@ -607,6 +602,7 @@ fun ChatsListScreen(
// Requests count for badge on hamburger & sidebar // Requests count for badge on hamburger & sidebar
val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount val topLevelRequestsCount = topLevelChatsState.requestsCount
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
@@ -766,7 +762,7 @@ fun ChatsListScreen(
) { ) {
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen, gesturesEnabled = !showRequestsScreen && !showDownloadsScreen && !showCallsScreen,
drawerContent = { drawerContent = {
ModalDrawerSheet( ModalDrawerSheet(
drawerContainerColor = Color.Transparent, drawerContainerColor = Color.Transparent,
@@ -1194,6 +1190,23 @@ fun ChatsListScreen(
} }
) )
// 📞 Calls
DrawerMenuItemEnhanced(
icon = TablerIcons.Phone,
text = "Calls",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(100)
setInlineCallsVisible(true)
onCallsClick()
}
}
)
// 👥 New Group // 👥 New Group
DrawerMenuItemEnhanced( DrawerMenuItemEnhanced(
icon = TablerIcons.Users, icon = TablerIcons.Users,
@@ -1413,6 +1426,7 @@ fun ChatsListScreen(
key( key(
isDarkTheme, isDarkTheme,
showRequestsScreen, showRequestsScreen,
showCallsScreen,
showDownloadsScreen, showDownloadsScreen,
isSelectionMode isSelectionMode
) { ) {
@@ -1553,11 +1567,15 @@ fun ChatsListScreen(
// ═══ NORMAL HEADER ═══ // ═══ NORMAL HEADER ═══
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
if (showRequestsScreen || showDownloadsScreen) { if (showRequestsScreen || showDownloadsScreen || showCallsScreen) {
IconButton( IconButton(
onClick = { onClick = {
if (showDownloadsScreen) { if (showDownloadsScreen) {
showDownloadsScreen = false showDownloadsScreen = false
} else if (showCallsScreen) {
setInlineCallsVisible(
false
)
} else { } else {
setInlineRequestsVisible( setInlineRequestsVisible(
false false
@@ -1650,6 +1668,13 @@ fun ChatsListScreen(
fontSize = 20.sp, fontSize = 20.sp,
color = Color.White color = Color.White
) )
} else if (showCallsScreen) {
Text(
"Calls",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White
)
} else if (showRequestsScreen) { } else if (showRequestsScreen) {
Text( Text(
"Requests", "Requests",
@@ -1689,7 +1714,50 @@ fun ChatsListScreen(
} }
}, },
actions = { actions = {
if (!showRequestsScreen && !showDownloadsScreen) { if (showCallsScreen) {
Box {
IconButton(
onClick = {
showCallsMenu = true
}
) {
Icon(
TablerIcons.DotsVertical,
contentDescription = "Calls menu",
tint = Color.White
)
}
DropdownMenu(
expanded = showCallsMenu,
onDismissRequest = {
showCallsMenu = false
},
modifier = Modifier.background(
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
)
) {
DropdownMenuItem(
text = {
Text(
"Delete all calls",
color = if (isDarkTheme) Color.White else Color.Black
)
},
onClick = {
showCallsMenu = false
showDeleteCallsDialog = true
},
leadingIcon = {
Icon(
imageVector = TablerIcons.Trash,
contentDescription = null,
tint = Color(0xFFE55A5A)
)
}
)
}
}
} else if (!showRequestsScreen && !showDownloadsScreen) {
// 📥 Animated download indicator (Telegram-style) // 📥 Animated download indicator (Telegram-style)
Box( Box(
modifier = modifier =
@@ -1803,8 +1871,8 @@ fun ChatsListScreen(
// Это предотвращает "дергание" UI когда dialogs и requests // Это предотвращает "дергание" UI когда dialogs и requests
// обновляются // обновляются
// независимо // независимо
val chatsState by chatsViewModel.chatsState.collectAsState() val chatsState = topLevelChatsState
val isLoading by chatsViewModel.isLoading.collectAsState() val isLoading = topLevelIsLoading
val requests = chatsState.requests val requests = chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = chatsState.requestsCount
@@ -1897,6 +1965,48 @@ fun ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} else {
// 🎬 Animated content transition between main list and
// calls
AnimatedContent(
targetState = showCallsScreen,
transitionSpec = {
if (targetState) {
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeOut(
animationSpec = tween(150)
)
} else {
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeOut(
animationSpec = tween(150)
)
}
},
label = "CallsTransition"
) { isCallsScreen ->
if (isCallsScreen) {
CallsRouteContent(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onUserSelect = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onSearchClick,
onBack = { setInlineCallsVisible(false) }
)
} else { } else {
// 🎬 Animated content transition between main list and // 🎬 Animated content transition between main list and
// requests // requests
@@ -1932,110 +2042,42 @@ fun ChatsListScreen(
label = "RequestsTransition" label = "RequestsTransition"
) { isRequestsScreen -> ) { isRequestsScreen ->
if (isRequestsScreen) { if (isRequestsScreen) {
// 📬 Show Requests Screen with swipe-back RequestsRouteContent(
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
velocityTracker.addPosition(down.uptimeMillis, down.position)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!claimed) {
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (distance < touchSlop) continue
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
setInlineRequestsVisible(
false
)
}
}
}
}
) {
RequestsScreen(
requests = requests, requests = requests,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { avatarRepository = avatarRepository,
setInlineRequestsVisible(
false
)
},
onRequestClick = { request ->
val user =
chatsViewModel
.dialogToSearchUser(
request
)
onUserSelect(user)
},
avatarRepository =
avatarRepository,
blockedUsers = blockedUsers, blockedUsers = blockedUsers,
pinnedChats = pinnedChats, pinnedChats = pinnedChats,
isDrawerOpen = isDrawerOpen =
drawerState.isOpen || drawerState.isOpen ||
drawerState drawerState.isAnimationRunning,
.isAnimationRunning, onTogglePin = onTogglePin,
onTogglePin = { opponentKey ->
onTogglePin(opponentKey)
},
onDeleteDialog = { opponentKey -> onDeleteDialog = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.deleteDialog(opponentKey)
.deleteDialog(
opponentKey
)
} }
}, },
onBlockUser = { opponentKey -> onBlockUser = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.blockUser(opponentKey)
.blockUser(
opponentKey
)
} }
}, },
onUnblockUser = { opponentKey -> onUnblockUser = { opponentKey ->
scope.launch { scope.launch {
chatsViewModel chatsViewModel.unblockUser(opponentKey)
.unblockUser(
opponentKey
)
} }
},
onRequestClick = { request ->
val user =
chatsViewModel.dialogToSearchUser(
request
)
onUserSelect(user)
},
onBack = {
setInlineRequestsVisible(false)
} }
) )
} // Close Box wrapper
} else if (showSkeleton) { } else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme) ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (isLoading && chatsState.isEmpty) { } else if (isLoading && chatsState.isEmpty) {
@@ -2059,29 +2101,36 @@ fun ChatsListScreen(
Color(0xFF1A1A1A) Color(0xFF1A1A1A)
else Color(0xFFF2F2F7) else Color(0xFFF2F2F7)
} }
val showStickyCallBanner =
remember(callUiState, isCallOverlayExpanded) {
callUiState.isVisible &&
!isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
}
val callBannerHeight = 40.dp
// 🔥 Берем dialogs из chatsState для // 🔥 Берем dialogs из chatsState для
// консистентности // консистентности
// 📌 Сортируем: pinned сначала, потом по // 📌 Порядок по времени готовится в ViewModel.
// времени // Здесь поднимаем pinned наверх без полного sort/distinct.
val currentDialogs = val currentDialogs =
remember( remember(
chatsState.dialogs, chatsState.dialogs,
pinnedChats pinnedChats
) { ) {
chatsState.dialogs val pinned = ArrayList<DialogUiModel>()
.distinctBy { it.opponentKey } val regular = ArrayList<DialogUiModel>()
.sortedWith( chatsState.dialogs.forEach { dialog ->
compareByDescending< if (
DialogUiModel> { pinnedChats.contains(
pinnedChats dialog.opponentKey
.contains(
it.opponentKey
) )
) {
pinned.add(dialog)
} else {
regular.add(dialog)
} }
.thenByDescending {
it.lastMessageTimestamp
} }
) pinned + regular
} }
// Telegram-style: only one item can be // Telegram-style: only one item can be
@@ -2250,10 +2299,21 @@ fun ChatsListScreen(
} }
} }
Box(
modifier =
Modifier.fillMaxSize()
.background(listBackgroundColor)
) {
LazyColumn( LazyColumn(
state = chatListState, state = chatListState,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
)
.then( .then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll) if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
else Modifier else Modifier
@@ -2591,8 +2651,20 @@ fun ChatsListScreen(
} }
} }
} }
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
} }
} // Close AnimatedContent }
}
} // Close Requests AnimatedContent
} // Close calls/main switch
} // Close Calls AnimatedContent
} // Close downloads/main content switch } // Close downloads/main content switch
} // Close Downloads AnimatedContent } // Close Downloads AnimatedContent
@@ -2604,6 +2676,56 @@ fun ChatsListScreen(
// 🔥 Confirmation Dialogs // 🔥 Confirmation Dialogs
if (showDeleteCallsDialog) {
AlertDialog(
onDismissRequest = { showDeleteCallsDialog = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Delete all calls",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"This will remove all call records from history. Chats and contacts will stay unchanged.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
showDeleteCallsDialog = false
scope.launch {
val deleted = clearAllCallsHistory()
if (deleted > 0) {
Toast.makeText(
context,
"Deleted $deleted call records",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Call history is already empty",
Toast.LENGTH_SHORT
).show()
}
}
}
) {
Text("Delete", color = Color(0xFFE55A5A))
}
},
dismissButton = {
TextButton(onClick = { showDeleteCallsDialog = false }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
// Delete Dialog Confirmation // Delete Dialog Confirmation
if (dialogsToDelete.isNotEmpty()) { if (dialogsToDelete.isNotEmpty()) {
val count = dialogsToDelete.size val count = dialogsToDelete.size
@@ -4645,6 +4767,135 @@ fun TypingIndicatorSmall() {
} }
} }
@Composable
private fun SwipeBackContainer(
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier =
modifier.fillMaxSize().pointerInput(onBack) {
val velocityTracker = VelocityTracker()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
velocityTracker.addPosition(down.uptimeMillis, down.position)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull { it.id == down.id }
?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
if (!claimed) {
val distance =
kotlin.math.sqrt(
totalDragX * totalDragX +
totalDragY * totalDragY
)
if (distance < touchSlop) continue
if (
totalDragX > 0 &&
kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY) * 1.2f
) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (
totalDragX > screenWidth * 0.08f ||
velocityX > 200f
) {
onBack()
}
}
}
}
) {
content()
}
}
@Composable
private fun CallsRouteContent(
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit,
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit,
onStartNewCall: () -> Unit,
onBack: () -> Unit
) {
SwipeBackContainer(onBack = onBack) {
CallsHistoryScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onOpenChat = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onStartNewCall,
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun RequestsRouteContent(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository?,
blockedUsers: Set<String>,
pinnedChats: Set<String>,
isDrawerOpen: Boolean,
onTogglePin: (String) -> Unit,
onDeleteDialog: (String) -> Unit,
onBlockUser: (String) -> Unit,
onUnblockUser: (String) -> Unit,
onRequestClick: (DialogUiModel) -> Unit,
onBack: () -> Unit
) {
SwipeBackContainer(onBack = onBack) {
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = onRequestClick,
avatarRepository = avatarRepository,
blockedUsers = blockedUsers,
pinnedChats = pinnedChats,
isDrawerOpen = isDrawerOpen,
onTogglePin = onTogglePin,
onDeleteDialog = onDeleteDialog,
onBlockUser = onBlockUser,
onUnblockUser = onUnblockUser
)
}
}
/** 📬 Секция Requests — Telegram Archived Chats style */ /** 📬 Секция Requests — Telegram Archived Chats style */
@Composable @Composable
fun RequestsSection( fun RequestsSection(

View File

@@ -14,14 +14,15 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/** UI модель диалога с расшифрованным lastMessage */ /** UI модель диалога с расшифрованным lastMessage */
@Immutable @Immutable
@@ -80,6 +81,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл // 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>() private val subscribedOnlineKeys = mutableSetOf<String>()
private data class DialogUiCacheEntry(
val signature: Int,
val model: DialogUiModel
)
private val dialogsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
private val requestsUiCache = LinkedHashMap<String, DialogUiCacheEntry>()
// Job для отмены подписок при смене аккаунта // Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null private var accountSubscriptionsJob: Job? = null
@@ -147,19 +156,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
val senderKey = val senderKey =
if (dialog.lastMessageFromMe == 1) { dialog.lastSenderKey.trim().ifBlank {
currentAccount if (dialog.lastMessageFromMe == 1) currentAccount else ""
} else {
val lastMessage =
try {
dialogDao.getLastMessageByDialogKey(
account = dialog.account,
dialogKey = dialog.opponentKey.trim()
)
} catch (_: Exception) {
null
}
lastMessage?.fromPublicKey.orEmpty()
} }
if (senderKey.isBlank()) return null if (senderKey.isBlank()) return null
@@ -226,6 +224,124 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return null return null
} }
private fun buildDialogSignature(dialog: com.rosetta.messenger.database.DialogEntity): Int {
var result = dialog.id.hashCode()
result = 31 * result + dialog.account.hashCode()
result = 31 * result + dialog.opponentKey.hashCode()
result = 31 * result + dialog.opponentTitle.hashCode()
result = 31 * result + dialog.opponentUsername.hashCode()
result = 31 * result + dialog.lastMessage.hashCode()
result = 31 * result + dialog.lastMessageTimestamp.hashCode()
result = 31 * result + dialog.unreadCount
result = 31 * result + dialog.isOnline
result = 31 * result + dialog.lastSeen.hashCode()
result = 31 * result + dialog.verified
result = 31 * result + dialog.lastMessageFromMe
result = 31 * result + dialog.lastMessageDelivered
result = 31 * result + dialog.lastMessageRead
result = 31 * result + dialog.lastMessageAttachments.hashCode()
return result
}
private fun shouldRequestDialogUserInfo(
dialog: com.rosetta.messenger.database.DialogEntity,
isRequestsFlow: Boolean
): Boolean {
val title = dialog.opponentTitle
if (isRequestsFlow) {
return title.isEmpty() || title == dialog.opponentKey
}
return title.isEmpty() ||
title == dialog.opponentKey ||
title == dialog.opponentKey.take(7) ||
title == dialog.opponentKey.take(8)
}
private fun normalizeDialogList(dialogs: List<DialogUiModel>): List<DialogUiModel> {
if (dialogs.isEmpty()) return emptyList()
val deduped = LinkedHashMap<String, DialogUiModel>(dialogs.size)
dialogs.forEach { dialog ->
if (!deduped.containsKey(dialog.opponentKey)) {
deduped[dialog.opponentKey] = dialog
}
}
return deduped.values.sortedByDescending { it.lastMessageTimestamp }
}
private suspend fun mapDialogListIncremental(
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
privateKey: String,
cache: LinkedHashMap<String, DialogUiCacheEntry>,
isRequestsFlow: Boolean
): List<DialogUiModel> {
return withContext(Dispatchers.Default) {
val activeKeys = HashSet<String>(dialogsList.size)
val mapped = ArrayList<DialogUiModel>(dialogsList.size)
dialogsList.forEach { dialog ->
val cacheKey = dialog.opponentKey
activeKeys.add(cacheKey)
val signature = buildDialogSignature(dialog)
val cached = cache[cacheKey]
if (cached != null && cached.signature == signature) {
mapped.add(cached.model)
return@forEach
}
val isSavedMessages = dialog.account == dialog.opponentKey
if (!isSavedMessages && shouldRequestDialogUserInfo(dialog, isRequestsFlow)) {
if (isRequestsFlow) {
loadUserInfoForRequest(dialog.opponentKey)
} else {
loadUserInfoForDialog(dialog.opponentKey)
}
}
val decryptedLastMessage =
decryptDialogPreview(
encryptedLastMessage = dialog.lastMessage,
privateKey = privateKey
)
val attachmentType =
resolveAttachmentType(
attachmentType = dialog.lastMessageAttachmentType,
decryptedLastMessage = decryptedLastMessage,
lastMessageAttachments = dialog.lastMessageAttachments
)
val groupLastSenderInfo = resolveGroupLastSenderInfo(dialog)
val model =
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp = dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages = isSavedMessages,
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered = dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType = attachmentType,
lastMessageSenderPrefix = groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey = groupLastSenderInfo?.senderKey
)
cache[cacheKey] = DialogUiCacheEntry(signature = signature, model = model)
mapped.add(model)
}
cache.keys.retainAll(activeKeys)
normalizeDialogList(mapped)
}
}
/** Установить текущий аккаунт и загрузить диалоги */ /** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) { fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis() val setAccountStart = System.currentTimeMillis()
@@ -241,6 +357,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear() subscribedOnlineKeys.clear()
dialogsUiCache.clear()
requestsUiCache.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта // 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing // чтобы избежать показа сообщений с неправильным isOutgoing
@@ -280,104 +398,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList -> .map { dialogsList ->
val mapStart = System.currentTimeMillis() mapDialogListIncremental(
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений dialogsList = dialogsList,
withContext(Dispatchers.Default) { privateKey = privateKey,
dialogsList cache = dialogsUiCache,
.map { dialog -> isRequestsFlow = false
async {
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages =
(dialog.account == dialog.opponentKey)
if (!isSavedMessages &&
(dialog.opponentTitle.isEmpty() ||
dialog.opponentTitle ==
dialog.opponentKey ||
dialog.opponentTitle ==
dialog.opponentKey.take(
7
) ||
dialog.opponentTitle ==
dialog.opponentKey.take(
8
))
) {
loadUserInfoForDialog(dialog.opponentKey)
}
// Безопасная дешифровка превью: никогда не показываем raw ciphertext.
val decryptedLastMessage =
decryptDialogPreview(
encryptedLastMessage =
dialog.lastMessage,
privateKey = privateKey
) )
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// DialogEntity
// Статус и attachments уже записаны в dialogs через
// updateDialogFromMessages()
// Это устраняет N+1 проблему (ранее: 2 запроса на
// каждый диалог)
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
resolveAttachmentType(
attachmentsJson =
dialog.lastMessageAttachments,
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle = dialog.opponentTitle,
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
isSavedMessages, // 📁 Saved Messages
lastMessageFromMe =
dialog.lastMessageFromMe, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageDelivered =
dialog.lastMessageDelivered, // 🚀 Из
// DialogEntity (денормализовано)
lastMessageRead =
dialog.lastMessageRead, // 🚀 Из
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
.awaitAll()
}
.also {
val mapTime = System.currentTimeMillis() - mapStart
}
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
// Deduplicate by opponentKey to prevent LazyColumn crash _dialogs.value = decryptedDialogs
// (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
@@ -400,83 +430,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления .debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList -> .map { requestsList ->
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка mapDialogListIncremental(
withContext(Dispatchers.Default) { dialogsList = requestsList,
requestsList privateKey = privateKey,
.map { dialog -> cache = requestsUiCache,
async { isRequestsFlow = true
// 🔥 Загружаем информацию о пользователе если её нет
// 📁 НЕ загружаем для Saved Messages
val isSavedMessages =
(dialog.account == dialog.opponentKey)
if (!isSavedMessages &&
(dialog.opponentTitle.isEmpty() ||
dialog.opponentTitle ==
dialog.opponentKey)
) {
loadUserInfoForRequest(dialog.opponentKey)
}
// Безопасная дешифровка превью: никогда не показываем raw ciphertext.
val decryptedLastMessage =
decryptDialogPreview(
encryptedLastMessage =
dialog.lastMessage,
privateKey = privateKey
) )
// 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity
val attachmentType =
resolveAttachmentType(
attachmentsJson =
dialog.lastMessageAttachments,
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
account = dialog.account,
opponentKey = dialog.opponentKey,
opponentTitle =
dialog.opponentTitle, // 🔥 Показываем
// имя как в
// обычных чатах
opponentUsername = dialog.opponentUsername,
lastMessage = decryptedLastMessage,
lastMessageTimestamp =
dialog.lastMessageTimestamp,
unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen,
verified = dialog.verified,
isSavedMessages =
(dialog.account ==
dialog.opponentKey), // 📁 Saved
// Messages
lastMessageFromMe = dialog.lastMessageFromMe,
lastMessageDelivered =
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType =
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
.awaitAll()
}
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> .collect { decryptedRequests -> _requests.value = decryptedRequests }
_requests.value = decryptedRequests.distinctBy { it.opponentKey }
}
} }
// 📊 Подписываемся на количество requests // 📊 Подписываемся на количество requests
@@ -584,18 +546,13 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
private fun resolveAttachmentType( private fun resolveAttachmentType(
attachmentsJson: String, attachmentType: Int,
decryptedLastMessage: String decryptedLastMessage: String,
lastMessageAttachments: String
): String? { ): String? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
return when (attachmentType) {
return try { 0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
val attachments = org.json.JSONArray(attachmentsJson)
if (attachments.length() == 0) return null
val firstAttachment = attachments.getJSONObject(0)
when (firstAttachment.optInt("type", -1)) {
0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> { 1 -> {
// AttachmentType.MESSAGES = 1 (Reply/Forward). // AttachmentType.MESSAGES = 1 (Reply/Forward).
// Если текст пустой — показываем "Forwarded" как в desktop. // Если текст пустой — показываем "Forwarded" как в desktop.
@@ -604,19 +561,64 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
2 -> "File" // AttachmentType.FILE = 2 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4 4 -> "Call" // AttachmentType.CALL = 4
else -> null else -> if (inferredCall) "Call" else null
}
} catch (e: Exception) {
// Fallback: если JSON поврежден, но видно MESSAGES attachment и текста нет — это forward.
val hasMessagesType =
attachmentsJson.contains("\"type\":1") ||
attachmentsJson.contains("\"type\": 1")
if (decryptedLastMessage.isEmpty() && hasMessagesType) {
"Forwarded"
} else {
null
} }
} }
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
return try {
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return false
if (attachments.length() != 1) return false
val first = attachments.optJSONObject(0) ?: return false
val rawType = first.opt("type")
val typeValue =
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"call" -> 4
else -> -1
}
}
else -> -1
}
if (typeValue == 4) return true
val preview = first.optString("preview", "").trim()
if (preview.isEmpty()) return true
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
} catch (_: Throwable) {
false
}
}
private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? {
val normalized = rawAttachments.trim()
if (normalized.isEmpty() || normalized == "[]") return null
runCatching { JSONArray(normalized) }.getOrNull()?.let { return it }
runCatching { JSONObject(normalized) }.getOrNull()?.let { return JSONArray().put(it) }
if (normalized.length >= 2 && normalized.first() == '"' && normalized.last() == '"') {
val unescaped =
runCatching { JSONObject("{\"v\":$normalized}").optString("v", "") }
.getOrDefault("")
.trim()
if (unescaped.isNotEmpty() && unescaped != normalized) {
runCatching { JSONArray(unescaped) }.getOrNull()?.let { return it }
runCatching { JSONObject(unescaped) }.getOrNull()?.let { return JSONArray().put(it) }
}
}
return null
} }
private fun isLikelyEncryptedPayload(value: String): Boolean { private fun isLikelyEncryptedPayload(value: String): Boolean {
@@ -701,6 +703,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requests.value = _requests.value.filter { it.opponentKey != opponentKey } _requests.value = _requests.value.filter { it.opponentKey != opponentKey }
// 🔥 Обновляем счетчик requests // 🔥 Обновляем счетчик requests
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
dialogsUiCache.remove(opponentKey)
requestsUiCache.remove(opponentKey)
// Вычисляем правильный dialog_key // Вычисляем правильный dialog_key
val dialogKey = val dialogKey =
@@ -760,6 +764,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey } _dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey } _requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey) MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey) ChatViewModel.clearCacheForOpponent(groupPublicKey)
} }

View File

@@ -2293,6 +2293,23 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
val attachment = attachments.getJSONObject(i) val attachment = attachments.getJSONObject(i)
val type = AttachmentType.fromInt(attachment.optInt("type", 0)) val type = AttachmentType.fromInt(attachment.optInt("type", 0))
if (type == AttachmentType.MESSAGES) continue if (type == AttachmentType.MESSAGES) continue
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
add( add(
MessageAttachment( MessageAttachment(
@@ -2302,7 +2319,9 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
preview = attachment.optString("preview", ""), preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0), width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0), height = attachment.optInt("height", 0),
localUri = attachment.optString("localUri", "") localUri = attachment.optString("localUri", ""),
transportTag = transportTag,
transportServer = transportServer
) )
) )
} }

View File

@@ -45,7 +45,7 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.MessageSearchIndexEntity
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -63,9 +63,6 @@ import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
@@ -1005,9 +1002,10 @@ private fun MessagesTabContent(
} }
// Persistent decryption cache: messageId → plaintext (survives re-queries) // Persistent decryption cache: messageId → plaintext (survives re-queries)
val decryptCache = remember { ConcurrentHashMap<String, String>(512) } val decryptCache = remember(currentUserPublicKey) { ConcurrentHashMap<String, String>(512) }
// Cache for dialog metadata: opponentKey → (title, username, verified) // Cache for dialog metadata: opponentKey → (title, username, verified)
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() } val dialogCache =
remember(currentUserPublicKey) { ConcurrentHashMap<String, Triple<String, String, Int>>() }
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
@@ -1044,67 +1042,66 @@ private fun MessagesTabContent(
} }
val queryLower = searchQuery.trim().lowercase() val queryLower = searchQuery.trim().lowercase()
val matched = mutableListOf<MessageSearchResult>()
val semaphore = Semaphore(4)
val batchSize = 200
var offset = 0
val maxMessages = 5000 // Safety cap
val maxResults = 50 // Don't return more than 50 matches val maxResults = 50 // Don't return more than 50 matches
val batchSize = 200
val indexDao = db.messageSearchIndexDao()
while (offset < maxMessages && matched.size < maxResults) { fun toUiResult(item: MessageSearchIndexEntity): MessageSearchResult {
val batch = db.messageDao().getAllMessagesPaged( val normalized = item.opponentKey.trim()
currentUserPublicKey, batchSize, offset
)
if (batch.isEmpty()) break
// Decrypt in parallel, filter by query
val batchResults = kotlinx.coroutines.coroutineScope {
batch.chunked(20).flatMap { chunk ->
chunk.map { msg ->
async {
semaphore.withPermit {
val cached = decryptCache[msg.messageId]
val plain = if (cached != null) {
cached
} else {
val decrypted = try {
CryptoManager.decryptWithPassword(
msg.plainMessage, privateKey
)
} catch (_: Exception) { null }
if (!decrypted.isNullOrBlank()) {
decryptCache[msg.messageId] = decrypted
}
decrypted
}
if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) {
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
val normalized = opponent.trim()
val meta = dialogCache[normalized] val meta = dialogCache[normalized]
MessageSearchResult( return MessageSearchResult(
messageId = msg.messageId, messageId = item.messageId,
dialogKey = msg.dialogKey, dialogKey = item.dialogKey,
opponentKey = normalized, opponentKey = normalized,
opponentTitle = meta?.first.orEmpty(), opponentTitle = meta?.first.orEmpty(),
opponentUsername = meta?.second.orEmpty(), opponentUsername = meta?.second.orEmpty(),
plainText = plain, plainText = item.plainText,
timestamp = msg.timestamp, timestamp = item.timestamp,
fromMe = msg.fromMe == 1, fromMe = item.fromMe == 1,
verified = meta?.third ?: 0 verified = meta?.third ?: 0
) )
} else null
}
}
}.awaitAll().filterNotNull()
}
} }
matched.addAll(batchResults) var indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0)
offset += batchSize var indexingPasses = 0
while (indexed.size < maxResults && indexingPasses < 15) {
val unindexed = indexDao.getUnindexedMessages(currentUserPublicKey, batchSize)
if (unindexed.isEmpty()) break
val rows = ArrayList<MessageSearchIndexEntity>(unindexed.size)
unindexed.forEach { msg ->
val plain =
decryptCache[msg.messageId]
?: try {
CryptoManager.decryptWithPassword(msg.plainMessage, privateKey)
} catch (_: Exception) {
null
}.orEmpty()
if (plain.isNotEmpty()) {
decryptCache[msg.messageId] = plain
}
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
rows.add(
MessageSearchIndexEntity(
account = currentUserPublicKey,
messageId = msg.messageId,
dialogKey = msg.dialogKey,
opponentKey = opponent.trim(),
timestamp = msg.timestamp,
fromMe = msg.fromMe,
plainText = plain,
plainTextNormalized = plain.lowercase()
)
)
}
if (rows.isNotEmpty()) {
indexDao.upsert(rows)
}
indexingPasses++
indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0)
} }
results = matched.take(maxResults) results = indexed.map(::toUiResult)
} catch (_: Exception) { } catch (_: Exception) {
results = emptyList() results = emptyList()
} }

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -45,6 +46,10 @@ import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.data.MessageRepository
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown
// ── Telegram-style dark gradient colors ────────────────────────── // ── Telegram-style dark gradient colors ──────────────────────────
@@ -66,11 +71,13 @@ fun CallOverlay(
state: CallUiState, state: CallUiState,
isDarkTheme: Boolean, isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
isExpanded: Boolean = true,
onAccept: () -> Unit, onAccept: () -> Unit,
onDecline: () -> Unit, onDecline: () -> Unit,
onEnd: () -> Unit, onEnd: () -> Unit,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit onToggleSpeaker: () -> Unit,
onMinimize: (() -> Unit)? = null
) { ) {
val view = LocalView.current val view = LocalView.current
LaunchedEffect(state.isVisible) { LaunchedEffect(state.isVisible) {
@@ -85,7 +92,7 @@ fun CallOverlay(
} }
AnimatedVisibility( AnimatedVisibility(
visible = state.isVisible, visible = state.isVisible && isExpanded,
enter = fadeIn(tween(300)), enter = fadeIn(tween(300)),
exit = fadeOut(tween(200)) exit = fadeOut(tween(200))
) { ) {
@@ -96,26 +103,42 @@ fun CallOverlay(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom)) Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
) )
) { ) {
// ── Top bar: "Encrypted" left + QR icon right ── // ── Top controls: minimize (left) + key cast QR (right) ──
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) { val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
val showKeyCast =
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
state.keyCast.isNotBlank()
if (canMinimize || showKeyCast) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( if (canMinimize) {
text = "\uD83D\uDD12 Encrypted", IconButton(
color = Color.White.copy(alpha = 0.4f), onClick = { onMinimize?.invoke() },
fontSize = 13.sp, modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Minimize call",
tint = Color.White,
modifier = Modifier.size(26.dp)
) )
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
// QR grid icon — tap to show popover if (showKeyCast) {
if (state.keyCast.isNotBlank()) {
EncryptionKeyButton(keyHex = state.keyCast) EncryptionKeyButton(keyHex = state.keyCast)
} else {
Spacer(modifier = Modifier.size(48.dp))
} }
} }
} }
@@ -141,6 +164,11 @@ fun CallOverlay(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Name // Name
Row(
modifier = Modifier.padding(horizontal = 48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text( Text(
text = state.displayName, text = state.displayName,
color = Color.White, color = Color.White,
@@ -148,8 +176,22 @@ fun CallOverlay(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 48.dp) modifier = Modifier.weight(1f, fill = false)
) )
val isRosettaOfficial = state.peerTitle.equals("Rosetta", ignoreCase = true) ||
state.peerUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(state.peerPublicKey)
val isFreddyVerified = state.peerUsername.equals("freddy", ignoreCase = true) ||
state.peerTitle.equals("freddy", ignoreCase = true)
if (isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(6.dp))
VerifiedBadge(
verified = 1,
size = 20,
isDarkTheme = true
)
}
}
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))

View File

@@ -0,0 +1,129 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
private val TelegramCallGreenStart = Color(0xFF3FD17F)
private val TelegramCallGreenEnd = Color(0xFF27C4AE)
@Composable
fun CallTopBanner(
state: CallUiState,
onOpenCall: () -> Unit,
isSticky: Boolean = false,
isDarkTheme: Boolean = true,
avatarRepository: AvatarRepository? = null
) {
AnimatedVisibility(
visible = state.isVisible && state.phase != CallPhase.INCOMING,
enter = slideInVertically(initialOffsetY = { -it / 2 }, animationSpec = tween(220)) +
fadeIn(animationSpec = tween(220)),
exit = slideOutVertically(targetOffsetY = { -it / 2 }, animationSpec = tween(180)) +
fadeOut(animationSpec = tween(160))
) {
val bannerModifier =
if (isSticky) {
Modifier
.fillMaxWidth()
.height(42.dp)
} else {
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 10.dp, vertical = 8.dp)
.height(42.dp)
.clip(RoundedCornerShape(12.dp))
}
Row(
modifier = bannerModifier
.background(
Brush.horizontalGradient(
listOf(TelegramCallGreenStart, TelegramCallGreenEnd)
)
)
.clickable { onOpenCall() }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.offset(x = if (isSticky) (-4).dp else 0.dp)) {
AvatarImage(
publicKey = state.peerPublicKey,
avatarRepository = avatarRepository,
size = 24.dp,
isDarkTheme = isDarkTheme,
showOnlineIndicator = false,
displayName = state.displayName
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = buildBannerText(state),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
modifier =
Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Voice call",
tint = Color.White,
modifier = Modifier.size(17.dp)
)
}
}
}
}
private fun buildBannerText(state: CallUiState): String {
return state.displayName
}

View File

@@ -0,0 +1,425 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallMade
import androidx.compose.material.icons.filled.CallReceived
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import com.rosetta.messenger.database.CallHistoryRow
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.Phone
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import org.json.JSONArray
private data class CallHistoryItem(
val messageId: String,
val peerKey: String,
val peerTitle: String,
val peerUsername: String,
val peerVerified: Int,
val peerOnline: Int,
val timestamp: Long,
val isOutgoing: Boolean,
val durationSec: Int,
val isMissed: Boolean
) {
fun toSearchUser(): SearchUser =
SearchUser(
publicKey = peerKey,
title = peerTitle,
username = peerUsername,
verified = peerVerified,
online = peerOnline
)
}
@Composable
fun CallsHistoryScreen(
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onOpenChat: (SearchUser) -> Unit,
onStartCall: (SearchUser) -> Unit,
onStartNewCall: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val messageDao = remember(context) { RosettaDatabase.getDatabase(context).messageDao() }
val rows by produceState(initialValue = emptyList<CallHistoryRow>(), accountPublicKey) {
if (accountPublicKey.isBlank()) {
value = emptyList()
return@produceState
}
messageDao.getCallHistoryFlow(accountPublicKey).collect { value = it }
}
val items = remember(rows) { rows.map { it.toCallHistoryItem() } }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color(0xFF111111)
val secondaryTextColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
val dividerColor = if (isDarkTheme) Color(0xFF2D2D2F) else Color(0xFFE7E7EA)
LazyColumn(
modifier = modifier.fillMaxSize().background(backgroundColor),
contentPadding = PaddingValues(bottom = 16.dp)
) {
item(key = "start_new_call") {
StartNewCallRow(
isDarkTheme = isDarkTheme,
onClick = onStartNewCall
)
Divider(color = dividerColor, thickness = 0.5.dp)
Text(
text = "You can add up to 200 participants to a call.",
color = secondaryTextColor,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)
)
Divider(color = dividerColor, thickness = 0.5.dp)
}
if (items.isEmpty()) {
item(key = "empty_calls") {
EmptyCallsState(
isDarkTheme = isDarkTheme,
title = "No calls yet",
subtitle = "Your call history will appear here",
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
)
}
} else {
items(items, key = { it.messageId }) { item ->
CallHistoryRowItem(
item = item,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onOpenChat = onOpenChat,
onStartCall = onStartCall
)
Divider(color = dividerColor, thickness = 0.5.dp)
}
}
}
}
@Composable
private fun StartNewCallRow(
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val rowColor = if (isDarkTheme) Color(0xFF1B2B3A) else Color(0xFFEAF4FF)
val textColor = if (isDarkTheme) Color(0xFF74B8FF) else Color(0xFF1A73E8)
Row(
modifier = Modifier.fillMaxWidth().background(rowColor).clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Phone,
contentDescription = null,
tint = textColor,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Start New Call",
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun CallHistoryRowItem(
item: CallHistoryItem,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository?,
textColor: Color,
secondaryTextColor: Color,
onOpenChat: (SearchUser) -> Unit,
onStartCall: (SearchUser) -> Unit
) {
val subtitleColor =
when {
item.isMissed -> Color(0xFFE55A5A)
isDarkTheme -> Color(0xFF56D97A)
else -> Color(0xFF1EA75E)
}
val directionIconColor =
if (item.durationSec == 0) Color(0xFFE55A5A) else subtitleColor
val directionIcon =
when {
item.durationSec == 0 -> Icons.Default.Close
item.isOutgoing -> Icons.Default.CallMade
else -> Icons.Default.CallReceived
}
val subtitleText =
when {
item.durationSec > 0 -> "${item.directionLabel()} ${formatCallTimestamp(item.timestamp)}"
else -> item.directionLabel() + " " + formatCallTimestamp(item.timestamp)
}
Row(
modifier =
Modifier.fillMaxWidth()
.clickable { onOpenChat(item.toSearchUser()) }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = item.peerKey,
avatarRepository = avatarRepository,
size = 52.dp,
isDarkTheme = isDarkTheme,
displayName = item.peerTitle
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.peerTitle,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
val isRosettaOfficial = item.peerTitle.equals("Rosetta", ignoreCase = true) ||
item.peerUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(item.peerKey)
val isFreddyVerified = item.peerUsername.equals("freddy", ignoreCase = true) ||
item.peerTitle.equals("freddy", ignoreCase = true)
if (item.peerVerified > 0 || isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (item.peerVerified > 0) item.peerVerified else 1,
size = 16,
isDarkTheme = isDarkTheme
)
}
}
Spacer(modifier = Modifier.height(3.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = directionIcon,
contentDescription = null,
tint = directionIconColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = subtitleText,
color = if (item.isMissed) Color(0xFFE55A5A) else secondaryTextColor,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(
onClick = { onStartCall(item.toSearchUser()) }
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call",
tint = PrimaryBlue
)
}
}
}
@Composable
private fun EmptyCallsState(
isDarkTheme: Boolean,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
Column(
modifier = modifier.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(34.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
text = title,
color = titleColor,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitle,
color = subtitleColor,
fontSize = 14.sp
)
}
}
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)
val username = peerUsername.orEmpty().trim().trimStart('@')
val durationSec = parseCallDurationFromAttachments(message.attachments)
val isOutgoing = message.fromMe == 1
val isMissed = !isOutgoing && durationSec == 0
return CallHistoryItem(
messageId = message.messageId,
peerKey = peerKey,
peerTitle = displayName,
peerUsername = username,
peerVerified = peerVerified ?: 0,
peerOnline = peerOnline ?: 0,
timestamp = message.timestamp,
isOutgoing = isOutgoing,
durationSec = durationSec,
isMissed = isMissed
)
}
private fun resolveDisplayName(title: String, username: String, publicKey: String): String {
val normalizedTitle = title.trim()
if (normalizedTitle.isNotEmpty() &&
normalizedTitle != publicKey &&
normalizedTitle != publicKey.take(7) &&
normalizedTitle != publicKey.take(8)
) {
return normalizedTitle
}
val normalizedUsername = username.trim().trimStart('@')
if (normalizedUsername.isNotEmpty()) return normalizedUsername
return publicKey.take(8)
}
private fun parseCallDurationFromAttachments(attachmentsJson: String): Int {
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return 0
return runCatching {
val attachments = JSONArray(attachmentsJson)
for (i in 0 until attachments.length()) {
val attachment = attachments.optJSONObject(i) ?: continue
if (attachment.optInt("type", -1) != 4) continue
return parseCallDurationSeconds(attachment.optString("preview", ""))
}
0
}.getOrDefault(0)
}
private fun parseCallDurationSeconds(preview: String): Int {
if (preview.isBlank()) return 0
preview.substringAfterLast("::").trim().toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
val durationRegex =
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
private fun CallHistoryItem.directionLabel(): String {
return when {
durationSec == 0 && isOutgoing -> "Rejected call"
durationSec == 0 && !isOutgoing -> "Missed call"
isOutgoing -> "Outgoing call"
else -> "Incoming call"
}
}
private fun formatCallTimestamp(timestamp: Long): String {
if (timestamp <= 0L) return ""
val now = Calendar.getInstance()
val callTime = Calendar.getInstance().apply { timeInMillis = timestamp }
val sameYear = now.get(Calendar.YEAR) == callTime.get(Calendar.YEAR)
val sameDay =
sameYear && now.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
val yesterday = now.clone() as Calendar
yesterday.add(Calendar.DAY_OF_YEAR, -1)
val isYesterday =
sameYear && yesterday.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
return when {
sameDay -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
isYesterday -> "Yesterday"
else -> SimpleDateFormat("dd MMM", Locale.getDefault()).format(Date(timestamp))
}
}

View File

@@ -530,9 +530,7 @@ fun MessageAttachments(
CallAttachment( CallAttachment(
attachment = attachment, attachment = attachment,
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme
timestamp = timestamp,
messageStatus = messageStatus
) )
} }
else -> { else -> {
@@ -929,8 +927,8 @@ fun ImageAttachment(
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) } var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") } var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val preview = getPreview(attachment.preview) val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment.preview) val downloadTag = getDownloadTag(attachment)
// Анимация прогресса // Анимация прогресса
val animatedProgress by val animatedProgress by
@@ -967,8 +965,8 @@ fun ImageAttachment(
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 2. Если preview НЕ содержит UUID → это наш локальный файл → DOWNLOADED // 2. Нет transport tag → это локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> { downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
@@ -991,7 +989,7 @@ fun ImageAttachment(
} }
// Декодируем blurhash для placeholder (если есть) // Декодируем blurhash для placeholder (если есть)
if (preview.isNotEmpty() && !isDownloadTag(preview)) { if (preview.isNotEmpty()) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
blurhashBitmap = BlurHash.decode(preview, 200, 200) blurhashBitmap = BlurHash.decode(preview, 200, 200)
@@ -1119,7 +1117,7 @@ fun ImageAttachment(
scope.launch { scope.launch {
val idShort = shortDebugId(attachment.id) val idShort = shortDebugId(attachment.id)
val tagShort = shortDebugId(downloadTag) val tagShort = shortDebugId(downloadTag)
val server = TransportManager.getTransportServer() ?: "unset" val server = attachment.transportServer.ifBlank { TransportManager.getTransportServer() ?: "unset" }
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server") logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
@@ -1128,13 +1126,23 @@ fun ImageAttachment(
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val encryptedContent: String val encryptedContent: String
try { try {
encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) encryptedContent =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
} catch (e: Exception) { } catch (e: Exception) {
// Один авто-ретрай через 1с // Один авто-ретрай через 1с
logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}") logPhotoDebug("CDN download failed, retrying: id=$idShort, reason=${e.message}")
kotlinx.coroutines.delay(1000) kotlinx.coroutines.delay(1000)
try { try {
val retryResult = TransportManager.downloadFile(attachment.id, downloadTag) val retryResult =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
@Suppress("NAME_SHADOWING") @Suppress("NAME_SHADOWING")
val encryptedContent = retryResult val encryptedContent = retryResult
logPhotoDebug("CDN retry OK: id=$idShort") logPhotoDebug("CDN retry OK: id=$idShort")
@@ -1605,26 +1613,19 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC
fun CallAttachment( fun CallAttachment(
attachment: MessageAttachment, attachment: MessageAttachment,
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ
) { ) {
val callUi = remember(attachment.preview, isOutgoing) { val callUi = remember(attachment.preview, isOutgoing) {
resolveDesktopCallUi(attachment.preview, isOutgoing) resolveDesktopCallUi(attachment.preview, isOutgoing)
} }
val containerShape = RoundedCornerShape(10.dp) val containerShape = RoundedCornerShape(17.dp)
val containerBackground = val containerBackground =
if (isOutgoing) { if (isOutgoing) {
Color.White.copy(alpha = 0.12f) PrimaryBlue
} else { } else {
if (isDarkTheme) Color(0xFF1F2733) else Color(0xFFF3F8FF) if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
val containerBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
if (isDarkTheme) Color(0xFF33435A) else Color(0xFFD8E5F4)
} }
val containerBorder = Color.Transparent
val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
val iconVector = val iconVector =
when { when {
@@ -1690,59 +1691,6 @@ fun CallAttachment(
) )
} }
if (isOutgoing) {
Spacer(modifier = Modifier.width(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
when (messageStatus) {
MessageStatus.SENDING -> {
Icon(
painter = TelegramIcons.Clock,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.SENT, MessageStatus.DELIVERED -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.READ -> {
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp).offset(x = 4.dp)
)
}
}
MessageStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(14.dp)
)
}
}
}
}
} }
} }
} }
@@ -1777,8 +1725,8 @@ fun FileAttachment(
) )
} }
val preview = attachment.preview val preview = getPreview(attachment)
val downloadTag = getDownloadTag(preview) val downloadTag = getDownloadTag(attachment)
val (fileSize, fileName) = parseFilePreview(preview) val (fileSize, fileName) = parseFilePreview(preview)
// Анимация прогресса // Анимация прогресса
@@ -1841,7 +1789,7 @@ fun FileAttachment(
isPaused = false isPaused = false
return@LaunchedEffect return@LaunchedEffect
} }
downloadStatus = if (isDownloadTag(preview)) { downloadStatus = if (downloadTag.isNotBlank()) {
// Проверяем, был ли файл уже скачан ранее // Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED else DownloadStatus.NOT_DOWNLOADED
@@ -1890,6 +1838,7 @@ fun FileAttachment(
com.rosetta.messenger.network.FileDownloadManager.download( com.rosetta.messenger.network.FileDownloadManager.download(
attachmentId = attachment.id, attachmentId = attachment.id,
downloadTag = downloadTag, downloadTag = downloadTag,
transportServer = attachment.transportServer,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
accountPublicKey = currentUserPublicKey, accountPublicKey = currentUserPublicKey,
@@ -2149,8 +2098,8 @@ fun AvatarAttachment(
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) } var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurhashBitmap by remember { mutableStateOf<Bitmap?>(null) }
val preview = getPreview(attachment.preview) val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment.preview) val downloadTag = getDownloadTag(attachment)
val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) } val timeFormat = remember { java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) }
@@ -2175,8 +2124,8 @@ fun AvatarAttachment(
attachment.blob.isNotEmpty() -> { attachment.blob.isNotEmpty() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 2. Если preview НЕ содержит UUID → локальный файл → DOWNLOADED // 2. Нет transport tag → локальный файл → DOWNLOADED
!isDownloadTag(attachment.preview) -> { downloadTag.isBlank() -> {
DownloadStatus.DOWNLOADED DownloadStatus.DOWNLOADED
} }
// 3. Есть UUID (download tag) → проверяем файловую систему // 3. Есть UUID (download tag) → проверяем файловую систему
@@ -2238,7 +2187,12 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag) val encryptedContent =
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer
)
val downloadTime = System.currentTimeMillis() - startTime val downloadTime = System.currentTimeMillis() - startTime
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
@@ -2582,6 +2536,12 @@ internal fun getDownloadTag(preview: String): String {
return "" return ""
} }
/** Получить download tag из attachment (new format first, legacy preview fallback). */
internal fun getDownloadTag(attachment: MessageAttachment): String {
if (attachment.transportTag.isNotBlank()) return attachment.transportTag
return getDownloadTag(attachment.preview)
}
/** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */ /** Получить preview без download tag Как в desktop: preview.split("::").splice(1).join("::") */
internal fun getPreview(preview: String): String { internal fun getPreview(preview: String): String {
val parts = preview.split("::") val parts = preview.split("::")
@@ -2591,6 +2551,15 @@ internal fun getPreview(preview: String): String {
return preview return preview
} }
/** Получить attachment preview (new format raw preview, legacy preview fallback). */
internal fun getPreview(attachment: MessageAttachment): String {
return if (attachment.transportTag.isNotBlank()) {
attachment.preview
} else {
getPreview(attachment.preview)
}
}
/** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */ /** Парсинг preview для файлов Формат: "UUID::filesize::filename" или "filesize::filename" */
private fun parseFilePreview(preview: String): Pair<Long, String> { private fun parseFilePreview(preview: String): Pair<Long, String> {
val parts = preview.split("::") val parts = preview.split("::")

View File

@@ -666,6 +666,15 @@ fun MessageBubble(
.IMAGE .IMAGE
} }
val isCallMessage =
message.attachments.isNotEmpty() &&
message.text.isEmpty() &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.all {
it.type == AttachmentType.CALL
}
val isStandaloneGroupInvite = val isStandaloneGroupInvite =
message.attachments.isEmpty() && message.attachments.isEmpty() &&
message.replyData == null && message.replyData == null &&
@@ -794,7 +803,8 @@ fun MessageBubble(
onLongClick = onLongClick onLongClick = onLongClick
) )
.then( .then(
if (false) { if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
Modifier Modifier
} else { } else {
Modifier.clip(bubbleShape) Modifier.clip(bubbleShape)
@@ -3235,6 +3245,7 @@ fun KebabMenu(
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
isSystemAccount: Boolean = false, isSystemAccount: Boolean = false,
isBlocked: Boolean, isBlocked: Boolean,
onSearchInChatClick: () -> Unit = {},
onGroupInfoClick: () -> Unit = {}, onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {}, onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {}, onLeaveGroupClick: () -> Unit = {},
@@ -3266,7 +3277,16 @@ fun KebabMenu(
dismissOnClickOutside = true dismissOnClickOutside = true
) )
) { ) {
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search",
onClick = onSearchInChatClick,
tintColor = iconColor,
textColor = textColor
)
if (isGroupChat) { if (isGroupChat) {
Divider(color = dividerColor)
ContextMenuItemWithVector( ContextMenuItemWithVector(
icon = TablerIcons.Search, icon = TablerIcons.Search,
text = "Search Members", text = "Search Members",

View File

@@ -655,9 +655,14 @@ fun MessageInputBar(
val hasImageAttachment = msg.attachments.any { val hasImageAttachment = msg.attachments.any {
it.type == AttachmentType.IMAGE it.type == AttachmentType.IMAGE
} }
val hasCallAttachment = msg.attachments.any {
it.type == AttachmentType.CALL
}
AppleEmojiText( AppleEmojiText(
text = if (panelReplyMessages.size == 1) { text = if (panelReplyMessages.size == 1) {
if (msg.text.isEmpty() && hasImageAttachment) { if (msg.text.isEmpty() && hasCallAttachment) {
"Call"
} else if (msg.text.isEmpty() && hasImageAttachment) {
"Photo" "Photo"
} else { } else {
val shortText = msg.text.take(40) val shortText = msg.text.take(40)

View File

@@ -1338,6 +1338,23 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
val id = attachment.optString("id", "") val id = attachment.optString("id", "")
val blob = attachment.optString("blob", "") val blob = attachment.optString("blob", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
val transportServer =
attachment.optString(
"transportServer",
attachment.optString(
"transport_server",
transportObj?.optString("transport_server", "") ?: ""
)
)
add( add(
MessageAttachment( MessageAttachment(
@@ -1347,7 +1364,9 @@ private fun parseAttachmentsForOtherProfile(attachmentsJson: String): List<Messa
preview = attachment.optString("preview", ""), preview = attachment.optString("preview", ""),
width = attachment.optInt("width", 0), width = attachment.optInt("width", 0),
height = attachment.optInt("height", 0), height = attachment.optInt("height", 0),
localUri = attachment.optString("localUri", "") localUri = attachment.optString("localUri", ""),
transportTag = transportTag,
transportServer = transportServer
) )
) )
} }

View File

@@ -510,11 +510,11 @@ private fun WallpaperSelectorItem(
Column( Column(
modifier = modifier =
Modifier.width(118.dp).clickable(onClick = onClick), Modifier.width(100.dp).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth().height(76.dp), modifier = Modifier.size(width = 100.dp, height = 76.dp),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor) border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)

View File

@@ -96,6 +96,27 @@ object ThemeWallpapers {
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_4", pairGroup = "pair_4",
drawableRes = R.drawable.wallpaper_back_2 drawableRes = R.drawable.wallpaper_back_2
),
ThemeWallpaper(
id = "dark_01",
name = "Dark 1",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5",
drawableRes = R.drawable.wallpaper_dark_01
),
ThemeWallpaper(
id = "dark_02",
name = "Dark 2",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_5",
drawableRes = R.drawable.wallpaper_dark_02
),
ThemeWallpaper(
id = "dark_03",
name = "Dark 3",
preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_6",
drawableRes = R.drawable.wallpaper_dark_03
) )
) )

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -29,5 +29,5 @@ dependencies {
implementation("androidx.test.ext:junit:1.1.5") implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test.espresso:espresso-core:3.5.1") implementation("androidx.test.espresso:espresso-core:3.5.1")
implementation("androidx.test.uiautomator:uiautomator:2.2.0") implementation("androidx.test.uiautomator:uiautomator:2.2.0")
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.2") implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
} }

20
benchmark/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Macrobenchmark
Этот модуль запускает замеры производительности приложения `:app` на устройстве:
- `coldStartup` — холодный запуск
- `chatListScroll` — прокрутка списка чатов
- `searchFlow` — вход в поиск и ввод запроса
## Запуск
```bash
./gradlew :benchmark:connectedCheck
```
Запуск только одного класса:
```bash
./gradlew :benchmark:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.rosetta.messenger.benchmark.AppMacrobenchmark
```

View File

@@ -0,0 +1,37 @@
plugins {
id("com.android.test")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.rosetta.messenger.benchmark"
compileSdk = 34
defaultConfig {
minSdk = 28
targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["androidx.benchmark.enabledRules"] = "Macrobenchmark"
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR,DEBUGGABLE"
testInstrumentationRunnerArguments["androidx.benchmark.compilation.enabled"] = "false"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test:runner:1.5.2")
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -0,0 +1,150 @@
package com.rosetta.messenger.benchmark
import android.content.Intent
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class AppMacrobenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
private val appPackage = "com.rosetta.messenger"
private val searchTriggers = listOf("Search", "search", "Поиск", "поиск", "Найти", "найти")
private val runtimePermissions = listOf(
"android.permission.POST_NOTIFICATIONS",
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.BLUETOOTH_CONNECT"
)
@Test
fun coldStartup() {
benchmarkRule.measureRepeated(
packageName = appPackage,
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.COLD,
iterations = 1,
setupBlock = {
grantRuntimePermissions()
pressHome()
}
) {
launchMainActivity()
device.waitForIdle()
}
}
@Test
fun chatListScroll() {
benchmarkRule.measureRepeated(
packageName = appPackage,
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.Partial(),
iterations = 1,
setupBlock = {
prepareUi()
}
) {
val list = device.findObject(By.scrollable(true)) ?: return@measureRepeated
repeat(2) {
list.safeScroll(Direction.DOWN, 1.0f)
device.waitForIdle()
list.safeScroll(Direction.UP, 1.0f)
device.waitForIdle()
}
}
}
@Test
fun searchFlow() {
benchmarkRule.measureRepeated(
packageName = appPackage,
metrics = listOf(FrameTimingMetric()),
compilationMode = CompilationMode.Partial(),
iterations = 1,
setupBlock = {
prepareUi()
}
) {
if (!openSearchInput()) return@measureRepeated
val input = device.wait(Until.findObject(By.clazz("android.widget.EditText")), 1_500)
?: return@measureRepeated
input.text = "rosetta"
device.waitForIdle()
device.findObject(By.scrollable(true))?.safeScroll(Direction.DOWN, 0.7f)
device.waitForIdle()
device.pressBack()
device.waitForIdle()
}
}
private fun MacrobenchmarkScope.prepareUi() {
grantRuntimePermissions()
pressHome()
launchMainActivity()
device.waitForIdle()
device.wait(Until.hasObject(By.pkg(appPackage).depth(0)), 3_000)
}
private fun MacrobenchmarkScope.openSearchInput(): Boolean {
if (device.hasObject(By.clazz("android.widget.EditText"))) return true
for (label in searchTriggers) {
val node = device.findObject(By.descContains(label)) ?: device.findObject(By.textContains(label))
if (node != null) {
node.click()
device.waitForIdle()
if (device.wait(Until.hasObject(By.clazz("android.widget.EditText")), 1_000)) {
return true
}
}
}
return device.hasObject(By.clazz("android.widget.EditText"))
}
private fun UiObject2.safeScroll(direction: Direction, percent: Float) {
runCatching { scroll(direction, percent) }
}
private fun MacrobenchmarkScope.grantRuntimePermissions() {
runtimePermissions.forEach { permission ->
runCatching {
device.executeShellCommand("pm grant $appPackage $permission")
}
}
}
private fun MacrobenchmarkScope.launchMainActivity() {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
setClassName(appPackage, "$appPackage.MainActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
startActivityAndWait(intent)
}
}

View File

@@ -19,3 +19,4 @@ rootProject.name = "rosetta-android"
include(":app") include(":app")
include(":baselineprofile") include(":baselineprofile")
include(":benchmark")