Refactor image handling and decoding logic
- Introduced a maximum bitmap decode dimension to prevent excessive memory usage. - Enhanced base64 to bitmap conversion by extracting payload and applying EXIF orientation. - Improved error handling for image downloads and decoding processes. - Simplified media picker and chat input components to manage keyboard visibility more effectively. - Updated color selection grid to adaptively adjust based on available width. - Added safety checks for notifications and call actions in profile screens. - Optimized bitmap decoding in uriToBase64Image to handle large images more efficiently.
This commit is contained in:
@@ -247,6 +247,28 @@ fun ChatDetailScreen(
|
||||
// Триггер для возврата фокуса на инпут после отправки фото из редактора
|
||||
var inputFocusTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// 📷 При входе в экран камеры гарантированно закрываем IME и снимаем фокус с инпута.
|
||||
// Это защищает от кейсов, когда keyboardController не успевает из-за анимаций overlay.
|
||||
LaunchedEffect(showInAppCamera) {
|
||||
if (showInAppCamera) {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
repeat(8) {
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
window?.let { win ->
|
||||
androidx.core.view.WindowCompat.getInsetsController(win, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
view.findFocus()?.clearFocus()
|
||||
(context as? Activity)?.currentFocus?.clearFocus()
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
showEmojiPicker = false
|
||||
delay(40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Блокируем swipe-back родительского экрана, пока открыт fullscreen/media overlay.
|
||||
val shouldLockParentSwipeBack by
|
||||
remember(
|
||||
@@ -503,7 +525,10 @@ fun ChatDetailScreen(
|
||||
// ВАЖНО: Используем maxByOrNull по timestamp, НЕ firstOrNull()!
|
||||
// При пагинации старые сообщения добавляются в начало списка,
|
||||
// поэтому firstOrNull() возвращает старое сообщение, а не новое.
|
||||
val newestMessageId = messages.maxByOrNull { it.timestamp.time }?.id
|
||||
val newestMessageId =
|
||||
messages
|
||||
.maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id }))
|
||||
?.id
|
||||
var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||
@@ -640,9 +665,12 @@ fun ChatDetailScreen(
|
||||
it.id
|
||||
)
|
||||
}
|
||||
.sortedBy {
|
||||
it.timestamp
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<ChatMessage>(
|
||||
{ it.timestamp.time },
|
||||
{ it.id }
|
||||
)
|
||||
)
|
||||
.joinToString(
|
||||
"\n\n"
|
||||
) {
|
||||
@@ -1297,9 +1325,12 @@ fun ChatDetailScreen(
|
||||
it.id
|
||||
)
|
||||
}
|
||||
.sortedBy {
|
||||
it.timestamp
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<ChatMessage>(
|
||||
{ it.timestamp.time },
|
||||
{ it.id }
|
||||
)
|
||||
)
|
||||
viewModel
|
||||
.setReplyMessages(
|
||||
selectedMsgs
|
||||
@@ -1380,9 +1411,12 @@ fun ChatDetailScreen(
|
||||
it.id
|
||||
)
|
||||
}
|
||||
.sortedBy {
|
||||
it.timestamp
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<ChatMessage>(
|
||||
{ it.timestamp.time },
|
||||
{ it.id }
|
||||
)
|
||||
)
|
||||
|
||||
val forwardMessages =
|
||||
selectedMsgs
|
||||
@@ -1604,7 +1638,9 @@ fun ChatDetailScreen(
|
||||
myPrivateKey =
|
||||
currentUserPrivateKey,
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger
|
||||
inputFocusTrigger,
|
||||
suppressKeyboard =
|
||||
showInAppCamera
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2086,8 +2122,19 @@ fun ChatDetailScreen(
|
||||
},
|
||||
onOpenCamera = {
|
||||
// 📷 Открываем встроенную камеру (без системного превью!)
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
window?.let { win ->
|
||||
androidx.core.view.WindowCompat.getInsetsController(win, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
view.findFocus()?.clearFocus()
|
||||
(context as? Activity)?.currentFocus?.clearFocus()
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
focusManager.clearFocus(force = true)
|
||||
showEmojiPicker = false
|
||||
showMediaPicker = false
|
||||
showInAppCamera = true
|
||||
},
|
||||
onOpenFilePicker = {
|
||||
|
||||
@@ -42,6 +42,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val chatMessageAscComparator =
|
||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
||||
private val chatMessageDescComparator =
|
||||
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }
|
||||
|
||||
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
|
||||
messages.sortedWith(chatMessageAscComparator)
|
||||
|
||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
|
||||
// Ключ включает account для изоляции данных между аккаунтами
|
||||
@@ -55,14 +62,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* сообщений для предотвращения OOM
|
||||
*/
|
||||
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
|
||||
val orderedMessages = sortMessagesAsc(messages)
|
||||
val limitedMessages =
|
||||
if (messages.size > MAX_CACHE_SIZE) {
|
||||
// Оставляем только последние сообщения (по timestamp)
|
||||
messages.sortedByDescending { it.timestamp }.take(MAX_CACHE_SIZE).sortedBy {
|
||||
it.timestamp
|
||||
}
|
||||
if (orderedMessages.size > MAX_CACHE_SIZE) {
|
||||
// Оставляем только последние сообщения в детерминированном порядке.
|
||||
orderedMessages.takeLast(MAX_CACHE_SIZE)
|
||||
} else {
|
||||
messages
|
||||
orderedMessages
|
||||
}
|
||||
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
|
||||
}
|
||||
@@ -130,7 +136,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.mapLatest { rawMessages ->
|
||||
withContext(Dispatchers.Default) {
|
||||
val unique = rawMessages.distinctBy { it.id }
|
||||
val sorted = unique.sortedByDescending { it.timestamp.time }
|
||||
val sorted = unique.sortedWith(chatMessageDescComparator)
|
||||
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size)
|
||||
var prevDateStr: String? = null
|
||||
for (i in sorted.indices) {
|
||||
@@ -205,6 +211,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Флаг что read receipt уже отправлен для текущего диалога
|
||||
private var readReceiptSentForCurrentDialog = false
|
||||
|
||||
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
|
||||
messages.sortedWith(chatMessageAscComparator)
|
||||
|
||||
private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
|
||||
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
|
||||
|
||||
// 🔥 Флаг что диалог АКТИВЕН (пользователь внутри чата, а не на главной)
|
||||
// Как currentDialogPublicKeyView в архиве
|
||||
private var isDialogActive = false
|
||||
@@ -321,7 +333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Добавляем все сразу
|
||||
kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
|
||||
val currentList = _messages.value
|
||||
val newList = (currentList + newMessages).distinctBy { it.id }.sortedBy { it.timestamp }
|
||||
val newList = sortMessagesAscending((currentList + newMessages).distinctBy { it.id })
|
||||
_messages.value = newList
|
||||
}
|
||||
|
||||
@@ -712,7 +724,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val newList = messages + optimisticMessages
|
||||
|
||||
// 🔍 Финальная дедупликация по ID (на всякий случай)
|
||||
val deduplicatedList = newList.distinctBy { it.id }
|
||||
val deduplicatedList =
|
||||
sortMessagesAscending(newList.distinctBy { it.id })
|
||||
|
||||
if (deduplicatedList.size != newList.size) {}
|
||||
|
||||
@@ -736,7 +749,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Отправляем read receipt собеседнику (НЕ для saved messages!)
|
||||
if (!isSavedMessages && messages.isNotEmpty()) {
|
||||
val lastIncoming = messages.lastOrNull { !it.isOutgoing }
|
||||
val lastIncoming = latestIncomingMessage(messages)
|
||||
if (lastIncoming != null &&
|
||||
lastIncoming.timestamp.time >
|
||||
lastReadMessageTimestamp
|
||||
@@ -794,7 +807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
|
||||
// Сортируем по timestamp чтобы новые были в конце
|
||||
val updatedMessages = (currentMessages + newMessages).distinctBy { it.id }.sortedBy { it.timestamp }
|
||||
val updatedMessages =
|
||||
sortMessagesAscending((currentMessages + newMessages).distinctBy { it.id })
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
|
||||
// Объединяем существующий кэш с новыми сообщениями
|
||||
@@ -805,7 +819,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateCacheWithLimit(
|
||||
account,
|
||||
dialogKey,
|
||||
(existingCache + trulyNewMessages).sortedBy { it.timestamp }
|
||||
sortMessagesAscending(existingCache + trulyNewMessages)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -900,13 +914,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateCacheWithLimit(
|
||||
account,
|
||||
dialogKey,
|
||||
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
|
||||
sortMessagesAscending(trulyNewMessages + existingCache)
|
||||
)
|
||||
}
|
||||
|
||||
// Добавляем в начало списка (старые сообщения)
|
||||
withContext(Dispatchers.Main) {
|
||||
_messages.value = (newMessages + _messages.value).distinctBy { it.id }
|
||||
_messages.value =
|
||||
sortMessagesAscending(
|
||||
(newMessages + _messages.value).distinctBy { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,19 +975,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Fallback на расшифровку plainMessage с приватным ключом
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
val result =
|
||||
val decrypted =
|
||||
CryptoManager.decryptWithPassword(
|
||||
entity.plainMessage,
|
||||
privateKey
|
||||
)
|
||||
?: entity.plainMessage
|
||||
decryptionCache[entity.messageId] = result
|
||||
result
|
||||
if (decrypted != null) {
|
||||
decryptionCache[entity.messageId] = decrypted
|
||||
decrypted
|
||||
} else {
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
entity.plainMessage
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -978,16 +998,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(
|
||||
val decrypted = CryptoManager.decryptWithPassword(
|
||||
entity.plainMessage,
|
||||
privateKey
|
||||
)
|
||||
?: entity.plainMessage
|
||||
if (decrypted != null) {
|
||||
decryptionCache[entity.messageId] = decrypted
|
||||
decrypted
|
||||
} else {
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
entity.plainMessage
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
safePlainMessageFallback(entity.plainMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,6 +1061,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Никогда не показываем в UI сырые шифротексты (`CHNK:`/`iv:ciphertext`) как текст сообщения.
|
||||
* Это предотвращает появление "ключа" в подписи медиа при сбоях дешифровки.
|
||||
*/
|
||||
private fun safePlainMessageFallback(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
return if (isProbablyEncryptedPayload(raw)) "" else raw
|
||||
}
|
||||
|
||||
private fun isProbablyEncryptedPayload(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.startsWith("CHNK:")) return true
|
||||
|
||||
val parts = trimmed.split(":")
|
||||
if (parts.size != 2) return false
|
||||
return parts.all { part ->
|
||||
part.length >= 16 && part.all { ch ->
|
||||
ch.isLetterOrDigit() || ch == '+' || ch == '/' || ch == '='
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для
|
||||
* IMAGE - загружает blob из файловой системы если пустой в БД
|
||||
@@ -1991,8 +2038,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered =
|
||||
if (isSavedMessages) 2
|
||||
else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0)
|
||||
if (isSavedMessages) 1
|
||||
else 0, // 📁 Saved Messages: сразу DELIVERED (1), иначе SENDING (0)
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
@@ -2162,7 +2209,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 2 else 0,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
@@ -2459,9 +2506,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val finalAttachmentsJson = attachmentsJson // Уже без localUri
|
||||
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson)
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
||||
} else {
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson)
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -2628,14 +2675,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных
|
||||
delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
|
||||
attachmentsJson = attachmentsJson,
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
|
||||
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI
|
||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 2) // SENT
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
@@ -2866,14 +2913,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 2 else 0,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJsonArray.toString(),
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
|
||||
// 🔥 Обновляем статус в БД после отправки
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 2) // SENT
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
}
|
||||
|
||||
// Обновляем UI
|
||||
@@ -3023,14 +3070,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered =
|
||||
if (isSavedMessages) 2
|
||||
else 0, // SENDING для обычных, SENT для saved
|
||||
if (isSavedMessages) 1
|
||||
else 0, // SENDING для обычных, DELIVERED для saved
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI
|
||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 2) // SENT
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
@@ -3241,13 +3288,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
encryptedKey = encryptedKey,
|
||||
timestamp = timestamp,
|
||||
isFromMe = true,
|
||||
delivered = if (isSavedMessages) 2 else 0, // Как в sendImageMessage
|
||||
delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
// 🔥 Обновляем статус в БД после отправки
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 2) // SENT
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
}
|
||||
|
||||
// Обновляем UI
|
||||
@@ -3481,7 +3528,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val privateKey = myPrivateKey ?: return
|
||||
|
||||
// Обновляем timestamp последнего прочитанного
|
||||
val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing }
|
||||
val lastIncoming = latestIncomingMessage(_messages.value)
|
||||
if (lastIncoming != null) {
|
||||
lastReadMessageTimestamp = lastIncoming.timestamp.time
|
||||
}
|
||||
@@ -3518,7 +3565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val account = myPublicKey ?: return
|
||||
|
||||
// Находим последнее входящее сообщение
|
||||
val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing }
|
||||
val lastIncoming = latestIncomingMessage(_messages.value)
|
||||
if (lastIncoming == null) return
|
||||
|
||||
// Если timestamp не изменился - не отправляем повторно
|
||||
|
||||
@@ -262,6 +262,7 @@ fun ChatsListScreen(
|
||||
|
||||
// Protocol connection state
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||
|
||||
// 🔥 Пользователи, которые сейчас печатают
|
||||
@@ -291,6 +292,30 @@ fun ChatsListScreen(
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
||||
val inlineRequestsTransitionLockMs = 340L
|
||||
val requestsRouteTapLockMs = 420L
|
||||
|
||||
fun setInlineRequestsVisible(visible: Boolean) {
|
||||
if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return
|
||||
isInlineRequestsTransitionLocked = true
|
||||
showRequestsScreen = visible
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(inlineRequestsTransitionLockMs)
|
||||
isInlineRequestsTransitionLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
fun openRequestsRouteSafely() {
|
||||
if (isRequestsRouteTapLocked) return
|
||||
isRequestsRouteTapLocked = true
|
||||
onRequestsClick()
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(requestsRouteTapLockMs)
|
||||
isRequestsRouteTapLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
// 📂 Accounts section expanded state (arrow toggle)
|
||||
var accountsSectionExpanded by remember { mutableStateOf(false) }
|
||||
@@ -542,9 +567,18 @@ fun ChatsListScreen(
|
||||
top = 16.dp,
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
bottom = 20.dp
|
||||
bottom = 12.dp
|
||||
)
|
||||
) {
|
||||
val isRosettaOfficial =
|
||||
accountName.equals(
|
||||
"Rosetta",
|
||||
ignoreCase = true
|
||||
) ||
|
||||
accountUsername.equals(
|
||||
"rosetta",
|
||||
ignoreCase = true
|
||||
)
|
||||
// Avatar row with theme toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -612,12 +646,28 @@ fun ChatsListScreen(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Display name
|
||||
if (accountName.isNotEmpty()) {
|
||||
Text(
|
||||
text = accountName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = accountName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (isRosettaOfficial) {
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(
|
||||
6.dp
|
||||
)
|
||||
)
|
||||
VerifiedBadge(
|
||||
verified = 1,
|
||||
size = 15
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Username
|
||||
@@ -841,7 +891,7 @@ fun ChatsListScreen(
|
||||
drawerState.close()
|
||||
kotlinx.coroutines
|
||||
.delay(100)
|
||||
showRequestsScreen = true
|
||||
setInlineRequestsVisible(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1082,8 +1132,9 @@ fun ChatsListScreen(
|
||||
if (showRequestsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showRequestsScreen =
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
@@ -1172,7 +1223,21 @@ fun ChatsListScreen(
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||
if (protocolState != ProtocolState.AUTHENTICATED) {
|
||||
AnimatedDotsText(
|
||||
baseText = "Connecting",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
} else if (syncInProgress) {
|
||||
AnimatedDotsText(
|
||||
baseText = "Synchronizing",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Rosetta",
|
||||
fontWeight =
|
||||
@@ -1183,13 +1248,6 @@ fun ChatsListScreen(
|
||||
color =
|
||||
Color.White
|
||||
)
|
||||
} else {
|
||||
AnimatedDotsText(
|
||||
baseText = "Connecting",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1373,8 +1431,10 @@ fun ChatsListScreen(
|
||||
if (claimed) {
|
||||
val velocityX = velocityTracker.calculateVelocity().x
|
||||
val screenWidth = size.width.toFloat()
|
||||
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
|
||||
showRequestsScreen = false
|
||||
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1384,7 +1444,9 @@ fun ChatsListScreen(
|
||||
requests = requests,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = {
|
||||
showRequestsScreen = false
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
},
|
||||
onRequestClick = { request ->
|
||||
val user =
|
||||
@@ -1590,7 +1652,7 @@ fun ChatsListScreen(
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
onClick = {
|
||||
onRequestsClick()
|
||||
openRequestsRouteSafely()
|
||||
}
|
||||
)
|
||||
Divider(
|
||||
@@ -1927,9 +1989,6 @@ private fun DeviceResolveDialog(
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val isAccept = action == DeviceResolveAction.ACCEPT
|
||||
val confirmColor = if (isAccept) PrimaryBlue else Color(0xFFFF3B30)
|
||||
val accentBg =
|
||||
if (isDarkTheme) confirmColor.copy(alpha = 0.18f)
|
||||
else confirmColor.copy(alpha = 0.12f)
|
||||
|
||||
val composition by rememberLottieComposition(
|
||||
LottieCompositionSpec.RawRes(
|
||||
@@ -1967,9 +2026,7 @@ private fun DeviceResolveDialog(
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(96.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(accentBg),
|
||||
Modifier.size(96.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
|
||||
@@ -72,6 +72,8 @@ import java.io.File
|
||||
import kotlin.math.min
|
||||
|
||||
private const val TAG = "AttachmentComponents"
|
||||
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
|
||||
private val whitespaceRegex = "\\s+".toRegex()
|
||||
|
||||
private fun shortDebugId(value: String): String {
|
||||
if (value.isBlank()) return "empty"
|
||||
@@ -907,6 +909,8 @@ fun ImageAttachment(
|
||||
imageBitmap = bitmap
|
||||
// 🔥 Сохраняем в глобальный кэш
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} else if (attachment.localUri.isEmpty()) {
|
||||
// Только если нет localUri - помечаем как NOT_DOWNLOADED
|
||||
@@ -914,6 +918,15 @@ fun ImageAttachment(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
downloadStatus =
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
DownloadStatus.NOT_DOWNLOADED
|
||||
} else {
|
||||
DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,24 +971,34 @@ fun ImageAttachment(
|
||||
downloadProgress = 0.8f
|
||||
|
||||
if (decrypted != null) {
|
||||
var decodedBitmap: Bitmap? = null
|
||||
var saved = false
|
||||
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
|
||||
withContext(Dispatchers.IO) {
|
||||
imageBitmap = base64ToBitmap(decrypted)
|
||||
decodedBitmap = base64ToBitmap(decrypted)
|
||||
if (decodedBitmap != null) {
|
||||
imageBitmap = decodedBitmap
|
||||
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
|
||||
|
||||
// 💾 Сохраняем в файловую систему (как в Desktop)
|
||||
val saved =
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
logPhotoDebug("Cache save result: id=$idShort, saved=$saved")
|
||||
// 💾 Сохраняем в файловую систему (как в Desktop)
|
||||
saved =
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
if (decodedBitmap != null) {
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
logPhotoDebug("Image ready: id=$idShort, saved=$saved")
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
logPhotoDebug("Image decode FAILED: id=$idShort")
|
||||
}
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
logPhotoDebug("Image ready: id=$idShort")
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
|
||||
@@ -2117,62 +2140,112 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
|
||||
/** Декодирование base64 в Bitmap */
|
||||
internal fun base64ToBitmap(base64: String): Bitmap? {
|
||||
return try {
|
||||
val cleanBase64 =
|
||||
if (base64.contains(",")) {
|
||||
base64.substringAfter(",")
|
||||
} else {
|
||||
base64
|
||||
}
|
||||
val bytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||
val payload = extractBase64Payload(base64) ?: return null
|
||||
val bytes = Base64.decode(payload, Base64.DEFAULT)
|
||||
if (bytes.isEmpty()) return null
|
||||
|
||||
val decoded = decodeBitmapWithSampling(bytes) ?: return null
|
||||
|
||||
val orientation =
|
||||
ByteArrayInputStream(bytes).use { stream ->
|
||||
ExifInterface(stream)
|
||||
.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
}
|
||||
runCatching {
|
||||
ByteArrayInputStream(bytes).use { stream ->
|
||||
ExifInterface(stream)
|
||||
.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
|
||||
if (orientation != ExifInterface.ORIENTATION_NORMAL &&
|
||||
orientation != ExifInterface.ORIENTATION_UNDEFINED) {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(270f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val rotated =
|
||||
Bitmap.createBitmap(
|
||||
bitmap,
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
matrix,
|
||||
true
|
||||
)
|
||||
if (rotated != bitmap) {
|
||||
bitmap.recycle()
|
||||
bitmap = rotated
|
||||
}
|
||||
}
|
||||
|
||||
bitmap
|
||||
applyExifOrientation(decoded, orientation)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} catch (e: OutOfMemoryError) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractBase64Payload(value: String): String? {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val payload =
|
||||
when {
|
||||
trimmed.startsWith("data:", ignoreCase = true) &&
|
||||
trimmed.contains("base64,", ignoreCase = true) -> {
|
||||
trimmed.substringAfter("base64,", "")
|
||||
}
|
||||
trimmed.contains(",") &&
|
||||
trimmed.substringBefore(",").contains("base64", ignoreCase = true) -> {
|
||||
trimmed.substringAfter(",", "")
|
||||
}
|
||||
else -> trimmed
|
||||
}
|
||||
|
||||
val clean = payload.replace(whitespaceRegex, "")
|
||||
return clean.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun decodeBitmapWithSampling(bytes: ByteArray): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
var sampleSize = 1
|
||||
while ((bounds.outWidth / sampleSize) > MAX_BITMAP_DECODE_DIMENSION ||
|
||||
(bounds.outHeight / sampleSize) > MAX_BITMAP_DECODE_DIMENSION) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
|
||||
repeat(4) {
|
||||
val options =
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
val bitmap = runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) }.getOrNull()
|
||||
if (bitmap != null) return bitmap
|
||||
sampleSize *= 2
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||
if (orientation == ExifInterface.ORIENTATION_NORMAL ||
|
||||
orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.postRotate(90f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.postRotate(270f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
else -> return bitmap
|
||||
}
|
||||
|
||||
return try {
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
if (rotated != bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
rotated
|
||||
} catch (_: Exception) {
|
||||
bitmap
|
||||
} catch (_: OutOfMemoryError) {
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2211,22 +2284,27 @@ internal suspend fun downloadAndDecryptImage(
|
||||
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
|
||||
)
|
||||
|
||||
// Try decryptReplyBlob first (desktop decodeWithPassword)
|
||||
var decrypted = try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) { null }
|
||||
// Primary path for image attachments
|
||||
var decrypted =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent,
|
||||
plainKeyAndNonce
|
||||
)
|
||||
|
||||
// Fallback: decryptAttachmentBlobWithPlainKey
|
||||
if (decrypted == null) {
|
||||
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent, plainKeyAndNonce
|
||||
)
|
||||
// Fallback for legacy payloads
|
||||
if (decrypted.isNullOrEmpty()) {
|
||||
decrypted =
|
||||
try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (decrypted == null) return@withContext null
|
||||
if (decrypted.isNullOrEmpty()) return@withContext null
|
||||
|
||||
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
|
||||
val base64Data = extractBase64Payload(decrypted) ?: return@withContext null
|
||||
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
|
||||
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -1126,15 +1125,18 @@ fun AnimatedMessageStatus(
|
||||
)
|
||||
|
||||
var showErrorMenu by remember { mutableStateOf(false) }
|
||||
val iconSize = with(LocalDensity.current) { 14.sp.toDp() }
|
||||
val statusSlotWidth = iconSize + 6.dp
|
||||
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier.width(statusSlotWidth).height(iconSize),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = effectiveStatus,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
val iconSize = with(LocalDensity.current) { 14.sp.toDp() }
|
||||
|
||||
if (currentStatus == MessageStatus.ERROR) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.AlertCircle,
|
||||
@@ -1142,6 +1144,7 @@ fun AnimatedMessageStatus(
|
||||
tint = animatedColor,
|
||||
modifier =
|
||||
Modifier.size(iconSize)
|
||||
.align(Alignment.CenterStart)
|
||||
.scale(scale)
|
||||
.clickable {
|
||||
showErrorMenu = true
|
||||
@@ -1151,7 +1154,7 @@ fun AnimatedMessageStatus(
|
||||
if (currentStatus == MessageStatus.READ) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(iconSize + 6.dp)
|
||||
Modifier.width(statusSlotWidth)
|
||||
.height(iconSize)
|
||||
.scale(scale)
|
||||
) {
|
||||
@@ -1989,34 +1992,7 @@ fun ReplyImagePreview(
|
||||
try {
|
||||
// Пробуем сначала из blob
|
||||
if (attachment.blob.isNotEmpty()) {
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (attachment.blob
|
||||
.contains(
|
||||
","
|
||||
)
|
||||
) {
|
||||
attachment.blob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
attachment.blob
|
||||
}
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
decodedBytes.size
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val decoded = base64ToBitmap(attachment.blob)
|
||||
if (decoded != null) {
|
||||
fullImageBitmap = decoded
|
||||
return@withContext
|
||||
@@ -2033,31 +2009,7 @@ fun ReplyImagePreview(
|
||||
)
|
||||
|
||||
if (localBlob != null) {
|
||||
val decoded =
|
||||
try {
|
||||
val cleanBase64 =
|
||||
if (localBlob.contains(",")
|
||||
) {
|
||||
localBlob
|
||||
.substringAfter(
|
||||
","
|
||||
)
|
||||
} else {
|
||||
localBlob
|
||||
}
|
||||
val decodedBytes =
|
||||
Base64.decode(
|
||||
cleanBase64,
|
||||
Base64.DEFAULT
|
||||
)
|
||||
BitmapFactory.decodeByteArray(
|
||||
decodedBytes,
|
||||
0,
|
||||
decodedBytes.size
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val decoded = base64ToBitmap(localBlob)
|
||||
fullImageBitmap = decoded
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -968,19 +966,7 @@ private suspend fun loadBitmapForViewerImage(
|
||||
* Безопасное декодирование base64 в Bitmap
|
||||
*/
|
||||
private fun base64ToBitmapSafe(base64String: String): Bitmap? {
|
||||
return try {
|
||||
// Убираем возможные префиксы data:image/...
|
||||
val cleanBase64 = if (base64String.contains(",")) {
|
||||
base64String.substringAfter(",")
|
||||
} else {
|
||||
base64String
|
||||
}
|
||||
|
||||
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
return base64ToBitmap(base64String)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
@@ -34,11 +36,13 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -68,6 +72,7 @@ fun InAppCameraScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val view = LocalView.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Camera state
|
||||
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
|
||||
@@ -86,7 +91,25 @@ fun InAppCameraScreen(
|
||||
|
||||
// Enter animation + hide keyboard
|
||||
LaunchedEffect(Unit) {
|
||||
val localActivity = context as? Activity
|
||||
val localWindow = localActivity?.window
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
localWindow?.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN or
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
|
||||
)
|
||||
repeat(4) {
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
localWindow?.let { win ->
|
||||
WindowCompat.getInsetsController(win, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
view.findFocus()?.clearFocus()
|
||||
localActivity?.currentFocus?.clearFocus()
|
||||
delay(50)
|
||||
}
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
animationProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
|
||||
@@ -111,6 +134,9 @@ fun InAppCameraScreen(
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val activity = context as? Activity
|
||||
val window = activity?.window
|
||||
val originalSoftInputMode = remember(window) {
|
||||
window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||
}
|
||||
|
||||
val originalStatusBarColor = remember { window?.statusBarColor ?: android.graphics.Color.WHITE }
|
||||
val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE }
|
||||
@@ -138,6 +164,7 @@ fun InAppCameraScreen(
|
||||
DisposableEffect(window) {
|
||||
onDispose {
|
||||
if (window == null || insetsController == null) return@onDispose
|
||||
window.setSoftInputMode(originalSoftInputMode)
|
||||
window.statusBarColor = originalStatusBarColor
|
||||
insetsController.isAppearanceLightStatusBars = originalLightStatusBars
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -123,9 +124,21 @@ fun MediaPickerBottomSheet(
|
||||
|
||||
// Function to hide keyboard
|
||||
fun hideKeyboard() {
|
||||
focusManager.clearFocus()
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardView.findFocus()?.clearFocus()
|
||||
val activity = context as? Activity
|
||||
activity?.currentFocus?.clearFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(keyboardView.windowToken, 0)
|
||||
val servedToken =
|
||||
activity?.currentFocus?.windowToken
|
||||
?: keyboardView.findFocus()?.windowToken
|
||||
?: keyboardView.windowToken
|
||||
imm.hideSoftInputFromWindow(servedToken, 0)
|
||||
imm.hideSoftInputFromWindow(servedToken, InputMethodManager.HIDE_NOT_ALWAYS)
|
||||
activity?.window?.let { win ->
|
||||
WindowCompat.getInsetsController(win, keyboardView)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
// Media items from gallery
|
||||
@@ -228,6 +241,7 @@ fun MediaPickerBottomSheet(
|
||||
// 🎬 Анимация появления/закрытия
|
||||
var isClosing by remember { mutableStateOf(false) }
|
||||
var shouldShow by remember { mutableStateOf(false) }
|
||||
var closeAction by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
|
||||
// Scope для анимаций
|
||||
val animationScope = rememberCoroutineScope()
|
||||
@@ -245,10 +259,13 @@ fun MediaPickerBottomSheet(
|
||||
isClosing = false
|
||||
shouldShow = false
|
||||
isExpanded = false
|
||||
val action = closeAction
|
||||
closeAction = null
|
||||
// Сбрасываем высоту
|
||||
animationScope.launch {
|
||||
sheetHeightPx.snapTo(collapsedHeightPx)
|
||||
}
|
||||
action?.invoke()
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
@@ -293,8 +310,12 @@ fun MediaPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для анимированного закрытия
|
||||
val animatedClose: () -> Unit = {
|
||||
// Функция для анимированного закрытия.
|
||||
// afterClose важен для camera flow: сначала полностью закрываем sheet, потом открываем камеру.
|
||||
fun animatedClose(afterClose: (() -> Unit)? = null) {
|
||||
if (afterClose != null) {
|
||||
closeAction = afterClose
|
||||
}
|
||||
if (!isClosing) {
|
||||
isClosing = true
|
||||
}
|
||||
@@ -428,7 +449,7 @@ fun MediaPickerBottomSheet(
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран
|
||||
onDismissRequest = animatedClose,
|
||||
onDismissRequest = { animatedClose() },
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
@@ -525,7 +546,7 @@ fun MediaPickerBottomSheet(
|
||||
// Header with action buttons
|
||||
MediaPickerHeader(
|
||||
selectedCount = selectedItems.size,
|
||||
onDismiss = animatedClose,
|
||||
onDismiss = { animatedClose() },
|
||||
onSend = {
|
||||
val selected = mediaItems.filter { it.id in selectedItems }
|
||||
onMediaSelected(selected, pickerCaption.trim())
|
||||
@@ -540,16 +561,16 @@ fun MediaPickerBottomSheet(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onCameraClick = {
|
||||
hideKeyboard()
|
||||
animatedClose()
|
||||
onOpenCamera()
|
||||
animatedClose {
|
||||
hideKeyboard()
|
||||
onOpenCamera()
|
||||
}
|
||||
},
|
||||
onFileClick = {
|
||||
animatedClose()
|
||||
onOpenFilePicker()
|
||||
animatedClose { onOpenFilePicker() }
|
||||
},
|
||||
onAvatarClick = {
|
||||
animatedClose()
|
||||
onAvatarClick()
|
||||
animatedClose { onAvatarClick() }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -623,8 +644,10 @@ fun MediaPickerBottomSheet(
|
||||
selectedItems = selectedItems,
|
||||
onCameraClick = {
|
||||
hideKeyboard()
|
||||
animatedClose()
|
||||
onOpenCamera()
|
||||
animatedClose {
|
||||
hideKeyboard()
|
||||
onOpenCamera()
|
||||
}
|
||||
},
|
||||
onItemClick = { item, _ ->
|
||||
// Telegram-style selection:
|
||||
|
||||
@@ -85,7 +85,8 @@ fun MessageInputBar(
|
||||
myPublicKey: String = "",
|
||||
opponentPublicKey: String = "",
|
||||
myPrivateKey: String = "",
|
||||
inputFocusTrigger: Int = 0
|
||||
inputFocusTrigger: Int = 0,
|
||||
suppressKeyboard: Boolean = false
|
||||
) {
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val liveReplyMessages =
|
||||
@@ -115,7 +116,7 @@ fun MessageInputBar(
|
||||
|
||||
// Auto-focus when reply panel opens
|
||||
LaunchedEffect(hasReply, editTextView) {
|
||||
if (hasReply) {
|
||||
if (hasReply && !suppressKeyboard) {
|
||||
delay(50)
|
||||
editTextView?.let { editText ->
|
||||
if (!showEmojiPicker) {
|
||||
@@ -129,7 +130,7 @@ fun MessageInputBar(
|
||||
|
||||
// Return focus to input after closing the photo editor
|
||||
LaunchedEffect(inputFocusTrigger) {
|
||||
if (inputFocusTrigger > 0) {
|
||||
if (inputFocusTrigger > 0 && !suppressKeyboard) {
|
||||
delay(100)
|
||||
editTextView?.let { editText ->
|
||||
editText.requestFocus()
|
||||
@@ -139,6 +140,16 @@ fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Camera overlay opened: hard-stop any keyboard/focus restoration from input effects.
|
||||
LaunchedEffect(suppressKeyboard) {
|
||||
if (suppressKeyboard) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus(force = true)
|
||||
view.findFocus()?.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val imeInsets = WindowInsets.ime
|
||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
||||
@@ -205,6 +216,8 @@ fun MessageInputBar(
|
||||
}
|
||||
|
||||
fun toggleEmojiPicker() {
|
||||
if (suppressKeyboard) return
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastToggle = currentTime - lastToggleTime
|
||||
|
||||
|
||||
Reference in New Issue
Block a user