Compare commits
27 Commits
new-server
...
676c205666
| Author | SHA1 | Date | |
|---|---|---|---|
| 676c205666 | |||
| b9ac7791f6 | |||
| 20bef53869 | |||
| 2ff1383b13 | |||
| 727b902df7 | |||
| 89259b2a46 | |||
| ce6bc985be | |||
| ff854e919e | |||
| 434ccef30c | |||
| 26f4597c3b | |||
| fa1288479f | |||
| 46b1b3a6f1 | |||
| aa40f5287c | |||
| b271917594 | |||
| 4cfa9f1d48 | |||
| 20c6696fdf | |||
| 3eac17d9a8 | |||
| 84aad5f094 | |||
| e7efe0856c | |||
| 93a2de315a | |||
| c3e97eee56 | |||
| 39b0b0e107 | |||
| 51f76b5073 | |||
| 0fc637b42a | |||
| 59addf4373 | |||
| 3953d93207 | |||
| de958e10a1 |
@@ -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
|
||||||
|
|
||||||
### Групповые чаты и медиа
|
### Групповые чаты и медиа
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1555,14 +1625,20 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CallOverlay(
|
CallOverlay(
|
||||||
state = callUiState,
|
state = callUiState,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onAccept = { acceptCallWithPermission() },
|
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
|
||||||
onDecline = { CallManager.declineIncomingCall() },
|
onAccept = { acceptCallWithPermission() },
|
||||||
onEnd = { CallManager.endCall() },
|
onDecline = { CallManager.declineIncomingCall() },
|
||||||
onToggleMute = { CallManager.toggleMute() },
|
onEnd = { CallManager.endCall() },
|
||||||
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
onToggleMute = { CallManager.toggleMute() },
|
||||||
|
onToggleSpeaker = { CallManager.toggleSpeaker() },
|
||||||
|
onMinimize = {
|
||||||
|
if (callUiState.phase != CallPhase.INCOMING) {
|
||||||
|
isCallOverlayExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,27 +597,141 @@ 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(
|
@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(
|
||||||
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND plain_message != ''
|
AND plain_message != ''
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
|||||||
@@ -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() // Для разработки - только
|
||||||
// если миграция не
|
// если миграция не
|
||||||
|
|||||||
@@ -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}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
try {
|
if (BuildConfig.DEBUG) {
|
||||||
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
try {
|
||||||
if (!dir.exists()) dir.mkdirs()
|
val dir = java.io.File(appContext!!.filesDir, "crash_reports")
|
||||||
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
if (!dir.exists()) dir.mkdirs()
|
||||||
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
|
||||||
} catch (_: Throwable) {}
|
XChaCha20E2EE.nativeOpenDiagFile(diagPath)
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
// If sender track already exists, bind encryptor now.
|
// If sender track already exists, bind encryptor now.
|
||||||
val existingSender =
|
val existingSender =
|
||||||
pendingAudioSenderForE2ee
|
pendingAudioSenderForE2ee
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
.firstOrNull()
|
||||||
|
?: run {
|
||||||
|
stream.setReadPointerBits(startPointer)
|
||||||
|
parseFromStream(stream, 2)
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"Failed to parse PacketMessage payload"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val attachmentCount = stream.readInt8()
|
fromPublicKey = parsed.fromPublicKey
|
||||||
val attachmentsList = mutableListOf<MessageAttachment>()
|
toPublicKey = parsed.toPublicKey
|
||||||
for (i in 0 until attachmentCount) {
|
content = parsed.content
|
||||||
attachmentsList.add(MessageAttachment(
|
chachaKey = parsed.chachaKey
|
||||||
id = stream.readString(),
|
timestamp = parsed.timestamp
|
||||||
preview = stream.readString(),
|
privateKey = parsed.privateKey
|
||||||
blob = stream.readString(),
|
messageId = parsed.messageId
|
||||||
type = AttachmentType.fromInt(stream.readInt8())
|
attachments = parsed.attachments
|
||||||
))
|
aesChachaKey = parsed.aesChachaKey
|
||||||
}
|
|
||||||
attachments = attachmentsList
|
|
||||||
aesChachaKey = stream.readString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
if (bit == 1) {
|
stream[byteIndex] =
|
||||||
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
if (bit == 1) {
|
||||||
} else {
|
(stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
||||||
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
} else {
|
||||||
}
|
(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,74 +123,134 @@ 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
|
||||||
|
val data = remoteMessage.data
|
||||||
|
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
|
||||||
|
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
|
||||||
|
|
||||||
// Обрабатываем data payload
|
// Обрабатываем data payload (новый server формат + legacy fallback)
|
||||||
if (remoteMessage.data.isNotEmpty()) {
|
if (data.isNotEmpty()) {
|
||||||
val data = remoteMessage.data
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val looksLikeMessagePayload =
|
if (!handledByData) {
|
||||||
type.contains("message") ||
|
val looksLikeMessagePayload =
|
||||||
data.keys.any { key ->
|
type.contains("message") ||
|
||||||
val lower = key.lowercase(Locale.ROOT)
|
data.keys.any { key ->
|
||||||
lower.contains("message") ||
|
val lower = key.lowercase(Locale.ROOT)
|
||||||
lower.contains("text") ||
|
lower.contains("message") ||
|
||||||
lower.contains("body")
|
lower.contains("text") ||
|
||||||
}
|
lower.contains("body")
|
||||||
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) {
|
}
|
||||||
showSimpleNotification(senderName, messagePreview, senderPublicKey)
|
if (looksLikeMessagePayload) {
|
||||||
handledMessageData = true
|
showSimpleNotification(senderName, messagePreview, dialogKey ?: senderPublicKey)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -1898,6 +1966,48 @@ fun ChatsListScreen(
|
|||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
} else {
|
} 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 {
|
||||||
// 🎬 Animated content transition between main list and
|
// 🎬 Animated content transition between main list and
|
||||||
// requests
|
// requests
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
@@ -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 {
|
||||||
.thenByDescending {
|
regular.add(dialog)
|
||||||
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(
|
||||||
|
|||||||
@@ -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,39 +546,79 @@ 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)
|
1 -> {
|
||||||
if (attachments.length() == 0) return null
|
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
||||||
|
// Если текст пустой — показываем "Forwarded" как в desktop.
|
||||||
val firstAttachment = attachments.getJSONObject(0)
|
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||||
when (firstAttachment.optInt("type", -1)) {
|
|
||||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
|
||||||
1 -> {
|
|
||||||
// AttachmentType.MESSAGES = 1 (Reply/Forward).
|
|
||||||
// Если текст пустой — показываем "Forwarded" как в desktop.
|
|
||||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
|
||||||
}
|
|
||||||
2 -> "File" // AttachmentType.FILE = 2
|
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
|
||||||
4 -> "Call" // AttachmentType.CALL = 4
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
// Fallback: если JSON поврежден, но видно MESSAGES attachment и текста нет — это forward.
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
val hasMessagesType =
|
4 -> "Call" // AttachmentType.CALL = 4
|
||||||
attachmentsJson.contains("\"type\":1") ||
|
else -> if (inferredCall) "Call" else null
|
||||||
attachmentsJson.contains("\"type\": 1")
|
}
|
||||||
if (decryptedLastMessage.isEmpty() && hasMessagesType) {
|
}
|
||||||
"Forwarded"
|
|
||||||
} else {
|
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
|
||||||
null
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
val meta = dialogCache[normalized]
|
||||||
|
return MessageSearchResult(
|
||||||
|
messageId = item.messageId,
|
||||||
|
dialogKey = item.dialogKey,
|
||||||
|
opponentKey = normalized,
|
||||||
|
opponentTitle = meta?.first.orEmpty(),
|
||||||
|
opponentUsername = meta?.second.orEmpty(),
|
||||||
|
plainText = item.plainText,
|
||||||
|
timestamp = item.timestamp,
|
||||||
|
fromMe = item.fromMe == 1,
|
||||||
|
verified = meta?.third ?: 0
|
||||||
)
|
)
|
||||||
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]
|
|
||||||
MessageSearchResult(
|
|
||||||
messageId = msg.messageId,
|
|
||||||
dialogKey = msg.dialogKey,
|
|
||||||
opponentKey = normalized,
|
|
||||||
opponentTitle = meta?.first.orEmpty(),
|
|
||||||
opponentUsername = meta?.second.orEmpty(),
|
|
||||||
plainText = plain,
|
|
||||||
timestamp = msg.timestamp,
|
|
||||||
fromMe = msg.fromMe == 1,
|
|
||||||
verified = meta?.third ?: 0
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll().filterNotNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matched.addAll(batchResults)
|
|
||||||
offset += batchSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results = matched.take(maxResults)
|
var indexed = indexDao.search(currentUserPublicKey, queryLower, maxResults, 0)
|
||||||
|
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 = indexed.map(::toUiResult)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
results = emptyList()
|
results = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +164,34 @@ fun CallOverlay(
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
Text(
|
Row(
|
||||||
text = state.displayName,
|
modifier = Modifier.padding(horizontal = 48.dp),
|
||||||
color = Color.White,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = 26.sp,
|
horizontalArrangement = Arrangement.Center
|
||||||
fontWeight = FontWeight.SemiBold,
|
) {
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = state.displayName,
|
||||||
modifier = Modifier.padding(horizontal = 48.dp)
|
color = Color.White,
|
||||||
)
|
fontSize = 26.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("::")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_01.png
Normal file
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_02.png
Normal file
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_03.png
Normal file
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_03.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_04.png
Normal file
BIN
app/src/main/res/drawable-nodpi/wallpaper_dark_04.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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
20
benchmark/README.md
Normal 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
|
||||||
|
```
|
||||||
37
benchmark/build.gradle.kts
Normal file
37
benchmark/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
2
benchmark/src/main/AndroidManifest.xml
Normal file
2
benchmark/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,4 @@ rootProject.name = "rosetta-android"
|
|||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":baselineprofile")
|
include(":baselineprofile")
|
||||||
|
include(":benchmark")
|
||||||
|
|||||||
Reference in New Issue
Block a user