feat: enhance forwarded messages display by enabling link support
This commit is contained in:
@@ -888,6 +888,7 @@ fun MainScreen(
|
|||||||
user = currentChatUser,
|
user = currentChatUser,
|
||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
currentUserPrivateKey = accountPrivateKey,
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
|
currentUserName = accountName,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
onUserProfileClick = { user ->
|
onUserProfileClick = { user ->
|
||||||
// Открываем профиль другого пользователя
|
// Открываем профиль другого пользователя
|
||||||
@@ -971,7 +972,13 @@ fun MainScreen(
|
|||||||
},
|
},
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
currentUserPrivateKey = accountPrivateKey
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
|
onWriteMessage = { chatUser ->
|
||||||
|
// Close profile and navigate to chat
|
||||||
|
navStack = navStack.filterNot {
|
||||||
|
it is Screen.OtherProfile || it is Screen.ChatDetail
|
||||||
|
} + Screen.ChatDetail(chatUser)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,81 +519,27 @@ object MessageCrypto {
|
|||||||
* @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64)
|
* @param encryptedData зашифрованный контент (ivBase64:ciphertextBase64)
|
||||||
* @param chachaKeyPlain Уже расшифрованный ChaCha ключ+nonce (56 bytes: 32 key + 24 nonce)
|
* @param chachaKeyPlain Уже расшифрованный ChaCha ключ+nonce (56 bytes: 32 key + 24 nonce)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Расшифровка аттачмента зашифрованного через encodeWithPassword (desktop parity)
|
||||||
|
*
|
||||||
|
* Десктоп: decodeWithPassword(keyPlain, data)
|
||||||
|
* 1. keyPlain = chachaDecryptedKey.toString('utf-8') — JS Buffer → UTF-8 string
|
||||||
|
* 2. PBKDF2(keyPlain, 'rosetta', {keySize: 256/32, iterations: 1000}) — SHA256
|
||||||
|
* 3. AES-CBC decrypt
|
||||||
|
* 4. pako.inflate → string
|
||||||
|
*
|
||||||
|
* Ровно то же самое делаем здесь.
|
||||||
|
*/
|
||||||
fun decryptAttachmentBlobWithPlainKey(
|
fun decryptAttachmentBlobWithPlainKey(
|
||||||
encryptedData: String,
|
encryptedData: String,
|
||||||
chachaKeyPlain: ByteArray
|
chachaKeyPlain: ByteArray
|
||||||
): String? {
|
): String? {
|
||||||
|
// Один путь, как в десктопе: bytesToJsUtf8String → PBKDF2-SHA256 → AES-CBC → inflate
|
||||||
return try {
|
return try {
|
||||||
|
val password = bytesToJsUtf8String(chachaKeyPlain)
|
||||||
// Desktop использует key.toString('binary') → encrypt → decrypt → toString('utf-8') → PBKDF2
|
val pbkdf2Key = generatePBKDF2Key(password)
|
||||||
// Это эквивалентно: raw bytes → Latin1 string → UTF-8 encode → шифрование →
|
decryptWithPBKDF2Key(encryptedData, pbkdf2Key)
|
||||||
// расшифровка → UTF-8 decode → опять UTF-8 для PBKDF2
|
} catch (_: Exception) {
|
||||||
//
|
|
||||||
// Но crypto-js PBKDF2 принимает string и делает UTF-8 encode для получения password bytes
|
|
||||||
// Desktop: PBKDF2(string) → внутри делает UTF-8 encode этой string → использует эти bytes
|
|
||||||
//
|
|
||||||
// КРИТИЧНО: Desktop сохраняет chachaDecryptedKey.toString('utf-8') в БД
|
|
||||||
// И потом использует ЭТУ СТРОКУ напрямую как password для PBKDF2!
|
|
||||||
//
|
|
||||||
// Пробуем РАЗНЫЕ варианты и логируем результат
|
|
||||||
|
|
||||||
// Вариант 1: UTF-8 decode (как Node.js Buffer.toString('utf-8'))
|
|
||||||
val password1 = bytesToJsUtf8String(chachaKeyPlain)
|
|
||||||
val passwordBytes1 = password1.toByteArray(Charsets.UTF_8)
|
|
||||||
|
|
||||||
// Вариант 2: Latin1 decode (каждый byte = char 0-255)
|
|
||||||
val password2 = String(chachaKeyPlain, Charsets.ISO_8859_1)
|
|
||||||
val passwordBytes2 = password2.toByteArray(Charsets.UTF_8)
|
|
||||||
|
|
||||||
// Вариант 3: Raw bytes напрямую (без string conversion)
|
|
||||||
|
|
||||||
// Пробуем расшифровать с КАЖДЫМ вариантом
|
|
||||||
val pbkdf2Key1 = generatePBKDF2Key(password1)
|
|
||||||
val result1 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key1)
|
|
||||||
if (result1 != null) {
|
|
||||||
return result1
|
|
||||||
}
|
|
||||||
|
|
||||||
val pbkdf2Key2 = generatePBKDF2Key(password2)
|
|
||||||
val result2 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key2)
|
|
||||||
if (result2 != null) {
|
|
||||||
return result2
|
|
||||||
}
|
|
||||||
|
|
||||||
val pbkdf2Key3 = generatePBKDF2KeyFromBytes(chachaKeyPlain)
|
|
||||||
val result3 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key3)
|
|
||||||
if (result3 != null) {
|
|
||||||
return result3
|
|
||||||
}
|
|
||||||
|
|
||||||
// V4: Стандартный Java PBKDF2 (PBEKeySpec с char[]) - для совместимости с Android encryptReplyBlob
|
|
||||||
val pbkdf2Key4 = generatePBKDF2KeyJava(password2)
|
|
||||||
val result4 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key4)
|
|
||||||
if (result4 != null) {
|
|
||||||
return result4
|
|
||||||
}
|
|
||||||
|
|
||||||
// V5: Стандартный Java PBKDF2 с UTF-8 password
|
|
||||||
val pbkdf2Key5 = generatePBKDF2KeyJava(password1)
|
|
||||||
val result5 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key5)
|
|
||||||
if (result5 != null) {
|
|
||||||
return result5
|
|
||||||
}
|
|
||||||
|
|
||||||
// V6: Java CharsetDecoder with REPLACE для UTF-8 (может отличаться от bytesToJsUtf8String)
|
|
||||||
val decoder = java.nio.charset.StandardCharsets.UTF_8.newDecoder()
|
|
||||||
decoder.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
|
|
||||||
decoder.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE)
|
|
||||||
val password6 = decoder.decode(java.nio.ByteBuffer.wrap(chachaKeyPlain)).toString()
|
|
||||||
val passwordBytes6 = password6.toByteArray(Charsets.UTF_8)
|
|
||||||
val pbkdf2Key6 = generatePBKDF2Key(password6)
|
|
||||||
val result6 = decryptWithPBKDF2Key(encryptedData, pbkdf2Key6)
|
|
||||||
if (result6 != null) {
|
|
||||||
return result6
|
|
||||||
}
|
|
||||||
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton manager for Protocol instance
|
* Singleton manager for Protocol instance
|
||||||
@@ -34,6 +36,12 @@ object ProtocolManager {
|
|||||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||||
|
|
||||||
|
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
|
||||||
|
// publicKey → SearchUser (resolved via PacketSearch 0x03)
|
||||||
|
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||||
|
// Pending resolves: publicKey → list of continuations waiting for the result
|
||||||
|
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
||||||
|
|
||||||
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||||
|
|
||||||
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
|
// 🚀 Флаг для включения UI логов (по умолчанию ВЫКЛЮЧЕНО - это вызывало ANR!)
|
||||||
@@ -167,6 +175,14 @@ object ProtocolManager {
|
|||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val ownPublicKey = getProtocol().getPublicKey()
|
val ownPublicKey = getProtocol().getPublicKey()
|
||||||
searchPacket.users.forEach { user ->
|
searchPacket.users.forEach { user ->
|
||||||
|
// 🔍 Кэшируем всех пользователей (desktop parity: cachedUsers)
|
||||||
|
userInfoCache[user.publicKey] = user
|
||||||
|
|
||||||
|
// Resume pending resolves for this publicKey
|
||||||
|
pendingResolves.remove(user.publicKey)?.forEach { cont ->
|
||||||
|
try { cont.resume(user) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем инфо в диалогах (для всех пользователей)
|
// Обновляем инфо в диалогах (для всех пользователей)
|
||||||
messageRepository?.updateDialogUserInfo(
|
messageRepository?.updateDialogUserInfo(
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
@@ -188,6 +204,13 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume pending resolves that got empty response (no match)
|
||||||
|
if (searchPacket.search.isNotEmpty() && searchPacket.users.none { it.publicKey == searchPacket.search }) {
|
||||||
|
pendingResolves.remove(searchPacket.search)?.forEach { cont ->
|
||||||
|
try { cont.resume(null) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 Обработчик транспортного сервера (0x0F)
|
// 🚀 Обработчик транспортного сервера (0x0F)
|
||||||
@@ -255,6 +278,104 @@ object ProtocolManager {
|
|||||||
send(packet)
|
send(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔍 Resolve publicKey → user title (like Desktop useUserInformation)
|
||||||
|
* Checks cache first, then sends PacketSearch and waits for response.
|
||||||
|
* Returns title or null on timeout/not found.
|
||||||
|
* @param timeoutMs max wait time for server response (default 3s)
|
||||||
|
*/
|
||||||
|
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||||
|
if (publicKey.isEmpty()) return null
|
||||||
|
|
||||||
|
// 1. Check in-memory cache (instant)
|
||||||
|
userInfoCache[publicKey]?.let { cached ->
|
||||||
|
val name = cached.title.ifEmpty { cached.username }
|
||||||
|
if (name.isNotEmpty()) return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Send PacketSearch and wait for response via suspendCancellableCoroutine
|
||||||
|
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
withTimeout(timeoutMs) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
// Register continuation in pending list
|
||||||
|
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||||
|
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
pendingResolves[publicKey]?.remove(cont)
|
||||||
|
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||||
|
pendingResolves.remove(publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send search request
|
||||||
|
val packet = PacketSearch().apply {
|
||||||
|
this.privateKey = privateHash
|
||||||
|
this.search = publicKey
|
||||||
|
}
|
||||||
|
send(packet)
|
||||||
|
}
|
||||||
|
}?.let { user -> user.title.ifEmpty { user.username }.ifEmpty { null } }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Timeout or cancellation — clean up
|
||||||
|
pendingResolves.remove(publicKey)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔍 Get cached user info (no network request)
|
||||||
|
*/
|
||||||
|
fun getCachedUserName(publicKey: String): String? {
|
||||||
|
val cached = userInfoCache[publicKey] ?: return null
|
||||||
|
return cached.title.ifEmpty { cached.username }.ifEmpty { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔍 Get full cached user info (no network request)
|
||||||
|
*/
|
||||||
|
fun getCachedUserInfo(publicKey: String): SearchUser? {
|
||||||
|
return userInfoCache[publicKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔍 Resolve publicKey → full SearchUser (with server request if needed)
|
||||||
|
*/
|
||||||
|
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||||
|
if (publicKey.isEmpty()) return null
|
||||||
|
|
||||||
|
// 1. Check in-memory cache
|
||||||
|
userInfoCache[publicKey]?.let { return it }
|
||||||
|
|
||||||
|
// 2. Send PacketSearch and wait
|
||||||
|
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
withTimeout(timeoutMs) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
pendingResolves[publicKey]?.remove(cont)
|
||||||
|
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||||
|
pendingResolves.remove(publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val packet = PacketSearch().apply {
|
||||||
|
this.privateKey = privateHash
|
||||||
|
this.search = publicKey
|
||||||
|
}
|
||||||
|
send(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingResolves.remove(publicKey)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send packet (simplified)
|
* Send packet (simplified)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ fun ChatDetailScreen(
|
|||||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
currentUserPrivateKey: String,
|
currentUserPrivateKey: String,
|
||||||
|
currentUserName: String = "",
|
||||||
totalUnreadFromOthers: Int = 0,
|
totalUnreadFromOthers: Int = 0,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
@@ -1369,10 +1370,49 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
val forwardMessages =
|
val forwardMessages =
|
||||||
selectedMsgs
|
selectedMsgs
|
||||||
.map {
|
.flatMap {
|
||||||
msg
|
msg
|
||||||
->
|
->
|
||||||
|
// Re-forward: preserve original senders
|
||||||
|
if (msg.forwardedMessages.isNotEmpty()) {
|
||||||
|
msg.forwardedMessages.map { fwd ->
|
||||||
ForwardManager
|
ForwardManager
|
||||||
|
.ForwardMessage(
|
||||||
|
messageId =
|
||||||
|
fwd.messageId,
|
||||||
|
text =
|
||||||
|
fwd.text,
|
||||||
|
timestamp =
|
||||||
|
msg.timestamp
|
||||||
|
.time,
|
||||||
|
isOutgoing =
|
||||||
|
fwd.isFromMe,
|
||||||
|
senderPublicKey =
|
||||||
|
fwd.senderPublicKey.ifEmpty {
|
||||||
|
if (fwd.isFromMe) currentUserPublicKey else user.publicKey
|
||||||
|
},
|
||||||
|
originalChatPublicKey =
|
||||||
|
user.publicKey,
|
||||||
|
senderName =
|
||||||
|
fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } },
|
||||||
|
attachments =
|
||||||
|
fwd.attachments
|
||||||
|
.filter {
|
||||||
|
it.type !=
|
||||||
|
AttachmentType
|
||||||
|
.MESSAGES
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
attachment ->
|
||||||
|
attachment.copy(
|
||||||
|
localUri =
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listOf(ForwardManager
|
||||||
.ForwardMessage(
|
.ForwardMessage(
|
||||||
messageId =
|
messageId =
|
||||||
msg.id,
|
msg.id,
|
||||||
@@ -1392,7 +1432,7 @@ fun ChatDetailScreen(
|
|||||||
originalChatPublicKey =
|
originalChatPublicKey =
|
||||||
user.publicKey,
|
user.publicKey,
|
||||||
senderName =
|
senderName =
|
||||||
if (msg.isOutgoing) "You"
|
if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
|
||||||
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
|
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
|
||||||
attachments =
|
attachments =
|
||||||
msg.attachments
|
msg.attachments
|
||||||
@@ -1408,7 +1448,8 @@ fun ChatDetailScreen(
|
|||||||
""
|
""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ForwardManager
|
ForwardManager
|
||||||
.setForwardMessages(
|
.setForwardMessages(
|
||||||
@@ -1965,6 +2006,15 @@ fun ChatDetailScreen(
|
|||||||
onImageViewerChanged(
|
onImageViewerChanged(
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onForwardedSenderClick = { senderPublicKey ->
|
||||||
|
// Open profile of the forwarded message sender
|
||||||
|
scope.launch {
|
||||||
|
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
|
||||||
|
if (resolvedUser != null) {
|
||||||
|
onUserProfileClick(resolvedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1299,7 +1299,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
val senderDisplayName = fwdSenderName.ifEmpty {
|
val senderDisplayName = fwdSenderName.ifEmpty {
|
||||||
if (fwdIsFromMe) "You"
|
if (fwdIsFromMe) "You"
|
||||||
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
else if (fwdPublicKey.isNotEmpty()) {
|
||||||
|
// 1. Try local DB (existing dialog)
|
||||||
|
val dbName = try {
|
||||||
|
val dialog = dialogDao.getDialog(account, fwdPublicKey)
|
||||||
|
dialog?.opponentTitle?.ifEmpty { dialog.opponentUsername }?.ifEmpty { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
// 2. Try ProtocolManager cache (previously resolved)
|
||||||
|
val cachedName = dbName ?: ProtocolManager.getCachedUserName(fwdPublicKey)
|
||||||
|
|
||||||
|
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
|
||||||
|
val serverName = if (cachedName == null) {
|
||||||
|
try {
|
||||||
|
ProtocolManager.resolveUserName(fwdPublicKey, 3000)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
} else null
|
||||||
|
|
||||||
|
cachedName ?: serverName ?: "User"
|
||||||
|
} else "User"
|
||||||
}
|
}
|
||||||
|
|
||||||
forwardedList.add(ReplyData(
|
forwardedList.add(ReplyData(
|
||||||
@@ -1310,7 +1328,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
isForwarded = true,
|
isForwarded = true,
|
||||||
forwardedFromName = senderDisplayName,
|
forwardedFromName = senderDisplayName,
|
||||||
attachments = fwdAttachments,
|
attachments = fwdAttachments,
|
||||||
senderPublicKey = if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "",
|
senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
|
||||||
recipientPrivateKey = myPrivateKey ?: ""
|
recipientPrivateKey = myPrivateKey ?: ""
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -1326,9 +1344,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val replyText = replyMessage.optString("message", "")
|
val replyText = replyMessage.optString("message", "")
|
||||||
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
|
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
|
||||||
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
|
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
|
||||||
val isForwarded = replyMessage.optBoolean("forwarded", false)
|
|
||||||
val senderNameFromJson = replyMessage.optString("senderName", "")
|
val senderNameFromJson = replyMessage.optString("senderName", "")
|
||||||
|
|
||||||
|
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
|
||||||
|
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
|
||||||
|
// both myPublicKey and opponentKey — it's a forwarded message from someone else
|
||||||
|
val isFromThirdParty = replyPublicKey.isNotEmpty() &&
|
||||||
|
replyPublicKey != myPublicKey &&
|
||||||
|
replyPublicKey != opponentKey
|
||||||
|
val isForwarded = replyMessage.optBoolean("forwarded", false) || isFromThirdParty
|
||||||
|
|
||||||
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
// 📸 Парсим attachments из JSON reply (как в Desktop)
|
||||||
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
val replyAttachmentsFromJson = mutableListOf<MessageAttachment>()
|
||||||
try {
|
try {
|
||||||
@@ -1410,28 +1435,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 Resolve sender name by publicKey (like desktop useUserInformation)
|
||||||
|
// Always resolve from replyPublicKey, not hardcode opponentTitle
|
||||||
|
val resolvedSenderName = if (isReplyFromMe) {
|
||||||
|
"You"
|
||||||
|
} else if (replyPublicKey == opponentKey) {
|
||||||
|
// Reply to opponent's message in this chat — use known opponent info
|
||||||
|
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
||||||
|
} else if (replyPublicKey.isNotEmpty()) {
|
||||||
|
// Third-party publicKey — resolve like desktop useUserInformation
|
||||||
|
senderNameFromJson.ifEmpty {
|
||||||
|
// 1. Try local DB (existing dialog with this user)
|
||||||
|
val dbName = try {
|
||||||
|
val fwdDialog = dialogDao.getDialog(account, replyPublicKey)
|
||||||
|
fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
// 2. Try ProtocolManager cache
|
||||||
|
val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey)
|
||||||
|
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
|
||||||
|
val serverName = if (cachedName == null) {
|
||||||
|
try {
|
||||||
|
ProtocolManager.resolveUserName(replyPublicKey, 3000)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
} else null
|
||||||
|
cachedName ?: serverName ?: "User"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
senderNameFromJson.ifEmpty { "User" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For forwarded messages, determine the forwardedFromName
|
||||||
|
val forwardFromDisplay = if (isForwarded) resolvedSenderName else ""
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = realMessageId,
|
messageId = realMessageId,
|
||||||
senderName =
|
senderName = resolvedSenderName,
|
||||||
if (isReplyFromMe) "You"
|
|
||||||
else
|
|
||||||
opponentTitle.ifEmpty {
|
|
||||||
opponentUsername.ifEmpty { "User" }
|
|
||||||
},
|
|
||||||
text = replyText,
|
text = replyText,
|
||||||
isFromMe = isReplyFromMe,
|
isFromMe = isReplyFromMe,
|
||||||
isForwarded = isForwarded,
|
isForwarded = isForwarded,
|
||||||
forwardedFromName = if (isForwarded) senderNameFromJson.ifEmpty {
|
forwardedFromName = forwardFromDisplay,
|
||||||
if (isReplyFromMe) "You"
|
|
||||||
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
|
||||||
} else "",
|
|
||||||
attachments = originalAttachments,
|
attachments = originalAttachments,
|
||||||
senderPublicKey =
|
senderPublicKey = replyPublicKey.ifEmpty {
|
||||||
if (isReplyFromMe) myPublicKey ?: ""
|
if (isReplyFromMe) myPublicKey ?: ""
|
||||||
else opponentKey ?: "",
|
else opponentKey ?: ""
|
||||||
|
},
|
||||||
recipientPrivateKey = myPrivateKey ?: ""
|
recipientPrivateKey = myPrivateKey ?: ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
|
||||||
|
// so it renders with "Forwarded from" header (like multi-forward)
|
||||||
|
if (isForwarded) {
|
||||||
|
return ParsedReplyResult(
|
||||||
|
replyData = result,
|
||||||
|
forwardedMessages = listOf(result)
|
||||||
|
)
|
||||||
|
}
|
||||||
return ParsedReplyResult(replyData = result)
|
return ParsedReplyResult(replyData = result)
|
||||||
} else {}
|
} else {}
|
||||||
} else {}
|
} else {}
|
||||||
@@ -1528,6 +1587,79 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a publicKey to a SearchUser for profile navigation.
|
||||||
|
* Tries: local DB → ProtocolManager cache → server resolve.
|
||||||
|
*/
|
||||||
|
suspend fun resolveUserForProfile(publicKey: String): SearchUser? {
|
||||||
|
if (publicKey.isEmpty()) return null
|
||||||
|
|
||||||
|
// If it's the current opponent, we already have info
|
||||||
|
if (publicKey == opponentKey) {
|
||||||
|
return SearchUser(
|
||||||
|
title = opponentTitle,
|
||||||
|
username = opponentUsername,
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = 0,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = myPublicKey ?: return null
|
||||||
|
|
||||||
|
// 1. Try local DB
|
||||||
|
try {
|
||||||
|
val dialog = dialogDao.getDialog(account, publicKey)
|
||||||
|
if (dialog != null) {
|
||||||
|
val t = dialog.opponentTitle.ifEmpty { dialog.opponentUsername }
|
||||||
|
if (t.isNotEmpty()) {
|
||||||
|
return SearchUser(
|
||||||
|
title = dialog.opponentTitle,
|
||||||
|
username = dialog.opponentUsername,
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = 0,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
// 2. Try ProtocolManager cache
|
||||||
|
val cached = ProtocolManager.getCachedUserInfo(publicKey)
|
||||||
|
if (cached != null) {
|
||||||
|
return SearchUser(
|
||||||
|
title = cached.title,
|
||||||
|
username = cached.username,
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = cached.verified,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Server resolve
|
||||||
|
try {
|
||||||
|
val resolved = ProtocolManager.resolveUserInfo(publicKey, 3000)
|
||||||
|
if (resolved != null) {
|
||||||
|
return SearchUser(
|
||||||
|
title = resolved.title,
|
||||||
|
username = resolved.username,
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = resolved.verified,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
// 4. Fallback: minimal user info
|
||||||
|
return SearchUser(
|
||||||
|
title = "User",
|
||||||
|
username = "",
|
||||||
|
publicKey = publicKey,
|
||||||
|
verified = 0,
|
||||||
|
online = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 🔥 Повторить отправку сообщения (для ошибки) */
|
/** 🔥 Повторить отправку сообщения (для ошибки) */
|
||||||
fun retryMessage(message: ChatMessage) {
|
fun retryMessage(message: ChatMessage) {
|
||||||
// Удаляем старое сообщение
|
// Удаляем старое сообщение
|
||||||
@@ -1593,26 +1725,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val replyAttachments =
|
val replyAttachments =
|
||||||
_messages.value.find { it.id == firstReply.messageId }?.attachments
|
_messages.value.find { it.id == firstReply.messageId }?.attachments
|
||||||
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
|
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
|
||||||
|
// 🔥 Use actual senderName from ForwardMessage (preserves real author)
|
||||||
|
val firstReplySenderName = if (firstReply.isOutgoing) "You"
|
||||||
|
else firstReply.senderName.ifEmpty {
|
||||||
|
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
||||||
|
}
|
||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = firstReply.messageId,
|
messageId = firstReply.messageId,
|
||||||
senderName =
|
senderName = firstReplySenderName,
|
||||||
if (firstReply.isOutgoing) "You"
|
|
||||||
else
|
|
||||||
opponentTitle.ifEmpty {
|
|
||||||
opponentUsername.ifEmpty { "User" }
|
|
||||||
},
|
|
||||||
text = firstReply.text,
|
text = firstReply.text,
|
||||||
isFromMe = firstReply.isOutgoing,
|
isFromMe = firstReply.isOutgoing,
|
||||||
isForwarded = isForward,
|
isForwarded = isForward,
|
||||||
forwardedFromName =
|
forwardedFromName =
|
||||||
if (isForward) firstReply.senderName.ifEmpty {
|
if (isForward) firstReplySenderName else "",
|
||||||
if (firstReply.isOutgoing) "You"
|
|
||||||
else opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
|
|
||||||
} else "",
|
|
||||||
attachments = replyAttachments,
|
attachments = replyAttachments,
|
||||||
senderPublicKey =
|
senderPublicKey = firstReply.publicKey.ifEmpty {
|
||||||
if (firstReply.isOutgoing) myPublicKey ?: ""
|
if (firstReply.isOutgoing) myPublicKey ?: ""
|
||||||
else opponentKey ?: "",
|
else opponentKey ?: ""
|
||||||
|
},
|
||||||
recipientPrivateKey = myPrivateKey ?: ""
|
recipientPrivateKey = myPrivateKey ?: ""
|
||||||
)
|
)
|
||||||
} else null
|
} else null
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ fun MessageBubble(
|
|||||||
onReplyClick: (String) -> Unit = {},
|
onReplyClick: (String) -> Unit = {},
|
||||||
onRetry: () -> Unit = {},
|
onRetry: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// Swipe-to-reply state
|
// Swipe-to-reply state
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
@@ -649,7 +650,8 @@ fun MessageBubble(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick,
|
||||||
|
onForwardedSenderClick = onForwardedSenderClick
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -662,7 +664,8 @@ fun MessageBubble(
|
|||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
onClick = { onReplyClick(reply.messageId) },
|
onClick = { onReplyClick(reply.messageId) },
|
||||||
onImageClick = onImageClick
|
onImageClick = onImageClick,
|
||||||
|
onForwardedSenderClick = onForwardedSenderClick
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -1095,7 +1098,8 @@ fun ReplyBubble(
|
|||||||
chachaKey: String = "",
|
chachaKey: String = "",
|
||||||
privateKey: String = "",
|
privateKey: String = "",
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
@@ -1220,32 +1224,24 @@ fun ReplyBubble(
|
|||||||
imageAttachment.id, downloadTag
|
imageAttachment.id, downloadTag
|
||||||
)
|
)
|
||||||
if (encryptedContent.isNotEmpty()) {
|
if (encryptedContent.isNotEmpty()) {
|
||||||
// Расшифровываем: нужен chachaKey сообщения-контейнера
|
// Desktop: decryptKeyFromSender → decodeWithPassword
|
||||||
val keyToUse = chachaKey.ifEmpty { replyData.recipientPrivateKey }
|
|
||||||
val privKey = privateKey.ifEmpty { replyData.recipientPrivateKey }
|
|
||||||
var decrypted: String? = null
|
var decrypted: String? = null
|
||||||
|
|
||||||
// Способ 1: chachaKey + privateKey → ECDH → decrypt
|
if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
|
||||||
if (chachaKey.isNotEmpty() && privKey.isNotEmpty()) {
|
|
||||||
try {
|
try {
|
||||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
||||||
chachaKey, privKey
|
chachaKey, privateKey
|
||||||
)
|
)
|
||||||
|
// decryptReplyBlob = desktop decodeWithPassword
|
||||||
|
decrypted = try {
|
||||||
|
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||||
|
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
if (decrypted == null) {
|
||||||
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||||
encryptedContent, plainKeyAndNonce
|
encryptedContent, plainKeyAndNonce
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Способ 2: senderPublicKey + recipientPrivateKey
|
|
||||||
if (decrypted == null && replyData.senderPublicKey.isNotEmpty() && replyData.recipientPrivateKey.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
|
||||||
replyData.senderPublicKey, replyData.recipientPrivateKey
|
|
||||||
)
|
|
||||||
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
|
||||||
encryptedContent, plainKeyAndNonce
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,7 +1292,12 @@ fun ReplyBubble(
|
|||||||
) {
|
) {
|
||||||
// Заголовок (имя отправителя / Forwarded from)
|
// Заголовок (имя отправителя / Forwarded from)
|
||||||
if (replyData.isForwarded && replyData.forwardedFromName.isNotEmpty()) {
|
if (replyData.isForwarded && replyData.forwardedFromName.isNotEmpty()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable(enabled = replyData.senderPublicKey.isNotEmpty()) {
|
||||||
|
onForwardedSenderClick(replyData.senderPublicKey)
|
||||||
|
}
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Forwarded from ",
|
text = "Forwarded from ",
|
||||||
color = nameColor,
|
color = nameColor,
|
||||||
@@ -1431,7 +1432,8 @@ fun ForwardedMessagesBubble(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
chachaKey: String = "",
|
chachaKey: String = "",
|
||||||
privateKey: String = "",
|
privateKey: String = "",
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
||||||
@@ -1488,8 +1490,13 @@ fun ForwardedMessagesBubble(
|
|||||||
senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue
|
senderColorMap[fwd.forwardedFromName] ?: PrimaryBlue
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Forwarded from [Name]"
|
// "Forwarded from [Name]" — clickable to open profile
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable(enabled = fwd.senderPublicKey.isNotEmpty()) {
|
||||||
|
onForwardedSenderClick(fwd.senderPublicKey)
|
||||||
|
}
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Forwarded from ",
|
text = "Forwarded from ",
|
||||||
color = nameColor.copy(alpha = 0.7f),
|
color = nameColor.copy(alpha = 0.7f),
|
||||||
@@ -1508,22 +1515,7 @@ fun ForwardedMessagesBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message text
|
// Attachments (images) — before text (text acts as caption)
|
||||||
if (fwd.text.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
val textColor = if (isOutgoing) Color.White.copy(alpha = 0.9f)
|
|
||||||
else if (isDarkTheme) Color.White else Color.Black
|
|
||||||
AppleEmojiText(
|
|
||||||
text = fwd.text,
|
|
||||||
color = textColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
maxLines = 50,
|
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
|
||||||
enableLinks = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments (images)
|
|
||||||
val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE }
|
val imageAttachments = fwd.attachments.filter { it.type == AttachmentType.IMAGE }
|
||||||
if (imageAttachments.isNotEmpty()) {
|
if (imageAttachments.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@@ -1538,13 +1530,28 @@ fun ForwardedMessagesBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message text (below image, like a caption)
|
||||||
|
if (fwd.text.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
val textColor = if (isOutgoing) Color.White.copy(alpha = 0.9f)
|
||||||
|
else if (isDarkTheme) Color.White else Color.Black
|
||||||
|
AppleEmojiText(
|
||||||
|
text = fwd.text,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 50,
|
||||||
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
|
enableLinks = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Image preview inside a forwarded message */
|
/** Image preview inside a forwarded message — decrypts exactly like desktop */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ForwardedImagePreview(
|
private fun ForwardedImagePreview(
|
||||||
attachment: MessageAttachment,
|
attachment: MessageAttachment,
|
||||||
@@ -1555,49 +1562,87 @@ private fun ForwardedImagePreview(
|
|||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
val cacheKey = "img_${attachment.id}"
|
||||||
var blurPreviewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
|
|
||||||
// Load blur preview first
|
var imageBitmap by remember(attachment.id) {
|
||||||
|
mutableStateOf(ImageBitmapCache.get(cacheKey))
|
||||||
|
}
|
||||||
|
var blurPreviewBitmap by remember(attachment.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
|
||||||
|
// Extract CDN tag and blurhash from preview (format: "UUID::blurhash")
|
||||||
|
val downloadTag = remember(attachment.preview) { getDownloadTag(attachment.preview) }
|
||||||
|
val blurhashPreview = remember(attachment.preview) { getPreview(attachment.preview) }
|
||||||
|
|
||||||
|
// Load blur preview
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
if (attachment.preview.isNotEmpty()) {
|
if (blurhashPreview.isNotEmpty()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val bitmap = BlurHash.decode(attachment.preview, 32, 32)
|
val bitmap = BlurHash.decode(blurhashPreview, 32, 32)
|
||||||
if (bitmap != null) blurPreviewBitmap = bitmap
|
if (bitmap != null) blurPreviewBitmap = bitmap
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt and load full image
|
// Main image loading — Desktop pipeline:
|
||||||
|
// 1. decryptKeyFromSender(chachaKey, privateKey) → plainKeyAndNonce
|
||||||
|
// 2. password = plainKeyAndNonce.toString('utf-8') (bytesToJsUtf8String)
|
||||||
|
// 3. decodeWithPassword(password, encryptedBlob) = PBKDF2 + AES-CBC + inflate
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
|
// Skip if already loaded
|
||||||
|
val cached = ImageBitmapCache.get(cacheKey)
|
||||||
|
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
|
// Try local file cache first
|
||||||
try {
|
try {
|
||||||
// Try loading from file cache first
|
val localBlob = AttachmentFileManager.readAttachment(
|
||||||
val cached = AttachmentFileManager.readAttachment(
|
|
||||||
context, attachment.id, senderPublicKey, recipientPrivateKey
|
context, attachment.id, senderPublicKey, recipientPrivateKey
|
||||||
)
|
)
|
||||||
if (cached != null) {
|
if (localBlob != null) {
|
||||||
val bytes = Base64.decode(cached, Base64.DEFAULT)
|
val bitmap = base64ToBitmap(localBlob)
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let {
|
if (bitmap != null) {
|
||||||
imageBitmap = it
|
imageBitmap = bitmap
|
||||||
|
ImageBitmapCache.put(cacheKey, bitmap)
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
// Try decrypting from blob
|
// CDN download — exactly like desktop useAttachment.ts
|
||||||
if (attachment.blob.isNotEmpty()) {
|
if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
|
||||||
val keyToUse = chachaKey.ifEmpty { recipientPrivateKey }
|
try {
|
||||||
val privKey = privateKey.ifEmpty { recipientPrivateKey }
|
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||||
val decrypted = MessageCrypto.decryptAttachmentBlob(
|
if (encryptedContent.isNotEmpty()) {
|
||||||
attachment.blob, keyToUse, privKey
|
// Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword
|
||||||
)
|
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
if (decrypted != null) {
|
// decryptReplyBlob = exact same as desktop decodeWithPassword:
|
||||||
val bytes = Base64.decode(decrypted, Base64.DEFAULT)
|
// bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let {
|
val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||||
imageBitmap = it
|
if (decrypted.isNotEmpty() && decrypted != encryptedContent) {
|
||||||
ImageBitmapCache.put("img_${attachment.id}", it)
|
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
|
||||||
|
val bitmap = base64ToBitmap(base64Data)
|
||||||
|
if (bitmap != null) {
|
||||||
|
imageBitmap = bitmap
|
||||||
|
ImageBitmapCache.put(cacheKey, bitmap)
|
||||||
AttachmentFileManager.saveAttachment(
|
AttachmentFileManager.saveAttachment(
|
||||||
context, decrypted, attachment.id,
|
context, base64Data, attachment.id,
|
||||||
|
senderPublicKey, recipientPrivateKey
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: try decryptAttachmentBlobWithPlainKey (same logic, different entry point)
|
||||||
|
val decrypted2 = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKeyAndNonce)
|
||||||
|
if (decrypted2 != null) {
|
||||||
|
val base64Data = if (decrypted2.contains(",")) decrypted2.substringAfter(",") else decrypted2
|
||||||
|
val bitmap = base64ToBitmap(base64Data)
|
||||||
|
if (bitmap != null) {
|
||||||
|
imageBitmap = bitmap
|
||||||
|
ImageBitmapCache.put(cacheKey, bitmap)
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context, base64Data, attachment.id,
|
||||||
senderPublicKey, recipientPrivateKey
|
senderPublicKey, recipientPrivateKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1607,6 +1652,15 @@ private fun ForwardedImagePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry from cache (another composable may have loaded it)
|
||||||
|
if (imageBitmap == null) {
|
||||||
|
repeat(5) {
|
||||||
|
kotlinx.coroutines.delay(400)
|
||||||
|
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
|
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
|
||||||
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
|
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
|
||||||
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)
|
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ fun OtherProfileScreen(
|
|||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
currentUserPrivateKey: String = "",
|
currentUserPrivateKey: String = "",
|
||||||
backgroundBlurColorId: String = "avatar"
|
backgroundBlurColorId: String = "avatar",
|
||||||
|
onWriteMessage: (SearchUser) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isBlocked by remember { mutableStateOf(false) }
|
var isBlocked by remember { mutableStateOf(false) }
|
||||||
var showAvatarMenu by remember { mutableStateOf(false) }
|
var showAvatarMenu by remember { mutableStateOf(false) }
|
||||||
@@ -541,6 +542,47 @@ fun OtherProfileScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// ✉️ WRITE MESSAGE BUTTON
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { onWriteMessage(user) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryBlue,
|
||||||
|
contentColor = Color.White
|
||||||
|
),
|
||||||
|
elevation = ButtonDefaults.buttonElevation(
|
||||||
|
defaultElevation = 0.dp,
|
||||||
|
pressedElevation = 0.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
TablerIcons.MessageCircle2,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Write Message",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 📚 SHARED CONTENT
|
// 📚 SHARED CONTENT
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user