feat: Enhance group chat functionality and UI improvements

- Added support for group action system messages in MessageBubble.
- Implemented group invite handling with inline cards for joining groups.
- Updated MessageBubble to display group sender labels and admin badges.
- Enhanced image decryption logic for group attachments.
- Modified BlurredAvatarBackground to load system avatars based on public keys.
- Improved SwipeBackContainer with layer management for better swipe effects.
- Updated VerifiedBadge to use dynamic icons based on user verification status.
- Added new drawable resource for admin badge icon.
This commit is contained in:
2026-03-01 00:01:01 +05:00
parent 3f2b52b578
commit a0569648e8
28 changed files with 5053 additions and 483 deletions

View File

@@ -42,6 +42,8 @@ import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
import com.rosetta.messenger.ui.chats.GroupInfoScreen
import com.rosetta.messenger.ui.chats.GroupSetupScreen
import com.rosetta.messenger.ui.chats.RequestsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
@@ -498,6 +500,8 @@ sealed class Screen {
data object Profile : Screen()
data object Requests : Screen()
data object Search : Screen()
data object GroupSetup : Screen()
data class GroupInfo(val group: SearchUser) : Screen()
data class ChatDetail(val user: SearchUser) : Screen()
data class OtherProfile(val user: SearchUser) : Screen()
data object Updates : Screen()
@@ -600,10 +604,15 @@ fun MainScreen(
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
val chatDetailScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() }
}
val selectedUser = chatDetailScreen?.user
val groupInfoScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.GroupInfo>().lastOrNull() }
}
val selectedGroup = groupInfoScreen?.group
val otherProfileScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().lastOrNull() }
}
@@ -650,7 +659,10 @@ fun MainScreen(
}
}
fun popChatAndChildren() {
navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile }
navStack =
navStack.filterNot {
it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo
}
}
// ProfileViewModel для логов
@@ -689,7 +701,7 @@ fun MainScreen(
// 🔥 Простая навигация с swipe back
Box(modifier = Modifier.fillMaxSize()) {
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize()) {
SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize(), layer = 0) {
ChatsListScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
@@ -702,7 +714,7 @@ fun MainScreen(
onToggleTheme = onToggleTheme,
onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = {
// TODO: Navigate to new group
pushScreen(Screen.GroupSetup)
},
onContactsClick = {
// TODO: Navigate to contacts
@@ -754,7 +766,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isRequestsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 1
) {
RequestsListScreen(
isDarkTheme = isDarkTheme,
@@ -783,7 +796,9 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isProfileVisible,
onBack = { popProfileAndChildren() },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 1,
propagateBackgroundProgress = false
) {
// Экран профиля
ProfileScreen(
@@ -816,7 +831,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isSafetyVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
SafetyScreen(
isDarkTheme = isDarkTheme,
@@ -833,7 +849,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isBackupVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Backup } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 3
) {
BackupScreen(
isDarkTheme = isDarkTheme,
@@ -878,7 +895,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
ThemeScreen(
isDarkTheme = isDarkTheme,
@@ -891,7 +909,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isAppearanceVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
com.rosetta.messenger.ui.settings.AppearanceScreen(
isDarkTheme = isDarkTheme,
@@ -912,7 +931,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isUpdatesVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
UpdatesScreen(
isDarkTheme = isDarkTheme,
@@ -938,6 +958,7 @@ fun MainScreen(
isVisible = selectedUser != null,
onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked,
propagateBackgroundProgress = false
) {
@@ -959,6 +980,9 @@ fun MainScreen(
pushScreen(Screen.OtherProfile(user))
}
},
onGroupInfoClick = { groupUser ->
pushScreen(Screen.GroupInfo(groupUser))
},
onNavigateToChat = { forwardUser ->
// 📨 Forward: переход в выбранный чат с полными данными
navStack =
@@ -973,10 +997,54 @@ fun MainScreen(
}
}
var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) }
LaunchedEffect(selectedGroup?.publicKey) {
isGroupInfoSwipeEnabled = true
}
SwipeBackContainer(
isVisible = selectedGroup != null,
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
isDarkTheme = isDarkTheme,
layer = 2,
swipeEnabled = isGroupInfoSwipeEnabled,
propagateBackgroundProgress = false
) {
selectedGroup?.let { groupUser ->
GroupInfoScreen(
groupUser = groupUser,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
onMemberClick = { member ->
if (member.publicKey == accountPublicKey) {
pushScreen(Screen.Profile)
} else {
pushScreen(Screen.OtherProfile(member))
}
},
onSwipeBackEnabledChanged = { enabled ->
isGroupInfoSwipeEnabled = enabled
},
onGroupLeft = {
navStack =
navStack.filterNot {
it is Screen.GroupInfo ||
(it is Screen.ChatDetail &&
it.user.publicKey == groupUser.publicKey)
}
}
)
}
}
SwipeBackContainer(
isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 1
) {
// Экран поиска
SearchScreen(
@@ -996,10 +1064,30 @@ fun MainScreen(
)
}
SwipeBackContainer(
isVisible = isGroupSetupVisible,
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
isDarkTheme = isDarkTheme,
layer = 1
) {
GroupSetupScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser ->
navStack =
navStack.filterNot { it is Screen.GroupSetup } +
Screen.ChatDetail(groupUser)
}
)
}
SwipeBackContainer(
isVisible = isLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Logs } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
com.rosetta.messenger.ui.settings.ProfileLogsScreen(
isDarkTheme = isDarkTheme,
@@ -1012,7 +1100,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isCrashLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 1
) {
CrashLogsScreen(
onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } }
@@ -1022,7 +1111,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isConnectionLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 1
) {
ConnectionLogsScreen(
isDarkTheme = isDarkTheme,
@@ -1039,7 +1129,9 @@ fun MainScreen(
isVisible = selectedOtherUser != null,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
isDarkTheme = isDarkTheme,
swipeEnabled = isOtherProfileSwipeEnabled
layer = 2,
swipeEnabled = isOtherProfileSwipeEnabled,
propagateBackgroundProgress = false
) {
selectedOtherUser?.let { currentOtherUser ->
OtherProfileScreen(
@@ -1066,7 +1158,8 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isBiometricVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Biometric } },
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
layer = 2
) {
val biometricManager = remember {
com.rosetta.messenger.biometric.BiometricAuthManager(context)

View File

@@ -0,0 +1,431 @@
package com.rosetta.messenger.data
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.GroupEntity
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketCreateGroup
import com.rosetta.messenger.network.PacketGroupBan
import com.rosetta.messenger.network.PacketGroupInfo
import com.rosetta.messenger.network.PacketGroupInviteInfo
import com.rosetta.messenger.network.PacketGroupJoin
import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager
import java.security.SecureRandom
import java.util.UUID
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
class GroupRepository private constructor(context: Context) {
private val db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao()
private val messageDao = db.messageDao()
private val dialogDao = db.dialogDao()
companion object {
private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
@Volatile
private var INSTANCE: GroupRepository? = null
fun getInstance(context: Context): GroupRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GroupRepository(context).also { INSTANCE = it }
}
}
}
data class ParsedGroupInvite(
val groupId: String,
val title: String,
val encryptKey: String,
val description: String
)
data class GroupJoinResult(
val success: Boolean,
val dialogPublicKey: String? = null,
val title: String = "",
val status: GroupStatus = GroupStatus.NOT_JOINED,
val error: String? = null
)
data class GroupInviteInfoResult(
val groupId: String,
val membersCount: Int,
val status: GroupStatus
)
fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith(GROUP_PREFIX) || normalized.startsWith("group:")
}
fun normalizeGroupId(value: String): String {
val trimmed = value.trim()
return when {
trimmed.startsWith(GROUP_PREFIX) -> trimmed.removePrefix(GROUP_PREFIX).trim()
trimmed.startsWith("group:", ignoreCase = true) ->
trimmed.substringAfter(':').trim()
else -> trimmed
}
}
fun toGroupDialogPublicKey(groupId: String): String = "$GROUP_PREFIX${normalizeGroupId(groupId)}"
fun constructInviteString(
groupId: String,
title: String,
encryptKey: String,
description: String = ""
): String {
val normalizedGroupId = normalizeGroupId(groupId)
if (normalizedGroupId.isBlank() || title.isBlank() || encryptKey.isBlank()) {
return ""
}
val payload = buildString {
append(normalizedGroupId)
append(':')
append(title)
append(':')
append(encryptKey)
if (description.isNotBlank()) {
append(':')
append(description)
}
}
val encoded = CryptoManager.encryptWithPassword(payload, GROUP_INVITE_PASSWORD)
return "$GROUP_PREFIX$encoded"
}
fun parseInviteString(inviteString: String): ParsedGroupInvite? {
if (!inviteString.trim().startsWith(GROUP_PREFIX)) return null
val encodedPayload = inviteString.trim().removePrefix(GROUP_PREFIX)
if (encodedPayload.isBlank()) return null
val decodedPayload =
CryptoManager.decryptWithPassword(encodedPayload, GROUP_INVITE_PASSWORD) ?: return null
val parts = decodedPayload.split(':')
if (parts.size < 3) return null
return ParsedGroupInvite(
groupId = normalizeGroupId(parts[0]),
title = parts[1],
encryptKey = parts[2],
description = parts.drop(3).joinToString(":")
)
}
suspend fun getGroupKey(
accountPublicKey: String,
accountPrivateKey: String,
groupPublicKeyOrId: String
): String? {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null
val stored = groupDao.getGroup(accountPublicKey, groupId) ?: return null
return CryptoManager.decryptWithPassword(stored.key, accountPrivateKey)
}
suspend fun getGroup(accountPublicKey: String, groupPublicKeyOrId: String): GroupEntity? {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null
return groupDao.getGroup(accountPublicKey, groupId)
}
suspend fun requestGroupMembers(groupPublicKeyOrId: String): List<String>? {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null
val packet = PacketGroupInfo().apply {
this.groupId = groupId
this.members = emptyList()
}
ProtocolManager.send(packet)
val response = awaitPacketOnce<PacketGroupInfo>(
packetId = 0x12,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return null
return response.members
}
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null
val packet = PacketGroupInviteInfo().apply {
this.groupId = groupId
this.membersCount = 0
this.groupStatus = GroupStatus.NOT_JOINED
}
ProtocolManager.send(packet)
val response = awaitPacketOnce<PacketGroupInviteInfo>(
packetId = 0x13,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return null
return GroupInviteInfoResult(
groupId = groupId,
membersCount = response.membersCount.coerceAtLeast(0),
status = response.groupStatus
)
}
suspend fun createGroup(
accountPublicKey: String,
accountPrivateKey: String,
title: String,
description: String = ""
): GroupJoinResult {
if (title.isBlank()) {
return GroupJoinResult(success = false, error = "Title is empty")
}
val createPacket = PacketCreateGroup()
ProtocolManager.send(createPacket)
val response = awaitPacketOnce<PacketCreateGroup>(
packetId = 0x11,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { packet -> packet.groupId.isNotBlank() }
?: return GroupJoinResult(success = false, error = "Create group timeout")
val groupId = normalizeGroupId(response.groupId)
if (groupId.isBlank()) {
return GroupJoinResult(success = false, error = "Server returned empty group id")
}
val groupKey = generateGroupKey()
val invite = constructInviteString(groupId, title.trim(), groupKey, description.trim())
if (invite.isBlank()) {
return GroupJoinResult(success = false, error = "Failed to construct invite")
}
return joinGroup(accountPublicKey, accountPrivateKey, invite)
}
suspend fun joinGroup(
accountPublicKey: String,
accountPrivateKey: String,
inviteString: String
): GroupJoinResult {
val parsed = parseInviteString(inviteString)
?: return GroupJoinResult(success = false, error = "Invalid invite string")
val encodedGroupStringForServer =
CryptoManager.encryptWithPassword(inviteString, accountPrivateKey)
val packet = PacketGroupJoin().apply {
groupId = parsed.groupId
groupString = encodedGroupStringForServer
groupStatus = GroupStatus.NOT_JOINED
}
ProtocolManager.send(packet)
val response = awaitPacketOnce<PacketGroupJoin>(
packetId = 0x14,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { incoming -> normalizeGroupId(incoming.groupId) == parsed.groupId }
?: return GroupJoinResult(success = false, error = "Join group timeout")
if (response.groupStatus != GroupStatus.JOINED) {
return GroupJoinResult(
success = false,
status = response.groupStatus,
title = parsed.title,
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
error = "Join rejected"
)
}
persistJoinedGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
parsedInvite = parsed,
emitSystemJoinMessage = true
)
return GroupJoinResult(
success = true,
status = GroupStatus.JOINED,
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
title = parsed.title
)
}
suspend fun synchronizeJoinedGroup(
accountPublicKey: String,
accountPrivateKey: String,
packet: PacketGroupJoin
): GroupJoinResult? {
if (packet.groupStatus != GroupStatus.JOINED) return null
if (packet.groupString.isBlank()) return null
val decryptedInvite =
CryptoManager.decryptWithPassword(packet.groupString, accountPrivateKey) ?: return null
val parsed = parseInviteString(decryptedInvite) ?: return null
persistJoinedGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
parsedInvite = parsed,
emitSystemJoinMessage = false
)
return GroupJoinResult(
success = true,
status = GroupStatus.JOINED,
dialogPublicKey = toGroupDialogPublicKey(parsed.groupId),
title = parsed.title
)
}
suspend fun leaveGroup(accountPublicKey: String, groupPublicKeyOrId: String): Boolean {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return false
val packet = PacketGroupLeave().apply {
this.groupId = groupId
}
ProtocolManager.send(packet)
val response = awaitPacketOnce<PacketGroupLeave>(
packetId = 0x15,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return false
if (normalizeGroupId(response.groupId) != groupId) return false
val groupDialogKey = toGroupDialogPublicKey(groupId)
groupDao.deleteGroup(accountPublicKey, groupId)
messageDao.deleteDialog(accountPublicKey, groupDialogKey)
dialogDao.deleteDialog(accountPublicKey, groupDialogKey)
return true
}
suspend fun kickMember(groupPublicKeyOrId: String, memberPublicKey: String): Boolean {
val groupId = normalizeGroupId(groupPublicKeyOrId)
val targetPublicKey = memberPublicKey.trim()
if (groupId.isBlank() || targetPublicKey.isBlank()) return false
val packet = PacketGroupBan().apply {
this.groupId = groupId
this.publicKey = targetPublicKey
}
ProtocolManager.send(packet)
val response = awaitPacketOnce<PacketGroupBan>(
packetId = 0x16,
timeoutMs = GROUP_WAIT_TIMEOUT_MS
) { incoming ->
normalizeGroupId(incoming.groupId) == groupId &&
incoming.publicKey.trim().equals(targetPublicKey, ignoreCase = true)
} ?: return false
return normalizeGroupId(response.groupId) == groupId &&
response.publicKey.trim().equals(targetPublicKey, ignoreCase = true)
}
private suspend fun persistJoinedGroup(
accountPublicKey: String,
accountPrivateKey: String,
parsedInvite: ParsedGroupInvite,
emitSystemJoinMessage: Boolean
) {
val encryptedGroupKey =
CryptoManager.encryptWithPassword(parsedInvite.encryptKey, accountPrivateKey)
groupDao.insertGroup(
GroupEntity(
account = accountPublicKey,
groupId = parsedInvite.groupId,
title = parsedInvite.title,
description = parsedInvite.description,
key = encryptedGroupKey
)
)
val dialogPublicKey = toGroupDialogPublicKey(parsedInvite.groupId)
if (emitSystemJoinMessage) {
val joinText = "\$a=Group joined"
val encryptedPlainMessage = CryptoManager.encryptWithPassword(joinText, accountPrivateKey)
val encryptedContent = CryptoManager.encryptWithPassword(joinText, parsedInvite.encryptKey)
messageDao.insertMessage(
MessageEntity(
account = accountPublicKey,
fromPublicKey = accountPublicKey,
toPublicKey = dialogPublicKey,
content = encryptedContent,
timestamp = System.currentTimeMillis(),
chachaKey = buildStoredGroupKey(parsedInvite.encryptKey, accountPrivateKey),
read = 1,
fromMe = 1,
delivered = 1,
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
plainMessage = encryptedPlainMessage,
attachments = "[]",
dialogKey = dialogPublicKey
)
)
dialogDao.updateDialogFromMessages(accountPublicKey, dialogPublicKey)
}
// Ensure title is present after updateDialogFromMessages for list/header rendering.
dialogDao.updateOpponentDisplayName(
accountPublicKey,
dialogPublicKey,
parsedInvite.title,
parsedInvite.description
)
}
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
return "group:$encrypted"
}
private fun generateGroupKey(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private suspend inline fun <reified T : Packet> awaitPacketOnce(
packetId: Int,
timeoutMs: Long,
crossinline predicate: (T) -> Boolean = { true }
): T? {
return withTimeoutOrNull(timeoutMs) {
suspendCancellableCoroutine { continuation ->
lateinit var callback: (Packet) -> Unit
callback = { packet ->
val typedPacket = packet as? T
if (typedPacket != null && predicate(typedPacket)) {
ProtocolManager.unwaitPacket(packetId, callback)
continuation.resume(typedPacket)
}
}
ProtocolManager.waitPacket(packetId, callback)
continuation.invokeOnCancellation {
ProtocolManager.unwaitPacket(packetId, callback)
}
}
}
}
}

View File

@@ -50,6 +50,7 @@ class MessageRepository private constructor(private val context: Context) {
private val dialogDao = database.dialogDao()
private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -365,6 +366,23 @@ class MessageRepository private constructor(private val context: Context) {
return currentAccount != null && currentPrivateKey != null
}
/**
* Clear active account binding for this process session.
* Must be called on explicit logout/account switch so stale account context
* cannot be reused after reconnect.
*/
fun clearInitialization() {
currentAccount = null
currentPrivateKey = null
requestedUserInfoKeys.clear()
messageCache.clear()
clearProcessedCache()
}
fun getCurrentAccountKey(): String? = currentAccount
fun getCurrentPrivateKey(): String? = currentPrivateKey
suspend fun getLastSyncTimestamp(): Long {
val account = currentAccount ?: return 0L
val stored = syncTimeDao.getLastSync(account) ?: 0L
@@ -647,9 +665,10 @@ class MessageRepository private constructor(private val context: Context) {
)
val isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
// 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage) {
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
if (isBlocked) {
MessageLogger.logBlockedSender(packet.fromPublicKey)
@@ -688,25 +707,51 @@ class MessageRepository private constructor(private val context: Context) {
return true
}
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
val dialogOpponentKey =
when {
isGroupMessage -> packet.toPublicKey
isOwnMessage -> packet.toPublicKey
else -> packet.fromPublicKey
}
val dialogKey = getDialogKey(dialogOpponentKey)
try {
val groupKey =
if (isGroupMessage) {
val fromPacket =
if (packet.aesChachaKey.isNotBlank()) {
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
} else {
null
}
fromPacket ?: resolveGroupKeyForDialog(account, privateKey, packet.toPublicKey)
} else {
null
}
if (isGroupMessage && groupKey.isNullOrBlank()) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
)
processedMessageIds.remove(messageId)
return false
}
val plainKeyAndNonce =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
?.toByteArray(Charsets.ISO_8859_1)
} else {
null
}
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
)
}
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
if (!isGroupMessage && isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
MessageLogger.debug(
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
)
@@ -714,7 +759,10 @@ class MessageRepository private constructor(private val context: Context) {
// Расшифровываем
val plainText =
if (plainKeyAndNonce != null) {
if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload")
} else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
@@ -733,7 +781,8 @@ class MessageRepository private constructor(private val context: Context) {
packet.attachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce
plainKeyAndNonce,
groupKey
)
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
@@ -742,7 +791,8 @@ class MessageRepository private constructor(private val context: Context) {
packet.chachaKey,
privateKey,
plainKeyAndNonce,
messageId
messageId,
groupKey
)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
@@ -751,14 +801,17 @@ class MessageRepository private constructor(private val context: Context) {
packet.fromPublicKey,
packet.chachaKey,
privateKey,
plainKeyAndNonce
plainKeyAndNonce,
groupKey
)
// 🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
val storedChachaKey =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
if (isGroupMessage) {
buildStoredGroupKey(groupKey!!, privateKey)
} else if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
"sync:${packet.aesChachaKey}"
} else {
packet.chachaKey
@@ -807,8 +860,19 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
// Desktop parity: always re-fetch on incoming message so renamed contacts
// get their new name/username updated in the chat list.
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey)
if (!isGroupDialogKey(dialogOpponentKey)) {
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey)
} else {
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
val senderKey = packet.fromPublicKey.trim()
if (senderKey.isNotBlank() &&
senderKey != account &&
!isGroupDialogKey(senderKey)
) {
requestUserInfo(senderKey)
}
}
// Обновляем кэш только если сообщение новое
if (!stillExists) {
@@ -889,6 +953,10 @@ class MessageRepository private constructor(private val context: Context) {
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
val isOwnReadSync = fromPublicKey == account
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
// Group read receipts are currently not mapped to per-message states.
return
}
val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
if (opponentKey.isBlank()) return
@@ -1038,6 +1106,8 @@ class MessageRepository private constructor(private val context: Context) {
// We can re-send the PacketMessage directly using stored fields.
val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
entity.chachaKey.removePrefix("sync:")
} else if (entity.chachaKey.startsWith("group:")) {
entity.chachaKey.removePrefix("group:")
} else {
// Re-generate aesChachaKey from the stored chachaKey + privateKey.
// The chachaKey in DB is the ECC-encrypted key for the recipient.
@@ -1058,7 +1128,15 @@ class MessageRepository private constructor(private val context: Context) {
this.fromPublicKey = account
this.toPublicKey = entity.toPublicKey
this.content = entity.content
this.chachaKey = if (entity.chachaKey.startsWith("sync:")) "" else entity.chachaKey
this.chachaKey =
if (
entity.chachaKey.startsWith("sync:") ||
entity.chachaKey.startsWith("group:")
) {
""
} else {
entity.chachaKey
}
this.aesChachaKey = aesChachaKeyValue
this.timestamp = entity.timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1088,6 +1166,9 @@ class MessageRepository private constructor(private val context: Context) {
*/
private fun getDialogKey(opponentKey: String): String {
val account = currentAccount ?: return opponentKey
if (isGroupDialogKey(opponentKey)) {
return opponentKey.trim()
}
// Для saved messages dialog_key = просто publicKey
if (account == opponentKey) {
return account
@@ -1096,6 +1177,54 @@ class MessageRepository private constructor(private val context: Context) {
return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account"
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun normalizeGroupId(value: String): String {
val trimmed = value.trim()
return when {
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
else -> trimmed
}
}
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
}
private fun decodeStoredGroupKey(storedKey: String, privateKey: String): String? {
if (!storedKey.startsWith("group:")) return null
val encoded = storedKey.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private suspend fun resolveGroupKeyForDialog(
account: String,
privateKey: String,
groupDialogPublicKey: String
): String? {
val groupId = normalizeGroupId(groupDialogPublicKey)
if (groupId.isBlank()) return null
val group = groupDao.getGroup(account, groupId) ?: return null
return CryptoManager.decryptWithPassword(group.key, privateKey)
}
private suspend fun applyGroupDisplayNameToDialog(account: String, dialogKey: String) {
val groupId = normalizeGroupId(dialogKey)
if (groupId.isBlank()) return
val group = groupDao.getGroup(account, groupId) ?: return
dialogDao.updateOpponentDisplayName(
account = account,
opponentKey = dialogKey,
title = group.title,
username = group.description
)
}
private fun updateMessageCache(dialogKey: String, message: Message) {
messageCache[dialogKey]?.let { flow ->
val currentList = flow.value.toMutableList()
@@ -1252,6 +1381,7 @@ class MessageRepository private constructor(private val context: Context) {
for (dialog in dialogs) {
// Skip self (Saved Messages)
if (dialog.opponentKey == account) continue
if (isGroupDialogKey(dialog.opponentKey)) continue
// Skip if already requested in this cycle
if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
requestedUserInfoKeys.add(dialog.opponentKey)
@@ -1271,6 +1401,7 @@ class MessageRepository private constructor(private val context: Context) {
* Use when opening a dialog to ensure the name/username is fresh.
*/
fun forceRequestUserInfo(publicKey: String) {
if (isGroupDialogKey(publicKey)) return
requestedUserInfoKeys.remove(publicKey)
requestUserInfo(publicKey)
}
@@ -1281,6 +1412,7 @@ class MessageRepository private constructor(private val context: Context) {
*/
fun requestUserInfo(publicKey: String) {
val privateKey = currentPrivateKey ?: return
if (isGroupDialogKey(publicKey)) return
// 🔥 Не запрашиваем если уже запрашивали
if (requestedUserInfoKeys.contains(publicKey)) {
@@ -1396,7 +1528,8 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey: String,
encryptedKey: String,
privateKey: String,
plainKeyAndNonce: ByteArray? = null
plainKeyAndNonce: ByteArray? = null,
groupKey: String? = null
) {
for (attachment in attachments) {
@@ -1406,14 +1539,18 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш
@@ -1446,7 +1583,8 @@ class MessageRepository private constructor(private val context: Context) {
encryptedKey: String,
privateKey: String,
plainKeyAndNonce: ByteArray? = null,
messageId: String = ""
messageId: String = "",
groupKey: String? = null
) {
val publicKey = currentAccount ?: return
@@ -1458,14 +1596,18 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Сохраняем в файл (как в desktop)
@@ -1503,7 +1645,8 @@ class MessageRepository private constructor(private val context: Context) {
attachments: List<MessageAttachment>,
encryptedKey: String,
privateKey: String,
plainKeyAndNonce: ByteArray? = null
plainKeyAndNonce: ByteArray? = null,
groupKey: String? = null
): String {
if (attachments.isEmpty()) return "[]"
@@ -1517,14 +1660,18 @@ class MessageRepository private constructor(private val context: Context) {
try {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob =
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
}
?: MessageCrypto.decryptAttachmentBlob(
attachment.blob,
encryptedKey,
privateKey
)
if (decryptedBlob != null) {
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)

View File

@@ -141,6 +141,39 @@ data class DialogEntity(
"[]" // 📎 JSON attachments последнего сообщения (кэш из messages)
)
/** Entity для групповых диалогов */
@Entity(
tableName = "groups",
indices =
[
Index(value = ["account", "group_id"], unique = true)]
)
data class GroupEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "account") val account: String,
@ColumnInfo(name = "group_id") val groupId: String,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "description") val description: String = "",
@ColumnInfo(name = "key") val key: String
)
@Dao
interface GroupDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGroup(group: GroupEntity): Long
@Query(
"SELECT * FROM groups WHERE account = :account AND group_id = :groupId LIMIT 1"
)
suspend fun getGroup(account: String, groupId: String): GroupEntity?
@Query("DELETE FROM groups WHERE account = :account AND group_id = :groupId")
suspend fun deleteGroup(account: String, groupId: String): Int
@Query("DELETE FROM groups WHERE account = :account")
suspend fun deleteAllByAccount(account: String): Int
}
/** DAO для работы с сообщениями */
@Dao
interface MessageDao {
@@ -476,8 +509,12 @@ interface DialogDao {
"""
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 1
AND last_message_timestamp > 0
AND (
i_have_sent = 1
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -489,8 +526,12 @@ interface DialogDao {
"""
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 1
AND last_message_timestamp > 0
AND (
i_have_sent = 1
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -506,7 +547,9 @@ interface DialogDao {
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 0
AND last_message_timestamp > 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -519,7 +562,9 @@ interface DialogDao {
SELECT COUNT(*) FROM dialogs
WHERE account = :account
AND i_have_sent = 0
AND last_message_timestamp > 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
"""
)
fun getRequestsCountFlow(account: String): Flow<Int>
@@ -532,7 +577,8 @@ interface DialogDao {
@Query("""
SELECT * FROM dialogs
WHERE account = :account
AND last_message_timestamp > 0
AND (last_message != '' OR last_message_attachments != '[]')
AND opponent_key NOT LIKE '#group:%'
AND (
opponent_title = ''
OR opponent_title = opponent_key
@@ -709,6 +755,32 @@ interface DialogDao {
)
suspend fun hasSentToOpponent(account: String, opponentKey: String): Boolean
/** 🚀 Универсальный подсчет непрочитанных входящих по dialog_key (direct + group) */
@Query(
"""
SELECT COUNT(*) FROM messages
WHERE account = :account
AND dialog_key = :dialogKey
AND from_me = 0
AND read = 0
"""
)
suspend fun countUnreadByDialogKey(account: String, dialogKey: String): Int
/** 🚀 Универсальная проверка исходящих сообщений по dialog_key (direct + group) */
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM messages
WHERE account = :account
AND dialog_key = :dialogKey
AND from_me = 1
LIMIT 1
)
"""
)
suspend fun hasSentByDialogKey(account: String, dialogKey: String): Boolean
/**
* 🚀 ОПТИМИЗИРОВАННЫЙ updateDialogFromMessages Заменяет монолитный SQL с 9 коррелированными
* подзапросами на:
@@ -720,31 +792,40 @@ interface DialogDao {
*/
@Transaction
suspend fun updateDialogFromMessages(account: String, opponentKey: String) {
val normalizedOpponentKey = opponentKey.trim()
val normalizedOpponentKeyLower = normalizedOpponentKey.lowercase()
val isGroupDialog =
normalizedOpponentKeyLower.startsWith("#group:") ||
normalizedOpponentKeyLower.startsWith("group:")
val isSystemDialog =
normalizedOpponentKey == "0x000000000000000000000000000000000000000001" ||
normalizedOpponentKey == "0x000000000000000000000000000000000000000002"
// 📁 Для saved messages dialogKey = account (не "$account:$account")
val dialogKey =
if (account == opponentKey) account
else if (account < opponentKey) "$account:$opponentKey"
else "$opponentKey:$account"
if (account == normalizedOpponentKey) account
else if (isGroupDialog) normalizedOpponentKey
else if (account < normalizedOpponentKey) "$account:$normalizedOpponentKey"
else "$normalizedOpponentKey:$account"
// 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp)
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
// 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...)
val existing = getDialog(account, opponentKey)
val existing = getDialog(account, normalizedOpponentKey)
// 3. Считаем непрочитанные — O(N) по индексу (account, from_public_key, to_public_key,
// timestamp)
val unread = countUnreadFromOpponent(account, opponentKey)
// 3. Считаем непрочитанные входящие по dialog_key.
val unread = countUnreadByDialogKey(account, dialogKey)
// 4. Проверяем были ли исходящие — O(1)
val hasSent = hasSentToOpponent(account, opponentKey)
val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными
insertDialog(
DialogEntity(
id = existing?.id ?: 0,
account = account,
opponentKey = opponentKey,
opponentKey = normalizedOpponentKey,
opponentTitle = existing?.opponentTitle ?: "",
opponentUsername = existing?.opponentUsername ?: "",
lastMessage = lastMsg.plainMessage,
@@ -753,7 +834,8 @@ interface DialogDao {
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0,
iHaveSent = if (hasSent) 1 else (existing?.iHaveSent ?: 0),
// Desktop parity: request flag is always derived from message history.
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,

View File

@@ -16,8 +16,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
BlacklistEntity::class,
AvatarCacheEntity::class,
AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class],
version = 13,
version = 14,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -27,6 +28,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao
companion object {
@@ -176,6 +178,30 @@ abstract class RosettaDatabase : RoomDatabase() {
}
}
/**
* 👥 МИГРАЦИЯ 13->14: Таблица groups для групповых диалогов
*/
private val MIGRATION_13_14 =
object : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
account TEXT NOT NULL,
group_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
`key` TEXT NOT NULL
)
"""
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_groups_account_group_id ON groups (account, group_id)"
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE
?: synchronized(this) {
@@ -197,7 +223,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13
MIGRATION_12_13,
MIGRATION_13_14
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не

View File

@@ -0,0 +1,12 @@
package com.rosetta.messenger.network
enum class GroupStatus(val value: Int) {
JOINED(0),
INVALID(1),
NOT_JOINED(2),
BANNED(3);
companion object {
fun fromInt(value: Int): GroupStatus = entries.firstOrNull { it.value == value } ?: NOT_JOINED
}
}

View File

@@ -0,0 +1,18 @@
package com.rosetta.messenger.network
class PacketCreateGroup : Packet() {
var groupId: String = ""
override fun getPacketId(): Int = 0x11
override fun receive(stream: Stream) {
groupId = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
return stream
}
}

View File

@@ -0,0 +1,21 @@
package com.rosetta.messenger.network
class PacketGroupBan : Packet() {
var groupId: String = ""
var publicKey: String = ""
override fun getPacketId(): Int = 0x16
override fun receive(stream: Stream) {
groupId = stream.readString()
publicKey = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
stream.writeString(publicKey)
return stream
}
}

View File

@@ -0,0 +1,27 @@
package com.rosetta.messenger.network
class PacketGroupInfo : Packet() {
var groupId: String = ""
var members: List<String> = emptyList()
override fun getPacketId(): Int = 0x12
override fun receive(stream: Stream) {
groupId = stream.readString()
val count = stream.readInt16().coerceAtLeast(0)
val parsed = ArrayList<String>(count)
repeat(count) {
parsed.add(stream.readString())
}
members = parsed
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
stream.writeInt16(members.size)
members.forEach { member -> stream.writeString(member) }
return stream
}
}

View File

@@ -0,0 +1,24 @@
package com.rosetta.messenger.network
class PacketGroupInviteInfo : Packet() {
var groupId: String = ""
var membersCount: Int = 0
var groupStatus: GroupStatus = GroupStatus.NOT_JOINED
override fun getPacketId(): Int = 0x13
override fun receive(stream: Stream) {
groupId = stream.readString()
membersCount = stream.readInt16().coerceAtLeast(0)
groupStatus = GroupStatus.fromInt(stream.readInt8())
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
stream.writeInt16(membersCount)
stream.writeInt8(groupStatus.value)
return stream
}
}

View File

@@ -0,0 +1,24 @@
package com.rosetta.messenger.network
class PacketGroupJoin : Packet() {
var groupId: String = ""
var groupStatus: GroupStatus = GroupStatus.NOT_JOINED
var groupString: String = ""
override fun getPacketId(): Int = 0x14
override fun receive(stream: Stream) {
groupId = stream.readString()
groupStatus = GroupStatus.fromInt(stream.readInt8())
groupString = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
stream.writeInt8(groupStatus.value)
stream.writeString(groupString)
return stream
}
}

View File

@@ -0,0 +1,18 @@
package com.rosetta.messenger.network
class PacketGroupLeave : Packet() {
var groupId: String = ""
override fun getPacketId(): Int = 0x15
override fun receive(stream: Stream) {
groupId = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(groupId)
return stream
}
}

View File

@@ -127,6 +127,12 @@ class Protocol(
0x09 to { PacketDeviceNew() },
0x0A to { PacketRequestUpdate() },
0x0B to { PacketTyping() },
0x11 to { PacketCreateGroup() },
0x12 to { PacketGroupInfo() },
0x13 to { PacketGroupInviteInfo() },
0x14 to { PacketGroupJoin() },
0x15 to { PacketGroupLeave() },
0x16 to { PacketGroupBan() },
0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() },
0x18 to { PacketDeviceResolve() },

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context
import android.os.Build
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName
import kotlinx.coroutines.*
@@ -39,6 +40,7 @@ object ProtocolManager {
private var protocol: Protocol? = null
private var messageRepository: MessageRepository? = null
private var groupRepository: GroupRepository? = null
private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var packetHandlersRegistered = false
@@ -169,6 +171,7 @@ object ProtocolManager {
fun initialize(context: Context) {
appContext = context.applicationContext
messageRepository = MessageRepository.getInstance(context)
groupRepository = GroupRepository.getInstance(context)
if (!packetHandlersRegistered) {
setupPacketHandlers()
packetHandlersRegistered = true
@@ -285,7 +288,7 @@ object ProtocolManager {
if (isUnsupportedDialogKey(deliveryPacket.toPublicKey)) {
android.util.Log.w(
TAG,
"Skipping unsupported delivery packet (conversation/group): to=${deliveryPacket.toPublicKey.take(24)}"
"Skipping unsupported delivery packet: to=${deliveryPacket.toPublicKey.take(24)}"
)
return@launchInboundPacketTask
}
@@ -360,6 +363,34 @@ object ProtocolManager {
waitPacket(0x19) { packet ->
handleSyncPacket(packet as PacketSync)
}
// 👥 Обработчик синхронизации групп (0x14)
// Desktop parity: во время sync сервер отправляет PacketGroupJoin с groupString.
waitPacket(0x14) { packet ->
val joinPacket = packet as PacketGroupJoin
launchInboundPacketTask {
val repository = messageRepository
val groups = groupRepository
val account = repository?.getCurrentAccountKey()
val privateKey = repository?.getCurrentPrivateKey()
if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) {
return@launchInboundPacketTask
}
try {
val result = groups.synchronizeJoinedGroup(
accountPublicKey = account,
accountPrivateKey = privateKey,
packet = joinPacket
)
if (result?.success == true) {
addLog("👥 GROUP synced: ${result.dialogPublicKey}")
}
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to sync group packet", e)
}
}
}
// 🟢 Обработчик онлайн-статуса (0x05)
waitPacket(0x05) { packet ->
@@ -512,12 +543,21 @@ object ProtocolManager {
return true
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun isConversationDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.startsWith("conversation:")
}
private fun isUnsupportedDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT)
if (normalized.isBlank()) return true
return normalized.startsWith("#") ||
normalized.startsWith("group:") ||
normalized.startsWith("conversation:")
if (isConversationDialogKey(normalized)) return true
return false
}
private fun isSupportedDirectPeerKey(peerKey: String, ownKey: String): Boolean {
@@ -532,6 +572,8 @@ object ProtocolManager {
val from = packet.fromPublicKey.trim()
val to = packet.toPublicKey.trim()
if (from.isBlank() || to.isBlank()) return false
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
if (isGroupDialogKey(to)) return true
return when {
from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
@@ -543,6 +585,8 @@ object ProtocolManager {
val from = packet.fromPublicKey.trim()
val to = packet.toPublicKey.trim()
if (from.isBlank() || to.isBlank()) return false
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
if (isGroupDialogKey(to)) return true
return when {
from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
@@ -637,6 +681,18 @@ object ProtocolManager {
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch
}
val protocolAccount = getProtocol().getPublicKey().orEmpty()
val repositoryAccount = repository.getCurrentAccountKey().orEmpty()
if (protocolAccount.isNotBlank() &&
repositoryAccount.isNotBlank() &&
repositoryAccount != protocolAccount
) {
syncRequestInFlight = false
requireResyncAfterAccountInit(
"⏳ Sync postponed: repository bound to another account"
)
return@launch
}
val lastSync = repository.getLastSyncTimestamp()
addLog("🔄 SYNC sending request with lastSync=$lastSync")
sendSynchronize(lastSync)
@@ -1127,10 +1183,12 @@ object ProtocolManager {
fun disconnect() {
protocol?.disconnect()
protocol?.clearCredentials()
messageRepository?.clearInitialization()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false
setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
lastSubscribedToken = null // reset so token is re-sent on next connect
}
@@ -1140,10 +1198,12 @@ object ProtocolManager {
fun destroy() {
protocol?.destroy()
protocol = null
messageRepository?.clearInitialization()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false
setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
scope.cancel()
}

View File

@@ -74,6 +74,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -94,11 +95,13 @@ import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(
ExperimentalMaterial3Api::class,
@@ -112,6 +115,7 @@ fun ChatDetailScreen(
onBack: () -> Unit,
onNavigateToChat: (SearchUser) -> Unit,
onUserProfileClick: (SearchUser) -> Unit = {},
onGroupInfoClick: (SearchUser) -> Unit = {},
currentUserPublicKey: String,
currentUserPrivateKey: String,
currentUserName: String = "",
@@ -183,6 +187,7 @@ fun ChatDetailScreen(
// 📌 PINNED MESSAGES
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
val pinnedMessagePreviews by viewModel.pinnedMessagePreviews.collectAsState()
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
@@ -210,10 +215,23 @@ fun ChatDetailScreen(
// Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey
val isGroupChat = user.publicKey.trim().startsWith("#group:")
val chatTitle =
if (isSavedMessages) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) }
val openDialogInfo: () -> Unit = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
if (isGroupChat) {
onGroupInfoClick(user)
} else {
onUserProfileClick(user)
}
}
// 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) }
@@ -393,6 +411,10 @@ fun ChatDetailScreen(
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) }
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
@@ -401,6 +423,20 @@ fun ChatDetailScreen(
}
}
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
groupAdminKeys = emptySet()
return@LaunchedEffect
}
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
}
val adminKey = members.firstOrNull().orEmpty()
groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
}
// Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
@@ -503,12 +539,15 @@ fun ChatDetailScreen(
}
// <20> Текст текущего pinned сообщения для баннера
val currentPinnedMessagePreview = remember(pinnedMessages, currentPinnedIndex, messages) {
val currentPinnedMessagePreview =
remember(pinnedMessages, currentPinnedIndex, messages, pinnedMessagePreviews) {
if (pinnedMessages.isEmpty()) ""
else {
val idx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
val pinnedMsgId = pinnedMessages[idx].messageId
messages.find { it.id == pinnedMsgId }?.text ?: "..."
pinnedMessagePreviews[pinnedMsgId]
?: messages.find { it.id == pinnedMsgId }?.text
?: "Pinned message"
}
}
@@ -531,8 +570,20 @@ fun ChatDetailScreen(
delay(50) // Небольшая задержка для сброса анимации
// Находим индекс сообщения в списке
val messageIndex =
messagesWithDates.indexOfFirst { it.first.id == messageId }
var messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId }
if (messageIndex == -1) {
val loaded = viewModel.ensureMessageLoaded(messageId)
if (loaded) {
for (attempt in 0 until 8) {
delay(16)
messageIndex =
viewModel.messagesWithDates.value.indexOfFirst {
it.first.id == messageId
}
if (messageIndex != -1) break
}
}
}
if (messageIndex != -1) {
// Скроллим к сообщению
listState.animateScrollToItem(messageIndex)
@@ -553,6 +604,7 @@ fun ChatDetailScreen(
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isGroupChat -> "group"
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
isSystemAccount -> "official account"
@@ -604,7 +656,7 @@ fun ChatDetailScreen(
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
// Подписываемся на онлайн статус собеседника
if (!isSavedMessages) {
if (!isSavedMessages && !isGroupChat) {
viewModel.subscribeToOnlineStatus()
}
// 🔥 Предзагружаем эмодзи в фоне
@@ -944,8 +996,7 @@ fun ChatDetailScreen(
modifier =
Modifier.size(40.dp)
.then(
if (!isSavedMessages
) {
if (!isSavedMessages) {
Modifier
.clickable(
indication =
@@ -955,21 +1006,7 @@ fun ChatDetailScreen(
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
openDialogInfo()
}
} else
Modifier
@@ -1034,8 +1071,7 @@ fun ChatDetailScreen(
modifier =
Modifier.weight(1f)
.then(
if (!isSavedMessages
) {
if (!isSavedMessages) {
Modifier
.clickable(
indication =
@@ -1045,21 +1081,7 @@ fun ChatDetailScreen(
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
openDialogInfo()
}
} else
Modifier
@@ -1087,6 +1109,7 @@ fun ChatDetailScreen(
.Ellipsis
)
if (!isSavedMessages &&
!isGroupChat &&
(user.verified >
0 || isRosettaOfficial)
) {
@@ -1146,6 +1169,7 @@ fun ChatDetailScreen(
}
// Кнопки действий
if (!isSavedMessages &&
!isGroupChat &&
!isSystemAccount
) {
IconButton(
@@ -1209,10 +1233,32 @@ fun ChatDetailScreen(
isDarkTheme,
isSavedMessages =
isSavedMessages,
isGroupChat =
isGroupChat,
isSystemAccount =
isSystemAccount,
isBlocked =
isBlocked,
onGroupInfoClick = {
showMenu =
false
onGroupInfoClick(
user
)
},
onSearchMembersClick = {
showMenu =
false
onGroupInfoClick(
user
)
},
onLeaveGroupClick = {
showMenu =
false
showDeleteConfirm =
true
},
onBlockClick = {
showMenu =
false
@@ -2016,6 +2062,14 @@ fun ChatDetailScreen(
}
val selectionKey =
message.id
val senderPublicKeyForMessage =
if (message.senderPublicKey.isNotBlank()) {
message.senderPublicKey
} else if (message.isOutgoing) {
currentUserPublicKey
} else {
user.publicKey
}
MessageBubble(
message =
message,
@@ -2042,11 +2096,20 @@ fun ChatDetailScreen(
privateKey =
currentUserPrivateKey,
senderPublicKey =
if (message.isOutgoing
)
currentUserPublicKey
else
user.publicKey,
senderPublicKeyForMessage,
senderName =
message.senderName,
showGroupSenderLabel =
isGroupChat &&
!message.isOutgoing,
isGroupSenderAdmin =
isGroupChat &&
senderPublicKeyForMessage
.isNotBlank() &&
groupAdminKeys
.contains(
senderPublicKeyForMessage
),
currentUserPublicKey =
currentUserPublicKey,
avatarRepository =
@@ -2195,6 +2258,9 @@ fun ChatDetailScreen(
}
}
},
onGroupInviteOpen = { inviteGroup ->
onNavigateToChat(inviteGroup)
},
contextMenuContent = {
// 💬 Context menu anchored to this bubble
if (showContextMenu && contextMenuMessage?.id == message.id) {
@@ -2444,15 +2510,24 @@ fun ChatDetailScreen(
// Диалог подтверждения удаления чата
if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor)
Text(
if (isLeaveGroupDialog) "Leave Group" else "Delete Chat",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to delete this chat? This action cannot be undone.",
if (isLeaveGroupDialog) {
"Are you sure you want to leave this group?"
} else {
"Are you sure you want to delete this chat? This action cannot be undone."
},
color = secondaryTextColor
)
},
@@ -2462,60 +2537,79 @@ fun ChatDetailScreen(
showDeleteConfirm = false
scope.launch {
try {
// Вычисляем правильный dialog_key
val dialogKey =
if (currentUserPublicKey <
if (isLeaveGroupDialog) {
GroupRepository
.getInstance(
context
)
.leaveGroup(
currentUserPublicKey,
user.publicKey
) {
"$currentUserPublicKey:${user.publicKey}"
} else {
"${user.publicKey}:$currentUserPublicKey"
}
)
} else {
// Вычисляем правильный dialog_key
val dialogKey =
if (currentUserPublicKey <
user.publicKey
) {
"$currentUserPublicKey:${user.publicKey}"
} else {
"${user.publicKey}:$currentUserPublicKey"
}
// 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data
.MessageRepository
.getInstance(context)
.clearDialogCache(
user.publicKey
)
ChatViewModel.clearCacheForOpponent(
user.publicKey
)
// Удаляем все сообщения из диалога
database.messageDao()
.deleteDialog(
account =
currentUserPublicKey,
dialogKey =
dialogKey
)
database.messageDao()
.deleteMessagesBetweenUsers(
account =
currentUserPublicKey,
user1 =
user.publicKey,
user2 =
currentUserPublicKey
)
// Очищаем кеш диалога
database.dialogDao()
.deleteDialog(
account =
currentUserPublicKey,
opponentKey =
// 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data
.MessageRepository
.getInstance(
context
)
.clearDialogCache(
user.publicKey
)
)
ChatViewModel
.clearCacheForOpponent(
user.publicKey
)
// Удаляем все сообщения из диалога
database.messageDao()
.deleteDialog(
account =
currentUserPublicKey,
dialogKey =
dialogKey
)
database.messageDao()
.deleteMessagesBetweenUsers(
account =
currentUserPublicKey,
user1 =
user.publicKey,
user2 =
currentUserPublicKey
)
// Очищаем кеш диалога
database.dialogDao()
.deleteDialog(
account =
currentUserPublicKey,
opponentKey =
user.publicKey
)
}
} catch (e: Exception) {
// Error deleting chat
}
hideKeyboardAndBack()
}
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
) {
Text(
if (isLeaveGroupDialog) "Leave" else "Delete",
color = Color(0xFFFF3B30)
)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {

View File

@@ -97,6 +97,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao()
private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao()
// MessageRepository для подписки на события новых сообщений
@@ -105,6 +106,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>()
private val groupKeyCache = ConcurrentHashMap<String, String>()
private val groupSenderNameCache = ConcurrentHashMap<String, String>()
private val groupSenderResolveRequested = ConcurrentHashMap.newKeySet<String>()
@Volatile private var isCleared = false
// Информация о собеседнике
@@ -196,6 +200,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 Pinned messages state
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
private val _pinnedMessagePreviews = MutableStateFlow<Map<String, String>>(emptyMap())
val pinnedMessagePreviews: StateFlow<Map<String, String>> = _pinnedMessagePreviews.asStateFlow()
private val _currentPinnedIndex = MutableStateFlow(0)
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
@@ -529,6 +535,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear()
decryptionCache.clear()
groupKeyCache.clear()
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
}
myPublicKey = publicKey
myPrivateKey = privateKey
@@ -549,6 +558,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentTitle = title
opponentUsername = username
opponentVerified = verified.coerceAtLeast(0)
if (!isGroupDialogKey(publicKey)) {
groupKeyCache.remove(publicKey)
}
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
// Это важно для правильного отображения forward сообщений сразу
@@ -583,6 +597,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
lastReadMessageTimestamp = 0L
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
_pinnedMessagePreviews.value = emptyMap()
// Desktop parity: refresh opponent name/username from server on dialog open,
// so renamed contacts get their new name displayed immediately.
@@ -626,6 +641,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_pinnedMessages.value = pins
// Всегда показываем самый последний пин (index 0, ORDER BY DESC)
_currentPinnedIndex.value = 0
refreshPinnedMessagePreviews(acc, pins)
}
}
@@ -997,6 +1013,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
val cachedText = decryptionCache[entity.messageId]
val privateKey = myPrivateKey
val groupDialogKey =
when {
isGroupDialogKey(entity.dialogKey) -> entity.dialogKey.trim()
isGroupDialogKey(entity.toPublicKey) -> entity.toPublicKey.trim()
else -> opponentKey?.trim()?.takeIf { isGroupDialogKey(it) }
}
val isGroupMessage = groupDialogKey != null || entity.chachaKey.startsWith("group:")
val groupKey =
if (isGroupMessage && privateKey != null) {
decodeStoredGroupKey(entity.chachaKey, privateKey)
?: groupDialogKey?.let { resolveGroupKeyForDialog(it) }
} else {
null
}
// Расшифровываем сообщение из content + chachaKey
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
var displayText =
@@ -1004,10 +1036,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
cachedText
} else
try {
val privateKey = myPrivateKey
if (privateKey != null &&
entity.content.isNotEmpty() &&
entity.chachaKey.isNotEmpty()
if (isGroupMessage && groupKey != null && entity.content.isNotEmpty()) {
val decrypted = CryptoManager.decryptWithPassword(entity.content, groupKey)
if (decrypted != null) {
decryptionCache[entity.messageId] = decrypted
decrypted
} else {
safePlainMessageFallback(entity.plainMessage)
}
} else if (
privateKey != null &&
entity.content.isNotEmpty() &&
entity.chachaKey.isNotEmpty() &&
!entity.chachaKey.startsWith("group:")
) {
// Расшифровываем как в архиве: content + chachaKey + privateKey
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
@@ -1047,7 +1088,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
} catch (e: Exception) {
// Пробуем расшифровать plainMessage
val privateKey = myPrivateKey
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try {
val decrypted = CryptoManager.decryptWithPassword(
@@ -1076,7 +1116,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isFromMe = entity.fromMe == 1,
content = entity.content,
chachaKey = entity.chachaKey,
plainKeyAndNonce = plainKeyAndNonce
plainKeyAndNonce = plainKeyAndNonce,
groupPassword = groupKey
)
var replyData = parsedReplyResult?.replyData
val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()
@@ -1093,6 +1134,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Парсим все attachments (IMAGE, FILE, AVATAR)
val parsedAttachments = parseAllAttachments(entity.attachments)
val myKey = myPublicKey.orEmpty().trim()
val senderKey = entity.fromPublicKey.trim()
val senderName =
when {
senderKey.isBlank() -> ""
senderKey == myKey -> "You"
isGroupMessage -> resolveGroupSenderName(senderKey)
else -> opponentTitle.ifBlank { opponentUsername.ifBlank { shortPublicKey(senderKey) } }
}
if (isGroupMessage && senderKey.isNotBlank() && senderKey != myKey) {
requestGroupSenderNameIfNeeded(senderKey)
}
return ChatMessage(
id = entity.messageId,
text = displayText,
@@ -1109,10 +1164,102 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages,
attachments = parsedAttachments,
chachaKey = entity.chachaKey
chachaKey = entity.chachaKey,
senderPublicKey = senderKey,
senderName = senderName
)
}
private fun shortPublicKey(value: String): String {
val trimmed = value.trim()
if (trimmed.length <= 12) return trimmed
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
}
private suspend fun resolveGroupSenderName(publicKey: String): String {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return ""
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
}
val account = myPublicKey
if (!account.isNullOrBlank()) {
try {
val dialog = dialogDao.getDialog(account, normalizedPublicKey)
val localName = dialog?.opponentTitle?.trim().orEmpty()
.ifBlank { dialog?.opponentUsername?.trim().orEmpty() }
if (isUsableSenderName(localName, normalizedPublicKey)) {
groupSenderNameCache[normalizedPublicKey] = localName
return localName
}
} catch (_: Exception) {
// ignore
}
}
val cached = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
val protocolName = cached?.title?.trim().orEmpty()
.ifBlank { cached?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
groupSenderNameCache[normalizedPublicKey] = protocolName
return protocolName
}
return shortPublicKey(normalizedPublicKey)
}
private fun isUsableSenderName(name: String, publicKey: String): Boolean {
if (name.isBlank()) return false
val normalized = name.trim()
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isNotBlank()) {
if (normalized.equals(normalizedPublicKey, ignoreCase = true)) return false
if (normalized.equals(normalizedPublicKey.take(7), ignoreCase = true)) return false
if (normalized.equals(normalizedPublicKey.take(8), ignoreCase = true)) return false
}
return true
}
private fun requestGroupSenderNameIfNeeded(publicKey: String) {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return
if (normalizedPublicKey == myPublicKey?.trim()) return
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
if (isUsableSenderName(cached, normalizedPublicKey)) return
}
if (!groupSenderResolveRequested.add(normalizedPublicKey)) return
viewModelScope.launch(Dispatchers.IO) {
try {
val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
val name = resolved?.title?.trim().orEmpty()
.ifBlank { resolved?.username?.trim().orEmpty() }
if (!isUsableSenderName(name, normalizedPublicKey)) {
groupSenderResolveRequested.remove(normalizedPublicKey)
return@launch
}
groupSenderNameCache[normalizedPublicKey] = name
withContext(Dispatchers.Main) {
_messages.update { current ->
current.map { message ->
if (message.senderPublicKey.trim() == normalizedPublicKey &&
message.senderName != name
) {
message.copy(senderName = name)
} else {
message
}
}
}
}
} catch (_: Exception) {
groupSenderResolveRequested.remove(normalizedPublicKey)
}
}
}
/**
* Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
* Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
@@ -1245,7 +1392,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
content: String,
chachaKey: String,
plainKeyAndNonce: ByteArray? =
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
null, // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
groupPassword: String? = null
): ParsedReplyResult? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
@@ -1281,6 +1429,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey
var decryptionSuccess = false
// 🔥 Способ 0: Группа — blob шифруется ключом группы
if (groupPassword != null) {
try {
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
if (decrypted != null) {
dataJson = decrypted
decryptionSuccess = true
}
} catch (_: Exception) {}
}
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
// сообщений)
if (privateKey != null) {
@@ -1618,6 +1777,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* (account == opponent) возвращает просто account
*/
private fun getDialogKey(account: String, opponent: String): String {
if (isGroupDialogKey(opponent)) {
return opponent.trim()
}
// Для saved messages dialog_key = просто publicKey
if (account == opponent) {
return account
@@ -1630,6 +1792,96 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun normalizeGroupId(value: String): String {
val trimmed = value.trim()
return when {
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
else -> trimmed
}
}
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
}
private fun decodeStoredGroupKey(stored: String, privateKey: String): String? {
if (!stored.startsWith("group:")) return null
val encoded = stored.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private suspend fun resolveGroupKeyForDialog(dialogPublicKey: String): String? {
val account = myPublicKey ?: return null
val privateKey = myPrivateKey ?: return null
val normalizedDialog = dialogPublicKey.trim()
if (!isGroupDialogKey(normalizedDialog)) return null
groupKeyCache[normalizedDialog]?.let { return it }
val groupId = normalizeGroupId(normalizedDialog)
if (groupId.isBlank()) return null
val group = groupDao.getGroup(account, groupId) ?: return null
val decrypted = CryptoManager.decryptWithPassword(group.key, privateKey) ?: return null
groupKeyCache[normalizedDialog] = decrypted
return decrypted
}
private data class OutgoingEncryptionContext(
val encryptedContent: String,
val encryptedKey: String,
val aesChachaKey: String,
val plainKeyAndNonce: ByteArray?,
val attachmentPassword: String,
val isGroup: Boolean
)
private suspend fun buildEncryptionContext(
plaintext: String,
recipient: String,
privateKey: String
): OutgoingEncryptionContext? {
return if (isGroupDialogKey(recipient)) {
val groupKey = resolveGroupKeyForDialog(recipient) ?: return null
OutgoingEncryptionContext(
encryptedContent = CryptoManager.encryptWithPassword(plaintext, groupKey),
encryptedKey = "",
aesChachaKey = CryptoManager.encryptWithPassword(groupKey, privateKey),
plainKeyAndNonce = null,
attachmentPassword = groupKey,
isGroup = true
)
} else {
val encryptResult = MessageCrypto.encryptForSending(plaintext, recipient)
OutgoingEncryptionContext(
encryptedContent = encryptResult.ciphertext,
encryptedKey = encryptResult.encryptedKey,
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
plainKeyAndNonce = encryptResult.plainKeyAndNonce,
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
isGroup = false
)
}
}
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return if (context.isGroup) {
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
} else {
val plainKeyAndNonce =
context.plainKeyAndNonce
?: throw IllegalStateException("Missing key+nonce for direct message")
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
}
}
/** Обновить текст ввода */
fun updateInputText(text: String) {
_inputText.value = text
@@ -1695,6 +1947,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 PINNED MESSAGES
// ═══════════════════════════════════════════════════════════
private fun buildPinnedPreview(message: ChatMessage): String {
if (message.text.isNotBlank()) return message.text
return when {
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply"
else -> "Pinned message"
}
}
private suspend fun resolvePinnedPreviewText(account: String, messageId: String): String {
_messages.value.firstOrNull { it.id == messageId }?.let { visibleMessage ->
return buildPinnedPreview(visibleMessage)
}
decryptionCache[messageId]?.takeIf { it.isNotBlank() }?.let { cached ->
return cached
}
val entity = messageDao.getMessageById(account, messageId) ?: return "Pinned message"
return buildPinnedPreview(entityToChatMessage(entity))
}
private suspend fun refreshPinnedMessagePreviews(
account: String,
pins: List<com.rosetta.messenger.database.PinnedMessageEntity>
) {
if (pins.isEmpty()) {
_pinnedMessagePreviews.value = emptyMap()
return
}
val activeIds = pins.map { it.messageId }.toSet()
val nextPreviews = _pinnedMessagePreviews.value.filterKeys { it in activeIds }.toMutableMap()
for (pin in pins) {
val existingPreview = nextPreviews[pin.messageId]
if (existingPreview != null && existingPreview != "Pinned message") continue
val preview = resolvePinnedPreviewText(account, pin.messageId)
nextPreviews[pin.messageId] = preview
}
_pinnedMessagePreviews.value = nextPreviews
}
suspend fun ensureMessageLoaded(messageId: String): Boolean {
if (_messages.value.any { it.id == messageId }) return true
val account = myPublicKey ?: return false
val opponent = opponentKey ?: return false
val dialogKey = getDialogKey(account, opponent)
return withContext(Dispatchers.IO) {
val entity = messageDao.getMessageById(account, messageId) ?: return@withContext false
if (entity.dialogKey != dialogKey) return@withContext false
val hydratedMessage = entityToChatMessage(entity)
withContext(Dispatchers.Main.immediate) {
if (_messages.value.none { it.id == messageId }) {
_messages.value =
sortMessagesAscending((_messages.value + hydratedMessage).distinctBy { it.id })
}
}
updateCacheFromCurrentMessages()
_pinnedMessagePreviews.update { current ->
current + (messageId to buildPinnedPreview(hydratedMessage))
}
true
}
}
/** 📌 Закрепить сообщение */
fun pinMessage(messageId: String) {
viewModelScope.launch(Dispatchers.IO) {
@@ -1729,14 +2054,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return pinnedMessageDao.isPinned(account, dialogKey, messageId)
}
/** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */
/**
* 📌 Клик по pinned banner:
* 1) вернуть ТЕКУЩЕЕ отображаемое сообщение для скролла
* 2) после этого переключить индекс на следующее (циклически)
*
* Так скролл всегда попадает ровно в тот pinned, который видит пользователь в баннере.
*/
fun navigateToNextPinned(): String? {
val pins = _pinnedMessages.value
if (pins.isEmpty()) return null
val currentIdx = _currentPinnedIndex.value
val currentIdx = _currentPinnedIndex.value.coerceIn(0, pins.size - 1)
val currentMessageId = pins[currentIdx].messageId
val nextIdx = (currentIdx + 1) % pins.size
_currentPinnedIndex.value = nextIdx
return pins[nextIdx].messageId
return currentMessageId
}
/** 📌 Открепить все сообщения */
@@ -1769,6 +2101,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/
suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
if (publicKey.isEmpty()) return null
if (isGroupDialogKey(publicKey)) return null
// If it's the current opponent, we already have info
if (publicKey == opponentKey) {
@@ -1961,12 +2294,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 2. 🔥 Шифрование и отправка в IO потоке
viewModelScope.launch(Dispatchers.IO) {
try {
// Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1993,7 +2329,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey
)
if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
@@ -2067,8 +2403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob =
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
@@ -2153,7 +2488,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered =
@@ -2193,11 +2536,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val isSavedMessages = (sender == recipientPublicKey)
// Шифрование (пустой текст для forward)
val encryptResult = MessageCrypto.encryptForSending("", recipientPublicKey)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipientPublicKey,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val messageAttachments = mutableListOf<MessageAttachment>()
@@ -2218,7 +2565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey
)
if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
@@ -2277,7 +2624,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}"
@@ -2325,7 +2672,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
@@ -2565,11 +2920,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифрование текста
val encryptStartedAt = System.currentTimeMillis()
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = caption,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline(
messageId,
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
@@ -2579,7 +2938,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val blobEncryptStartedAt = System.currentTimeMillis()
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
logPhotoPipeline(
messageId,
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
@@ -2686,11 +3045,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
logPhotoPipeline(messageId, "ui status switched to SENT")
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp,
opponentPublicKey = recipient
)
logPhotoPipeline(
messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
@@ -2760,17 +3119,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
backgroundUploadScope.launch {
try {
// Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val encryptedImageBlob =
MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
val attachmentId = "img_$timestamp"
@@ -2846,7 +3208,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
@@ -3022,11 +3392,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
try {
val groupStartedAt = System.currentTimeMillis()
// Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline(
messageId,
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
@@ -3047,7 +3421,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифруем изображение с ChaCha ключом
val encryptedImageBlob =
MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce)
encryptAttachmentPayload(imageData.base64, encryptionContext)
// Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
@@ -3122,7 +3496,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
@@ -3227,16 +3609,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
} catch (_: Exception) {}
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем файл с ChaCha ключом для Transport Server
val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce)
val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext)
val attachmentId = "file_$timestamp"
@@ -3299,7 +3685,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered =
@@ -3430,19 +3824,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) }
// 2. Шифрование текста (пустой текст для аватарки)
val encryptResult = MessageCrypto.encryptForSending("", recipient)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = userPrivateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
// 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
// НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
// Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob!
val encryptedAvatarBlob =
MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce)
val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext)
val avatarAttachmentId = "avatar_$timestamp"
@@ -3518,7 +3915,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId,
text = "", // Аватар без текста
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
userPrivateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
@@ -3568,6 +3973,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponent)
}
if (isGroupDialogKey(opponent)) {
val groupId = normalizeGroupId(opponent)
val group = groupDao.getGroup(account, groupId)
if (group != null) {
dialogDao.updateOpponentDisplayName(
account,
opponent,
group.title,
group.description
)
return
}
}
// 🔥 FIX: updateDialogFromMessages создаёт новый диалог с пустым title/username
// когда диалога ещё не было. Обновляем метаданные из уже известных данных.
if (opponent != account && opponentTitle.isNotEmpty()) {
@@ -3602,6 +4021,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponentKey)
}
if (isGroupDialogKey(opponentKey)) {
val groupId = normalizeGroupId(opponentKey)
val group = groupDao.getGroup(account, groupId)
if (group != null) {
dialogDao.updateOpponentDisplayName(
account,
opponentKey,
group.title,
group.description
)
return
}
}
// 🔥 FIX: Сохраняем title/username после пересчёта счётчиков
if (opponentKey != account && opponentTitle.isNotEmpty()) {
dialogDao.updateOpponentDisplayName(
@@ -3711,7 +4144,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender) {
if (opponent == sender || isGroupDialogKey(opponent)) {
return
}
@@ -3754,7 +4187,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey ?: return
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
if (opponent == sender) {
if (opponent == sender || isGroupDialogKey(opponent)) {
return
}
@@ -3855,7 +4288,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return
// 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
if (account == opponent) {
if (account == opponent || isGroupDialogKey(opponent)) {
return
}
@@ -3887,7 +4320,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
// Удаляем все сообщения из БД
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
if (isGroupDialogKey(opponent)) {
val dialogKey = getDialogKey(account, opponent)
messageDao.deleteDialog(account, dialogKey)
} else {
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
}
// Очищаем кэш
val dialogKey = getDialogKey(account, opponent)
@@ -3926,6 +4364,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
isCleared = true
pinnedCollectionJob?.cancel()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.chats
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
@@ -186,6 +187,11 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase()
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -505,6 +511,7 @@ fun ChatsListScreen(
// Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
@@ -1115,6 +1122,22 @@ fun ChatsListScreen(
}
)
// 👥 New Group
DrawerMenuItemEnhanced(
icon = TablerIcons.Users,
text = "New Group",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(100)
onNewGroupClick()
}
}
)
// 📖 Saved Messages
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved),
@@ -2011,6 +2034,10 @@ fun ChatsListScreen(
.contains(
dialog.opponentKey
)
val isGroupDialog =
isGroupDialogKey(
dialog.opponentKey
)
val isTyping by
remember(
dialog.opponentKey
@@ -2128,6 +2155,10 @@ fun ChatsListScreen(
dialogsToDelete =
listOf(dialog)
},
onLeave = {
dialogToLeave =
dialog
},
onBlock = {
dialogToBlock =
dialog
@@ -2136,6 +2167,8 @@ fun ChatsListScreen(
dialogToUnblock =
dialog
},
isGroupChat =
isGroupDialog,
isPinned =
isPinnedDialog,
swipeEnabled =
@@ -2248,6 +2281,54 @@ fun ChatsListScreen(
)
}
// Leave Group Confirmation
dialogToLeave?.let { dialog ->
val groupTitle = dialog.opponentTitle.ifEmpty { "this group" }
AlertDialog(
onDismissRequest = { dialogToLeave = null },
containerColor =
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Leave Group",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to leave \"$groupTitle\"?",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val groupKey = dialog.opponentKey
dialogToLeave = null
scope.launch {
val left = chatsViewModel.leaveGroup(groupKey)
if (!left) {
Toast
.makeText(
context,
"Failed to leave group",
Toast.LENGTH_SHORT
)
.show()
}
}
}
) { Text("Leave", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { dialogToLeave = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
// Block Dialog Confirmation
dialogToBlock?.let { dialog ->
AlertDialog(
@@ -3022,7 +3103,7 @@ fun DrawerMenuItem(
}
/**
* 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия
* 🔥 Swipeable wrapper для DialogItem (Pin + Leave/Block + Delete). Свайп влево показывает действия
* (как в React Native версии)
*/
@Composable
@@ -3031,6 +3112,7 @@ fun SwipeableDialogItem(
isDarkTheme: Boolean,
isTyping: Boolean = false,
isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false,
swipeEnabled: Boolean = true,
isMuted: Boolean = false,
@@ -3043,6 +3125,7 @@ fun SwipeableDialogItem(
onClick: () -> Unit,
onLongClick: () -> Unit = {},
onDelete: () -> Unit = {},
onLeave: () -> Unit = {},
onBlock: () -> Unit = {},
onUnblock: () -> Unit = {},
isPinned: Boolean = false,
@@ -3068,7 +3151,7 @@ fun SwipeableDialogItem(
label = "pinnedBackground"
)
var offsetX by remember { mutableStateOf(0f) }
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
// 📌 3 кнопки: Pin + Leave/Block + Delete (для SavedMessages: Pin + Delete)
val buttonCount =
if (!swipeEnabled) 0
else if (isSavedMessages) 2
@@ -3147,18 +3230,28 @@ fun SwipeableDialogItem(
}
}
// Кнопка Block/Unblock (только если не Saved Messages)
// Кнопка Leave (для группы) или Block/Unblock (для личных чатов)
if (!isSavedMessages) {
val middleActionColor =
if (isGroupChat) Color(0xFFFF6B6B)
else if (isBlocked) Color(0xFF4CAF50)
else Color(0xFFFF6B6B)
val middleActionIcon =
if (isGroupChat) TelegramIcons.Leave
else if (isBlocked) TelegramIcons.Unlock
else TelegramIcons.Block
val middleActionTitle =
if (isGroupChat) "Leave"
else if (isBlocked) "Unblock"
else "Block"
Box(
modifier =
Modifier.width(80.dp)
.fillMaxHeight()
.background(
if (isBlocked) Color(0xFF4CAF50)
else Color(0xFFFF6B6B)
)
.background(middleActionColor)
.clickable {
if (isBlocked) onUnblock()
if (isGroupChat) onLeave()
else if (isBlocked) onUnblock()
else onBlock()
offsetX = 0f
onSwipeClosed()
@@ -3170,20 +3263,14 @@ fun SwipeableDialogItem(
verticalArrangement = Arrangement.Center
) {
Icon(
painter =
if (isBlocked) TelegramIcons.Unlock
else TelegramIcons.Block,
contentDescription =
if (isBlocked) "Unblock"
else "Block",
painter = middleActionIcon,
contentDescription = middleActionTitle,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text =
if (isBlocked) "Unblock"
else "Block",
text = middleActionTitle,
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
@@ -3461,6 +3548,7 @@ fun DialogItemContent(
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя
// 🔥 Как в Архиве: title > username > "DELETED"
@@ -3628,6 +3716,15 @@ fun DialogItemContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp))
Icon(
imageVector = TablerIcons.Users,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.9f),
modifier = Modifier.size(15.dp)
)
}
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -3871,7 +3968,7 @@ fun DialogItemContent(
)
}
} else {
val displayText =
val baseDisplayText =
when {
dialog.lastMessageAttachmentType ==
"Photo" -> "Photo"
@@ -3885,23 +3982,122 @@ fun DialogItemContent(
"No messages"
else -> dialog.lastMessage
}
val senderPrefix =
dialog.lastMessageSenderPrefix
.orEmpty()
.trim()
val showSenderPrefix =
isGroupDialog &&
senderPrefix.isNotEmpty() &&
baseDisplayText != "No messages"
val senderPrefixColor =
remember(
showSenderPrefix,
senderPrefix,
dialog.lastMessageSenderKey,
isDarkTheme
) {
when {
!showSenderPrefix ->
secondaryTextColor
senderPrefix.equals(
"You",
ignoreCase = true
) -> PrimaryBlue
else -> {
val colorSeed =
dialog.lastMessageSenderKey
?.trim()
.orEmpty()
val resolvedSeed =
if (colorSeed.isNotEmpty()) {
colorSeed
} else {
senderPrefix
}
getAvatarColor(
resolvedSeed,
isDarkTheme
)
.textColor
}
}
}
AppleEmojiText(
text = displayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.fillMaxWidth(),
enableLinks = false
)
if (showSenderPrefix) {
Row(
modifier =
Modifier.fillMaxWidth(),
verticalAlignment =
Alignment.CenterVertically
) {
AppleEmojiText(
text = "$senderPrefix:",
fontSize = 14.sp,
color = senderPrefixColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow =
android.text.TextUtils.TruncateAt.END,
modifier =
Modifier.widthIn(
max = 120.dp
),
enableLinks = false
)
Spacer(
modifier =
Modifier.width(4.dp)
)
AppleEmojiText(
text =
baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount >
0
)
textColor.copy(
alpha =
0.85f
)
else
secondaryTextColor,
fontWeight =
if (dialog.unreadCount >
0
)
FontWeight.Medium
else
FontWeight.Normal,
maxLines = 1,
overflow =
android.text.TextUtils.TruncateAt.END,
modifier =
Modifier.weight(
1f
),
enableLinks = false
)
}
} else {
AppleEmojiText(
text = baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.fillMaxWidth(),
enableLinks = false
)
}
}
}
}

View File

@@ -5,8 +5,9 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -42,6 +43,8 @@ data class DialogUiModel(
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? =
null, // 📎 Тип attachment: "Photo", "File", или null
val lastMessageSenderPrefix: String? = null, // 👥 Для групп: "You" или имя отправителя
val lastMessageSenderKey: String? = null, // 👥 Для групп: public key отправителя
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
)
@@ -66,6 +69,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val groupRepository = GroupRepository.getInstance(application)
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -123,6 +127,104 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
private val groupJoinedMarker = "\$a=Group joined"
private val groupCreatedMarker = "\$a=Group created"
private fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private data class GroupLastSenderInfo(
val senderPrefix: String,
val senderKey: String
)
private suspend fun resolveGroupLastSenderInfo(
dialog: com.rosetta.messenger.database.DialogEntity
): GroupLastSenderInfo? {
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
val senderKey =
if (dialog.lastMessageFromMe == 1) {
currentAccount
} else {
val lastMessage =
try {
dialogDao.getLastMessageByDialogKey(
account = dialog.account,
dialogKey = dialog.opponentKey.trim()
)
} catch (_: Exception) {
null
}
lastMessage?.fromPublicKey.orEmpty()
}
if (senderKey.isBlank()) return null
if (senderKey == currentAccount) {
return GroupLastSenderInfo(senderPrefix = "You", senderKey = senderKey)
}
val senderName = resolveKnownDisplayName(senderKey)
if (senderName == null) {
loadUserInfoForDialog(senderKey)
}
return GroupLastSenderInfo(
senderPrefix = senderName ?: senderKey.take(7),
senderKey = senderKey
)
}
private suspend fun resolveKnownDisplayName(publicKey: String): String? {
if (publicKey.isBlank()) return null
if (publicKey == currentAccount) return "You"
val dialogName =
try {
val userDialog = dialogDao.getDialog(currentAccount, publicKey)
userDialog?.let {
extractDisplayName(
title = it.opponentTitle,
username = it.opponentUsername,
publicKey = publicKey
)
}
} catch (_: Exception) {
null
}
if (!dialogName.isNullOrBlank()) return dialogName
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
if (cached.isNotBlank() && cached != publicKey) {
return cached
}
return null
}
private fun extractDisplayName(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()
if (normalizedUsername.isNotEmpty() &&
normalizedUsername != publicKey &&
normalizedUsername != publicKey.take(7) &&
normalizedUsername != publicKey.take(8)
) {
return normalizedUsername
}
return null
}
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
@@ -222,6 +324,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
@@ -249,7 +353,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// DialogEntity
// (денормализовано)
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
@@ -320,6 +428,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage =
decryptedLastMessage
)
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel(
id = dialog.id,
@@ -346,7 +456,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType =
attachmentType // 📎 Тип attachment
attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
)
}
}
@@ -404,7 +518,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (opponentKeys.isEmpty()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
val newKeys =
opponentKeys.filter { key ->
!subscribedOnlineKeys.contains(key) && !isGroupKey(key)
}
if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
@@ -429,7 +546,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (encryptedLastMessage.isEmpty()) return ""
if (privateKey.isEmpty()) {
return if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
val plainCandidate =
if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
return formatPreviewText(plainCandidate)
}
val decrypted =
@@ -439,11 +558,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
null
}
return when {
val resolved = when {
decrypted != null -> decrypted
isLikelyEncryptedPayload(encryptedLastMessage) -> ""
else -> encryptedLastMessage
}
return formatPreviewText(resolved)
}
private fun formatPreviewText(value: String): String {
if (value.isBlank()) return value
val normalized = value.trim()
return when {
groupInviteRegex.matches(normalized) -> "Group Invite"
normalized == groupJoinedMarker -> "You joined the group"
normalized == groupCreatedMarker -> "Group created"
else -> value
}
}
private fun resolveAttachmentType(
@@ -611,6 +742,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
}
/** Выйти из группы и удалить локальный групповой диалог */
suspend fun leaveGroup(groupPublicKey: String): Boolean {
if (currentAccount.isEmpty()) return false
if (!isGroupKey(groupPublicKey)) return false
return try {
val left = groupRepository.leaveGroup(currentAccount, groupPublicKey)
if (left) {
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
_requestsCount.value = _requests.value.size
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey)
}
left
} catch (_: Exception) {
false
}
}
/** Заблокировать пользователя */
suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
@@ -649,7 +800,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/
private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) {
if (publicKey == currentAccount || isGroupKey(publicKey)) {
return
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupSetupScreen(
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var selectedTab by remember { mutableIntStateOf(0) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var inviteString by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) }
fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened(
SearchUser(
publicKey = dialogPublicKey,
title = groupTitle.ifBlank { "Group" },
username = "",
verified = 0,
online = 0
)
)
}
suspend fun createGroup() =
GroupRepository.getInstance(context).createGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
title = title.trim(),
description = description.trim()
)
suspend fun joinGroup() =
GroupRepository.getInstance(context).joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = inviteString.trim()
)
fun mapError(status: GroupStatus, fallback: String): String {
return when (status) {
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "Invite is invalid"
else -> fallback
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Groups", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) {
Text("Back")
}
}
)
}
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Top
) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
errorText = null
},
text = { Text("Create") }
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
errorText = null
},
text = { Text("Join") }
)
}
Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Group title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
minLines = 3,
maxLines = 4
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot create group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = title.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Create Group")
}
}
} else {
OutlinedTextField(
value = inviteString,
onValueChange = { inviteString = it },
label = { Text("Invite string") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { joinGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot join group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteString.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Join Group")
}
}
}
if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = errorText ?: "",
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (selectedTab == 0) {
"Creates a new private group and joins it automatically."
} else {
"Paste a full invite string that starts with #group:."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
@@ -77,6 +78,31 @@ private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex()
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private fun decodeBase64Payload(data: String): ByteArray? {
val raw = data.trim()
if (raw.isBlank()) return null
val payload =
if (raw.startsWith("data:") && raw.contains(",")) {
raw.substringAfter(",")
} else {
raw
}
return try {
Base64.decode(payload, Base64.DEFAULT)
} catch (_: Exception) {
null
}
}
private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty"
val clean = value.trim()
@@ -1488,28 +1514,53 @@ fun FileAttachment(
downloadStatus = DownloadStatus.DOWNLOADING
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
val success =
if (isGroupStoredKey(chachaKey)) {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
downloadStatus = DownloadStatus.DECRYPTING
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
false
} else {
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
val bytes = decrypted?.let { decodeBase64Payload(it) }
if (bytes != null) {
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
true
} else {
false
}
}
} else {
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
downloadStatus = DownloadStatus.DECRYPTING
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
val success = withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
}
downloadProgress = 0.95f
if (success) {
@@ -1860,19 +1911,28 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
// bytes в password
val decryptStartTime = System.currentTimeMillis()
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
null
} else {
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
}
} else {
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Используем decryptAttachmentBlobWithPlainKey который правильно
// конвертирует bytes в password
MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent,
decryptedKeyAndNonce
)
}
val decryptTime = System.currentTimeMillis() - decryptStartTime
if (decrypted != null) {
@@ -2351,21 +2411,35 @@ private suspend fun processDownloadedImage(
onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ
val keyCandidates: List<ByteArray>
var keyCandidates: List<ByteArray> = emptyList()
var groupPassword: String? = null
try {
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates")
}
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
keyCandidates.forEachIndexed { idx, candidate ->
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
if (isGroupStoredKey(chachaKey)) {
groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
throw IllegalArgumentException("empty group password")
}
logPhotoDebug("Group key decrypt OK: id=$idShort, keyLen=${groupPassword.length}")
} else {
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates")
}
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
keyCandidates.forEachIndexed { idx, candidate ->
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
}
}
} catch (e: Exception) {
onError("Error")
onStatus(DownloadStatus.ERROR)
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
val keyPrefix =
when {
chachaKey.startsWith("sync:") -> "sync"
chachaKey.startsWith("group:") -> "group"
else -> "ecdh"
}
logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
return
}
@@ -2374,17 +2448,26 @@ private suspend fun processDownloadedImage(
val decryptStartTime = System.currentTimeMillis()
var successKeyIdx = -1
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) {
successKeyIdx = idx
decryptDebug = attempt
break
}
// Keep last trace for diagnostics if all fail.
decryptDebug = attempt
}
val decrypted = decryptDebug.decrypted
val decrypted =
if (groupPassword != null) {
val plain = CryptoManager.decryptWithPassword(encryptedContent, groupPassword!!)
decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(plain, emptyList())
plain
} else {
var value: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) {
successKeyIdx = idx
decryptDebug = attempt
value = attempt.decrypted
break
}
// Keep last trace for diagnostics if all fail.
decryptDebug = attempt
}
value
}
val decryptTime = System.currentTimeMillis() - decryptStartTime
onProgress(0.8f)
@@ -2428,7 +2511,12 @@ private suspend fun processDownloadedImage(
} else {
onError("Error")
onStatus(DownloadStatus.ERROR)
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
val keyPrefix =
when {
chachaKey.startsWith("sync:") -> "sync"
chachaKey.startsWith("group:") -> "group"
else -> "ecdh"
}
val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
decryptDebug.trace.take(96).forEachIndexed { index, line ->
@@ -2467,47 +2555,54 @@ internal suspend fun downloadAndDecryptImage(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
)
logPhotoDebug(
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
)
// Primary path for image attachments
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var decrypted: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
encryptedContent,
keyCandidate
val decrypted: String? =
if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
} else {
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first()
logPhotoDebug(
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
)
logPhotoDebug(
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
)
if (attempt.decrypted != null) {
decryptDebug = attempt
decrypted = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break
}
decryptDebug = attempt
}
// Fallback for legacy payloads
if (decrypted.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
decrypted =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) {
null
// Primary path for image attachments
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var value: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
encryptedContent,
keyCandidate
)
if (attempt.decrypted != null) {
decryptDebug = attempt
value = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break
}
decryptDebug = attempt
}
}
// Fallback for legacy payloads
if (value.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
}
value =
try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent }
} catch (_: Exception) {
null
}
}
value
}
if (decrypted.isNullOrEmpty()) return@withContext null

View File

@@ -1,8 +1,14 @@
package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap
import android.widget.Toast
import com.rosetta.messenger.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory
import androidx.compose.animation.*
import androidx.compose.animation.core.*
@@ -41,19 +47,25 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.*
@@ -69,6 +81,7 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
@@ -281,6 +294,9 @@ fun MessageBubble(
isSavedMessages: Boolean = false,
privateKey: String = "",
senderPublicKey: String = "",
senderName: String = "",
showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "",
avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {},
@@ -291,6 +307,7 @@ fun MessageBubble(
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
// Swipe-to-reply state
@@ -366,6 +383,22 @@ fun MessageBubble(
message.attachments.isEmpty() &&
message.text.isNotBlank()
val groupActionSystemText =
remember(message.text) { resolveGroupActionSystemText(message.text) }
val isGroupActionSystemMessage =
groupActionSystemText != null &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.attachments.isEmpty()
if (isGroupActionSystemMessage) {
GroupActionSystemMessage(
text = groupActionSystemText.orEmpty(),
isDarkTheme = isDarkTheme
)
return
}
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape =
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
@@ -719,6 +752,32 @@ fun MessageBubble(
)
} else {
Column {
if (showGroupSenderLabel &&
!message.isOutgoing &&
senderName.isNotBlank()
) {
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = senderName,
color =
groupSenderLabelColor(
senderPublicKey,
isDarkTheme
),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (isGroupSenderAdmin) {
Spacer(modifier = Modifier.width(4.dp))
GroupAdminBadge(isDarkTheme = isDarkTheme)
}
}
}
// 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) {
ForwardedMessagesBubble(
@@ -974,81 +1033,124 @@ fun MessageBubble(
!hasImageWithCaption &&
message.text.isNotEmpty()
) {
// Telegram-style: текст + время с автоматическим
// переносом
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
if (isGroupInviteCode(message.text)) {
val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
GroupInviteInlineCard(
inviteText = message.text,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode,
onOpenGroup = onGroupInviteOpen
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = displayStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = message.isOutgoing,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
},
timeContent = {
Row(
verticalAlignment =
Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) {
Text(
text =
timeFormat
.format(
message.timestamp
),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose
.ui
.text
.font
.FontStyle
.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages
)
MessageStatus
.READ
else
message.status
AnimatedMessageStatus(
status =
displayStatus,
timeColor =
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
)
}
}
}
)
} else {
// Telegram-style: текст + время с автоматическим
// переносом
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
// press
// для
// selection
)
},
timeContent = {
Row(
verticalAlignment =
Alignment
.CenterVertically,
horizontalArrangement =
Arrangement
.spacedBy(
2.dp
)
) {
Text(
text =
timeFormat
.format(
message.timestamp
),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose
.ui
.text
.font
.FontStyle
.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages
)
MessageStatus
.READ
else
message.status
AnimatedMessageStatus(
status =
displayStatus,
timeColor =
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
onRetry =
onRetry,
onDelete =
onDelete
)
}
}
}
)
}
}
}
}
@@ -1059,6 +1161,392 @@ fun MessageBubble(
}
}
private val GROUP_INVITE_REGEX = Regex("^#group:[A-Za-z0-9+/=:]+$")
private const val GROUP_ACTION_JOINED = "\$a=Group joined"
private const val GROUP_ACTION_CREATED = "\$a=Group created"
private fun resolveGroupActionSystemText(text: String): String? {
return when (text.trim()) {
GROUP_ACTION_JOINED -> "You joined the group"
GROUP_ACTION_CREATED -> "Group created"
else -> null
}
}
@Composable
private fun GroupActionSystemMessage(text: String, isDarkTheme: Boolean) {
val successColor = if (isDarkTheme) Color(0xFF7EE787) else Color(0xFF2E7D32)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
color = successColor,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
private fun isGroupInviteCode(text: String): Boolean {
val normalized = text.trim()
if (!normalized.startsWith("#group:")) return false
return GROUP_INVITE_REGEX.matches(normalized)
}
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
val paletteDark =
listOf(
Color(0xFF7ED957),
Color(0xFF6EC1FF),
Color(0xFFFF9F68),
Color(0xFFC38AFF),
Color(0xFFFF7AA2),
Color(0xFF4DD7C8)
)
val paletteLight =
listOf(
Color(0xFF2E7D32),
Color(0xFF1565C0),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFFC2185B),
Color(0xFF00695C)
)
val palette = if (isDarkTheme) paletteDark else paletteLight
val index = kotlin.math.abs(publicKey.hashCode()) % palette.size
return palette[index]
}
@Composable
private fun GroupAdminBadge(isDarkTheme: Boolean) {
var showInfo by remember { mutableStateOf(false) }
val iconTint = Color(0xFFF6C445)
val popupBackground = if (isDarkTheme) Color(0xFF2E2E31) else Color(0xFFF2F2F5)
val popupTextColor = if (isDarkTheme) Color(0xFFE3E3E6) else Color(0xFF2B2B2F)
Box {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
contentDescription = "Admin",
tint = iconTint,
modifier =
Modifier.size(14.dp).clickable(
indication = null,
interactionSource =
remember { MutableInteractionSource() }
) { showInfo = true }
)
DropdownMenu(
expanded = showInfo,
onDismissRequest = { showInfo = false },
offset = DpOffset(x = 0.dp, y = 6.dp),
modifier =
Modifier.background(
color = popupBackground,
shape = RoundedCornerShape(14.dp)
)
) {
Text(
text = "This user is administrator of\nthis group.",
color = popupTextColor,
fontSize = 14.sp,
lineHeight = 20.sp,
modifier =
Modifier.padding(
horizontal = 14.dp,
vertical = 10.dp
)
)
}
}
}
@Composable
private fun GroupInviteInlineCard(
inviteText: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
actionsEnabled: Boolean,
onOpenGroup: (SearchUser) -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val groupRepository = remember { GroupRepository.getInstance(context) }
val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
LaunchedEffect(normalizedInvite, accountPublicKey) {
if (parsedInvite == null) {
status = GroupStatus.INVALID
membersCount = 0
statusLoading = false
return@LaunchedEffect
}
statusLoading = true
val localGroupExists =
withContext(Dispatchers.IO) {
if (accountPublicKey.isBlank()) {
false
} else {
groupRepository.getGroup(accountPublicKey, parsedInvite.groupId) != null
}
}
val inviteInfo =
withContext(Dispatchers.IO) {
groupRepository.requestInviteInfo(parsedInvite.groupId)
}
membersCount = inviteInfo?.membersCount ?: 0
status =
when {
localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED
}
statusLoading = false
}
val title =
parsedInvite?.title?.trim().takeUnless { it.isNullOrBlank() }
?: "Group Invite"
val subtitle =
if (statusLoading) {
"Checking invite..."
} else {
when (status) {
GroupStatus.NOT_JOINED ->
if (membersCount > 0) {
"$membersCount members • Invite to join this group"
} else {
"Invite to join this group"
}
GroupStatus.JOINED ->
if (membersCount > 0) {
"$membersCount members • You are already a member"
} else {
"You are already a member of this group"
}
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "This group invite is invalid"
}
}
val cardBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.16f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.06f)
} else {
Color.Black.copy(alpha = 0.03f)
}
val cardBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.22f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.12f)
} else {
Color.Black.copy(alpha = 0.08f)
}
val titleColor =
if (isOutgoing) Color.White
else if (isDarkTheme) Color.White
else Color(0xFF1A1A1A)
val subtitleColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA)
else Color(0xFF70757F)
val accentColor =
when (status) {
GroupStatus.NOT_JOINED ->
if (isOutgoing) Color.White else Color(0xFF228BE6)
GroupStatus.JOINED ->
if (isOutgoing) Color(0xFFD9FAD6) else Color(0xFF34C759)
GroupStatus.BANNED, GroupStatus.INVALID ->
if (isOutgoing) Color(0xFFFFD7D7) else Color(0xFFFF3B30)
}
val actionLabel =
when (status) {
GroupStatus.NOT_JOINED -> "Join Group"
GroupStatus.JOINED -> "Open Group"
GroupStatus.INVALID -> "Invalid"
GroupStatus.BANNED -> "Banned"
}
val actionIcon =
when (status) {
GroupStatus.NOT_JOINED -> Icons.Default.PersonAdd
GroupStatus.JOINED -> Icons.Default.Check
GroupStatus.INVALID -> Icons.Default.Link
GroupStatus.BANNED -> Icons.Default.Block
}
val actionEnabled = actionsEnabled && !statusLoading && !actionLoading && status != GroupStatus.INVALID && status != GroupStatus.BANNED
fun openParsedGroup() {
val parsed = parsedInvite ?: return
onOpenGroup(
SearchUser(
publicKey = groupRepository.toGroupDialogPublicKey(parsed.groupId),
title = parsed.title.ifBlank { "Group" },
username = "",
verified = 0,
online = 0
)
)
}
fun handleAction() {
if (!actionEnabled) return
if (parsedInvite == null) return
if (status == GroupStatus.JOINED) {
openParsedGroup()
return
}
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
Toast.makeText(context, "Account is not ready", Toast.LENGTH_SHORT).show()
return
}
scope.launch {
actionLoading = true
val joinResult =
withContext(Dispatchers.IO) {
groupRepository.joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = normalizedInvite
)
}
actionLoading = false
if (joinResult.success) {
status = GroupStatus.JOINED
openParsedGroup()
} else {
status = joinResult.status
val errorMessage =
when (joinResult.status) {
GroupStatus.BANNED -> "You are banned in this group"
GroupStatus.INVALID -> "This invite is invalid"
else -> joinResult.error ?: "Failed to join group"
}
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = cardBackground,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(34.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = titleColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = subtitleColor,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier =
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
enabled = actionEnabled,
onClick = ::handleAction
),
color =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
accentColor.copy(alpha = 0.14f)
},
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (actionLoading || statusLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.8.dp,
color = accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = actionLabel,
color = accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
@Composable
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
@@ -2368,8 +2856,12 @@ fun KebabMenu(
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isSavedMessages: Boolean,
isGroupChat: Boolean = false,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {},
onBlockClick: () -> Unit,
onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit
@@ -2398,24 +2890,49 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
if (isGroupChat) {
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search Members",
onClick = onSearchMembersClick,
tintColor = iconColor,
textColor = textColor
)
}
KebabMenuItem(
icon = TelegramIcons.Info,
text = "Group Info",
onClick = onGroupInfoClick,
tintColor = iconColor,
textColor = textColor
)
Divider(color = dividerColor)
KebabMenuItem(
icon = TelegramIcons.Leave,
text = "Leave Group",
onClick = onLeaveGroupClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
} else {
if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
tintColor = iconColor,
textColor = textColor
)
}
// Delete chat
KebabMenuItem(
icon = TelegramIcons.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
// Delete chat
KebabMenuItem(
icon = TelegramIcons.Delete,
text = "Delete Chat",
onClick = onDeleteClick,
tintColor = Color(0xFFFF3B30),
textColor = Color(0xFFFF3B30)
)
}
}
}
}

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.TransportManager
@@ -931,13 +932,22 @@ private suspend fun loadBitmapForViewerImage(
AttachmentDownloadDebugLogger.log(
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
if (image.chachaKey.startsWith("group:")) {
val groupPassword =
CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"),
privateKey
) ?: return null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword) ?: return null
} else {
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
AttachmentDownloadDebugLogger.log(
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
)
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
?: return null
}
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null

View File

@@ -40,7 +40,9 @@ data class ChatMessage(
val replyData: ReplyData? = null,
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
val attachments: List<MessageAttachment> = emptyList(),
val chachaKey: String = "" // Для расшифровки attachments
val chachaKey: String = "", // Для расшифровки attachments
val senderPublicKey: String = "",
val senderName: String = ""
)
/** Message delivery and read status */

View File

@@ -7,6 +7,7 @@ import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -24,6 +25,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.rosetta.messenger.R
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
@@ -56,17 +60,19 @@ fun BoxScope.BlurredAvatarBackground(
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKey) {
LaunchedEffect(avatarKey, publicKey) {
val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) {
val newOriginal = withContext(Dispatchers.IO) {
val newOriginal = withContext(Dispatchers.IO) {
if (currentAvatars.isNotEmpty()) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
} else {
loadSystemAvatarBitmap(context, publicKey)
}
if (newOriginal != null) {
originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
}
}
if (newOriginal != null) {
originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
}
} else {
originalBitmap = null
@@ -165,6 +171,21 @@ fun BoxScope.BlurredAvatarBackground(
}
}
private fun resolveSystemAvatarRes(publicKey: String): Int? =
when (publicKey) {
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY -> R.drawable.safe_account
MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY -> R.drawable.updates_account
else -> null
}
private fun loadSystemAvatarBitmap(context: Context, publicKey: String): Bitmap? {
val resId = resolveSystemAvatarRes(publicKey) ?: return null
val drawable = AppCompatResources.getDrawable(context, resId) ?: return null
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 256
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 256
return drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888)
}
/**
* Gaussian blur via RenderScript.
* Pads the image with mirrored edges before blurring to eliminate edge banding artifacts,

View File

@@ -33,8 +33,8 @@ private const val EDGE_ZONE_DP = 320
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f
private const val TOUCH_SLOP_FACTOR = 0.35f
private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
private const val BACKGROUND_MIN_SCALE = 0.94f
private const val BACKGROUND_PARALLAX_DP = 18
private const val BACKGROUND_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 4
/**
* Telegram-style swipe back container (optimized)
@@ -50,6 +50,7 @@ private const val BACKGROUND_PARALLAX_DP = 18
*/
private object SwipeBackSharedProgress {
var ownerId by mutableLongStateOf(Long.MIN_VALUE)
var ownerLayer by mutableIntStateOf(Int.MIN_VALUE)
var progress by mutableFloatStateOf(0f)
var active by mutableStateOf(false)
}
@@ -57,19 +58,20 @@ private object SwipeBackSharedProgress {
@Composable
fun SwipeBackBackgroundEffect(
modifier: Modifier = Modifier,
layer: Int = 0,
content: @Composable BoxScope.() -> Unit
) {
val density = LocalDensity.current
val progress = SwipeBackSharedProgress.progress.coerceIn(0f, 1f)
val active = SwipeBackSharedProgress.active
val active = SwipeBackSharedProgress.active && SwipeBackSharedProgress.ownerLayer == layer + 1
val parallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val scale = if (active) BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * progress else 1f
val backgroundTranslationX = if (active) -parallaxPx * (1f - progress) else 0f
val backgroundTranslationX = if (active) parallaxPx * (1f - progress) else 0f
Box(
modifier =
modifier.graphicsLayer {
transformOrigin = TransformOrigin(0f, 0.5f)
transformOrigin = TransformOrigin(1f, 0f)
scaleX = scale
scaleY = scale
translationX = backgroundTranslationX
@@ -83,6 +85,7 @@ fun SwipeBackContainer(
isVisible: Boolean,
onBack: () -> Unit,
isDarkTheme: Boolean,
layer: Int = 1,
swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true,
content: @Composable () -> Unit
@@ -134,9 +137,11 @@ fun SwipeBackContainer(
// Scrim alpha based on swipe progress
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
val sharedOwnerId = SwipeBackSharedProgress.ownerId
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
val sharedProgress = SwipeBackSharedProgress.progress
val sharedActive = SwipeBackSharedProgress.active
val isBackgroundForActiveSwipe = sharedActive && sharedOwnerId != containerId
val isBackgroundForActiveSwipe =
sharedActive && sharedOwnerId != containerId && sharedOwnerLayer == layer + 1
val backgroundScale =
if (isBackgroundForActiveSwipe) {
BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * sharedProgress.coerceIn(0f, 1f)
@@ -145,7 +150,7 @@ fun SwipeBackContainer(
}
val backgroundTranslationX =
if (isBackgroundForActiveSwipe) {
-backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f))
backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f))
} else {
0f
}
@@ -153,6 +158,7 @@ fun SwipeBackContainer(
fun updateSharedSwipeProgress(progress: Float, active: Boolean) {
if (!propagateBackgroundProgress) return
SwipeBackSharedProgress.ownerId = containerId
SwipeBackSharedProgress.ownerLayer = layer
SwipeBackSharedProgress.progress = progress.coerceIn(0f, 1f)
SwipeBackSharedProgress.active = active
}
@@ -161,6 +167,7 @@ fun SwipeBackContainer(
if (!propagateBackgroundProgress) return
if (SwipeBackSharedProgress.ownerId == containerId) {
SwipeBackSharedProgress.ownerId = Long.MIN_VALUE
SwipeBackSharedProgress.ownerLayer = Int.MIN_VALUE
SwipeBackSharedProgress.progress = 0f
SwipeBackSharedProgress.active = false
}
@@ -215,7 +222,7 @@ fun SwipeBackContainer(
modifier =
Modifier.fillMaxSize().graphicsLayer {
if (isBackgroundForActiveSwipe) {
transformOrigin = TransformOrigin(0f, 0.5f)
transformOrigin = TransformOrigin(1f, 0f)
scaleX = backgroundScale
scaleY = backgroundScale
translationX = backgroundTranslationX

View File

@@ -46,9 +46,15 @@ fun VerifiedBadge(
2 -> "This is official account belonging to administration of Rosetta."
else -> "This user is administrator of this group."
}
val badgeIconRes = if (verified == 3) {
R.drawable.ic_arrow_badge_down_filled
} else {
R.drawable.ic_rosette_discount_check
}
Icon(
painter = painterResource(id = R.drawable.ic_rosette_discount_check),
painter = painterResource(id = badgeIconRes),
contentDescription = "Verified",
tint = badgeColor,
modifier = modifier
@@ -69,7 +75,7 @@ fun VerifiedBadge(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = R.drawable.ic_rosette_discount_check),
painter = painterResource(id = badgeIconRes),
contentDescription = null,
tint = badgeColor,
modifier = Modifier.size(32.dp)

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M16.375,6.22l-4.375,3.498l-4.375,-3.5a1,1 0,0 0,-1.625,0.782v6a1,1 0,0 0,0.375,0.78l5,4a1,1 0,0 0,1.25,0l5,-4a1,1 0,0 0,0.375,-0.78v-6a1,1 0,0 0,-1.625,-0.78z" />
</vector>