Compare commits
4 Commits
new-server
...
e7efe0856c
| Author | SHA1 | Date | |
|---|---|---|---|
| e7efe0856c | |||
| c3e97eee56 | |||
| 39b0b0e107 | |||
| 51f76b5073 |
@@ -1,5 +1,14 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 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.3"
|
||||||
val rosettaVersionCode = 34 // Increment on each release
|
val rosettaVersionCode = 35 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -1015,6 +1015,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 ->
|
||||||
@@ -1277,7 +1280,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = encryptedPlainMessage,
|
plainMessage = encryptedPlainMessage,
|
||||||
attachments = "[]",
|
attachments = "[]",
|
||||||
|
primaryAttachmentType = -1,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -266,6 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
plainMessage = encryptedPlainMessage,
|
plainMessage = encryptedPlainMessage,
|
||||||
attachments = "[]",
|
attachments = "[]",
|
||||||
|
primaryAttachmentType = -1,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -528,6 +530,8 @@ 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
|
||||||
)
|
)
|
||||||
@@ -860,6 +864,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(packet.attachments),
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1638,6 +1644,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return jsonArray.toString()
|
return jsonArray.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
|
||||||
|
if (attachments.isEmpty()) return -1
|
||||||
|
return attachments.first().type.value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
|
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
|
||||||
* получении attachment с типом AVATAR - сохраняем в avatar_cache
|
* получении attachment с типом AVATAR - сохраняем в avatar_cache
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Защищенные звонки и диагностика E2EE
|
Оптимизация E2EE и списка чатов
|
||||||
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
- В release отключена frame-диагностика E2EE (детальные frame-логи только в debug)
|
||||||
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
- Упрощен ChatsListScreen: убрано дублирование collectAsState и вынесены route-компоненты
|
||||||
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
- Ускорены выборки по вложениям: добавлен denormalized attachment type + индекс в БД
|
||||||
|
- Добавлена миграция БД с backfill типа вложения для старых сообщений
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ 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", "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,6 +100,8 @@ 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 // Ключ диалога для быстрой выборки
|
||||||
@@ -174,6 +177,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 +548,10 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND (
|
||||||
AND attachments LIKE '%"type":0%'
|
primary_attachment_type = 0
|
||||||
|
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":0%')
|
||||||
|
)
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
@@ -551,14 +566,93 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND attachments != '[]'
|
AND (
|
||||||
AND attachments LIKE '%"type":2%'
|
primary_attachment_type = 2
|
||||||
|
OR (primary_attachment_type = -1 AND attachments != '[]' AND attachments LIKE '%"type":2%')
|
||||||
|
)
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📞 История звонков на основе CALL attachments (type: 4)
|
||||||
|
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
m.*,
|
||||||
|
CASE
|
||||||
|
WHEN m.from_me = 1 THEN m.to_public_key
|
||||||
|
ELSE m.from_public_key
|
||||||
|
END AS peer_key,
|
||||||
|
d.opponent_title AS peer_title,
|
||||||
|
d.opponent_username AS peer_username,
|
||||||
|
d.verified AS peer_verified,
|
||||||
|
d.is_online AS peer_online
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN dialogs d
|
||||||
|
ON d.account = m.account
|
||||||
|
AND d.opponent_key = CASE
|
||||||
|
WHEN m.from_me = 1 THEN m.to_public_key
|
||||||
|
ELSE m.from_public_key
|
||||||
|
END
|
||||||
|
WHERE m.account = :account
|
||||||
|
AND (
|
||||||
|
m.primary_attachment_type = 4
|
||||||
|
OR (
|
||||||
|
m.primary_attachment_type = -1
|
||||||
|
AND m.attachments != '[]'
|
||||||
|
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"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
|
||||||
|
OR (
|
||||||
|
primary_attachment_type = -1
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getCallHistoryPeers(account: String): List<String>
|
||||||
|
|
||||||
|
/** Удалить все call events из messages для аккаунта. */
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
DELETE FROM messages
|
||||||
|
WHERE account = :account
|
||||||
|
AND (
|
||||||
|
primary_attachment_type = 4
|
||||||
|
OR (
|
||||||
|
primary_attachment_type = -1
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun deleteAllCallMessages(account: String): Int
|
||||||
|
|
||||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
AccountSyncTimeEntity::class,
|
AccountSyncTimeEntity::class,
|
||||||
GroupEntity::class,
|
GroupEntity::class,
|
||||||
PinnedMessageEntity::class],
|
PinnedMessageEntity::class],
|
||||||
version = 14,
|
version = 15,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class RosettaDatabase : RoomDatabase() {
|
abstract class RosettaDatabase : RoomDatabase() {
|
||||||
@@ -202,6 +202,36 @@ 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
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getDatabase(context: Context): RosettaDatabase {
|
fun getDatabase(context: Context): RosettaDatabase {
|
||||||
return INSTANCE
|
return INSTANCE
|
||||||
?: synchronized(this) {
|
?: synchronized(this) {
|
||||||
@@ -224,7 +254,8 @@ 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
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration() // Для разработки - только
|
.fallbackToDestructiveMigration() // Для разработки - только
|
||||||
// если миграция не
|
// если миграция не
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -199,7 +200,7 @@ object CallManager {
|
|||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
phase = CallPhase.OUTGOING,
|
phase = CallPhase.OUTGOING,
|
||||||
statusText = "Calling..."
|
statusText = "Calling"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,13 +873,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,332 +1,163 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary stream for protocol packets.
|
* Binary stream for protocol packets
|
||||||
* Ported from desktop/dev stream.ts implementation.
|
* Matches the React Native implementation exactly
|
||||||
*/
|
*/
|
||||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||||
private var stream: ByteArray
|
private var _stream = mutableListOf<Int>()
|
||||||
private var readPointer = 0 // bits
|
private var _readPointer = 0
|
||||||
private var writePointer = 0 // bits
|
private var _writePointer = 0
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (stream.isEmpty()) {
|
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||||
this.stream = ByteArray(0)
|
|
||||||
} else {
|
|
||||||
this.stream = stream.copyOf()
|
|
||||||
this.writePointer = this.stream.size shl 3
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStream(): ByteArray {
|
fun getStream(): ByteArray {
|
||||||
return stream.copyOf(length())
|
return _stream.map { it.toByte() }.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setStream(stream: ByteArray = ByteArray(0)) {
|
fun getReadPointerBits(): Int = _readPointer
|
||||||
if (stream.isEmpty()) {
|
|
||||||
this.stream = ByteArray(0)
|
fun getTotalBits(): Int = _stream.size * 8
|
||||||
this.readPointer = 0
|
|
||||||
this.writePointer = 0
|
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||||
return
|
|
||||||
|
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
||||||
|
|
||||||
|
fun setStream(stream: ByteArray) {
|
||||||
|
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||||
|
_readPointer = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeInt8(value: Int) {
|
||||||
|
val negationBit = if (value < 0) 1 else 0
|
||||||
|
val int8Value = Math.abs(value) and 0xFF
|
||||||
|
|
||||||
|
ensureCapacity(_writePointer shr 3)
|
||||||
|
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
||||||
|
_writePointer++
|
||||||
|
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
val bit = (int8Value shr (7 - i)) and 1
|
||||||
|
ensureCapacity(_writePointer shr 3)
|
||||||
|
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||||
|
_writePointer++
|
||||||
}
|
}
|
||||||
this.stream = stream.copyOf()
|
|
||||||
this.readPointer = 0
|
|
||||||
this.writePointer = this.stream.size shl 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBuffer(): ByteArray = getStream()
|
fun readInt8(): Int {
|
||||||
|
var value = 0
|
||||||
fun isEmpty(): Boolean = writePointer == 0
|
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||||
|
_readPointer++
|
||||||
fun length(): Int = (writePointer + 7) shr 3
|
|
||||||
|
for (i in 0 until 8) {
|
||||||
fun getReadPointerBits(): Int = readPointer
|
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||||
|
value = value or (bit shl (7 - i))
|
||||||
fun getTotalBits(): Int = writePointer
|
_readPointer++
|
||||||
|
}
|
||||||
fun getRemainingBits(): Int = writePointer - readPointer
|
|
||||||
|
return if (negationBit == 1) -value else value
|
||||||
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
}
|
||||||
|
|
||||||
fun writeBit(value: Int) {
|
fun writeBit(value: Int) {
|
||||||
writeBits((value and 1).toULong(), 1)
|
val bit = value and 1
|
||||||
|
ensureCapacity(_writePointer shr 3)
|
||||||
|
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||||
|
_writePointer++
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBit(): Int = readBits(1).toInt()
|
fun readBit(): Int {
|
||||||
|
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||||
|
_readPointer++
|
||||||
|
return bit
|
||||||
|
}
|
||||||
|
|
||||||
fun writeBoolean(value: Boolean) {
|
fun writeBoolean(value: Boolean) {
|
||||||
writeBit(if (value) 1 else 0)
|
writeBit(if (value) 1 else 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBoolean(): Boolean = readBit() == 1
|
fun readBoolean(): Boolean {
|
||||||
|
return readBit() == 1
|
||||||
fun writeByte(value: Int) {
|
|
||||||
writeUInt8(value and 0xFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readByte(): Int {
|
|
||||||
val value = readUInt8()
|
|
||||||
return if (value >= 0x80) value - 0x100 else value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeUInt8(value: Int) {
|
|
||||||
val v = value and 0xFF
|
|
||||||
|
|
||||||
if ((writePointer and 7) == 0) {
|
|
||||||
reserveBits(8)
|
|
||||||
stream[writePointer shr 3] = v.toByte()
|
|
||||||
writePointer += 8
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeBits(v.toULong(), 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt8(): Int {
|
|
||||||
if (remainingBits() < 8L) {
|
|
||||||
throw IllegalStateException("Not enough bits to read UInt8")
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((readPointer and 7) == 0) {
|
|
||||||
val value = stream[readPointer shr 3].toInt() and 0xFF
|
|
||||||
readPointer += 8
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return readBits(8).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeInt8(value: Int) {
|
|
||||||
writeUInt8(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt8(): Int {
|
|
||||||
val value = readUInt8()
|
|
||||||
return if (value >= 0x80) value - 0x100 else value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeUInt16(value: Int) {
|
|
||||||
val v = value and 0xFFFF
|
|
||||||
writeUInt8((v ushr 8) and 0xFF)
|
|
||||||
writeUInt8(v and 0xFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt16(): Int {
|
|
||||||
val hi = readUInt8()
|
|
||||||
val lo = readUInt8()
|
|
||||||
return (hi shl 8) or lo
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeInt16(value: Int) {
|
fun writeInt16(value: Int) {
|
||||||
writeUInt16(value)
|
writeInt8(value shr 8)
|
||||||
|
writeInt8(value and 0xFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt16(): Int {
|
fun readInt16(): Int {
|
||||||
val value = readUInt16()
|
val high = readInt8() shl 8
|
||||||
return if (value >= 0x8000) value - 0x10000 else value
|
return high or readInt8()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeUInt32(value: Long) {
|
|
||||||
if (value < 0L || value > 0xFFFF_FFFFL) {
|
|
||||||
throw IllegalArgumentException("UInt32 out of range: $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
|
||||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
|
||||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
|
||||||
writeUInt8((value and 0xFF).toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readUInt32(): Long {
|
|
||||||
val b1 = readUInt8().toLong()
|
|
||||||
val b2 = readUInt8().toLong()
|
|
||||||
val b3 = readUInt8().toLong()
|
|
||||||
val b4 = readUInt8().toLong()
|
|
||||||
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeInt32(value: Int) {
|
fun writeInt32(value: Int) {
|
||||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
writeInt16(value shr 16)
|
||||||
|
writeInt16(value and 0xFFFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt32(): Int = readUInt32().toInt()
|
fun readInt32(): Int {
|
||||||
|
val high = readInt16() shl 16
|
||||||
fun writeUInt64(value: ULong) {
|
return high or readInt16()
|
||||||
writeUInt8(((value shr 56) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 48) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 40) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 32) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 24) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 16) and 0xFFu).toInt())
|
|
||||||
writeUInt8(((value shr 8) and 0xFFu).toInt())
|
|
||||||
writeUInt8((value and 0xFFu).toInt())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readUInt64(): ULong {
|
fun writeInt64(value: Long) {
|
||||||
val high = readUInt32().toULong()
|
val high = (value shr 32).toInt()
|
||||||
val low = readUInt32().toULong()
|
val low = (value and 0xFFFFFFFF).toInt()
|
||||||
|
writeInt32(high)
|
||||||
|
writeInt32(low)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readInt64(): Long {
|
||||||
|
val high = readInt32().toLong()
|
||||||
|
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||||
return (high shl 32) or low
|
return (high shl 32) or low
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt64(value: Long) {
|
fun writeString(value: String) {
|
||||||
writeUInt64(value.toULong())
|
writeInt32(value.length)
|
||||||
}
|
for (char in value) {
|
||||||
|
writeInt16(char.code)
|
||||||
fun readInt64(): Long = readUInt64().toLong()
|
|
||||||
|
|
||||||
fun writeFloat32(value: Float) {
|
|
||||||
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
|
|
||||||
writeUInt32(bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readFloat32(): Float {
|
|
||||||
val bits = readUInt32().toInt()
|
|
||||||
return Float.fromBits(bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeString(value: String?) {
|
|
||||||
val str = value ?: ""
|
|
||||||
writeUInt32(str.length.toLong())
|
|
||||||
|
|
||||||
if (str.isEmpty()) return
|
|
||||||
|
|
||||||
reserveBits(str.length.toLong() * 16L)
|
|
||||||
for (i in str.indices) {
|
|
||||||
writeUInt16(str[i].code and 0xFFFF)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readString(): String {
|
fun readString(): String {
|
||||||
val len = readUInt32()
|
val length = readInt32()
|
||||||
if (len > Int.MAX_VALUE.toLong()) {
|
// Desktop parity + safety: don't trust malformed string length.
|
||||||
throw IllegalStateException("String length too large: $len")
|
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||||
|
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"RosettaStream",
|
||||||
|
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
val sb = StringBuilder()
|
||||||
val requiredBits = len * 16L
|
for (i in 0 until length) {
|
||||||
if (requiredBits > remainingBits()) {
|
sb.append(readInt16().toChar())
|
||||||
throw IllegalStateException("Not enough bits to read string")
|
|
||||||
}
|
}
|
||||||
|
return sb.toString()
|
||||||
val chars = CharArray(len.toInt())
|
|
||||||
for (i in chars.indices) {
|
|
||||||
chars[i] = readUInt16().toChar()
|
|
||||||
}
|
|
||||||
return String(chars)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeBytes(value: ByteArray?) {
|
fun writeBytes(value: ByteArray) {
|
||||||
val bytes = value ?: ByteArray(0)
|
writeInt32(value.size)
|
||||||
writeUInt32(bytes.size.toLong())
|
for (byte in value) {
|
||||||
if (bytes.isEmpty()) return
|
writeInt8(byte.toInt())
|
||||||
|
|
||||||
reserveBits(bytes.size.toLong() * 8L)
|
|
||||||
|
|
||||||
if ((writePointer and 7) == 0) {
|
|
||||||
val byteIndex = writePointer shr 3
|
|
||||||
ensureCapacity(byteIndex + bytes.size - 1)
|
|
||||||
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size)
|
|
||||||
writePointer += bytes.size shl 3
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (b in bytes) {
|
|
||||||
writeUInt8(b.toInt() and 0xFF)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBytes(): ByteArray {
|
fun readBytes(): ByteArray {
|
||||||
val len = readUInt32()
|
val length = readInt32()
|
||||||
if (len == 0L) return ByteArray(0)
|
val bytes = ByteArray(length)
|
||||||
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
for (i in 0 until length) {
|
||||||
|
bytes[i] = readInt8().toByte()
|
||||||
val requiredBits = len * 8L
|
|
||||||
if (requiredBits > remainingBits()) {
|
|
||||||
return ByteArray(0)
|
|
||||||
}
|
}
|
||||||
|
return bytes
|
||||||
val out = ByteArray(len.toInt())
|
|
||||||
|
|
||||||
if ((readPointer and 7) == 0) {
|
|
||||||
val byteIndex = readPointer shr 3
|
|
||||||
System.arraycopy(stream, byteIndex, out, 0, out.size)
|
|
||||||
readPointer += out.size shl 3
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in out.indices) {
|
|
||||||
out[i] = readUInt8().toByte()
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
|
||||||
|
|
||||||
private fun writeBits(value: ULong, bits: Int) {
|
|
||||||
if (bits <= 0) return
|
|
||||||
|
|
||||||
reserveBits(bits.toLong())
|
|
||||||
|
|
||||||
for (i in bits - 1 downTo 0) {
|
|
||||||
val bit = ((value shr i) and 1u).toInt()
|
|
||||||
val byteIndex = writePointer shr 3
|
|
||||||
val shift = 7 - (writePointer and 7)
|
|
||||||
|
|
||||||
if (bit == 1) {
|
|
||||||
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
|
||||||
} else {
|
|
||||||
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
writePointer++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readBits(bits: Int): ULong {
|
|
||||||
if (bits <= 0) return 0u
|
|
||||||
if (remainingBits() < bits.toLong()) {
|
|
||||||
throw IllegalStateException("Not enough bits to read")
|
|
||||||
}
|
|
||||||
|
|
||||||
var value = 0uL
|
|
||||||
repeat(bits) {
|
|
||||||
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1
|
|
||||||
value = (value shl 1) or bit.toULong()
|
|
||||||
readPointer++
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reserveBits(bitsToWrite: Long) {
|
|
||||||
if (bitsToWrite <= 0L) return
|
|
||||||
|
|
||||||
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
|
|
||||||
if (lastBitIndex < 0L) {
|
|
||||||
throw IllegalStateException("Bit index overflow")
|
|
||||||
}
|
|
||||||
|
|
||||||
val byteIndex = lastBitIndex ushr 3
|
|
||||||
if (byteIndex > Int.MAX_VALUE.toLong()) {
|
|
||||||
throw IllegalStateException("Stream too large")
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureCapacity(byteIndex.toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureCapacity(index: Int) {
|
private fun ensureCapacity(index: Int) {
|
||||||
val requiredSize = index + 1
|
while (_stream.size <= index) {
|
||||||
if (requiredSize <= stream.size) return
|
_stream.add(0)
|
||||||
|
|
||||||
var newSize = if (stream.isEmpty()) 32 else stream.size
|
|
||||||
while (newSize < requiredSize) {
|
|
||||||
if (newSize > (Int.MAX_VALUE shr 1)) {
|
|
||||||
newSize = requiredSize
|
|
||||||
break
|
|
||||||
}
|
|
||||||
newSize = newSize shl 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val next = ByteArray(newSize)
|
|
||||||
System.arraycopy(stream, 0, next, 0, stream.size)
|
|
||||||
stream = next
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,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
|
||||||
@@ -381,6 +382,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) {
|
||||||
@@ -2974,7 +2982,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 +3025,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (simplePickerPreviewUri != null) {
|
if (simplePickerPreviewUri != null || isCallActive) {
|
||||||
return@MessageBubble
|
return@MessageBubble
|
||||||
}
|
}
|
||||||
if (shouldIgnoreTapAfterLongPress(
|
if (shouldIgnoreTapAfterLongPress(
|
||||||
@@ -3039,12 +3047,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
|
||||||
|
|||||||
@@ -4641,6 +4641,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
|
encryptedPlainMessage, // 🔒 Зашифрованный текст для хранения в
|
||||||
// БД
|
// БД
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType =
|
||||||
|
resolvePrimaryAttachmentTypeFromJson(attachmentsJson),
|
||||||
replyToMessageId = null,
|
replyToMessageId = null,
|
||||||
dialogKey = dialogKey
|
dialogKey = dialogKey
|
||||||
)
|
)
|
||||||
@@ -4649,6 +4651,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} catch (e: Exception) {}
|
} catch (e: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolvePrimaryAttachmentTypeFromJson(attachmentsJson: String): Int {
|
||||||
|
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return -1
|
||||||
|
return try {
|
||||||
|
val array = JSONArray(attachmentsJson)
|
||||||
|
if (array.length() == 0) return -1
|
||||||
|
val first = array.optJSONObject(0) ?: return -1
|
||||||
|
first.optInt("type", -1)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showTypingIndicator() {
|
private fun showTypingIndicator() {
|
||||||
_opponentTyping.value = true
|
_opponentTyping.value = true
|
||||||
// Отменяем предыдущий таймер, чтобы избежать race condition
|
// Отменяем предыдущий таймер, чтобы избежать race condition
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ 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.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.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,6 +262,7 @@ 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 = {},
|
||||||
@@ -477,6 +480,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 +524,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 +545,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 +603,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 +627,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 +644,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 +666,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 +826,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 +1254,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 +1490,7 @@ fun ChatsListScreen(
|
|||||||
key(
|
key(
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
showRequestsScreen,
|
showRequestsScreen,
|
||||||
|
showCallsScreen,
|
||||||
showDownloadsScreen,
|
showDownloadsScreen,
|
||||||
isSelectionMode
|
isSelectionMode
|
||||||
) {
|
) {
|
||||||
@@ -1553,11 +1631,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 +1732,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 +1778,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 +1935,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 +2030,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 +2106,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) {
|
||||||
@@ -2592,7 +2698,9 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // 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 +2712,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 +4803,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(
|
||||||
|
|||||||
@@ -96,27 +96,17 @@ fun CallOverlay(
|
|||||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// ── Top bar: "Encrypted" left + QR icon right ──
|
// ── Top-right QR icon ──
|
||||||
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
|
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
contentAlignment = Alignment.CenterEnd
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Text(
|
EncryptionKeyButton(keyHex = state.keyCast)
|
||||||
text = "\uD83D\uDD12 Encrypted",
|
|
||||||
color = Color.White.copy(alpha = 0.4f),
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
|
|
||||||
// QR grid icon — tap to show popover
|
|
||||||
if (state.keyCast.isNotBlank()) {
|
|
||||||
EncryptionKeyButton(keyHex = state.keyCast)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
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.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)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.peerTitle,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1593,7 +1593,7 @@ private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopC
|
|||||||
}
|
}
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (isError) {
|
if (isError) {
|
||||||
"Call was not answered or was rejected"
|
if (isOutgoing) "Rejected" else "Missed"
|
||||||
} else {
|
} else {
|
||||||
formatDesktopCallDuration(durationSec)
|
formatDesktopCallDuration(durationSec)
|
||||||
}
|
}
|
||||||
@@ -1612,19 +1612,14 @@ fun CallAttachment(
|
|||||||
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 +1685,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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -652,12 +652,17 @@ fun MessageInputBar(
|
|||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
if (panelReplyMessages.isNotEmpty()) {
|
if (panelReplyMessages.isNotEmpty()) {
|
||||||
val msg = panelReplyMessages.first()
|
val msg = panelReplyMessages.first()
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user