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.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen 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.RequestsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
@@ -498,6 +500,8 @@ sealed class Screen {
data object Profile : Screen() data object Profile : Screen()
data object Requests : Screen() data object Requests : Screen()
data object Search : 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 ChatDetail(val user: SearchUser) : Screen()
data class OtherProfile(val user: SearchUser) : Screen() data class OtherProfile(val user: SearchUser) : Screen()
data object Updates : Screen() data object Updates : Screen()
@@ -600,10 +604,15 @@ fun MainScreen(
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } 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 { val chatDetailScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() } derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() }
} }
val selectedUser = chatDetailScreen?.user val selectedUser = chatDetailScreen?.user
val groupInfoScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.GroupInfo>().lastOrNull() }
}
val selectedGroup = groupInfoScreen?.group
val otherProfileScreen by remember { val otherProfileScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().lastOrNull() } derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().lastOrNull() }
} }
@@ -650,7 +659,10 @@ fun MainScreen(
} }
} }
fun popChatAndChildren() { 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 для логов // ProfileViewModel для логов
@@ -689,7 +701,7 @@ fun MainScreen(
// 🔥 Простая навигация с swipe back // 🔥 Простая навигация с swipe back
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize()) { SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize(), layer = 0) {
ChatsListScreen( ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
accountName = accountName, accountName = accountName,
@@ -702,7 +714,7 @@ fun MainScreen(
onToggleTheme = onToggleTheme, onToggleTheme = onToggleTheme,
onProfileClick = { pushScreen(Screen.Profile) }, onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = { onNewGroupClick = {
// TODO: Navigate to new group pushScreen(Screen.GroupSetup)
}, },
onContactsClick = { onContactsClick = {
// TODO: Navigate to contacts // TODO: Navigate to contacts
@@ -754,7 +766,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isRequestsVisible, isVisible = isRequestsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Requests } }, onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 1
) { ) {
RequestsListScreen( RequestsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -783,7 +796,9 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isProfileVisible, isVisible = isProfileVisible,
onBack = { popProfileAndChildren() }, onBack = { popProfileAndChildren() },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 1,
propagateBackgroundProgress = false
) { ) {
// Экран профиля // Экран профиля
ProfileScreen( ProfileScreen(
@@ -816,7 +831,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isSafetyVisible, isVisible = isSafetyVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } }, onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
SafetyScreen( SafetyScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -833,7 +849,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isBackupVisible, isVisible = isBackupVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Backup } }, onBack = { navStack = navStack.filterNot { it is Screen.Backup } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 3
) { ) {
BackupScreen( BackupScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -878,7 +895,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isThemeVisible, isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
ThemeScreen( ThemeScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -891,7 +909,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isAppearanceVisible, isVisible = isAppearanceVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } }, onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
com.rosetta.messenger.ui.settings.AppearanceScreen( com.rosetta.messenger.ui.settings.AppearanceScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -912,7 +931,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isUpdatesVisible, isVisible = isUpdatesVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Updates } }, onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
UpdatesScreen( UpdatesScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -938,6 +958,7 @@ fun MainScreen(
isVisible = selectedUser != null, isVisible = selectedUser != null,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !isChatSwipeLocked,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
@@ -959,6 +980,9 @@ fun MainScreen(
pushScreen(Screen.OtherProfile(user)) pushScreen(Screen.OtherProfile(user))
} }
}, },
onGroupInfoClick = { groupUser ->
pushScreen(Screen.GroupInfo(groupUser))
},
onNavigateToChat = { forwardUser -> onNavigateToChat = { forwardUser ->
// 📨 Forward: переход в выбранный чат с полными данными // 📨 Forward: переход в выбранный чат с полными данными
navStack = 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( SwipeBackContainer(
isVisible = isSearchVisible, isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } }, onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 1
) { ) {
// Экран поиска // Экран поиска
SearchScreen( 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( SwipeBackContainer(
isVisible = isLogsVisible, isVisible = isLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Logs } }, onBack = { navStack = navStack.filterNot { it is Screen.Logs } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
com.rosetta.messenger.ui.settings.ProfileLogsScreen( com.rosetta.messenger.ui.settings.ProfileLogsScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -1012,7 +1100,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isCrashLogsVisible, isVisible = isCrashLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } }, onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 1
) { ) {
CrashLogsScreen( CrashLogsScreen(
onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } } onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } }
@@ -1022,7 +1111,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isConnectionLogsVisible, isVisible = isConnectionLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } }, onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 1
) { ) {
ConnectionLogsScreen( ConnectionLogsScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -1039,7 +1129,9 @@ fun MainScreen(
isVisible = selectedOtherUser != null, isVisible = selectedOtherUser != null,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } }, onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
swipeEnabled = isOtherProfileSwipeEnabled layer = 2,
swipeEnabled = isOtherProfileSwipeEnabled,
propagateBackgroundProgress = false
) { ) {
selectedOtherUser?.let { currentOtherUser -> selectedOtherUser?.let { currentOtherUser ->
OtherProfileScreen( OtherProfileScreen(
@@ -1066,7 +1158,8 @@ fun MainScreen(
SwipeBackContainer( SwipeBackContainer(
isVisible = isBiometricVisible, isVisible = isBiometricVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Biometric } }, onBack = { navStack = navStack.filterNot { it is Screen.Biometric } },
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
layer = 2
) { ) {
val biometricManager = remember { val biometricManager = remember {
com.rosetta.messenger.biometric.BiometricAuthManager(context) 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 dialogDao = database.dialogDao()
private val avatarDao = database.avatarDao() private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao() private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -365,6 +366,23 @@ class MessageRepository private constructor(private val context: Context) {
return currentAccount != null && currentPrivateKey != null 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 { suspend fun getLastSyncTimestamp(): Long {
val account = currentAccount ?: return 0L val account = currentAccount ?: return 0L
val stored = syncTimeDao.getLastSync(account) ?: 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 isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
// 🔥 Проверяем, не заблокирован ли отправитель // 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage) { if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account) val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
if (isBlocked) { if (isBlocked) {
MessageLogger.logBlockedSender(packet.fromPublicKey) MessageLogger.logBlockedSender(packet.fromPublicKey)
@@ -688,25 +707,51 @@ class MessageRepository private constructor(private val context: Context) {
return true 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) val dialogKey = getDialogKey(dialogOpponentKey)
try { 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 = val plainKeyAndNonce =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) { if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey) CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
?.toByteArray(Charsets.ISO_8859_1) ?.toByteArray(Charsets.ISO_8859_1)
} else { } else {
null null
} }
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) { if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog( ProtocolManager.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..." "⚠️ 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( MessageLogger.debug(
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt" "📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
) )
@@ -714,7 +759,10 @@ class MessageRepository private constructor(private val context: Context) {
// Расшифровываем // Расшифровываем
val plainText = 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) MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else { } else {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
@@ -733,7 +781,8 @@ class MessageRepository private constructor(private val context: Context) {
packet.attachments, packet.attachments,
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
plainKeyAndNonce plainKeyAndNonce,
groupKey
) )
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop) // 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
@@ -742,7 +791,8 @@ class MessageRepository private constructor(private val context: Context) {
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
plainKeyAndNonce, plainKeyAndNonce,
messageId messageId,
groupKey
) )
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
@@ -751,14 +801,17 @@ class MessageRepository private constructor(private val context: Context) {
packet.fromPublicKey, packet.fromPublicKey,
packet.chachaKey, packet.chachaKey,
privateKey, privateKey,
plainKeyAndNonce plainKeyAndNonce,
groupKey
) )
// 🔒 Шифруем plainMessage с использованием приватного ключа // 🔒 Шифруем plainMessage с использованием приватного ключа
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey) val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
val storedChachaKey = val storedChachaKey =
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) { if (isGroupMessage) {
buildStoredGroupKey(groupKey!!, privateKey)
} else if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
"sync:${packet.aesChachaKey}" "sync:${packet.aesChachaKey}"
} else { } else {
packet.chachaKey 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 // Desktop parity: always re-fetch on incoming message so renamed contacts
// get their new name/username updated in the chat list. // get their new name/username updated in the chat list.
if (!isGroupDialogKey(dialogOpponentKey)) {
requestedUserInfoKeys.remove(dialogOpponentKey) requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey) requestUserInfo(dialogOpponentKey)
} else {
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
val senderKey = packet.fromPublicKey.trim()
if (senderKey.isNotBlank() &&
senderKey != account &&
!isGroupDialogKey(senderKey)
) {
requestUserInfo(senderKey)
}
}
// Обновляем кэш только если сообщение новое // Обновляем кэш только если сообщение новое
if (!stillExists) { if (!stillExists) {
@@ -889,6 +953,10 @@ class MessageRepository private constructor(private val context: Context) {
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check) // 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие) // 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
val isOwnReadSync = fromPublicKey == account 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 val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
if (opponentKey.isBlank()) return 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. // We can re-send the PacketMessage directly using stored fields.
val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) { val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
entity.chachaKey.removePrefix("sync:") entity.chachaKey.removePrefix("sync:")
} else if (entity.chachaKey.startsWith("group:")) {
entity.chachaKey.removePrefix("group:")
} else { } else {
// Re-generate aesChachaKey from the stored chachaKey + privateKey. // Re-generate aesChachaKey from the stored chachaKey + privateKey.
// The chachaKey in DB is the ECC-encrypted key for the recipient. // 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.fromPublicKey = account
this.toPublicKey = entity.toPublicKey this.toPublicKey = entity.toPublicKey
this.content = entity.content 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.aesChachaKey = aesChachaKeyValue
this.timestamp = entity.timestamp this.timestamp = entity.timestamp
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1088,6 +1166,9 @@ class MessageRepository private constructor(private val context: Context) {
*/ */
private fun getDialogKey(opponentKey: String): String { private fun getDialogKey(opponentKey: String): String {
val account = currentAccount ?: return opponentKey val account = currentAccount ?: return opponentKey
if (isGroupDialogKey(opponentKey)) {
return opponentKey.trim()
}
// Для saved messages dialog_key = просто publicKey // Для saved messages dialog_key = просто publicKey
if (account == opponentKey) { if (account == opponentKey) {
return account return account
@@ -1096,6 +1177,54 @@ class MessageRepository private constructor(private val context: Context) {
return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account" 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) { private fun updateMessageCache(dialogKey: String, message: Message) {
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
val currentList = flow.value.toMutableList() val currentList = flow.value.toMutableList()
@@ -1252,6 +1381,7 @@ class MessageRepository private constructor(private val context: Context) {
for (dialog in dialogs) { for (dialog in dialogs) {
// Skip self (Saved Messages) // Skip self (Saved Messages)
if (dialog.opponentKey == account) continue if (dialog.opponentKey == account) continue
if (isGroupDialogKey(dialog.opponentKey)) continue
// Skip if already requested in this cycle // Skip if already requested in this cycle
if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
requestedUserInfoKeys.add(dialog.opponentKey) 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. * Use when opening a dialog to ensure the name/username is fresh.
*/ */
fun forceRequestUserInfo(publicKey: String) { fun forceRequestUserInfo(publicKey: String) {
if (isGroupDialogKey(publicKey)) return
requestedUserInfoKeys.remove(publicKey) requestedUserInfoKeys.remove(publicKey)
requestUserInfo(publicKey) requestUserInfo(publicKey)
} }
@@ -1281,6 +1412,7 @@ class MessageRepository private constructor(private val context: Context) {
*/ */
fun requestUserInfo(publicKey: String) { fun requestUserInfo(publicKey: String) {
val privateKey = currentPrivateKey ?: return val privateKey = currentPrivateKey ?: return
if (isGroupDialogKey(publicKey)) return
// 🔥 Не запрашиваем если уже запрашивали // 🔥 Не запрашиваем если уже запрашивали
if (requestedUserInfoKeys.contains(publicKey)) { if (requestedUserInfoKeys.contains(publicKey)) {
@@ -1396,7 +1528,8 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey: String, fromPublicKey: String,
encryptedKey: String, encryptedKey: String,
privateKey: String, privateKey: String,
plainKeyAndNonce: ByteArray? = null plainKeyAndNonce: ByteArray? = null,
groupKey: String? = null
) { ) {
for (attachment in attachments) { for (attachment in attachments) {
@@ -1406,6 +1539,9 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
} }
@@ -1414,6 +1550,7 @@ class MessageRepository private constructor(private val context: Context) {
encryptedKey, encryptedKey,
privateKey privateKey
) )
}
if (decryptedBlob != null) { if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш // 2. Сохраняем аватар в кэш
@@ -1446,7 +1583,8 @@ class MessageRepository private constructor(private val context: Context) {
encryptedKey: String, encryptedKey: String,
privateKey: String, privateKey: String,
plainKeyAndNonce: ByteArray? = null, plainKeyAndNonce: ByteArray? = null,
messageId: String = "" messageId: String = "",
groupKey: String? = null
) { ) {
val publicKey = currentAccount ?: return val publicKey = currentAccount ?: return
@@ -1458,6 +1596,9 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
} }
@@ -1466,6 +1607,7 @@ class MessageRepository private constructor(private val context: Context) {
encryptedKey, encryptedKey,
privateKey privateKey
) )
}
if (decryptedBlob != null) { if (decryptedBlob != null) {
// 2. Сохраняем в файл (как в desktop) // 2. Сохраняем в файл (как в desktop)
@@ -1503,7 +1645,8 @@ class MessageRepository private constructor(private val context: Context) {
attachments: List<MessageAttachment>, attachments: List<MessageAttachment>,
encryptedKey: String, encryptedKey: String,
privateKey: String, privateKey: String,
plainKeyAndNonce: ByteArray? = null plainKeyAndNonce: ByteArray? = null,
groupKey: String? = null
): String { ): String {
if (attachments.isEmpty()) return "[]" if (attachments.isEmpty()) return "[]"
@@ -1517,6 +1660,9 @@ class MessageRepository private constructor(private val context: Context) {
try { try {
// 1. Расшифровываем с ChaCha ключом сообщения // 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
} }
@@ -1525,6 +1671,7 @@ class MessageRepository private constructor(private val context: Context) {
encryptedKey, encryptedKey,
privateKey privateKey
) )
}
if (decryptedBlob != null) { if (decryptedBlob != null) {
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве) // 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)

View File

@@ -141,6 +141,39 @@ data class DialogEntity(
"[]" // 📎 JSON attachments последнего сообщения (кэш из messages) "[]" // 📎 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 для работы с сообщениями */
@Dao @Dao
interface MessageDao { interface MessageDao {
@@ -476,8 +509,12 @@ interface DialogDao {
""" """
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 1 AND (
AND last_message_timestamp > 0 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 ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -489,8 +526,12 @@ interface DialogDao {
""" """
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 1 AND (
AND last_message_timestamp > 0 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 ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
@@ -506,7 +547,9 @@ interface DialogDao {
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 0 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 ORDER BY last_message_timestamp DESC
LIMIT 30 LIMIT 30
""" """
@@ -519,7 +562,9 @@ interface DialogDao {
SELECT COUNT(*) FROM dialogs SELECT COUNT(*) FROM dialogs
WHERE account = :account WHERE account = :account
AND i_have_sent = 0 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> fun getRequestsCountFlow(account: String): Flow<Int>
@@ -532,7 +577,8 @@ interface DialogDao {
@Query(""" @Query("""
SELECT * FROM dialogs SELECT * FROM dialogs
WHERE account = :account WHERE account = :account
AND last_message_timestamp > 0 AND (last_message != '' OR last_message_attachments != '[]')
AND opponent_key NOT LIKE '#group:%'
AND ( AND (
opponent_title = '' opponent_title = ''
OR opponent_title = opponent_key OR opponent_title = opponent_key
@@ -709,6 +755,32 @@ interface DialogDao {
) )
suspend fun hasSentToOpponent(account: String, opponentKey: String): Boolean 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 коррелированными * 🚀 ОПТИМИЗИРОВАННЫЙ updateDialogFromMessages Заменяет монолитный SQL с 9 коррелированными
* подзапросами на: * подзапросами на:
@@ -720,31 +792,40 @@ interface DialogDao {
*/ */
@Transaction @Transaction
suspend fun updateDialogFromMessages(account: String, opponentKey: String) { 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") // 📁 Для saved messages dialogKey = account (не "$account:$account")
val dialogKey = val dialogKey =
if (account == opponentKey) account if (account == normalizedOpponentKey) account
else if (account < opponentKey) "$account:$opponentKey" else if (isGroupDialog) normalizedOpponentKey
else "$opponentKey:$account" else if (account < normalizedOpponentKey) "$account:$normalizedOpponentKey"
else "$normalizedOpponentKey:$account"
// 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp) // 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp)
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
// 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...) // 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...)
val existing = getDialog(account, opponentKey) val existing = getDialog(account, normalizedOpponentKey)
// 3. Считаем непрочитанные — O(N) по индексу (account, from_public_key, to_public_key, // 3. Считаем непрочитанные входящие по dialog_key.
// timestamp) val unread = countUnreadByDialogKey(account, dialogKey)
val unread = countUnreadFromOpponent(account, opponentKey)
// 4. Проверяем были ли исходящие — O(1) // 4. Проверяем были ли исходящие — O(1)
val hasSent = hasSentToOpponent(account, opponentKey) val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными // 5. Один INSERT OR REPLACE с вычисленными данными
insertDialog( insertDialog(
DialogEntity( DialogEntity(
id = existing?.id ?: 0, id = existing?.id ?: 0,
account = account, account = account,
opponentKey = opponentKey, opponentKey = normalizedOpponentKey,
opponentTitle = existing?.opponentTitle ?: "", opponentTitle = existing?.opponentTitle ?: "",
opponentUsername = existing?.opponentUsername ?: "", opponentUsername = existing?.opponentUsername ?: "",
lastMessage = lastMsg.plainMessage, lastMessage = lastMsg.plainMessage,
@@ -753,7 +834,8 @@ interface DialogDao {
isOnline = existing?.isOnline ?: 0, isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 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, lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0, lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0, lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,

View File

@@ -16,8 +16,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
BlacklistEntity::class, BlacklistEntity::class,
AvatarCacheEntity::class, AvatarCacheEntity::class,
AccountSyncTimeEntity::class, AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class], PinnedMessageEntity::class],
version = 13, version = 14,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -27,6 +28,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun blacklistDao(): BlacklistDao abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao abstract fun pinnedMessageDao(): PinnedMessageDao
companion object { 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 { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -197,7 +223,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_9_10,
MIGRATION_10_11, MIGRATION_10_11,
MIGRATION_11_12, MIGRATION_11_12,
MIGRATION_12_13 MIGRATION_12_13,
MIGRATION_13_14
) )
.fallbackToDestructiveMigration() // Для разработки - только .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() }, 0x09 to { PacketDeviceNew() },
0x0A to { PacketRequestUpdate() }, 0x0A to { PacketRequestUpdate() },
0x0B to { PacketTyping() }, 0x0B to { PacketTyping() },
0x11 to { PacketCreateGroup() },
0x12 to { PacketGroupInfo() },
0x13 to { PacketGroupInviteInfo() },
0x14 to { PacketGroupJoin() },
0x15 to { PacketGroupLeave() },
0x16 to { PacketGroupBan() },
0x0F to { PacketRequestTransport() }, 0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() }, 0x17 to { PacketDeviceList() },
0x18 to { PacketDeviceResolve() }, 0x18 to { PacketDeviceResolve() },

View File

@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -39,6 +40,7 @@ object ProtocolManager {
private var protocol: Protocol? = null private var protocol: Protocol? = null
private var messageRepository: MessageRepository? = null private var messageRepository: MessageRepository? = null
private var groupRepository: GroupRepository? = null
private var appContext: Context? = null private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Volatile private var packetHandlersRegistered = false @Volatile private var packetHandlersRegistered = false
@@ -169,6 +171,7 @@ object ProtocolManager {
fun initialize(context: Context) { fun initialize(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
messageRepository = MessageRepository.getInstance(context) messageRepository = MessageRepository.getInstance(context)
groupRepository = GroupRepository.getInstance(context)
if (!packetHandlersRegistered) { if (!packetHandlersRegistered) {
setupPacketHandlers() setupPacketHandlers()
packetHandlersRegistered = true packetHandlersRegistered = true
@@ -285,7 +288,7 @@ object ProtocolManager {
if (isUnsupportedDialogKey(deliveryPacket.toPublicKey)) { if (isUnsupportedDialogKey(deliveryPacket.toPublicKey)) {
android.util.Log.w( android.util.Log.w(
TAG, TAG,
"Skipping unsupported delivery packet (conversation/group): to=${deliveryPacket.toPublicKey.take(24)}" "Skipping unsupported delivery packet: to=${deliveryPacket.toPublicKey.take(24)}"
) )
return@launchInboundPacketTask return@launchInboundPacketTask
} }
@@ -361,6 +364,34 @@ object ProtocolManager {
handleSyncPacket(packet as PacketSync) 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) // 🟢 Обработчик онлайн-статуса (0x05)
waitPacket(0x05) { packet -> waitPacket(0x05) { packet ->
val onlinePacket = packet as PacketOnlineState val onlinePacket = packet as PacketOnlineState
@@ -512,12 +543,21 @@ object ProtocolManager {
return true 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 { private fun isUnsupportedDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT) val normalized = value.trim().lowercase(Locale.ROOT)
if (normalized.isBlank()) return true if (normalized.isBlank()) return true
return normalized.startsWith("#") || if (isConversationDialogKey(normalized)) return true
normalized.startsWith("group:") || return false
normalized.startsWith("conversation:")
} }
private fun isSupportedDirectPeerKey(peerKey: String, ownKey: String): Boolean { private fun isSupportedDirectPeerKey(peerKey: String, ownKey: String): Boolean {
@@ -532,6 +572,8 @@ object ProtocolManager {
val from = packet.fromPublicKey.trim() val from = packet.fromPublicKey.trim()
val to = packet.toPublicKey.trim() val to = packet.toPublicKey.trim()
if (from.isBlank() || to.isBlank()) return false if (from.isBlank() || to.isBlank()) return false
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
if (isGroupDialogKey(to)) return true
return when { return when {
from == ownKey -> isSupportedDirectPeerKey(to, ownKey) from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
to == ownKey -> isSupportedDirectPeerKey(from, ownKey) to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
@@ -543,6 +585,8 @@ object ProtocolManager {
val from = packet.fromPublicKey.trim() val from = packet.fromPublicKey.trim()
val to = packet.toPublicKey.trim() val to = packet.toPublicKey.trim()
if (from.isBlank() || to.isBlank()) return false if (from.isBlank() || to.isBlank()) return false
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
if (isGroupDialogKey(to)) return true
return when { return when {
from == ownKey -> isSupportedDirectPeerKey(to, ownKey) from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
to == ownKey -> isSupportedDirectPeerKey(from, ownKey) to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
@@ -637,6 +681,18 @@ object ProtocolManager {
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized") requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch 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() val lastSync = repository.getLastSyncTimestamp()
addLog("🔄 SYNC sending request with lastSync=$lastSync") addLog("🔄 SYNC sending request with lastSync=$lastSync")
sendSynchronize(lastSync) sendSynchronize(lastSync)
@@ -1127,10 +1183,12 @@ object ProtocolManager {
fun disconnect() { fun disconnect() {
protocol?.disconnect() protocol?.disconnect()
protocol?.clearCredentials() protocol?.clearCredentials()
messageRepository?.clearInitialization()
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
lastSubscribedToken = null // reset so token is re-sent on next connect lastSubscribedToken = null // reset so token is re-sent on next connect
} }
@@ -1140,10 +1198,12 @@ object ProtocolManager {
fun destroy() { fun destroy() {
protocol?.destroy() protocol?.destroy()
protocol = null protocol = null
messageRepository?.clearInitialization()
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
scope.cancel() scope.cancel()
} }

View File

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

View File

@@ -97,6 +97,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao() private val pinnedMessageDao = database.pinnedMessageDao()
// MessageRepository для подписки на события новых сообщений // MessageRepository для подписки на события новых сообщений
@@ -105,6 +106,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText) // 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>() 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 @Volatile private var isCleared = false
// Информация о собеседнике // Информация о собеседнике
@@ -196,6 +200,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 Pinned messages state // 📌 Pinned messages state
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList()) private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow() 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) private val _currentPinnedIndex = MutableStateFlow(0)
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow() val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
@@ -529,6 +535,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// может получить стейтный кэш от предыдущего аккаунта // может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear() dialogMessagesCache.clear()
decryptionCache.clear() decryptionCache.clear()
groupKeyCache.clear()
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
} }
myPublicKey = publicKey myPublicKey = publicKey
myPrivateKey = privateKey myPrivateKey = privateKey
@@ -549,6 +558,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentTitle = title opponentTitle = title
opponentUsername = username opponentUsername = username
opponentVerified = verified.coerceAtLeast(0) opponentVerified = verified.coerceAtLeast(0)
if (!isGroupDialogKey(publicKey)) {
groupKeyCache.remove(publicKey)
}
groupSenderNameCache.clear()
groupSenderResolveRequested.clear()
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния! // 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
// Это важно для правильного отображения forward сообщений сразу // Это важно для правильного отображения forward сообщений сразу
@@ -583,6 +597,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
lastReadMessageTimestamp = 0L lastReadMessageTimestamp = 0L
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен! isDialogActive = true // 🔥 Диалог активен!
_pinnedMessagePreviews.value = emptyMap()
// Desktop parity: refresh opponent name/username from server on dialog open, // Desktop parity: refresh opponent name/username from server on dialog open,
// so renamed contacts get their new name displayed immediately. // so renamed contacts get their new name displayed immediately.
@@ -626,6 +641,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_pinnedMessages.value = pins _pinnedMessages.value = pins
// Всегда показываем самый последний пин (index 0, ORDER BY DESC) // Всегда показываем самый последний пин (index 0, ORDER BY DESC)
_currentPinnedIndex.value = 0 _currentPinnedIndex.value = 0
refreshPinnedMessagePreviews(acc, pins)
} }
} }
@@ -997,6 +1013,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId // 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
val cachedText = decryptionCache[entity.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 // Расшифровываем сообщение из content + chachaKey
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
var displayText = var displayText =
@@ -1004,10 +1036,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
cachedText cachedText
} else } else
try { try {
val privateKey = myPrivateKey if (isGroupMessage && groupKey != null && entity.content.isNotEmpty()) {
if (privateKey != null && 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.content.isNotEmpty() &&
entity.chachaKey.isNotEmpty() entity.chachaKey.isNotEmpty() &&
!entity.chachaKey.startsWith("group:")
) { ) {
// Расшифровываем как в архиве: content + chachaKey + privateKey // Расшифровываем как в архиве: content + chachaKey + privateKey
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для // 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
@@ -1047,7 +1088,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} catch (e: Exception) { } catch (e: Exception) {
// Пробуем расшифровать plainMessage // Пробуем расшифровать plainMessage
val privateKey = myPrivateKey
if (privateKey != null && entity.plainMessage.isNotEmpty()) { if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try { try {
val decrypted = CryptoManager.decryptWithPassword( val decrypted = CryptoManager.decryptWithPassword(
@@ -1076,7 +1116,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isFromMe = entity.fromMe == 1, isFromMe = entity.fromMe == 1,
content = entity.content, content = entity.content,
chachaKey = entity.chachaKey, chachaKey = entity.chachaKey,
plainKeyAndNonce = plainKeyAndNonce plainKeyAndNonce = plainKeyAndNonce,
groupPassword = groupKey
) )
var replyData = parsedReplyResult?.replyData var replyData = parsedReplyResult?.replyData
val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList() val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()
@@ -1093,6 +1134,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Парсим все attachments (IMAGE, FILE, AVATAR) // Парсим все attachments (IMAGE, FILE, AVATAR)
val parsedAttachments = parseAllAttachments(entity.attachments) 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( return ChatMessage(
id = entity.messageId, id = entity.messageId,
text = displayText, text = displayText,
@@ -1109,10 +1164,102 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
replyData = if (forwardedMessages.isNotEmpty()) null else replyData, replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages, forwardedMessages = forwardedMessages,
attachments = parsedAttachments, attachments = 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`) как текст сообщения. * Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
* Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки. * Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
@@ -1245,7 +1392,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
content: String, content: String,
chachaKey: String, chachaKey: String,
plainKeyAndNonce: ByteArray? = plainKeyAndNonce: ByteArray? =
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки null, // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
groupPassword: String? = null
): ParsedReplyResult? { ): ParsedReplyResult? {
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") { if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
@@ -1281,6 +1429,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey val privateKey = myPrivateKey
var decryptionSuccess = false 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: Пробуем расшифровать с приватным ключом (для исходящих // 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
// сообщений) // сообщений)
if (privateKey != null) { if (privateKey != null) {
@@ -1618,6 +1777,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* (account == opponent) возвращает просто account * (account == opponent) возвращает просто account
*/ */
private fun getDialogKey(account: String, opponent: String): String { private fun getDialogKey(account: String, opponent: String): String {
if (isGroupDialogKey(opponent)) {
return opponent.trim()
}
// Для saved messages dialog_key = просто publicKey // Для saved messages dialog_key = просто publicKey
if (account == opponent) { if (account == opponent) {
return account 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) { fun updateInputText(text: String) {
_inputText.value = text _inputText.value = text
@@ -1695,6 +1947,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📌 PINNED MESSAGES // 📌 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) { fun pinMessage(messageId: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -1729,14 +2054,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return pinnedMessageDao.isPinned(account, dialogKey, messageId) return pinnedMessageDao.isPinned(account, dialogKey, messageId)
} }
/** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */ /**
* 📌 Клик по pinned banner:
* 1) вернуть ТЕКУЩЕЕ отображаемое сообщение для скролла
* 2) после этого переключить индекс на следующее (циклически)
*
* Так скролл всегда попадает ровно в тот pinned, который видит пользователь в баннере.
*/
fun navigateToNextPinned(): String? { fun navigateToNextPinned(): String? {
val pins = _pinnedMessages.value val pins = _pinnedMessages.value
if (pins.isEmpty()) return null 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 val nextIdx = (currentIdx + 1) % pins.size
_currentPinnedIndex.value = nextIdx _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? { suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
if (publicKey.isEmpty()) return null if (publicKey.isEmpty()) return null
if (isGroupDialogKey(publicKey)) return null
// If it's the current opponent, we already have info // If it's the current opponent, we already have info
if (publicKey == opponentKey) { if (publicKey == opponentKey) {
@@ -1961,12 +2294,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 2. 🔥 Шифрование и отправка в IO потоке // 2. 🔥 Шифрование и отправка в IO потоке
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce val encryptionContext =
val encryptResult = MessageCrypto.encryptForSending(text, recipient) buildEncryptionContext(
val encryptedContent = encryptResult.ciphertext plaintext = text,
val encryptedKey = encryptResult.encryptedKey recipient = recipient,
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments privateKey = privateKey
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, 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 privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
@@ -1993,7 +2329,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey privateKey = privateKey
) )
if (imageBlob != null) { if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce) val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}" val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = "" var uploadTag = ""
@@ -2067,8 +2403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyBlobPlaintext = replyJsonArray.toString() val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
replyBlobForDatabase = replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
@@ -2153,7 +2488,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = text, text = text,
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = delivered =
@@ -2193,11 +2536,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val isSavedMessages = (sender == recipientPublicKey) val isSavedMessages = (sender == recipientPublicKey)
// Шифрование (пустой текст для forward) // Шифрование (пустой текст для forward)
val encryptResult = MessageCrypto.encryptForSending("", recipientPublicKey) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = "",
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipientPublicKey,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) 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 privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val messageAttachments = mutableListOf<MessageAttachment>() val messageAttachments = mutableListOf<MessageAttachment>()
@@ -2218,7 +2565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey privateKey = privateKey
) )
if (imageBlob != null) { if (imageBlob != null) {
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce) val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}" val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = "" var uploadTag = ""
@@ -2277,7 +2624,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
val replyBlobPlaintext = replyJsonArray.toString() val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce) val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey) replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}" val replyAttachmentId = "reply_${timestamp}"
@@ -2325,7 +2672,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = "", text = "",
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, delivered = if (isSavedMessages) 1 else 0,
@@ -2565,11 +2920,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифрование текста // Шифрование текста
val encryptStartedAt = System.currentTimeMillis() val encryptStartedAt = System.currentTimeMillis()
val encryptResult = MessageCrypto.encryptForSending(caption, recipient) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = caption,
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipient,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline( logPhotoPipeline(
messageId, messageId,
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms" "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 // 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val blobEncryptStartedAt = System.currentTimeMillis() val blobEncryptStartedAt = System.currentTimeMillis()
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce) val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
logPhotoPipeline( logPhotoPipeline(
messageId, messageId,
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms" "blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
@@ -2760,17 +3119,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
backgroundUploadScope.launch { backgroundUploadScope.launch {
try { try {
// Шифрование текста // Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = text,
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipient,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) 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 privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server // 🚀 Шифруем изображение с ChaCha ключом для Transport Server
val encryptedImageBlob = val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
val attachmentId = "img_$timestamp" val attachmentId = "img_$timestamp"
@@ -2846,7 +3208,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = text, text = text,
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
@@ -3022,11 +3392,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
try { try {
val groupStartedAt = System.currentTimeMillis() val groupStartedAt = System.currentTimeMillis()
// Шифрование текста // Шифрование текста
val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = text,
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipient,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
logPhotoPipeline( logPhotoPipeline(
messageId, messageId,
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}" "group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
@@ -3047,7 +3421,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Шифруем изображение с ChaCha ключом // Шифруем изображение с ChaCha ключом
val encryptedImageBlob = val encryptedImageBlob =
MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce) encryptAttachmentPayload(imageData.base64, encryptionContext)
// Загружаем на Transport Server // Загружаем на Transport Server
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
@@ -3122,7 +3496,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = text, text = text,
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, delivered = if (isSavedMessages) 1 else 0,
@@ -3227,16 +3609,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} catch (_: Exception) {} } catch (_: Exception) {}
val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = text,
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipient,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey) 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 privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// 🚀 Шифруем файл с ChaCha ключом для Transport Server // 🚀 Шифруем файл с ChaCha ключом для Transport Server
val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce) val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext)
val attachmentId = "file_$timestamp" val attachmentId = "file_$timestamp"
@@ -3299,7 +3685,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = text, text = text,
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = delivered =
@@ -3430,19 +3824,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) } withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) }
// 2. Шифрование текста (пустой текст для аватарки) // 2. Шифрование текста (пустой текст для аватарки)
val encryptResult = MessageCrypto.encryptForSending("", recipient) val encryptionContext =
val encryptedContent = encryptResult.ciphertext buildEncryptionContext(
val encryptedKey = encryptResult.encryptedKey plaintext = "",
val plainKeyAndNonce = encryptResult.plainKeyAndNonce recipient = recipient,
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey) 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) val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
// 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce) // 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
// НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения // НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
// Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob! // Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob!
val encryptedAvatarBlob = val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext)
MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce)
val avatarAttachmentId = "avatar_$timestamp" val avatarAttachmentId = "avatar_$timestamp"
@@ -3518,7 +3915,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId = messageId, messageId = messageId,
text = "", // Аватар без текста text = "", // Аватар без текста
encryptedContent = encryptedContent, encryptedContent = encryptedContent,
encryptedKey = encryptedKey, encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
userPrivateKey
)
} else {
encryptedKey
},
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
@@ -3568,6 +3973,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponent) 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 // 🔥 FIX: updateDialogFromMessages создаёт новый диалог с пустым title/username
// когда диалога ещё не было. Обновляем метаданные из уже известных данных. // когда диалога ещё не было. Обновляем метаданные из уже известных данных.
if (opponent != account && opponentTitle.isNotEmpty()) { if (opponent != account && opponentTitle.isNotEmpty()) {
@@ -3602,6 +4021,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.updateDialogFromMessages(account, opponentKey) 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 после пересчёта счётчиков // 🔥 FIX: Сохраняем title/username после пересчёта счётчиков
if (opponentKey != account && opponentTitle.isNotEmpty()) { if (opponentKey != account && opponentTitle.isNotEmpty()) {
dialogDao.updateOpponentDisplayName( dialogDao.updateOpponentDisplayName(
@@ -3711,7 +4144,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 📁 Для Saved Messages - не отправляем typing indicator // 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender) { if (opponent == sender || isGroupDialogKey(opponent)) {
return return
} }
@@ -3754,7 +4187,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey ?: return val sender = myPublicKey ?: return
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender) // 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
if (opponent == sender) { if (opponent == sender || isGroupDialogKey(opponent)) {
return return
} }
@@ -3855,7 +4288,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
// 📁 Для Saved Messages - не нужно подписываться на свой собственный статус // 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
if (account == opponent) { if (account == opponent || isGroupDialogKey(opponent)) {
return return
} }
@@ -3887,7 +4320,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// Удаляем все сообщения из БД // Удаляем все сообщения из БД
if (isGroupDialogKey(opponent)) {
val dialogKey = getDialogKey(account, opponent)
messageDao.deleteDialog(account, dialogKey)
} else {
messageDao.deleteMessagesBetweenUsers(account, account, opponent) messageDao.deleteMessagesBetweenUsers(account, account, opponent)
}
// Очищаем кэш // Очищаем кэш
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
@@ -3926,6 +4364,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
isCleared = true isCleared = true
pinnedCollectionJob?.cancel()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.content.Context import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
@@ -186,6 +187,11 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase() 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_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -505,6 +511,7 @@ fun ChatsListScreen(
// Confirmation dialogs state // Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) } var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) } var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
@@ -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 // 📖 Saved Messages
DrawerMenuItemEnhanced( DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved), painter = painterResource(id = R.drawable.msg_saved),
@@ -2011,6 +2034,10 @@ fun ChatsListScreen(
.contains( .contains(
dialog.opponentKey dialog.opponentKey
) )
val isGroupDialog =
isGroupDialogKey(
dialog.opponentKey
)
val isTyping by val isTyping by
remember( remember(
dialog.opponentKey dialog.opponentKey
@@ -2128,6 +2155,10 @@ fun ChatsListScreen(
dialogsToDelete = dialogsToDelete =
listOf(dialog) listOf(dialog)
}, },
onLeave = {
dialogToLeave =
dialog
},
onBlock = { onBlock = {
dialogToBlock = dialogToBlock =
dialog dialog
@@ -2136,6 +2167,8 @@ fun ChatsListScreen(
dialogToUnblock = dialogToUnblock =
dialog dialog
}, },
isGroupChat =
isGroupDialog,
isPinned = isPinned =
isPinnedDialog, isPinnedDialog,
swipeEnabled = 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 // Block Dialog Confirmation
dialogToBlock?.let { dialog -> dialogToBlock?.let { dialog ->
AlertDialog( AlertDialog(
@@ -3022,7 +3103,7 @@ fun DrawerMenuItem(
} }
/** /**
* 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия * 🔥 Swipeable wrapper для DialogItem (Pin + Leave/Block + Delete). Свайп влево показывает действия
* (как в React Native версии) * (как в React Native версии)
*/ */
@Composable @Composable
@@ -3031,6 +3112,7 @@ fun SwipeableDialogItem(
isDarkTheme: Boolean, isDarkTheme: Boolean,
isTyping: Boolean = false, isTyping: Boolean = false,
isBlocked: Boolean = false, isBlocked: Boolean = false,
isGroupChat: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
isMuted: Boolean = false, isMuted: Boolean = false,
@@ -3043,6 +3125,7 @@ fun SwipeableDialogItem(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onLeave: () -> Unit = {},
onBlock: () -> Unit = {}, onBlock: () -> Unit = {},
onUnblock: () -> Unit = {}, onUnblock: () -> Unit = {},
isPinned: Boolean = false, isPinned: Boolean = false,
@@ -3068,7 +3151,7 @@ fun SwipeableDialogItem(
label = "pinnedBackground" label = "pinnedBackground"
) )
var offsetX by remember { mutableStateOf(0f) } var offsetX by remember { mutableStateOf(0f) }
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete) // 📌 3 кнопки: Pin + Leave/Block + Delete (для SavedMessages: Pin + Delete)
val buttonCount = val buttonCount =
if (!swipeEnabled) 0 if (!swipeEnabled) 0
else if (isSavedMessages) 2 else if (isSavedMessages) 2
@@ -3147,18 +3230,28 @@ fun SwipeableDialogItem(
} }
} }
// Кнопка Block/Unblock (только если не Saved Messages) // Кнопка Leave (для группы) или Block/Unblock (для личных чатов)
if (!isSavedMessages) { 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( Box(
modifier = modifier =
Modifier.width(80.dp) Modifier.width(80.dp)
.fillMaxHeight() .fillMaxHeight()
.background( .background(middleActionColor)
if (isBlocked) Color(0xFF4CAF50)
else Color(0xFFFF6B6B)
)
.clickable { .clickable {
if (isBlocked) onUnblock() if (isGroupChat) onLeave()
else if (isBlocked) onUnblock()
else onBlock() else onBlock()
offsetX = 0f offsetX = 0f
onSwipeClosed() onSwipeClosed()
@@ -3170,20 +3263,14 @@ fun SwipeableDialogItem(
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Icon( Icon(
painter = painter = middleActionIcon,
if (isBlocked) TelegramIcons.Unlock contentDescription = middleActionTitle,
else TelegramIcons.Block,
contentDescription =
if (isBlocked) "Unblock"
else "Block",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = text = middleActionTitle,
if (isBlocked) "Unblock"
else "Block",
color = Color.White, color = Color.White,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
@@ -3461,6 +3548,7 @@ fun DialogItemContent(
remember(dialog.opponentKey, isDarkTheme) { remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme) getAvatarColor(dialog.opponentKey, isDarkTheme)
} }
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
// 📁 Для Saved Messages показываем специальное имя // 📁 Для Saved Messages показываем специальное имя
// 🔥 Как в Архиве: title > username > "DELETED" // 🔥 Как в Архиве: title > username > "DELETED"
@@ -3628,6 +3716,15 @@ fun DialogItemContent(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis 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) || val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) || dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey) MessageRepository.isSystemAccount(dialog.opponentKey)
@@ -3871,7 +3968,7 @@ fun DialogItemContent(
) )
} }
} else { } else {
val displayText = val baseDisplayText =
when { when {
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
"Photo" -> "Photo" "Photo" -> "Photo"
@@ -3885,9 +3982,107 @@ fun DialogItemContent(
"No messages" "No messages"
else -> dialog.lastMessage 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
}
}
}
if (showSenderPrefix) {
Row(
modifier =
Modifier.fillMaxWidth(),
verticalAlignment =
Alignment.CenterVertically
) {
AppleEmojiText( AppleEmojiText(
text = displayText, 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, fontSize = 14.sp,
color = color =
if (dialog.unreadCount > 0) if (dialog.unreadCount > 0)
@@ -3905,6 +4100,7 @@ fun DialogItemContent(
} }
} }
} }
}
// Unread badge // Unread badge
if (dialog.unreadCount > 0) { if (dialog.unreadCount > 0) {

View File

@@ -5,8 +5,9 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.DraftManager 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.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -42,6 +43,8 @@ data class DialogUiModel(
val lastMessageRead: Int = 0, // Прочитано (0/1) val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? = val lastMessageAttachmentType: String? =
null, // 📎 Тип attachment: "Photo", "File", или null null, // 📎 Тип attachment: "Photo", "File", или null
val lastMessageSenderPrefix: String? = null, // 👥 Для групп: "You" или имя отправителя
val lastMessageSenderKey: String? = null, // 👥 Для групп: public key отправителя
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram) val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
) )
@@ -66,6 +69,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val groupRepository = GroupRepository.getInstance(application)
private var currentAccount: String = "" private var currentAccount: String = ""
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
@@ -123,6 +127,104 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val TAG = "ChatsListVM" 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) { fun setAccount(publicKey: String, privateKey: String) {
@@ -222,6 +324,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage = decryptedLastMessage =
decryptedLastMessage decryptedLastMessage
) )
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel( DialogUiModel(
id = dialog.id, id = dialog.id,
@@ -249,7 +353,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// DialogEntity // DialogEntity
// (денормализовано) // (денормализовано)
lastMessageAttachmentType = lastMessageAttachmentType =
attachmentType // 📎 Тип attachment attachmentType, // 📎 Тип attachment
lastMessageSenderPrefix =
groupLastSenderInfo?.senderPrefix,
lastMessageSenderKey =
groupLastSenderInfo?.senderKey
) )
} }
} }
@@ -320,6 +428,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedLastMessage = decryptedLastMessage =
decryptedLastMessage decryptedLastMessage
) )
val groupLastSenderInfo =
resolveGroupLastSenderInfo(dialog)
DialogUiModel( DialogUiModel(
id = dialog.id, id = dialog.id,
@@ -346,7 +456,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialog.lastMessageDelivered, dialog.lastMessageDelivered,
lastMessageRead = dialog.lastMessageRead, lastMessageRead = dialog.lastMessageRead,
lastMessageAttachmentType = 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 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 // Все уже подписаны if (newKeys.isEmpty()) return // Все уже подписаны
// Добавляем в Set ДО отправки пакета чтобы избежать race condition // Добавляем в Set ДО отправки пакета чтобы избежать race condition
@@ -429,7 +546,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (encryptedLastMessage.isEmpty()) return "" if (encryptedLastMessage.isEmpty()) return ""
if (privateKey.isEmpty()) { if (privateKey.isEmpty()) {
return if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage val plainCandidate =
if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
return formatPreviewText(plainCandidate)
} }
val decrypted = val decrypted =
@@ -439,11 +558,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
null null
} }
return when { val resolved = when {
decrypted != null -> decrypted decrypted != null -> decrypted
isLikelyEncryptedPayload(encryptedLastMessage) -> "" isLikelyEncryptedPayload(encryptedLastMessage) -> ""
else -> 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( 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) { suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return if (currentAccount.isEmpty()) return
@@ -649,7 +800,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/ */
private fun loadUserInfoForDialog(publicKey: String) { private fun loadUserInfoForDialog(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages) // 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) { if (publicKey == currentAccount || isGroupKey(publicKey)) {
return 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
@@ -77,6 +78,31 @@ private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096 private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex() 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 { private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty" if (value.isBlank()) return "empty"
val clean = value.trim() val clean = value.trim()
@@ -1487,6 +1513,30 @@ fun FileAttachment(
try { try {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
// Streaming: скачиваем во temp file, не в память
val success =
if (isGroupStoredKey(chachaKey)) {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
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, не в память // Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag) val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f downloadProgress = 0.5f
@@ -1499,7 +1549,7 @@ fun FileAttachment(
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB // Пиковое потребление памяти ~128KB вместо ~200MB
val success = withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
MessageCrypto.decryptAttachmentFileStreaming( MessageCrypto.decryptAttachmentFileStreaming(
tempFile, tempFile,
@@ -1510,6 +1560,7 @@ fun FileAttachment(
tempFile.delete() tempFile.delete()
} }
} }
}
downloadProgress = 0.95f downloadProgress = 0.95f
if (success) { if (success) {
@@ -1860,19 +1911,28 @@ fun AvatarAttachment(
downloadStatus = DownloadStatus.DECRYPTING downloadStatus = DownloadStatus.DECRYPTING
val decryptStartTime = System.currentTimeMillis()
val decrypted =
if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
null
} else {
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
}
} else {
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop) // КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes // Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce = val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует // Используем decryptAttachmentBlobWithPlainKey который правильно
// bytes в password // конвертирует bytes в password
val decryptStartTime = System.currentTimeMillis()
val decrypted =
MessageCrypto.decryptAttachmentBlobWithPlainKey( MessageCrypto.decryptAttachmentBlobWithPlainKey(
encryptedContent, encryptedContent,
decryptedKeyAndNonce decryptedKeyAndNonce
) )
}
val decryptTime = System.currentTimeMillis() - decryptStartTime val decryptTime = System.currentTimeMillis() - decryptStartTime
if (decrypted != null) { if (decrypted != null) {
@@ -2351,8 +2411,16 @@ private suspend fun processDownloadedImage(
onStatus(DownloadStatus.DECRYPTING) onStatus(DownloadStatus.DECRYPTING)
// Расшифровываем ключ // Расшифровываем ключ
val keyCandidates: List<ByteArray> var keyCandidates: List<ByteArray> = emptyList()
var groupPassword: String? = null
try { try {
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) keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) { if (keyCandidates.isEmpty()) {
throw IllegalArgumentException("empty key candidates") throw IllegalArgumentException("empty key candidates")
@@ -2362,10 +2430,16 @@ private suspend fun processDownloadedImage(
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) } 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}") logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
} }
}
} catch (e: Exception) { } catch (e: Exception) {
onError("Error") onError("Error")
onStatus(DownloadStatus.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)}") logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
return return
} }
@@ -2374,17 +2448,26 @@ private suspend fun processDownloadedImage(
val decryptStartTime = System.currentTimeMillis() val decryptStartTime = System.currentTimeMillis()
var successKeyIdx = -1 var successKeyIdx = -1
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList()) var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
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()) { for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate) val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
if (attempt.decrypted != null) { if (attempt.decrypted != null) {
successKeyIdx = idx successKeyIdx = idx
decryptDebug = attempt decryptDebug = attempt
value = attempt.decrypted
break break
} }
// Keep last trace for diagnostics if all fail. // Keep last trace for diagnostics if all fail.
decryptDebug = attempt decryptDebug = attempt
} }
val decrypted = decryptDebug.decrypted value
}
val decryptTime = System.currentTimeMillis() - decryptStartTime val decryptTime = System.currentTimeMillis() - decryptStartTime
onProgress(0.8f) onProgress(0.8f)
@@ -2428,7 +2511,12 @@ private suspend fun processDownloadedImage(
} else { } else {
onError("Error") onError("Error")
onStatus(DownloadStatus.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 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}") 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 -> decryptDebug.trace.take(96).forEachIndexed { index, line ->
@@ -2467,6 +2555,11 @@ internal suspend fun downloadAndDecryptImage(
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}" "Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
) )
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) val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
if (keyCandidates.isEmpty()) return@withContext null if (keyCandidates.isEmpty()) return@withContext null
val plainKeyAndNonce = keyCandidates.first() val plainKeyAndNonce = keyCandidates.first()
@@ -2479,7 +2572,7 @@ internal suspend fun downloadAndDecryptImage(
// Primary path for image attachments // Primary path for image attachments
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList()) var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
var decrypted: String? = null var value: String? = null
for ((idx, keyCandidate) in keyCandidates.withIndex()) { for ((idx, keyCandidate) in keyCandidates.withIndex()) {
val attempt = val attempt =
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug( MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
@@ -2488,7 +2581,7 @@ internal suspend fun downloadAndDecryptImage(
) )
if (attempt.decrypted != null) { if (attempt.decrypted != null) {
decryptDebug = attempt decryptDebug = attempt
decrypted = attempt.decrypted value = attempt.decrypted
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx") logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
break break
} }
@@ -2496,11 +2589,11 @@ internal suspend fun downloadAndDecryptImage(
} }
// Fallback for legacy payloads // Fallback for legacy payloads
if (decrypted.isNullOrEmpty()) { if (value.isNullOrEmpty()) {
decryptDebug.trace.takeLast(12).forEachIndexed { index, line -> decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line") logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
} }
decrypted = value =
try { try {
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
.takeIf { it.isNotEmpty() && it != encryptedContent } .takeIf { it.isNotEmpty() && it != encryptedContent }
@@ -2508,6 +2601,8 @@ internal suspend fun downloadAndDecryptImage(
null null
} }
} }
value
}
if (decrypted.isNullOrEmpty()) return@withContext null if (decrypted.isNullOrEmpty()) return@withContext null

View File

@@ -1,8 +1,14 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.widget.Toast
import com.rosetta.messenger.R
import androidx.compose.material.icons.Icons 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.ContentCopy
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* 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.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
@@ -69,6 +81,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
@@ -281,6 +294,9 @@ fun MessageBubble(
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
privateKey: String = "", privateKey: String = "",
senderPublicKey: String = "", senderPublicKey: String = "",
senderName: String = "",
showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
@@ -291,6 +307,7 @@ fun MessageBubble(
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {} contextMenuContent: @Composable () -> Unit = {}
) { ) {
// Swipe-to-reply state // Swipe-to-reply state
@@ -366,6 +383,22 @@ fun MessageBubble(
message.attachments.isEmpty() && message.attachments.isEmpty() &&
message.text.isNotBlank() 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 // Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
val bubbleShape = val bubbleShape =
remember(message.isOutgoing, showTail, isSafeSystemMessage) { remember(message.isOutgoing, showTail, isSafeSystemMessage) {
@@ -719,6 +752,32 @@ fun MessageBubble(
) )
} else { } else {
Column { 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) // 🔥 Forwarded messages (multiple, desktop parity)
if (message.forwardedMessages.isNotEmpty()) { if (message.forwardedMessages.isNotEmpty()) {
ForwardedMessagesBubble( ForwardedMessagesBubble(
@@ -974,6 +1033,48 @@ fun MessageBubble(
!hasImageWithCaption && !hasImageWithCaption &&
message.text.isNotEmpty() message.text.isNotEmpty()
) { ) {
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
)
}
}
} else {
// Telegram-style: текст + время с автоматическим // Telegram-style: текст + время с автоматическим
// переносом // переносом
TelegramStyleMessageContent( TelegramStyleMessageContent(
@@ -1052,6 +1153,7 @@ fun MessageBubble(
} }
} }
} }
}
// 💬 Context menu anchor (DropdownMenu positions relative to this Box) // 💬 Context menu anchor (DropdownMenu positions relative to this Box)
contextMenuContent() contextMenuContent()
} }
@@ -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 @Composable
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) { private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23) val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
@@ -2368,8 +2856,12 @@ fun KebabMenu(
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSavedMessages: Boolean, isSavedMessages: Boolean,
isGroupChat: Boolean = false,
isSystemAccount: Boolean = false, isSystemAccount: Boolean = false,
isBlocked: Boolean, isBlocked: Boolean,
onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {},
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onUnblockClick: () -> Unit, onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
@@ -2398,6 +2890,30 @@ fun KebabMenu(
dismissOnClickOutside = true dismissOnClickOutside = true
) )
) { ) {
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) { if (!isSavedMessages && !isSystemAccount) {
KebabMenuItem( KebabMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
@@ -2419,6 +2935,7 @@ fun KebabMenu(
} }
} }
} }
}
@Composable @Composable
private fun KebabMenuItem( private fun KebabMenuItem(

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import android.renderscript.Allocation
import android.renderscript.Element import android.renderscript.Element
import android.renderscript.RenderScript import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur import android.renderscript.ScriptIntrinsicBlur
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -56,18 +60,20 @@ fun BoxScope.BlurredAvatarBackground(
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) } var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKey) { LaunchedEffect(avatarKey, publicKey) {
val currentAvatars = avatars val currentAvatars = avatars
if (currentAvatars.isNotEmpty()) {
val newOriginal = withContext(Dispatchers.IO) { val newOriginal = withContext(Dispatchers.IO) {
if (currentAvatars.isNotEmpty()) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data) AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
} else {
loadSystemAvatarBitmap(context, publicKey)
}
} }
if (newOriginal != null) { if (newOriginal != null) {
originalBitmap = newOriginal originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) { blurredBitmap = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = 20f, passes = 2) gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
} }
}
} else { } else {
originalBitmap = null originalBitmap = null
blurredBitmap = null blurredBitmap = 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. * Gaussian blur via RenderScript.
* Pads the image with mirrored edges before blurring to eliminate edge banding artifacts, * 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 EDGE_ZONE_WIDTH_FRACTION = 0.85f
private const val TOUCH_SLOP_FACTOR = 0.35f private const val TOUCH_SLOP_FACTOR = 0.35f
private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
private const val BACKGROUND_MIN_SCALE = 0.94f private const val BACKGROUND_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 18 private const val BACKGROUND_PARALLAX_DP = 4
/** /**
* Telegram-style swipe back container (optimized) * Telegram-style swipe back container (optimized)
@@ -50,6 +50,7 @@ private const val BACKGROUND_PARALLAX_DP = 18
*/ */
private object SwipeBackSharedProgress { private object SwipeBackSharedProgress {
var ownerId by mutableLongStateOf(Long.MIN_VALUE) var ownerId by mutableLongStateOf(Long.MIN_VALUE)
var ownerLayer by mutableIntStateOf(Int.MIN_VALUE)
var progress by mutableFloatStateOf(0f) var progress by mutableFloatStateOf(0f)
var active by mutableStateOf(false) var active by mutableStateOf(false)
} }
@@ -57,19 +58,20 @@ private object SwipeBackSharedProgress {
@Composable @Composable
fun SwipeBackBackgroundEffect( fun SwipeBackBackgroundEffect(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
layer: Int = 0,
content: @Composable BoxScope.() -> Unit content: @Composable BoxScope.() -> Unit
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val progress = SwipeBackSharedProgress.progress.coerceIn(0f, 1f) 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 parallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val scale = if (active) BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * progress else 1f 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( Box(
modifier = modifier =
modifier.graphicsLayer { modifier.graphicsLayer {
transformOrigin = TransformOrigin(0f, 0.5f) transformOrigin = TransformOrigin(1f, 0f)
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
translationX = backgroundTranslationX translationX = backgroundTranslationX
@@ -83,6 +85,7 @@ fun SwipeBackContainer(
isVisible: Boolean, isVisible: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
layer: Int = 1,
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
@@ -134,9 +137,11 @@ fun SwipeBackContainer(
// Scrim alpha based on swipe progress // Scrim alpha based on swipe progress
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
val sharedOwnerId = SwipeBackSharedProgress.ownerId val sharedOwnerId = SwipeBackSharedProgress.ownerId
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
val sharedProgress = SwipeBackSharedProgress.progress val sharedProgress = SwipeBackSharedProgress.progress
val sharedActive = SwipeBackSharedProgress.active val sharedActive = SwipeBackSharedProgress.active
val isBackgroundForActiveSwipe = sharedActive && sharedOwnerId != containerId val isBackgroundForActiveSwipe =
sharedActive && sharedOwnerId != containerId && sharedOwnerLayer == layer + 1
val backgroundScale = val backgroundScale =
if (isBackgroundForActiveSwipe) { if (isBackgroundForActiveSwipe) {
BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * sharedProgress.coerceIn(0f, 1f) BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * sharedProgress.coerceIn(0f, 1f)
@@ -145,7 +150,7 @@ fun SwipeBackContainer(
} }
val backgroundTranslationX = val backgroundTranslationX =
if (isBackgroundForActiveSwipe) { if (isBackgroundForActiveSwipe) {
-backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f)) backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f))
} else { } else {
0f 0f
} }
@@ -153,6 +158,7 @@ fun SwipeBackContainer(
fun updateSharedSwipeProgress(progress: Float, active: Boolean) { fun updateSharedSwipeProgress(progress: Float, active: Boolean) {
if (!propagateBackgroundProgress) return if (!propagateBackgroundProgress) return
SwipeBackSharedProgress.ownerId = containerId SwipeBackSharedProgress.ownerId = containerId
SwipeBackSharedProgress.ownerLayer = layer
SwipeBackSharedProgress.progress = progress.coerceIn(0f, 1f) SwipeBackSharedProgress.progress = progress.coerceIn(0f, 1f)
SwipeBackSharedProgress.active = active SwipeBackSharedProgress.active = active
} }
@@ -161,6 +167,7 @@ fun SwipeBackContainer(
if (!propagateBackgroundProgress) return if (!propagateBackgroundProgress) return
if (SwipeBackSharedProgress.ownerId == containerId) { if (SwipeBackSharedProgress.ownerId == containerId) {
SwipeBackSharedProgress.ownerId = Long.MIN_VALUE SwipeBackSharedProgress.ownerId = Long.MIN_VALUE
SwipeBackSharedProgress.ownerLayer = Int.MIN_VALUE
SwipeBackSharedProgress.progress = 0f SwipeBackSharedProgress.progress = 0f
SwipeBackSharedProgress.active = false SwipeBackSharedProgress.active = false
} }
@@ -215,7 +222,7 @@ fun SwipeBackContainer(
modifier = modifier =
Modifier.fillMaxSize().graphicsLayer { Modifier.fillMaxSize().graphicsLayer {
if (isBackgroundForActiveSwipe) { if (isBackgroundForActiveSwipe) {
transformOrigin = TransformOrigin(0f, 0.5f) transformOrigin = TransformOrigin(1f, 0f)
scaleX = backgroundScale scaleX = backgroundScale
scaleY = backgroundScale scaleY = backgroundScale
translationX = backgroundTranslationX translationX = backgroundTranslationX

View File

@@ -47,8 +47,14 @@ fun VerifiedBadge(
else -> "This user is administrator of this group." 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( Icon(
painter = painterResource(id = R.drawable.ic_rosette_discount_check), painter = painterResource(id = badgeIconRes),
contentDescription = "Verified", contentDescription = "Verified",
tint = badgeColor, tint = badgeColor,
modifier = modifier modifier = modifier
@@ -69,7 +75,7 @@ fun VerifiedBadge(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_rosette_discount_check), painter = painterResource(id = badgeIconRes),
contentDescription = null, contentDescription = null,
tint = badgeColor, tint = badgeColor,
modifier = Modifier.size(32.dp) 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>