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