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:
@@ -42,6 +42,8 @@ import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||
import com.rosetta.messenger.ui.chats.ChatsListScreen
|
||||
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
|
||||
import com.rosetta.messenger.ui.chats.GroupInfoScreen
|
||||
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||
import com.rosetta.messenger.ui.chats.SearchScreen
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
||||
@@ -498,6 +500,8 @@ sealed class Screen {
|
||||
data object Profile : Screen()
|
||||
data object Requests : Screen()
|
||||
data object Search : Screen()
|
||||
data object GroupSetup : Screen()
|
||||
data class GroupInfo(val group: SearchUser) : Screen()
|
||||
data class ChatDetail(val user: SearchUser) : Screen()
|
||||
data class OtherProfile(val user: SearchUser) : Screen()
|
||||
data object Updates : Screen()
|
||||
@@ -600,10 +604,15 @@ fun MainScreen(
|
||||
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
|
||||
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
|
||||
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
|
||||
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
|
||||
val chatDetailScreen by remember {
|
||||
derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() }
|
||||
}
|
||||
val selectedUser = chatDetailScreen?.user
|
||||
val groupInfoScreen by remember {
|
||||
derivedStateOf { navStack.filterIsInstance<Screen.GroupInfo>().lastOrNull() }
|
||||
}
|
||||
val selectedGroup = groupInfoScreen?.group
|
||||
val otherProfileScreen by remember {
|
||||
derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().lastOrNull() }
|
||||
}
|
||||
@@ -650,7 +659,10 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
fun popChatAndChildren() {
|
||||
navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile }
|
||||
navStack =
|
||||
navStack.filterNot {
|
||||
it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileViewModel для логов
|
||||
@@ -689,7 +701,7 @@ fun MainScreen(
|
||||
// 🔥 Простая навигация с swipe back
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
|
||||
SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize()) {
|
||||
SwipeBackBackgroundEffect(modifier = Modifier.fillMaxSize(), layer = 0) {
|
||||
ChatsListScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountName = accountName,
|
||||
@@ -702,7 +714,7 @@ fun MainScreen(
|
||||
onToggleTheme = onToggleTheme,
|
||||
onProfileClick = { pushScreen(Screen.Profile) },
|
||||
onNewGroupClick = {
|
||||
// TODO: Navigate to new group
|
||||
pushScreen(Screen.GroupSetup)
|
||||
},
|
||||
onContactsClick = {
|
||||
// TODO: Navigate to contacts
|
||||
@@ -754,7 +766,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isRequestsVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Requests } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1
|
||||
) {
|
||||
RequestsListScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -783,7 +796,9 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isProfileVisible,
|
||||
onBack = { popProfileAndChildren() },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
// Экран профиля
|
||||
ProfileScreen(
|
||||
@@ -816,7 +831,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isSafetyVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
SafetyScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -833,7 +849,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isBackupVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Backup } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 3
|
||||
) {
|
||||
BackupScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -878,7 +895,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isThemeVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
ThemeScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -891,7 +909,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isAppearanceVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
com.rosetta.messenger.ui.settings.AppearanceScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -912,7 +931,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isUpdatesVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
UpdatesScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -938,6 +958,7 @@ fun MainScreen(
|
||||
isVisible = selectedUser != null,
|
||||
onBack = { popChatAndChildren() },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
swipeEnabled = !isChatSwipeLocked,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
@@ -959,6 +980,9 @@ fun MainScreen(
|
||||
pushScreen(Screen.OtherProfile(user))
|
||||
}
|
||||
},
|
||||
onGroupInfoClick = { groupUser ->
|
||||
pushScreen(Screen.GroupInfo(groupUser))
|
||||
},
|
||||
onNavigateToChat = { forwardUser ->
|
||||
// 📨 Forward: переход в выбранный чат с полными данными
|
||||
navStack =
|
||||
@@ -973,10 +997,54 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(selectedGroup?.publicKey) {
|
||||
isGroupInfoSwipeEnabled = true
|
||||
}
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = selectedGroup != null,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2,
|
||||
swipeEnabled = isGroupInfoSwipeEnabled,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
selectedGroup?.let { groupUser ->
|
||||
GroupInfoScreen(
|
||||
groupUser = groupUser,
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
|
||||
onMemberClick = { member ->
|
||||
if (member.publicKey == accountPublicKey) {
|
||||
pushScreen(Screen.Profile)
|
||||
} else {
|
||||
pushScreen(Screen.OtherProfile(member))
|
||||
}
|
||||
},
|
||||
onSwipeBackEnabledChanged = { enabled ->
|
||||
isGroupInfoSwipeEnabled = enabled
|
||||
},
|
||||
onGroupLeft = {
|
||||
navStack =
|
||||
navStack.filterNot {
|
||||
it is Screen.GroupInfo ||
|
||||
(it is Screen.ChatDetail &&
|
||||
it.user.publicKey == groupUser.publicKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = isSearchVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1
|
||||
) {
|
||||
// Экран поиска
|
||||
SearchScreen(
|
||||
@@ -996,10 +1064,30 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = isGroupSetupVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1
|
||||
) {
|
||||
GroupSetupScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
||||
onGroupOpened = { groupUser ->
|
||||
navStack =
|
||||
navStack.filterNot { it is Screen.GroupSetup } +
|
||||
Screen.ChatDetail(groupUser)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = isLogsVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Logs } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
com.rosetta.messenger.ui.settings.ProfileLogsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -1012,7 +1100,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isCrashLogsVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1
|
||||
) {
|
||||
CrashLogsScreen(
|
||||
onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } }
|
||||
@@ -1022,7 +1111,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isConnectionLogsVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1
|
||||
) {
|
||||
ConnectionLogsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -1039,7 +1129,9 @@ fun MainScreen(
|
||||
isVisible = selectedOtherUser != null,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
|
||||
isDarkTheme = isDarkTheme,
|
||||
swipeEnabled = isOtherProfileSwipeEnabled
|
||||
layer = 2,
|
||||
swipeEnabled = isOtherProfileSwipeEnabled,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
selectedOtherUser?.let { currentOtherUser ->
|
||||
OtherProfileScreen(
|
||||
@@ -1066,7 +1158,8 @@ fun MainScreen(
|
||||
SwipeBackContainer(
|
||||
isVisible = isBiometricVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Biometric } },
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
) {
|
||||
val biometricManager = remember {
|
||||
com.rosetta.messenger.biometric.BiometricAuthManager(context)
|
||||
|
||||
431
app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt
Normal file
431
app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
private val groupDao = database.groupDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -365,6 +366,23 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return currentAccount != null && currentPrivateKey != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active account binding for this process session.
|
||||
* Must be called on explicit logout/account switch so stale account context
|
||||
* cannot be reused after reconnect.
|
||||
*/
|
||||
fun clearInitialization() {
|
||||
currentAccount = null
|
||||
currentPrivateKey = null
|
||||
requestedUserInfoKeys.clear()
|
||||
messageCache.clear()
|
||||
clearProcessedCache()
|
||||
}
|
||||
|
||||
fun getCurrentAccountKey(): String? = currentAccount
|
||||
|
||||
fun getCurrentPrivateKey(): String? = currentPrivateKey
|
||||
|
||||
suspend fun getLastSyncTimestamp(): Long {
|
||||
val account = currentAccount ?: return 0L
|
||||
val stored = syncTimeDao.getLastSync(account) ?: 0L
|
||||
@@ -647,9 +665,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
|
||||
|
||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||
if (!isOwnMessage) {
|
||||
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
|
||||
val isBlocked = database.blacklistDao().isUserBlocked(packet.fromPublicKey, account)
|
||||
if (isBlocked) {
|
||||
MessageLogger.logBlockedSender(packet.fromPublicKey)
|
||||
@@ -688,25 +707,51 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
val dialogOpponentKey = if (isOwnMessage) packet.toPublicKey else packet.fromPublicKey
|
||||
val dialogOpponentKey =
|
||||
when {
|
||||
isGroupMessage -> packet.toPublicKey
|
||||
isOwnMessage -> packet.toPublicKey
|
||||
else -> packet.fromPublicKey
|
||||
}
|
||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||
|
||||
try {
|
||||
val groupKey =
|
||||
if (isGroupMessage) {
|
||||
val fromPacket =
|
||||
if (packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
fromPacket ?: resolveGroupKeyForDialog(account, privateKey, packet.toPublicKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
|
||||
val plainKeyAndNonce =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
CryptoManager.decryptWithPassword(packet.aesChachaKey, privateKey)
|
||||
?.toByteArray(Charsets.ISO_8859_1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
|
||||
if (isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
if (!isGroupMessage && isOwnMessage && plainKeyAndNonce == null && packet.aesChachaKey.isBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 OWN SYNC fallback: aesChachaKey is missing, trying chachaKey decrypt"
|
||||
)
|
||||
@@ -714,7 +759,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// Расшифровываем
|
||||
val plainText =
|
||||
if (plainKeyAndNonce != null) {
|
||||
if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
@@ -733,7 +781,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.attachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
plainKeyAndNonce,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
@@ -742,7 +791,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce,
|
||||
messageId
|
||||
messageId,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
@@ -751,14 +801,17 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.fromPublicKey,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce
|
||||
plainKeyAndNonce,
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 🔒 Шифруем plainMessage с использованием приватного ключа
|
||||
val encryptedPlainMessage = CryptoManager.encryptWithPassword(plainText, privateKey)
|
||||
|
||||
val storedChachaKey =
|
||||
if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
if (isGroupMessage) {
|
||||
buildStoredGroupKey(groupKey!!, privateKey)
|
||||
} else if (isOwnMessage && packet.aesChachaKey.isNotBlank()) {
|
||||
"sync:${packet.aesChachaKey}"
|
||||
} else {
|
||||
packet.chachaKey
|
||||
@@ -807,8 +860,19 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
// Desktop parity: always re-fetch on incoming message so renamed contacts
|
||||
// get their new name/username updated in the chat list.
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
if (!isGroupDialogKey(dialogOpponentKey)) {
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
} else {
|
||||
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
||||
val senderKey = packet.fromPublicKey.trim()
|
||||
if (senderKey.isNotBlank() &&
|
||||
senderKey != account &&
|
||||
!isGroupDialogKey(senderKey)
|
||||
) {
|
||||
requestUserInfo(senderKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем кэш только если сообщение новое
|
||||
if (!stillExists) {
|
||||
@@ -889,6 +953,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 1) from=opponent, to=account -> собеседник прочитал НАШИ сообщения (double check)
|
||||
// 2) from=account, to=opponent -> sync с другого нашего устройства (мы прочитали входящие)
|
||||
val isOwnReadSync = fromPublicKey == account
|
||||
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
|
||||
// Group read receipts are currently not mapped to per-message states.
|
||||
return
|
||||
}
|
||||
val opponentKey = if (isOwnReadSync) toPublicKey else fromPublicKey
|
||||
if (opponentKey.isBlank()) return
|
||||
|
||||
@@ -1038,6 +1106,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// We can re-send the PacketMessage directly using stored fields.
|
||||
val aesChachaKeyValue = if (entity.chachaKey.startsWith("sync:")) {
|
||||
entity.chachaKey.removePrefix("sync:")
|
||||
} else if (entity.chachaKey.startsWith("group:")) {
|
||||
entity.chachaKey.removePrefix("group:")
|
||||
} else {
|
||||
// Re-generate aesChachaKey from the stored chachaKey + privateKey.
|
||||
// The chachaKey in DB is the ECC-encrypted key for the recipient.
|
||||
@@ -1058,7 +1128,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.fromPublicKey = account
|
||||
this.toPublicKey = entity.toPublicKey
|
||||
this.content = entity.content
|
||||
this.chachaKey = if (entity.chachaKey.startsWith("sync:")) "" else entity.chachaKey
|
||||
this.chachaKey =
|
||||
if (
|
||||
entity.chachaKey.startsWith("sync:") ||
|
||||
entity.chachaKey.startsWith("group:")
|
||||
) {
|
||||
""
|
||||
} else {
|
||||
entity.chachaKey
|
||||
}
|
||||
this.aesChachaKey = aesChachaKeyValue
|
||||
this.timestamp = entity.timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
@@ -1088,6 +1166,9 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
private fun getDialogKey(opponentKey: String): String {
|
||||
val account = currentAccount ?: return opponentKey
|
||||
if (isGroupDialogKey(opponentKey)) {
|
||||
return opponentKey.trim()
|
||||
}
|
||||
// Для saved messages dialog_key = просто publicKey
|
||||
if (account == opponentKey) {
|
||||
return account
|
||||
@@ -1096,6 +1177,54 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account"
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupId(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
|
||||
trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||
}
|
||||
|
||||
private fun decodeStoredGroupKey(storedKey: String, privateKey: String): String? {
|
||||
if (!storedKey.startsWith("group:")) return null
|
||||
val encoded = storedKey.removePrefix("group:")
|
||||
if (encoded.isBlank()) return null
|
||||
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||
}
|
||||
|
||||
private suspend fun resolveGroupKeyForDialog(
|
||||
account: String,
|
||||
privateKey: String,
|
||||
groupDialogPublicKey: String
|
||||
): String? {
|
||||
val groupId = normalizeGroupId(groupDialogPublicKey)
|
||||
if (groupId.isBlank()) return null
|
||||
val group = groupDao.getGroup(account, groupId) ?: return null
|
||||
return CryptoManager.decryptWithPassword(group.key, privateKey)
|
||||
}
|
||||
|
||||
private suspend fun applyGroupDisplayNameToDialog(account: String, dialogKey: String) {
|
||||
val groupId = normalizeGroupId(dialogKey)
|
||||
if (groupId.isBlank()) return
|
||||
val group = groupDao.getGroup(account, groupId) ?: return
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
account = account,
|
||||
opponentKey = dialogKey,
|
||||
title = group.title,
|
||||
username = group.description
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateMessageCache(dialogKey: String, message: Message) {
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
val currentList = flow.value.toMutableList()
|
||||
@@ -1252,6 +1381,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
for (dialog in dialogs) {
|
||||
// Skip self (Saved Messages)
|
||||
if (dialog.opponentKey == account) continue
|
||||
if (isGroupDialogKey(dialog.opponentKey)) continue
|
||||
// Skip if already requested in this cycle
|
||||
if (requestedUserInfoKeys.contains(dialog.opponentKey)) continue
|
||||
requestedUserInfoKeys.add(dialog.opponentKey)
|
||||
@@ -1271,6 +1401,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
* Use when opening a dialog to ensure the name/username is fresh.
|
||||
*/
|
||||
fun forceRequestUserInfo(publicKey: String) {
|
||||
if (isGroupDialogKey(publicKey)) return
|
||||
requestedUserInfoKeys.remove(publicKey)
|
||||
requestUserInfo(publicKey)
|
||||
}
|
||||
@@ -1281,6 +1412,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
fun requestUserInfo(publicKey: String) {
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
if (isGroupDialogKey(publicKey)) return
|
||||
|
||||
// 🔥 Не запрашиваем если уже запрашивали
|
||||
if (requestedUserInfoKeys.contains(publicKey)) {
|
||||
@@ -1396,7 +1528,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
fromPublicKey: String,
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
groupKey: String? = null
|
||||
) {
|
||||
|
||||
for (attachment in attachments) {
|
||||
@@ -1406,14 +1539,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем аватар в кэш
|
||||
@@ -1446,7 +1583,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
messageId: String = ""
|
||||
messageId: String = "",
|
||||
groupKey: String? = null
|
||||
) {
|
||||
val publicKey = currentAccount ?: return
|
||||
|
||||
@@ -1458,14 +1596,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем в файл (как в desktop)
|
||||
@@ -1503,7 +1645,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
attachments: List<MessageAttachment>,
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
groupKey: String? = null
|
||||
): String {
|
||||
if (attachments.isEmpty()) return "[]"
|
||||
|
||||
@@ -1517,14 +1660,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
}
|
||||
?: MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
privateKey
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
|
||||
|
||||
@@ -141,6 +141,39 @@ data class DialogEntity(
|
||||
"[]" // 📎 JSON attachments последнего сообщения (кэш из messages)
|
||||
)
|
||||
|
||||
/** Entity для групповых диалогов */
|
||||
@Entity(
|
||||
tableName = "groups",
|
||||
indices =
|
||||
[
|
||||
Index(value = ["account", "group_id"], unique = true)]
|
||||
)
|
||||
data class GroupEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "account") val account: String,
|
||||
@ColumnInfo(name = "group_id") val groupId: String,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "description") val description: String = "",
|
||||
@ColumnInfo(name = "key") val key: String
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface GroupDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGroup(group: GroupEntity): Long
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM groups WHERE account = :account AND group_id = :groupId LIMIT 1"
|
||||
)
|
||||
suspend fun getGroup(account: String, groupId: String): GroupEntity?
|
||||
|
||||
@Query("DELETE FROM groups WHERE account = :account AND group_id = :groupId")
|
||||
suspend fun deleteGroup(account: String, groupId: String): Int
|
||||
|
||||
@Query("DELETE FROM groups WHERE account = :account")
|
||||
suspend fun deleteAllByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** DAO для работы с сообщениями */
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@@ -476,8 +509,12 @@ interface DialogDao {
|
||||
"""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND i_have_sent = 1
|
||||
AND last_message_timestamp > 0
|
||||
AND (
|
||||
i_have_sent = 1
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -489,8 +526,12 @@ interface DialogDao {
|
||||
"""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND i_have_sent = 1
|
||||
AND last_message_timestamp > 0
|
||||
AND (
|
||||
i_have_sent = 1
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
@@ -506,7 +547,9 @@ interface DialogDao {
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND i_have_sent = 0
|
||||
AND last_message_timestamp > 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -519,7 +562,9 @@ interface DialogDao {
|
||||
SELECT COUNT(*) FROM dialogs
|
||||
WHERE account = :account
|
||||
AND i_have_sent = 0
|
||||
AND last_message_timestamp > 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
"""
|
||||
)
|
||||
fun getRequestsCountFlow(account: String): Flow<Int>
|
||||
@@ -532,7 +577,8 @@ interface DialogDao {
|
||||
@Query("""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND last_message_timestamp > 0
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND (
|
||||
opponent_title = ''
|
||||
OR opponent_title = opponent_key
|
||||
@@ -709,6 +755,32 @@ interface DialogDao {
|
||||
)
|
||||
suspend fun hasSentToOpponent(account: String, opponentKey: String): Boolean
|
||||
|
||||
/** 🚀 Универсальный подсчет непрочитанных входящих по dialog_key (direct + group) */
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) FROM messages
|
||||
WHERE account = :account
|
||||
AND dialog_key = :dialogKey
|
||||
AND from_me = 0
|
||||
AND read = 0
|
||||
"""
|
||||
)
|
||||
suspend fun countUnreadByDialogKey(account: String, dialogKey: String): Int
|
||||
|
||||
/** 🚀 Универсальная проверка исходящих сообщений по dialog_key (direct + group) */
|
||||
@Query(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM messages
|
||||
WHERE account = :account
|
||||
AND dialog_key = :dialogKey
|
||||
AND from_me = 1
|
||||
LIMIT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
suspend fun hasSentByDialogKey(account: String, dialogKey: String): Boolean
|
||||
|
||||
/**
|
||||
* 🚀 ОПТИМИЗИРОВАННЫЙ updateDialogFromMessages Заменяет монолитный SQL с 9 коррелированными
|
||||
* подзапросами на:
|
||||
@@ -720,31 +792,40 @@ interface DialogDao {
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun updateDialogFromMessages(account: String, opponentKey: String) {
|
||||
val normalizedOpponentKey = opponentKey.trim()
|
||||
val normalizedOpponentKeyLower = normalizedOpponentKey.lowercase()
|
||||
val isGroupDialog =
|
||||
normalizedOpponentKeyLower.startsWith("#group:") ||
|
||||
normalizedOpponentKeyLower.startsWith("group:")
|
||||
val isSystemDialog =
|
||||
normalizedOpponentKey == "0x000000000000000000000000000000000000000001" ||
|
||||
normalizedOpponentKey == "0x000000000000000000000000000000000000000002"
|
||||
|
||||
// 📁 Для saved messages dialogKey = account (не "$account:$account")
|
||||
val dialogKey =
|
||||
if (account == opponentKey) account
|
||||
else if (account < opponentKey) "$account:$opponentKey"
|
||||
else "$opponentKey:$account"
|
||||
if (account == normalizedOpponentKey) account
|
||||
else if (isGroupDialog) normalizedOpponentKey
|
||||
else if (account < normalizedOpponentKey) "$account:$normalizedOpponentKey"
|
||||
else "$normalizedOpponentKey:$account"
|
||||
|
||||
// 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp)
|
||||
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
|
||||
|
||||
// 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...)
|
||||
val existing = getDialog(account, opponentKey)
|
||||
val existing = getDialog(account, normalizedOpponentKey)
|
||||
|
||||
// 3. Считаем непрочитанные — O(N) по индексу (account, from_public_key, to_public_key,
|
||||
// timestamp)
|
||||
val unread = countUnreadFromOpponent(account, opponentKey)
|
||||
// 3. Считаем непрочитанные входящие по dialog_key.
|
||||
val unread = countUnreadByDialogKey(account, dialogKey)
|
||||
|
||||
// 4. Проверяем были ли исходящие — O(1)
|
||||
val hasSent = hasSentToOpponent(account, opponentKey)
|
||||
val hasSent = hasSentByDialogKey(account, dialogKey)
|
||||
|
||||
// 5. Один INSERT OR REPLACE с вычисленными данными
|
||||
insertDialog(
|
||||
DialogEntity(
|
||||
id = existing?.id ?: 0,
|
||||
account = account,
|
||||
opponentKey = opponentKey,
|
||||
opponentKey = normalizedOpponentKey,
|
||||
opponentTitle = existing?.opponentTitle ?: "",
|
||||
opponentUsername = existing?.opponentUsername ?: "",
|
||||
lastMessage = lastMsg.plainMessage,
|
||||
@@ -753,7 +834,8 @@ interface DialogDao {
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = existing?.verified ?: 0,
|
||||
iHaveSent = if (hasSent) 1 else (existing?.iHaveSent ?: 0),
|
||||
// Desktop parity: request flag is always derived from message history.
|
||||
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
|
||||
lastMessageFromMe = lastMsg.fromMe,
|
||||
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
|
||||
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
|
||||
|
||||
@@ -16,8 +16,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class,
|
||||
AccountSyncTimeEntity::class,
|
||||
GroupEntity::class,
|
||||
PinnedMessageEntity::class],
|
||||
version = 13,
|
||||
version = 14,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
@@ -27,6 +28,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun blacklistDao(): BlacklistDao
|
||||
abstract fun avatarDao(): AvatarDao
|
||||
abstract fun syncTimeDao(): SyncTimeDao
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun pinnedMessageDao(): PinnedMessageDao
|
||||
|
||||
companion object {
|
||||
@@ -176,6 +178,30 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 👥 МИГРАЦИЯ 13->14: Таблица groups для групповых диалогов
|
||||
*/
|
||||
private val MIGRATION_13_14 =
|
||||
object : Migration(13, 14) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
`key` TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS index_groups_account_group_id ON groups (account, group_id)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): RosettaDatabase {
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
@@ -197,7 +223,8 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_10_11,
|
||||
MIGRATION_11_12,
|
||||
MIGRATION_12_13
|
||||
MIGRATION_12_13,
|
||||
MIGRATION_13_14
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,12 @@ class Protocol(
|
||||
0x09 to { PacketDeviceNew() },
|
||||
0x0A to { PacketRequestUpdate() },
|
||||
0x0B to { PacketTyping() },
|
||||
0x11 to { PacketCreateGroup() },
|
||||
0x12 to { PacketGroupInfo() },
|
||||
0x13 to { PacketGroupInviteInfo() },
|
||||
0x14 to { PacketGroupJoin() },
|
||||
0x15 to { PacketGroupLeave() },
|
||||
0x16 to { PacketGroupBan() },
|
||||
0x0F to { PacketRequestTransport() },
|
||||
0x17 to { PacketDeviceList() },
|
||||
0x18 to { PacketDeviceResolve() },
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.rosetta.messenger.network
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import kotlinx.coroutines.*
|
||||
@@ -39,6 +40,7 @@ object ProtocolManager {
|
||||
|
||||
private var protocol: Protocol? = null
|
||||
private var messageRepository: MessageRepository? = null
|
||||
private var groupRepository: GroupRepository? = null
|
||||
private var appContext: Context? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@Volatile private var packetHandlersRegistered = false
|
||||
@@ -169,6 +171,7 @@ object ProtocolManager {
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
messageRepository = MessageRepository.getInstance(context)
|
||||
groupRepository = GroupRepository.getInstance(context)
|
||||
if (!packetHandlersRegistered) {
|
||||
setupPacketHandlers()
|
||||
packetHandlersRegistered = true
|
||||
@@ -285,7 +288,7 @@ object ProtocolManager {
|
||||
if (isUnsupportedDialogKey(deliveryPacket.toPublicKey)) {
|
||||
android.util.Log.w(
|
||||
TAG,
|
||||
"Skipping unsupported delivery packet (conversation/group): to=${deliveryPacket.toPublicKey.take(24)}"
|
||||
"Skipping unsupported delivery packet: to=${deliveryPacket.toPublicKey.take(24)}"
|
||||
)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
@@ -360,6 +363,34 @@ object ProtocolManager {
|
||||
waitPacket(0x19) { packet ->
|
||||
handleSyncPacket(packet as PacketSync)
|
||||
}
|
||||
|
||||
// 👥 Обработчик синхронизации групп (0x14)
|
||||
// Desktop parity: во время sync сервер отправляет PacketGroupJoin с groupString.
|
||||
waitPacket(0x14) { packet ->
|
||||
val joinPacket = packet as PacketGroupJoin
|
||||
|
||||
launchInboundPacketTask {
|
||||
val repository = messageRepository
|
||||
val groups = groupRepository
|
||||
val account = repository?.getCurrentAccountKey()
|
||||
val privateKey = repository?.getCurrentPrivateKey()
|
||||
if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) {
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
try {
|
||||
val result = groups.synchronizeJoinedGroup(
|
||||
accountPublicKey = account,
|
||||
accountPrivateKey = privateKey,
|
||||
packet = joinPacket
|
||||
)
|
||||
if (result?.success == true) {
|
||||
addLog("👥 GROUP synced: ${result.dialogPublicKey}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to sync group packet", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 Обработчик онлайн-статуса (0x05)
|
||||
waitPacket(0x05) { packet ->
|
||||
@@ -512,12 +543,21 @@ object ProtocolManager {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun isConversationDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("conversation:")
|
||||
}
|
||||
|
||||
private fun isUnsupportedDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
if (normalized.isBlank()) return true
|
||||
return normalized.startsWith("#") ||
|
||||
normalized.startsWith("group:") ||
|
||||
normalized.startsWith("conversation:")
|
||||
if (isConversationDialogKey(normalized)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isSupportedDirectPeerKey(peerKey: String, ownKey: String): Boolean {
|
||||
@@ -532,6 +572,8 @@ object ProtocolManager {
|
||||
val from = packet.fromPublicKey.trim()
|
||||
val to = packet.toPublicKey.trim()
|
||||
if (from.isBlank() || to.isBlank()) return false
|
||||
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
|
||||
if (isGroupDialogKey(to)) return true
|
||||
return when {
|
||||
from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
|
||||
to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
|
||||
@@ -543,6 +585,8 @@ object ProtocolManager {
|
||||
val from = packet.fromPublicKey.trim()
|
||||
val to = packet.toPublicKey.trim()
|
||||
if (from.isBlank() || to.isBlank()) return false
|
||||
if (isConversationDialogKey(from) || isConversationDialogKey(to)) return false
|
||||
if (isGroupDialogKey(to)) return true
|
||||
return when {
|
||||
from == ownKey -> isSupportedDirectPeerKey(to, ownKey)
|
||||
to == ownKey -> isSupportedDirectPeerKey(from, ownKey)
|
||||
@@ -637,6 +681,18 @@ object ProtocolManager {
|
||||
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
||||
return@launch
|
||||
}
|
||||
val protocolAccount = getProtocol().getPublicKey().orEmpty()
|
||||
val repositoryAccount = repository.getCurrentAccountKey().orEmpty()
|
||||
if (protocolAccount.isNotBlank() &&
|
||||
repositoryAccount.isNotBlank() &&
|
||||
repositoryAccount != protocolAccount
|
||||
) {
|
||||
syncRequestInFlight = false
|
||||
requireResyncAfterAccountInit(
|
||||
"⏳ Sync postponed: repository bound to another account"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val lastSync = repository.getLastSyncTimestamp()
|
||||
addLog("🔄 SYNC sending request with lastSync=$lastSync")
|
||||
sendSynchronize(lastSync)
|
||||
@@ -1127,10 +1183,12 @@ object ProtocolManager {
|
||||
fun disconnect() {
|
||||
protocol?.disconnect()
|
||||
protocol?.clearCredentials()
|
||||
messageRepository?.clearInitialization()
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
setSyncInProgress(false)
|
||||
resyncRequiredAfterAccountInit = false
|
||||
lastSubscribedToken = null // reset so token is re-sent on next connect
|
||||
}
|
||||
|
||||
@@ -1140,10 +1198,12 @@ object ProtocolManager {
|
||||
fun destroy() {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
messageRepository?.clearInitialization()
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
setSyncInProgress(false)
|
||||
resyncRequiredAfterAccountInit = false
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
@@ -94,11 +95,13 @@ import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
||||
import com.rosetta.messenger.utils.MediaUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
@@ -112,6 +115,7 @@ fun ChatDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (SearchUser) -> Unit,
|
||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||
currentUserPublicKey: String,
|
||||
currentUserPrivateKey: String,
|
||||
currentUserName: String = "",
|
||||
@@ -183,6 +187,7 @@ fun ChatDetailScreen(
|
||||
|
||||
// 📌 PINNED MESSAGES
|
||||
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
|
||||
val pinnedMessagePreviews by viewModel.pinnedMessagePreviews.collectAsState()
|
||||
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
|
||||
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -210,10 +215,23 @@ fun ChatDetailScreen(
|
||||
|
||||
// Определяем это Saved Messages или обычный чат
|
||||
val isSavedMessages = user.publicKey == currentUserPublicKey
|
||||
val isGroupChat = user.publicKey.trim().startsWith("#group:")
|
||||
val chatTitle =
|
||||
if (isSavedMessages) "Saved Messages"
|
||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||
|
||||
val openDialogInfo: () -> Unit = {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
if (isGroupChat) {
|
||||
onGroupInfoClick(user)
|
||||
} else {
|
||||
onUserProfileClick(user)
|
||||
}
|
||||
}
|
||||
|
||||
// 📨 Forward: показывать ли выбор чата
|
||||
var showForwardPicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -393,6 +411,10 @@ fun ChatDetailScreen(
|
||||
// 📨 Forward: список диалогов для выбора (загружаем из базы)
|
||||
val chatsListViewModel: ChatsListViewModel = viewModel()
|
||||
val dialogsList by chatsListViewModel.dialogs.collectAsState()
|
||||
val groupRepository = remember { GroupRepository.getInstance(context) }
|
||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Set<String>>(emptySet())
|
||||
}
|
||||
|
||||
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
|
||||
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
|
||||
@@ -401,6 +423,20 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
|
||||
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
|
||||
groupAdminKeys = emptySet()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val members = withContext(Dispatchers.IO) {
|
||||
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
|
||||
}
|
||||
val adminKey = members.firstOrNull().orEmpty()
|
||||
groupAdminKeys =
|
||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||
}
|
||||
|
||||
// Состояние выпадающего меню
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
@@ -503,12 +539,15 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// <20> Текст текущего pinned сообщения для баннера
|
||||
val currentPinnedMessagePreview = remember(pinnedMessages, currentPinnedIndex, messages) {
|
||||
val currentPinnedMessagePreview =
|
||||
remember(pinnedMessages, currentPinnedIndex, messages, pinnedMessagePreviews) {
|
||||
if (pinnedMessages.isEmpty()) ""
|
||||
else {
|
||||
val idx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
|
||||
val pinnedMsgId = pinnedMessages[idx].messageId
|
||||
messages.find { it.id == pinnedMsgId }?.text ?: "..."
|
||||
pinnedMessagePreviews[pinnedMsgId]
|
||||
?: messages.find { it.id == pinnedMsgId }?.text
|
||||
?: "Pinned message"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,8 +570,20 @@ fun ChatDetailScreen(
|
||||
delay(50) // Небольшая задержка для сброса анимации
|
||||
|
||||
// Находим индекс сообщения в списке
|
||||
val messageIndex =
|
||||
messagesWithDates.indexOfFirst { it.first.id == messageId }
|
||||
var messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId }
|
||||
if (messageIndex == -1) {
|
||||
val loaded = viewModel.ensureMessageLoaded(messageId)
|
||||
if (loaded) {
|
||||
for (attempt in 0 until 8) {
|
||||
delay(16)
|
||||
messageIndex =
|
||||
viewModel.messagesWithDates.value.indexOfFirst {
|
||||
it.first.id == messageId
|
||||
}
|
||||
if (messageIndex != -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (messageIndex != -1) {
|
||||
// Скроллим к сообщению
|
||||
listState.animateScrollToItem(messageIndex)
|
||||
@@ -553,6 +604,7 @@ fun ChatDetailScreen(
|
||||
val chatSubtitle =
|
||||
when {
|
||||
isSavedMessages -> "Notes"
|
||||
isGroupChat -> "group"
|
||||
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
|
||||
isOnline -> "online"
|
||||
isSystemAccount -> "official account"
|
||||
@@ -604,7 +656,7 @@ fun ChatDetailScreen(
|
||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||
.cancelNotificationForChat(context, user.publicKey)
|
||||
// Подписываемся на онлайн статус собеседника
|
||||
if (!isSavedMessages) {
|
||||
if (!isSavedMessages && !isGroupChat) {
|
||||
viewModel.subscribeToOnlineStatus()
|
||||
}
|
||||
// 🔥 Предзагружаем эмодзи в фоне
|
||||
@@ -944,8 +996,7 @@ fun ChatDetailScreen(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.then(
|
||||
if (!isSavedMessages
|
||||
) {
|
||||
if (!isSavedMessages) {
|
||||
Modifier
|
||||
.clickable(
|
||||
indication =
|
||||
@@ -955,21 +1006,7 @@ fun ChatDetailScreen(
|
||||
MutableInteractionSource()
|
||||
}
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm =
|
||||
context.getSystemService(
|
||||
Context.INPUT_METHOD_SERVICE
|
||||
) as
|
||||
InputMethodManager
|
||||
imm.hideSoftInputFromWindow(
|
||||
view.windowToken,
|
||||
0
|
||||
)
|
||||
focusManager
|
||||
.clearFocus()
|
||||
onUserProfileClick(
|
||||
user
|
||||
)
|
||||
openDialogInfo()
|
||||
}
|
||||
} else
|
||||
Modifier
|
||||
@@ -1034,8 +1071,7 @@ fun ChatDetailScreen(
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
.then(
|
||||
if (!isSavedMessages
|
||||
) {
|
||||
if (!isSavedMessages) {
|
||||
Modifier
|
||||
.clickable(
|
||||
indication =
|
||||
@@ -1045,21 +1081,7 @@ fun ChatDetailScreen(
|
||||
MutableInteractionSource()
|
||||
}
|
||||
) {
|
||||
// Мгновенное закрытие клавиатуры через нативный API
|
||||
val imm =
|
||||
context.getSystemService(
|
||||
Context.INPUT_METHOD_SERVICE
|
||||
) as
|
||||
InputMethodManager
|
||||
imm.hideSoftInputFromWindow(
|
||||
view.windowToken,
|
||||
0
|
||||
)
|
||||
focusManager
|
||||
.clearFocus()
|
||||
onUserProfileClick(
|
||||
user
|
||||
)
|
||||
openDialogInfo()
|
||||
}
|
||||
} else
|
||||
Modifier
|
||||
@@ -1087,6 +1109,7 @@ fun ChatDetailScreen(
|
||||
.Ellipsis
|
||||
)
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
(user.verified >
|
||||
0 || isRosettaOfficial)
|
||||
) {
|
||||
@@ -1146,6 +1169,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
// Кнопки действий
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
!isSystemAccount
|
||||
) {
|
||||
IconButton(
|
||||
@@ -1209,10 +1233,32 @@ fun ChatDetailScreen(
|
||||
isDarkTheme,
|
||||
isSavedMessages =
|
||||
isSavedMessages,
|
||||
isGroupChat =
|
||||
isGroupChat,
|
||||
isSystemAccount =
|
||||
isSystemAccount,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
onGroupInfoClick = {
|
||||
showMenu =
|
||||
false
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
},
|
||||
onSearchMembersClick = {
|
||||
showMenu =
|
||||
false
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
},
|
||||
onLeaveGroupClick = {
|
||||
showMenu =
|
||||
false
|
||||
showDeleteConfirm =
|
||||
true
|
||||
},
|
||||
onBlockClick = {
|
||||
showMenu =
|
||||
false
|
||||
@@ -2016,6 +2062,14 @@ fun ChatDetailScreen(
|
||||
}
|
||||
val selectionKey =
|
||||
message.id
|
||||
val senderPublicKeyForMessage =
|
||||
if (message.senderPublicKey.isNotBlank()) {
|
||||
message.senderPublicKey
|
||||
} else if (message.isOutgoing) {
|
||||
currentUserPublicKey
|
||||
} else {
|
||||
user.publicKey
|
||||
}
|
||||
MessageBubble(
|
||||
message =
|
||||
message,
|
||||
@@ -2042,11 +2096,20 @@ fun ChatDetailScreen(
|
||||
privateKey =
|
||||
currentUserPrivateKey,
|
||||
senderPublicKey =
|
||||
if (message.isOutgoing
|
||||
)
|
||||
currentUserPublicKey
|
||||
else
|
||||
user.publicKey,
|
||||
senderPublicKeyForMessage,
|
||||
senderName =
|
||||
message.senderName,
|
||||
showGroupSenderLabel =
|
||||
isGroupChat &&
|
||||
!message.isOutgoing,
|
||||
isGroupSenderAdmin =
|
||||
isGroupChat &&
|
||||
senderPublicKeyForMessage
|
||||
.isNotBlank() &&
|
||||
groupAdminKeys
|
||||
.contains(
|
||||
senderPublicKeyForMessage
|
||||
),
|
||||
currentUserPublicKey =
|
||||
currentUserPublicKey,
|
||||
avatarRepository =
|
||||
@@ -2195,6 +2258,9 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
onGroupInviteOpen = { inviteGroup ->
|
||||
onNavigateToChat(inviteGroup)
|
||||
},
|
||||
contextMenuContent = {
|
||||
// 💬 Context menu anchored to this bubble
|
||||
if (showContextMenu && contextMenuMessage?.id == message.id) {
|
||||
@@ -2444,15 +2510,24 @@ fun ChatDetailScreen(
|
||||
|
||||
// Диалог подтверждения удаления чата
|
||||
if (showDeleteConfirm) {
|
||||
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor)
|
||||
Text(
|
||||
if (isLeaveGroupDialog) "Leave Group" else "Delete Chat",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to delete this chat? This action cannot be undone.",
|
||||
if (isLeaveGroupDialog) {
|
||||
"Are you sure you want to leave this group?"
|
||||
} else {
|
||||
"Are you sure you want to delete this chat? This action cannot be undone."
|
||||
},
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
@@ -2462,60 +2537,79 @@ fun ChatDetailScreen(
|
||||
showDeleteConfirm = false
|
||||
scope.launch {
|
||||
try {
|
||||
// Вычисляем правильный dialog_key
|
||||
val dialogKey =
|
||||
if (currentUserPublicKey <
|
||||
if (isLeaveGroupDialog) {
|
||||
GroupRepository
|
||||
.getInstance(
|
||||
context
|
||||
)
|
||||
.leaveGroup(
|
||||
currentUserPublicKey,
|
||||
user.publicKey
|
||||
) {
|
||||
"$currentUserPublicKey:${user.publicKey}"
|
||||
} else {
|
||||
"${user.publicKey}:$currentUserPublicKey"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Вычисляем правильный dialog_key
|
||||
val dialogKey =
|
||||
if (currentUserPublicKey <
|
||||
user.publicKey
|
||||
) {
|
||||
"$currentUserPublicKey:${user.publicKey}"
|
||||
} else {
|
||||
"${user.publicKey}:$currentUserPublicKey"
|
||||
}
|
||||
|
||||
// 🗑️ Очищаем ВСЕ кэши сообщений
|
||||
com.rosetta.messenger.data
|
||||
.MessageRepository
|
||||
.getInstance(context)
|
||||
.clearDialogCache(
|
||||
user.publicKey
|
||||
)
|
||||
ChatViewModel.clearCacheForOpponent(
|
||||
user.publicKey
|
||||
)
|
||||
|
||||
// Удаляем все сообщения из диалога
|
||||
database.messageDao()
|
||||
.deleteDialog(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
dialogKey =
|
||||
dialogKey
|
||||
)
|
||||
database.messageDao()
|
||||
.deleteMessagesBetweenUsers(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
user1 =
|
||||
user.publicKey,
|
||||
user2 =
|
||||
currentUserPublicKey
|
||||
)
|
||||
|
||||
// Очищаем кеш диалога
|
||||
database.dialogDao()
|
||||
.deleteDialog(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
opponentKey =
|
||||
// 🗑️ Очищаем ВСЕ кэши сообщений
|
||||
com.rosetta.messenger.data
|
||||
.MessageRepository
|
||||
.getInstance(
|
||||
context
|
||||
)
|
||||
.clearDialogCache(
|
||||
user.publicKey
|
||||
)
|
||||
)
|
||||
ChatViewModel
|
||||
.clearCacheForOpponent(
|
||||
user.publicKey
|
||||
)
|
||||
|
||||
// Удаляем все сообщения из диалога
|
||||
database.messageDao()
|
||||
.deleteDialog(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
dialogKey =
|
||||
dialogKey
|
||||
)
|
||||
database.messageDao()
|
||||
.deleteMessagesBetweenUsers(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
user1 =
|
||||
user.publicKey,
|
||||
user2 =
|
||||
currentUserPublicKey
|
||||
)
|
||||
|
||||
// Очищаем кеш диалога
|
||||
database.dialogDao()
|
||||
.deleteDialog(
|
||||
account =
|
||||
currentUserPublicKey,
|
||||
opponentKey =
|
||||
user.publicKey
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Error deleting chat
|
||||
}
|
||||
hideKeyboardAndBack()
|
||||
}
|
||||
}
|
||||
) { Text("Delete", color = Color(0xFFFF3B30)) }
|
||||
) {
|
||||
Text(
|
||||
if (isLeaveGroupDialog) "Leave" else "Delete",
|
||||
color = Color(0xFFFF3B30)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
|
||||
@@ -97,6 +97,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val messageDao = database.messageDao()
|
||||
private val groupDao = database.groupDao()
|
||||
private val pinnedMessageDao = database.pinnedMessageDao()
|
||||
|
||||
// MessageRepository для подписки на события новых сообщений
|
||||
@@ -105,6 +106,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>()
|
||||
private val groupKeyCache = ConcurrentHashMap<String, String>()
|
||||
private val groupSenderNameCache = ConcurrentHashMap<String, String>()
|
||||
private val groupSenderResolveRequested = ConcurrentHashMap.newKeySet<String>()
|
||||
@Volatile private var isCleared = false
|
||||
|
||||
// Информация о собеседнике
|
||||
@@ -196,6 +200,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 📌 Pinned messages state
|
||||
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
|
||||
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
|
||||
private val _pinnedMessagePreviews = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val pinnedMessagePreviews: StateFlow<Map<String, String>> = _pinnedMessagePreviews.asStateFlow()
|
||||
|
||||
private val _currentPinnedIndex = MutableStateFlow(0)
|
||||
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
|
||||
@@ -529,6 +535,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// может получить стейтный кэш от предыдущего аккаунта
|
||||
dialogMessagesCache.clear()
|
||||
decryptionCache.clear()
|
||||
groupKeyCache.clear()
|
||||
groupSenderNameCache.clear()
|
||||
groupSenderResolveRequested.clear()
|
||||
}
|
||||
myPublicKey = publicKey
|
||||
myPrivateKey = privateKey
|
||||
@@ -549,6 +558,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
opponentTitle = title
|
||||
opponentUsername = username
|
||||
opponentVerified = verified.coerceAtLeast(0)
|
||||
if (!isGroupDialogKey(publicKey)) {
|
||||
groupKeyCache.remove(publicKey)
|
||||
}
|
||||
groupSenderNameCache.clear()
|
||||
groupSenderResolveRequested.clear()
|
||||
|
||||
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
|
||||
// Это важно для правильного отображения forward сообщений сразу
|
||||
@@ -583,6 +597,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
lastReadMessageTimestamp = 0L
|
||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
|
||||
isDialogActive = true // 🔥 Диалог активен!
|
||||
_pinnedMessagePreviews.value = emptyMap()
|
||||
|
||||
// Desktop parity: refresh opponent name/username from server on dialog open,
|
||||
// so renamed contacts get their new name displayed immediately.
|
||||
@@ -626,6 +641,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_pinnedMessages.value = pins
|
||||
// Всегда показываем самый последний пин (index 0, ORDER BY DESC)
|
||||
_currentPinnedIndex.value = 0
|
||||
refreshPinnedMessagePreviews(acc, pins)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,6 +1013,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
|
||||
val cachedText = decryptionCache[entity.messageId]
|
||||
|
||||
val privateKey = myPrivateKey
|
||||
val groupDialogKey =
|
||||
when {
|
||||
isGroupDialogKey(entity.dialogKey) -> entity.dialogKey.trim()
|
||||
isGroupDialogKey(entity.toPublicKey) -> entity.toPublicKey.trim()
|
||||
else -> opponentKey?.trim()?.takeIf { isGroupDialogKey(it) }
|
||||
}
|
||||
val isGroupMessage = groupDialogKey != null || entity.chachaKey.startsWith("group:")
|
||||
val groupKey =
|
||||
if (isGroupMessage && privateKey != null) {
|
||||
decodeStoredGroupKey(entity.chachaKey, privateKey)
|
||||
?: groupDialogKey?.let { resolveGroupKeyForDialog(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Расшифровываем сообщение из content + chachaKey
|
||||
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
|
||||
var displayText =
|
||||
@@ -1004,10 +1036,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
cachedText
|
||||
} else
|
||||
try {
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null &&
|
||||
entity.content.isNotEmpty() &&
|
||||
entity.chachaKey.isNotEmpty()
|
||||
if (isGroupMessage && groupKey != null && entity.content.isNotEmpty()) {
|
||||
val decrypted = CryptoManager.decryptWithPassword(entity.content, groupKey)
|
||||
if (decrypted != null) {
|
||||
decryptionCache[entity.messageId] = decrypted
|
||||
decrypted
|
||||
} else {
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
} else if (
|
||||
privateKey != null &&
|
||||
entity.content.isNotEmpty() &&
|
||||
entity.chachaKey.isNotEmpty() &&
|
||||
!entity.chachaKey.startsWith("group:")
|
||||
) {
|
||||
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
||||
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
|
||||
@@ -1047,7 +1088,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Пробуем расшифровать plainMessage
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
val decrypted = CryptoManager.decryptWithPassword(
|
||||
@@ -1076,7 +1116,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isFromMe = entity.fromMe == 1,
|
||||
content = entity.content,
|
||||
chachaKey = entity.chachaKey,
|
||||
plainKeyAndNonce = plainKeyAndNonce
|
||||
plainKeyAndNonce = plainKeyAndNonce,
|
||||
groupPassword = groupKey
|
||||
)
|
||||
var replyData = parsedReplyResult?.replyData
|
||||
val forwardedMessages = parsedReplyResult?.forwardedMessages ?: emptyList()
|
||||
@@ -1093,6 +1134,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Парсим все attachments (IMAGE, FILE, AVATAR)
|
||||
val parsedAttachments = parseAllAttachments(entity.attachments)
|
||||
|
||||
val myKey = myPublicKey.orEmpty().trim()
|
||||
val senderKey = entity.fromPublicKey.trim()
|
||||
val senderName =
|
||||
when {
|
||||
senderKey.isBlank() -> ""
|
||||
senderKey == myKey -> "You"
|
||||
isGroupMessage -> resolveGroupSenderName(senderKey)
|
||||
else -> opponentTitle.ifBlank { opponentUsername.ifBlank { shortPublicKey(senderKey) } }
|
||||
}
|
||||
|
||||
if (isGroupMessage && senderKey.isNotBlank() && senderKey != myKey) {
|
||||
requestGroupSenderNameIfNeeded(senderKey)
|
||||
}
|
||||
|
||||
return ChatMessage(
|
||||
id = entity.messageId,
|
||||
text = displayText,
|
||||
@@ -1109,10 +1164,102 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
||||
forwardedMessages = forwardedMessages,
|
||||
attachments = parsedAttachments,
|
||||
chachaKey = entity.chachaKey
|
||||
chachaKey = entity.chachaKey,
|
||||
senderPublicKey = senderKey,
|
||||
senderName = senderName
|
||||
)
|
||||
}
|
||||
|
||||
private fun shortPublicKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.length <= 12) return trimmed
|
||||
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||
}
|
||||
|
||||
private suspend fun resolveGroupSenderName(publicKey: String): String {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return ""
|
||||
|
||||
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
|
||||
if (isUsableSenderName(cached, normalizedPublicKey)) return cached
|
||||
}
|
||||
|
||||
val account = myPublicKey
|
||||
if (!account.isNullOrBlank()) {
|
||||
try {
|
||||
val dialog = dialogDao.getDialog(account, normalizedPublicKey)
|
||||
val localName = dialog?.opponentTitle?.trim().orEmpty()
|
||||
.ifBlank { dialog?.opponentUsername?.trim().orEmpty() }
|
||||
if (isUsableSenderName(localName, normalizedPublicKey)) {
|
||||
groupSenderNameCache[normalizedPublicKey] = localName
|
||||
return localName
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
val cached = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
|
||||
val protocolName = cached?.title?.trim().orEmpty()
|
||||
.ifBlank { cached?.username?.trim().orEmpty() }
|
||||
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
|
||||
groupSenderNameCache[normalizedPublicKey] = protocolName
|
||||
return protocolName
|
||||
}
|
||||
|
||||
return shortPublicKey(normalizedPublicKey)
|
||||
}
|
||||
|
||||
private fun isUsableSenderName(name: String, publicKey: String): Boolean {
|
||||
if (name.isBlank()) return false
|
||||
val normalized = name.trim()
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isNotBlank()) {
|
||||
if (normalized.equals(normalizedPublicKey, ignoreCase = true)) return false
|
||||
if (normalized.equals(normalizedPublicKey.take(7), ignoreCase = true)) return false
|
||||
if (normalized.equals(normalizedPublicKey.take(8), ignoreCase = true)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun requestGroupSenderNameIfNeeded(publicKey: String) {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return
|
||||
if (normalizedPublicKey == myPublicKey?.trim()) return
|
||||
groupSenderNameCache[normalizedPublicKey]?.let { cached ->
|
||||
if (isUsableSenderName(cached, normalizedPublicKey)) return
|
||||
}
|
||||
if (!groupSenderResolveRequested.add(normalizedPublicKey)) return
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
|
||||
val name = resolved?.title?.trim().orEmpty()
|
||||
.ifBlank { resolved?.username?.trim().orEmpty() }
|
||||
if (!isUsableSenderName(name, normalizedPublicKey)) {
|
||||
groupSenderResolveRequested.remove(normalizedPublicKey)
|
||||
return@launch
|
||||
}
|
||||
groupSenderNameCache[normalizedPublicKey] = name
|
||||
withContext(Dispatchers.Main) {
|
||||
_messages.update { current ->
|
||||
current.map { message ->
|
||||
if (message.senderPublicKey.trim() == normalizedPublicKey &&
|
||||
message.senderName != name
|
||||
) {
|
||||
message.copy(senderName = name)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
groupSenderResolveRequested.remove(normalizedPublicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
|
||||
* Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
|
||||
@@ -1245,7 +1392,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
content: String,
|
||||
chachaKey: String,
|
||||
plainKeyAndNonce: ByteArray? =
|
||||
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
|
||||
null, // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
|
||||
groupPassword: String? = null
|
||||
): ParsedReplyResult? {
|
||||
|
||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
||||
@@ -1281,6 +1429,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val privateKey = myPrivateKey
|
||||
var decryptionSuccess = false
|
||||
|
||||
// 🔥 Способ 0: Группа — blob шифруется ключом группы
|
||||
if (groupPassword != null) {
|
||||
try {
|
||||
val decrypted = CryptoManager.decryptWithPassword(dataJson, groupPassword)
|
||||
if (decrypted != null) {
|
||||
dataJson = decrypted
|
||||
decryptionSuccess = true
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// 🔥 Способ 1: Пробуем расшифровать с приватным ключом (для исходящих
|
||||
// сообщений)
|
||||
if (privateKey != null) {
|
||||
@@ -1618,6 +1777,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* (account == opponent) возвращает просто account
|
||||
*/
|
||||
private fun getDialogKey(account: String, opponent: String): String {
|
||||
if (isGroupDialogKey(opponent)) {
|
||||
return opponent.trim()
|
||||
}
|
||||
// Для saved messages dialog_key = просто publicKey
|
||||
if (account == opponent) {
|
||||
return account
|
||||
@@ -1630,6 +1792,96 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupId(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
return when {
|
||||
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
|
||||
trimmed.startsWith("group:", ignoreCase = true) -> trimmed.substringAfter(':').trim()
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||
}
|
||||
|
||||
private fun decodeStoredGroupKey(stored: String, privateKey: String): String? {
|
||||
if (!stored.startsWith("group:")) return null
|
||||
val encoded = stored.removePrefix("group:")
|
||||
if (encoded.isBlank()) return null
|
||||
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||
}
|
||||
|
||||
private suspend fun resolveGroupKeyForDialog(dialogPublicKey: String): String? {
|
||||
val account = myPublicKey ?: return null
|
||||
val privateKey = myPrivateKey ?: return null
|
||||
val normalizedDialog = dialogPublicKey.trim()
|
||||
if (!isGroupDialogKey(normalizedDialog)) return null
|
||||
|
||||
groupKeyCache[normalizedDialog]?.let { return it }
|
||||
|
||||
val groupId = normalizeGroupId(normalizedDialog)
|
||||
if (groupId.isBlank()) return null
|
||||
|
||||
val group = groupDao.getGroup(account, groupId) ?: return null
|
||||
val decrypted = CryptoManager.decryptWithPassword(group.key, privateKey) ?: return null
|
||||
groupKeyCache[normalizedDialog] = decrypted
|
||||
return decrypted
|
||||
}
|
||||
|
||||
private data class OutgoingEncryptionContext(
|
||||
val encryptedContent: String,
|
||||
val encryptedKey: String,
|
||||
val aesChachaKey: String,
|
||||
val plainKeyAndNonce: ByteArray?,
|
||||
val attachmentPassword: String,
|
||||
val isGroup: Boolean
|
||||
)
|
||||
|
||||
private suspend fun buildEncryptionContext(
|
||||
plaintext: String,
|
||||
recipient: String,
|
||||
privateKey: String
|
||||
): OutgoingEncryptionContext? {
|
||||
return if (isGroupDialogKey(recipient)) {
|
||||
val groupKey = resolveGroupKeyForDialog(recipient) ?: return null
|
||||
OutgoingEncryptionContext(
|
||||
encryptedContent = CryptoManager.encryptWithPassword(plaintext, groupKey),
|
||||
encryptedKey = "",
|
||||
aesChachaKey = CryptoManager.encryptWithPassword(groupKey, privateKey),
|
||||
plainKeyAndNonce = null,
|
||||
attachmentPassword = groupKey,
|
||||
isGroup = true
|
||||
)
|
||||
} else {
|
||||
val encryptResult = MessageCrypto.encryptForSending(plaintext, recipient)
|
||||
OutgoingEncryptionContext(
|
||||
encryptedContent = encryptResult.ciphertext,
|
||||
encryptedKey = encryptResult.encryptedKey,
|
||||
aesChachaKey = encryptAesChachaKey(encryptResult.plainKeyAndNonce, privateKey),
|
||||
plainKeyAndNonce = encryptResult.plainKeyAndNonce,
|
||||
attachmentPassword = String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
isGroup = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
|
||||
return if (context.isGroup) {
|
||||
CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
|
||||
} else {
|
||||
val plainKeyAndNonce =
|
||||
context.plainKeyAndNonce
|
||||
?: throw IllegalStateException("Missing key+nonce for direct message")
|
||||
MessageCrypto.encryptReplyBlob(payload, plainKeyAndNonce)
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновить текст ввода */
|
||||
fun updateInputText(text: String) {
|
||||
_inputText.value = text
|
||||
@@ -1695,6 +1947,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 📌 PINNED MESSAGES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private fun buildPinnedPreview(message: ChatMessage): String {
|
||||
if (message.text.isNotBlank()) return message.text
|
||||
return when {
|
||||
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
||||
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
||||
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||
message.replyData != null -> "Reply"
|
||||
else -> "Pinned message"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolvePinnedPreviewText(account: String, messageId: String): String {
|
||||
_messages.value.firstOrNull { it.id == messageId }?.let { visibleMessage ->
|
||||
return buildPinnedPreview(visibleMessage)
|
||||
}
|
||||
|
||||
decryptionCache[messageId]?.takeIf { it.isNotBlank() }?.let { cached ->
|
||||
return cached
|
||||
}
|
||||
|
||||
val entity = messageDao.getMessageById(account, messageId) ?: return "Pinned message"
|
||||
return buildPinnedPreview(entityToChatMessage(entity))
|
||||
}
|
||||
|
||||
private suspend fun refreshPinnedMessagePreviews(
|
||||
account: String,
|
||||
pins: List<com.rosetta.messenger.database.PinnedMessageEntity>
|
||||
) {
|
||||
if (pins.isEmpty()) {
|
||||
_pinnedMessagePreviews.value = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
val activeIds = pins.map { it.messageId }.toSet()
|
||||
val nextPreviews = _pinnedMessagePreviews.value.filterKeys { it in activeIds }.toMutableMap()
|
||||
|
||||
for (pin in pins) {
|
||||
val existingPreview = nextPreviews[pin.messageId]
|
||||
if (existingPreview != null && existingPreview != "Pinned message") continue
|
||||
val preview = resolvePinnedPreviewText(account, pin.messageId)
|
||||
nextPreviews[pin.messageId] = preview
|
||||
}
|
||||
|
||||
_pinnedMessagePreviews.value = nextPreviews
|
||||
}
|
||||
|
||||
suspend fun ensureMessageLoaded(messageId: String): Boolean {
|
||||
if (_messages.value.any { it.id == messageId }) return true
|
||||
|
||||
val account = myPublicKey ?: return false
|
||||
val opponent = opponentKey ?: return false
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val entity = messageDao.getMessageById(account, messageId) ?: return@withContext false
|
||||
if (entity.dialogKey != dialogKey) return@withContext false
|
||||
|
||||
val hydratedMessage = entityToChatMessage(entity)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (_messages.value.none { it.id == messageId }) {
|
||||
_messages.value =
|
||||
sortMessagesAscending((_messages.value + hydratedMessage).distinctBy { it.id })
|
||||
}
|
||||
}
|
||||
updateCacheFromCurrentMessages()
|
||||
_pinnedMessagePreviews.update { current ->
|
||||
current + (messageId to buildPinnedPreview(hydratedMessage))
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** 📌 Закрепить сообщение */
|
||||
fun pinMessage(messageId: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -1729,14 +2054,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return pinnedMessageDao.isPinned(account, dialogKey, messageId)
|
||||
}
|
||||
|
||||
/** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */
|
||||
/**
|
||||
* 📌 Клик по pinned banner:
|
||||
* 1) вернуть ТЕКУЩЕЕ отображаемое сообщение для скролла
|
||||
* 2) после этого переключить индекс на следующее (циклически)
|
||||
*
|
||||
* Так скролл всегда попадает ровно в тот pinned, который видит пользователь в баннере.
|
||||
*/
|
||||
fun navigateToNextPinned(): String? {
|
||||
val pins = _pinnedMessages.value
|
||||
if (pins.isEmpty()) return null
|
||||
val currentIdx = _currentPinnedIndex.value
|
||||
val currentIdx = _currentPinnedIndex.value.coerceIn(0, pins.size - 1)
|
||||
val currentMessageId = pins[currentIdx].messageId
|
||||
val nextIdx = (currentIdx + 1) % pins.size
|
||||
_currentPinnedIndex.value = nextIdx
|
||||
return pins[nextIdx].messageId
|
||||
return currentMessageId
|
||||
}
|
||||
|
||||
/** 📌 Открепить все сообщения */
|
||||
@@ -1769,6 +2101,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
|
||||
if (publicKey.isEmpty()) return null
|
||||
if (isGroupDialogKey(publicKey)) return null
|
||||
|
||||
// If it's the current opponent, we already have info
|
||||
if (publicKey == opponentKey) {
|
||||
@@ -1961,12 +2294,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// 2. 🔥 Шифрование и отправка в IO потоке
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Шифрование текста - теперь возвращает EncryptedForSending с plainKeyAndNonce
|
||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce // Для шифрования attachments
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
@@ -1993,7 +2329,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (imageBlob != null) {
|
||||
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
|
||||
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
|
||||
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
|
||||
|
||||
var uploadTag = ""
|
||||
@@ -2067,8 +2403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
|
||||
val encryptedReplyBlob =
|
||||
MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
|
||||
|
||||
replyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
@@ -2153,7 +2488,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered =
|
||||
@@ -2193,11 +2536,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val isSavedMessages = (sender == recipientPublicKey)
|
||||
|
||||
// Шифрование (пустой текст для forward)
|
||||
val encryptResult = MessageCrypto.encryptForSending("", recipientPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = recipientPublicKey,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
@@ -2218,7 +2565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (imageBlob != null) {
|
||||
val encryptedBlob = MessageCrypto.encryptReplyBlob(imageBlob, plainKeyAndNonce)
|
||||
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
|
||||
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
|
||||
|
||||
var uploadTag = ""
|
||||
@@ -2277,7 +2624,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
|
||||
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
@@ -2325,7 +2672,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
@@ -2565,11 +2920,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Шифрование текста
|
||||
val encryptStartedAt = System.currentTimeMillis()
|
||||
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = caption,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
|
||||
@@ -2579,7 +2938,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
||||
val blobEncryptStartedAt = System.currentTimeMillis()
|
||||
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
||||
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
|
||||
@@ -2686,11 +3045,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
logPhotoPipeline(messageId, "ui status switched to SENT")
|
||||
|
||||
saveDialog(
|
||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||
timestamp = timestamp,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
saveDialog(
|
||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||
timestamp = timestamp,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
|
||||
@@ -2760,17 +3119,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
backgroundUploadScope.launch {
|
||||
try {
|
||||
// Шифрование текста
|
||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
||||
val encryptedImageBlob =
|
||||
MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
||||
val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext)
|
||||
|
||||
val attachmentId = "img_$timestamp"
|
||||
|
||||
@@ -2846,7 +3208,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
|
||||
@@ -3022,11 +3392,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try {
|
||||
val groupStartedAt = System.currentTimeMillis()
|
||||
// Шифрование текста
|
||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
logPhotoPipeline(
|
||||
messageId,
|
||||
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
|
||||
@@ -3047,7 +3421,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Шифруем изображение с ChaCha ключом
|
||||
val encryptedImageBlob =
|
||||
MessageCrypto.encryptReplyBlob(imageData.base64, plainKeyAndNonce)
|
||||
encryptAttachmentPayload(imageData.base64, encryptionContext)
|
||||
|
||||
// Загружаем на Transport Server
|
||||
val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||
@@ -3122,7 +3496,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
@@ -3227,16 +3609,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = recipient,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
// 🚀 Шифруем файл с ChaCha ключом для Transport Server
|
||||
val encryptedFileBlob = MessageCrypto.encryptReplyBlob(fileBase64, plainKeyAndNonce)
|
||||
val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext)
|
||||
|
||||
val attachmentId = "file_$timestamp"
|
||||
|
||||
@@ -3299,7 +3685,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered =
|
||||
@@ -3430,19 +3824,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) }
|
||||
|
||||
// 2. Шифрование текста (пустой текст для аватарки)
|
||||
val encryptResult = MessageCrypto.encryptForSending("", recipient)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, userPrivateKey)
|
||||
val encryptionContext =
|
||||
buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = recipient,
|
||||
privateKey = userPrivateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey)
|
||||
|
||||
// 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce)
|
||||
// НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения
|
||||
// Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob!
|
||||
val encryptedAvatarBlob =
|
||||
MessageCrypto.encryptReplyBlob(avatarDataUrl, plainKeyAndNonce)
|
||||
val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext)
|
||||
|
||||
val avatarAttachmentId = "avatar_$timestamp"
|
||||
|
||||
@@ -3518,7 +3915,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId = messageId,
|
||||
text = "", // Аватар без текста
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
buildStoredGroupKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
userPrivateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
|
||||
@@ -3568,6 +3973,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
dialogDao.updateDialogFromMessages(account, opponent)
|
||||
}
|
||||
|
||||
if (isGroupDialogKey(opponent)) {
|
||||
val groupId = normalizeGroupId(opponent)
|
||||
val group = groupDao.getGroup(account, groupId)
|
||||
if (group != null) {
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
account,
|
||||
opponent,
|
||||
group.title,
|
||||
group.description
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 FIX: updateDialogFromMessages создаёт новый диалог с пустым title/username
|
||||
// когда диалога ещё не было. Обновляем метаданные из уже известных данных.
|
||||
if (opponent != account && opponentTitle.isNotEmpty()) {
|
||||
@@ -3602,6 +4021,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||
}
|
||||
|
||||
if (isGroupDialogKey(opponentKey)) {
|
||||
val groupId = normalizeGroupId(opponentKey)
|
||||
val group = groupDao.getGroup(account, groupId)
|
||||
if (group != null) {
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
account,
|
||||
opponentKey,
|
||||
group.title,
|
||||
group.description
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 FIX: Сохраняем title/username после пересчёта счётчиков
|
||||
if (opponentKey != account && opponentTitle.isNotEmpty()) {
|
||||
dialogDao.updateOpponentDisplayName(
|
||||
@@ -3711,7 +4144,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// 📁 Для Saved Messages - не отправляем typing indicator
|
||||
if (opponent == sender) {
|
||||
if (opponent == sender || isGroupDialogKey(opponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3754,7 +4187,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val sender = myPublicKey ?: return
|
||||
|
||||
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
|
||||
if (opponent == sender) {
|
||||
if (opponent == sender || isGroupDialogKey(opponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3855,7 +4288,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val account = myPublicKey ?: return
|
||||
|
||||
// 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
|
||||
if (account == opponent) {
|
||||
if (account == opponent || isGroupDialogKey(opponent)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3887,7 +4320,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Удаляем все сообщения из БД
|
||||
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
|
||||
if (isGroupDialogKey(opponent)) {
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
messageDao.deleteDialog(account, dialogKey)
|
||||
} else {
|
||||
messageDao.deleteMessagesBetweenUsers(account, account, opponent)
|
||||
}
|
||||
|
||||
// Очищаем кэш
|
||||
val dialogKey = getDialogKey(account, opponent)
|
||||
@@ -3926,6 +4364,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
isCleared = true
|
||||
pinnedCollectionJob?.cancel()
|
||||
|
||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -186,6 +187,11 @@ fun getAvatarText(publicKey: String): String {
|
||||
return publicKey.take(2).uppercase()
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -505,6 +511,7 @@ fun ChatsListScreen(
|
||||
|
||||
// Confirmation dialogs state
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
|
||||
@@ -1115,6 +1122,22 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// 👥 New Group
|
||||
DrawerMenuItemEnhanced(
|
||||
icon = TablerIcons.Users,
|
||||
text = "New Group",
|
||||
iconColor = menuIconColor,
|
||||
textColor = menuTextColor,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
kotlinx.coroutines
|
||||
.delay(100)
|
||||
onNewGroupClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 📖 Saved Messages
|
||||
DrawerMenuItemEnhanced(
|
||||
painter = painterResource(id = R.drawable.msg_saved),
|
||||
@@ -2011,6 +2034,10 @@ fun ChatsListScreen(
|
||||
.contains(
|
||||
dialog.opponentKey
|
||||
)
|
||||
val isGroupDialog =
|
||||
isGroupDialogKey(
|
||||
dialog.opponentKey
|
||||
)
|
||||
val isTyping by
|
||||
remember(
|
||||
dialog.opponentKey
|
||||
@@ -2128,6 +2155,10 @@ fun ChatsListScreen(
|
||||
dialogsToDelete =
|
||||
listOf(dialog)
|
||||
},
|
||||
onLeave = {
|
||||
dialogToLeave =
|
||||
dialog
|
||||
},
|
||||
onBlock = {
|
||||
dialogToBlock =
|
||||
dialog
|
||||
@@ -2136,6 +2167,8 @@ fun ChatsListScreen(
|
||||
dialogToUnblock =
|
||||
dialog
|
||||
},
|
||||
isGroupChat =
|
||||
isGroupDialog,
|
||||
isPinned =
|
||||
isPinnedDialog,
|
||||
swipeEnabled =
|
||||
@@ -2248,6 +2281,54 @@ fun ChatsListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Leave Group Confirmation
|
||||
dialogToLeave?.let { dialog ->
|
||||
val groupTitle = dialog.opponentTitle.ifEmpty { "this group" }
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogToLeave = null },
|
||||
containerColor =
|
||||
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text(
|
||||
"Leave Group",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to leave \"$groupTitle\"?",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val groupKey = dialog.opponentKey
|
||||
dialogToLeave = null
|
||||
scope.launch {
|
||||
val left = chatsViewModel.leaveGroup(groupKey)
|
||||
if (!left) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"Failed to leave group",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
) { Text("Leave", color = Color(0xFFFF3B30)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogToLeave = null }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Block Dialog Confirmation
|
||||
dialogToBlock?.let { dialog ->
|
||||
AlertDialog(
|
||||
@@ -3022,7 +3103,7 @@ fun DrawerMenuItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete Свайп влево показывает действия
|
||||
* 🔥 Swipeable wrapper для DialogItem (Pin + Leave/Block + Delete). Свайп влево показывает действия
|
||||
* (как в React Native версии)
|
||||
*/
|
||||
@Composable
|
||||
@@ -3031,6 +3112,7 @@ fun SwipeableDialogItem(
|
||||
isDarkTheme: Boolean,
|
||||
isTyping: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isGroupChat: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
swipeEnabled: Boolean = true,
|
||||
isMuted: Boolean = false,
|
||||
@@ -3043,6 +3125,7 @@ fun SwipeableDialogItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onLeave: () -> Unit = {},
|
||||
onBlock: () -> Unit = {},
|
||||
onUnblock: () -> Unit = {},
|
||||
isPinned: Boolean = false,
|
||||
@@ -3068,7 +3151,7 @@ fun SwipeableDialogItem(
|
||||
label = "pinnedBackground"
|
||||
)
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
|
||||
// 📌 3 кнопки: Pin + Leave/Block + Delete (для SavedMessages: Pin + Delete)
|
||||
val buttonCount =
|
||||
if (!swipeEnabled) 0
|
||||
else if (isSavedMessages) 2
|
||||
@@ -3147,18 +3230,28 @@ fun SwipeableDialogItem(
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка Block/Unblock (только если не Saved Messages)
|
||||
// Кнопка Leave (для группы) или Block/Unblock (для личных чатов)
|
||||
if (!isSavedMessages) {
|
||||
val middleActionColor =
|
||||
if (isGroupChat) Color(0xFFFF6B6B)
|
||||
else if (isBlocked) Color(0xFF4CAF50)
|
||||
else Color(0xFFFF6B6B)
|
||||
val middleActionIcon =
|
||||
if (isGroupChat) TelegramIcons.Leave
|
||||
else if (isBlocked) TelegramIcons.Unlock
|
||||
else TelegramIcons.Block
|
||||
val middleActionTitle =
|
||||
if (isGroupChat) "Leave"
|
||||
else if (isBlocked) "Unblock"
|
||||
else "Block"
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(80.dp)
|
||||
.fillMaxHeight()
|
||||
.background(
|
||||
if (isBlocked) Color(0xFF4CAF50)
|
||||
else Color(0xFFFF6B6B)
|
||||
)
|
||||
.background(middleActionColor)
|
||||
.clickable {
|
||||
if (isBlocked) onUnblock()
|
||||
if (isGroupChat) onLeave()
|
||||
else if (isBlocked) onUnblock()
|
||||
else onBlock()
|
||||
offsetX = 0f
|
||||
onSwipeClosed()
|
||||
@@ -3170,20 +3263,14 @@ fun SwipeableDialogItem(
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter =
|
||||
if (isBlocked) TelegramIcons.Unlock
|
||||
else TelegramIcons.Block,
|
||||
contentDescription =
|
||||
if (isBlocked) "Unblock"
|
||||
else "Block",
|
||||
painter = middleActionIcon,
|
||||
contentDescription = middleActionTitle,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text =
|
||||
if (isBlocked) "Unblock"
|
||||
else "Block",
|
||||
text = middleActionTitle,
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -3461,6 +3548,7 @@ fun DialogItemContent(
|
||||
remember(dialog.opponentKey, isDarkTheme) {
|
||||
getAvatarColor(dialog.opponentKey, isDarkTheme)
|
||||
}
|
||||
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
||||
|
||||
// 📁 Для Saved Messages показываем специальное имя
|
||||
// 🔥 Как в Архиве: title > username > "DELETED"
|
||||
@@ -3628,6 +3716,15 @@ fun DialogItemContent(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (isGroupDialog) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Icon(
|
||||
imageVector = TablerIcons.Users,
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
|
||||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
|
||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
@@ -3871,7 +3968,7 @@ fun DialogItemContent(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val displayText =
|
||||
val baseDisplayText =
|
||||
when {
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Photo" -> "Photo"
|
||||
@@ -3885,23 +3982,122 @@ fun DialogItemContent(
|
||||
"No messages"
|
||||
else -> dialog.lastMessage
|
||||
}
|
||||
val senderPrefix =
|
||||
dialog.lastMessageSenderPrefix
|
||||
.orEmpty()
|
||||
.trim()
|
||||
val showSenderPrefix =
|
||||
isGroupDialog &&
|
||||
senderPrefix.isNotEmpty() &&
|
||||
baseDisplayText != "No messages"
|
||||
val senderPrefixColor =
|
||||
remember(
|
||||
showSenderPrefix,
|
||||
senderPrefix,
|
||||
dialog.lastMessageSenderKey,
|
||||
isDarkTheme
|
||||
) {
|
||||
when {
|
||||
!showSenderPrefix ->
|
||||
secondaryTextColor
|
||||
senderPrefix.equals(
|
||||
"You",
|
||||
ignoreCase = true
|
||||
) -> PrimaryBlue
|
||||
else -> {
|
||||
val colorSeed =
|
||||
dialog.lastMessageSenderKey
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
val resolvedSeed =
|
||||
if (colorSeed.isNotEmpty()) {
|
||||
colorSeed
|
||||
} else {
|
||||
senderPrefix
|
||||
}
|
||||
getAvatarColor(
|
||||
resolvedSeed,
|
||||
isDarkTheme
|
||||
)
|
||||
.textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppleEmojiText(
|
||||
text = displayText,
|
||||
fontSize = 14.sp,
|
||||
color =
|
||||
if (dialog.unreadCount > 0)
|
||||
textColor.copy(alpha = 0.85f)
|
||||
else secondaryTextColor,
|
||||
fontWeight =
|
||||
if (dialog.unreadCount > 0)
|
||||
FontWeight.Medium
|
||||
else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enableLinks = false
|
||||
)
|
||||
if (showSenderPrefix) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment =
|
||||
Alignment.CenterVertically
|
||||
) {
|
||||
AppleEmojiText(
|
||||
text = "$senderPrefix:",
|
||||
fontSize = 14.sp,
|
||||
color = senderPrefixColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow =
|
||||
android.text.TextUtils.TruncateAt.END,
|
||||
modifier =
|
||||
Modifier.widthIn(
|
||||
max = 120.dp
|
||||
),
|
||||
enableLinks = false
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(4.dp)
|
||||
)
|
||||
AppleEmojiText(
|
||||
text =
|
||||
baseDisplayText,
|
||||
fontSize = 14.sp,
|
||||
color =
|
||||
if (dialog.unreadCount >
|
||||
0
|
||||
)
|
||||
textColor.copy(
|
||||
alpha =
|
||||
0.85f
|
||||
)
|
||||
else
|
||||
secondaryTextColor,
|
||||
fontWeight =
|
||||
if (dialog.unreadCount >
|
||||
0
|
||||
)
|
||||
FontWeight.Medium
|
||||
else
|
||||
FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow =
|
||||
android.text.TextUtils.TruncateAt.END,
|
||||
modifier =
|
||||
Modifier.weight(
|
||||
1f
|
||||
),
|
||||
enableLinks = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AppleEmojiText(
|
||||
text = baseDisplayText,
|
||||
fontSize = 14.sp,
|
||||
color =
|
||||
if (dialog.unreadCount > 0)
|
||||
textColor.copy(alpha = 0.85f)
|
||||
else secondaryTextColor,
|
||||
fontWeight =
|
||||
if (dialog.unreadCount > 0)
|
||||
FontWeight.Medium
|
||||
else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enableLinks = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.DraftManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.BlacklistEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||
@@ -42,6 +43,8 @@ data class DialogUiModel(
|
||||
val lastMessageRead: Int = 0, // Прочитано (0/1)
|
||||
val lastMessageAttachmentType: String? =
|
||||
null, // 📎 Тип attachment: "Photo", "File", или null
|
||||
val lastMessageSenderPrefix: String? = null, // 👥 Для групп: "You" или имя отправителя
|
||||
val lastMessageSenderKey: String? = null, // 👥 Для групп: public key отправителя
|
||||
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
|
||||
)
|
||||
|
||||
@@ -66,6 +69,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val groupRepository = GroupRepository.getInstance(application)
|
||||
|
||||
private var currentAccount: String = ""
|
||||
private var currentPrivateKey: String? = null
|
||||
@@ -123,6 +127,104 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
|
||||
private val groupJoinedMarker = "\$a=Group joined"
|
||||
private val groupCreatedMarker = "\$a=Group created"
|
||||
|
||||
private fun isGroupKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private data class GroupLastSenderInfo(
|
||||
val senderPrefix: String,
|
||||
val senderKey: String
|
||||
)
|
||||
|
||||
private suspend fun resolveGroupLastSenderInfo(
|
||||
dialog: com.rosetta.messenger.database.DialogEntity
|
||||
): GroupLastSenderInfo? {
|
||||
if (!isGroupKey(dialog.opponentKey) || dialog.lastMessageTimestamp <= 0L) return null
|
||||
|
||||
val senderKey =
|
||||
if (dialog.lastMessageFromMe == 1) {
|
||||
currentAccount
|
||||
} else {
|
||||
val lastMessage =
|
||||
try {
|
||||
dialogDao.getLastMessageByDialogKey(
|
||||
account = dialog.account,
|
||||
dialogKey = dialog.opponentKey.trim()
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
lastMessage?.fromPublicKey.orEmpty()
|
||||
}
|
||||
|
||||
if (senderKey.isBlank()) return null
|
||||
if (senderKey == currentAccount) {
|
||||
return GroupLastSenderInfo(senderPrefix = "You", senderKey = senderKey)
|
||||
}
|
||||
|
||||
val senderName = resolveKnownDisplayName(senderKey)
|
||||
if (senderName == null) {
|
||||
loadUserInfoForDialog(senderKey)
|
||||
}
|
||||
return GroupLastSenderInfo(
|
||||
senderPrefix = senderName ?: senderKey.take(7),
|
||||
senderKey = senderKey
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveKnownDisplayName(publicKey: String): String? {
|
||||
if (publicKey.isBlank()) return null
|
||||
if (publicKey == currentAccount) return "You"
|
||||
|
||||
val dialogName =
|
||||
try {
|
||||
val userDialog = dialogDao.getDialog(currentAccount, publicKey)
|
||||
userDialog?.let {
|
||||
extractDisplayName(
|
||||
title = it.opponentTitle,
|
||||
username = it.opponentUsername,
|
||||
publicKey = publicKey
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (!dialogName.isNullOrBlank()) return dialogName
|
||||
|
||||
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
|
||||
if (cached.isNotBlank() && cached != publicKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractDisplayName(title: String, username: String, publicKey: String): String? {
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isNotEmpty() &&
|
||||
normalizedTitle != publicKey &&
|
||||
normalizedTitle != publicKey.take(7) &&
|
||||
normalizedTitle != publicKey.take(8)
|
||||
) {
|
||||
return normalizedTitle
|
||||
}
|
||||
|
||||
val normalizedUsername = username.trim()
|
||||
if (normalizedUsername.isNotEmpty() &&
|
||||
normalizedUsername != publicKey &&
|
||||
normalizedUsername != publicKey.take(7) &&
|
||||
normalizedUsername != publicKey.take(8)
|
||||
) {
|
||||
return normalizedUsername
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
@@ -222,6 +324,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
decryptedLastMessage =
|
||||
decryptedLastMessage
|
||||
)
|
||||
val groupLastSenderInfo =
|
||||
resolveGroupLastSenderInfo(dialog)
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
@@ -249,7 +353,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// DialogEntity
|
||||
// (денормализовано)
|
||||
lastMessageAttachmentType =
|
||||
attachmentType // 📎 Тип attachment
|
||||
attachmentType, // 📎 Тип attachment
|
||||
lastMessageSenderPrefix =
|
||||
groupLastSenderInfo?.senderPrefix,
|
||||
lastMessageSenderKey =
|
||||
groupLastSenderInfo?.senderKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -320,6 +428,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
decryptedLastMessage =
|
||||
decryptedLastMessage
|
||||
)
|
||||
val groupLastSenderInfo =
|
||||
resolveGroupLastSenderInfo(dialog)
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
@@ -346,7 +456,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead,
|
||||
lastMessageAttachmentType =
|
||||
attachmentType // 📎 Тип attachment
|
||||
attachmentType, // 📎 Тип attachment
|
||||
lastMessageSenderPrefix =
|
||||
groupLastSenderInfo?.senderPrefix,
|
||||
lastMessageSenderKey =
|
||||
groupLastSenderInfo?.senderKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,7 +518,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (opponentKeys.isEmpty()) return
|
||||
|
||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
|
||||
val newKeys =
|
||||
opponentKeys.filter { key ->
|
||||
!subscribedOnlineKeys.contains(key) && !isGroupKey(key)
|
||||
}
|
||||
if (newKeys.isEmpty()) return // Все уже подписаны
|
||||
|
||||
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
|
||||
@@ -429,7 +546,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (encryptedLastMessage.isEmpty()) return ""
|
||||
|
||||
if (privateKey.isEmpty()) {
|
||||
return if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
|
||||
val plainCandidate =
|
||||
if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage
|
||||
return formatPreviewText(plainCandidate)
|
||||
}
|
||||
|
||||
val decrypted =
|
||||
@@ -439,11 +558,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
null
|
||||
}
|
||||
|
||||
return when {
|
||||
val resolved = when {
|
||||
decrypted != null -> decrypted
|
||||
isLikelyEncryptedPayload(encryptedLastMessage) -> ""
|
||||
else -> encryptedLastMessage
|
||||
}
|
||||
return formatPreviewText(resolved)
|
||||
}
|
||||
|
||||
private fun formatPreviewText(value: String): String {
|
||||
if (value.isBlank()) return value
|
||||
val normalized = value.trim()
|
||||
return when {
|
||||
groupInviteRegex.matches(normalized) -> "Group Invite"
|
||||
normalized == groupJoinedMarker -> "You joined the group"
|
||||
normalized == groupCreatedMarker -> "Group created"
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAttachmentType(
|
||||
@@ -611,6 +742,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
}
|
||||
|
||||
/** Выйти из группы и удалить локальный групповой диалог */
|
||||
suspend fun leaveGroup(groupPublicKey: String): Boolean {
|
||||
if (currentAccount.isEmpty()) return false
|
||||
if (!isGroupKey(groupPublicKey)) return false
|
||||
|
||||
return try {
|
||||
val left = groupRepository.leaveGroup(currentAccount, groupPublicKey)
|
||||
if (left) {
|
||||
_dialogs.value = _dialogs.value.filter { it.opponentKey != groupPublicKey }
|
||||
_requests.value = _requests.value.filter { it.opponentKey != groupPublicKey }
|
||||
_requestsCount.value = _requests.value.size
|
||||
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
|
||||
ChatViewModel.clearCacheForOpponent(groupPublicKey)
|
||||
}
|
||||
left
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Заблокировать пользователя */
|
||||
suspend fun blockUser(publicKey: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
@@ -649,7 +800,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
*/
|
||||
private fun loadUserInfoForDialog(publicKey: String) {
|
||||
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
|
||||
if (publicKey == currentAccount) {
|
||||
if (publicKey == currentAccount || isGroupKey(publicKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
1785
app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt
Normal file
1785
app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
@@ -77,6 +78,31 @@ private const val TAG = "AttachmentComponents"
|
||||
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
|
||||
private val whitespaceRegex = "\\s+".toRegex()
|
||||
|
||||
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||
|
||||
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
||||
if (!isGroupStoredKey(storedKey)) return null
|
||||
val encoded = storedKey.removePrefix("group:")
|
||||
if (encoded.isBlank()) return null
|
||||
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||
}
|
||||
|
||||
private fun decodeBase64Payload(data: String): ByteArray? {
|
||||
val raw = data.trim()
|
||||
if (raw.isBlank()) return null
|
||||
val payload =
|
||||
if (raw.startsWith("data:") && raw.contains(",")) {
|
||||
raw.substringAfter(",")
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
return try {
|
||||
Base64.decode(payload, Base64.DEFAULT)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortDebugId(value: String): String {
|
||||
if (value.isBlank()) return "empty"
|
||||
val clean = value.trim()
|
||||
@@ -1488,28 +1514,53 @@ fun FileAttachment(
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
val success =
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
if (groupPassword.isNullOrBlank()) {
|
||||
false
|
||||
} else {
|
||||
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||
val bytes = decrypted?.let { decodeBase64Payload(it) }
|
||||
if (bytes != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
savedFile.parentFile?.mkdirs()
|
||||
savedFile.writeBytes(bytes)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
downloadProgress = 0.6f
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||
// Пиковое потребление памяти ~128KB вместо ~200MB
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MessageCrypto.decryptAttachmentFileStreaming(
|
||||
tempFile,
|
||||
decryptedKeyAndNonce,
|
||||
savedFile
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
downloadProgress = 0.6f
|
||||
|
||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||
// Пиковое потребление памяти ~128KB вместо ~200MB
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MessageCrypto.decryptAttachmentFileStreaming(
|
||||
tempFile,
|
||||
decryptedKeyAndNonce,
|
||||
savedFile
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadProgress = 0.95f
|
||||
|
||||
if (success) {
|
||||
@@ -1860,19 +1911,28 @@ fun AvatarAttachment(
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||
// Сначала расшифровываем его, получаем raw bytes
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
|
||||
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
||||
// bytes в password
|
||||
val decryptStartTime = System.currentTimeMillis()
|
||||
val decrypted =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent,
|
||||
decryptedKeyAndNonce
|
||||
)
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
if (groupPassword.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||
}
|
||||
} else {
|
||||
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
|
||||
// Сначала расшифровываем его, получаем raw bytes
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
|
||||
// Используем decryptAttachmentBlobWithPlainKey который правильно
|
||||
// конвертирует bytes в password
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent,
|
||||
decryptedKeyAndNonce
|
||||
)
|
||||
}
|
||||
val decryptTime = System.currentTimeMillis() - decryptStartTime
|
||||
|
||||
if (decrypted != null) {
|
||||
@@ -2351,21 +2411,35 @@ private suspend fun processDownloadedImage(
|
||||
onStatus(DownloadStatus.DECRYPTING)
|
||||
|
||||
// Расшифровываем ключ
|
||||
val keyCandidates: List<ByteArray>
|
||||
var keyCandidates: List<ByteArray> = emptyList()
|
||||
var groupPassword: String? = null
|
||||
try {
|
||||
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||
if (keyCandidates.isEmpty()) {
|
||||
throw IllegalArgumentException("empty key candidates")
|
||||
}
|
||||
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
|
||||
keyCandidates.forEachIndexed { idx, candidate ->
|
||||
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
if (groupPassword.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("empty group password")
|
||||
}
|
||||
logPhotoDebug("Group key decrypt OK: id=$idShort, keyLen=${groupPassword.length}")
|
||||
} else {
|
||||
keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||
if (keyCandidates.isEmpty()) {
|
||||
throw IllegalArgumentException("empty key candidates")
|
||||
}
|
||||
logPhotoDebug("Key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${keyCandidates.first().size}")
|
||||
keyCandidates.forEachIndexed { idx, candidate ->
|
||||
val keyHead = candidate.take(8).joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
logPhotoDebug("Key material[$idx]: id=$idShort, keyFp=${shortDebugHash(candidate)}, keyHead=$keyHead, keySize=${candidate.size}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Error")
|
||||
onStatus(DownloadStatus.ERROR)
|
||||
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
|
||||
val keyPrefix =
|
||||
when {
|
||||
chachaKey.startsWith("sync:") -> "sync"
|
||||
chachaKey.startsWith("group:") -> "group"
|
||||
else -> "ecdh"
|
||||
}
|
||||
logPhotoDebug("Key decrypt FAILED: id=$idShort, keyType=$keyPrefix, keyLen=${chachaKey.length}, err=${e.javaClass.simpleName}: ${e.message?.take(80)}")
|
||||
return
|
||||
}
|
||||
@@ -2374,17 +2448,26 @@ private suspend fun processDownloadedImage(
|
||||
val decryptStartTime = System.currentTimeMillis()
|
||||
var successKeyIdx = -1
|
||||
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
|
||||
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
|
||||
if (attempt.decrypted != null) {
|
||||
successKeyIdx = idx
|
||||
decryptDebug = attempt
|
||||
break
|
||||
}
|
||||
// Keep last trace for diagnostics if all fail.
|
||||
decryptDebug = attempt
|
||||
}
|
||||
val decrypted = decryptDebug.decrypted
|
||||
val decrypted =
|
||||
if (groupPassword != null) {
|
||||
val plain = CryptoManager.decryptWithPassword(encryptedContent, groupPassword!!)
|
||||
decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(plain, emptyList())
|
||||
plain
|
||||
} else {
|
||||
var value: String? = null
|
||||
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||
val attempt = MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(encryptedContent, keyCandidate)
|
||||
if (attempt.decrypted != null) {
|
||||
successKeyIdx = idx
|
||||
decryptDebug = attempt
|
||||
value = attempt.decrypted
|
||||
break
|
||||
}
|
||||
// Keep last trace for diagnostics if all fail.
|
||||
decryptDebug = attempt
|
||||
}
|
||||
value
|
||||
}
|
||||
val decryptTime = System.currentTimeMillis() - decryptStartTime
|
||||
onProgress(0.8f)
|
||||
|
||||
@@ -2428,7 +2511,12 @@ private suspend fun processDownloadedImage(
|
||||
} else {
|
||||
onError("Error")
|
||||
onStatus(DownloadStatus.ERROR)
|
||||
val keyPrefix = if (chachaKey.startsWith("sync:")) "sync" else "ecdh"
|
||||
val keyPrefix =
|
||||
when {
|
||||
chachaKey.startsWith("sync:") -> "sync"
|
||||
chachaKey.startsWith("group:") -> "group"
|
||||
else -> "ecdh"
|
||||
}
|
||||
val firstKeySize = keyCandidates.firstOrNull()?.size ?: -1
|
||||
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms, contentLen=${encryptedContent.length}, keyType=$keyPrefix, keyNonceSize=$firstKeySize, keyCandidates=${keyCandidates.size}")
|
||||
decryptDebug.trace.take(96).forEachIndexed { index, line ->
|
||||
@@ -2467,47 +2555,54 @@ internal suspend fun downloadAndDecryptImage(
|
||||
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||
)
|
||||
|
||||
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||
if (keyCandidates.isEmpty()) return@withContext null
|
||||
val plainKeyAndNonce = keyCandidates.first()
|
||||
logPhotoDebug(
|
||||
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
|
||||
)
|
||||
logPhotoDebug(
|
||||
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
|
||||
)
|
||||
|
||||
// Primary path for image attachments
|
||||
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
|
||||
var decrypted: String? = null
|
||||
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||
val attempt =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
|
||||
encryptedContent,
|
||||
keyCandidate
|
||||
val decrypted: String? =
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
|
||||
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||
} else {
|
||||
val keyCandidates = MessageCrypto.decryptKeyFromSenderCandidates(chachaKey, privateKey)
|
||||
if (keyCandidates.isEmpty()) return@withContext null
|
||||
val plainKeyAndNonce = keyCandidates.first()
|
||||
logPhotoDebug(
|
||||
"Helper key decrypt OK: id=$idShort, candidates=${keyCandidates.size}, keySize=${plainKeyAndNonce.size}"
|
||||
)
|
||||
logPhotoDebug(
|
||||
"Helper key material: id=$idShort, keyFp=${shortDebugHash(plainKeyAndNonce)}"
|
||||
)
|
||||
if (attempt.decrypted != null) {
|
||||
decryptDebug = attempt
|
||||
decrypted = attempt.decrypted
|
||||
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
|
||||
break
|
||||
}
|
||||
decryptDebug = attempt
|
||||
}
|
||||
|
||||
// Fallback for legacy payloads
|
||||
if (decrypted.isNullOrEmpty()) {
|
||||
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
|
||||
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
|
||||
}
|
||||
decrypted =
|
||||
try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
// Primary path for image attachments
|
||||
var decryptDebug = MessageCrypto.AttachmentDecryptDebugResult(null, emptyList())
|
||||
var value: String? = null
|
||||
for ((idx, keyCandidate) in keyCandidates.withIndex()) {
|
||||
val attempt =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKeyDebug(
|
||||
encryptedContent,
|
||||
keyCandidate
|
||||
)
|
||||
if (attempt.decrypted != null) {
|
||||
decryptDebug = attempt
|
||||
value = attempt.decrypted
|
||||
logPhotoDebug("Helper decrypt OK: id=$idShort, keyIdx=$idx")
|
||||
break
|
||||
}
|
||||
decryptDebug = attempt
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for legacy payloads
|
||||
if (value.isNullOrEmpty()) {
|
||||
decryptDebug.trace.takeLast(12).forEachIndexed { index, line ->
|
||||
logPhotoDebug("Helper decrypt TRACE[$index]: id=$idShort, $line")
|
||||
}
|
||||
value =
|
||||
try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
if (decrypted.isNullOrEmpty()) return@withContext null
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.widget.Toast
|
||||
import com.rosetta.messenger.R
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -41,19 +47,25 @@ import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.GroupStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
@@ -69,6 +81,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
@@ -281,6 +294,9 @@ fun MessageBubble(
|
||||
isSavedMessages: Boolean = false,
|
||||
privateKey: String = "",
|
||||
senderPublicKey: String = "",
|
||||
senderName: String = "",
|
||||
showGroupSenderLabel: Boolean = false,
|
||||
isGroupSenderAdmin: Boolean = false,
|
||||
currentUserPublicKey: String = "",
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onLongClick: () -> Unit = {},
|
||||
@@ -291,6 +307,7 @@ fun MessageBubble(
|
||||
onDelete: () -> Unit = {},
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||
contextMenuContent: @Composable () -> Unit = {}
|
||||
) {
|
||||
// Swipe-to-reply state
|
||||
@@ -366,6 +383,22 @@ fun MessageBubble(
|
||||
message.attachments.isEmpty() &&
|
||||
message.text.isNotBlank()
|
||||
|
||||
val groupActionSystemText =
|
||||
remember(message.text) { resolveGroupActionSystemText(message.text) }
|
||||
val isGroupActionSystemMessage =
|
||||
groupActionSystemText != null &&
|
||||
message.replyData == null &&
|
||||
message.forwardedMessages.isEmpty() &&
|
||||
message.attachments.isEmpty()
|
||||
|
||||
if (isGroupActionSystemMessage) {
|
||||
GroupActionSystemMessage(
|
||||
text = groupActionSystemText.orEmpty(),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Telegram: bubbleRadius = 17dp, smallRad (хвостик) = 6dp
|
||||
val bubbleShape =
|
||||
remember(message.isOutgoing, showTail, isSafeSystemMessage) {
|
||||
@@ -719,6 +752,32 @@ fun MessageBubble(
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
if (showGroupSenderLabel &&
|
||||
!message.isOutgoing &&
|
||||
senderName.isNotBlank()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = senderName,
|
||||
color =
|
||||
groupSenderLabelColor(
|
||||
senderPublicKey,
|
||||
isDarkTheme
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (isGroupSenderAdmin) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
GroupAdminBadge(isDarkTheme = isDarkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔥 Forwarded messages (multiple, desktop parity)
|
||||
if (message.forwardedMessages.isNotEmpty()) {
|
||||
ForwardedMessagesBubble(
|
||||
@@ -974,81 +1033,124 @@ fun MessageBubble(
|
||||
!hasImageWithCaption &&
|
||||
message.text.isNotEmpty()
|
||||
) {
|
||||
// Telegram-style: текст + время с автоматическим
|
||||
// переносом
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor,
|
||||
enableLinks = linksEnabled,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
if (isGroupInviteCode(message.text)) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages) MessageStatus.READ
|
||||
else message.status
|
||||
|
||||
GroupInviteInlineCard(
|
||||
inviteText = message.text,
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = currentUserPublicKey,
|
||||
accountPrivateKey = privateKey,
|
||||
actionsEnabled = !isSelectionMode,
|
||||
onOpenGroup = onGroupInviteOpen
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = statusColor,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isOutgoing = message.isOutgoing,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
verticalAlignment =
|
||||
Alignment
|
||||
.CenterVertically,
|
||||
horizontalArrangement =
|
||||
Arrangement
|
||||
.spacedBy(
|
||||
2.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
timeFormat
|
||||
.format(
|
||||
message.timestamp
|
||||
),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle =
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.font
|
||||
.FontStyle
|
||||
.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages
|
||||
)
|
||||
MessageStatus
|
||||
.READ
|
||||
else
|
||||
message.status
|
||||
AnimatedMessageStatus(
|
||||
status =
|
||||
displayStatus,
|
||||
timeColor =
|
||||
statusColor,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
isOutgoing =
|
||||
message.isOutgoing,
|
||||
timestamp =
|
||||
message.timestamp
|
||||
.time,
|
||||
onRetry =
|
||||
onRetry,
|
||||
onDelete =
|
||||
onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Telegram-style: текст + время с автоматическим
|
||||
// переносом
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor,
|
||||
enableLinks = linksEnabled,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
// Long
|
||||
// press
|
||||
// для
|
||||
// selection
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
Row(
|
||||
verticalAlignment =
|
||||
Alignment
|
||||
.CenterVertically,
|
||||
horizontalArrangement =
|
||||
Arrangement
|
||||
.spacedBy(
|
||||
2.dp
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
timeFormat
|
||||
.format(
|
||||
message.timestamp
|
||||
),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle =
|
||||
androidx.compose
|
||||
.ui
|
||||
.text
|
||||
.font
|
||||
.FontStyle
|
||||
.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
val displayStatus =
|
||||
if (isSavedMessages
|
||||
)
|
||||
MessageStatus
|
||||
.READ
|
||||
else
|
||||
message.status
|
||||
AnimatedMessageStatus(
|
||||
status =
|
||||
displayStatus,
|
||||
timeColor =
|
||||
statusColor,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
isOutgoing =
|
||||
message.isOutgoing,
|
||||
timestamp =
|
||||
message.timestamp
|
||||
.time,
|
||||
onRetry =
|
||||
onRetry,
|
||||
onDelete =
|
||||
onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1059,6 +1161,392 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
private val GROUP_INVITE_REGEX = Regex("^#group:[A-Za-z0-9+/=:]+$")
|
||||
private const val GROUP_ACTION_JOINED = "\$a=Group joined"
|
||||
private const val GROUP_ACTION_CREATED = "\$a=Group created"
|
||||
|
||||
private fun resolveGroupActionSystemText(text: String): String? {
|
||||
return when (text.trim()) {
|
||||
GROUP_ACTION_JOINED -> "You joined the group"
|
||||
GROUP_ACTION_CREATED -> "Group created"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupActionSystemMessage(text: String, isDarkTheme: Boolean) {
|
||||
val successColor = if (isDarkTheme) Color(0xFF7EE787) else Color(0xFF2E7D32)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = successColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGroupInviteCode(text: String): Boolean {
|
||||
val normalized = text.trim()
|
||||
if (!normalized.startsWith("#group:")) return false
|
||||
return GROUP_INVITE_REGEX.matches(normalized)
|
||||
}
|
||||
|
||||
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
|
||||
val paletteDark =
|
||||
listOf(
|
||||
Color(0xFF7ED957),
|
||||
Color(0xFF6EC1FF),
|
||||
Color(0xFFFF9F68),
|
||||
Color(0xFFC38AFF),
|
||||
Color(0xFFFF7AA2),
|
||||
Color(0xFF4DD7C8)
|
||||
)
|
||||
val paletteLight =
|
||||
listOf(
|
||||
Color(0xFF2E7D32),
|
||||
Color(0xFF1565C0),
|
||||
Color(0xFFEF6C00),
|
||||
Color(0xFF6A1B9A),
|
||||
Color(0xFFC2185B),
|
||||
Color(0xFF00695C)
|
||||
)
|
||||
val palette = if (isDarkTheme) paletteDark else paletteLight
|
||||
val index = kotlin.math.abs(publicKey.hashCode()) % palette.size
|
||||
return palette[index]
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupAdminBadge(isDarkTheme: Boolean) {
|
||||
var showInfo by remember { mutableStateOf(false) }
|
||||
val iconTint = Color(0xFFF6C445)
|
||||
val popupBackground = if (isDarkTheme) Color(0xFF2E2E31) else Color(0xFFF2F2F5)
|
||||
val popupTextColor = if (isDarkTheme) Color(0xFFE3E3E6) else Color(0xFF2B2B2F)
|
||||
|
||||
Box {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
|
||||
contentDescription = "Admin",
|
||||
tint = iconTint,
|
||||
modifier =
|
||||
Modifier.size(14.dp).clickable(
|
||||
indication = null,
|
||||
interactionSource =
|
||||
remember { MutableInteractionSource() }
|
||||
) { showInfo = true }
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showInfo,
|
||||
onDismissRequest = { showInfo = false },
|
||||
offset = DpOffset(x = 0.dp, y = 6.dp),
|
||||
modifier =
|
||||
Modifier.background(
|
||||
color = popupBackground,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "This user is administrator of\nthis group.",
|
||||
color = popupTextColor,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
horizontal = 14.dp,
|
||||
vertical = 10.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupInviteInlineCard(
|
||||
inviteText: String,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
actionsEnabled: Boolean,
|
||||
onOpenGroup: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val groupRepository = remember { GroupRepository.getInstance(context) }
|
||||
|
||||
val normalizedInvite = remember(inviteText) { inviteText.trim() }
|
||||
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
|
||||
|
||||
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
|
||||
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
|
||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
|
||||
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(normalizedInvite, accountPublicKey) {
|
||||
if (parsedInvite == null) {
|
||||
status = GroupStatus.INVALID
|
||||
membersCount = 0
|
||||
statusLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
statusLoading = true
|
||||
|
||||
val localGroupExists =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (accountPublicKey.isBlank()) {
|
||||
false
|
||||
} else {
|
||||
groupRepository.getGroup(accountPublicKey, parsedInvite.groupId) != null
|
||||
}
|
||||
}
|
||||
|
||||
val inviteInfo =
|
||||
withContext(Dispatchers.IO) {
|
||||
groupRepository.requestInviteInfo(parsedInvite.groupId)
|
||||
}
|
||||
|
||||
membersCount = inviteInfo?.membersCount ?: 0
|
||||
status =
|
||||
when {
|
||||
localGroupExists -> GroupStatus.JOINED
|
||||
inviteInfo != null -> inviteInfo.status
|
||||
else -> GroupStatus.NOT_JOINED
|
||||
}
|
||||
|
||||
statusLoading = false
|
||||
}
|
||||
|
||||
val title =
|
||||
parsedInvite?.title?.trim().takeUnless { it.isNullOrBlank() }
|
||||
?: "Group Invite"
|
||||
|
||||
val subtitle =
|
||||
if (statusLoading) {
|
||||
"Checking invite..."
|
||||
} else {
|
||||
when (status) {
|
||||
GroupStatus.NOT_JOINED ->
|
||||
if (membersCount > 0) {
|
||||
"$membersCount members • Invite to join this group"
|
||||
} else {
|
||||
"Invite to join this group"
|
||||
}
|
||||
GroupStatus.JOINED ->
|
||||
if (membersCount > 0) {
|
||||
"$membersCount members • You are already a member"
|
||||
} else {
|
||||
"You are already a member of this group"
|
||||
}
|
||||
GroupStatus.BANNED -> "You are banned in this group"
|
||||
GroupStatus.INVALID -> "This group invite is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
val cardBackground =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.16f)
|
||||
} else if (isDarkTheme) {
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.03f)
|
||||
}
|
||||
val cardBorder =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.22f)
|
||||
} else if (isDarkTheme) {
|
||||
Color.White.copy(alpha = 0.12f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.08f)
|
||||
}
|
||||
val titleColor =
|
||||
if (isOutgoing) Color.White
|
||||
else if (isDarkTheme) Color.White
|
||||
else Color(0xFF1A1A1A)
|
||||
val subtitleColor =
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||
else Color(0xFF70757F)
|
||||
|
||||
val accentColor =
|
||||
when (status) {
|
||||
GroupStatus.NOT_JOINED ->
|
||||
if (isOutgoing) Color.White else Color(0xFF228BE6)
|
||||
GroupStatus.JOINED ->
|
||||
if (isOutgoing) Color(0xFFD9FAD6) else Color(0xFF34C759)
|
||||
GroupStatus.BANNED, GroupStatus.INVALID ->
|
||||
if (isOutgoing) Color(0xFFFFD7D7) else Color(0xFFFF3B30)
|
||||
}
|
||||
val actionLabel =
|
||||
when (status) {
|
||||
GroupStatus.NOT_JOINED -> "Join Group"
|
||||
GroupStatus.JOINED -> "Open Group"
|
||||
GroupStatus.INVALID -> "Invalid"
|
||||
GroupStatus.BANNED -> "Banned"
|
||||
}
|
||||
val actionIcon =
|
||||
when (status) {
|
||||
GroupStatus.NOT_JOINED -> Icons.Default.PersonAdd
|
||||
GroupStatus.JOINED -> Icons.Default.Check
|
||||
GroupStatus.INVALID -> Icons.Default.Link
|
||||
GroupStatus.BANNED -> Icons.Default.Block
|
||||
}
|
||||
val actionEnabled = actionsEnabled && !statusLoading && !actionLoading && status != GroupStatus.INVALID && status != GroupStatus.BANNED
|
||||
|
||||
fun openParsedGroup() {
|
||||
val parsed = parsedInvite ?: return
|
||||
onOpenGroup(
|
||||
SearchUser(
|
||||
publicKey = groupRepository.toGroupDialogPublicKey(parsed.groupId),
|
||||
title = parsed.title.ifBlank { "Group" },
|
||||
username = "",
|
||||
verified = 0,
|
||||
online = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun handleAction() {
|
||||
if (!actionEnabled) return
|
||||
if (parsedInvite == null) return
|
||||
|
||||
if (status == GroupStatus.JOINED) {
|
||||
openParsedGroup()
|
||||
return
|
||||
}
|
||||
|
||||
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
|
||||
Toast.makeText(context, "Account is not ready", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
actionLoading = true
|
||||
val joinResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
groupRepository.joinGroup(
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
inviteString = normalizedInvite
|
||||
)
|
||||
}
|
||||
actionLoading = false
|
||||
|
||||
if (joinResult.success) {
|
||||
status = GroupStatus.JOINED
|
||||
openParsedGroup()
|
||||
} else {
|
||||
status = joinResult.status
|
||||
val errorMessage =
|
||||
when (joinResult.status) {
|
||||
GroupStatus.BANNED -> "You are banned in this group"
|
||||
GroupStatus.INVALID -> "This invite is invalid"
|
||||
else -> joinResult.error ?: "Failed to join group"
|
||||
}
|
||||
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardBackground,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.14f)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.8.dp,
|
||||
color = accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = accentColor,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SafeSystemMessageCard(text: String, timestamp: Date, isDarkTheme: Boolean) {
|
||||
val contentColor = if (isDarkTheme) Color(0xFFE8E9EE) else Color(0xFF1E1F23)
|
||||
@@ -2368,8 +2856,12 @@ fun KebabMenu(
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
isSavedMessages: Boolean,
|
||||
isGroupChat: Boolean = false,
|
||||
isSystemAccount: Boolean = false,
|
||||
isBlocked: Boolean,
|
||||
onGroupInfoClick: () -> Unit = {},
|
||||
onSearchMembersClick: () -> Unit = {},
|
||||
onLeaveGroupClick: () -> Unit = {},
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
@@ -2398,24 +2890,49 @@ fun KebabMenu(
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
if (!isSavedMessages && !isSystemAccount) {
|
||||
KebabMenuItem(
|
||||
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
|
||||
if (isGroupChat) {
|
||||
ContextMenuItemWithVector(
|
||||
icon = TablerIcons.Search,
|
||||
text = "Search Members",
|
||||
onClick = onSearchMembersClick,
|
||||
tintColor = iconColor,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
KebabMenuItem(
|
||||
icon = TelegramIcons.Info,
|
||||
text = "Group Info",
|
||||
onClick = onGroupInfoClick,
|
||||
tintColor = iconColor,
|
||||
textColor = textColor
|
||||
)
|
||||
Divider(color = dividerColor)
|
||||
KebabMenuItem(
|
||||
icon = TelegramIcons.Leave,
|
||||
text = "Leave Group",
|
||||
onClick = onLeaveGroupClick,
|
||||
tintColor = Color(0xFFFF3B30),
|
||||
textColor = Color(0xFFFF3B30)
|
||||
)
|
||||
} else {
|
||||
if (!isSavedMessages && !isSystemAccount) {
|
||||
KebabMenuItem(
|
||||
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
onClick = { if (isBlocked) onUnblockClick() else onBlockClick() },
|
||||
tintColor = iconColor,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
|
||||
// Delete chat
|
||||
KebabMenuItem(
|
||||
icon = TelegramIcons.Delete,
|
||||
text = "Delete Chat",
|
||||
onClick = onDeleteClick,
|
||||
tintColor = Color(0xFFFF3B30),
|
||||
textColor = Color(0xFFFF3B30)
|
||||
)
|
||||
// Delete chat
|
||||
KebabMenuItem(
|
||||
icon = TelegramIcons.Delete,
|
||||
text = "Delete Chat",
|
||||
onClick = onDeleteClick,
|
||||
tintColor = Color(0xFFFF3B30),
|
||||
textColor = Color(0xFFFF3B30)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
@@ -931,13 +932,22 @@ private suspend fun loadBitmapForViewerImage(
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||
)
|
||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||
)
|
||||
val decrypted =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
||||
?: return null
|
||||
if (image.chachaKey.startsWith("group:")) {
|
||||
val groupPassword =
|
||||
CryptoManager.decryptWithPassword(
|
||||
image.chachaKey.removePrefix("group:"),
|
||||
privateKey
|
||||
) ?: return null
|
||||
CryptoManager.decryptWithPassword(encryptedContent, groupPassword) ?: return null
|
||||
} else {
|
||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||
)
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
||||
?: return null
|
||||
}
|
||||
|
||||
val decodedBitmap = base64ToBitmapSafe(decrypted) ?: return null
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ data class ChatMessage(
|
||||
val replyData: ReplyData? = null,
|
||||
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKey: String = "" // Для расшифровки attachments
|
||||
val chachaKey: String = "", // Для расшифровки attachments
|
||||
val senderPublicKey: String = "",
|
||||
val senderName: String = ""
|
||||
)
|
||||
|
||||
/** Message delivery and read status */
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.renderscript.Allocation
|
||||
import android.renderscript.Element
|
||||
import android.renderscript.RenderScript
|
||||
import android.renderscript.ScriptIntrinsicBlur
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -24,6 +25,9 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -56,17 +60,19 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(avatarKey) {
|
||||
LaunchedEffect(avatarKey, publicKey) {
|
||||
val currentAvatars = avatars
|
||||
if (currentAvatars.isNotEmpty()) {
|
||||
val newOriginal = withContext(Dispatchers.IO) {
|
||||
val newOriginal = withContext(Dispatchers.IO) {
|
||||
if (currentAvatars.isNotEmpty()) {
|
||||
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
|
||||
} else {
|
||||
loadSystemAvatarBitmap(context, publicKey)
|
||||
}
|
||||
if (newOriginal != null) {
|
||||
originalBitmap = newOriginal
|
||||
blurredBitmap = withContext(Dispatchers.Default) {
|
||||
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
|
||||
}
|
||||
}
|
||||
if (newOriginal != null) {
|
||||
originalBitmap = newOriginal
|
||||
blurredBitmap = withContext(Dispatchers.Default) {
|
||||
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
|
||||
}
|
||||
} else {
|
||||
originalBitmap = null
|
||||
@@ -165,6 +171,21 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSystemAvatarRes(publicKey: String): Int? =
|
||||
when (publicKey) {
|
||||
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY -> R.drawable.safe_account
|
||||
MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY -> R.drawable.updates_account
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun loadSystemAvatarBitmap(context: Context, publicKey: String): Bitmap? {
|
||||
val resId = resolveSystemAvatarRes(publicKey) ?: return null
|
||||
val drawable = AppCompatResources.getDrawable(context, resId) ?: return null
|
||||
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 256
|
||||
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 256
|
||||
return drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaussian blur via RenderScript.
|
||||
* Pads the image with mirrored edges before blurring to eliminate edge banding artifacts,
|
||||
|
||||
@@ -33,8 +33,8 @@ private const val EDGE_ZONE_DP = 320
|
||||
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f
|
||||
private const val TOUCH_SLOP_FACTOR = 0.35f
|
||||
private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
|
||||
private const val BACKGROUND_MIN_SCALE = 0.94f
|
||||
private const val BACKGROUND_PARALLAX_DP = 18
|
||||
private const val BACKGROUND_MIN_SCALE = 0.97f
|
||||
private const val BACKGROUND_PARALLAX_DP = 4
|
||||
|
||||
/**
|
||||
* Telegram-style swipe back container (optimized)
|
||||
@@ -50,6 +50,7 @@ private const val BACKGROUND_PARALLAX_DP = 18
|
||||
*/
|
||||
private object SwipeBackSharedProgress {
|
||||
var ownerId by mutableLongStateOf(Long.MIN_VALUE)
|
||||
var ownerLayer by mutableIntStateOf(Int.MIN_VALUE)
|
||||
var progress by mutableFloatStateOf(0f)
|
||||
var active by mutableStateOf(false)
|
||||
}
|
||||
@@ -57,19 +58,20 @@ private object SwipeBackSharedProgress {
|
||||
@Composable
|
||||
fun SwipeBackBackgroundEffect(
|
||||
modifier: Modifier = Modifier,
|
||||
layer: Int = 0,
|
||||
content: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val progress = SwipeBackSharedProgress.progress.coerceIn(0f, 1f)
|
||||
val active = SwipeBackSharedProgress.active
|
||||
val active = SwipeBackSharedProgress.active && SwipeBackSharedProgress.ownerLayer == layer + 1
|
||||
val parallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
|
||||
val scale = if (active) BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * progress else 1f
|
||||
val backgroundTranslationX = if (active) -parallaxPx * (1f - progress) else 0f
|
||||
val backgroundTranslationX = if (active) parallaxPx * (1f - progress) else 0f
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier.graphicsLayer {
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
transformOrigin = TransformOrigin(1f, 0f)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = backgroundTranslationX
|
||||
@@ -83,6 +85,7 @@ fun SwipeBackContainer(
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
layer: Int = 1,
|
||||
swipeEnabled: Boolean = true,
|
||||
propagateBackgroundProgress: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
@@ -134,9 +137,11 @@ fun SwipeBackContainer(
|
||||
// Scrim alpha based on swipe progress
|
||||
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
|
||||
val sharedOwnerId = SwipeBackSharedProgress.ownerId
|
||||
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
||||
val sharedProgress = SwipeBackSharedProgress.progress
|
||||
val sharedActive = SwipeBackSharedProgress.active
|
||||
val isBackgroundForActiveSwipe = sharedActive && sharedOwnerId != containerId
|
||||
val isBackgroundForActiveSwipe =
|
||||
sharedActive && sharedOwnerId != containerId && sharedOwnerLayer == layer + 1
|
||||
val backgroundScale =
|
||||
if (isBackgroundForActiveSwipe) {
|
||||
BACKGROUND_MIN_SCALE + (1f - BACKGROUND_MIN_SCALE) * sharedProgress.coerceIn(0f, 1f)
|
||||
@@ -145,7 +150,7 @@ fun SwipeBackContainer(
|
||||
}
|
||||
val backgroundTranslationX =
|
||||
if (isBackgroundForActiveSwipe) {
|
||||
-backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f))
|
||||
backgroundParallaxPx * (1f - sharedProgress.coerceIn(0f, 1f))
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
@@ -153,6 +158,7 @@ fun SwipeBackContainer(
|
||||
fun updateSharedSwipeProgress(progress: Float, active: Boolean) {
|
||||
if (!propagateBackgroundProgress) return
|
||||
SwipeBackSharedProgress.ownerId = containerId
|
||||
SwipeBackSharedProgress.ownerLayer = layer
|
||||
SwipeBackSharedProgress.progress = progress.coerceIn(0f, 1f)
|
||||
SwipeBackSharedProgress.active = active
|
||||
}
|
||||
@@ -161,6 +167,7 @@ fun SwipeBackContainer(
|
||||
if (!propagateBackgroundProgress) return
|
||||
if (SwipeBackSharedProgress.ownerId == containerId) {
|
||||
SwipeBackSharedProgress.ownerId = Long.MIN_VALUE
|
||||
SwipeBackSharedProgress.ownerLayer = Int.MIN_VALUE
|
||||
SwipeBackSharedProgress.progress = 0f
|
||||
SwipeBackSharedProgress.active = false
|
||||
}
|
||||
@@ -215,7 +222,7 @@ fun SwipeBackContainer(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
if (isBackgroundForActiveSwipe) {
|
||||
transformOrigin = TransformOrigin(0f, 0.5f)
|
||||
transformOrigin = TransformOrigin(1f, 0f)
|
||||
scaleX = backgroundScale
|
||||
scaleY = backgroundScale
|
||||
translationX = backgroundTranslationX
|
||||
|
||||
@@ -46,9 +46,15 @@ fun VerifiedBadge(
|
||||
2 -> "This is official account belonging to administration of Rosetta."
|
||||
else -> "This user is administrator of this group."
|
||||
}
|
||||
|
||||
val badgeIconRes = if (verified == 3) {
|
||||
R.drawable.ic_arrow_badge_down_filled
|
||||
} else {
|
||||
R.drawable.ic_rosette_discount_check
|
||||
}
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_rosette_discount_check),
|
||||
painter = painterResource(id = badgeIconRes),
|
||||
contentDescription = "Verified",
|
||||
tint = badgeColor,
|
||||
modifier = modifier
|
||||
@@ -69,7 +75,7 @@ fun VerifiedBadge(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_rosette_discount_check),
|
||||
painter = painterResource(id = badgeIconRes),
|
||||
contentDescription = null,
|
||||
tint = badgeColor,
|
||||
modifier = Modifier.size(32.dp)
|
||||
|
||||
9
app/src/main/res/drawable/ic_arrow_badge_down_filled.xml
Normal file
9
app/src/main/res/drawable/ic_arrow_badge_down_filled.xml
Normal 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>
|
||||
Reference in New Issue
Block a user