Compare commits

..

4 Commits

19 changed files with 1143 additions and 490 deletions

View File

@@ -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
### Групповые чаты и медиа ### Групповые чаты и медиа

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.2" val rosettaVersionName = "1.3.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 {

View File

@@ -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
) )
} }
} }

View File

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

View File

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

View File

@@ -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 =

View File

@@ -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(
""" """

View File

@@ -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() // Для разработки - только
// если миграция не // если миграция не

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
@@ -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

View File

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

View File

@@ -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
} }
} }

View File

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

View File

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

View File

@@ -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(

View File

@@ -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)
}
} }
} }

View File

@@ -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))
}
}

View File

@@ -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)
)
}
}
}
}
} }
} }
} }

View File

@@ -666,6 +666,15 @@ fun MessageBubble(
.IMAGE .IMAGE
} }
val isCallMessage =
message.attachments.isNotEmpty() &&
message.text.isEmpty() &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.all {
it.type == AttachmentType.CALL
}
val isStandaloneGroupInvite = val isStandaloneGroupInvite =
message.attachments.isEmpty() && message.attachments.isEmpty() &&
message.replyData == null && message.replyData == null &&
@@ -794,7 +803,8 @@ fun MessageBubble(
onLongClick = onLongClick onLongClick = onLongClick
) )
.then( .then(
if (false) { if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
Modifier Modifier
} else { } else {
Modifier.clip(bubbleShape) Modifier.clip(bubbleShape)

View File

@@ -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)