feat: Enhance background color animation on theme change in SwipeableDialogItem
This commit is contained in:
@@ -64,7 +64,6 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
private suspend fun getActiveServer(): String {
|
private suspend fun getActiveServer(): String {
|
||||||
transportServer?.let { return it }
|
transportServer?.let { return it }
|
||||||
|
|
||||||
requestTransportServer()
|
requestTransportServer()
|
||||||
repeat(40) { // 10s total
|
repeat(40) { // 10s total
|
||||||
val server = transportServer
|
val server = transportServer
|
||||||
@@ -73,7 +72,6 @@ object TransportManager {
|
|||||||
}
|
}
|
||||||
delay(250)
|
delay(250)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IOException("Transport server is not set")
|
throw IOException("Transport server is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,17 +109,16 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer()
|
val server = getActiveServer()
|
||||||
|
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||||
|
|
||||||
// Добавляем в список загрузок
|
// Добавляем в список загрузок
|
||||||
_uploading.value = _uploading.value + TransportState(id, 0)
|
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
withRetry {
|
withRetry {
|
||||||
// 🔥 КРИТИЧНО: Преобразуем строку в байты (как desktop делает new Blob([content]))
|
|
||||||
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
||||||
val totalSize = contentBytes.size.toLong()
|
val totalSize = contentBytes.size.toLong()
|
||||||
|
|
||||||
// 🔥 RequestBody с отслеживанием прогресса загрузки
|
|
||||||
val progressRequestBody = object : RequestBody() {
|
val progressRequestBody = object : RequestBody() {
|
||||||
override fun contentType() = "application/octet-stream".toMediaType()
|
override fun contentType() = "application/octet-stream".toMediaType()
|
||||||
override fun contentLength() = totalSize
|
override fun contentLength() = totalSize
|
||||||
@@ -129,7 +126,7 @@ object TransportManager {
|
|||||||
override fun writeTo(sink: okio.BufferedSink) {
|
override fun writeTo(sink: okio.BufferedSink) {
|
||||||
val source = okio.Buffer().write(contentBytes)
|
val source = okio.Buffer().write(contentBytes)
|
||||||
var uploaded = 0L
|
var uploaded = 0L
|
||||||
val bufferSize = 8 * 1024L // 8 KB chunks
|
val bufferSize = 8 * 1024L
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = source.read(sink.buffer, bufferSize)
|
val read = source.read(sink.buffer, bufferSize)
|
||||||
@@ -138,7 +135,6 @@ object TransportManager {
|
|||||||
uploaded += read
|
uploaded += read
|
||||||
sink.flush()
|
sink.flush()
|
||||||
|
|
||||||
// Обновляем прогресс
|
|
||||||
val progress = ((uploaded * 100) / totalSize).toInt()
|
val progress = ((uploaded * 100) / totalSize).toInt()
|
||||||
_uploading.value = _uploading.value.map {
|
_uploading.value = _uploading.value.map {
|
||||||
if (it.id == id) it.copy(progress = progress) else it
|
if (it.id == id) it.copy(progress = progress) else it
|
||||||
@@ -175,8 +171,6 @@ object TransportManager {
|
|||||||
|
|
||||||
val responseBody = response.body?.string()
|
val responseBody = response.body?.string()
|
||||||
?: throw IOException("Empty response")
|
?: throw IOException("Empty response")
|
||||||
|
|
||||||
// Parse JSON response to get tag
|
|
||||||
val tag = org.json.JSONObject(responseBody).getString("t")
|
val tag = org.json.JSONObject(responseBody).getString("t")
|
||||||
|
|
||||||
// Обновляем прогресс до 100%
|
// Обновляем прогресс до 100%
|
||||||
@@ -184,8 +178,15 @@ object TransportManager {
|
|||||||
if (it.id == id) it.copy(progress = 100) else it
|
if (it.id == id) it.copy(progress = 100) else it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||||
|
|
||||||
tag
|
tag
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
|
)
|
||||||
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
// Удаляем из списка загрузок
|
// Удаляем из списка загрузок
|
||||||
_uploading.value = _uploading.value.filter { it.id != id }
|
_uploading.value = _uploading.value.filter { it.id != id }
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import com.rosetta.messenger.data.ForwardManager
|
|||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.components.*
|
import com.rosetta.messenger.ui.chats.components.*
|
||||||
@@ -355,11 +356,18 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
ProtocolManager.enableUILogs(true)
|
||||||
|
onDispose { ProtocolManager.enableUILogs(false) }
|
||||||
|
}
|
||||||
|
|
||||||
// Состояние выпадающего меню
|
// Состояние выпадающего меню
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
var showDebugLogs by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||||
|
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||||
val isBlocked by
|
val isBlocked by
|
||||||
database.blacklistDao()
|
database.blacklistDao()
|
||||||
@@ -1178,6 +1186,10 @@ fun ChatDetailScreen(
|
|||||||
isSystemAccount,
|
isSystemAccount,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
|
onLogsClick = {
|
||||||
|
showMenu = false
|
||||||
|
showDebugLogs = true
|
||||||
|
},
|
||||||
onBlockClick = {
|
onBlockClick = {
|
||||||
showMenu =
|
showMenu =
|
||||||
false
|
false
|
||||||
@@ -2480,4 +2492,13 @@ fun ChatDetailScreen(
|
|||||||
recipientName = user.title
|
recipientName = user.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDebugLogs) {
|
||||||
|
DebugLogsBottomSheet(
|
||||||
|
logs = debugLogs,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onDismiss = { showDebugLogs = false },
|
||||||
|
onClearLogs = { ProtocolManager.clearLogs() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,6 +446,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
updateCacheFromCurrentMessages()
|
updateCacheFromCurrentMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shortPhotoId(value: String, limit: Int = 8): String {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isEmpty()) return "unknown"
|
||||||
|
return if (trimmed.length <= limit) trimmed else trimmed.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logPhotoPipeline(messageId: String, message: String) {
|
||||||
|
ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) {
|
||||||
|
val reason = throwable.message ?: "unknown"
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */
|
/** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */
|
||||||
private fun updateMessageAttachments(messageId: String, localUri: String?) {
|
private fun updateMessageAttachments(messageId: String, localUri: String?) {
|
||||||
_messages.value =
|
_messages.value =
|
||||||
@@ -2240,6 +2257,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
|
|
||||||
if (recipient == null || sender == null || privateKey == null) {
|
if (recipient == null || sender == null || privateKey == null) {
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2248,10 +2268,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val text = caption.trim()
|
val text = caption.trim()
|
||||||
val attachmentId = "img_$timestamp"
|
val attachmentId = "img_$timestamp"
|
||||||
|
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${shortPhotoId(attachmentId, 12)}"
|
||||||
|
)
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла)
|
// 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла)
|
||||||
// Это предотвращает "расширение" пузырька при первом показе
|
// Это предотвращает "расширение" пузырька при первом показе
|
||||||
val (imageWidth, imageHeight) =
|
val (imageWidth, imageHeight) =
|
||||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
logPhotoPipeline(messageId, "dimensions: ${imageWidth}x$imageHeight")
|
||||||
|
|
||||||
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ
|
// 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ
|
||||||
// Используем URI напрямую для отображения (без конвертации в base64)
|
// Используем URI напрямую для отображения (без конвертации в base64)
|
||||||
@@ -2282,11 +2308,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
addMessageSafely(optimisticMessage)
|
addMessageSafely(optimisticMessage)
|
||||||
_inputText.value = ""
|
_inputText.value = ""
|
||||||
|
logPhotoPipeline(messageId, "optimistic UI added")
|
||||||
|
|
||||||
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
||||||
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
||||||
backgroundUploadScope.launch {
|
backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
|
logPhotoPipeline(messageId, "persist optimistic message in DB")
|
||||||
val optimisticAttachmentsJson =
|
val optimisticAttachmentsJson =
|
||||||
JSONArray()
|
JSONArray()
|
||||||
.apply {
|
.apply {
|
||||||
@@ -2323,16 +2351,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
logPhotoPipeline(messageId, "optimistic dialog updated")
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
val convertStartedAt = System.currentTimeMillis()
|
||||||
val (width, height) =
|
val (width, height) =
|
||||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
|
||||||
val imageBase64 =
|
val imageBase64 =
|
||||||
com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri)
|
||||||
if (imageBase64 == null) {
|
if (imageBase64 == null) {
|
||||||
|
logPhotoPipeline(messageId, "base64 conversion returned null")
|
||||||
if (!isCleared) {
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
@@ -2340,14 +2372,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
|
||||||
|
)
|
||||||
|
|
||||||
val blurhash =
|
val blurhash =
|
||||||
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
|
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri)
|
||||||
|
logPhotoPipeline(messageId, "blurhash ready: len=${blurhash.length}")
|
||||||
|
|
||||||
if (!isCleared) {
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
|
updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height)
|
||||||
}
|
}
|
||||||
|
logPhotoPipeline(messageId, "optimistic payload updated in UI")
|
||||||
}
|
}
|
||||||
|
|
||||||
sendImageMessageInternal(
|
sendImageMessageInternal(
|
||||||
@@ -2362,7 +2400,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
sender = sender,
|
sender = sender,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
} catch (_: Exception) {
|
logPhotoPipeline(messageId, "pipeline completed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logPhotoPipelineError(messageId, "prepare+convert", e)
|
||||||
if (!isCleared) {
|
if (!isCleared) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
@@ -2415,27 +2455,53 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
|
val pipelineStartedAt = System.currentTimeMillis()
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}"
|
||||||
|
)
|
||||||
|
|
||||||
// Шифрование текста
|
// Шифрование текста
|
||||||
|
val encryptStartedAt = System.currentTimeMillis()
|
||||||
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
|
val encryptResult = MessageCrypto.encryptForSending(caption, recipient)
|
||||||
val encryptedContent = encryptResult.ciphertext
|
val encryptedContent = encryptResult.ciphertext
|
||||||
val encryptedKey = encryptResult.encryptedKey
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
|
||||||
|
)
|
||||||
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
// 🚀 Шифруем изображение с ChaCha ключом для Transport Server
|
||||||
|
val blobEncryptStartedAt = System.currentTimeMillis()
|
||||||
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
val encryptedImageBlob = MessageCrypto.encryptReplyBlob(imageBase64, plainKeyAndNonce)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
|
||||||
|
)
|
||||||
|
|
||||||
val attachmentId = "img_$timestamp"
|
val attachmentId = "img_$timestamp"
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"attachment prepared: id=${shortPhotoId(attachmentId, 12)}, size=${width}x$height"
|
||||||
|
)
|
||||||
|
|
||||||
// 📤 Загружаем на Transport Server
|
// 📤 Загружаем на Transport Server
|
||||||
val isSavedMessages = (sender == recipient)
|
val isSavedMessages = (sender == recipient)
|
||||||
var uploadTag = ""
|
var uploadTag = ""
|
||||||
|
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
|
logPhotoPipeline(messageId, "upload start: attachment=${shortPhotoId(attachmentId, 12)}")
|
||||||
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"upload done: tag=${shortPhotoId(uploadTag, 12)}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logPhotoPipeline(messageId, "saved-messages mode: upload skipped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview содержит tag::blurhash
|
// Preview содержит tag::blurhash
|
||||||
@@ -2467,16 +2533,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Отправляем пакет
|
// Отправляем пакет
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
|
logPhotoPipeline(messageId, "packet sent to protocol")
|
||||||
|
} else {
|
||||||
|
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💾 Сохраняем изображение в файл локально
|
// 💾 Сохраняем изображение в файл локально
|
||||||
AttachmentFileManager.saveAttachment(
|
val savedLocally =
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
context = context,
|
context = context,
|
||||||
blob = imageBase64,
|
blob = imageBase64,
|
||||||
attachmentId = attachmentId,
|
attachmentId = attachmentId,
|
||||||
publicKey = sender,
|
publicKey = sender,
|
||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
|
logPhotoPipeline(messageId, "local file cache saved=$savedLocally")
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val attachmentsJson =
|
val attachmentsJson =
|
||||||
@@ -2504,19 +2575,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
||||||
}
|
}
|
||||||
|
logPhotoPipeline(messageId, "db status+attachments updated")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
// Также очищаем localUri в UI
|
// Также очищаем localUri в UI
|
||||||
updateMessageAttachments(messageId, null)
|
updateMessageAttachments(messageId, null)
|
||||||
}
|
}
|
||||||
|
logPhotoPipeline(messageId, "ui status switched to SENT")
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
logPhotoPipelineError(messageId, "internal-send", e)
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2687,7 +2765,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
@@ -2719,10 +2797,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
|
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
|
||||||
|
)
|
||||||
|
|
||||||
backgroundUploadScope.launch {
|
backgroundUploadScope.launch {
|
||||||
val preparedImages =
|
val preparedImages =
|
||||||
imageUris.mapNotNull { uri ->
|
imageUris.mapIndexedNotNull { index, uri ->
|
||||||
val (width, height) =
|
val (width, height) =
|
||||||
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
|
com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
|
||||||
context,
|
context,
|
||||||
@@ -2733,12 +2815,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
context,
|
context,
|
||||||
uri
|
uri
|
||||||
)
|
)
|
||||||
?: return@mapNotNull null
|
?: run {
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
|
||||||
|
)
|
||||||
|
return@mapIndexedNotNull null
|
||||||
|
}
|
||||||
val blurhash =
|
val blurhash =
|
||||||
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
|
com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
|
||||||
context,
|
context,
|
||||||
uri
|
uri
|
||||||
)
|
)
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
|
||||||
|
)
|
||||||
ImageData(
|
ImageData(
|
||||||
base64 = imageBase64,
|
base64 = imageBase64,
|
||||||
blurhash = blurhash,
|
blurhash = blurhash,
|
||||||
@@ -2747,7 +2837,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preparedImages.isEmpty()) return@launch
|
if (preparedImages.isEmpty()) {
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
|
||||||
|
)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
sendImageGroup(preparedImages, caption)
|
sendImageGroup(preparedImages, caption)
|
||||||
@@ -2786,6 +2884,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
val text = caption.trim()
|
val text = caption.trim()
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"group start: count=${images.size}, captionLen=${text.length}"
|
||||||
|
)
|
||||||
|
|
||||||
// Создаём attachments для всех изображений
|
// Создаём attachments для всех изображений
|
||||||
val attachmentsList =
|
val attachmentsList =
|
||||||
@@ -2816,12 +2918,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
backgroundUploadScope.launch {
|
backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
|
val groupStartedAt = System.currentTimeMillis()
|
||||||
// Шифрование текста
|
// Шифрование текста
|
||||||
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
val encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||||
val encryptedContent = encryptResult.ciphertext
|
val encryptedContent = encryptResult.ciphertext
|
||||||
val encryptedKey = encryptResult.encryptedKey
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
val plainKeyAndNonce = encryptResult.plainKeyAndNonce
|
||||||
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
val aesChachaKey = encryptAesChachaKey(plainKeyAndNonce, privateKey)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}"
|
||||||
|
)
|
||||||
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
@@ -2831,6 +2938,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
for ((index, imageData) in images.withIndex()) {
|
for ((index, imageData) in images.withIndex()) {
|
||||||
val attachmentId = "img_${timestamp}_$index"
|
val attachmentId = "img_${timestamp}_$index"
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"group item#$index start: id=${shortPhotoId(attachmentId, 12)}, size=${imageData.width}x${imageData.height}"
|
||||||
|
)
|
||||||
|
|
||||||
// Шифруем изображение с ChaCha ключом
|
// Шифруем изображение с ChaCha ключом
|
||||||
val encryptedImageBlob =
|
val encryptedImageBlob =
|
||||||
@@ -2844,6 +2955,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
} else {
|
} else {
|
||||||
imageData.blurhash
|
imageData.blurhash
|
||||||
}
|
}
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}"
|
||||||
|
)
|
||||||
|
|
||||||
// Сохраняем в файл локально
|
// Сохраняем в файл локально
|
||||||
AttachmentFileManager.saveAttachment(
|
AttachmentFileManager.saveAttachment(
|
||||||
@@ -2897,6 +3012,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val isSavedMessages = (sender == recipient)
|
val isSavedMessages = (sender == recipient)
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
|
logPhotoPipeline(messageId, "group packet sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
@@ -2925,8 +3041,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
"group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
logPhotoPipelineError(messageId, "group-send", e)
|
||||||
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2544,10 +2544,15 @@ fun SwipeableDialogItem(
|
|||||||
} else {
|
} else {
|
||||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
}
|
}
|
||||||
|
var previousTheme by remember { mutableStateOf(isDarkTheme) }
|
||||||
|
val themeJustChanged = previousTheme != isDarkTheme
|
||||||
|
SideEffect { previousTheme = isDarkTheme }
|
||||||
val backgroundColor by
|
val backgroundColor by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
targetValue = targetBackgroundColor,
|
targetValue = targetBackgroundColor,
|
||||||
animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing),
|
animationSpec =
|
||||||
|
if (themeJustChanged) snap()
|
||||||
|
else tween(durationMillis = 260, easing = FastOutSlowInEasing),
|
||||||
label = "pinnedBackground"
|
label = "pinnedBackground"
|
||||||
)
|
)
|
||||||
var offsetX by remember { mutableStateOf(0f) }
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
|
|||||||
@@ -1863,6 +1863,7 @@ fun KebabMenu(
|
|||||||
isSavedMessages: Boolean,
|
isSavedMessages: Boolean,
|
||||||
isSystemAccount: Boolean = false,
|
isSystemAccount: Boolean = false,
|
||||||
isBlocked: Boolean,
|
isBlocked: Boolean,
|
||||||
|
onLogsClick: () -> Unit,
|
||||||
onBlockClick: () -> Unit,
|
onBlockClick: () -> Unit,
|
||||||
onUnblockClick: () -> Unit,
|
onUnblockClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit
|
||||||
@@ -1891,6 +1892,14 @@ fun KebabMenu(
|
|||||||
dismissOnClickOutside = true
|
dismissOnClickOutside = true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
KebabMenuItem(
|
||||||
|
icon = TelegramIcons.Info,
|
||||||
|
text = "Debug Logs",
|
||||||
|
onClick = onLogsClick,
|
||||||
|
tintColor = iconColor,
|
||||||
|
textColor = textColor
|
||||||
|
)
|
||||||
|
|
||||||
if (!isSavedMessages && !isSystemAccount) {
|
if (!isSavedMessages && !isSystemAccount) {
|
||||||
KebabMenuItem(
|
KebabMenuItem(
|
||||||
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
|
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package com.rosetta.messenger.utils
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.vanniktech.blurhash.BlurHash
|
import com.vanniktech.blurhash.BlurHash
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -28,6 +31,10 @@ object MediaUtils {
|
|||||||
// Android ограничение: файл + base64 + шифрование = ~3x памяти
|
// Android ограничение: файл + base64 + шифрование = ~3x памяти
|
||||||
// 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств
|
// 20 МБ файл = ~60 МБ RAM, безопасно для большинства устройств
|
||||||
const val MAX_FILE_SIZE_MB = 20
|
const val MAX_FILE_SIZE_MB = 20
|
||||||
|
|
||||||
|
private fun logImage(message: String) {
|
||||||
|
ProtocolManager.addLog("🧪 IMG-UTIL | $message")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Конвертировать изображение из Uri в Base64 PNG
|
* Конвертировать изображение из Uri в Base64 PNG
|
||||||
@@ -35,8 +42,12 @@ object MediaUtils {
|
|||||||
*/
|
*/
|
||||||
suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
|
suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val uriInfo = "${uri.scheme ?: "unknown"}:${uri.lastPathSegment ?: "unknown"}"
|
||||||
|
logImage("encode start: $uriInfo")
|
||||||
|
|
||||||
// Читаем EXIF ориентацию
|
// Читаем EXIF ориентацию
|
||||||
val orientation = getExifOrientation(context, uri)
|
val orientation = getExifOrientation(context, uri)
|
||||||
|
logImage("orientation=$orientation")
|
||||||
|
|
||||||
val boundsOptions =
|
val boundsOptions =
|
||||||
BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
@@ -45,47 +56,117 @@ object MediaUtils {
|
|||||||
} ?: return@withContext null
|
} ?: return@withContext null
|
||||||
|
|
||||||
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
|
if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
|
||||||
|
logImage("bounds decode failed, trying direct decode fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceWidth = boundsOptions.outWidth.coerceAtLeast(1)
|
||||||
|
val sourceHeight = boundsOptions.outHeight.coerceAtLeast(1)
|
||||||
|
val initialSample =
|
||||||
|
calculateInSampleSize(
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
MAX_IMAGE_SIZE * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var bitmap: Bitmap? = null
|
||||||
|
var sample = initialSample.coerceAtLeast(1)
|
||||||
|
|
||||||
|
repeat(2) { attempt ->
|
||||||
|
if (bitmap != null) return@repeat
|
||||||
|
val decodeOptions =
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sample
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap =
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
BitmapFactory.decodeStream(inputStream, null, decodeOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||||
|
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, decodeOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
logImage("decode attempt ${attempt + 1} failed (sample=$sample)")
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
bitmap = decodeWithImageDecoder(context, uri)
|
||||||
|
if (bitmap != null) {
|
||||||
|
logImage("decoded via ImageDecoder fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
logImage("decode failed after all fallbacks")
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
val decodeOptions =
|
val decodedBitmap = bitmap
|
||||||
BitmapFactory.Options().apply {
|
?: run {
|
||||||
inSampleSize =
|
logImage("decode failed: bitmap is null after fallbacks")
|
||||||
calculateInSampleSize(
|
return@withContext null
|
||||||
boundsOptions.outWidth,
|
|
||||||
boundsOptions.outHeight,
|
|
||||||
MAX_IMAGE_SIZE * 2
|
|
||||||
)
|
|
||||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
|
||||||
}
|
}
|
||||||
var bitmap =
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
||||||
BitmapFactory.decodeStream(inputStream, null, decodeOptions)
|
|
||||||
} ?: return@withContext null
|
|
||||||
|
|
||||||
// Применяем EXIF ориентацию (поворот/отражение)
|
// Применяем EXIF ориентацию (поворот/отражение)
|
||||||
bitmap = applyExifOrientation(bitmap, orientation)
|
val orientedBitmap = applyExifOrientation(decodedBitmap, orientation)
|
||||||
|
|
||||||
// Масштабируем если слишком большое
|
// Масштабируем если слишком большое
|
||||||
val scaledBitmap = scaleDownBitmap(bitmap, MAX_IMAGE_SIZE)
|
val scaledBitmap = scaleDownBitmap(orientedBitmap, MAX_IMAGE_SIZE)
|
||||||
if (scaledBitmap != bitmap) {
|
if (scaledBitmap != orientedBitmap) {
|
||||||
bitmap.recycle()
|
orientedBitmap.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Конвертируем в PNG Base64
|
// Конвертируем в PNG Base64
|
||||||
val outputStream = ByteArrayOutputStream()
|
val outputStream = ByteArrayOutputStream()
|
||||||
scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
|
val compressed = scaledBitmap.compress(Bitmap.CompressFormat.PNG, IMAGE_QUALITY, outputStream)
|
||||||
|
if (!compressed) {
|
||||||
|
logImage("bitmap compress failed")
|
||||||
|
scaledBitmap.recycle()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
val bytes = outputStream.toByteArray()
|
val bytes = outputStream.toByteArray()
|
||||||
|
|
||||||
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
|
||||||
scaledBitmap.recycle()
|
scaledBitmap.recycle()
|
||||||
|
logImage("encode success: outBytes=${bytes.size}")
|
||||||
|
|
||||||
base64
|
base64
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
logImage("encode exception: ${e.javaClass.simpleName}: ${e.message ?: "unknown"}")
|
||||||
null
|
null
|
||||||
} catch (e: OutOfMemoryError) {
|
} catch (e: OutOfMemoryError) {
|
||||||
|
logImage("encode OOM: ${e.message ?: "unknown"}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeWithImageDecoder(context: Context, uri: Uri): Bitmap? {
|
||||||
|
return try {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||||
|
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||||
|
ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
|
||||||
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
|
val size = info.size
|
||||||
|
val maxDimension = maxOf(size.width, size.height)
|
||||||
|
if (maxDimension > MAX_IMAGE_SIZE * 2) {
|
||||||
|
val ratio = (MAX_IMAGE_SIZE * 2).toFloat() / maxDimension.toFloat()
|
||||||
|
val targetW = (size.width * ratio).toInt().coerceAtLeast(1)
|
||||||
|
val targetH = (size.height * ratio).toInt().coerceAtLeast(1)
|
||||||
|
decoder.setTargetSize(targetW, targetH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user