From a0569648e8c99a478e0487d51b00aca0d9ff0ceb Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 1 Mar 2026 00:01:01 +0500 Subject: [PATCH] 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. --- .../com/rosetta/messenger/MainActivity.kt | 125 +- .../rosetta/messenger/data/GroupRepository.kt | 431 ++++ .../messenger/data/MessageRepository.kt | 221 +- .../messenger/database/MessageEntities.kt | 116 +- .../messenger/database/RosettaDatabase.kt | 31 +- .../rosetta/messenger/network/GroupStatus.kt | 12 + .../messenger/network/PacketCreateGroup.kt | 18 + .../messenger/network/PacketGroupBan.kt | 21 + .../messenger/network/PacketGroupInfo.kt | 27 + .../network/PacketGroupInviteInfo.kt | 24 + .../messenger/network/PacketGroupJoin.kt | 24 + .../messenger/network/PacketGroupLeave.kt | 18 + .../com/rosetta/messenger/network/Protocol.kt | 6 + .../messenger/network/ProtocolManager.kt | 68 +- .../messenger/ui/chats/ChatDetailScreen.kt | 276 ++- .../messenger/ui/chats/ChatViewModel.kt | 587 +++++- .../messenger/ui/chats/ChatsListScreen.kt | 264 ++- .../messenger/ui/chats/ChatsListViewModel.kt | 165 +- .../messenger/ui/chats/GroupInfoScreen.kt | 1785 +++++++++++++++++ .../messenger/ui/chats/GroupSetupScreen.kt | 248 +++ .../chats/components/AttachmentComponents.kt | 275 ++- .../chats/components/ChatDetailComponents.kt | 689 ++++++- .../ui/chats/components/ImageViewerScreen.kt | 22 +- .../ui/chats/models/ChatDetailModels.kt | 4 +- .../ui/components/BlurredAvatarBackground.kt | 37 +- .../ui/components/SwipeBackContainer.kt | 23 +- .../messenger/ui/components/VerifiedBadge.kt | 10 +- .../drawable/ic_arrow_badge_down_filled.xml | 9 + 28 files changed, 5053 insertions(+), 483 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/GroupStatus.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketCreateGroup.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketGroupBan.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketGroupInfo.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketGroupInviteInfo.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketGroupJoin.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/PacketGroupLeave.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt create mode 100644 app/src/main/res/drawable/ic_arrow_badge_down_filled.xml diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5e199cf..e34da6f 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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().lastOrNull() } } val selectedUser = chatDetailScreen?.user + val groupInfoScreen by remember { + derivedStateOf { navStack.filterIsInstance().lastOrNull() } + } + val selectedGroup = groupInfoScreen?.group val otherProfileScreen by remember { derivedStateOf { navStack.filterIsInstance().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) diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt new file mode 100644 index 0000000..7ad7ba3 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -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? { + 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( + 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( + 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( + 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( + 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( + 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( + 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 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) + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index f57fe37..142e8c0 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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, 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 Архиве) diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index ae472b6..14ca260 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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 @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 5374c3c..710d5e1 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -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() // Для разработки - только // если миграция не diff --git a/app/src/main/java/com/rosetta/messenger/network/GroupStatus.kt b/app/src/main/java/com/rosetta/messenger/network/GroupStatus.kt new file mode 100644 index 0000000..f9fd961 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/GroupStatus.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketCreateGroup.kt b/app/src/main/java/com/rosetta/messenger/network/PacketCreateGroup.kt new file mode 100644 index 0000000..a41e225 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketCreateGroup.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketGroupBan.kt b/app/src/main/java/com/rosetta/messenger/network/PacketGroupBan.kt new file mode 100644 index 0000000..b3c4225 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketGroupBan.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketGroupInfo.kt b/app/src/main/java/com/rosetta/messenger/network/PacketGroupInfo.kt new file mode 100644 index 0000000..66daaff --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketGroupInfo.kt @@ -0,0 +1,27 @@ +package com.rosetta.messenger.network + +class PacketGroupInfo : Packet() { + var groupId: String = "" + var members: List = emptyList() + + override fun getPacketId(): Int = 0x12 + + override fun receive(stream: Stream) { + groupId = stream.readString() + val count = stream.readInt16().coerceAtLeast(0) + val parsed = ArrayList(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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketGroupInviteInfo.kt b/app/src/main/java/com/rosetta/messenger/network/PacketGroupInviteInfo.kt new file mode 100644 index 0000000..e667300 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketGroupInviteInfo.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketGroupJoin.kt b/app/src/main/java/com/rosetta/messenger/network/PacketGroupJoin.kt new file mode 100644 index 0000000..8450533 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketGroupJoin.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/PacketGroupLeave.kt b/app/src/main/java/com/rosetta/messenger/network/PacketGroupLeave.kt new file mode 100644 index 0000000..8d485a3 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/PacketGroupLeave.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 48eccf9..ae578e8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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() }, diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index f1d6bed..bc4265b 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 6cd025d..6b709de 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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>(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( } // � Текст текущего 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 }) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index b84ea5a..de6f3c9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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() + private val groupKeyCache = ConcurrentHashMap() + private val groupSenderNameCache = ConcurrentHashMap() + private val groupSenderResolveRequested = ConcurrentHashMap.newKeySet() @Volatile private var isCleared = false // Информация о собеседнике @@ -196,6 +200,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 📌 Pinned messages state private val _pinnedMessages = MutableStateFlow>(emptyList()) val pinnedMessages: StateFlow> = _pinnedMessages.asStateFlow() + private val _pinnedMessagePreviews = MutableStateFlow>(emptyMap()) + val pinnedMessagePreviews: StateFlow> = _pinnedMessagePreviews.asStateFlow() private val _currentPinnedIndex = MutableStateFlow(0) val currentPinnedIndex: StateFlow = _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 + ) { + 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() @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index cc77562..c2098ed 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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>(emptyList()) } + var dialogToLeave by remember { mutableStateOf(null) } var dialogToBlock by remember { mutableStateOf(null) } var dialogToUnblock by remember { mutableStateOf(null) } var accountToDelete by remember { mutableStateOf(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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index d6d46d0..8126bbc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -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 = _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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt new file mode 100644 index 0000000..4fe05e0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -0,0 +1,1785 @@ +package com.rosetta.messenger.ui.chats + +import android.app.Activity +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Message +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.rosetta.messenger.crypto.CryptoManager +import com.rosetta.messenger.data.ForwardManager +import com.rosetta.messenger.data.GroupRepository +import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.database.MessageEntity +import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.MessageAttachment +import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.ui.chats.components.ImageAttachment +import com.rosetta.messenger.ui.chats.components.ImageSourceBounds +import com.rosetta.messenger.ui.chats.components.ImageViewerScreen +import com.rosetta.messenger.ui.chats.components.ViewableImage +import com.rosetta.messenger.ui.components.AvatarImage +import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.ui.icons.TelegramIcons +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.core.view.WindowCompat +import org.json.JSONArray +import java.util.Date +import java.util.UUID +import kotlin.math.abs + +private enum class GroupInfoTab(val title: String) { + MEMBERS("Members"), + MEDIA("Media"), + FILES("Files"), + LINKS("Links") +} + +private data class GroupMemberUi( + val publicKey: String, + val title: String, + val subtitle: String, + val verified: Int, + val online: Boolean, + val isAdmin: Boolean, + val searchUser: SearchUser +) + +private data class GroupSharedStats( + val mediaCount: Int = 0, + val fileCount: Int = 0, + val linksCount: Int = 0 +) + +private data class GroupMediaItem( + val key: String, + val attachment: MessageAttachment, + val chachaKey: String, + val senderPublicKey: String, + val senderName: String, + val timestamp: Long, + val caption: String +) + +private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)") +private val KEY_IMAGE_COLORS = listOf( + Color(0xFFD0EBFF), + Color(0xFFA5D8FF), + Color(0xFF74C0FC), + Color(0xFF4DABF7), + Color(0xFF339AF0) +) + +@Composable +fun GroupInfoScreen( + groupUser: SearchUser, + currentUserPublicKey: String, + currentUserPrivateKey: String, + isDarkTheme: Boolean, + avatarRepository: AvatarRepository? = null, + onBack: () -> Unit, + onMemberClick: (SearchUser) -> Unit = {}, + onGroupLeft: () -> Unit = {}, + onSwipeBackEnabledChanged: (Boolean) -> Unit = {} +) { + BackHandler(onBack = onBack) + + val context = androidx.compose.ui.platform.LocalContext.current + val view = LocalView.current + val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current + val hapticFeedback = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val topSurfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6) + val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val sectionColor = if (isDarkTheme) Color(0xFF222224) else Color.White + val listRowColor = if (isDarkTheme) Color(0xFF1F1F21) else Color.White + val borderColor = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color(0xFFE5E5EA) + val memberDividerColor = if (isDarkTheme) Color(0xFF2E3239) else Color(0xFFDDE3EC) + val primaryText = if (isDarkTheme) Color.White else Color.Black + val secondaryText = Color(0xFF8E8E93) + val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) + val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + + // Keep status bar unified with group header color. + DisposableEffect(topSurfaceColor, view) { + val window = (view.context as? Activity)?.window + if (window == null) { + onDispose { } + } else { + val controller = WindowCompat.getInsetsController(window, view) + val previousColor = window.statusBarColor + val previousLightIcons = controller.isAppearanceLightStatusBars + + window.statusBarColor = topSurfaceColor.toArgb() + controller.isAppearanceLightStatusBars = false + + onDispose { + window.statusBarColor = previousColor + controller.isAppearanceLightStatusBars = previousLightIcons + } + } + } + + val groupRepository = remember { GroupRepository.getInstance(context) } + val messageRepository = remember { MessageRepository.getInstance(context) } + val preferencesManager = remember { PreferencesManager(context) } + val database = remember { RosettaDatabase.getDatabase(context) } + val groupDao = remember { database.groupDao() } + val messageDao = remember { database.messageDao() } + val chatsListViewModel: ChatsListViewModel = viewModel() + val forwardDialogs by chatsListViewModel.dialogs.collectAsState() + + val normalizedGroupId = remember(groupUser.publicKey) { + groupRepository.normalizeGroupId(groupUser.publicKey) + } + val dialogPublicKey = remember(normalizedGroupId) { + groupRepository.toGroupDialogPublicKey(normalizedGroupId) + } + + var selectedTab by rememberSaveable(dialogPublicKey) { mutableStateOf(GroupInfoTab.MEMBERS) } + var searchQuery by rememberSaveable(dialogPublicKey) { mutableStateOf("") } + var showSearch by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + var showLeaveConfirm by remember { mutableStateOf(false) } + var isLeaving by remember { mutableStateOf(false) } + var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") } + var encryptionKeyLoading by remember { mutableStateOf(false) } + var membersLoading by remember { mutableStateOf(false) } + var isMuted by remember { mutableStateOf(false) } + var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } + + var members by remember(dialogPublicKey) { mutableStateOf>(emptyList()) } + val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf() } + + val groupEntity by produceState( + initialValue = null, + key1 = currentUserPublicKey, + key2 = normalizedGroupId + ) { + value = withContext(Dispatchers.IO) { + groupDao.getGroup(currentUserPublicKey, normalizedGroupId) + } + } + + val messagesFlow = remember(messageDao, currentUserPublicKey, dialogPublicKey) { + if (currentUserPublicKey.isBlank() || dialogPublicKey.isBlank()) { + flowOf(emptyList()) + } else { + messageDao.getMessagesFlow(currentUserPublicKey, dialogPublicKey) + } + } + val groupMessages by messagesFlow.collectAsState(initial = emptyList()) + + val sharedStats by produceState( + initialValue = GroupSharedStats(), + key1 = groupMessages, + key2 = currentUserPrivateKey + ) { + value = withContext(Dispatchers.Default) { + buildGroupSharedStats(groupMessages, currentUserPrivateKey) + } + } + + val groupMediaItems by produceState( + initialValue = emptyList(), + groupMessages, + currentUserPrivateKey, + memberInfoByKey.toMap(), + currentUserPublicKey + ) { + val memberSnapshot = memberInfoByKey.toMap() + value = withContext(Dispatchers.Default) { + buildGroupMediaItems( + messages = groupMessages, + privateKey = currentUserPrivateKey, + currentUserPublicKey = currentUserPublicKey, + memberInfoByKey = memberSnapshot + ) + } + } + + val mediaViewerImages = remember(groupMediaItems) { + groupMediaItems.map { mediaItem -> + ViewableImage( + attachmentId = mediaItem.attachment.id, + preview = mediaItem.attachment.preview, + blob = mediaItem.attachment.blob, + chachaKey = mediaItem.chachaKey, + senderPublicKey = mediaItem.senderPublicKey, + senderName = mediaItem.senderName, + timestamp = Date(mediaItem.timestamp), + width = mediaItem.attachment.width, + height = mediaItem.attachment.height, + caption = mediaItem.caption + ) + } + } + var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) } + var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf(null) } + + val groupTitle = remember(groupEntity, groupUser.title) { + groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() } + ?: groupUser.title.trim().ifBlank { "Group" } + } + val groupDescription = remember(groupEntity) { + groupEntity?.description?.trim().orEmpty() + } + + LaunchedEffect(currentUserPublicKey, dialogPublicKey) { + if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) { + isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey) + } + } + + LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) { + if (currentUserPublicKey.isNotBlank() && currentUserPrivateKey.isNotBlank()) { + chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey) + messageRepository.initialize(currentUserPublicKey, currentUserPrivateKey) + } + } + + fun refreshMembers() { + if (normalizedGroupId.isBlank()) return + + scope.launch { + membersLoading = true + val fetchedMembers = withContext(Dispatchers.IO) { + groupRepository.requestGroupMembers(normalizedGroupId).orEmpty() + } + members = fetchedMembers.distinct() + membersLoading = false + + if (members.isEmpty()) return@launch + + val resolvedUsers = withContext(Dispatchers.IO) { + val resolvedMap = LinkedHashMap() + members.forEach { memberKey -> + val cached = ProtocolManager.getCachedUserInfo(memberKey) + if (cached != null) { + resolvedMap[memberKey] = cached + } else { + ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser -> + resolvedMap[memberKey] = resolvedUser + } + } + } + resolvedMap + } + if (resolvedUsers.isNotEmpty()) { + memberInfoByKey.putAll(resolvedUsers) + } + } + } + + LaunchedEffect(normalizedGroupId) { + refreshMembers() + } + + val onlineCount by remember(members, memberInfoByKey) { + derivedStateOf { + members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 } + } + } + + val memberItems by remember(members, memberInfoByKey, searchQuery) { + derivedStateOf { + val query = searchQuery.trim().lowercase() + members.mapIndexed { index, key -> + val info = memberInfoByKey[key] + val fallbackName = shortPublicKey(key) + val displayTitle = + info?.title?.takeIf { it.isNotBlank() } + ?: info?.username?.takeIf { it.isNotBlank() } + ?: fallbackName + val subtitle = when { + (info?.online ?: 0) > 0 -> "online" + info?.username?.isNotBlank() == true -> "@${info.username}" + else -> key.take(18) + } + GroupMemberUi( + publicKey = key, + title = displayTitle, + subtitle = subtitle, + verified = info?.verified ?: 0, + online = (info?.online ?: 0) > 0, + isAdmin = index == 0, + searchUser = SearchUser( + publicKey = key, + title = info?.title ?: displayTitle, + username = info?.username.orEmpty(), + verified = info?.verified ?: 0, + online = info?.online ?: 0 + ) + ) + }.filter { member -> + if (query.isBlank()) { + true + } else { + member.title.lowercase().contains(query) || + member.subtitle.lowercase().contains(query) || + member.publicKey.lowercase().contains(query) + } + } + } + } + val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() } + val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) { + derivedStateOf { + members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true + } + } + var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf(null) } + var memberToKick by remember(dialogPublicKey) { mutableStateOf(null) } + var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } + + LaunchedEffect(selectedTab) { + if (selectedTab != GroupInfoTab.MEMBERS) { + swipedMemberKey = null + } + } + LaunchedEffect(swipedMemberKey) { + onSwipeBackEnabledChanged(swipedMemberKey == null) + } + DisposableEffect(Unit) { + onDispose { + onSwipeBackEnabledChanged(true) + } + } + LaunchedEffect(memberItems) { + val currentSwiped = swipedMemberKey ?: return@LaunchedEffect + if (memberItems.none { it.publicKey == currentSwiped }) { + swipedMemberKey = null + } + } + + fun copyInvite() { + scope.launch { + if (currentUserPublicKey.isBlank() || currentUserPrivateKey.isBlank()) { + Toast.makeText(context, "Account keys not ready", Toast.LENGTH_SHORT).show() + return@launch + } + + val groupKey = withContext(Dispatchers.IO) { + groupRepository.getGroupKey(currentUserPublicKey, currentUserPrivateKey, normalizedGroupId) + } + if (groupKey.isNullOrBlank()) { + Toast.makeText(context, "Failed to load group key", Toast.LENGTH_SHORT).show() + return@launch + } + + val invite = groupRepository.constructInviteString( + groupId = normalizedGroupId, + title = groupTitle, + encryptKey = groupKey, + description = groupDescription + ) + if (invite.isBlank()) { + Toast.makeText(context, "Failed to create invite", Toast.LENGTH_SHORT).show() + return@launch + } + + clipboardManager.setText(AnnotatedString(invite)) + Toast.makeText(context, "Invite copied", Toast.LENGTH_SHORT).show() + } + } + + fun openInviteSharePicker() { + scope.launch { + if (currentUserPublicKey.isBlank() || currentUserPrivateKey.isBlank()) { + Toast.makeText(context, "Account keys not ready", Toast.LENGTH_SHORT).show() + return@launch + } + + val groupKey = withContext(Dispatchers.IO) { + groupRepository.getGroupKey(currentUserPublicKey, currentUserPrivateKey, normalizedGroupId) + } + if (groupKey.isNullOrBlank()) { + Toast.makeText(context, "Failed to load group key", Toast.LENGTH_SHORT).show() + return@launch + } + + val invite = groupRepository.constructInviteString( + groupId = normalizedGroupId, + title = groupTitle, + encryptKey = groupKey, + description = groupDescription + ) + if (invite.isBlank()) { + Toast.makeText(context, "Failed to create invite", Toast.LENGTH_SHORT).show() + return@launch + } + + pendingInviteText = invite + ForwardManager.setForwardMessages( + messages = listOf( + ForwardManager.ForwardMessage( + messageId = UUID.randomUUID().toString().replace("-", "").take(32), + text = invite, + timestamp = System.currentTimeMillis(), + isOutgoing = true, + senderPublicKey = currentUserPublicKey, + originalChatPublicKey = dialogPublicKey, + senderName = "You", + attachments = emptyList() + ) + ), + showPicker = false + ) + showAddMembersPicker = true + } + } + + fun leaveGroup() { + if (isLeaving) return + scope.launch { + isLeaving = true + val left = withContext(Dispatchers.IO) { + groupRepository.leaveGroup(currentUserPublicKey, normalizedGroupId) + } + isLeaving = false + if (left) { + onGroupLeft() + } else { + Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() + } + } + } + + fun kickMember(member: GroupMemberUi) { + if (isKickingMember) return + val memberKey = member.publicKey.trim() + if (memberKey.isBlank()) return + if (!currentUserIsAdmin) { + Toast.makeText(context, "Only admin can remove members", Toast.LENGTH_SHORT).show() + return + } + if (memberKey.equals(normalizedCurrentUserKey, ignoreCase = true)) { + Toast.makeText(context, "You cannot remove yourself", Toast.LENGTH_SHORT).show() + return + } + if (member.isAdmin) { + Toast.makeText(context, "Admin cannot be removed", Toast.LENGTH_SHORT).show() + return + } + + scope.launch { + isKickingMember = true + val removed = withContext(Dispatchers.IO) { + groupRepository.kickMember( + groupPublicKeyOrId = normalizedGroupId, + memberPublicKey = memberKey + ) + } + isKickingMember = false + memberToKick = null + swipedMemberKey = null + + if (removed) { + members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) } + memberInfoByKey.remove(member.publicKey) + refreshMembers() + Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show() + } + } + } + + fun openEncryptionKey() { + if (encryptionKeyLoading) return + scope.launch { + encryptionKeyLoading = true + val key = withContext(Dispatchers.IO) { + groupRepository.getGroupKey( + accountPublicKey = currentUserPublicKey, + accountPrivateKey = currentUserPrivateKey, + groupPublicKeyOrId = normalizedGroupId + ) + } + encryptionKeyLoading = false + if (key.isNullOrBlank()) { + Toast.makeText(context, "Failed to load encryption key", Toast.LENGTH_SHORT).show() + return@launch + } + encryptionKey = key + showEncryptionDialog = true + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .statusBarsPadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(topSurfaceColor) + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + + Box(modifier = Modifier.align(Alignment.TopEnd)) { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Menu", + tint = Color.White + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Search members") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + onClick = { + showMenu = false + selectedTab = GroupInfoTab.MEMBERS + showSearch = true + } + ) + DropdownMenuItem( + text = { Text("Copy invite") }, + leadingIcon = { + Icon(Icons.Default.PersonAdd, contentDescription = null) + }, + onClick = { + showMenu = false + copyInvite() + } + ) + DropdownMenuItem( + text = { Text("Encryption key") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + onClick = { + showMenu = false + openEncryptionKey() + } + ) + Divider() + DropdownMenuItem( + text = { Text("Leave group", color = Color(0xFFFF3B30)) }, + leadingIcon = { + Icon(Icons.Default.ExitToApp, contentDescription = null, tint = Color(0xFFFF3B30)) + }, + onClick = { + showMenu = false + showLeaveConfirm = true + } + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AvatarImage( + publicKey = dialogPublicKey, + avatarRepository = avatarRepository, + size = 86.dp, + isDarkTheme = isDarkTheme, + displayName = groupTitle + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = groupTitle, + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = if (membersLoading) { + "Loading members..." + } else { + "${members.size} members, $onlineCount online" + }, + color = Color.White.copy(alpha = 0.72f), + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + GroupActionButton( + modifier = Modifier.weight(1f), + icon = Icons.Default.Message, + label = "Message", + backgroundColor = cardColor, + contentColor = actionContentColor, + onClick = onBack + ) + GroupActionButton( + modifier = Modifier.weight(1f), + icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff, + label = if (isMuted) "Unmute" else "Mute", + backgroundColor = cardColor, + contentColor = actionContentColor, + onClick = { + scope.launch { + val newMutedState = !isMuted + preferencesManager.setChatMuted( + currentUserPublicKey, + dialogPublicKey, + newMutedState + ) + isMuted = newMutedState + } + } + ) + GroupActionButton( + modifier = Modifier.weight(1f), + icon = Icons.Default.ExitToApp, + label = "Leave", + backgroundColor = cardColor, + contentColor = Color(0xFFFF7A7A), + onClick = { showLeaveConfirm = true } + ) + } + + if (groupDescription.isNotBlank()) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = groupDescription, + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = sectionColor, + shape = RoundedCornerShape(0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { openInviteSharePicker() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + tint = accentColor + ) + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = "Add Members", + color = accentColor, + fontSize = 17.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = sectionColor, + shape = RoundedCornerShape(0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { openEncryptionKey() } + .padding(horizontal = 16.dp, vertical = 11.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Encryption Key", + color = primaryText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + if (encryptionKeyLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accentColor + ) + } + } + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "Tap to view key used for secure group communication", + color = secondaryText, + fontSize = 12.sp + ) + } + } + + TabRow( + selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab), + containerColor = backgroundColor, + contentColor = accentColor, + divider = { Divider(color = borderColor) } + ) { + GroupInfoTab.entries.forEach { tab -> + val tabTitle = when (tab) { + GroupInfoTab.MEMBERS -> "Members" + GroupInfoTab.MEDIA -> "Media ${sharedStats.mediaCount}" + GroupInfoTab.FILES -> "Files ${sharedStats.fileCount}" + GroupInfoTab.LINKS -> "Links ${sharedStats.linksCount}" + } + Tab( + selected = tab == selectedTab, + onClick = { selectedTab = tab }, + text = { + Text( + text = tabTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + color = if (tab == selectedTab) accentColor else secondaryText + ) + } + ) + } + } + + if (showSearch && selectedTab == GroupInfoTab.MEMBERS) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 8.dp), + singleLine = true, + label = { Text("Search members") }, + colors = TextFieldDefaults.colors( + focusedContainerColor = sectionColor, + unfocusedContainerColor = sectionColor, + focusedIndicatorColor = accentColor, + unfocusedIndicatorColor = borderColor, + focusedTextColor = primaryText, + unfocusedTextColor = primaryText, + focusedLabelColor = accentColor, + unfocusedLabelColor = secondaryText, + cursorColor = accentColor + ) + ) + } + + when (selectedTab) { + GroupInfoTab.MEMBERS -> { + if (membersLoading && members.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "Loading...", color = secondaryText) + } + } else if (memberItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = if (members.isEmpty()) "No members found" else "No results", + color = secondaryText + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp) + ) { + itemsIndexed(memberItems, key = { _, item -> item.publicKey }) { index, member -> + val canKickMember = + currentUserIsAdmin && + !member.publicKey.trim().equals(normalizedCurrentUserKey, ignoreCase = true) && + !member.isAdmin + + SwipeKickMemberItem( + memberKey = member.publicKey, + canKick = canKickMember, + isDarkTheme = isDarkTheme, + isSwipedOpen = swipedMemberKey == member.publicKey, + onSwipeStarted = { swipedMemberKey = member.publicKey }, + onSwipeClosed = { + if (swipedMemberKey == member.publicKey) { + swipedMemberKey = null + } + }, + onKickRequested = { + memberToKick = member + }, + onClick = { + onMemberClick(member.searchUser) + }, + onLongClick = { + if (canKickMember) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + memberToKick = member + } + } + ) { + GroupMemberRow( + member = member, + primaryText = primaryText, + secondaryText = secondaryText, + rowColor = listRowColor, + dividerColor = memberDividerColor, + accentColor = accentColor, + isDarkTheme = isDarkTheme, + avatarRepository = avatarRepository, + showDivider = index < memberItems.lastIndex + ) + } + } + } + } + } + GroupInfoTab.MEDIA -> { + if (groupMediaItems.isEmpty()) { + GroupTabPlaceholder( + animationAssetPath = "lottie/saved.json", + title = if (sharedStats.mediaCount > 0) "${sharedStats.mediaCount} media detected" else "No shared media yet", + subtitle = if (sharedStats.mediaCount > 0) "Media preview is preparing for this group." else "Photos from this group chat will appear here.", + isDarkTheme = isDarkTheme + ) + } else { + val mediaColumns = 3 + val mediaSpacing = 1.dp + val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp + val mediaCellSize = + (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns + val mediaIndexedRows = remember(groupMediaItems) { + groupMediaItems.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(mediaSpacing) + ) { + items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(mediaSpacing) + ) { + rowMedia.forEachIndexed { colIdx, mediaItem -> + val globalIndex = rowIdx * mediaColumns + colIdx + Box( + modifier = Modifier + .size(mediaCellSize) + .clip(RoundedCornerShape(0.dp)) + .background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB)) + ) { + ImageAttachment( + attachment = mediaItem.attachment, + chachaKey = mediaItem.chachaKey, + privateKey = currentUserPrivateKey, + senderPublicKey = mediaItem.senderPublicKey, + isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey, + isDarkTheme = isDarkTheme, + timestamp = Date(mediaItem.timestamp), + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = { _, bounds -> + imageViewerInitialIndex = globalIndex + imageViewerSourceBounds = bounds + showImageViewer = true + } + ) + } + } + + repeat(mediaColumns - rowMedia.size) { + Spacer(modifier = Modifier.size(mediaCellSize)) + } + } + } + } + } + } + GroupInfoTab.FILES -> { + GroupTabPlaceholder( + animationAssetPath = "lottie/folder.json", + title = if (sharedStats.fileCount > 0) "${sharedStats.fileCount} files detected" else "No shared files", + subtitle = if (sharedStats.fileCount > 0) "File list is preparing for this group." else "Documents from this group chat will appear here.", + isDarkTheme = isDarkTheme + ) + } + GroupInfoTab.LINKS -> { + GroupTabPlaceholder( + animationAssetPath = "lottie/earth.json", + title = if (sharedStats.linksCount > 0) "${sharedStats.linksCount} links detected" else "No shared links", + subtitle = if (sharedStats.linksCount > 0) "Links list is preparing for this group." else "Links from group messages will appear here.", + isDarkTheme = isDarkTheme + ) + } + } + } + + memberToKick?.let { targetMember -> + AlertDialog( + onDismissRequest = { + if (!isKickingMember) { + memberToKick = null + } + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + titleContentColor = primaryText, + textContentColor = primaryText, + title = { Text("Remove member") }, + text = { + Text("Remove ${targetMember.title} from this group?") + }, + confirmButton = { + TextButton( + onClick = { kickMember(targetMember) }, + enabled = !isKickingMember + ) { + if (isKickingMember) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Color(0xFFFF3B30) + ) + } else { + Text("Kick", color = Color(0xFFFF3B30)) + } + } + }, + dismissButton = { + TextButton( + onClick = { memberToKick = null }, + enabled = !isKickingMember + ) { + Text("Cancel", color = accentColor) + } + } + ) + } + + if (showLeaveConfirm) { + AlertDialog( + onDismissRequest = { showLeaveConfirm = false }, + title = { Text("Leave group") }, + text = { Text("Are you sure you want to leave this group?") }, + confirmButton = { + TextButton( + onClick = { + showLeaveConfirm = false + leaveGroup() + }, + enabled = !isLeaving + ) { + Text("Leave", color = Color(0xFFFF3B30)) + } + }, + dismissButton = { + TextButton(onClick = { showLeaveConfirm = false }, enabled = !isLeaving) { + Text("Cancel") + } + } + ) + } + + if (showEncryptionDialog) { + val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } + AlertDialog( + onDismissRequest = { showEncryptionDialog = false }, + title = { Text("Encryption key") }, + text = { + Column { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + DesktopStyleKeyImage( + keyRender = encryptionKey, + size = 180.dp, + radius = 14.dp + ) + } + Spacer(modifier = Modifier.height(12.dp)) + SelectionContainer { + Column { + if (displayLines.isNotEmpty()) { + displayLines.forEach { line -> + Text( + text = line, + color = secondaryText, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + Text( + text = "This key encrypts and decrypts group messages.", + color = secondaryText, + fontSize = 12.sp + ) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(encryptionKey)) + Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show() + } + ) { + Text("Copy", color = accentColor) + } + }, + dismissButton = { + TextButton(onClick = { showEncryptionDialog = false }) { + Text("Close", color = secondaryText) + } + } + ) + } + + if (showImageViewer && mediaViewerImages.isNotEmpty() && currentUserPrivateKey.isNotBlank()) { + ImageViewerScreen( + images = mediaViewerImages, + initialIndex = imageViewerInitialIndex.coerceIn(0, mediaViewerImages.lastIndex), + privateKey = currentUserPrivateKey, + onDismiss = { + showImageViewer = false + imageViewerSourceBounds = null + }, + isDarkTheme = isDarkTheme, + sourceBounds = imageViewerSourceBounds + ) + } + + if (showAddMembersPicker) { + val selectableDialogs = remember(forwardDialogs, dialogPublicKey) { + forwardDialogs.filter { it.opponentKey != dialogPublicKey } + } + ForwardChatPickerBottomSheet( + dialogs = selectableDialogs, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + avatarRepository = avatarRepository, + onDismiss = { + showAddMembersPicker = false + ForwardManager.clear() + }, + onChatsSelected = { selectedDialogs -> + showAddMembersPicker = false + ForwardManager.clear() + + if (selectedDialogs.isEmpty()) { + return@ForwardChatPickerBottomSheet + } + val inviteText = pendingInviteText.trim() + if (inviteText.isBlank()) { + Toast.makeText(context, "Invite is empty", Toast.LENGTH_SHORT).show() + return@ForwardChatPickerBottomSheet + } + + scope.launch { + var sentCount = 0 + selectedDialogs.forEach { dialog -> + val sent = runCatching { + messageRepository.sendMessage( + toPublicKey = dialog.opponentKey, + text = inviteText + ) + }.isSuccess + if (sent) sentCount += 1 + } + + if (sentCount > 0) { + Toast.makeText( + context, + "Invite sent to $sentCount chat${if (sentCount == 1) "" else "s"}", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText(context, "Failed to send invite", Toast.LENGTH_SHORT).show() + } + } + } + ) + } +} + +@Composable +private fun DesktopStyleKeyImage( + keyRender: String, + size: androidx.compose.ui.unit.Dp, + radius: androidx.compose.ui.unit.Dp = 0.dp +) { + val composition = remember(keyRender) { + buildList(64) { + val source = if (keyRender.isBlank()) "rosetta" else keyRender + for (i in 0 until 64) { + val code = source[i % source.length].code + val colorIndex = code % KEY_IMAGE_COLORS.size + add(KEY_IMAGE_COLORS[colorIndex]) + } + } + } + + Canvas( + modifier = Modifier + .size(size) + .clip(RoundedCornerShape(radius)) + .background(KEY_IMAGE_COLORS.first()) + ) { + val cells = 8 + val cellSize = this.size.minDimension / cells.toFloat() + for (i in 0 until 64) { + val row = i / cells + val col = i % cells + drawRect( + color = composition[i], + topLeft = Offset(col * cellSize, row * cellSize), + size = Size(cellSize, cellSize) + ) + } + } +} + +@Composable +private fun GroupActionButton( + modifier: Modifier = Modifier, + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + backgroundColor: Color, + contentColor: Color, + onClick: () -> Unit +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .clickable(onClick = onClick), + color = backgroundColor, + shape = RoundedCornerShape(14.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = label, color = contentColor, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SwipeKickMemberItem( + memberKey: String, + canKick: Boolean, + isDarkTheme: Boolean, + isSwipedOpen: Boolean, + onSwipeStarted: () -> Unit, + onSwipeClosed: () -> Unit, + onKickRequested: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val actionWidth = 80.dp + val actionWidthPx = with(density) { actionWidth.toPx() } + var offsetX by remember(memberKey) { mutableFloatStateOf(0f) } + var itemHeightPx by remember(memberKey) { mutableStateOf(0) } + + LaunchedEffect(isSwipedOpen, canKick, actionWidthPx) { + if (!canKick) { + offsetX = 0f + } else { + offsetX = if (isSwipedOpen) -actionWidthPx else 0f + } + } + + val animatedOffsetX by animateFloatAsState( + targetValue = offsetX, + animationSpec = tween(durationMillis = 180), + label = "groupMemberSwipeOffset" + ) + + val actionBackground = if (isDarkTheme) Color(0xFFFF6B6B) else Color(0xFFFF6B6B) + + Box( + modifier = Modifier + .fillMaxWidth() + .clipToBounds() + ) { + if (canKick) { + val actionHeight = + if (itemHeightPx > 0) with(density) { itemHeightPx.toDp() } else 72.dp + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .width(actionWidth) + .height(actionHeight) + .background(actionBackground) + .pointerInput(actionWidthPx) { + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + val updated = (offsetX + dragAmount).coerceIn(-actionWidthPx, 0f) + offsetX = updated + change.consume() + }, + onDragEnd = { + val shouldOpen = abs(offsetX) > actionWidthPx * 0.45f + if (shouldOpen) { + offsetX = -actionWidthPx + onSwipeStarted() + } else { + offsetX = 0f + onSwipeClosed() + } + }, + onDragCancel = { + if (abs(offsetX) <= actionWidthPx * 0.45f) { + offsetX = 0f + onSwipeClosed() + } + } + ) + } + .clickable { + offsetX = 0f + onSwipeClosed() + onKickRequested() + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = TelegramIcons.Leave, + contentDescription = "Kick", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Kick", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + Box( + modifier = Modifier + .offset { IntOffset(animatedOffsetX.toInt(), 0) } + .onSizeChanged { measured -> itemHeightPx = measured.height } + .pointerInput(canKick, actionWidthPx) { + if (!canKick) return@pointerInput + detectHorizontalDragGestures( + onDragStart = { + onSwipeStarted() + }, + onHorizontalDrag = { change, dragAmount -> + if (dragAmount < 0f || offsetX < 0f) { + val updated = (offsetX + dragAmount).coerceIn(-actionWidthPx, 0f) + offsetX = updated + change.consume() + } + }, + onDragEnd = { + val shouldOpen = abs(offsetX) > actionWidthPx * 0.45f + if (shouldOpen) { + offsetX = -actionWidthPx + onSwipeStarted() + } else { + offsetX = 0f + onSwipeClosed() + } + }, + onDragCancel = { + if (abs(offsetX) <= actionWidthPx * 0.45f) { + offsetX = 0f + onSwipeClosed() + } + } + ) + } + .combinedClickable( + onClick = { + if (offsetX < -6f) { + offsetX = 0f + onSwipeClosed() + } else { + onClick() + } + }, + onLongClick = { + if (canKick) { + onLongClick() + } + } + ) + ) { + content() + } + } +} + +@Composable +private fun GroupMemberRow( + member: GroupMemberUi, + primaryText: Color, + secondaryText: Color, + rowColor: Color, + dividerColor: Color, + accentColor: Color, + isDarkTheme: Boolean, + avatarRepository: AvatarRepository?, + showDivider: Boolean +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = rowColor + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarImage( + publicKey = member.publicKey, + avatarRepository = avatarRepository, + size = 48.dp, + isDarkTheme = isDarkTheme, + displayName = member.title + ) + Spacer(modifier = Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = member.title, + color = primaryText, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (member.verified > 0) { + VerifiedBadge( + verified = member.verified, + size = 16, + isDarkTheme = isDarkTheme + ) + } + } + Text( + text = member.subtitle, + color = if (member.online) accentColor else secondaryText, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (member.isAdmin) { + Text( + text = "Admin", + color = accentColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + } + if (showDivider) { + Divider( + modifier = Modifier.padding(start = 74.dp), + color = dividerColor, + thickness = 0.7.dp + ) + } + } + } +} + +@Composable +private fun GroupTabPlaceholder( + animationAssetPath: String, + title: String, + subtitle: String, + isDarkTheme: Boolean +) { + val composition by rememberLottieComposition(LottieCompositionSpec.Asset(animationAssetPath)) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + speed = 0.9f, + restartOnPlay = false, + isPlaying = composition != null + ) + val titleColor = if (isDarkTheme) Color(0xFFE5E5EA) else Color(0xFF1F2937) + val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF6B7280) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box(modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center) { + if (composition != null) { + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = Modifier.fillMaxSize() + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = title, + color = titleColor, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + color = subtitleColor, + fontSize = 13.sp, + textAlign = TextAlign.Center + ) + } +} + +private fun shortPublicKey(publicKey: String): String { + val trimmed = publicKey.trim() + if (trimmed.length <= 12) return trimmed + return "${trimmed.take(6)}...${trimmed.takeLast(4)}" +} + +private fun decryptStoredMessageText(encryptedText: String, privateKey: String): String { + if (encryptedText.isBlank()) return "" + if (privateKey.isBlank()) return encryptedText + return try { + CryptoManager.decryptWithPassword(encryptedText, privateKey) ?: encryptedText + } catch (_: Exception) { + encryptedText + } +} + +private fun extractUrls(text: String): List { + return URL_REGEX.findAll(text).map { match -> + val rawUrl = match.groupValues[1] + .trim() + .trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}') + if (rawUrl.startsWith("www.", ignoreCase = true)) { + "https://$rawUrl" + } else { + rawUrl + } + }.distinct().toList() +} + +private fun countAttachmentTypes(attachmentsJson: String): Pair { + if (attachmentsJson.isBlank() || attachmentsJson == "[]") return 0 to 0 + + var mediaCount = 0 + var fileCount = 0 + + runCatching { + val attachments = JSONArray(attachmentsJson) + for (i in 0 until attachments.length()) { + val attachment = attachments.getJSONObject(i) + when (AttachmentType.fromInt(attachment.optInt("type", 0))) { + AttachmentType.IMAGE -> mediaCount += 1 + AttachmentType.FILE -> fileCount += 1 + else -> Unit + } + } + } + + return mediaCount to fileCount +} + +private fun buildGroupSharedStats(messages: List, privateKey: String): GroupSharedStats { + if (messages.isEmpty()) return GroupSharedStats() + + var mediaCount = 0 + var fileCount = 0 + val links = linkedSetOf() + + messages.forEach { message -> + val decryptedText = decryptStoredMessageText(message.plainMessage, privateKey) + if (decryptedText.isNotBlank()) { + links.addAll(extractUrls(decryptedText)) + } + + val (mediaInMessage, filesInMessage) = countAttachmentTypes(message.attachments) + mediaCount += mediaInMessage + fileCount += filesInMessage + } + + return GroupSharedStats( + mediaCount = mediaCount, + fileCount = fileCount, + linksCount = links.size + ) +} + +private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List { + if (attachmentsJson.isBlank() || attachmentsJson == "[]") return emptyList() + + return try { + val attachments = JSONArray(attachmentsJson) + buildList { + for (i in 0 until attachments.length()) { + val attachment = attachments.getJSONObject(i) + val type = AttachmentType.fromInt(attachment.optInt("type", 0)) + if (type == AttachmentType.MESSAGES) continue + + add( + MessageAttachment( + id = attachment.optString("id", ""), + blob = attachment.optString("blob", ""), + type = type, + preview = attachment.optString("preview", ""), + width = attachment.optInt("width", 0), + height = attachment.optInt("height", 0), + localUri = attachment.optString("localUri", "") + ) + ) + } + } + } catch (_: Exception) { + emptyList() + } +} + +private fun resolveGroupSenderName( + senderPublicKey: String, + currentUserPublicKey: String, + memberInfoByKey: Map +): String { + if (senderPublicKey.isBlank()) return "Unknown" + if (senderPublicKey == currentUserPublicKey) return "You" + + val info = memberInfoByKey[senderPublicKey] ?: ProtocolManager.getCachedUserInfo(senderPublicKey) + return info?.title?.takeIf { it.isNotBlank() } + ?: info?.username?.takeIf { it.isNotBlank() } + ?: shortPublicKey(senderPublicKey) +} + +private fun buildGroupMediaItems( + messages: List, + privateKey: String, + currentUserPublicKey: String, + memberInfoByKey: Map +): List { + if (messages.isEmpty()) return emptyList() + + val uniqueMedia = hashSetOf() + val mediaItems = mutableListOf() + + for (message in messages) { + val attachments = parseAttachmentsForGroupInfo(message.attachments) + if (attachments.isEmpty()) continue + + val senderKey = message.fromPublicKey.takeIf { it.isNotBlank() } + ?: if (message.fromMe == 1) currentUserPublicKey else "" + val senderCacheKey = senderKey.ifBlank { currentUserPublicKey } + val senderName = resolveGroupSenderName( + senderPublicKey = senderCacheKey, + currentUserPublicKey = currentUserPublicKey, + memberInfoByKey = memberInfoByKey + ) + val caption = decryptStoredMessageText(message.plainMessage, privateKey).trim() + + for (attachment in attachments) { + if (attachment.type != AttachmentType.IMAGE) continue + + val key = "${message.messageId}:${attachment.id}" + if (!uniqueMedia.add(key)) continue + + mediaItems.add( + GroupMediaItem( + key = key, + attachment = attachment, + chachaKey = message.chachaKey, + senderPublicKey = senderCacheKey, + senderName = senderName, + timestamp = message.timestamp, + caption = caption + ) + ) + } + } + + return mediaItems.sortedByDescending { it.timestamp } +} + +private fun encodeGroupKeyForDisplay(encryptKey: String): List { + val normalized = encryptKey.trim() + if (normalized.isBlank()) return emptyList() + + val lines = mutableListOf() + normalized.chunked(16).forEach { chunk -> + val bytes = mutableListOf() + chunk.forEach { symbol -> + val encoded = (symbol.code xor 27).toString(16).padStart(2, '0') + bytes.add(encoded) + } + lines.add(bytes.joinToString(" ")) + } + return lines +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt new file mode 100644 index 0000000..6b7ec67 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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(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 + ) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index a920afc..040b747 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 + var keyCandidates: List = 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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 331c2e2..e674ffa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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.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) + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 2d562f5..94b2702 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 8264404..d60b174 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -40,7 +40,9 @@ data class ChatMessage( val replyData: ReplyData? = null, val forwardedMessages: List = emptyList(), // Multiple forwarded messages (desktop parity) val attachments: List = emptyList(), - val chachaKey: String = "" // Для расшифровки attachments + val chachaKey: String = "", // Для расшифровки attachments + val senderPublicKey: String = "", + val senderName: String = "" ) /** Message delivery and read status */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index 38147d4..7838063 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -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(null) } var blurredBitmap by remember { mutableStateOf(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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 2057292..02dfb36 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt index f9fb51d..db955f8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt @@ -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) diff --git a/app/src/main/res/drawable/ic_arrow_badge_down_filled.xml b/app/src/main/res/drawable/ic_arrow_badge_down_filled.xml new file mode 100644 index 0000000..e6235a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_badge_down_filled.xml @@ -0,0 +1,9 @@ + + +